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;

View file

@ -94,6 +94,7 @@ function createComment(index: number): IssueChatComment {
id: `long-thread-comment-${String(index + 1).padStart(3, "0")}`,
companyId: "company-long-thread",
issueId: "issue-long-thread",
authorType: authorAgentId ? "agent" : "user",
authorAgentId,
authorUserId: authorAgentId ? null : "user-board",
body: isMarkdown
@ -101,6 +102,8 @@ function createComment(index: number): IssueChatComment {
: authorAgentId
? plainAssistantBody(index + 1)
: plainUserBody(index + 1),
presentation: null,
metadata: null,
createdAt: atMinute(index),
updatedAt: atMinute(index),
};

View file

@ -43,17 +43,21 @@ function createAgent(
}
function createComment(overrides: Partial<IssueChatComment>): IssueChatComment {
return {
const merged: IssueChatComment = {
id: "comment-default",
companyId: "company-ux",
issueId: "issue-ux",
authorType: overrides.authorAgentId ? "agent" : "user",
authorAgentId: null,
authorUserId: "user-1",
body: "",
presentation: null,
metadata: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
...overrides,
};
return merged;
}
const primaryAgent = createAgent("agent-1", "CodexCoder", "code", "codexcoder");

View file

@ -23,9 +23,12 @@ function createComment(overrides: Partial<IssueChatComment>): IssueChatComment {
id: "comment-default",
companyId: issueThreadInteractionFixtureMeta.companyId,
issueId: issueThreadInteractionFixtureMeta.issueId,
authorType: overrides.authorAgentId ? "agent" : "user",
authorAgentId: null,
authorUserId: issueThreadInteractionFixtureMeta.currentUserId,
body: "",
presentation: null,
metadata: null,
createdAt,
updatedAt: overrides.updatedAt ?? createdAt,
...overrides,

View file

@ -0,0 +1,204 @@
import type {
SystemNoticeMetadataSection,
SystemNoticeProps,
} from "../components/SystemNotice";
export type SystemNoticeFixture = {
id: string;
caption: string;
} & SystemNoticeProps;
const HANDOFF_METADATA: SystemNoticeMetadataSection[] = [
{
title: "Recovery owner",
rows: [
{
kind: "issue",
label: "Recovery issue",
identifier: "PAP-3440",
href: "/PAP/issues/PAP-3440",
title: "Successful run handoff missing disposition",
},
{
kind: "agent",
label: "Owner",
name: "CTO",
href: "/PAP/agents/cto",
},
{
kind: "text",
label: "Suggested action",
value: "Reassign to a recovery agent and pick a disposition.",
},
],
},
{
title: "Run evidence",
rows: [
{
kind: "run",
label: "Source run",
runId: "9cdba892-c7ca-4d93-8604-4843873b127c",
href: "/PAP/agents/codexcoder/runs/9cdba892-c7ca-4d93-8604-4843873b127c",
status: "succeeded",
},
{
kind: "run",
label: "Recovery run",
runId: "61fdb79b-8012-4676-ac71-2971830e126a",
href: "/PAP/agents/codexcoder/runs/61fdb79b-8012-4676-ac71-2971830e126a",
status: "failed",
},
{
kind: "text",
label: "Normalized cause",
value: "Run completed without issuing a disposition for an in_progress task.",
},
],
},
];
const REQUIRED_METADATA: SystemNoticeMetadataSection[] = [
{
title: "Required action",
rows: [
{
kind: "issue",
label: "Source issue",
identifier: "PAP-3440",
href: "/PAP/issues/PAP-3440",
title: "Successful run handoff missing disposition",
},
{
kind: "agent",
label: "Assignee",
name: "CodexCoder",
href: "/PAP/agents/codexcoder",
},
{
kind: "text",
label: "Next step",
value: "Pick done, blocked, or in_review and post a one-line rationale.",
},
],
},
{
title: "Run context",
rows: [
{
kind: "run",
label: "Successful run",
runId: "9cdba892-c7ca-4d93-8604-4843873b127c",
href: "/PAP/agents/codexcoder/runs/9cdba892-c7ca-4d93-8604-4843873b127c",
status: "succeeded",
},
{
kind: "code",
label: "Status before",
value: "in_progress",
},
],
},
];
const NEUTRAL_METADATA: SystemNoticeMetadataSection[] = [
{
rows: [
{
kind: "agent",
label: "Reassigned to",
name: "ClaudeFixer",
href: "/PAP/agents/claudefixer",
},
{
kind: "agent",
label: "From",
name: "CodexCoder",
href: "/PAP/agents/codexcoder",
},
{
kind: "text",
label: "Reason",
value: "Manual reassignment requested by Board.",
},
],
},
];
export const systemNoticeFixtures: readonly SystemNoticeFixture[] = [
{
id: "warning-collapsed",
caption: "Warning · collapsed (default)",
tone: "warning",
label: "System warning",
source: { label: "Paperclip", href: "/PAP/agents" },
timestamp: "2026-05-04T16:32:00.000Z",
body: "Paperclip needs a disposition before this issue can continue.",
metadata: REQUIRED_METADATA,
detailsDefaultOpen: false,
},
{
id: "warning-expanded",
caption: "Warning · expanded",
tone: "warning",
label: "System warning",
source: { label: "Paperclip", href: "/PAP/agents" },
timestamp: "2026-05-04T16:32:00.000Z",
body: "Paperclip needs a disposition before this issue can continue.",
metadata: REQUIRED_METADATA,
detailsDefaultOpen: true,
},
{
id: "danger-collapsed",
caption: "Danger · collapsed (default)",
tone: "danger",
label: "System alert",
source: { label: "Paperclip", href: "/PAP/agents" },
timestamp: "2026-05-04T16:48:00.000Z",
body: "Paperclip could not resolve this issue's missing disposition automatically. The issue is blocked on a recovery owner.",
metadata: HANDOFF_METADATA,
detailsDefaultOpen: false,
},
{
id: "danger-expanded",
caption: "Danger · expanded",
tone: "danger",
label: "System alert",
source: { label: "Paperclip", href: "/PAP/agents" },
timestamp: "2026-05-04T16:48:00.000Z",
body: "Paperclip could not resolve this issue's missing disposition automatically. The issue is blocked on a recovery owner.",
metadata: HANDOFF_METADATA,
detailsDefaultOpen: true,
},
{
id: "neutral-collapsed",
caption: "Neutral · collapsed (default)",
tone: "neutral",
label: "System notice",
source: { label: "Paperclip" },
timestamp: "2026-05-04T15:10:00.000Z",
body: "Reassigned to ClaudeFixer.",
metadata: NEUTRAL_METADATA,
detailsDefaultOpen: false,
},
{
id: "neutral-expanded",
caption: "Neutral · expanded",
tone: "neutral",
label: "System notice",
source: { label: "Paperclip" },
timestamp: "2026-05-04T15:10:00.000Z",
body: "Reassigned to ClaudeFixer.",
metadata: NEUTRAL_METADATA,
detailsDefaultOpen: true,
},
{
id: "warning-no-details",
caption: "Warning · no metadata (Details affordance hidden)",
tone: "warning",
label: "System warning",
source: { label: "Paperclip" },
timestamp: "2026-05-04T17:02:00.000Z",
body: "This run paused while waiting on board approval.",
},
];

View file

@ -65,4 +65,13 @@ describe("activity formatting", () => {
expect(formatIssueActivityAction("issue.monitor_cleared")).toBe("cleared a monitor");
expect(formatIssueActivityAction("issue.monitor_recovery_issue_created")).toBe("created a monitor recovery issue");
});
it("uses plain next-step copy for successful-run handoff activity", () => {
expect(formatActivityVerb("issue.successful_run_handoff_required")).toBe("flagged missing next step on");
expect(formatIssueActivityAction("issue.successful_run_handoff_required")).toBe("Run finished without a clear next step");
expect(formatIssueActivityAction("issue.successful_run_handoff_resolved")).toBe("Next step chosen");
expect(formatIssueActivityAction("issue.successful_run_handoff_escalated")).toBe(
"Run finished without a next step - recovery escalated",
);
});
});

View file

@ -43,6 +43,9 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
"issue.monitor_escalated_to_board": "escalated monitor for",
"issue.commented": "commented on",
"issue.deleted": "deleted",
"issue.successful_run_handoff_required": "flagged missing next step on",
"issue.successful_run_handoff_resolved": "recorded next step chosen on",
"issue.successful_run_handoff_escalated": "escalated missing next step on",
"agent.created": "created",
"agent.updated": "updated",
"agent.paused": "paused",
@ -92,6 +95,9 @@ const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
"issue.monitor_recovery_issue_created": "created a monitor recovery issue",
"issue.monitor_escalated_to_board": "escalated a monitor to the board",
"issue.deleted": "deleted the issue",
"issue.successful_run_handoff_required": "Run finished without a clear next step",
"issue.successful_run_handoff_resolved": "Next step chosen",
"issue.successful_run_handoff_escalated": "Run finished without a next step - recovery escalated",
"agent.created": "created an agent",
"agent.updated": "updated the agent",
"agent.paused": "paused the agent",

View file

@ -39,6 +39,7 @@ function createAgent(id: string, name: string): Agent {
}
function createComment(overrides: Partial<IssueChatComment> = {}): IssueChatComment {
const authorAgentId = overrides.authorAgentId ?? null;
return {
id: "comment-1",
companyId: "company-1",
@ -46,6 +47,9 @@ function createComment(overrides: Partial<IssueChatComment> = {}): IssueChatComm
authorAgentId: null,
authorUserId: "user-1",
body: "Hello",
authorType: authorAgentId ? "agent" : "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
...overrides,

View file

@ -305,11 +305,13 @@ function createCommentMessage(args: {
const { comment, agentMap, currentUserId, userLabelMap, companyId, projectId } = args;
const createdAt = toDate(comment.createdAt);
const authorName = authorNameForComment(comment, agentMap, currentUserId, userLabelMap);
const isSystemNotice = comment.authorType === "system";
const custom = {
kind: "comment",
kind: isSystemNotice ? "system_notice" : "comment",
commentId: comment.id,
anchorId: `comment-${comment.id}`,
authorName,
authorType: comment.authorType,
authorAgentId: comment.authorAgentId,
authorUserId: comment.authorUserId,
companyId: companyId ?? comment.companyId,
@ -322,8 +324,21 @@ function createCommentMessage(args: {
queueReason: comment.queueReason ?? null,
interruptedRunId: comment.interruptedRunId ?? null,
followUpRequested: comment.followUpRequested === true,
presentation: comment.presentation ?? null,
commentMetadata: comment.metadata ?? null,
};
if (isSystemNotice) {
const message: ThreadSystemMessage = {
id: comment.id,
role: "system",
createdAt,
content: [{ type: "text", text: comment.body }],
metadata: { custom },
};
return message;
}
if (comment.authorAgentId) {
const message: ThreadAssistantMessage = {
id: comment.id,

View file

@ -83,6 +83,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Second",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:02.000Z"),
updatedAt: new Date("2026-03-28T14:00:02.000Z"),
},
@ -97,6 +100,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "First",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
@ -140,6 +146,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Original",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
},
@ -151,6 +160,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Updated",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:05.000Z"),
},
@ -170,6 +182,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Newest",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:03.000Z"),
updatedAt: new Date("2026-03-28T14:00:03.000Z"),
},
@ -182,6 +197,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Oldest",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
@ -192,6 +210,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Middle",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:02.000Z"),
updatedAt: new Date("2026-03-28T14:00:02.000Z"),
},
@ -216,6 +237,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Second",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:02.000Z"),
updatedAt: new Date("2026-03-28T14:00:02.000Z"),
},
@ -226,6 +250,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "First",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
@ -310,6 +337,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Newest",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:03.000Z"),
updatedAt: new Date("2026-03-28T14:00:03.000Z"),
},
@ -322,6 +352,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Oldest",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
@ -334,6 +367,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Brand new",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:04.000Z"),
updatedAt: new Date("2026-03-28T14:00:04.000Z"),
},
@ -354,6 +390,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Newest",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:03.000Z"),
updatedAt: new Date("2026-03-28T14:00:03.000Z"),
},
@ -366,6 +405,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Middle",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:02.000Z"),
updatedAt: new Date("2026-03-28T14:00:02.000Z"),
},
@ -376,6 +418,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Oldest",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
@ -827,6 +872,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Follow up after the active run",
authorType: "user" as const,
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T16:20:05.000Z"),
updatedAt: new Date("2026-03-28T16:20:05.000Z"),
};
@ -853,6 +901,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Follow up after the active run",
authorType: "user" as const,
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T16:20:05.000Z"),
updatedAt: new Date("2026-03-28T16:20:05.000Z"),
};
@ -874,6 +925,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Follow up after the active run",
authorType: "user" as const,
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T16:20:05.000Z"),
updatedAt: new Date("2026-03-28T16:20:05.000Z"),
};

