mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
fix(ui): add retry action to stranded task notices
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d1cd9c37f4
commit
28fb9d5cef
5 files changed
+209
-24
No files matched your search
@@ -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 () => {}} />);
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user