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 (
-
- );
+ // 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),
+ };
+}