Files
PaperClipAI/ui/src/components/SystemNotice.tsx
T
scotttongandPaperclip cc35c3c395 feat: structure and humanize recovery notices (#11075)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip posts system comments when automatic run recovery cannot
continue
> - These comments currently mix the main event with recovery
identifiers and routing details
> - The task chat shell also renders these comments as large raw text
blocks
> - Operators need a short explanation first and inspectable evidence on
demand
> - This pull request emits structured recovery notices and renders them
as compact humanized rows
> - The benefit is a quieter task thread that keeps the full recovery
evidence available

## Linked Issues or Issue Description

Related prior extraction source: #11070. This pull request replaces only
its structured recovery notice slice with a focused branch based on
current master.

**What existing behavior does this improve?**

Paperclip recovery escalations and the experimental task chat
system-comment renderer.

**Current behavior**

Recovery escalation comments put action identifiers, owner details, run
details, and failure codes into the visible markdown body. The task chat
shell renders the complete system comment as a large text block.

**Proposed behavior**

The server emits a short system notice with typed metadata sections. The
task chat shell classifies known recovery families and renders one
compact row. An operator can expand the row to inspect the full body and
metadata.

**Reason and benefit**

The main thread stays readable during repeated recovery activity. Typed
links and evidence remain available without exposing raw failure text in
the default view.

**Breaking changes**

The visible recovery comment body is shorter. Recovery action
deduplication now reads the structured metadata and still recognizes
legacy body markers. No API schema or database migration changes.

## What Changed

- Emit stranded recovery escalations with `system_notice` presentation
and typed recovery, owner, run, and failure-code metadata.
- Share bounded metadata row builders across recovery notice producers
and preserve legacy deduplication compatibility.
- Humanize known recovery notice families and render compact expandable
task-chat rows.
- Route system-authored comments ahead of derived agent authorship so
recovery notices do not appear as agent bubbles.
- Add focused server and UI regression coverage.

## Verification

- `pnpm check:token-gates` — 3/3 clean.
- `pnpm --filter @paperclipai/server typecheck` — passed.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- `pnpm --filter @paperclipai/shared typecheck` — passed.
- `pnpm --filter @paperclipai/shared exec vitest run
src/validators/issue.test.ts` — 32 tests passed.
- `pnpm --filter @paperclipai/server exec vitest run
src/services/recovery/stranded-notice.test.ts
src/__tests__/issue-recovery-actions.test.ts` — 57 tests passed.
- `pnpm --filter @paperclipai/server exec vitest run
src/services/recovery/successful-run-handoff.test.ts
src/services/recovery/stranded-notice.test.ts` — 39 tests passed.
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-process-recovery.test.ts -t 'escalates an
exhausted failed successful-run handoff without using generic
continuation recovery first|escalates an exhausted successful handoff
run that still leaves no disposition'` — 2 tests passed.
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-process-recovery.test.ts -t 'blocks assigned
todo work after the one automatic dispatch recovery was already used'` —
passed.
- `pnpm --filter @paperclipai/ui exec vitest run
src/lib/system-notice-humanizer.test.ts
src/components/task-chat/TaskChatSystemNotice.test.tsx
src/components/task-chat/task-chat-adapter.test.ts` — 15 tests passed.
- Storybook visual baselines were not updated because this chat-shell
path has no affected snapshot baseline. Focused rendering tests and
token gates cover this change.

## Risks

- Consumers that parse recovery action identifiers from comment markdown
must move to structured metadata. Server deduplication remains backward
compatible with legacy comments.
- The humanizer uses stable recovery-family phrases. Unknown notices use
a generic truncated first-sentence fallback.
- The UI changes only the experimental task chat presentation. The
stored comment body and expanded metadata remain available.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex with GPT-5, reasoning mode, repository tools, shell
execution, and GitHub integration. The runtime did not expose a
context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-07 18:41:52 -07:00

355 lines
12 KiB
TypeScript

import { useId, useState, type ReactNode } from "react";
import {
ChevronDown,
CircleAlert,
CircleCheck,
Info,
OctagonAlert,
TriangleAlert,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
export type SystemNoticeTone = "neutral" | "info" | "success" | "warning" | "danger";
export type SystemNoticeMetadataRow =
| { kind: "text"; label: string; value: string }
| { kind: "code"; label: string; value: string }
| { kind: "issue"; label: string; identifier: string; href?: string; title?: string }
| { kind: "agent"; label: string; name: string; href?: string }
| { kind: "run"; label: string; runId: string; href?: string; status?: string };
export type SystemNoticeMetadataSection = {
title?: string;
rows: SystemNoticeMetadataRow[];
};
export type SystemNoticeProps = {
tone?: SystemNoticeTone;
/** Short label that names the system actor + tone, e.g. "System warning". Required so tone is not color-only. */
label?: string;
/** Short visible body — one or two sentences from the system perspective. */
body: ReactNode;
/** Optional small chip for the originating run link. */
source?: { label: string; href?: string };
/** Hidden-by-default metadata. Renders the Details affordance only when present. */
metadata?: SystemNoticeMetadataSection[];
/** Force the details panel open initially. Defaults to false (collapsed). */
detailsDefaultOpen?: boolean;
/** Optional ISO timestamp shown next to the label. */
timestamp?: string;
className?: string;
};
type ToneTokens = {
container: string;
iconWrap: string;
icon: LucideIcon;
iconClass: string;
label: string;
divider: string;
};
const TONE_TOKENS: Record<SystemNoticeTone, ToneTokens> = {
neutral: {
container:
"border-border bg-muted/35 dark:bg-muted/20",
iconWrap: "bg-muted text-foreground/70",
icon: Info,
iconClass: "text-muted-foreground",
label: "text-muted-foreground",
divider: "border-border/70",
},
info: {
container:
"border-sky-300/70 bg-sky-50/70 dark:border-sky-500/30 dark:bg-sky-500/10",
iconWrap: "bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-200",
icon: Info,
iconClass: "text-sky-700 dark:text-sky-300",
label: "text-sky-800 dark:text-sky-200",
divider: "border-sky-300/50 dark:border-sky-500/30",
},
success: {
container:
"border-emerald-300/70 bg-emerald-50/70 dark:border-emerald-500/30 dark:bg-emerald-500/10",
iconWrap: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200",
icon: CircleCheck,
iconClass: "text-emerald-700 dark:text-emerald-300",
label: "text-emerald-800 dark:text-emerald-200",
divider: "border-emerald-300/50 dark:border-emerald-500/30",
},
warning: {
container:
"border-amber-300/70 bg-amber-50/80 dark:border-amber-500/30 dark:bg-amber-500/10",
iconWrap: "bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-200",
icon: TriangleAlert,
iconClass: "text-amber-700 dark:text-amber-300",
label: "text-amber-900 dark:text-amber-200",
divider: "border-amber-300/60 dark:border-amber-500/30",
},
danger: {
container:
"border-red-400/60 bg-red-50/80 dark:border-red-500/35 dark:bg-red-500/10",
iconWrap: "bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-200",
icon: OctagonAlert,
iconClass: "text-red-700 dark:text-red-300",
label: "text-red-900 dark:text-red-200",
divider: "border-red-400/50 dark:border-red-500/30",
},
};
function formatTimestamp(ts: string) {
try {
return new Date(ts).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
} catch {
return ts;
}
}
function MetadataRow({ row, tone }: { row: SystemNoticeMetadataRow; tone: ToneTokens }) {
return (
<div className="grid grid-cols-(--gtc-8) gap-x-3 gap-y-0.5 px-3 py-1.5 text-xs">
<div className="truncate text-(length:--text-micro) font-medium uppercase tracking-(--tracking-label) text-muted-foreground">
{row.label}
</div>
<div className="min-w-0 break-words text-foreground/90">
{(() => {
switch (row.kind) {
case "text":
return <span>{row.value}</span>;
case "code":
return (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-(length:--text-micro) text-foreground/80">
{row.value}
</code>
);
case "issue": {
const issueLabel = (
<>
<span>{row.identifier}</span>
{row.title ? (
<span className="text-muted-foreground">— {row.title}</span>
) : null}
</>
);
if (row.href) {
return (
<a
href={row.href}
className={cn(
"inline-flex items-center gap-1 rounded-sm font-medium underline-offset-2 hover:underline",
tone.label,
)}
>
{issueLabel}
</a>
);
}
return (
<span className={cn("inline-flex items-center gap-1 font-medium", tone.label)}>
{issueLabel}
</span>
);
}
case "agent":
if (row.href) {
return (
<a
href={row.href}
className={cn(
"inline-flex items-center gap-1 rounded-sm font-medium underline-offset-2 hover:underline",
tone.label,
)}
>
{row.name}
</a>
);
}
return (
<span className={cn("font-medium", tone.label)}>{row.name}</span>
);
case "run": {
const runShort = row.runId.length > 12 ? `${row.runId.slice(0, 8)}…` : row.runId;
const inner = (
<>
<code className="rounded bg-muted px-1.5 py-0.5 text-foreground/80">{runShort}</code>
{row.status ? (
<span className={cn("font-sans", tone.label)}>{row.status}</span>
) : null}
</>
);
if (row.href) {
return (
<a
href={row.href}
className="inline-flex items-center gap-2 rounded-sm font-mono text-(length:--text-micro) underline-offset-2 hover:underline"
>
{inner}
</a>
);
}
return (
<span className="inline-flex items-center gap-2 font-mono text-(length:--text-micro)">
{inner}
</span>
);
}
}
})()}
</div>
</div>
);
}
/**
* 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,
body,
source,
metadata,
detailsDefaultOpen = false,
timestamp,
className,
}: SystemNoticeProps) {
const tokens = TONE_TOKENS[tone];
const ToneIcon = tokens.icon;
const [open, setOpen] = useState(detailsDefaultOpen);
const detailsId = useId();
const hasDetails = Boolean(metadata && metadata.length > 0);
const resolvedLabel =
label ??
{
neutral: "System notice",
info: "System notice",
success: "System notice",
warning: "System warning",
danger: "System alert",
}[tone];
return (
<section
role="status"
aria-label={resolvedLabel}
className={cn(
"relative w-full overflow-hidden rounded-lg border text-sm shadow-(--shadow-extract-8)",
tokens.container,
className,
)}
>
<header className="flex items-start gap-3 px-3 py-2.5 sm:px-4">
<span
className={cn(
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md",
tokens.iconWrap,
)}
aria-hidden
>
<ToneIcon className={cn("h-4 w-4", tokens.iconClass)} />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow)">
<span className={tokens.label}>{resolvedLabel}</span>
{source ? (
<>
<span className="text-muted-foreground/60" aria-hidden>·</span>
{source.href ? (
<a
href={source.href}
className="rounded-sm font-medium normal-case tracking-normal text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
{source.label}
</a>
) : (
<span className="font-medium normal-case tracking-normal text-muted-foreground">
{source.label}
</span>
)}
</>
) : null}
{timestamp ? (
<>
<span className="text-muted-foreground/60" aria-hidden>·</span>
<span className="font-medium normal-case tracking-normal text-muted-foreground">
{formatTimestamp(timestamp)}
</span>
</>
) : null}
</div>
<div className="mt-1 break-words text-sm leading-6 text-foreground">{body}</div>
</div>
{hasDetails ? (
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={detailsId}
className={cn(
"ml-1 inline-flex h-7 shrink-0 items-center gap-1 rounded-md border border-transparent px-2 text-(length:--text-micro) font-medium uppercase tracking-(--tracking-eyebrow) text-muted-foreground transition-(--tp-background-color-border-color-color)",
"hover:border-border/70 hover:bg-background/70 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
)}
>
<span>{open ? "Hide details" : "Details"}</span>
<ChevronDown
className={cn(
"h-3.5 w-3.5 transition-transform duration-150",
open && "rotate-180",
)}
/>
</button>
) : null}
</header>
{hasDetails && open ? (
<div
id={detailsId}
className={cn(
"border-t bg-background/50 dark:bg-background/30",
tokens.divider,
)}
>
<SystemNoticeMetadataSections sections={metadata!} tone={tone} />
</div>
) : null}
</section>
);
}
export default SystemNotice;