From a3e5afb8359a2238627ad299314e4c7c88dd7081 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 26 Aug 2026 13:37:30 -0500 Subject: [PATCH] fix(ui): refine task chat recovery states --- ui/src/components/IssueChatThread.tsx | 3 + ui/src/components/IssueProperties.test.tsx | 23 +--- ui/src/components/TaskChatThread.test.tsx | 44 +++++++ ui/src/components/TaskChatThread.tsx | 6 + .../issue-properties/IssueProperties.tsx | 7 +- .../task-chat/TaskChatActivityPhase.tsx | 5 +- .../components/task-chat/TaskChatBubble.tsx | 13 ++- .../task-chat/TaskChatLiveTail.test.tsx | 1 + .../components/task-chat/TaskChatLiveTail.tsx | 3 + .../task-chat/TaskChatRunnerTurn.test.tsx | 25 ++++ .../task-chat/TaskChatRunnerTurn.tsx | 9 +- .../task-chat/TaskChatSystemNotice.test.tsx | 99 +++++++++++++++- .../task-chat/TaskChatSystemNotice.tsx | 88 ++++++++++---- .../components/task-chat/TaskChatThinking.tsx | 35 +++++- .../task-chat/TaskChatThreadView.tsx | 11 ++ .../task-chat/transcript-adapter.test.ts | 17 ++- .../task-chat/transcript-adapter.ts | 16 ++- .../task-side-panel/TaskSidePanel.test.tsx | 28 ++++- .../task-side-panel/TaskSidePanel.tsx | 38 +++--- ui/src/pages/IssueDetail.test.tsx | 109 ++++++++++++++++++ ui/src/pages/IssueDetail.tsx | 63 ++++++---- 21 files changed, 541 insertions(+), 102 deletions(-) diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index deefc59e461..73a9f591802 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -483,6 +483,9 @@ interface IssueChatThreadProps { issueAssigneeAgentId?: string | null; onResumeFromBacklog?: () => Promise | void; resumeFromBacklogPending?: boolean; + /** Chat-style task view only: move a blocked no-live-execution-path task back to todo. */ + onTryAgainNoLiveExecutionPath?: () => Promise | void; + tryAgainNoLiveExecutionPathPending?: boolean; companyId?: string | null; projectId?: string | null; issueStatus?: string; diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 702154a54cf..bacdee29f3a 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -505,18 +505,11 @@ describe("IssueProperties", () => { document.body.innerHTML = ""; }); - it("keeps the Plan tab visible for a planning-mode issue without a plan document", async () => { + it("does not show a Plan tab for a planning-mode issue without a plan document", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableTaskWatchdogs: false, enableClassicTaskInterface: false, }); - mockIssuesApi.listInteractions.mockResolvedValue([ - { - kind: "request_confirmation", - status: "pending", - payload: { target: { type: "issue_document", key: "plan" } }, - }, - ]); const root = renderProperties(container, { issue: createIssue({ workMode: "planning" }), childIssues: [], @@ -525,18 +518,8 @@ describe("IssueProperties", () => { }); await waitForAssertion(() => { - expect(Array.from(container.querySelectorAll("button")).some((button) => button.textContent === "Plan")).toBe(true); - }); - - const planTab = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "Plan"); - await act(async () => { - // Radix Tabs triggers select on mousedown (button 0), not on click. - planTab!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })); - }); - - await waitForAssertion(() => { - expect(container.textContent).toContain("This task is in plan mode but no plan document has been written yet."); - expect(container.textContent).toContain("A plan confirmation is pending, but the plan document it should confirm is missing."); + expect(Array.from(container.querySelectorAll("button")).some((button) => button.textContent === "Plan")).toBe(false); + expect(container.textContent).not.toContain("This task is in plan mode but no plan document has been written yet."); }); act(() => root.unmount()); diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index ef50ce0f980..8c946334db1 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -395,6 +395,50 @@ describe("TaskChatThread composer alignment (PAP-498)", () => { }); }); +describe("TaskChatThread no-live-execution-path recovery", () => { + const noLivePathComment = { + id: "comment-no-live-path", + companyId: "company-1", + issueId: "issue-1", + authorType: "system" as const, + authorAgentId: null, + authorUserId: null, + body: "Paperclip retried continuation, but it still has no live execution path.", + presentation: { + kind: "system_notice" as const, + tone: "danger" as const, + title: "No live execution path", + detailsDefaultOpen: false, + }, + metadata: null, + createdAt: new Date("2026-08-26T12:00:00.000Z"), + updatedAt: new Date("2026-08-26T12:00:00.000Z"), + }; + + it("offers Try again only while the task is blocked", async () => { + const onTryAgain = vi.fn(); + const props = { + comments: [noLivePathComment], + onAdd: async () => {}, + onTryAgainNoLiveExecutionPath: onTryAgain, + showComposer: false, + }; + + render(); + const tryAgain = container.querySelector( + '[data-testid="task-chat-no-live-path-try-again"]', + ); + expect(tryAgain).not.toBeNull(); + + flushSync(() => tryAgain!.click()); + await Promise.resolve(); + expect(onTryAgain).toHaveBeenCalledTimes(1); + + render(); + expect(container.querySelector('[data-testid="task-chat-no-live-path-try-again"]')).toBeNull(); + }); +}); + describe("TaskChatThread blocker links", () => { it("shows the direct and server-selected terminal blocker at the top and bottom", () => { const terminalBlocker = { diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 65f474035ca..d00ab7faf89 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -277,6 +277,8 @@ export function TaskChatThread(props: TaskChatThreadProps) { draftKey, onInterruptQueued, interruptingQueuedRunId, + onTryAgainNoLiveExecutionPath, + tryAgainNoLiveExecutionPathPending, queuedCommentQueue, onEditQueuedComment, onReorderQueuedComments, @@ -1444,6 +1446,10 @@ export function TaskChatThread(props: TaskChatThreadProps) { } renderMessageActions={renderMessageActions} renderQueuedAction={renderQueuedAction} + onTryAgainNoLiveExecutionPath={ + issueStatus === "blocked" ? onTryAgainNoLiveExecutionPath : undefined + } + tryAgainNoLiveExecutionPathPending={tryAgainNoLiveExecutionPathPending} tail={ tailRunId || optimisticRunnerStartup || bottomBlockerLinks ? ( <> diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index e7d1e1bbbf2..6a93ae64b62 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -210,9 +210,7 @@ export function IssueProperties({ } setPaneHeaderSlot(document.getElementById(PROPERTIES_PANE_HEADER_SLOT_ID)); }, [taskChatShellEnabled, inline]); - // Plan earns a tab as soon as an issue is in planning mode, even before the - // plan document arrives. This keeps an expected plan surface visible and - // lets its diagnostic empty state explain what is missing. + // A Plan tab represents materialized plan content, not merely planning mode. // Same query keys as the tab bodies, so these share their cached fetches. const { data: paneTabPlanDocument } = useIssuePlanDocument( taskChatShellEnabled ? issue.id : null, @@ -241,8 +239,7 @@ export function IssueProperties({ const hasPlanTab = Boolean(paneTabPlanDocument) || (paneTabAcceptedPlans?.length ?? 0) > 0 - || paneTabStandaloneDocuments.length > 0 - || issue.workMode === "planning"; + || paneTabStandaloneDocuments.length > 0; // Artifacts covers the same three sources the tab body composes: work // products, documents (redundant with the Plan tab, intentionally), and // agent-created attachments. User comment uploads stay thread-only and diff --git a/ui/src/components/task-chat/TaskChatActivityPhase.tsx b/ui/src/components/task-chat/TaskChatActivityPhase.tsx index d947fa40789..65c19ca2431 100644 --- a/ui/src/components/task-chat/TaskChatActivityPhase.tsx +++ b/ui/src/components/task-chat/TaskChatActivityPhase.tsx @@ -7,12 +7,15 @@ import type { TaskChatActivityPhaseItem } from "./task-chat-model"; export function TaskChatActivityPhase({ item, renderChild, + defaultOpen = false, }: { item: TaskChatActivityPhaseItem; renderChild: (child: TaskChatActivityPhaseItem["items"][number]) => ReactNode; + /** Keep useful historical content visible in an in-flight transcript. */ + defaultOpen?: boolean; }) { const shouldAutoOpen = - item.active || item.items.some((child) => + defaultOpen || item.active || item.items.some((child) => (child.kind === "thinking" && child.streaming) || (child.kind === "tool" && child.status === "in_progress") || (child.kind === "protocol" && child.surface === "provider_activity" && child.status === "running") || diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index f8bb2b7d7aa..b5bd760b3cb 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -20,6 +20,9 @@ import type { TaskChatMessageItem } from "./task-chat-model"; interface TaskChatBubbleProps { item: TaskChatMessageItem; + /** Requeues a blocked task after a no-live-execution-path recovery notice. */ + onTryAgainNoLiveExecutionPath?: () => Promise | void; + tryAgainNoLiveExecutionPathPending?: boolean; /** Disable the entrance animation when replacing an already-visible live response. */ animateEntry?: boolean; /** Action shown beside the queued state for an interruptible message. */ @@ -105,6 +108,8 @@ export function TaskChatBubble({ attachedTurn, beforeTurn, actions, + onTryAgainNoLiveExecutionPath, + tryAgainNoLiveExecutionPathPending, }: TaskChatBubbleProps) { // Clicking an embedded image opens the full-screen lightbox (with download); // arrow keys walk across the other images in the same bubble. @@ -119,7 +124,13 @@ export function TaskChatBubble({ if (item.author === "system") { // Collapsed humanized one-liner, expandable to the full detail (PAP-443). - return ; + return ( + + ); } const isHuman = item.author === "human"; diff --git a/ui/src/components/task-chat/TaskChatLiveTail.test.tsx b/ui/src/components/task-chat/TaskChatLiveTail.test.tsx index c778abb2ab2..3bac8ebbaf3 100644 --- a/ui/src/components/task-chat/TaskChatLiveTail.test.tsx +++ b/ui/src/components/task-chat/TaskChatLiveTail.test.tsx @@ -135,6 +135,7 @@ describe("TaskChatLiveTail", () => { { kind: "thinking", ts: TS, text: "Provider reasoning summary" }, { kind: "assistant", ts: TS, text: "Visible answer." }, ]); + expect(items[0]).toMatchObject({ kind: "thinking", streaming: false }); render(items); expect(container.textContent).toContain("Visible answer."); diff --git a/ui/src/components/task-chat/TaskChatLiveTail.tsx b/ui/src/components/task-chat/TaskChatLiveTail.tsx index a404dc602a3..0c56fe4a63c 100644 --- a/ui/src/components/task-chat/TaskChatLiveTail.tsx +++ b/ui/src/components/task-chat/TaskChatLiveTail.tsx @@ -104,6 +104,9 @@ function renderTailRow( + child.kind === "thinking" && child.lines.some((line) => line.trim().length > 0) + )} renderChild={(child) => child.kind === "tool" ? : child.kind === "thinking" diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx index a55863800ae..97540d1f63e 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx @@ -122,6 +122,31 @@ describe("TaskChatRunnerTurn", () => { expect(history?.querySelectorAll('[data-testid="task-chat-thinking"]')).toHaveLength(2); }); + it("renders only the current reasoning block as active", () => { + render([ + { id: "old-reasoning", kind: "thinking", lines: ["Inspect the current card."], streaming: true, transcriptIndex: 1 }, + { id: "read", kind: "tool", name: "Read files", rawName: "Read", status: "completed" }, + { id: "current-reasoning", kind: "thinking", lines: ["Verify the updated state."], streaming: true, transcriptIndex: 3 }, + ]); + + act(() => container.querySelector('[data-testid="task-chat-current-activity"]')?.click()); + + const oldReasoning = container.querySelector('[data-activity-item-id="old-reasoning"]'); + expect(oldReasoning?.textContent).toBe("Inspect the current card."); + expect(oldReasoning?.textContent).not.toContain("Reasoning"); + expect(oldReasoning?.querySelector('[data-testid="task-chat-thinking-text"]')).not.toBeNull(); + expect(oldReasoning?.querySelector('[data-testid="task-chat-thinking-icon"]')?.classList) + .toContain("text-muted-foreground/50"); + expect(oldReasoning?.querySelector('[data-testid="task-chat-thinking-icon"]')?.classList) + .not.toContain("text-(--status-agent-running)"); + expect(oldReasoning?.querySelector(".shimmer-text")).toBeNull(); + + const currentReasoning = container.querySelector('[data-activity-item-id="current-reasoning"]'); + expect(currentReasoning?.textContent).toBe("Reasoning…"); + expect(currentReasoning?.querySelector('[data-testid="task-chat-thinking-icon"]')?.classList) + .toContain("text-(--status-agent-running)"); + }); + it("lets a lifecycle-only reasoning event replace stale commentary without inventing reasoning text", () => { render([ { id: "commentary", kind: "message", author: "agent", text: "Old commentary", interstitial: true, transcriptIndex: 1 }, diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx index 0b64cf7f334..3d216fd3273 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx @@ -209,14 +209,19 @@ function RunnerActivityTimeline({ items }: { items: readonly TaskChatItem[] }) {
    - {items.map((item) => ( + {items.map((item, index) => (
  1. {item.kind === "message" ? (
    {item.text}
    ) : item.kind === "thinking" ? ( - + ) : item.kind === "tool" ? ( ) : item.kind === "marker" ? ( diff --git a/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx b/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx index 6d34d7b45ea..2557b3cedae 100644 --- a/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx +++ b/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx @@ -2,7 +2,7 @@ import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ThemeProvider } from "@/context/ThemeContext"; import { TaskChatSystemNotice } from "./TaskChatSystemNotice"; import type { TaskChatMessageItem } from "./task-chat-model"; @@ -27,7 +27,13 @@ describe("TaskChatSystemNotice (PAP-443)", () => { "Paperclip stopped before dispatching the adapter because required secret/env bindings are missing. " + "Latest retry failure: `configuration_incomplete`. Moving it to `blocked` with a source-scoped recovery action."; - function renderNotice(overrides: Partial = {}) { + function renderNotice( + overrides: Partial = {}, + props: { + onTryAgainNoLiveExecutionPath?: () => Promise | void; + tryAgainNoLiveExecutionPathPending?: boolean; + } = {}, + ) { const item: TaskChatMessageItem = { id: "sys-1", kind: "message", @@ -39,7 +45,7 @@ describe("TaskChatSystemNotice (PAP-443)", () => { flushSync(() => root!.render( - + , ), ); @@ -47,7 +53,7 @@ describe("TaskChatSystemNotice (PAP-443)", () => { function toggleButton() { return container.querySelector( - '[data-testid="task-chat-system-notice"] button', + '[data-testid="task-chat-system-notice"] button[aria-expanded]', )!; } @@ -120,4 +126,89 @@ describe("TaskChatSystemNotice (PAP-443)", () => { container.querySelector('[data-testid="task-chat-system-notice-details"]'), ).not.toBeNull(); }); + + it("shows Try again while folded and invokes it without expanding the notice", async () => { + const onTryAgain = vi.fn(); + renderNotice( + { + text: "Paperclip retried continuation, but it still has no live execution path.", + presentation: { + kind: "system_notice", + tone: "danger", + title: "No live execution path", + detailsDefaultOpen: false, + }, + }, + { onTryAgainNoLiveExecutionPath: onTryAgain }, + ); + + const tryAgain = container.querySelector( + '[data-testid="task-chat-no-live-path-try-again"]', + ); + expect(tryAgain?.textContent).toBe("Try again"); + flushSync(() => tryAgain!.click()); + await Promise.resolve(); + + expect(onTryAgain).toHaveBeenCalledTimes(1); + expect(toggleButton().getAttribute("aria-expanded")).toBe("false"); + }); + + it("moves Try again into the expanded notice footer", () => { + renderNotice( + { + text: "Paperclip retried continuation, but it still has no live execution path.", + presentation: { + kind: "system_notice", + tone: "danger", + title: "No live execution path", + detailsDefaultOpen: false, + }, + }, + { onTryAgainNoLiveExecutionPath: vi.fn() }, + ); + + flushSync(() => toggleButton().click()); + + const details = container.querySelector('[data-testid="task-chat-system-notice-details"]'); + expect(details?.querySelector('[data-testid="task-chat-no-live-path-try-again"]')).not.toBeNull(); + expect(container.querySelectorAll('[data-testid="task-chat-no-live-path-try-again"]')).toHaveLength(1); + }); + + it("shows the pending state and does not add Try again to unrelated notices", () => { + renderNotice( + { + text: "Paperclip retried continuation, but it still has no live execution path.", + presentation: { + kind: "system_notice", + tone: "danger", + title: "No live execution path", + detailsDefaultOpen: false, + }, + }, + { + onTryAgainNoLiveExecutionPath: vi.fn(), + tryAgainNoLiveExecutionPathPending: true, + }, + ); + + const pending = container.querySelector( + '[data-testid="task-chat-no-live-path-try-again"]', + ); + expect(pending?.disabled).toBe(true); + expect(pending?.textContent).toBe("Trying again..."); + + renderNotice( + { + text: recoveryBody, + presentation: { + kind: "system_notice", + tone: "danger", + title: "Configuration incomplete", + detailsDefaultOpen: false, + }, + }, + { onTryAgainNoLiveExecutionPath: vi.fn() }, + ); + expect(container.querySelector('[data-testid="task-chat-no-live-path-try-again"]')).toBeNull(); + }); }); diff --git a/ui/src/components/task-chat/TaskChatSystemNotice.tsx b/ui/src/components/task-chat/TaskChatSystemNotice.tsx index da9c40bd1bf..0ec8f07a8c1 100644 --- a/ui/src/components/task-chat/TaskChatSystemNotice.tsx +++ b/ui/src/components/task-chat/TaskChatSystemNotice.tsx @@ -9,6 +9,7 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { MarkdownBody } from "@/components/MarkdownBody"; +import { Button } from "@/components/ui/button"; import { SystemNoticeMetadataSections, type SystemNoticeTone, @@ -42,7 +43,15 @@ const TONE_ICON_CLASS: Record = { * markdown-rendered body plus any structured metadata sections; nothing is * suppressed, only folded. */ -export function TaskChatSystemNotice({ item }: { item: TaskChatMessageItem }) { +export function TaskChatSystemNotice({ + item, + onTryAgainNoLiveExecutionPath, + tryAgainNoLiveExecutionPathPending = false, +}: { + item: TaskChatMessageItem; + onTryAgainNoLiveExecutionPath?: () => Promise | void; + tryAgainNoLiveExecutionPathPending?: boolean; +}) { const [open, setOpen] = useState(Boolean(item.presentation?.detailsDefaultOpen)); const detailsId = useId(); const { title, tone, detail } = humanizeSystemNotice({ @@ -54,29 +63,53 @@ export function TaskChatSystemNotice({ item }: { item: TaskChatMessageItem }) { }); const ToneIcon = TONE_ICON[tone]; const relative = item.createdAtIso ? timeAgo(item.createdAtIso) : undefined; + const showTryAgain = + Boolean(onTryAgainNoLiveExecutionPath) && + (item.presentation?.title?.trim() === "No live execution path" || + item.text.toLowerCase().includes("no live execution path")); + const handleTryAgain = () => { + void Promise.resolve() + .then(() => onTryAgainNoLiveExecutionPath?.()) + // The parent mutation owns visible error feedback. + .catch(() => undefined); + }; return (
    - +
    + + {showTryAgain && !open ? ( + + ) : null} +
    {open ? (
    ) : null} + {showTryAgain ? ( +
    + +
    + ) : null}
    ) : null}
diff --git a/ui/src/components/task-chat/TaskChatThinking.tsx b/ui/src/components/task-chat/TaskChatThinking.tsx index 9036b6f2084..b8c2e597b2b 100644 --- a/ui/src/components/task-chat/TaskChatThinking.tsx +++ b/ui/src/components/task-chat/TaskChatThinking.tsx @@ -9,25 +9,50 @@ export function TaskChatThinking({ item, defaultOpen, rowClassName, + active = Boolean(item.streaming), }: { item: TaskChatThinkingItem; /** Activity timelines keep reasoning as one compact row until requested. */ defaultOpen?: boolean; /** Optional alignment override for a containing activity rail. */ rowClassName?: string; + /** Whether this reasoning block is the runner's current activity. */ + active?: boolean; }) { - const [open, setOpen] = useState(defaultOpen ?? (item.streaming || !item.collapsed)); + const [open, setOpen] = useState(defaultOpen ?? (active || !item.collapsed)); const body = item.lines.join("\n").trim(); const baseLabel = item.channel === "detail" ? "Reasoning detail" : "Reasoning"; - const label = item.streaming + const label = active ? body ? `${baseLabel}…` : "Thinking…" : item.summaryLabel ?? (body ? baseLabel : "Thought"); + if (body && !active) { + return ( +
+ +
+ {body} +
+
+ ); + } + if (!body) { return (
- - {label} + + {label}
); } @@ -40,7 +65,7 @@ export function TaskChatThinking({ onClick={() => setOpen((value) => !value)} className={cn("group/thinking flex min-w-0 items-center gap-1.5 rounded-sm px-1 py-0.5 text-left text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", rowClassName)} > - + {label} diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx index ff03d392890..a9a28c405c1 100644 --- a/ui/src/components/task-chat/TaskChatThreadView.tsx +++ b/ui/src/components/task-chat/TaskChatThreadView.tsx @@ -49,6 +49,9 @@ interface TaskChatThreadViewProps { renderMessageActions?: (item: TaskChatMessageItem) => ReactNode; /** Renders an interrupt action beside a queued human message. */ renderQueuedAction?: (item: TaskChatMessageItem) => ReactNode; + /** Requeues a blocked task from its no-live-execution-path system notice. */ + onTryAgainNoLiveExecutionPath?: () => Promise | void; + tryAgainNoLiveExecutionPathPending?: boolean; /** Content appended inside the transcript scroller after the settled thread. */ tail?: ReactNode; /** Optional streaming-aware key when `tail` changes without changing `items`. */ @@ -66,6 +69,8 @@ function renderItem( renderMessageActions?: (item: TaskChatMessageItem) => ReactNode, renderQueuedAction?: (item: TaskChatMessageItem) => ReactNode, onRuntimeRequestDecision?: (item: TaskChatRuntimeRequestItem, decision: TaskChatRuntimeRequestDecision) => void | Promise, + onTryAgainNoLiveExecutionPath?: () => Promise | void, + tryAgainNoLiveExecutionPathPending?: boolean, ) { switch (item.kind) { case "message": { @@ -95,6 +100,8 @@ function renderItem( queuedAction={renderQueuedAction?.(item)} beforeTurn={item.attachedTurn?.standaloneHeader ? turn : undefined} attachedTurn={item.attachedTurn?.standaloneHeader ? undefined : turn} + onTryAgainNoLiveExecutionPath={onTryAgainNoLiveExecutionPath} + tryAgainNoLiveExecutionPathPending={tryAgainNoLiveExecutionPathPending} /> ); } @@ -153,6 +160,8 @@ export function TaskChatThreadView({ renderBrief, renderMessageActions, renderQueuedAction, + onTryAgainNoLiveExecutionPath, + tryAgainNoLiveExecutionPathPending, tail, contentKey, className, @@ -175,6 +184,8 @@ export function TaskChatThreadView({ renderMessageActions, renderQueuedAction, onRuntimeRequestDecision, + onTryAgainNoLiveExecutionPath, + tryAgainNoLiveExecutionPathPending, )} ))} diff --git a/ui/src/components/task-chat/transcript-adapter.test.ts b/ui/src/components/task-chat/transcript-adapter.test.ts index cdbc9385170..c0d8b293a89 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -589,6 +589,19 @@ describe("paperclip runner semantic channels", () => { }); }); + it("settles a reasoning block as soon as later activity takes over", () => { + const parsed = transcriptToTaskChatItems([ + { kind: "thinking", ts: TS, text: "Inspect the runner", delta: true, channel: "summary" }, + toolCall("Read", { file_path: "runner.ts" }), + { kind: "thinking", ts: TS, text: "Verify the change", delta: true, channel: "summary" }, + ], { runId: "run-reasoning-handoff", running: true }); + + const reasoning = parsed.filter((item) => item.kind === "thinking"); + expect(reasoning).toHaveLength(2); + expect(reasoning[0]).toMatchObject({ lines: ["Inspect the runner"], streaming: false }); + expect(reasoning[1]).toMatchObject({ lines: ["Verify the change"], streaming: true }); + }); + it("turns empty reasoning lifecycle frames into a visible thinking item", () => { const parsed = transcriptToTaskChatItems([ { kind: "thinking", ts: "2026-08-21T12:00:00.000Z", text: "", lifecycle: "started", channel: "summary" }, @@ -613,8 +626,8 @@ describe("paperclip runner semantic channels", () => { ], { runId: "run-channels", running: true }); expect(parsed.map((item) => item.kind)).toEqual(["message", "thinking", "thinking", "message"]); expect(parsed[0]).toMatchObject({ interstitial: true, channel: "progress" }); - expect(parsed[1]).toMatchObject({ channel: "summary" }); - expect(parsed[2]).toMatchObject({ channel: "detail" }); + expect(parsed[1]).toMatchObject({ channel: "summary", streaming: false }); + expect(parsed[2]).toMatchObject({ channel: "detail", streaming: false }); expect(parsed[3]).toMatchObject({ interstitial: false, channel: "final" }); const history = settledRunChildren(parsed); expect(JSON.stringify(history)).not.toContain("Done."); diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index 603c6a4d26e..b3de31be283 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -333,9 +333,17 @@ export function transcriptToTaskChatItems( let messageIndex = -1; let messageChannel: "progress" | "final" | "unknown" | undefined; - const resetInline = () => { + const settleThinking = () => { + if (thinkingIndex >= 0) { + const item = items[thinkingIndex]; + if (item?.kind === "thinking") item.streaming = false; + } thinkingIndex = -1; thinkingChannel = undefined; + }; + + const resetInline = () => { + settleThinking(); messageIndex = -1; messageChannel = undefined; }; @@ -358,6 +366,10 @@ export function transcriptToTaskChatItems( if (entry.lifecycle === "completed") it.streaming = false; } } else { + // A different reasoning channel is a new logical activity. The + // previous block is historical even while the overall run remains + // live, so it must stop presenting as actively streaming. + settleThinking(); items.push({ id: `${runId}:think:${i}`, kind: "thinking", @@ -386,6 +398,7 @@ export function transcriptToTaskChatItems( } case "assistant": { if (!entry.text) break; + settleThinking(); const channel = entry.channel; if (messageIndex >= 0 && messageChannel === channel) { const it = items[messageIndex]; @@ -412,7 +425,6 @@ export function transcriptToTaskChatItems( }); messageIndex = items.length - 1; messageChannel = channel; - thinkingIndex = -1; } break; } diff --git a/ui/src/components/task-side-panel/TaskSidePanel.test.tsx b/ui/src/components/task-side-panel/TaskSidePanel.test.tsx index 9dc92fec528..3ad43e7615e 100644 --- a/ui/src/components/task-side-panel/TaskSidePanel.test.tsx +++ b/ui/src/components/task-side-panel/TaskSidePanel.test.tsx @@ -7,6 +7,7 @@ import type { Issue, IssueDocument } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "@/components/ui/tooltip"; import { + taskPanelDocumentTab, taskPanelPropertiesTab, writeTaskSidePanelState, } from "@/lib/task-side-panel-state"; @@ -157,10 +158,13 @@ describe("TaskSidePanel", () => { expect(container.textContent).toContain("Properties content"); }); - it("opens and selects Plan for a planning task", async () => { + it("does not create a Plan tab for planning mode before a plan exists", async () => { await render(panel({ issue: issue({ workMode: "planning" }) })); - expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Plan"); - expect(container.textContent).toContain("Plan content"); + expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties"); + expect(container.querySelector('[data-side-panel-tab-target="document:plan"]')).toBeNull(); + + await act(async () => container.querySelector('button[aria-label="Open a new tab"]')?.click()); + expect(Array.from(container.querySelectorAll('[role="option"]')).some((item) => item.textContent?.includes("Plan"))).toBe(false); }); it("opens a newly materialized plan until the user has interacted", async () => { @@ -194,6 +198,24 @@ describe("TaskSidePanel", () => { expect(container.getElementsByTagName("input")[0]?.getAttribute("aria-label")).toBe("Search tabs and resources…"); }); + it("removes a Plan tab persisted before a plan document existed", async () => { + writeTaskSidePanelState("user-1", "company-1", "task-1", { + state: { + tabs: [taskPanelPropertiesTab(), taskPanelDocumentTab("plan", "Plan")], + activeTabId: "document:plan", + }, + launcherOpen: false, + userInteracted: false, + autoPlanHandled: false, + updatedAt: 1, + }); + + await render(panel({ issue: issue({ workMode: "planning" }) })); + + expect(container.querySelector('[data-side-panel-tab-target="document:plan"]')).toBeNull(); + expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties"); + }); + it("opens an ordinary document deep link in a deduplicated document tab", async () => { fixture.documents = [issueDocument("brief", "Implementation brief")]; await render(panel({ documentDeepLink: { requestId: 1, documentKey: "brief" } })); diff --git a/ui/src/components/task-side-panel/TaskSidePanel.tsx b/ui/src/components/task-side-panel/TaskSidePanel.tsx index f70826f1d18..d9fde30adc7 100644 --- a/ui/src/components/task-side-panel/TaskSidePanel.tsx +++ b/ui/src/components/task-side-panel/TaskSidePanel.tsx @@ -213,15 +213,10 @@ export function TaskSidePanel({ const scrollPositionsRef = useRef(new Map()); const userInteractedRef = useRef(restoredRef.current?.userInteracted ?? false); const autoPlanHandledRef = useRef(restoredRef.current?.autoPlanHandled ?? false); - const initialState = useMemo(() => { - if (restoredRef.current) return restoredRef.current.state; - const tabs = [taskPanelPropertiesTab()]; - if (issue.workMode === "planning") tabs.push(taskPanelDocumentTab("plan", "Plan")); - return { - tabs, - activeTabId: issue.workMode === "planning" ? "document:plan" : "properties", - }; - }, [issue.workMode]); + const initialState = useMemo(() => restoredRef.current?.state ?? ({ + tabs: [taskPanelPropertiesTab()], + activeTabId: "properties", + }), []); const persist = useCallback((state: { tabs: SidePanelTabRecord[]; activeTabId: string | null }) => { writeTaskSidePanelState(accountScope, issue.companyId, issue.id, { @@ -243,6 +238,16 @@ export function TaskSidePanel({ setPaneHeaderSlot(document.getElementById(PROPERTIES_PANE_HEADER_SLOT_ID)); }, [inline]); + // Older clients persisted an empty Plan tab merely because the task was in + // planning mode. Remove that stale tab once the plan lookup confirms there + // is no document; a real plan will be opened by the materialization effect + // below when it arrives. + useEffect(() => { + if (planDocument !== null || !controller.tabs.some((tab) => tab.id === "document:plan")) return; + autoPlanHandledRef.current = false; + controller.closeTab("document:plan"); + }, [controller.closeTab, controller.tabs, planDocument]); + // Surface a newly materialized plan exactly once until the user takes manual // control of the tab set. Persisted empty/custom states therefore stay put. useEffect(() => { @@ -253,10 +258,11 @@ export function TaskSidePanel({ useEffect(() => { if (!documentDeepLink) return; + if (documentDeepLink.documentKey === "plan" && !planDocument) return; const document = documents.find((candidate) => candidate.key === documentDeepLink.documentKey); const label = document ? documentDisplayTitle(document) : documentDeepLink.documentKey === "plan" ? "Plan" : documentDeepLink.documentKey; controller.openTab(taskPanelDocumentTab(documentDeepLink.documentKey, label)); - }, [controller.openTab, documentDeepLink, documents]); + }, [controller.openTab, documentDeepLink, documents, planDocument]); // Existing URL-backed workspace links remain the external integration API. useEffect(() => { @@ -394,13 +400,13 @@ export function TaskSidePanel({ primary.push({ id: "files", label: "Files", icon: , shortcut: "G F", alreadyOpen: controller.tabs.some((tab) => tab.id === "files") }); } const documentItems: SidePanelLauncherItem[] = [ - { + ...(planDocument ? [{ id: "document:plan", - label: planDocument ? documentDisplayTitle(planDocument) : "Plan", - description: planDocument ? `Revision ${planDocument.latestRevisionNumber ?? 1}` : "No plan document yet", + label: documentDisplayTitle(planDocument), + description: `Revision ${planDocument.latestRevisionNumber ?? 1}`, icon: , alreadyOpen: controller.tabs.some((tab) => tab.id === "document:plan"), - }, + }] : []), ...documents .filter((document) => document.key !== "plan" && !isArtifactReviewDocumentKey(document.key)) .sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime()) @@ -414,8 +420,10 @@ export function TaskSidePanel({ ]; const sections: SidePanelLauncherSection[] = [ { id: "open", label: "Open", items: primary }, - { id: "documents", label: "Task documents", items: documentItems }, ]; + if (documentItems.length > 0) { + sections.push({ id: "documents", label: "Task documents", items: documentItems }); + } if (fileTabsEnabled) { const recentItems = recentFilesQuery.data?.state === "available" ? recentFilesQuery.data.items.filter((item) => item.kind === "file").slice(0, 5).map((item) => ({ diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index af19c08f828..0ce21d634b8 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -29,6 +29,7 @@ const mockIssuesApi = vi.hoisted(() => ({ listFeedbackVotes: vi.fn(), markRead: vi.fn(), update: vi.fn(), + resolveRecoveryAction: vi.fn(), previewTreeControl: vi.fn(), getTreeControlState: vi.fn(), listTreeHolds: vi.fn(), @@ -314,6 +315,7 @@ vi.mock("../components/TaskChatThread", () => ({ threadHeader?: ReactNode; onStopRun?: (runId: string) => Promise; stopRunLabel?: string; + onTryAgainNoLiveExecutionPath?: () => Promise | void; runFinalizationActions?: readonly { id: string; label: string; @@ -331,6 +333,15 @@ vi.mock("../components/TaskChatThread", () => ({ {props.stopRunLabel ?? "Stop run"} ) : null} + {props.onTryAgainNoLiveExecutionPath ? ( + + ) : null} {props.runFinalizationActions?.map((action) => ( - {panelVisible && !suppressPanelForFirstTask ? null : ( + {panelVisible && !suppressPanelUntilPlan ? null : ( { - if (suppressPanelForFirstTask && issue?.id) { - setFirstTaskPanelOverrideIssueId(issue.id); + if (suppressPanelUntilPlan && issue?.id) { + setPanelBeforePlanOverrideIssueId(issue.id); } setPanelVisible(true); }} @@ -5699,6 +5711,15 @@ export function IssueDetail() { resumeFromBacklogPending={ updateIssue.isPending && updateIssue.variables?.status === "todo" } + onTryAgainNoLiveExecutionPath={ + issue.status === "blocked" && issue.activeRecoveryAction + ? handleTryAgainNoLiveExecutionPath + : undefined + } + tryAgainNoLiveExecutionPathPending={ + resolveRecoveryAction.isPending && + resolveRecoveryAction.variables?.sourceIssueStatus === "todo" + } externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined} linkCaseReferences={casesChipsEnabled} />