From c185e64b772a22e603dddee55e22bb0c1ecd8be7 Mon Sep 17 00:00:00 2001 From: scotttong Date: Sat, 1 Aug 2026 02:26:47 -0700 Subject: [PATCH] feat(ui): chat-style task view behind an experimental flag (#10606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators spend most of their time on the issue detail page. They talk to the assigned agent there through comments. > - The current page reads as a ticket form. The thread sits below properties, the composer sits mid-page, and live agent activity renders as dense transcript logs. > - Talking to an agent is a conversation. A chat-first layout matches that mental model better than a ticket form. > - A layout change this large must not disrupt current users. It needs a safe opt-in path and full parity with the existing thread features. > - This pull request adds a chat-style task view behind a new "Chat-Style Tasks" experiment toggle. The flag is off by default and the existing page is unchanged when it is off. > - The benefit is a focused, readable conversation with the agent: live tool activity folds into compact summaries, the composer stays at the bottom, and properties, plan, and artifacts move into header tabs. ## Linked Issues or Issue Description Refs #49 (chat with agents is a much-wanted feature). Related PRs found in the dedup search: - #4489 — an earlier, closed attempt to promote the conversation to the primary surface on issue detail. This PR is a fresh, flag-gated take on the same goal. - #8837 — an open PR that proposes a two-column task layout. It restructures the same page but keeps the ticket paradigm; this PR is orthogonal because it is opt-in and chat-first. **Subsystem affected** UI (issue detail page). **Problem or motivation** The issue detail page presents agent conversations as a ticket: properties first, thread below, composer in the middle of the page, and raw transcript noise during live runs. Users who mainly converse with their agents must scroll past chrome to follow the conversation, and live activity is hard to read. **Proposed solution** An opt-in chat-style view of the issue detail page, gated by a new "Chat-Style Tasks" experiment toggle in Settings → Experimental. With the flag on, the thread fills the center pane, the composer docks to the bottom of the viewport, Properties / Plan / Artifacts become header tabs, live turns show a status pill with the current tool action and elapsed time, and settled turns collapse to a "Worked · N tools" summary that expands into per-tool rows. With the flag off, nothing changes. **Alternatives considered** Restyling the existing layout in place (rejected: too disruptive without an opt-out), and a separate chat page beside the issue page (rejected: splits the task's single source of truth). A per-request lab page (`/task-chat-lab`, dev-only) was kept for design iteration instead. **Roadmap alignment** ROADMAP.md "CEO Chat" wants lighter conversations that still resolve to real work objects. This PR keeps the core task-and-comments model — it only changes presentation, opt-in — so it does not duplicate that planned work. ## What Changed - New `enableTaskChatRedesign` instance setting, exposed as a "Chat-Style Tasks" experiment card in Settings → Experimental (shared feature catalog, validators, server instance-settings service, and UI settings page). - New `ui/src/components/task-chat/` component family: chat thread with turn grouping, agent reply bubbles, live status pill, collapsible turn summaries with per-tool rows, plan tab with a sticky CTA action bar, inline interaction cards, per-request mode chips, and a bottom-docked composer. - A shared tool taxonomy (`tool-taxonomy.ts`) maps tool names to verbs and icons; the status pill, tool rows, and the classic transcript view all use it. - A transcript adapter converts stored run logs into chat turns; it dedupes tool-call updates by `toolUseId` so tool counts match the expanded rows, and it keeps a tool row's first real name when later generic updates arrive. - Composer: posts on Cmd/Ctrl+Enter, supports image paste with object-URL thumbnail previews (revoked on clear/unmount), and uploads through the issue attachments route. - `IssueDetail.tsx`: with the flag on, pane tabs move to the header bar, the header is not sticky, and the chat fills the center; with the flag off, the previous layout renders unchanged. - Motion tokens for the new animations live in `ui/src/index.css` with a `motion-tokens.ts` catalog and a test that keeps the two in sync (the catalog now also covers the shared enter/exit/swap tokens that the decision/quicklook block declares). - A dev-only `/task-chat-lab` page with fixtures and a tweak panel for motion tuning. ## Verification - `pnpm typecheck` — clean across the workspace. - `pnpm check:token-gates` — 3/3 CLEAN. - `cd ui && pnpm vitest run` — 3,344 of 3,345 tests pass locally. The one failure is `IssueProperties.test.tsx` monitor-row time formatting, which is timezone-sensitive: it also fails on unmodified `origin/master` in a non-UTC timezone and passes with `TZ=UTC`. It is not related to this change. - `cd server && pnpm vitest run src/__tests__/instance-settings-service.test.ts` — 21/21 pass (covers the new setting). - Manual: start the dev server, open Settings → Experimental, enable "Chat-Style Tasks", and open any issue. The thread fills the page, the composer docks to the bottom, and Properties / Plan / Artifacts appear as header tabs. Assign an agent and comment to watch a live run: the status pill shows the current tool action with elapsed time, and the finished turn folds into a "Worked · N tools" summary. Disable the toggle and confirm the classic page is unchanged. - Visual snapshot baselines are intentionally not updated: per `doc/design/DECISION-SHEET.md`, "Per-change snapshot verification demoted to dormant (Jul 13 2026)". ## Risks - The flag-off path goes through the same `IssueDetail.tsx` file, so a regression there would affect current users. Mitigation: the classic markup renders through the same components as before behind explicit flag conditionals, and the full UI suite passes. - The transcript adapter interprets stored run-log formats, including legacy entries without `toolUseId`. Malformed logs degrade to generic tool rows rather than crashing. - The new view changes no server behavior other than one additive instance setting; it is additive and default-off. Overall risk with the flag off is low. ## Model Used - Claude (Anthropic), model id `claude-fable-5` (Claude Fable 5), extended thinking enabled, agentic tool use (file editing, shell, test execution) via Claude Code / Claude Agent SDK. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 --- DESIGN.md | 26 + .../adapter-utils/src/acpx-engine/execute.ts | 29 +- packages/shared/src/feature-catalog.ts | 8 + packages/shared/src/types/instance.ts | 1 + packages/shared/src/validators/instance.ts | 1 + scripts/check-task-chat-motion.mjs | 106 ++++ .../instance-settings-service.test.ts | 1 + server/src/services/instance-settings.ts | 2 + ui/src/App.tsx | 10 + ui/src/components/IssueChatThread.tsx | 15 + .../components/IssueThreadInteractionCard.tsx | 13 +- ui/src/components/PropertiesPanel.test.tsx | 147 ++++++ ui/src/components/PropertiesPanel.tsx | 404 ++++++++++++++- .../components/TaskChatRedesignGate.test.tsx | 95 ++++ ui/src/components/TaskChatRedesignGate.tsx | 18 + ui/src/components/TaskChatThread.tsx | 403 +++++++++++++++ .../IssuePlanConfirmationActionBar.tsx | 204 ++++++++ .../issue-properties/IssueProperties.tsx | 126 ++++- .../IssuePropertiesArtifactsTab.tsx | 57 +++ .../IssuePropertiesPlansTab.tsx | 67 +++ .../components/task-chat/TaskChatBubble.tsx | 80 +++ .../task-chat/TaskChatComposer.test.tsx | 258 ++++++++++ .../components/task-chat/TaskChatComposer.tsx | 475 ++++++++++++++++++ .../TaskChatInteractionCard.test.tsx | 162 ++++++ .../task-chat/TaskChatInteractionCard.tsx | 44 ++ .../components/task-chat/TaskChatMarker.tsx | 30 ++ .../components/task-chat/TaskChatPlanView.tsx | 55 ++ .../task-chat/TaskChatStatusPill.tsx | 137 +++++ .../task-chat/TaskChatThinkingBlock.tsx | 50 ++ .../task-chat/TaskChatThreadView.tsx | 116 +++++ .../components/task-chat/TaskChatToolCard.tsx | 106 ++++ .../task-chat/TaskChatTurn.test.tsx | 109 ++++ ui/src/components/task-chat/TaskChatTurn.tsx | 84 ++++ .../task-chat/TaskChatUsageReadout.tsx | 35 ++ .../task-chat/TaskMessageScroller.test.tsx | 215 ++++++++ .../task-chat/TaskMessageScroller.tsx | 171 +++++++ .../components/task-chat/TweakPanel.test.tsx | 56 +++ ui/src/components/task-chat/TweakPanel.tsx | 241 +++++++++ .../task-chat/motion-tokens.test.ts | 45 ++ ui/src/components/task-chat/motion-tokens.ts | 90 ++++ .../components/task-chat/task-chat-adapter.ts | 81 +++ .../task-chat/task-chat-fixtures.ts | 185 +++++++ .../components/task-chat/task-chat-model.ts | 228 +++++++++ .../task-chat/task-chat-render.test.tsx | 52 ++ .../components/task-chat/task-chat-states.ts | 165 ++++++ .../task-chat/tool-taxonomy.test.ts | 117 +++++ ui/src/components/task-chat/tool-taxonomy.ts | 100 ++++ .../task-chat/transcript-adapter.test.ts | 210 ++++++++ .../task-chat/transcript-adapter.ts | 389 ++++++++++++++ .../transcript/RunTranscriptView.tsx | 14 +- ui/src/hooks/useIssuePlanDocument.ts | 27 + ui/src/hooks/useTaskChatRedesignEnabled.ts | 45 ++ ui/src/index.css | 187 +++++++ ui/src/lib/issue-chat-messages.ts | 2 +- ui/src/lib/issue-timeline-events.test.ts | 123 ++++- ui/src/lib/issue-timeline-events.ts | 62 ++- .../InstanceExperimentalSettings.test.tsx | 49 ++ ui/src/pages/InstanceExperimentalSettings.tsx | 13 + ui/src/pages/IssueDetail.tsx | 401 +++++++++------ ui/src/pages/TaskChatLab.tsx | 174 +++++++ 60 files changed, 6744 insertions(+), 172 deletions(-) create mode 100644 scripts/check-task-chat-motion.mjs create mode 100644 ui/src/components/PropertiesPanel.test.tsx create mode 100644 ui/src/components/TaskChatRedesignGate.test.tsx create mode 100644 ui/src/components/TaskChatRedesignGate.tsx create mode 100644 ui/src/components/TaskChatThread.tsx create mode 100644 ui/src/components/issue-properties/IssuePlanConfirmationActionBar.tsx create mode 100644 ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx create mode 100644 ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx create mode 100644 ui/src/components/task-chat/TaskChatBubble.tsx create mode 100644 ui/src/components/task-chat/TaskChatComposer.test.tsx create mode 100644 ui/src/components/task-chat/TaskChatComposer.tsx create mode 100644 ui/src/components/task-chat/TaskChatInteractionCard.test.tsx create mode 100644 ui/src/components/task-chat/TaskChatInteractionCard.tsx create mode 100644 ui/src/components/task-chat/TaskChatMarker.tsx create mode 100644 ui/src/components/task-chat/TaskChatPlanView.tsx create mode 100644 ui/src/components/task-chat/TaskChatStatusPill.tsx create mode 100644 ui/src/components/task-chat/TaskChatThinkingBlock.tsx create mode 100644 ui/src/components/task-chat/TaskChatThreadView.tsx create mode 100644 ui/src/components/task-chat/TaskChatToolCard.tsx create mode 100644 ui/src/components/task-chat/TaskChatTurn.test.tsx create mode 100644 ui/src/components/task-chat/TaskChatTurn.tsx create mode 100644 ui/src/components/task-chat/TaskChatUsageReadout.tsx create mode 100644 ui/src/components/task-chat/TaskMessageScroller.test.tsx create mode 100644 ui/src/components/task-chat/TaskMessageScroller.tsx create mode 100644 ui/src/components/task-chat/TweakPanel.test.tsx create mode 100644 ui/src/components/task-chat/TweakPanel.tsx create mode 100644 ui/src/components/task-chat/motion-tokens.test.ts create mode 100644 ui/src/components/task-chat/motion-tokens.ts create mode 100644 ui/src/components/task-chat/task-chat-adapter.ts create mode 100644 ui/src/components/task-chat/task-chat-fixtures.ts create mode 100644 ui/src/components/task-chat/task-chat-model.ts create mode 100644 ui/src/components/task-chat/task-chat-render.test.tsx create mode 100644 ui/src/components/task-chat/task-chat-states.ts create mode 100644 ui/src/components/task-chat/tool-taxonomy.test.ts create mode 100644 ui/src/components/task-chat/tool-taxonomy.ts create mode 100644 ui/src/components/task-chat/transcript-adapter.test.ts create mode 100644 ui/src/components/task-chat/transcript-adapter.ts create mode 100644 ui/src/hooks/useIssuePlanDocument.ts create mode 100644 ui/src/hooks/useTaskChatRedesignEnabled.ts create mode 100644 ui/src/pages/TaskChatLab.tsx diff --git a/DESIGN.md b/DESIGN.md index 6eaa1e12cf..128fc6fafb 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -56,3 +56,29 @@ No visual redesign, no new colors or typefaces, no layout restructuring, no new See `doc/design/PRIOR-ART.md` — a previous audit pass (PAP-280/283/284, on the `PAP-282-playground` branch, NOT on master) found that of ~220 hardcoded drift sites, only 6 were exact-value-mappable to existing tokens; expect the verbatim extraction to mint many new tokens that the human scale-collapse step later merges. It also drafted usage rules (radius tiers, CTA tiers, named type styles) that are good candidates for the post-audit scale decision. How-to guide for day-to-day UI changes: see `doc/design/CHANGING-THE-UI.md`. + +## Motion tokens (Task Chat Redesign) + +The redesigned task thread (flag `enableTaskChatRedesign`) is the first surface to +tokenize motion. Principles — reasoning only; values live in `ui/src/index.css`: + +- **One home, and it is `:root`, not `@theme inline`.** `@theme inline` bakes literals + at build time, so a value placed there cannot be moved at runtime. The dev tweak panel + tunes motion by writing CSS custom properties live, so every motion token must resolve + at runtime — hence `:root`. +- **Two tiers.** Primitives (`--motion-duration-*`, `--motion-ease-*`) express the app's + baseline motion feel; state/component-scoped tokens (`--motion--*`) reference the + primitives so the whole thread retunes from a few knobs. Scoped tokens exist so the + tweak panel can group controls by the state they affect. +- **Reuse the house curves.** New easing defaults point at the two curves already used + across the app rather than inventing a third feel. +- **No hardcoded timing in components.** Durations, easings, delays, and staggers used by + the redesigned thread must reference these tokens; a check script rejects raw `ms` / + `cubic-bezier` values outside `ui/src/index.css`. This discipline is what makes the + tweak panel structurally possible. +- **Values are placeholders.** The committed numbers are sensible starting points, tuned + live by a human and pasted back from the tweak panel's export — never treated as final + during the baseline build. +- **Reduced motion is honored at the token layer.** A `prefers-reduced-motion: reduce` + block collapses the duration/stagger tokens to zero, cascading to every scoped token, + in addition to each animation's own component-level guard. diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index f62972089a..29b584c391 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -2333,7 +2333,17 @@ async function emitAcpxLog(ctx: AdapterExecutionContext, payload: Record, +) { if (event.type === "text_delta") { await emitAcpxLog(ctx, { type: "acpx.text_delta", @@ -2346,9 +2356,21 @@ async function emitRuntimeEvent(ctx: AdapterExecutionContext, event: AcpRuntimeE if (event.type === "tool_call") { const eventRecord = event as Record; const toolInput = eventRecord.input; + let name = event.title ?? "acp_tool"; + const toolCallId = typeof event.toolCallId === "string" ? event.toolCallId : ""; + if (toolTitles && toolCallId) { + if (event.title && event.title !== GENERIC_ACP_TOOL_TITLE) { + // First real title is the call's identity; later retitles (ACP swaps + // in the invocation, e.g. "Terminal" → "ls -la") keep their own line + // but don't become the remembered name. + if (!toolTitles.has(toolCallId)) toolTitles.set(toolCallId, event.title); + } else { + name = toolTitles.get(toolCallId) ?? name; + } + } await emitAcpxLog(ctx, { type: "acpx.tool_call", - name: event.title ?? "acp_tool", + name, toolCallId: event.toolCallId, status: event.status, text: event.text, @@ -3294,13 +3316,14 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { cancelActiveTurn = async (reason: string) => { await turn.cancel({ reason }); }; + const toolTitles = new Map(); for await (const event of turn.events) { if (event.type === "text_delta") textParts.push(event.text); if (event.type === "status" && event.tag === "usage_update") { eventBreakdown = event.breakdown ?? eventBreakdown; eventCostUsd = usdCostAmount(event.cost) ?? eventCostUsd; } - await emitRuntimeEvent(ctx, event); + await emitRuntimeEvent(ctx, event, toolTitles); } const terminal = await turn.result; if (timeout) clearTimeout(timeout); diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index 21c93ebe57..5b0837e17e 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -96,6 +96,14 @@ export const INSTANCE_FEATURE_CATALOG: Recordms` duration or a `cubic-bezier(` + * literal. All motion must reference the --motion-* tokens in + * ui/src/index.css (the single motion source), so the dev tweak panel can + * retune them live. + * + * Documented allowlist: ui/src/components/task-chat/motion-tokens.ts is the + * easing-preset catalog the tweak panel offers — those cubic-bezier values + * are data for the tool, analogous to the token layer, not component motion. + * Test files are excluded. + * + * 2. Flag-off isolation seams are single conditionals (grep-provable): asserts + * the IssueDetail chat-tab seam and the IssueProperties tab-shell seam exist. + * + * Exit non-zero on any violation. + */ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, relative } from "node:path"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); + +const SCAN_DIRS = ["ui/src/components/task-chat"]; +const SCAN_FILES = [ + "ui/src/components/TaskChatThread.tsx", + "ui/src/components/PropertiesPanel.tsx", + "ui/src/components/TaskChatRedesignGate.tsx", + "ui/src/pages/TaskChatLab.tsx", + "ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx", + "ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx", +]; +const ALLOWLIST = new Set(["ui/src/components/task-chat/motion-tokens.ts"]); + +const MS_RE = /\d+ms\b/; +const CUBIC_RE = /cubic-bezier\(/; + +function isCheckable(path) { + if (!/\.(ts|tsx)$/.test(path)) return false; + if (/\.test\.(ts|tsx)$/.test(path)) return false; + return true; +} + +function walk(dir, out) { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) walk(full, out); + else out.push(full); + } +} + +const files = []; +for (const d of SCAN_DIRS) walk(join(repoRoot, d), files); +for (const f of SCAN_FILES) files.push(join(repoRoot, f)); + +const violations = []; +for (const file of files) { + const rel = relative(repoRoot, file).split("\\").join("/"); + if (!isCheckable(rel) || ALLOWLIST.has(rel)) continue; + const lines = readFileSync(file, "utf8").split("\n"); + lines.forEach((line, i) => { + if (MS_RE.test(line) || CUBIC_RE.test(line)) { + violations.push(`${rel}:${i + 1}: ${line.trim()}`); + } + }); +} + +// Seam assertions (clause B: single grep-provable conditional in each file). +const seams = [ + { + file: "ui/src/pages/IssueDetail.tsx", + needle: "? TaskChatThread : IssueChatThread", + label: "chat-tab seam", + }, + { + file: "ui/src/components/issue-properties/IssueProperties.tsx", + needle: "if (!taskChatRedesignEnabled) return propertiesBody;", + label: "properties-pane seam", + }, +]; +const missingSeams = []; +for (const seam of seams) { + const text = readFileSync(join(repoRoot, seam.file), "utf8"); + if (!text.includes(seam.needle)) missingSeams.push(`${seam.label} (${seam.file})`); +} + +let failed = false; +if (violations.length > 0) { + failed = true; + console.error("Hardcoded timing found in the task-chat redesign surface:"); + for (const v of violations) console.error(` ${v}`); + console.error("Use a --motion-* token from ui/src/index.css instead."); +} +if (missingSeams.length > 0) { + failed = true; + console.error("Missing flag-off isolation seam(s):"); + for (const s of missingSeams) console.error(` ${s}`); +} + +if (failed) process.exit(1); +console.log( + `check-task-chat-motion: OK (${files.filter((f) => isCheckable(relative(repoRoot, f).split("\\").join("/"))).length} files scanned, seams present).`, +); diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index f641eb6a5a..295f58720e 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -29,6 +29,7 @@ describe("instance settings service", () => { enableStreamlinedLeftNavigation: true, enableApps: false, enableConferenceRoomChat: false, + enableTaskChatRedesign: false, enableExternalObjects: false, enableSmokeLab: false, enablePipelines: false, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 2a94177c20..f7a017c5c9 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -214,6 +214,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enablePipelines: parsed.data.enablePipelines ?? false, enableCases: parsed.data.enableCases ?? false, enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false, + enableTaskChatRedesign: parsed.data.enableTaskChatRedesign ?? false, enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false, enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false, enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false, @@ -248,6 +249,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enablePipelines: false, enableCases: false, enableConferenceRoomChat: false, + enableTaskChatRedesign: false, enableTaskWatchdogs: false, enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index bf61c83db9..9a1163bafd 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -3,6 +3,8 @@ import { Button } from "@/components/ui/button"; import { useTranslation } from "@/i18n"; import { Layout } from "./components/Layout"; import { ConferenceRoomChatGate } from "./components/ConferenceRoomChatGate"; +import { TaskChatRedesignGate } from "./components/TaskChatRedesignGate"; +import { TaskChatLab } from "./pages/TaskChatLab"; import { PipelinesExperimentalGate } from "./components/PipelinesExperimentalGate"; import { CasesExperimentalGate } from "./components/CasesExperimentalGate"; import { StatusCardsExperimentalGate } from "./components/StatusCardsExperimentalGate"; @@ -269,6 +271,14 @@ function boardRoutes() { } /> } /> + {/* Task Chat Redesign dev harness — dev builds only, and additionally + gated by enableTaskChatRedesign (redirects to /dashboard when the + flag is off). */} + {import.meta.env.DEV ? ( + }> + } /> + + ) : null} } /> } /> } /> diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 104abe3402..0bcb0c6136 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -77,6 +77,7 @@ import { type IssueTimelineAssignee, type IssueTimelineEvent, type IssueTimelineWorkspace, + type IssueWorkModeChange, } from "../lib/issue-timeline-events"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -428,6 +429,13 @@ interface IssueChatThreadProps { feedbackTermsUrl?: string | null; linkedRuns?: IssueChatLinkedRun[]; timelineEvents?: IssueTimelineEvent[]; + /** + * Work-mode switch history from the activity feed. Only the redesigned + * TaskChatThread consumes this (flag: enableTaskChatRedesign) to tag each + * agent reply with the mode its request ran under; the legacy thread + * ignores it. + */ + workModeChanges?: IssueWorkModeChange[]; liveRuns?: LiveRunForIssue[]; activeRun?: ActiveRunForIssue | null; issueId?: string | null; @@ -492,6 +500,13 @@ interface IssueChatThreadProps { autoScrollToHashOnInitialLoad?: boolean; emptyMessage?: string; footer?: ReactNode; + /** + * Issue header content (title row, badges, plugin toolbars) rendered INSIDE + * the thread's scroll viewport so it scrolls away with the messages. Only the + * redesigned TaskChatThread consumes this (flag: enableTaskChatRedesign); + * the legacy thread ignores it — its header stays in the page flow. + */ + threadHeader?: ReactNode; variant?: "full" | "embedded"; enableLiveTranscriptPolling?: boolean; transcriptsByRunId?: ReadonlyMap; diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index 364a68a173..59f3a8e111 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -65,6 +65,8 @@ interface IssueThreadInteractionCardProps { onCancelInteraction?: ( interaction: AskUserQuestionsInteraction, ) => Promise | void; + /** Render confirmation CTAs with the primary action rightmost (task-chat grammar). */ + primaryActionOnRight?: boolean; onSubmitInteractionVerdicts?: ( interaction: RequestItemVerdictsInteraction, verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[], @@ -1842,6 +1844,7 @@ function RequestToolActionCard({ function RequestConfirmationCard({ interaction, isPlan = false, + primaryActionOnRight = false, onAcceptInteraction, onRejectInteraction, onUploadImage, @@ -1849,6 +1852,7 @@ function RequestConfirmationCard({ }: { interaction: RequestConfirmationInteraction; isPlan?: boolean; + primaryActionOnRight?: boolean; onAcceptInteraction?: ( interaction: RequestConfirmationInteraction, ) => Promise | void; @@ -1969,7 +1973,12 @@ function RequestConfirmationCard({ {interaction.status === "pending" ? (
-
+
+ if (!redesignEnabled) { + return ( +
- + + ); + } + + return ( + + ); +} + +/* ------------------------------------------------------------------------- * + * Task Chat Redesign (flag: enableTaskChatRedesign) — resizable/maximizable + * variant. Everything below renders only when the flag is ON. + * ------------------------------------------------------------------------- */ + +/** + * Portal target in the redesigned pane's header bar: hosted content (the + * Properties | Plan | Artifacts tab strip) renders here, left of the window + * controls. See IssueProperties' flag-ON shell. + */ +export const PROPERTIES_PANE_HEADER_SLOT_ID = "properties-pane-header-slot"; +/** + * Portal target pinned below the pane's scroll area: hosted content (the plan + * confirmation action bar) renders here so it stays visible while the pane + * body scrolls. + */ +export const PROPERTIES_PANE_FOOTER_SLOT_ID = "properties-pane-footer-slot"; + +const WIDTH_STORAGE_KEY = "taskChatRedesign.propertiesPaneWidth"; +const DEFAULT_PANE_WIDTH = 322; +const MIN_PANE_WIDTH = 260; +/** ~236px sidebar + ~420px minimum center column stay usable while resizing. */ +const RESERVED_LAYOUT_WIDTH = 656; +/** Content cap while maximized so text doesn't span the full viewport. */ +const MAXIMIZED_CONTENT_MAX_WIDTH = 840; +/** + * Defensive fallback (in milliseconds) for the restore glide in case + * `transitionend` never fires; slightly longer than the --motion-pane-glide + * token in index.css. + */ +const RESTORE_FALLBACK_DELAY = 400; + +function clampPaneWidth(width: number): number { + const max = + typeof window === "undefined" + ? Number.POSITIVE_INFINITY + : Math.max(MIN_PANE_WIDTH, window.innerWidth - RESERVED_LAYOUT_WIDTH); + return Math.min(Math.max(Math.round(width), MIN_PANE_WIDTH), max); +} + +function readStoredPaneWidth(): number { + if (typeof window === "undefined") return DEFAULT_PANE_WIDTH; + try { + const raw = window.localStorage.getItem(WIDTH_STORAGE_KEY); + const parsed = raw === null ? Number.NaN : Number(raw); + return Number.isFinite(parsed) ? parsed : DEFAULT_PANE_WIDTH; + } catch { + return DEFAULT_PANE_WIDTH; + } +} + +function persistPaneWidth(width: number) { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(WIDTH_STORAGE_KEY, String(width)); + } catch { + // Ignore storage failures. + } +} + +function clearStoredPaneWidth() { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(WIDTH_STORAGE_KEY); + } catch { + // Ignore storage failures. + } +} + +function prefersReducedMotion(): boolean { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +/** Fixed-position geometry while the panel is maximized (or gliding). */ +interface FixedPane { + top: number; + /** Animated by the .tc-pane-glide transition. */ + left: number; + /** Distance from the viewport's right edge to the panel's right edge. */ + rightInset: number; + /** Glide target: the layout row's left edge (flush with the sidebar). */ + parentLeft: number; +} + +interface ResizablePropertiesPanelProps { + panelContent: ReactNode; + panelVisible: boolean; + setPanelVisible: (visible: boolean) => void; +} + +function ResizablePropertiesPanel({ + panelContent, + panelVisible, + setPanelVisible, +}: ResizablePropertiesPanelProps) { + const [width, setWidth] = useState(() => clampPaneWidth(readStoredPaneWidth())); + const [dragging, setDragging] = useState(false); + const [maximized, setMaximized] = useState(false); + const [fixedPane, setFixedPane] = useState(null); + + const asideRef = useRef(null); + const widthRef = useRef(width); + widthRef.current = width; + const dragStateRef = useRef<{ pointerId: number; startX: number; startWidth: number } | null>( + null, + ); + const previousBodyUserSelectRef = useRef(""); + const restoreTimerRef = useRef(null); + + const clearRestoreTimer = useCallback(() => { + if (restoreTimerRef.current !== null) { + window.clearTimeout(restoreTimerRef.current); + restoreTimerRef.current = null; + } + }, []); + + const finishRestore = useCallback(() => { + clearRestoreTimer(); + setFixedPane(null); + }, [clearRestoreTimer]); + + // Hiding the panel keeps today's collapse-to-0 behavior; if it was + // maximized (or mid-glide), just unmaximize instantly first. + useEffect(() => { + if (!panelVisible) { + setMaximized(false); + finishRestore(); + } + }, [panelVisible, finishRestore]); + + useEffect( + () => () => { + if (restoreTimerRef.current !== null) window.clearTimeout(restoreTimerRef.current); + if (dragStateRef.current !== null) { + document.body.style.userSelect = previousBodyUserSelectRef.current; + } + }, + [], + ); + + const endDrag = useCallback((persist: boolean) => { + if (dragStateRef.current === null) return; + dragStateRef.current = null; + setDragging(false); + document.body.style.userSelect = previousBodyUserSelectRef.current; + if (persist) persistPaneWidth(widthRef.current); + }, []); + + const handleGripPointerDown = useCallback((event: React.PointerEvent) => { + // Primary button only (touch/pen report button 0 or -1 for down events). + if (event.pointerType === "mouse" && event.button !== 0) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + dragStateRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startWidth: widthRef.current, + }; + previousBodyUserSelectRef.current = document.body.style.userSelect; + document.body.style.userSelect = "none"; + setDragging(true); + }, []); + + const handleGripPointerMove = useCallback((event: React.PointerEvent) => { + const drag = dragStateRef.current; + if (drag === null || drag.pointerId !== event.pointerId) return; + // The grip sits on the panel's LEFT border: moving left widens the panel. + setWidth(clampPaneWidth(drag.startWidth + (drag.startX - event.clientX))); + }, []); + + const handleGripPointerUp = useCallback( + (event: React.PointerEvent) => { + const drag = dragStateRef.current; + if (drag === null || drag.pointerId !== event.pointerId) return; + endDrag(true); + }, + [endDrag], + ); + + const handleGripLostPointerCapture = useCallback(() => { + endDrag(true); + }, [endDrag]); + + const handleGripDoubleClick = useCallback(() => { + setWidth(DEFAULT_PANE_WIDTH); + clearStoredPaneWidth(); + }, []); + + const handleMaximize = useCallback(() => { + const aside = asideRef.current; + const row = aside?.parentElement; + if (!aside || !row) return; + clearRestoreTimer(); + setMaximized(true); + const rowRect = row.getBoundingClientRect(); + setFixedPane((pane) => { + // Re-maximizing mid-restore: keep the current geometry, glide back left. + if (pane) return { ...pane, left: pane.parentLeft }; + const rect = aside.getBoundingClientRect(); + const seeded: FixedPane = { + top: rect.top, + left: rect.left, + rightInset: Math.max(0, window.innerWidth - rect.right), + parentLeft: rowRect.left, + }; + if (prefersReducedMotion()) return { ...seeded, left: seeded.parentLeft }; + // Seed at the current left, then glide to the row's left edge once the + // fixed position has been committed (double rAF). + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + setFixedPane((current) => (current ? { ...current, left: current.parentLeft } : current)); + }); + }); + return seeded; + }); + }, [clearRestoreTimer]); + + const handleRestore = useCallback(() => { + const row = asideRef.current?.parentElement; + setMaximized(false); + if (prefersReducedMotion()) { + finishRestore(); + return; + } + setFixedPane((pane) => { + if (!pane) return pane; + const rowRight = row + ? row.getBoundingClientRect().right + : window.innerWidth - pane.rightInset; + return { ...pane, left: rowRight - widthRef.current }; + }); + clearRestoreTimer(); + restoreTimerRef.current = window.setTimeout(finishRestore, RESTORE_FALLBACK_DELAY); + }, [clearRestoreTimer, finishRestore]); + + const handleTransitionEnd = useCallback( + (event: React.TransitionEvent) => { + if (event.target !== asideRef.current || event.propertyName !== "left") return; + // Only the restore glide needs to unfix on arrival. + if (!maximized) finishRestore(); + }, + [maximized, finishRestore], + ); + + const isFixed = fixedPane !== null; + + return ( + <> + {isFixed ? ( + // Holds the panel's slot in the layout flex row while the panel is + // position:fixed, so the main column never reflows. +
+ ) : null} + + ); } diff --git a/ui/src/components/TaskChatRedesignGate.test.tsx b/ui/src/components/TaskChatRedesignGate.test.tsx new file mode 100644 index 0000000000..478d334361 --- /dev/null +++ b/ui/src/components/TaskChatRedesignGate.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TaskChatRedesignGate } from "./TaskChatRedesignGate"; +import { useTaskChatRedesignEnabled } from "@/hooks/useTaskChatRedesignEnabled"; + +const mockInstanceSettingsApi = vi.hoisted(() => ({ + getExperimental: vi.fn(), +})); + +vi.mock("@/api/instanceSettings", () => ({ + instanceSettingsApi: mockInstanceSettingsApi, +})); + +vi.mock("@/lib/router", () => ({ + Navigate: ({ to, replace }: { to: string; replace?: boolean }) => ( +
+ ), + Outlet: () =>
gated content
, +})); + +async function flushReact() { + for (let index = 0; index < 5; index += 1) { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } + flushSync(() => {}); +} + +describe("TaskChatRedesignGate", () => { + let container: HTMLDivElement; + let root: Root | null = null; + + async function renderGate() { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + flushSync(() => { + root!.render( + + + , + ); + }); + await flushReact(); + } + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + flushSync(() => { + root?.unmount(); + }); + root = null; + container.remove(); + vi.clearAllMocks(); + }); + + it("redirects to the company home when the flag is off (flag-off isolation)", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableTaskChatRedesign: false }); + await renderGate(); + const navigate = container.querySelector('[data-testid="navigate"]'); + expect(navigate?.getAttribute("data-to")).toBe("/dashboard"); + expect(container.querySelector('[data-testid="outlet"]')).toBeNull(); + }); + + it("renders the gated harness when the flag is on", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableTaskChatRedesign: true }); + await renderGate(); + expect(container.querySelector('[data-testid="outlet"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="navigate"]')).toBeNull(); + }); +}); + +describe("useTaskChatRedesignEnabled", () => { + it("resolves to flag-off when rendered without a QueryClientProvider", () => { + // Rendered detached (no provider) it must default OFF and loaded — this is + // what makes flag-off the provable current behavior at every call site. + let captured: { enabled: boolean; loaded: boolean } | null = null; + function Probe() { + captured = useTaskChatRedesignEnabled(); + return null; + } + const container = document.createElement("div"); + const root = createRoot(container); + flushSync(() => root.render()); + flushSync(() => root.unmount()); + expect(captured).toEqual({ enabled: false, loaded: true }); + }); +}); diff --git a/ui/src/components/TaskChatRedesignGate.tsx b/ui/src/components/TaskChatRedesignGate.tsx new file mode 100644 index 0000000000..59f2bb8c97 --- /dev/null +++ b/ui/src/components/TaskChatRedesignGate.tsx @@ -0,0 +1,18 @@ +import { Navigate, Outlet } from "@/lib/router"; +import { useTaskChatRedesignEnabled } from "@/hooks/useTaskChatRedesignEnabled"; + +/** + * Layout route guard for Task Chat Redesign dev surfaces (e.g. the + * /dev/task-chat-lab harness). + * + * The gated routes stay registered (gating is presentation-only, no 404 + * flash); when the experimental flag is off the element redirects to the + * company home instead of rendering. While the flag is still loading nothing + * renders so an enabled user is not bounced away by a premature redirect. + */ +export function TaskChatRedesignGate() { + const { enabled, loaded } = useTaskChatRedesignEnabled(); + if (!loaded) return null; + if (!enabled) return ; + return ; +} diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx new file mode 100644 index 0000000000..8e64458107 --- /dev/null +++ b/ui/src/components/TaskChatThread.tsx @@ -0,0 +1,403 @@ +import { useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react"; +import { IssueChatThread } from "@/components/IssueChatThread"; +import { useLiveRunTranscripts, type RunTranscriptSource } from "@/components/transcript/useLiveRunTranscripts"; +import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter"; +import { + buildTurnSummary, + deriveRunStatusLabel, + isTerminalRunStatus, + transcriptToTaskChatItems, +} from "@/components/task-chat/transcript-adapter"; +import type { + TaskChatInteractionItem, + TaskChatItem, + TaskChatTurnChildItem, + TaskChatTurnItem, +} from "@/components/task-chat/task-chat-model"; +import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard"; +import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView"; +import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; +import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument"; +import { latestSameRunHandoffTimestamp, type IssueChatComment } from "@/lib/issue-chat-messages"; +import { workModeInEffectAt } from "@/lib/issue-timeline-events"; +import { workModeMetaFor } from "@/lib/work-mode-meta"; + +function toMs(value: Date | string | null | undefined): number { + if (!value) return 0; + const ms = new Date(value).getTime(); + return Number.isNaN(ms) ? 0 : ms; +} + +export type TaskChatThreadProps = ComponentProps; + +/** + * Task Chat Redesign thread (experimental flag: `enableTaskChatRedesign`). + * + * Renders the redesigned, Claude-Code-style thread for the live task. It shares + * IssueChatThread's exact prop type — so the IssueDetail seam ternary + * (`redesign ? TaskChatThread : IssueChatThread`) type-checks with no casts. + * + * Two data sources feed the render layer, both reused from the existing thread: + * - the comment stream (incl. optimistic echoes) → author-typed bubbles, and + * - the live run transcript (useLiveRunTranscripts, the same poll+websocket + * source the current thread uses) → the in-flight turn streams + * thinking → tool → diff → responding, capped by a live "running" status + * pill. + * + * Run activity is grouped into TaskChatTurnItem: the in-flight run renders as + * an unsettled (expanded) turn; when it terminates the same turn id flips + * settled, so TaskChatTurn plays the ~--motion-turn-fold collapse down to the + * one-line "✓ Worked · …" summary (runs already terminal at mount collapse + * instantly). Settled turns interleave after the run's last comment + * (comment.runId linkage) — the agent's reply bubble above, the folded activity + * summary below, so the live "Running…" pill reads as being replaced by the + * summary in place. flag-OFF remains byte-for-byte IssueChatThread. + */ +export function TaskChatThread(props: TaskChatThreadProps) { + const { + comments, + interactions, + timelineEvents, + issueId = null, + agentMap, + userLabelMap, + currentUserId, + onAdd, + issueWorkMode = "standard", + onWorkModeChange, + composerAccessory, + footer, + showComposer = true, + composerDisabledReason, + emptyMessage = "No messages yet.", + companyId, + linkedRuns, + liveRuns, + activeRun, + onAttachImage, + imageUploadHandler, + enableReassign, + reassignOptions, + currentAssigneeValue, + issueStatus, + onAcceptInteraction, + onRejectInteraction, + onSubmitInteractionAnswers, + onCancelInteraction, + onSubmitInteractionVerdicts, + externalReferences, + threadHeader, + workModeChanges, + } = props; + + const linkedRunMetaById = useMemo(() => { + const map = new Map[number]>(); + for (const run of linkedRuns ?? []) map.set(run.runId, run); + return map; + }, [linkedRuns]); + + // Each agent reply is tagged with the mode its request ran under: the + // issue's work mode at the reply's run start (comment.runId linkage), + // reconstructed from the activity feed's work-mode switch history — not the + // issue's current mode, which the user may have changed since. + const agentModeLabelFor = useCallback( + (comment: IssueChatComment) => { + const runMeta = comment.runId ? linkedRunMetaById.get(comment.runId) : undefined; + const atMs = toMs(runMeta?.startedAt ?? runMeta?.createdAt ?? comment.createdAt); + return workModeMetaFor(workModeInEffectAt(workModeChanges ?? [], atMs, issueWorkMode)).label; + }, + [linkedRunMetaById, workModeChanges, issueWorkMode], + ); + const commentItems = useMemo( + () => commentsToTaskChatItems(comments, { agentMap, userLabelMap, currentUserId, agentModeLabelFor }), + [comments, agentMap, userLabelMap, currentUserId, agentModeLabelFor], + ); + + // Every run we might need a transcript for (history + live), deduped by id. + const runs = useMemo(() => { + const map = new Map(); + for (const r of linkedRuns ?? []) { + map.set(r.runId, { + id: r.runId, + status: r.status, + adapterType: r.adapterType ?? "", + hasStoredOutput: r.hasStoredOutput, + logBytes: r.logBytes, + }); + } + for (const r of liveRuns ?? []) { + map.set(r.id, { + id: r.id, + status: r.status, + adapterType: r.adapterType, + hasStoredOutput: map.get(r.id)?.hasStoredOutput, + logBytes: r.logBytes, + lastOutputBytes: r.lastOutputBytes, + }); + } + if (activeRun) { + map.set(activeRun.id, { + id: activeRun.id, + status: activeRun.status, + adapterType: activeRun.adapterType, + logBytes: activeRun.logBytes, + lastOutputBytes: activeRun.lastOutputBytes, + }); + } + return [...map.values()]; + }, [linkedRuns, liveRuns, activeRun]); + + const { transcriptByRun } = useLiveRunTranscripts({ runs, companyId }); + + // The single in-flight run whose turn we stream live (non-terminal). + const liveRun = useMemo(() => { + if (activeRun && !isTerminalRunStatus(activeRun.status)) return activeRun; + return (liveRuns ?? []).find((r) => !isTerminalRunStatus(r.status)) ?? null; + }, [activeRun, liveRuns]); + + // Runs observed non-terminal while mounted: their turns ANIMATE the fold when + // they settle. Runs already terminal at mount collapse instantly. + const liveSeenRef = useRef>(new Set()); + useEffect(() => { + if (liveRun) liveSeenRef.current.add(liveRun.id); + }, [liveRun]); + + // Each terminal run's turn anchors immediately after the run's last comment + // (its reply bubble), via the comment.runId linkage — the summary line lands + // below the bubble, where the live "Running…" pill sat. + const lastCommentIdByRun = useMemo(() => { + const map = new Map(); + for (const comment of comments) { + if (comment.deletedAt || !comment.runId || !comment.id) continue; + map.set(comment.runId, comment.id); + } + return map; + }, [comments]); + + const { data: planDocument } = useIssuePlanDocument(issueId); + + // Comments, interactions, and the plan-doc marker merged into one + // chronological backbone (same sort keys and same-run handoff shift as the + // legacy buildIssueChatMessages), so plan-mode confirmation/question cards + // land where they happened in the conversation. + const orderedEntries = useMemo(() => { + const entries: { ms: number; order: number; id: string; item: TaskChatItem }[] = []; + // commentsToTaskChatItems skips deleted comments — mirror its filter so the + // two lists stay index-aligned. + const visibleComments = comments.filter((comment) => !comment.deletedAt); + visibleComments.forEach((comment, index) => { + const item = commentItems[index]; + if (!item) return; + entries.push({ ms: toMs(comment.createdAt), order: 1, id: item.id, item }); + }); + for (const interaction of interactions ?? []) { + const createdAtMs = toMs(interaction.createdAt); + const handoffAtMs = + interaction.kind === "request_confirmation" && interaction.sourceRunId + ? latestSameRunHandoffTimestamp({ + interactionCreatedAtMs: createdAtMs, + sourceRunId: interaction.sourceRunId, + comments, + timelineEvents: timelineEvents ?? [], + linkedRuns: linkedRuns ?? [], + liveRuns: liveRuns ?? [], + }) + : null; + const id = `interaction:${interaction.id}`; + entries.push({ + ms: handoffAtMs ?? createdAtMs, + order: 2, + id, + item: { id, kind: "interaction", interaction }, + }); + } + if (planDocument) { + const revision = planDocument.latestRevisionNumber ?? 1; + const id = `plan-doc:${planDocument.latestRevisionId ?? planDocument.id}`; + entries.push({ + ms: toMs(planDocument.updatedAt), + order: 0, + id, + item: { + id, + kind: "marker", + variant: "turn_boundary", + label: revision > 1 ? "Plan updated" : "Plan created", + detail: `rev ${revision} — see the Plan tab`, + }, + }); + } + return entries.sort( + (a, b) => a.ms - b.ms || a.order - b.order || a.id.localeCompare(b.id), + ); + }, [comments, commentItems, interactions, timelineEvents, linkedRuns, liveRuns, planDocument]); + + const items = useMemo(() => { + // Settled turns for every terminal run whose transcript we have. The + // transcript's assistant text is excluded — it already landed as the run's + // comment bubble; the turn holds the activity (thinking/tools/diffs). + const settledTurns: { turn: TaskChatTurnItem; anchorCommentId: string | null; order: number }[] = []; + for (const source of runs) { + if (!isTerminalRunStatus(source.status)) continue; + if (liveRun && source.id === liveRun.id) continue; + const entries = transcriptByRun.get(source.id) ?? []; + if (entries.length === 0) continue; + const meta = linkedRunMetaById.get(source.id); + const started = meta?.startedAt ? new Date(meta.startedAt).getTime() : NaN; + const finished = meta?.finishedAt ? new Date(meta.finishedAt).getTime() : NaN; + const durationMs = + Number.isFinite(started) && Number.isFinite(finished) + ? Math.max(0, finished - started) + : undefined; + const children = transcriptToTaskChatItems(entries, { + runId: source.id, + agentName: meta?.agentName, + running: false, + }).filter((it): it is TaskChatTurnChildItem => it.kind !== "turn" && it.kind !== "message"); + if (children.length === 0) continue; + settledTurns.push({ + turn: { + id: `${source.id}:turn`, + kind: "turn", + settled: true, + animateFold: liveSeenRef.current.has(source.id), + items: children, + summary: buildTurnSummary(entries, { + durationMs, + failed: source.status !== "succeeded", + }), + }, + anchorCommentId: lastCommentIdByRun.get(source.id) ?? null, + order: meta?.createdAt ? new Date(meta.createdAt).getTime() : 0, + }); + } + settledTurns.sort((a, b) => a.order - b.order); + + const turnsByAnchor = new Map(); + const unanchored: TaskChatTurnItem[] = []; + for (const { turn, anchorCommentId } of settledTurns) { + if (anchorCommentId) { + const list = turnsByAnchor.get(anchorCommentId) ?? []; + list.push(turn); + turnsByAnchor.set(anchorCommentId, list); + } else { + unanchored.push(turn); + } + } + + const out: TaskChatItem[] = []; + for (const entry of orderedEntries) { + out.push(entry.item); + const following = turnsByAnchor.get(entry.id); + if (following) out.push(...following); + } + out.push(...unanchored); + + if (liveRun) { + const entries = transcriptByRun.get(liveRun.id) ?? []; + const children = transcriptToTaskChatItems(entries, { + runId: liveRun.id, + agentName: liveRun.agentName, + running: true, + }).filter((it): it is TaskChatTurnChildItem => it.kind !== "turn"); + if (children.length > 0) { + out.push({ + id: `${liveRun.id}:turn`, + kind: "turn", + settled: false, + items: children, + summary: buildTurnSummary(entries), + }); + } + const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null; + const queued = liveRun.status === "queued"; + const status = queued + ? { label: "Queued", detail: "Waiting to start", toolName: undefined } + : deriveRunStatusLabel(entries); + out.push({ + id: `${liveRun.id}:status`, + kind: "status", + status: "running", + label: status.label, + detail: status.detail, + toolName: status.toolName, + startedAtMs: startedAt ?? undefined, + }); + } + return out; + }, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun]); + + const renderInteraction = useCallback( + (item: TaskChatInteractionItem) => ( + + ), + [ + agentMap, + currentUserId, + userLabelMap, + onAcceptInteraction, + onRejectInteraction, + onSubmitInteractionAnswers, + onCancelInteraction, + onSubmitInteractionVerdicts, + imageUploadHandler, + externalReferences, + ], + ); + + return ( +
+
+ {items.length === 0 ? ( +
+ {threadHeader ? ( +
+ {threadHeader} +
+ ) : null} +
{emptyMessage}
+
+ ) : ( + + )} +
+ {showComposer ? ( +
+ {composerAccessory} + + {footer} +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/issue-properties/IssuePlanConfirmationActionBar.tsx b/ui/src/components/issue-properties/IssuePlanConfirmationActionBar.tsx new file mode 100644 index 0000000000..e679989f08 --- /dev/null +++ b/ui/src/components/issue-properties/IssuePlanConfirmationActionBar.tsx @@ -0,0 +1,204 @@ +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import type { Issue, IssueThreadInteraction, RequestConfirmationInteraction } from "@paperclipai/shared"; +import { issuesApi } from "@/api/issues"; +import { queryKeys } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { PROPERTIES_PANE_FOOTER_SLOT_ID } from "../PropertiesPanel"; + +/** The pending confirmation that targets the issue's `plan` document, if any. */ +function findPendingPlanConfirmation( + interactions: IssueThreadInteraction[] | undefined, +): RequestConfirmationInteraction | null { + for (const interaction of interactions ?? []) { + if (interaction.kind !== "request_confirmation") continue; + if (interaction.status !== "pending") continue; + const target = interaction.payload.target; + if (target?.type === "issue_document" && target.key === "plan") return interaction; + } + return null; +} + +interface IssuePlanConfirmationActionBarProps { + issue: Issue; + /** Inline hosts (mobile sheet) render the bar in place instead of portaling + * it into the pane's pinned footer slot. */ + inline?: boolean; +} + +/** + * Sticky action bar for the Plan pane (flag: enableTaskChatRedesign): while a + * plan confirmation is pending, its CTAs stay pinned below the pane's scroll + * area so the board can approve or send back the plan without hunting for the + * card in the thread. Mirrors the thread card's semantics (accept/reject labels + * and the optional decline reason) against the same interactions API. + */ +export function IssuePlanConfirmationActionBar({ issue, inline }: IssuePlanConfirmationActionBarProps) { + const queryClient = useQueryClient(); + const { data: interactions } = useQuery({ + queryKey: queryKeys.issues.interactions(issue.id), + queryFn: () => issuesApi.listInteractions(issue.id), + }); + const confirmation = findPendingPlanConfirmation(interactions); + + const [footerSlot, setFooterSlot] = useState(null); + useEffect(() => { + if (inline) { + setFooterSlot(null); + return; + } + setFooterSlot(document.getElementById(PROPERTIES_PANE_FOOTER_SLOT_ID)); + }, [inline]); + + const [rejecting, setRejecting] = useState(false); + const [rejectReason, setRejectReason] = useState(""); + const [rejectAttempted, setRejectAttempted] = useState(false); + const [actionError, setActionError] = useState(null); + + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issue.id) }); + }; + const accept = useMutation({ + mutationFn: (interactionId: string) => issuesApi.acceptInteraction(issue.id, interactionId), + onSuccess: invalidate, + onError: () => setActionError("Couldn't confirm — try again."), + }); + const reject = useMutation({ + mutationFn: ({ interactionId, reason }: { interactionId: string; reason?: string }) => + issuesApi.rejectInteraction(issue.id, interactionId, reason), + onSuccess: () => { + setRejecting(false); + setRejectReason(""); + invalidate(); + }, + onError: () => setActionError("Couldn't send that back — try again."), + }); + + // Interaction changed under us (resolved elsewhere, superseded): reset. + useEffect(() => { + setRejecting(false); + setRejectReason(""); + setRejectAttempted(false); + setActionError(null); + }, [confirmation?.id, confirmation?.status]); + + if (!confirmation) return null; + + const working = accept.isPending ? "accept" : reject.isPending ? "reject" : null; + const rejectRequiresReason = confirmation.payload.rejectRequiresReason === true; + const allowDeclineReason = confirmation.payload.allowDeclineReason !== false; + const trimmedReason = rejectReason.trim(); + const reasonInvalid = rejectRequiresReason && trimmedReason.length === 0; + + const handleReject = () => { + setRejectAttempted(true); + if (reasonInvalid) return; + setActionError(null); + reject.mutate({ interactionId: confirmation.id, reason: trimmedReason || undefined }); + }; + + const bar = ( +
+ {rejecting ? ( +
+