diff --git a/ui/src/components/SystemNotice.tsx b/ui/src/components/SystemNotice.tsx index 343c397694d..291e380d262 100644 --- a/ui/src/components/SystemNotice.tsx +++ b/ui/src/components/SystemNotice.tsx @@ -206,6 +206,38 @@ function MetadataRow({ row, tone }: { row: SystemNoticeMetadataRow; tone: ToneTo ); } +/** + * The structured metadata rows shared by the boxed SystemNotice details panel + * and the chat shell's expandable system row (TaskChatSystemNotice, PAP-443). + */ +export function SystemNoticeMetadataSections({ + sections, + tone = "neutral", +}: { + sections: SystemNoticeMetadataSection[]; + tone?: SystemNoticeTone; +}) { + const tokens = TONE_TOKENS[tone]; + return ( +
+ {sections.map((section, sectionIdx) => ( +
+ {section.title ? ( +
+ {section.title} +
+ ) : null} +
+ {section.rows.map((row, rowIdx) => ( + + ))} +
+
+ ))} +
+ ); +} + export function SystemNotice({ tone = "neutral", label, @@ -312,22 +344,7 @@ export function SystemNotice({ tokens.divider, )} > -
- {metadata!.map((section, sectionIdx) => ( -
- {section.title ? ( -
- {section.title} -
- ) : null} -
- {section.rows.map((row, rowIdx) => ( - - ))} -
-
- ))} -
+ ) : null} diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index 24bc20378a0..4d81995710a 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -13,6 +13,7 @@ import { AttachmentTrigger, } from "@/components/ui/attachment"; import { extractAttachmentRefs, fileKindForName } from "./task-chat-attachments"; +import { TaskChatSystemNotice } from "./TaskChatSystemNotice"; import type { TaskChatMessageItem } from "./task-chat-model"; interface TaskChatBubbleProps { @@ -50,11 +51,8 @@ export function TaskChatBubble({ item, attachedTurn }: TaskChatBubbleProps) { } if (item.author === "system") { - return ( -
-

{item.text}

-
- ); + // Collapsed humanized one-liner, expandable to the full detail (PAP-443). + return ; } const isHuman = item.author === "human"; diff --git a/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx b/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx new file mode 100644 index 00000000000..d27b26c1d09 --- /dev/null +++ b/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ThemeProvider } from "@/context/ThemeContext"; +import { TaskChatSystemNotice } from "./TaskChatSystemNotice"; +import type { TaskChatMessageItem } from "./task-chat-model"; + +describe("TaskChatSystemNotice (PAP-443)", () => { + let container: HTMLDivElement; + let root: Root | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + flushSync(() => root?.unmount()); + root = null; + container.remove(); + }); + + const recoveryBody = + "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 = {}) { + const item: TaskChatMessageItem = { + id: "sys-1", + kind: "message", + author: "system", + text: recoveryBody, + createdAtIso: new Date(Date.now() - 5 * 60_000).toISOString(), + ...overrides, + }; + flushSync(() => + root!.render( + + + , + ), + ); + } + + function toggleButton() { + return container.querySelector( + '[data-testid="task-chat-system-notice"] button', + )!; + } + + it("collapses to a humanized one-liner with relative time and hides the raw body", () => { + renderNotice(); + const button = toggleButton(); + expect(button.getAttribute("aria-expanded")).toBe("false"); + expect(button.textContent).toContain("Task paused — a secret/config binding is missing"); + expect(button.textContent).toContain("5m ago"); + expect(container.textContent).not.toContain("source-scoped recovery action"); + expect( + container.querySelector('[data-testid="task-chat-system-notice-details"]'), + ).toBeNull(); + }); + + it("expands on click to the full markdown body and metadata sections", () => { + renderNotice({ + metadata: { + version: 1, + sections: [ + { title: "Failure", rows: [{ type: "code", label: "Code", code: "configuration_incomplete" }] }, + ], + }, + }); + flushSync(() => toggleButton().click()); + + expect(toggleButton().getAttribute("aria-expanded")).toBe("true"); + const details = container.querySelector('[data-testid="task-chat-system-notice-details"]'); + expect(details).not.toBeNull(); + expect(details!.textContent).toContain("required secret/env bindings are missing"); + expect(details!.textContent).toContain("Failure"); + expect(details!.textContent).toContain("configuration_incomplete"); + + // Collapses back — presentation-only fold, nothing lost. + flushSync(() => toggleButton().click()); + expect( + container.querySelector('[data-testid="task-chat-system-notice-details"]'), + ).toBeNull(); + }); + + it("respects presentation.detailsDefaultOpen", () => { + renderNotice({ + presentation: { + kind: "system_notice", + tone: "warning", + title: "Run recovery", + detailsDefaultOpen: true, + }, + }); + expect(toggleButton().getAttribute("aria-expanded")).toBe("true"); + expect(toggleButton().textContent).toContain("Run recovery"); + expect( + container.querySelector('[data-testid="task-chat-system-notice-details"]'), + ).not.toBeNull(); + }); +}); diff --git a/ui/src/components/task-chat/TaskChatSystemNotice.tsx b/ui/src/components/task-chat/TaskChatSystemNotice.tsx new file mode 100644 index 00000000000..8d317b58cdf --- /dev/null +++ b/ui/src/components/task-chat/TaskChatSystemNotice.tsx @@ -0,0 +1,98 @@ +import { useId, useState } from "react"; +import { + ChevronDown, + CircleCheck, + Info, + OctagonAlert, + TriangleAlert, + type LucideIcon, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { MarkdownBody } from "@/components/MarkdownBody"; +import { + SystemNoticeMetadataSections, + type SystemNoticeTone, +} from "@/components/SystemNotice"; +import { humanizeSystemNotice } from "@/lib/system-notice-humanizer"; +import { mapCommentMetadataToSystemNoticeSections } from "@/lib/system-notice-comment"; +import { timeAgo } from "@/lib/timeAgo"; +import type { TaskChatMessageItem } from "./task-chat-model"; + +const TONE_ICON: Record = { + neutral: Info, + info: Info, + success: CircleCheck, + warning: TriangleAlert, + danger: OctagonAlert, +}; + +const TONE_ICON_CLASS: Record = { + neutral: "text-muted-foreground", + info: "text-sky-600 dark:text-sky-400", + success: "text-emerald-600 dark:text-emerald-400", + warning: "text-amber-600 dark:text-amber-400", + danger: "text-red-600 dark:text-red-400", +}; + +/** + * System comment row in the chat shell (PAP-443 / PAP-442 Phase 1): collapsed, + * it reads as a quiet centered one-liner in TaskChatMarker's register — tone + * icon + humanized plain-English title + relative time + chevron — instead of + * a large gray paragraph of raw text. Expanding reveals the full + * markdown-rendered body plus any structured metadata sections; nothing is + * suppressed, only folded. + */ +export function TaskChatSystemNotice({ item }: { item: TaskChatMessageItem }) { + const [open, setOpen] = useState(Boolean(item.presentation?.detailsDefaultOpen)); + const detailsId = useId(); + const { title, tone, detail } = humanizeSystemNotice({ + body: item.text, + presentation: item.presentation, + }); + const sections = mapCommentMetadataToSystemNoticeSections(item.metadata); + const ToneIcon = TONE_ICON[tone]; + const relative = item.createdAtIso ? timeAgo(item.createdAtIso) : undefined; + + return ( +
+ + {open ? ( +
+
+ + {item.text} + +
+ {sections.length > 0 ? ( +
+ +
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/task-chat/task-chat-adapter.test.ts b/ui/src/components/task-chat/task-chat-adapter.test.ts index 3af1b5b7074..68dd6793ae3 100644 --- a/ui/src/components/task-chat/task-chat-adapter.test.ts +++ b/ui/src/components/task-chat/task-chat-adapter.test.ts @@ -22,4 +22,64 @@ describe("commentsToTaskChatItems", () => { expect(item.author).toBe("agent"); expect(item.interstitial).toBeUndefined(); }); + + it("classifies system comments as system even with a derivable run→agent linkage (PAP-443)", () => { + const comments = [ + { + id: "c-sys", + body: "Paperclip automatically retried dispatch, but it still has no live execution path.", + authorType: "system", + authorAgentId: null, + derivedAuthorAgentId: "agent-1", + createdAt: "2026-08-07T09:00:00.000Z", + } as unknown as IssueChatComment, + ]; + const items = commentsToTaskChatItems(comments); + expect(items).toHaveLength(1); + const item = items[0]; + if (item.kind !== "message") throw new Error("expected message item"); + expect(item.author).toBe("system"); + }); + + it("carries presentation, metadata, and the raw timestamp for system comments", () => { + const presentation = { + kind: "system_notice", + tone: "warning", + title: "Run recovery", + detailsDefaultOpen: true, + }; + const metadata = { + version: 1, + sections: [{ rows: [{ type: "text", label: "Reason", text: "quota" }] }], + }; + const comments = [ + { + id: "c-sys", + body: "Recovery notice.", + authorType: "system", + presentation, + metadata, + createdAt: new Date("2026-08-07T09:00:00.000Z"), + } as unknown as IssueChatComment, + { + id: "c-agent", + body: "Agent reply.", + authorType: "agent", + authorAgentId: "agent-1", + presentation: null, + metadata, + createdAt: "2026-08-07T09:01:00.000Z", + } as unknown as IssueChatComment, + ]; + const items = commentsToTaskChatItems(comments); + const [sys, agent] = items; + if (sys.kind !== "message" || agent.kind !== "message") throw new Error("expected messages"); + expect(sys.presentation).toEqual(presentation); + expect(sys.metadata).toEqual(metadata); + expect(sys.createdAtIso).toBe("2026-08-07T09:00:00.000Z"); + // Non-system authors keep the item lean — no structured notice fields. + expect(agent.presentation).toBeUndefined(); + expect(agent.metadata).toBeUndefined(); + expect(agent.createdAtIso).toBeUndefined(); + }); }); diff --git a/ui/src/components/task-chat/task-chat-adapter.ts b/ui/src/components/task-chat/task-chat-adapter.ts index cc1bfb635ee..99e9375faa3 100644 --- a/ui/src/components/task-chat/task-chat-adapter.ts +++ b/ui/src/components/task-chat/task-chat-adapter.ts @@ -28,10 +28,13 @@ function effectiveAgentId(comment: IssueChatComment): string | null { } function authorKind(comment: IssueChatComment): TaskChatAuthorKind { + // System authorship wins over any derivable run→agent linkage (PAP-443): + // recovery notices carry a derivedAuthorAgentId but must not render as + // agent bubbles. + if (comment.authorType === "system") return "system"; if (effectiveAgentId(comment)) return "agent"; if (comment.authorType === "user") return "human"; - if (comment.authorType === "agent") return "agent"; - return "system"; + return "agent"; } /** Shared bubble-footer time format ("2:34 PM") — also used by the description bubble (PAP-375). */ @@ -66,6 +69,12 @@ export function commentsToTaskChatItems( : comment.clientStatus === "pending" ? "pending" : undefined; + const createdAtIso = + comment.createdAt instanceof Date + ? comment.createdAt.toISOString() + : comment.createdAt + ? String(comment.createdAt) + : undefined; items.push({ id: comment.id || comment.clientId || `${comment.createdAt}`, kind: "message", @@ -76,6 +85,11 @@ export function commentsToTaskChatItems( optimistic, agentIcon, modeLabel: kind === "agent" ? ctx.agentModeLabelFor?.(comment) : undefined, + // System notices carry their structured hints through to the render + // layer (PAP-443); other authors keep the item lean. + presentation: kind === "system" ? comment.presentation ?? null : undefined, + metadata: kind === "system" ? comment.metadata ?? null : undefined, + createdAtIso: kind === "system" ? createdAtIso : undefined, }); } return items; diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index 00d99f35ad4..887ce7b2dd9 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -12,6 +12,7 @@ * inventory for the mapping. No timing/motion values live here — those are * CSS motion tokens in ui/src/index.css. */ +import type { IssueCommentMetadata, IssueCommentPresentation } from "@paperclipai/shared"; import type { IssueThreadInteraction } from "@/lib/issue-thread-interactions"; /** Who authored a thread row — the primary legibility signal. */ @@ -94,6 +95,15 @@ export interface TaskChatMessageItem { * Expanding still nests the tool history beneath the bubble. */ attachedTurn?: TaskChatTurnItem; + /** + * Structured system-notice fields (PAP-443), carried only for + * author === "system": the comment's server-authored presentation hints and + * metadata sections drive the collapsed one-line row + expandable detail. + */ + presentation?: IssueCommentPresentation | null; + metadata?: IssueCommentMetadata | null; + /** Raw comment timestamp (ISO) — the collapsed system row shows relative time. */ + createdAtIso?: string; } /** Collapsed chain-of-thought (ACP agent_thought_chunk). */ diff --git a/ui/src/lib/system-notice-humanizer.test.ts b/ui/src/lib/system-notice-humanizer.test.ts new file mode 100644 index 00000000000..88d8787dcef --- /dev/null +++ b/ui/src/lib/system-notice-humanizer.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { humanizeSystemNotice } from "./system-notice-humanizer"; + +describe("humanizeSystemNotice", () => { + it("uses presentation title and tone when present", () => { + const result = humanizeSystemNotice({ + body: "Paperclip automatically retried dispatch but it still has no live execution path.", + presentation: { + kind: "system_notice", + tone: "danger", + title: "Run recovery exhausted", + detailsDefaultOpen: false, + }, + }); + expect(result).toEqual({ title: "Run recovery exhausted", tone: "danger" }); + }); + + it("classifies claude_auth_required retry failures", () => { + const result = humanizeSystemNotice({ + body: + "Paperclip automatically retried continuation for this assigned `in_progress` issue during terminal run " + + "recovery, but it still has no live execution path. Latest retry failure: `claude_auth_required` - adapter " + + "handshake failed. Moving it to `blocked` so it is visible for intervention.", + }); + expect(result.title).toBe("Task paused — Claude needs re-authentication"); + expect(result.tone).toBe("warning"); + }); + + it("classifies configuration_incomplete failures", () => { + const result = humanizeSystemNotice({ + body: + "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.", + }); + expect(result.title).toBe("Task paused — a secret/config binding is missing"); + expect(result.tone).toBe("warning"); + }); + + it("classifies missing secret bindings even without a failure code", () => { + const result = humanizeSystemNotice({ + body: + "Paperclip stopped before dispatching the adapter because required secret/env bindings are missing. " + + "Moving it to `blocked` so an operator can bind the missing secret(s).", + }); + expect(result.title).toBe("Task paused — a secret/config binding is missing"); + }); + + it("classifies workspace validation failures", () => { + const result = humanizeSystemNotice({ + body: + "Paperclip stopped before launching the local adapter because the issue workspace failed validation. " + + "This prevents git-sensitive adapters from running in an unrelated fallback cwd.", + }); + expect(result.title).toBe("Task paused — workspace problem"); + expect(result.tone).toBe("warning"); + }); + + it("names the recovery owner from the markdown link for generic no-live-execution-path notices", () => { + const result = humanizeSystemNotice({ + body: + "Paperclip automatically retried dispatch for this assigned `todo` issue during terminal run recovery, " + + "but it still has no live execution path. Moving it to `blocked` so it is visible for intervention.\n\n" + + "- Recovery action: `ra-123`\n" + + "- Recovery owner: [Dana the Manager](/PAP/agents/agent-9)\n" + + "- Next action: the recovery owner should restore a live execution path.", + }); + expect(result.title).toBe("Task paused — waiting on Dana the Manager"); + expect(result.tone).toBe("warning"); + }); + + it("still humanizes no-live-execution-path notices without an owner link", () => { + const result = humanizeSystemNotice({ + body: + "Paperclip automatically retried dispatch, but it still has no live execution path. " + + "Moving it to `blocked` so it is visible for intervention.", + }); + expect(result.title).toBe("Task paused — waiting on a recovery owner"); + }); + + it("falls back to System update plus a truncated first sentence", () => { + const longFirstSentence = + "The heartbeat scheduler observed an unusual condition while reconciling this issue's run ledger against the " + + "control plane state and recorded the discrepancy for later review."; + const result = humanizeSystemNotice({ body: `${longFirstSentence} Second sentence here.` }); + expect(result.title).toBe("System update"); + expect(result.tone).toBe("neutral"); + expect(result.detail).toBeDefined(); + expect(result.detail!.length).toBeLessThanOrEqual(80); + expect(result.detail!.endsWith("…")).toBe(true); + }); + + it("keeps a short first sentence untruncated in the fallback detail", () => { + const result = humanizeSystemNotice({ body: "Run rescheduled. More context follows." }); + expect(result).toEqual({ + title: "System update", + tone: "neutral", + detail: "Run rescheduled.", + }); + }); +}); diff --git a/ui/src/lib/system-notice-humanizer.ts b/ui/src/lib/system-notice-humanizer.ts new file mode 100644 index 00000000000..6b2177eada8 --- /dev/null +++ b/ui/src/lib/system-notice-humanizer.ts @@ -0,0 +1,92 @@ +import type { IssueCommentPresentation } from "@paperclipai/shared"; +import type { SystemNoticeTone } from "../components/SystemNotice"; + +/** + * Pure classifier for system comments in the chat shell (PAP-443 / PAP-442 + * Phase 1): derive a short plain-English one-liner + tone for the collapsed + * row. Server-authored presentation hints win when present; otherwise the + * known recovery families are pattern-matched from the comment body. Nothing + * here suppresses data — the full body stays available behind the expand. + */ +export interface HumanizedSystemNotice { + title: string; + tone: SystemNoticeTone; + /** Optional muted trailing snippet for the generic fallback row. */ + detail?: string; +} + +const FALLBACK_TITLE = "System update"; +const DETAIL_MAX_CHARS = 80; + +/** Failure code the recovery comments embed as "Latest retry failure: `code`". */ +function retryFailureCode(body: string): string | null { + const match = body.match(/Latest retry failure: `([^`]+)`/); + return match ? match[1] : null; +} + +/** Owner display name from a "Recovery owner: [Name](/…/agents/…)" markdown link. */ +function recoveryOwnerName(body: string): string | null { + const match = body.match(/Recovery owner: \[([^\]]+)\]\(/); + const name = match?.[1]?.trim(); + return name && name.length > 0 ? name : null; +} + +function firstSentence(body: string): string | undefined { + const firstLine = body + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0); + if (!firstLine) return undefined; + const sentenceEnd = firstLine.search(/[.!?](\s|$)/); + const sentence = sentenceEnd >= 0 ? firstLine.slice(0, sentenceEnd + 1) : firstLine; + if (sentence.length <= DETAIL_MAX_CHARS) return sentence; + return `${sentence.slice(0, DETAIL_MAX_CHARS - 1).trimEnd()}…`; +} + +export function humanizeSystemNotice(input: { + body: string; + presentation?: IssueCommentPresentation | null; +}): HumanizedSystemNotice { + const presentationTitle = input.presentation?.title?.trim(); + const presentationTone = input.presentation?.tone; + if (presentationTitle) { + return { title: presentationTitle, tone: presentationTone ?? "neutral" }; + } + + const body = input.body ?? ""; + const code = retryFailureCode(body); + + if (code === "claude_auth_required") { + return { + title: "Task paused — Claude needs re-authentication", + tone: presentationTone ?? "warning", + }; + } + if (code === "configuration_incomplete" || body.includes("secret/env bindings are missing")) { + return { + title: "Task paused — a secret/config binding is missing", + tone: presentationTone ?? "warning", + }; + } + if (code === "workspace_validation_failed" || body.includes("workspace failed validation")) { + return { + title: "Task paused — workspace problem", + tone: presentationTone ?? "warning", + }; + } + if (body.includes("no live execution path")) { + const owner = recoveryOwnerName(body); + return { + title: owner + ? `Task paused — waiting on ${owner}` + : "Task paused — waiting on a recovery owner", + tone: presentationTone ?? "warning", + }; + } + + return { + title: FALLBACK_TITLE, + tone: presentationTone ?? "neutral", + detail: firstSentence(body), + }; +}