Add recovery handoff system notices (#5289)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Agent runs can end productively while the source issue still lacks a
durable final disposition.
> - That leaves the control plane unsure whether to resume, escalate, or
close the work.
> - Issue comments also need a presentation contract so system-authored
recovery notices can render as first-class thread messages without
overloading normal comments.
> - This pull request adds successful-run handoff recovery, comment
presentation metadata, and system notice rendering.
> - The benefit is stricter task liveness with clearer operator-facing
recovery state.

## What Changed

- Added successful-run handoff decisions, wake payloads, escalation
behavior, and recovery tests.
- Added issue comment presentation metadata with migration
`0078_white_darwin.sql` and shared/server/company portability support.
- Rendered recovery/system notices in issue chat with dedicated UI
components, fixtures, tests, and storybook/lab coverage.
- Included the current recovery model-profile hint patch so automatic
recovery follow-ups use the cheap profile.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run
server/src/services/recovery/successful-run-handoff.test.ts
ui/src/components/SystemNotice.test.tsx
ui/src/lib/system-notice-comment.test.ts
ui/src/components/IssueChatThreadSystemNotice.test.tsx`

## Risks

- Migration-bearing PR: merge this before any other branch that might
later add a migration.
- The branch touches both recovery services and issue-thread rendering,
so review should pay attention to recovery wake idempotency and comment
metadata compatibility.

## Model Used

- OpenAI GPT-5 Codex via Paperclip `codex_local` adapter, with
shell/git/GitHub CLI tool use.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-05-06 06:05:58 -05:00 committed by GitHub
parent 50db8c01d2
commit 454edfe81e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 21919 additions and 125 deletions

View file

@ -192,6 +192,9 @@ describe("CommentThread", () => {
authorAgentId: null,
authorUserId: "local-board",
body: "Please continue validation.",
authorType: "user",
presentation: null,
metadata: null,
followUpRequested: true,
createdAt: new Date("2026-03-11T10:00:00.000Z"),
updatedAt: new Date("2026-03-11T10:00:00.000Z"),
@ -349,6 +352,9 @@ describe("CommentThread", () => {
authorAgentId: null,
authorUserId: "user-1",
body: "Hello from the comment body",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-11T11:00:00.000Z"),
updatedAt: new Date("2026-03-11T11:00:00.000Z"),
}]}

View file

@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { AnchorHTMLAttributes, ReactElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { IssueBlockedNotice } from "./IssueBlockedNotice";
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
root = null;
container?.remove();
container = null;
});
function render(element: ReactElement) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(element));
return container;
}
describe("IssueBlockedNotice", () => {
it("renders a successful-run next-step notice without requiring blockers", () => {
const node = render(
<IssueBlockedNotice
issueStatus="in_progress"
blockers={[]}
agentName="CodexCoder"
successfulRunHandoff={{
state: "required",
required: true,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: "Updated the plan and left follow-up work.",
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
expect(node.textContent).toContain("This issue still needs a next step.");
expect(node.textContent).toContain("Corrective wake queued for CodexCoder");
expect(node.textContent).toContain("Detected progress: Updated the plan");
expect(node.textContent).not.toContain("Work on this issue is blocked until");
expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull();
});
});

View file

@ -1,5 +1,6 @@
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
import type { IssueBlockerAttention, IssueRelationIssueSummary, SuccessfulRunHandoffState } from "@paperclipai/shared";
import { AlertTriangle } from "lucide-react";
import { Link } from "@/lib/router";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
@ -7,12 +8,17 @@ export function IssueBlockedNotice({
issueStatus,
blockers,
blockerAttention,
successfulRunHandoff,
agentName,
}: {
issueStatus?: string;
blockers: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
agentName?: string | null;
}) {
if (blockers.length === 0 && issueStatus !== "blocked") return null;
const showSuccessfulRunHandoff = successfulRunHandoff?.required === true;
if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null;
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
const terminalBlockers = blockers
@ -61,39 +67,87 @@ export function IssueBlockedNotice({
return (
<div
data-blocker-attention-state={blockerAttention?.state}
data-successful-run-handoff={showSuccessfulRunHandoff ? "required" : undefined}
className="mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="min-w-0 space-y-1.5">
<p className="leading-5">
{blockers.length > 0
? isStalled
? stalledLeafBlockers.length > 1
? <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.</>
: <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.</>
: <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage.</>
: <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.</>}
</p>
{blockers.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blockers.map(renderBlockerChip)}
</div>
{showSuccessfulRunHandoff ? (
<>
<p className="font-medium leading-5">This issue still needs a next step.</p>
<p className="leading-5">
A run finished successfully, but this issue is still open in{" "}
<code className="rounded bg-amber-100 px-1 py-0.5 text-[12px] dark:bg-amber-400/15">
in_progress
</code>{" "}
with no clear owner for the next action.
</p>
<ul className="list-disc space-y-1 pl-5 text-xs leading-5 text-amber-900 dark:text-amber-100">
<li>Mark it done or cancelled.</li>
<li>Send it for review or ask for input.</li>
<li>Mark it blocked with a blocker owner.</li>
<li>Delegate follow-up work or queue a continuation.</li>
</ul>
<div className="flex flex-wrap gap-1.5 text-xs">
{successfulRunHandoff.sourceRunId && successfulRunHandoff.assigneeAgentId ? (
<Link
to={`/agents/${successfulRunHandoff.assigneeAgentId}/runs/${successfulRunHandoff.sourceRunId}`}
className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-amber-950 hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
>
run {successfulRunHandoff.sourceRunId.slice(0, 8)}
</Link>
) : successfulRunHandoff.sourceRunId ? (
<span className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-amber-950 dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100">
run {successfulRunHandoff.sourceRunId.slice(0, 8)}
</span>
) : null}
<span className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 text-amber-900 dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100">
Corrective wake queued for {agentName ?? "the assignee"}
</span>
</div>
{successfulRunHandoff.detectedProgressSummary ? (
<p className="text-xs leading-5 text-amber-800 dark:text-amber-200">
Detected progress: {successfulRunHandoff.detectedProgressSummary}
</p>
) : null}
</>
) : null}
{showStalledRow ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Stalled in review
</span>
{stalledLeafBlockers.map(renderBlockerChip)}
</div>
) : terminalBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Ultimately waiting on
</span>
{terminalBlockers.map(renderBlockerChip)}
</div>
{showSuccessfulRunHandoff && (blockers.length > 0 || issueStatus === "blocked") ? (
<div className="border-t border-amber-300/60 pt-1.5 dark:border-amber-500/30" />
) : null}
{blockers.length > 0 || issueStatus === "blocked" ? (
<>
<p className="leading-5">
{blockers.length > 0
? isStalled
? stalledLeafBlockers.length > 1
? <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.</>
: <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.</>
: <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage.</>
: <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.</>}
</p>
{blockers.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blockers.map(renderBlockerChip)}
</div>
) : null}
{showStalledRow ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Stalled in review
</span>
{stalledLeafBlockers.map(renderBlockerChip)}
</div>
) : terminalBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Ultimately waiting on
</span>
{terminalBlockers.map(renderBlockerChip)}
</div>
) : null}
</>
) : null}
</div>
</div>

View file

@ -836,6 +836,9 @@ describe("IssueChatThread", () => {
authorAgentId: "agent-perf-codex",
authorUserId: null,
body: "Older loaded comment",
authorType: "agent" as const,
presentation: null,
metadata: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
};
@ -1056,6 +1059,9 @@ describe("IssueChatThread", () => {
authorAgentId: "agent-1",
authorUserId: null,
body: "Agent summary with **markdown**",
authorType: "agent" as const,
presentation: null,
metadata: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
}];
@ -1135,6 +1141,9 @@ describe("IssueChatThread", () => {
authorAgentId: null,
authorUserId: "local-board",
body: "Please continue validation.",
authorType: "user",
presentation: null,
metadata: null,
followUpRequested: true,
createdAt: new Date("2026-03-11T10:00:00.000Z"),
updatedAt: new Date("2026-03-11T10:00:00.000Z"),
@ -1588,6 +1597,9 @@ describe("IssueChatThread", () => {
authorAgentId: "agent-1",
authorUserId: null,
body: "Agent summary",
authorType: "agent",
presentation: null,
metadata: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
}]}
@ -1624,6 +1636,9 @@ describe("IssueChatThread", () => {
authorAgentId: null,
authorUserId: "user-1",
body: "Need a quick update",
authorType: "user",
presentation: null,
metadata: null,
queueState: "queued",
queueReason: "hold",
createdAt: new Date("2026-04-06T12:00:00.000Z"),
@ -1653,6 +1668,9 @@ describe("IssueChatThread", () => {
authorAgentId: null,
authorUserId: "user-1",
body: "Queue behind active run",
authorType: "user",
presentation: null,
metadata: null,
queueState: "queued",
queueReason: "active_run",
createdAt: new Date("2026-04-06T12:01:00.000Z"),
@ -1997,6 +2015,9 @@ describe("IssueChatThread", () => {
authorAgentId: null,
authorUserId: "user-1",
body: "hello",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-04-22T12:00:00.000Z"),
updatedAt: new Date("2026-04-22T12:00:00.000Z"),
}]}

View file

@ -36,6 +36,7 @@ import type {
IssueAttachment,
IssueBlockerAttention,
IssueRelationIssueSummary,
SuccessfulRunHandoffState,
} from "@paperclipai/shared";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
@ -93,6 +94,16 @@ import { formatAssigneeUserLabel } from "../lib/assignees";
import { useOptionalToastActions } from "../context/ToastContext";
import type { CompanyUserProfile } from "../lib/company-members";
import { timeAgo } from "../lib/timeAgo";
import {
isSuccessfulRunHandoffComment,
isSuccessfulRunHandoffEscalationComment,
} from "../lib/successful-run-handoff";
import { SystemNotice } from "./SystemNotice";
import { buildSystemNoticeProps } from "../lib/system-notice-comment";
import type {
IssueCommentMetadata,
IssueCommentPresentation,
} from "@paperclipai/shared";
import {
describeToolInput,
displayToolName,
@ -264,6 +275,7 @@ interface IssueChatThreadProps {
activeRun?: ActiveRunForIssue | null;
blockedBy?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
companyId?: string | null;
projectId?: string | null;
issueStatus?: string;
@ -583,6 +595,9 @@ function commentDateLabel(date: Date | string | undefined): string {
const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
const { onImageClick } = useContext(IssueChatCtx);
if (isSuccessfulRunHandoffComment(text)) {
return <SuccessfulRunHandoffCommentCallout text={text} recessed={recessed} onImageClick={onImageClick} />;
}
return (
<MarkdownBody
className="text-sm leading-6"
@ -595,6 +610,41 @@ const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: {
);
});
export function SuccessfulRunHandoffCommentCallout({
text,
recessed,
onImageClick,
}: {
text: string;
recessed?: boolean;
onImageClick?: (src: string) => void;
}) {
const escalated = isSuccessfulRunHandoffEscalationComment(text);
return (
<div
className={cn(
"rounded-md border px-3 py-2.5 text-sm shadow-sm",
escalated
? "border-red-500/35 bg-red-500/10 text-red-950 dark:text-red-100"
: "border-amber-300/70 bg-amber-50/90 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100",
)}
style={recessed ? { opacity: 0.55 } : undefined}
>
<div className="flex items-start gap-2">
<AlertTriangle
className={cn(
"mt-1 h-4 w-4 shrink-0",
escalated ? "text-red-600 dark:text-red-300" : "text-amber-600 dark:text-amber-300",
)}
/>
<MarkdownBody className="min-w-0 text-sm leading-6" softBreaks onImageClick={onImageClick}>
{text}
</MarkdownBody>
</div>
</div>
);
}
function humanizeValue(value: string | null) {
if (!value) return "None";
return value.replace(/_/g, " ");
@ -1901,6 +1951,127 @@ function ExpiredRequestConfirmationActivity({
);
}
function isIssueCommentPresentation(value: unknown): value is IssueCommentPresentation {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return v.kind === "system_notice" || v.kind === "message";
}
function isIssueCommentMetadata(value: unknown): value is IssueCommentMetadata {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return v.version === 1 && Array.isArray(v.sections);
}
function SystemNoticeCommentRow({
message,
anchorId,
}: {
message: ThreadMessage;
anchorId?: string;
}) {
const { onImageClick, agentMap } = useContext(IssueChatCtx);
const custom = message.metadata.custom as Record<string, unknown>;
const presentation = isIssueCommentPresentation(custom.presentation) ? custom.presentation : null;
const commentMetadata = isIssueCommentMetadata(custom.commentMetadata) ? custom.commentMetadata : null;
const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null;
const runId = typeof custom.runId === "string" ? custom.runId : null;
const authorType = typeof custom.authorType === "string" ? custom.authorType : null;
const authorName = typeof custom.authorName === "string" ? custom.authorName : null;
const bodyText = message.content
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("\n\n");
const [copied, setCopied] = useState(false);
const [copiedLink, setCopiedLink] = useState(false);
const source = (() => {
const runAgentName = runAgentId ? agentMap?.get(runAgentId)?.name ?? null : null;
if (authorType === "system") {
const label = runAgentName ?? "Paperclip";
if (runAgentId && runId) return { label, href: `/agents/${runAgentId}/runs/${runId}` };
return { label };
}
if (runAgentId && runId) {
return { label: authorName ?? runAgentName ?? "Paperclip", href: `/agents/${runAgentId}/runs/${runId}` };
}
if (authorName) return { label: authorName };
return undefined;
})();
const props = buildSystemNoticeProps({
presentation,
metadata: commentMetadata,
body: (
<MarkdownBody className="text-sm leading-6" softBreaks onImageClick={onImageClick}>
{bodyText}
</MarkdownBody>
),
timestamp: message.createdAt ? new Date(message.createdAt).toISOString() : undefined,
source,
runAgentId,
});
const handleCopy = () => {
void navigator.clipboard.writeText(bodyText).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const handleCopyLink = () => {
if (!anchorId || typeof window === "undefined") return;
const url = `${window.location.origin}${window.location.pathname}#${anchorId}`;
void navigator.clipboard.writeText(url).then(() => {
setCopiedLink(true);
setTimeout(() => setCopiedLink(false), 2000);
});
};
return (
<div id={anchorId} className="group">
<div className="py-1">
<SystemNotice {...props} />
<div className="mt-1 flex items-center justify-end gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<a
href={anchorId ? `#${anchorId}` : undefined}
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
>
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
</a>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{message.createdAt ? formatDateTime(message.createdAt) : ""}
</TooltipContent>
</Tooltip>
{anchorId ? (
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title="Copy link"
aria-label="Copy link to system notice"
onClick={handleCopyLink}
>
{copiedLink ? <Check className="h-3.5 w-3.5" /> : <Paperclip className="h-3.5 w-3.5" />}
</button>
) : null}
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title="Copy notice text"
aria-label="Copy system notice"
onClick={handleCopy}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
</div>
</div>
);
}
function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
const {
agentMap,
@ -1933,6 +2104,15 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
? custom.interaction
: null;
if (custom.kind === "system_notice") {
return (
<SystemNoticeCommentRow
message={message}
anchorId={anchorId}
/>
);
}
if (custom.kind === "interaction" && interaction) {
if (interaction.kind === "request_confirmation" && interaction.status === "expired") {
return (
@ -3077,6 +3257,7 @@ export function IssueChatThread({
activeRun = null,
blockedBy = [],
blockerAttention = null,
successfulRunHandoff = null,
companyId,
projectId,
issueStatus,
@ -3700,6 +3881,12 @@ export function IssueChatThread({
issueStatus={issueStatus}
blockers={unresolvedBlockers}
blockerAttention={blockerAttention}
successfulRunHandoff={successfulRunHandoff}
agentName={
successfulRunHandoff?.assigneeAgentId
? agentMap?.get(successfulRunHandoff.assigneeAgentId)?.name ?? null
: null
}
/>
<IssueAssigneePausedNotice agent={assignedAgent} />
</div>

View file

@ -0,0 +1,398 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueChatThread } from "./IssueChatThread";
import type { IssueChatComment } from "../lib/issue-chat-messages";
import type { Agent } from "@paperclipai/shared";
vi.mock("@assistant-ui/react", () => ({
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
useAui: () => ({ thread: () => ({ append: async () => undefined }) }),
}));
vi.mock("./transcript/useLiveRunTranscripts", () => ({
useLiveRunTranscripts: () => ({
transcriptByRun: new Map(),
hasOutputForRun: () => false,
}),
}));
vi.mock("./MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("./MarkdownEditor", () => ({
MarkdownEditor: () => <textarea aria-label="Issue chat editor" />,
}));
vi.mock("./InlineEntitySelector", () => ({ InlineEntitySelector: () => null }));
vi.mock("./Identity", () => ({ Identity: ({ name }: { name: string }) => <span>{name}</span> }));
vi.mock("./OutputFeedbackButtons", () => ({ OutputFeedbackButtons: () => null }));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("./AgentIconPicker", () => ({ AgentIcon: () => null }));
vi.mock("./StatusBadge", () => ({ StatusBadge: ({ status }: { status: string }) => <span>{status}</span> }));
vi.mock("./IssueLinkQuicklook", () => ({
IssueLinkQuicklook: ({
children,
to,
}: {
children: ReactNode;
to: string;
}) => <a href={to}>{children}</a>,
}));
vi.mock("../hooks/usePaperclipIssueRuntime", () => ({
usePaperclipIssueRuntime: () => ({}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
window.scrollTo = vi.fn();
root = createRoot(container);
});
afterEach(() => {
act(() => root?.unmount());
container.remove();
});
function renderThread(comments: IssueChatComment[], agentMap?: Map<string, Agent>) {
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={comments}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
showComposer={false}
enableLiveTranscriptPolling={false}
agentMap={agentMap}
/>
</MemoryRouter>,
);
});
}
const baseTimestamps = {
createdAt: new Date("2026-05-04T16:32:00.000Z"),
updatedAt: new Date("2026-05-04T16:32:00.000Z"),
};
describe("IssueChatThread system notice routing", () => {
it("renders authorType=system comments as a SystemNotice rather than a user bubble", () => {
const comment: IssueChatComment = {
id: "comment-system",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
body: "Paperclip needs a disposition before this issue can continue.",
presentation: {
kind: "system_notice",
tone: "warning",
title: "Missing issue disposition",
detailsDefaultOpen: false,
},
metadata: {
version: 1,
sections: [
{
title: "Required action",
rows: [
{ type: "issue_link", label: "Source issue", issueId: "i1", identifier: "PAP-3440", title: "Recovery" },
{ type: "key_value", label: "Status before", value: "in_progress" },
],
},
],
},
...baseTimestamps,
};
renderThread([comment]);
const row = container.querySelector('[data-message-role="system"]');
expect(row).not.toBeNull();
const status = row?.querySelector('[role="status"]');
expect(status?.getAttribute("aria-label")).toBe("Missing issue disposition");
expect(container.textContent).toContain("Paperclip needs a disposition");
// collapsed by default — metadata identifier should not be visible
expect(container.textContent).not.toContain("PAP-3440");
const toggle = row?.querySelector("button[aria-expanded]") as HTMLButtonElement | null;
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
expect(container.querySelectorAll('[data-message-role="user"]').length).toBe(0);
});
it("expands metadata when detailsDefaultOpen is true", () => {
const comment: IssueChatComment = {
id: "comment-system-open",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
body: "Recovery escalated.",
presentation: {
kind: "system_notice",
tone: "danger",
title: null,
detailsDefaultOpen: true,
},
metadata: {
version: 1,
sections: [
{
rows: [
{ type: "agent_link", label: "Owner", agentId: "agent-cto", name: "CTO" },
],
},
],
},
...baseTimestamps,
};
renderThread([comment]);
const status = container.querySelector('[role="status"]');
expect(status?.getAttribute("aria-label")).toBe("System alert");
expect(container.textContent).toContain("CTO");
const toggle = container.querySelector("button[aria-expanded]");
expect(toggle?.getAttribute("aria-expanded")).toBe("true");
});
it("falls back to legacy user bubble + handoff callout for old text-only comments", () => {
const comment: IssueChatComment = {
id: "comment-legacy",
companyId: "company-1",
issueId: "issue-1",
authorType: "user",
authorAgentId: null,
authorUserId: "user-1",
body: "## Successful run missing issue disposition\n\nFix this.",
presentation: null,
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
expect(container.querySelector('[role="status"]')).toBeNull();
const userRow = container.querySelector('[data-message-role="user"]');
expect(userRow).not.toBeNull();
expect(container.textContent).toContain("Successful run missing issue disposition");
});
it("keeps regular user comments rendering as user bubbles", () => {
const comment: IssueChatComment = {
id: "comment-user",
companyId: "company-1",
issueId: "issue-1",
authorType: "user",
authorAgentId: null,
authorUserId: "user-1",
body: "Standard user message.",
presentation: null,
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
expect(container.querySelector('[role="status"]')).toBeNull();
expect(container.querySelector('[data-message-role="user"]')).not.toBeNull();
expect(container.textContent).toContain("Standard user message.");
});
it("keeps agent-authored comments rendering as assistant bubbles even with system_notice presentation absent", () => {
const comment: IssueChatComment = {
id: "comment-agent",
companyId: "company-1",
issueId: "issue-1",
authorType: "agent",
authorAgentId: "agent-1",
authorUserId: null,
body: "Agent reply",
presentation: null,
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
expect(container.querySelector('[role="status"]')).toBeNull();
expect(container.querySelector('[data-message-role="assistant"]')).not.toBeNull();
});
it("labels system notice source as the originating run agent name when runAgentId is available", () => {
const codexAgent = {
id: "agent-codex",
name: "CodexCoder",
} as unknown as Agent;
const agentMap = new Map<string, Agent>([[codexAgent.id, codexAgent]]);
const comment: IssueChatComment = {
id: "comment-system-runagent",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
runId: "run-issue-chat-01",
runAgentId: "agent-codex",
body: "Paperclip needs a disposition before this issue can continue.",
presentation: {
kind: "system_notice",
tone: "warning",
title: "Missing issue disposition",
detailsDefaultOpen: false,
},
metadata: null,
...baseTimestamps,
};
renderThread([comment], agentMap);
const status = container.querySelector('[role="status"]');
expect(status).not.toBeNull();
const sourceLink = status?.querySelector('a[href^="/agents/"]') as HTMLAnchorElement | null;
expect(sourceLink?.getAttribute("href")).toBe("/agents/agent-codex/runs/run-issue-chat-01");
expect(sourceLink?.textContent).toBe("CodexCoder");
expect(sourceLink?.textContent).not.toBe("You");
});
it("shows copy-link feedback on the link button only", async () => {
const writeText = vi.fn(async () => undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const comment: IssueChatComment = {
id: "comment-copy-link",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
body: "System recovery completed.",
presentation: {
kind: "system_notice",
tone: "success",
title: null,
detailsDefaultOpen: false,
},
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
const copyLink = container.querySelector('button[aria-label="Copy link to system notice"]') as HTMLButtonElement;
const copyText = container.querySelector('button[aria-label="Copy system notice"]') as HTMLButtonElement;
await act(async () => {
copyLink.click();
await Promise.resolve();
});
expect(writeText).toHaveBeenCalledWith(expect.stringContaining("#comment-comment-copy-link"));
expect(copyLink.querySelector(".lucide-check")).not.toBeNull();
expect(copyText.querySelector(".lucide-check")).toBeNull();
});
it("labels system notice source as Paperclip when no run agent can be resolved", () => {
const comment: IssueChatComment = {
id: "comment-system-no-author",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
runId: null,
runAgentId: null,
body: "System recovery completed.",
presentation: {
kind: "system_notice",
tone: "info",
title: null,
detailsDefaultOpen: false,
},
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
const status = container.querySelector('[role="status"]');
expect(status).not.toBeNull();
expect(status?.textContent).toContain("Paperclip");
expect(status?.textContent).not.toContain("You");
});
it("falls back to Paperclip in the system notice header when run agent is unknown to agentMap", () => {
const comment: IssueChatComment = {
id: "comment-system-unknown-agent",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
runId: "run-xyz",
runAgentId: "agent-unknown",
body: "Disposition required.",
presentation: {
kind: "system_notice",
tone: "warning",
title: null,
detailsDefaultOpen: false,
},
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
const status = container.querySelector('[role="status"]');
const sourceLink = status?.querySelector('a[href^="/agents/"]') as HTMLAnchorElement | null;
expect(sourceLink?.getAttribute("href")).toBe("/agents/agent-unknown/runs/run-xyz");
expect(sourceLink?.textContent).toBe("Paperclip");
});
it("keeps agent-authored comments as assistant bubbles even when presentation requests system_notice", () => {
const comment: IssueChatComment = {
id: "comment-agent-system",
companyId: "company-1",
issueId: "issue-1",
authorType: "agent",
authorAgentId: "agent-1",
authorUserId: null,
body: "Reassigned to ClaudeFixer.",
presentation: {
kind: "system_notice",
tone: "neutral",
title: null,
detailsDefaultOpen: false,
},
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
expect(container.querySelector('[role="status"]')).toBeNull();
expect(container.querySelector('[data-message-role="assistant"]')).not.toBeNull();
});
});

View file

@ -66,6 +66,7 @@ import { buildIssueTree, countDescendants } from "../lib/issue-tree";
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
import { statusBadge } from "../lib/status-colors";
import { workflowSort } from "../lib/workflow-sort";
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
import { ISSUE_STATUSES, type Issue, type IssueStatus, type Project } from "@paperclipai/shared";
const ISSUE_SEARCH_DEBOUNCE_MS = 250;
const ISSUE_SEARCH_RESULT_LIMIT = 200;
@ -1528,6 +1529,16 @@ export function IssuesList({
</span>
)
) : null}
{isSuccessfulRunHandoffRequired(issue) ? (
<span
className="ml-1.5 inline-flex items-center gap-1 rounded-full border border-amber-400/45 bg-amber-50/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-300/35 dark:bg-amber-400/10 dark:text-amber-300"
aria-label="Needs next step"
title="This issue needs a next step"
>
<CircleDot className="h-3 w-3" />
Needs next step
</span>
) : null}
</>
)}
className={isMutedIssue ? "opacity-70" : undefined}

View file

@ -21,6 +21,8 @@ import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { Identity } from "./Identity";
import type { Issue } from "@paperclipai/shared";
import { AlertTriangle } from "lucide-react";
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
const boardStatuses = [
"backlog",
@ -159,6 +161,16 @@ function KanbanCard({
<span className="text-xs text-muted-foreground font-mono shrink-0">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{isSuccessfulRunHandoffRequired(issue) ? (
<span
className="inline-flex items-center gap-1 rounded-full border border-amber-400/45 bg-amber-50/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-300/35 dark:bg-amber-400/10 dark:text-amber-300"
title="This issue needs a next step"
aria-label="Needs next step"
>
<AlertTriangle className="h-3 w-3" />
Next step
</span>
) : null}
{isLive && (
<span className="relative flex h-2 w-2 shrink-0 mt-0.5">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />

View file

@ -0,0 +1,197 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { ReactElement } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { SystemNotice } from "./SystemNotice";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
root = null;
container?.remove();
container = null;
});
function render(element: ReactElement) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(element));
return container;
}
describe("SystemNotice", () => {
it("renders the warning tone label and body in a single status container", () => {
const node = render(
<SystemNotice
tone="warning"
body="Paperclip needs a disposition before this issue can continue."
/>,
);
const status = node.querySelectorAll('[role="status"]');
expect(status.length).toBe(1);
expect(status[0]?.getAttribute("aria-label")).toBe("System warning");
expect(node.textContent).toContain(
"Paperclip needs a disposition before this issue can continue.",
);
});
it("uses System alert label for danger tone", () => {
const node = render(
<SystemNotice tone="danger" body="Recovery escalated to CTO." />,
);
const status = node.querySelector('[role="status"]');
expect(status?.getAttribute("aria-label")).toBe("System alert");
});
it("uses neutral System notice label by default", () => {
const node = render(
<SystemNotice tone="neutral" body="Reassigned to ClaudeFixer." />,
);
const status = node.querySelector('[role="status"]');
expect(status?.getAttribute("aria-label")).toBe("System notice");
});
it("collapses metadata details by default and toggles aria-expanded on click", () => {
const node = render(
<SystemNotice
tone="warning"
body="Needs a disposition."
metadata={[
{
title: "Required action",
rows: [
{
kind: "issue",
label: "Source issue",
identifier: "PAP-3440",
href: "/PAP/issues/PAP-3440",
},
],
},
]}
/>,
);
const button = node.querySelector("button[aria-expanded]");
expect(button).not.toBeNull();
expect(button?.getAttribute("aria-expanded")).toBe("false");
expect(button?.getAttribute("aria-controls")).not.toBeNull();
expect(node.textContent).not.toContain("PAP-3440");
act(() => {
(button as HTMLButtonElement).click();
});
const reopened = node.querySelector("button[aria-expanded]");
expect(reopened?.getAttribute("aria-expanded")).toBe("true");
expect(node.textContent).toContain("PAP-3440");
});
it("renders metadata expanded when detailsDefaultOpen is true", () => {
const node = render(
<SystemNotice
tone="warning"
body="Needs a disposition."
detailsDefaultOpen
metadata={[
{
rows: [{ kind: "text", label: "Suggested action", value: "Pick a disposition" }],
},
]}
/>,
);
const button = node.querySelector("button[aria-expanded]");
expect(button?.getAttribute("aria-expanded")).toBe("true");
expect(node.textContent).toContain("Suggested action");
expect(node.textContent).toContain("Pick a disposition");
});
it("hides the details affordance when no metadata is provided", () => {
const node = render(<SystemNotice tone="warning" body="Short notice." />);
expect(node.querySelector("button[aria-expanded]")).toBeNull();
});
it("renders typed metadata rows with hrefs when present", () => {
const node = render(
<SystemNotice
tone="danger"
body="Recovery blocked"
detailsDefaultOpen
metadata={[
{
rows: [
{
kind: "issue",
label: "Recovery issue",
identifier: "PAP-3440",
href: "/PAP/issues/PAP-3440",
title: "Disposition recovery",
},
{
kind: "agent",
label: "Owner",
name: "CTO",
href: "/PAP/agents/cto",
},
{
kind: "run",
label: "Source run",
runId: "9cdba892-c7ca-4d93-8604-4843873b127c",
href: "/PAP/agents/codexcoder/runs/9cdba892",
status: "succeeded",
},
],
},
]}
/>,
);
const links = Array.from(node.querySelectorAll("a")).map((a) => a.getAttribute("href"));
expect(links).toContain("/PAP/issues/PAP-3440");
expect(links).toContain("/PAP/agents/cto");
expect(links).toContain("/PAP/agents/codexcoder/runs/9cdba892");
expect(node.textContent).toContain("PAP-3440");
expect(node.textContent).toContain("Disposition recovery");
expect(node.textContent).toContain("CTO");
expect(node.textContent).toContain("succeeded");
});
it("renders metadata link rows as plain text when href is missing", () => {
const node = render(
<SystemNotice
tone="neutral"
body="Reassigned"
detailsDefaultOpen
metadata={[
{
rows: [
{ kind: "agent", label: "Reassigned to", name: "ClaudeFixer" },
{ kind: "run", label: "Run", runId: "abc12345" },
{ kind: "issue", label: "Issue", identifier: "PAP-1" },
],
},
]}
/>,
);
expect(node.querySelectorAll("a").length).toBe(0);
expect(node.textContent).toContain("ClaudeFixer");
expect(node.textContent).toContain("abc12345");
expect(node.textContent).toContain("PAP-1");
});
});

View file

@ -0,0 +1,337 @@
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-[7.5rem_1fr] gap-x-3 gap-y-0.5 px-3 py-1.5 text-xs">
<div className="truncate text-[11px] font-medium uppercase tracking-[0.08em] 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-[11px] 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-[11px] underline-offset-2 hover:underline"
>
{inner}
</a>
);
}
return (
<span className="inline-flex items-center gap-2 font-mono text-[11px]">
{inner}
</span>
);
}
}
})()}
</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-[0_1px_0_rgba(15,23,42,0.02)]",
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-[11px] font-semibold uppercase tracking-[0.14em]">
<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-[14px] 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-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground transition-[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,
)}
>
<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-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{section.title}
</div>
) : null}
<div>
{section.rows.map((row, rowIdx) => (
<MetadataRow key={rowIdx} row={row} tone={tokens} />
))}
</div>
</div>
))}
</div>
</div>
) : null}
</section>
);
}
export default SystemNotice;