feat(ui): collapse chat-shell system notices to humanized one-line rows

System comments in the experimental chat shell now render as quiet centered
one-liners (tone icon + plain-English title + relative time + chevron) that
expand on click to the full markdown body and structured metadata sections,
instead of large gray paragraphs of raw recovery text.

- Thread presentation/metadata/createdAtIso through TaskChatMessageItem and
  the comment adapter (system comments only).
- Fix authorKind ordering: authorType system now wins over a derivable
  run->agent linkage, so recovery notices no longer render as agent bubbles.
- New pure classifier system-notice-humanizer.ts covering the recovery
  families (claude_auth_required, configuration_incomplete, workspace
  validation, no-live-execution-path with recovery-owner extraction) plus a
  System update fallback with truncated first sentence.
- New TaskChatSystemNotice component; expanded state reuses the SystemNotice
  metadata-section rows via a newly extracted SystemNoticeMetadataSections.
- Collapse is presentation-only; respects presentation.detailsDefaultOpen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Fable 5 committed 2026-08-07 16:57:53 -07:00
1 parent c0a31ccf7c
commit e907db5926
9 files changed
+518 -23

No files matched your search

+33 -16
View File
@@ -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 (
<div className="divide-y divide-border/50 px-1 py-1">
{sections.map((section, sectionIdx) => (
<div key={sectionIdx} className="py-1.5 first:pt-2 last:pb-2">
{section.title ? (
<div className="px-3 pb-1 pt-0.5 text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-caps) text-muted-foreground">
{section.title}
</div>
) : null}
<div>
{section.rows.map((row, rowIdx) => (
<MetadataRow key={rowIdx} row={row} tone={tokens} />
))}
</div>
</div>
))}
</div>
);
}
export function SystemNotice({
tone = "neutral",
label,
@@ -312,22 +344,7 @@ export function SystemNotice({
tokens.divider,
)}
>
<div className="divide-y divide-border/50 px-1 py-1">
{metadata!.map((section, sectionIdx) => (
<div key={sectionIdx} className="py-1.5 first:pt-2 last:pb-2">
{section.title ? (
<div className="px-3 pb-1 pt-0.5 text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-caps) text-muted-foreground">
{section.title}
</div>
) : null}
<div>
{section.rows.map((row, rowIdx) => (
<MetadataRow key={rowIdx} row={row} tone={tokens} />
))}
</div>
</div>
))}
</div>
<SystemNoticeMetadataSections sections={metadata!} tone={tone} />
</div>
) : null}
</section>
@@ -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 (
<div className="tc-enter-bubble flex justify-center py-1">
<p className="max-w-(--pct-85) text-center text-xs text-muted-foreground">{item.text}</p>
</div>
);
// Collapsed humanized one-liner, expandable to the full detail (PAP-443).
return <TaskChatSystemNotice item={item} />;
}
const isHuman = item.author === "human";
@@ -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<TaskChatMessageItem> = {}) {
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(
<ThemeProvider>
<TaskChatSystemNotice item={item} />
</ThemeProvider>,
),
);
}
function toggleButton() {
return container.querySelector<HTMLButtonElement>(
'[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();
});
});
@@ -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<SystemNoticeTone, LucideIcon> = {
neutral: Info,
info: Info,
success: CircleCheck,
warning: TriangleAlert,
danger: OctagonAlert,
};
const TONE_ICON_CLASS: Record<SystemNoticeTone, string> = {
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 (
<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("h-3.5 w-3.5 shrink-0 transition-transform duration-150", open && "rotate-180")}
aria-hidden
/>
</button>
{open ? (
<div
id={detailsId}
data-testid="task-chat-system-notice-details"
className="mt-1 w-full max-w-(--pct-85) overflow-hidden rounded-lg border border-border bg-muted/25 text-left text-sm dark:bg-muted/15"
>
<div className="px-3 py-2.5 text-foreground/90">
<MarkdownBody softBreaks linkIssueReferences>
{item.text}
</MarkdownBody>
</div>
{sections.length > 0 ? (
<div className="border-t border-border/70 bg-background/50 dark:bg-background/30">
<SystemNoticeMetadataSections sections={sections} tone={tone} />
</div>
) : null}
</div>
) : null}
</div>
);
}
@@ -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();
});
});
@@ -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;
@@ -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). */
+100
View File
@@ -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.",
});
});
});
+92
View File
@@ -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),
};
}