View file

@ -57,9 +57,12 @@ export function createOptimisticIssueComment(params: {
clientId,
companyId: params.companyId,
issueId: params.issueId,
authorType: "user",
authorAgentId: null,
authorUserId: params.authorUserId,
body: params.body,
presentation: null,
metadata: null,
clientStatus: params.clientStatus ?? "pending",
queueTargetRunId: params.queueTargetRunId ?? null,
createdAt: now,

View file

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import {
SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION,
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION,
isSuccessfulRunHandoffComment,
isSuccessfulRunHandoffEscalationComment,
successfulRunHandoffActivityTone,
} from "./successful-run-handoff";
describe("successful run handoff UI helpers", () => {
it("matches both required and escalated production comments", () => {
expect(isSuccessfulRunHandoffComment(SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY)).toBe(true);
expect(isSuccessfulRunHandoffComment("## This issue still needs a next step\n\n- Source run: abc")).toBe(true);
expect(isSuccessfulRunHandoffComment("## Successful run missing issue disposition\n\n- Source run: abc")).toBe(true);
expect(isSuccessfulRunHandoffComment(SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY)).toBe(true);
expect(
isSuccessfulRunHandoffComment(
"Paperclip exhausted the bounded successful-run handoff correction for this issue, but it still has no clear next-step disposition.",
),
).toBe(true);
expect(
isSuccessfulRunHandoffEscalationComment(
"Paperclip exhausted the bounded successful-run handoff correction for this issue, but it still has no clear next-step disposition.",
),
).toBe(true);
expect(isSuccessfulRunHandoffComment("Ordinary issue comment")).toBe(false);
});
it("returns shared tones for required, escalated, and neutral activity", () => {
expect(successfulRunHandoffActivityTone(SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION).className).toContain("amber");
expect(successfulRunHandoffActivityTone(SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION).className).toContain("red");
expect(successfulRunHandoffActivityTone(SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION).className).toContain("border");
});
});

View file

@ -0,0 +1,90 @@
import type { ActivityEvent, Issue, SuccessfulRunHandoffState } from "@paperclipai/shared";
export const SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION = "issue.successful_run_handoff_required";
export const SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION = "issue.successful_run_handoff_resolved";
export const SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION = "issue.successful_run_handoff_escalated";
export const SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY =
"Paperclip needs a disposition before this issue can continue.";
export const SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY =
"Paperclip could not resolve this issue's missing disposition automatically. The issue is blocked on a recovery owner.";
export function isSuccessfulRunHandoffActivity(action: string) {
return action === SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION
|| action === SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION
|| action === SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION;
}
export function isSuccessfulRunHandoffRequired(issue: Pick<Issue, "successfulRunHandoff">) {
return issue.successfulRunHandoff?.required === true;
}
function readString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export function successfulRunHandoffFromActivity(event: ActivityEvent): SuccessfulRunHandoffState | null {
if (!isSuccessfulRunHandoffActivity(event.action)) return null;
const details = event.details ?? {};
const state = event.action === SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION
? "required"
: event.action === SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION
? "resolved"
: "escalated";
return {
state,
required: state === "required",
sourceRunId:
readString(details.sourceRunId)
?? readString(details.source_run_id)
?? readString(details.resumeFromRunId)
?? event.runId
?? null,
correctiveRunId:
readString(details.correctiveRunId)
?? readString(details.corrective_run_id)
?? (state !== "required" ? event.runId : null),
assigneeAgentId:
readString(details.assigneeAgentId)
?? readString(details.agentId)
?? event.agentId
?? null,
detectedProgressSummary:
readString(details.detectedProgressSummary)
?? readString(details.detected_progress_summary)
?? null,
createdAt: event.createdAt,
};
}
export function isSuccessfulRunHandoffComment(text: string) {
const trimmed = text.trim();
return trimmed === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY
|| /^##\s+(This issue still needs a next step|Run finished without a next step|Successful run missing issue disposition)/i.test(trimmed)
|| isSuccessfulRunHandoffEscalationComment(trimmed);
}
export function isSuccessfulRunHandoffEscalationComment(text: string) {
const trimmed = text.trim();
return trimmed === SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY
|| /^Paperclip exhausted the bounded successful-run handoff correction\b/i.test(trimmed);
}
export function successfulRunHandoffActivityTone(action: string) {
if (action === SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION) {
return {
className: "border-red-500/35 bg-red-500/10 text-red-950 dark:text-red-100",
iconClassName: "text-red-600 dark:text-red-300",
};
}
if (action === SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION) {
return {
className: "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",
iconClassName: "text-amber-600 dark:text-amber-300",
};
}
return {
className: "border-border/60 text-muted-foreground",
iconClassName: "text-muted-foreground",
};
}

View file

@ -0,0 +1,143 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { buildSystemNoticeProps, mapCommentMetadataToSystemNoticeSections } from "./system-notice-comment";
describe("mapCommentMetadataToSystemNoticeSections", () => {
it("maps server metadata row types to SystemNotice rows", () => {
const sections = mapCommentMetadataToSystemNoticeSections(
{
version: 1,
sections: [
{
title: "Required action",
rows: [
{ type: "issue_link", label: "Source issue", issueId: "i1", identifier: "PAP-3440", title: "Recovery" },
{ type: "agent_link", label: "Assignee", agentId: "agent-1", name: "CodexCoder" },
{ type: "key_value", label: "Status before", value: "in_progress" },
{ type: "code", label: "Cause code", code: "missing_disposition" },
{ type: "text", label: "Notes", text: "Pick a disposition." },
{ type: "run_link", label: "Source run", runId: "9cdba892-c7ca-4d93-8604-4843873b127c", title: "succeeded" },
],
},
],
},
{ runAgentId: "agent-1" },
);
expect(sections).toHaveLength(1);
expect(sections[0]?.title).toBe("Required action");
const rows = sections[0]!.rows;
expect(rows).toEqual([
{
kind: "issue",
label: "Source issue",
identifier: "PAP-3440",
href: "/issues/PAP-3440",
title: "Recovery",
},
{ kind: "agent", label: "Assignee", name: "CodexCoder", href: "/agents/agent-1" },
{ kind: "text", label: "Status before", value: "in_progress" },
{ kind: "code", label: "Cause code", value: "missing_disposition" },
{ kind: "text", label: "Notes", value: "Pick a disposition." },
{
kind: "run",
label: "Source run",
runId: "9cdba892-c7ca-4d93-8604-4843873b127c",
href: "/agents/agent-1/runs/9cdba892-c7ca-4d93-8604-4843873b127c",
status: "succeeded",
},
]);
});
it("omits run href when no runAgentId is available", () => {
const sections = mapCommentMetadataToSystemNoticeSections(
{
version: 1,
sections: [
{
rows: [
{ type: "run_link", label: "Run", runId: "abc12345" },
],
},
],
},
{},
);
expect(sections[0]?.rows[0]).toEqual({
kind: "run",
label: "Run",
runId: "abc12345",
href: undefined,
status: undefined,
});
});
it("returns an empty array for null metadata", () => {
expect(mapCommentMetadataToSystemNoticeSections(null)).toEqual([]);
expect(mapCommentMetadataToSystemNoticeSections(undefined)).toEqual([]);
});
});
describe("buildSystemNoticeProps", () => {
it("derives tone, label, and metadata from a system_notice presentation", () => {
const props = buildSystemNoticeProps({
presentation: {
kind: "system_notice",
tone: "warning",
title: "Missing disposition",
detailsDefaultOpen: false,
},
metadata: {
version: 1,
sections: [
{
title: "Required",
rows: [{ type: "key_value", label: "Status", value: "in_progress" }],
},
],
},
body: "Body text",
runAgentId: "agent-1",
});
expect(props.tone).toBe("warning");
expect(props.label).toBe("Missing disposition");
expect(props.detailsDefaultOpen).toBe(false);
expect(props.metadata?.[0]?.rows[0]).toEqual({
kind: "text",
label: "Status",
value: "in_progress",
});
});
it("falls back to neutral tone with default label when presentation is null", () => {
const props = buildSystemNoticeProps({
presentation: null,
metadata: null,
body: "Hello",
});
expect(props.tone).toBe("neutral");
expect(props.label).toBe("System notice");
expect(props.metadata).toBeUndefined();
});
it("uses the danger default label when presentation lacks a title", () => {
const props = buildSystemNoticeProps({
presentation: {
kind: "system_notice",
tone: "danger",
title: null,
detailsDefaultOpen: true,
},
metadata: null,
body: "boom",
});
expect(props.label).toBe("System alert");
expect(props.detailsDefaultOpen).toBe(true);
});
});

View file

@ -0,0 +1,125 @@
import type {
IssueCommentMetadata,
IssueCommentMetadataRow,
IssueCommentPresentation,
} from "@paperclipai/shared";
import type {
SystemNoticeMetadataRow,
SystemNoticeMetadataSection,
SystemNoticeProps,
SystemNoticeTone,
} from "../components/SystemNotice";
const TONE_LABEL: Record<SystemNoticeTone, string> = {
neutral: "System notice",
info: "System notice",
success: "System notice",
warning: "System warning",
danger: "System alert",
};
function metadataRowText(row: { label?: string | null }, fallback: string) {
const label = row.label?.trim();
return label && label.length > 0 ? label : fallback;
}
function mapMetadataRow(
row: IssueCommentMetadataRow,
ctx: { runAgentId?: string | null },
): SystemNoticeMetadataRow | null {
switch (row.type) {
case "text":
return { kind: "text", label: metadataRowText(row, "Detail"), value: row.text };
case "code":
return { kind: "code", label: metadataRowText(row, "Code"), value: row.code };
case "key_value":
return { kind: "text", label: row.label, value: row.value };
case "issue_link": {
const identifier = row.identifier ?? null;
if (!identifier) {
return { kind: "text", label: metadataRowText(row, "Issue"), value: row.title ?? "unknown" };
}
return {
kind: "issue",
label: metadataRowText(row, "Issue"),
identifier,
href: `/issues/${identifier}`,
title: row.title ?? undefined,
};
}
case "agent_link": {
const name = row.name?.trim() || row.agentId.slice(0, 8);
return {
kind: "agent",
label: metadataRowText(row, "Agent"),
name,
href: `/agents/${row.agentId}`,
};
}
case "run_link": {
const runAgentId = ctx.runAgentId ?? null;
const href = runAgentId ? `/agents/${runAgentId}/runs/${row.runId}` : undefined;
return {
kind: "run",
label: metadataRowText(row, "Run"),
runId: row.runId,
href,
status: row.title ?? undefined,
};
}
default:
return null;
}
}
export function mapCommentMetadataToSystemNoticeSections(
metadata: IssueCommentMetadata | null | undefined,
ctx: { runAgentId?: string | null } = {},
): SystemNoticeMetadataSection[] {
if (!metadata || !Array.isArray(metadata.sections)) return [];
return metadata.sections
.map((section) => {
const rows = section.rows
.map((row) => mapMetadataRow(row, ctx))
.filter((r): r is SystemNoticeMetadataRow => r !== null);
if (rows.length === 0) return null;
const out: SystemNoticeMetadataSection = { rows };
if (section.title) out.title = section.title;
return out;
})
.filter((s): s is SystemNoticeMetadataSection => s !== null);
}
export function systemNoticeLabelForTone(
tone: SystemNoticeTone,
presentationTitle?: string | null,
): string {
const trimmed = presentationTitle?.trim();
if (trimmed && trimmed.length > 0) return trimmed;
return TONE_LABEL[tone];
}
export function buildSystemNoticeProps(input: {
presentation: IssueCommentPresentation | null;
metadata: IssueCommentMetadata | null;
body: import("react").ReactNode;
timestamp?: string;
source?: SystemNoticeProps["source"];
runAgentId?: string | null;
}): SystemNoticeProps {
const tone: SystemNoticeTone = input.presentation?.tone ?? "neutral";
const label = systemNoticeLabelForTone(tone, input.presentation?.title);
const detailsDefaultOpen = Boolean(input.presentation?.detailsDefaultOpen);
const sections = mapCommentMetadataToSystemNoticeSections(input.metadata, {
runAgentId: input.runAgentId ?? null,
});
return {
tone,
label,
body: input.body,
metadata: sections.length > 0 ? sections : undefined,
detailsDefaultOpen,
timestamp: input.timestamp,
source: input.source,
};
}

View file

@ -104,8 +104,14 @@ import { buildIssuePropertiesPanelKey } from "../lib/issue-properties-panel-key"
import { shouldRenderRichSubIssuesSection } from "../lib/issue-detail-subissues";
import { filterIssueDescendants } from "../lib/issue-tree";
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
import {
SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION,
successfulRunHandoffActivityTone,
} from "../lib/successful-run-handoff";
import {
Activity as ActivityIcon,
AlertTriangle,
Archive,
ArrowLeft,
Check,
@ -578,6 +584,7 @@ type IssueDetailChatTabProps = {
executionRunId: string | null;
blockedBy: Issue["blockedBy"];
blockerAttention: Issue["blockerAttention"] | null;
successfulRunHandoff: Issue["successfulRunHandoff"] | null;
comments: IssueDetailComment[];
locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
interactions: IssueThreadInteraction[];
@ -635,6 +642,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
executionRunId,
blockedBy,
blockerAttention,
successfulRunHandoff,
comments,
locallyQueuedCommentRunIds,
interactions,
@ -836,6 +844,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
activeRun={resolvedActiveRun}
blockedBy={blockedBy ?? []}
blockerAttention={blockerAttention}
successfulRunHandoff={successfulRunHandoff}
companyId={companyId}
projectId={projectId}
issueStatus={issueStatus}
@ -1065,16 +1074,25 @@ function IssueDetailActivityTab({
agentMap={agentMap}
hasLiveRuns={hasLiveRuns}
activityEvents={activity ?? []}
renderActivityEvent={(evt) => (
<div className="space-y-1.5 rounded-lg border border-border/60 px-3 py-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<ActorIdentity evt={evt} agentMap={agentMap} userProfileMap={userProfileMap} />
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })}</span>
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
renderActivityEvent={(evt) => {
const tone = successfulRunHandoffActivityTone(evt.action);
const isHandoffWarning =
evt.action === SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION
|| evt.action === SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION;
return (
<div className={cn("space-y-1.5 rounded-lg border px-3 py-2 text-xs", tone.className)}>
<div className="flex items-center gap-1.5">
{isHandoffWarning ? (
<AlertTriangle className={cn("h-3.5 w-3.5 shrink-0", tone.iconClassName)} />
) : null}
<ActorIdentity evt={evt} agentMap={agentMap} userProfileMap={userProfileMap} />
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })}</span>
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
</div>
<IssueReferenceActivitySummary event={evt} />
</div>
<IssueReferenceActivitySummary event={evt} />
</div>
)}
);
}}
/>
</div>
{linkedApprovals && linkedApprovals.length > 0 && (
@ -3662,6 +3680,7 @@ export function IssueDetail() {
executionRunId={issue.executionRunId ?? null}
blockedBy={issue.blockedBy ?? []}
blockerAttention={issue.blockerAttention ?? null}
successfulRunHandoff={issue.successfulRunHandoff ?? null}
comments={threadComments}
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
interactions={interactions}

View file

@ -0,0 +1,403 @@
import type { ReactNode } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { SystemNotice } from "@/components/SystemNotice";
import { systemNoticeFixtures } from "@/fixtures/systemNoticeFixtures";
import { cn } from "@/lib/utils";
import {
CircleDashed,
FlaskConical,
Layers,
ListChecks,
Sparkles,
} from "lucide-react";
function LabSection({
id,
eyebrow,
title,
description,
accentClassName,
children,
}: {
id?: string;
eyebrow: string;
title: string;
description: string;
accentClassName?: string;
children: ReactNode;
}) {
return (
<section
id={id}
className={cn(
"rounded-[28px] border border-border/70 bg-background/85 p-4 shadow-[0_24px_60px_rgba(15,23,42,0.08)] sm:p-5",
accentClassName,
)}
>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
{eyebrow}
</div>
<h2 className="mt-1 text-xl font-semibold tracking-tight">{title}</h2>
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{description}</p>
</div>
</div>
{children}
</section>
);
}
function FixtureFrame({ caption, children }: { caption: string; children: ReactNode }) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
<CircleDashed className="h-3.5 w-3.5" />
{caption}
</div>
{children}
</div>
);
}
function MockUserBubble({
authorName,
body,
alignEnd,
}: {
authorName: string;
body: string;
alignEnd?: boolean;
}) {
return (
<div className={cn("flex items-start gap-2.5", alignEnd && "justify-end")}>
{!alignEnd ? (
<Avatar size="sm" className="shrink-0">
<AvatarFallback>{authorName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
) : null}
<div className={cn("flex min-w-0 max-w-[85%] flex-col", alignEnd && "items-end")}>
<div
className={cn(
"mb-1 px-1 text-sm font-medium text-foreground",
alignEnd ? "text-right" : "text-left",
)}
>
{authorName}
</div>
<div className="min-w-0 max-w-full rounded-2xl bg-muted px-4 py-2.5 text-sm leading-6 text-foreground">
{body}
</div>
</div>
{alignEnd ? (
<Avatar size="sm" className="shrink-0">
<AvatarFallback>{authorName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
) : null}
</div>
);
}
function MockAgentBubble({ agentName, body }: { agentName: string; body: string }) {
return (
<div className="flex items-start gap-2.5">
<Avatar size="sm" className="shrink-0">
<AvatarFallback>{agentName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 max-w-[85%] flex-col">
<div className="mb-1 px-1 text-sm font-medium text-foreground">{agentName}</div>
<div className="min-w-0 max-w-full rounded-2xl border border-border/70 bg-background px-4 py-2.5 text-sm leading-6 text-foreground">
{body}
</div>
</div>
</div>
);
}
const checklist = [
"One container per system notice — no nested chat bubble",
"Tone communicated by icon + label, never color alone",
"Operational evidence hidden behind Details, expanded only on demand",
"Issue, agent, and run metadata render as typed link rows, not raw markdown",
"Hierarchy visibly distinct from user (right-aligned) and agent (left-aligned) bubbles",
];
export function SystemNoticeUxLab() {
const fixtureById = new Map(systemNoticeFixtures.map((f) => [f.id, f] as const));
const warningCollapsed = fixtureById.get("warning-collapsed")!;
const warningExpanded = fixtureById.get("warning-expanded")!;
const dangerCollapsed = fixtureById.get("danger-collapsed")!;
const dangerExpanded = fixtureById.get("danger-expanded")!;
const neutralCollapsed = fixtureById.get("neutral-collapsed")!;
const neutralExpanded = fixtureById.get("neutral-expanded")!;
const warningNoDetails = fixtureById.get("warning-no-details")!;
return (
<div className="space-y-6">
<div className="overflow-hidden rounded-[32px] border border-border/70 bg-[linear-gradient(135deg,rgba(245,158,11,0.10),transparent_28%),linear-gradient(180deg,rgba(8,145,178,0.08),transparent_44%),var(--background)] shadow-[0_30px_80px_rgba(15,23,42,0.10)]">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.2fr)_320px]">
<div className="p-6 sm:p-7">
<div className="inline-flex items-center gap-2 rounded-full border border-amber-500/25 bg-amber-500/[0.08] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.24em] text-amber-700 dark:text-amber-300">
<FlaskConical className="h-3.5 w-3.5" />
System Notice Lab
</div>
<h1 className="mt-4 text-3xl font-semibold tracking-tight">
First-class system notice treatment
</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
Replaces the current pattern where a Paperclip-authored warning renders inside a user-style
chat bubble. The notice is one container, system-styled, with hidden-by-default operational
metadata. Tone is conveyed by icon, label, and color together so it stays accessible.
</p>
<div className="mt-5 flex flex-wrap items-center gap-2">
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
PAP-3525 plan
</Badge>
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
phase 1 UX
</Badge>
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
tones: warning · danger · neutral
</Badge>
</div>
</div>
<aside className="border-t border-border/60 bg-background/70 p-6 lg:border-l lg:border-t-0">
<div className="mb-4 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
<ListChecks className="h-4 w-4 text-amber-700 dark:text-amber-300" />
What this lab proves
</div>
<div className="space-y-3">
{checklist.map((line) => (
<div
key={line}
className="rounded-2xl border border-border/70 bg-background/85 px-4 py-3 text-sm text-muted-foreground"
>
{line}
</div>
))}
</div>
</aside>
</div>
</div>
<LabSection
id="tones"
eyebrow="Tone matrix"
title="Three tones, two states"
description="Each tone pairs a unique icon and tone label so the notice is recognizable without color. Collapsed is the default; the Details affordance reveals operational metadata only when reviewers ask for it."
accentClassName="bg-[linear-gradient(180deg,rgba(245,158,11,0.05),transparent_28%),var(--background)]"
>
<div className="space-y-5">
<FixtureFrame caption={warningCollapsed.caption}>
<SystemNotice {...warningCollapsed} />
</FixtureFrame>
<FixtureFrame caption={warningExpanded.caption}>
<SystemNotice {...warningExpanded} />
</FixtureFrame>
<FixtureFrame caption={dangerCollapsed.caption}>
<SystemNotice {...dangerCollapsed} />
</FixtureFrame>
<FixtureFrame caption={dangerExpanded.caption}>
<SystemNotice {...dangerExpanded} />
</FixtureFrame>
<FixtureFrame caption={neutralCollapsed.caption}>
<SystemNotice {...neutralCollapsed} />
</FixtureFrame>
<FixtureFrame caption={neutralExpanded.caption}>
<SystemNotice {...neutralExpanded} />
</FixtureFrame>
<FixtureFrame caption={warningNoDetails.caption}>
<SystemNotice {...warningNoDetails} />
</FixtureFrame>
</div>
</LabSection>
<LabSection
id="hierarchy"
eyebrow="Hierarchy in thread"
title="Distinct from user and agent comments"
description="Side-by-side with adjacent comment types so reviewers can confirm the system row reads as a system row — full width, no avatar gutter, no chat bubble — while user and agent comments keep their existing rounded bubbles."
accentClassName="bg-[linear-gradient(180deg,rgba(8,145,178,0.05),transparent_28%),var(--background)]"
>
<div className="space-y-4 rounded-2xl border border-border/70 bg-background/70 p-4">
<MockUserBubble
authorName="Riley Board"
body="Why does this issue keep waking back up without a clear next step?"
alignEnd
/>
<MockAgentBubble
agentName="CodexCoder"
body="The previous run completed without picking a disposition. I'll wait for the new system notice to surface so the recovery owner is unambiguous."
/>
<SystemNotice
tone="danger"
label="System alert"
source={{ label: "Paperclip", href: "/PAP/agents" }}
timestamp="2026-05-04T16:48:00.000Z"
body="Paperclip could not resolve this issue's missing disposition automatically. The issue is blocked on a recovery owner."
metadata={[
{
title: "Recovery owner",
rows: [
{
kind: "issue",
label: "Recovery issue",
identifier: "PAP-3440",
href: "/PAP/issues/PAP-3440",
title: "Successful run handoff missing disposition",
},
{
kind: "agent",
label: "Owner",
name: "CTO",
href: "/PAP/agents/cto",
},
],
},
{
title: "Run evidence",
rows: [
{
kind: "run",
label: "Source run",
runId: "9cdba892-c7ca-4d93-8604-4843873b127c",
href: "/PAP/agents/codexcoder/runs/9cdba892-c7ca-4d93-8604-4843873b127c",
status: "succeeded",
},
],
},
]}
/>
<MockUserBubble
authorName="Riley Board"
body="Thanks — assigning the recovery owner now."
alignEnd
/>
</div>
</LabSection>
<div className="grid gap-5 xl:grid-cols-2">
<LabSection
eyebrow="Before"
title="Today's nested treatment"
description="The same content rendered through the existing user-bubble + warning-callout path. Two containers, same gray background as user comments, and the warning icon is forced inside a chat row."
accentClassName="bg-[linear-gradient(180deg,rgba(244,63,94,0.05),transparent_28%),var(--background)]"
>
<div className="space-y-3 rounded-2xl border border-border/70 bg-background/70 p-4">
<div className="flex items-start gap-2.5">
<Avatar size="sm" className="shrink-0">
<AvatarFallback>YO</AvatarFallback>
</Avatar>
<div className="flex min-w-0 max-w-[85%] flex-col">
<div className="mb-1 px-1 text-sm font-medium text-foreground">You</div>
<div className="min-w-0 max-w-full rounded-2xl bg-muted px-4 py-2.5 text-sm leading-6 text-foreground">
<div className="rounded-md border border-red-500/35 bg-red-500/10 px-3 py-2.5 text-sm text-red-950 dark:text-red-100">
<div className="flex items-start gap-2">
<Sparkles className="mt-1 h-4 w-4 shrink-0 text-red-600 dark:text-red-300" />
<div className="min-w-0">
<p className="m-0 font-semibold">Successful run handoff missing</p>
<ul className="mt-1.5 list-disc space-y-0.5 pl-4 text-[13px] leading-5">
<li>Source issue: PAP-3440</li>
<li>Source run: 9cdba892-c7ca-4d93-8604-4843873b127c</li>
<li>Recovery run: 61fdb79b-8012-4676-ac71-2971830e126a</li>
<li>Status before: in_progress</li>
<li>Normalized cause: Run completed without disposition</li>
<li>Recovery owner: CTO</li>
<li>Suggested action: Reassign to recovery agent</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<p className="px-1 text-xs text-muted-foreground">
Author reads as <span className="font-medium text-foreground">You</span> even though the
author is the Paperclip system. Two containers stack the warning inside a user-style
bubble, and operational evidence is always visible.
</p>
</div>
</LabSection>
<LabSection
eyebrow="After"
title="System notice replacement"
description="One container, system-authored label, hidden details. The chat surface keeps user and agent bubbles unchanged."
accentClassName="bg-[linear-gradient(180deg,rgba(16,185,129,0.05),transparent_28%),var(--background)]"
>
<div className="space-y-3 rounded-2xl border border-border/70 bg-background/70 p-4">
<SystemNotice {...dangerCollapsed} />
<p className="px-1 text-xs text-muted-foreground">
Same content. The visible body is one short system sentence; reviewers expand{" "}
<span className="font-medium text-foreground">Details</span> only when they need run
evidence. Tone is reinforced by the octagon icon and the &quot;System alert&quot; label,
not just red.
</p>
</div>
</LabSection>
</div>
<Card className="gap-4 border-border/70 bg-background/85 py-0">
<CardHeader className="px-5 pt-5 pb-0">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
<Layers className="h-4 w-4 text-amber-700 dark:text-amber-300" />
Implementation notes
</div>
<CardTitle className="text-lg">Handoff to engineering</CardTitle>
<CardDescription>
What the Phase 4 UI implementation should preserve from this design.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 px-5 pb-5 pt-0 text-sm text-muted-foreground">
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="mb-1 font-medium text-foreground">Component</div>
Use <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">{`<SystemNotice />`}</code>{" "}
from <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">@/components/SystemNotice</code>.
It accepts <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">tone</code>,{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">label</code>,{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">body</code>,{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">metadata</code>, and{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">detailsDefaultOpen</code>.
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="mb-1 font-medium text-foreground">Routing in IssueChatThread</div>
Comments where{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">authorType === &quot;system&quot;</code>{" "}
or{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">presentation.kind === &quot;system_notice&quot;</code>{" "}
should render as a SystemNotice row at full content width never inside an{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">IssueChatUserMessage</code>{" "}
or assistant bubble.
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="mb-1 font-medium text-foreground">Accessibility</div>
The Details button has{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">aria-expanded</code>{" "}
and{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">aria-controls</code>{" "}
wired to the panel id. The container exposes{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">role=&quot;status&quot;</code>{" "}
and an{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">aria-label</code>{" "}
equal to the visible tone label so screen readers announce tone with text.
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="mb-1 font-medium text-foreground">Legacy fallback</div>
Existing comments without{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">presentation</code>{" "}
keep rendering through the current{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">SuccessfulRunHandoffCommentCallout</code>{" "}
string-detector. The new contract is opt-in for the system generators in Phase 5.
</div>
</CardContent>
</Card>
</div>
);
}
export default SystemNoticeUxLab;

View file

@ -84,6 +84,7 @@ function ScenarioCard({
function createComment(overrides: Partial<StoryComment>): StoryComment {
const createdAt = overrides.createdAt ?? new Date("2026-04-20T14:00:00.000Z");
const authorAgentId = overrides.authorAgentId ?? null;
return {
id: "comment-default",
companyId,
@ -91,6 +92,9 @@ function createComment(overrides: Partial<StoryComment>): StoryComment {
authorAgentId: null,
authorUserId: currentUserId,
body: "",
authorType: authorAgentId ? "agent" : "user",
presentation: null,
metadata: null,
createdAt,
updatedAt: overrides.updatedAt ?? createdAt,
...overrides,
@ -384,6 +388,42 @@ const issueChatComments: IssueChatComment[] = [
runId: "run-issue-chat-01",
runAgentId: codexAgent.id,
}),
createComment({
id: "comment-issue-system-warning",
authorType: "system",
authorAgentId: null,
authorUserId: null,
runId: "run-issue-chat-01",
runAgentId: codexAgent.id,
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: issueId, identifier: "PAP-3440", title: "Successful run handoff" },
{ type: "agent_link", label: "Assignee", agentId: codexAgent.id, name: codexAgent.name },
{ type: "key_value", label: "Status before", value: "in_progress" },
],
},
{
title: "Run evidence",
rows: [
{ type: "run_link", label: "Successful run", runId: "run-issue-chat-01", title: "succeeded" },
{ type: "key_value", label: "Normalized cause", value: "Run completed without disposition" },
],
},
],
},
createdAt: new Date("2026-04-20T13:54:00.000Z"),
}),
createComment({
id: "comment-issue-queued",
body: "@QAChecker please do a quick visual pass after the Storybook build is green.",

View file

@ -0,0 +1,196 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ReactNode } from "react";
import { AlertTriangle } from "lucide-react";
import { IssueBlockedNotice } from "@/components/IssueBlockedNotice";
import { KanbanBoard } from "@/components/KanbanBoard";
import { SuccessfulRunHandoffCommentCallout } from "@/components/IssueChatThread";
import { Identity } from "@/components/Identity";
import { cn, relativeTime } from "@/lib/utils";
import { formatIssueActivityAction } from "@/lib/activity-format";
import {
SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION,
SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION,
successfulRunHandoffActivityTone,
} from "@/lib/successful-run-handoff";
import { createIssue, storybookAgents } from "../fixtures/paperclipData";
function ActivityExample({ action }: { action: string }) {
const tone = successfulRunHandoffActivityTone(action);
const isWarning = action !== SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION;
return (
<div className={cn("space-y-1.5 rounded-lg border px-3 py-2 text-xs", tone.className)}>
<div className="flex items-center gap-1.5">
{isWarning ? <AlertTriangle className={cn("h-3.5 w-3.5 shrink-0", tone.iconClassName)} /> : null}
<Identity name="System" size="sm" />
<span>{formatIssueActivityAction(action)}</span>
<span className="ml-auto shrink-0">{relativeTime(new Date(Date.now() - 3 * 60_000))}</span>
</div>
</div>
);
}
function SuccessfulRunHandoffStates() {
return (
<StoryFrame>
<section className="grid gap-4 lg:grid-cols-[1.15fr_0.85fr]">
<PinnedNoticePanel />
<ActivityEventsPanel />
</section>
<section className="grid gap-4 lg:grid-cols-[0.9fr_1.1fr]">
<IssueCardPanel />
<EscalationCommentPanel />
</section>
</StoryFrame>
);
}
function handoffIssue() {
return createIssue({
id: "issue-handoff",
identifier: "PAP-3053",
issueNumber: 3053,
title: "Add board-visible handoff affordances and activity copy",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-codex",
successfulRunHandoff: {
state: "required",
required: true,
sourceRunId: "9cdba892-c7ca-4d93-8604-4843873b127c",
correctiveRunId: "61fdb79b-8012-4676-ac71-2971830e126a",
assigneeAgentId: "agent-codex",
detectedProgressSummary: "Updated the plan and created the first implementation notes.",
createdAt: new Date(),
},
});
}
function StoryFrame({ children, title = "Board-visible handoff states" }: { children: ReactNode; title?: string }) {
return (
<main className="min-h-screen bg-background p-4 text-foreground sm:p-8">
<div className="mx-auto max-w-6xl space-y-5">
<div>
<div className="text-xs font-medium uppercase text-muted-foreground">Successful-run next-step review</div>
<h1 className="mt-1 text-2xl font-semibold">{title}</h1>
</div>
{children}
</div>
</main>
);
}
function PinnedNoticePanel() {
const issue = handoffIssue();
return (
<div className="rounded-lg border border-border bg-card p-4">
<div className="mb-3 text-sm font-medium text-muted-foreground">A. Pinned needs-next-step notice</div>
<IssueBlockedNotice
issueStatus="in_progress"
blockers={[]}
successfulRunHandoff={issue.successfulRunHandoff}
agentName="CodexCoder"
/>
</div>
);
}
function ActivityEventsPanel() {
return (
<div className="rounded-lg border border-border bg-card p-4">
<div className="mb-3 text-sm font-medium text-muted-foreground">B. Activity stream events</div>
<div className="space-y-2">
<ActivityExample action={SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION} />
<ActivityExample action="issue.successful_run_handoff_resolved" />
<ActivityExample action={SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION} />
</div>
</div>
);
}
function IssueCardPanel() {
const issue = handoffIssue();
return (
<div className="rounded-lg border border-border bg-card p-4">
<div className="mb-3 text-sm font-medium text-muted-foreground">C. Issue card indicator</div>
<KanbanBoard
issues={[
issue,
createIssue({
id: "issue-review",
identifier: "PAP-3054",
issueNumber: 3054,
title: "Review completed next-step recovery",
status: "in_review",
priority: "high",
assigneeAgentId: "agent-cto",
}),
]}
agents={storybookAgents}
onUpdateIssue={() => {}}
/>
</div>
);
}
function EscalationCommentPanel() {
return (
<div className="rounded-lg border border-border bg-card p-4">
<div className="mb-3 text-sm font-medium text-muted-foreground">D. Escalation comment callout</div>
<SuccessfulRunHandoffCommentCallout
text={[
"Paperclip exhausted the bounded successful-run handoff correction for this issue, but it still has no valid issue disposition.",
"",
"This is not a runtime/adapter crash report. The source run succeeded; the remaining problem is the missing `done`, `in_review`, `blocked`, delegated follow-up, or explicit continuation path.",
"",
"- Source issue: [PAP-3053](/PAP/issues/PAP-3053)",
"- Source run: [`9cdba892-c7ca-4d93-8604-4843873b127c`](/PAP/agents/agent-codex/runs/9cdba892-c7ca-4d93-8604-4843873b127c)",
"- Corrective handoff run: [`61fdb79b-8012-4676-ac71-2971830e126a`](/PAP/agents/agent-codex/runs/61fdb79b-8012-4676-ac71-2971830e126a)",
"- Source assignee: [CodexCoder](/PAP/agents/codexcoder)",
"- Latest issue status: `in_progress`",
"- Latest handoff run status: `succeeded`",
"- Normalized cause: `successful_run_missing_state`",
"- Missing disposition: `no_clear_next_step`",
"- Suggested manager action: choose and record a valid issue disposition without copying transcript content.",
"",
"Moving it to `blocked` with an explicit recovery owner so the missing disposition is visible and owned.",
].join("\n")}
/>
</div>
);
}
function SuccessfulRunHandoffPinnedNotice() {
return <StoryFrame title="Pinned needs-next-step notice"><PinnedNoticePanel /></StoryFrame>;
}
function SuccessfulRunHandoffActivityEvents() {
return <StoryFrame title="Activity stream events"><ActivityEventsPanel /></StoryFrame>;
}
function SuccessfulRunHandoffIssueCard() {
return <StoryFrame title="Issue card indicator"><IssueCardPanel /></StoryFrame>;
}
function SuccessfulRunHandoffEscalationComment() {
return <StoryFrame title="Escalation comment callout"><EscalationCommentPanel /></StoryFrame>;
}
const meta = {
title: "Paperclip/Successful Run Handoff",
component: SuccessfulRunHandoffStates,
parameters: {
layout: "fullscreen",
},
} satisfies Meta<typeof SuccessfulRunHandoffStates>;
export default meta;
type Story = StoryObj<typeof meta>;
export const AllStates: Story = {};
export const PinnedNotice: Story = { render: () => <SuccessfulRunHandoffPinnedNotice /> };
export const ActivityEvents: Story = { render: () => <SuccessfulRunHandoffActivityEvents /> };
export const IssueCardIndicator: Story = { render: () => <SuccessfulRunHandoffIssueCard /> };
export const EscalationComment: Story = { render: () => <SuccessfulRunHandoffEscalationComment /> };

View file

@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { IssueChatUxLab } from "@/pages/IssueChatUxLab";
import { InviteUxLab } from "@/pages/InviteUxLab";
import { RunTranscriptUxLab } from "@/pages/RunTranscriptUxLab";
import { SystemNoticeUxLab } from "@/pages/SystemNoticeUxLab";
function StoryFrame({ children }: { children: React.ReactNode }) {
return (
@ -61,6 +62,23 @@ export const RunTranscriptFixtures: Story = {
},
};
export const SystemNoticeTreatment: Story = {
name: "System Notice Treatment",
render: () => (
<StoryFrame>
<SystemNoticeUxLab />
</StoryFrame>
),
parameters: {
docs: {
description: {
story:
"Renders the first-class system notice (PAP-3525 plan): warning, danger, and neutral tones in collapsed and expanded states, an in-thread hierarchy comparison against user and agent bubbles, and a before/after replacement of the current nested user-bubble + warning-callout pattern.",
},
},
},
};
export const InviteAndAccessFlow: Story = {
name: "Invite And Access Flow",
render: () => (