diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 1f3ec7d5b87..1ebbbcf43d7 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -116,6 +116,125 @@ describe("TaskChatThread draft pass-through", () => { }); }); +describe("TaskChatThread recovery actions", () => { + const recoveryAction = { + id: "recovery-action-1", + companyId: "company-1", + sourceIssueId: "issue-1", + recoveryIssueId: null, + kind: "stranded_assigned_issue" as const, + status: "active" as const, + ownerType: "agent" as const, + ownerAgentId: "agent-2", + ownerUserId: null, + previousOwnerAgentId: "agent-1", + returnOwnerAgentId: "agent-1", + cause: "issue_continuation_needed", + fingerprint: "stranded-issue-1", + evidence: { sourceRunId: "run-1" }, + nextAction: "Restore a live execution path.", + wakePolicy: null, + monitorPolicy: null, + attemptCount: 1, + maxAttempts: 3, + timeoutAt: null, + lastAttemptAt: null, + outcome: null, + resolutionNote: null, + resolvedAt: null, + createdAt: "2026-08-18T10:00:00.000Z", + updatedAt: "2026-08-18T10:00:00.000Z", + }; + + const recoveryComment = { + id: "comment-recovery", + companyId: "company-1", + issueId: "issue-1", + authorType: "system" as const, + authorAgentId: null, + authorUserId: null, + body: + "Paperclip automatically retried continuation for this assigned `in_progress` issue after its live execution disappeared, but it still has no live execution path. Moving it to `blocked` so it is visible for intervention.", + presentation: { + kind: "system_notice" as const, + tone: "danger" as const, + title: "No live execution path", + detailsDefaultOpen: false, + }, + metadata: { + version: 1 as const, + sourceRunId: "run-1", + sections: [{ + title: "Recovery", + rows: [{ type: "key_value" as const, label: "Recovery action", value: recoveryAction.id }], + }], + }, + createdAt: new Date("2026-08-18T10:00:00.000Z"), + updatedAt: new Date("2026-08-18T10:00:00.000Z"), + }; + + it("keeps Try again visible in folded and expanded recovery notices and resolves to todo", () => { + const onResolveRecoveryAction = vi.fn(); + render( + {}} + issueId="issue-1" + issueStatus="blocked" + recoveryAction={recoveryAction} + onResolveRecoveryAction={onResolveRecoveryAction} + />, + ); + + const disclosure = container.querySelector( + '[data-testid="task-chat-system-notice"] button[aria-controls]', + )!; + const retry = container.querySelector( + '[data-testid="task-chat-recovery-try-again"]', + )!; + + expect(disclosure.getAttribute("aria-expanded")).toBe("false"); + expect(retry.textContent).toBe("Try again"); + + flushSync(() => retry.click()); + expect(onResolveRecoveryAction).toHaveBeenLastCalledWith("todo"); + expect(disclosure.getAttribute("aria-expanded")).toBe("false"); + + flushSync(() => disclosure.click()); + expect(disclosure.getAttribute("aria-expanded")).toBe("true"); + expect(container.querySelector('[data-testid="task-chat-system-notice-details"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="task-chat-recovery-try-again"]')).not.toBeNull(); + + flushSync(() => retry.click()); + expect(onResolveRecoveryAction).toHaveBeenCalledTimes(2); + expect(onResolveRecoveryAction).toHaveBeenLastCalledWith("todo"); + }); + + it("does not attach the active action to a stale recovery notice", () => { + render( + {}} + issueId="issue-1" + issueStatus="blocked" + recoveryAction={recoveryAction} + onResolveRecoveryAction={() => {}} + />, + ); + + expect(container.querySelector('[data-testid="task-chat-recovery-try-again"]')).toBeNull(); + }); +}); + describe("TaskChatThread composer alignment (PAP-498)", () => { it("matches the thread width on mobile and stays narrower on larger screens", () => { render( {}} />); diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index fbbc444d70e..3c7c4feb5c6 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -65,6 +65,17 @@ function toMs(value: Date | string | null | undefined): number { const SETTLING_TAIL_MAX_MS = 15_000; const EMPTY_LIVE_ISSUE_IDS: ReadonlySet = new Set(); +function systemNoticeReferencesRecoveryAction(item: TaskChatMessageItem, actionId: string): boolean { + if (item.author !== "system") return false; + return item.metadata?.sections.some((section) => + section.rows.some((row) => + row.type === "key_value" && + row.label.trim().toLowerCase() === "recovery action" && + row.value === actionId, + ), + ) ?? false; +} + export type TaskChatThreadProps = ComponentProps; /** @@ -136,6 +147,8 @@ export function TaskChatThread(props: TaskChatThreadProps) { blockedBy = [], blockerAttention, liveIssueIds, + recoveryAction, + onResolveRecoveryAction, } = props; const liveWorkLinks = useMemo( @@ -612,6 +625,34 @@ export function TaskChatThread(props: TaskChatThreadProps) { [interruptingQueuedRunId, onInterruptQueued], ); + const renderSystemNoticeAction = useCallback( + (item: TaskChatMessageItem) => { + if ( + !recoveryAction || + recoveryAction.kind !== "stranded_assigned_issue" || + recoveryAction.status === "resolved" || + recoveryAction.status === "cancelled" || + !onResolveRecoveryAction || + !systemNoticeReferencesRecoveryAction(item, recoveryAction.id) + ) { + return null; + } + + return ( + + ); + }, + [onResolveRecoveryAction, recoveryAction], + ); + const renderInteraction = useCallback( (item: TaskChatInteractionItem) => ( : undefined} renderMessageActions={renderMessageActions} + renderSystemNoticeAction={renderSystemNoticeAction} renderQueuedAction={renderQueuedAction} tail={tailRunId || bottomBlockerLinks ? ( <> diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index 45268b73096..8ba84f6115d 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -20,6 +20,8 @@ import type { TaskChatMessageItem } from "./task-chat-model"; interface TaskChatBubbleProps { item: TaskChatMessageItem; + /** Action rendered beside a system notice's disclosure control. */ + systemNoticeAction?: ReactNode; /** Action shown beside the queued state for an interruptible message. */ queuedAction?: ReactNode; /** @@ -63,7 +65,13 @@ function galleryItemForImage(src: string, name?: string): GalleryMediaItem { }; } -export function TaskChatBubble({ item, queuedAction, attachedTurn, actions }: TaskChatBubbleProps) { +export function TaskChatBubble({ + item, + queuedAction, + attachedTurn, + actions, + systemNoticeAction, +}: TaskChatBubbleProps) { // Clicking an embedded image opens the full-screen lightbox (with download); // arrow keys walk across the other images in the same bubble. const [lightboxSrc, setLightboxSrc] = useState(null); @@ -77,7 +85,7 @@ export function TaskChatBubble({ item, queuedAction, attachedTurn, actions }: Ta 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/TaskChatSystemNotice.tsx b/ui/src/components/task-chat/TaskChatSystemNotice.tsx index da9c40bd1bf..40bcc5a9157 100644 --- a/ui/src/components/task-chat/TaskChatSystemNotice.tsx +++ b/ui/src/components/task-chat/TaskChatSystemNotice.tsx @@ -1,4 +1,4 @@ -import { useId, useState } from "react"; +import { useId, useState, type ReactNode } from "react"; import { ChevronDown, CircleCheck, @@ -42,7 +42,14 @@ 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, + action, +}: { + item: TaskChatMessageItem; + /** Optional action that remains reachable whether the notice is folded or expanded. */ + action?: ReactNode; +}) { const [open, setOpen] = useState(Boolean(item.presentation?.detailsDefaultOpen)); const detailsId = useId(); const { title, tone, detail } = humanizeSystemNotice({ @@ -57,26 +64,29 @@ export function TaskChatSystemNotice({ item }: { item: TaskChatMessageItem }) { return (
- +
+ + {action} +
{open ? (
ReactNode; + /** Renders an action beside a system notice's disclosure control. */ + renderSystemNoticeAction?: (item: TaskChatMessageItem) => ReactNode; /** Renders an interrupt action beside a queued human message. */ renderQueuedAction?: (item: TaskChatMessageItem) => ReactNode; /** Content appended inside the transcript scroller after the settled thread. */ @@ -58,6 +60,7 @@ function renderItem( renderInteraction?: (item: TaskChatInteractionItem) => ReactNode, renderBrief?: () => ReactNode, renderMessageActions?: (item: TaskChatMessageItem) => ReactNode, + renderSystemNoticeAction?: (item: TaskChatMessageItem) => ReactNode, renderQueuedAction?: (item: TaskChatMessageItem) => ReactNode, ) { switch (item.kind) { @@ -72,6 +75,7 @@ function renderItem(