fix(ui): add retry action to stranded task notices

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-18 20:18:39 +00:00
1 parent d1cd9c37f4
commit 28fb9d5cef
5 files changed
+209 -24

No files matched your search

+119
View File
@@ -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(
<TaskChatThread
comments={[recoveryComment]}
onAdd={async () => {}}
issueId="issue-1"
issueStatus="blocked"
recoveryAction={recoveryAction}
onResolveRecoveryAction={onResolveRecoveryAction}
/>,
);
const disclosure = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-system-notice"] button[aria-controls]',
)!;
const retry = container.querySelector<HTMLButtonElement>(
'[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(
<TaskChatThread
comments={[{
...recoveryComment,
metadata: {
...recoveryComment.metadata,
sections: [{
title: "Recovery",
rows: [{ type: "key_value" as const, label: "Recovery action", value: "older-action" }],
}],
},
}]}
onAdd={async () => {}}
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(<TaskChatThread comments={[]} onAdd={async () => {}} />);
+42
View File
@@ -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<string> = new Set<string>();
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<typeof IssueChatThread>;
/**
@@ -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 (
<Button
type="button"
variant="outline"
size="xs"
data-testid="task-chat-recovery-try-again"
onClick={() => onResolveRecoveryAction("todo")}
>
Try again
</Button>
);
},
[onResolveRecoveryAction, recoveryAction],
);
const renderInteraction = useCallback(
(item: TaskChatInteractionItem) => (
<TaskChatInteractionCard
@@ -680,6 +721,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
renderInteraction={renderInteraction}
renderBrief={issueBrief ? () => <TaskChatDescriptionBubble brief={issueBrief} /> : undefined}
renderMessageActions={renderMessageActions}
renderSystemNoticeAction={renderSystemNoticeAction}
renderQueuedAction={renderQueuedAction}
tail={tailRunId || bottomBlockerLinks ? (
<>
+10 -2
View File
@@ -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<string | null>(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 <TaskChatSystemNotice item={item} />;
return <TaskChatSystemNotice item={item} action={systemNoticeAction} />;
}
const isHuman = item.author === "human";
@@ -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<SystemNoticeTone, string> = {
* 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 (
<div className="tc-enter-bubble flex flex-col items-center py-1" data-testid="task-chat-system-notice">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={detailsId}
className={cn(
"flex max-w-(--pct-85) items-center gap-1.5 rounded-md px-2 py-0.5 text-xs text-muted-foreground",
"hover:bg-muted/50 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
)}
>
<ToneIcon className={cn("h-3.5 w-3.5 shrink-0", TONE_ICON_CLASS[tone])} aria-hidden />
<span className="truncate font-medium">{title}</span>
{detail ? <span className="hidden truncate sm:inline">· {detail}</span> : null}
{relative ? <span className="shrink-0 text-muted-foreground/70">· {relative}</span> : null}
<ChevronDown
className={cn("tc-notice-chevron h-3.5 w-3.5 shrink-0 transition-transform", open && "rotate-180")}
aria-hidden
/>
</button>
<div className="flex max-w-(--pct-85) items-center justify-center gap-2">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={detailsId}
className={cn(
"flex min-w-0 items-center gap-1.5 rounded-md px-2 py-0.5 text-xs text-muted-foreground",
"hover:bg-muted/50 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
)}
>
<ToneIcon className={cn("h-3.5 w-3.5 shrink-0", TONE_ICON_CLASS[tone])} aria-hidden />
<span className="truncate font-medium">{title}</span>
{detail ? <span className="hidden truncate sm:inline">· {detail}</span> : null}
{relative ? <span className="shrink-0 text-muted-foreground/70">· {relative}</span> : null}
<ChevronDown
className={cn("tc-notice-chevron h-3.5 w-3.5 shrink-0 transition-transform", open && "rotate-180")}
aria-hidden
/>
</button>
{action}
</div>
{open ? (
<div
id={detailsId}
@@ -41,6 +41,8 @@ interface TaskChatThreadViewProps {
* fixtures omit it and the bubbles render actionless.
*/
renderMessageActions?: (item: TaskChatMessageItem) => 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(
<TaskChatBubble
item={item}
actions={actions}
systemNoticeAction={renderSystemNoticeAction?.(item)}
queuedAction={renderQueuedAction?.(item)}
attachedTurn={
item.attachedTurn ? (
@@ -139,6 +143,7 @@ export function TaskChatThreadView({
renderInteraction,
renderBrief,
renderMessageActions,
renderSystemNoticeAction,
renderQueuedAction,
tail,
contentKey,
@@ -160,6 +165,7 @@ export function TaskChatThreadView({
renderInteraction,
renderBrief,
renderMessageActions,
renderSystemNoticeAction,
renderQueuedAction,
)}
</div>