mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
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:
parent
50db8c01d2
commit
454edfe81e
70 changed files with 21919 additions and 125 deletions
|
|
@ -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"),
|
||||
}]}
|
||||
|
|
|
|||
63
ui/src/components/IssueBlockedNotice.test.tsx
Normal file
63
ui/src/components/IssueBlockedNotice.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}]}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
398
ui/src/components/IssueChatThreadSystemNotice.test.tsx
Normal file
398
ui/src/components/IssueChatThreadSystemNotice.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
197
ui/src/components/SystemNotice.test.tsx
Normal file
197
ui/src/components/SystemNotice.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
337
ui/src/components/SystemNotice.tsx
Normal file
337
ui/src/components/SystemNotice.tsx
Normal 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;
|
||||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
204
ui/src/fixtures/systemNoticeFixtures.ts
Normal file
204
ui/src/fixtures/systemNoticeFixtures.ts
Normal 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.",
|
||||
},
|
||||
];
|
||||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
37
ui/src/lib/successful-run-handoff.test.ts
Normal file
37
ui/src/lib/successful-run-handoff.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
90
ui/src/lib/successful-run-handoff.ts
Normal file
90
ui/src/lib/successful-run-handoff.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
143
ui/src/lib/system-notice-comment.test.ts
Normal file
143
ui/src/lib/system-notice-comment.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
125
ui/src/lib/system-notice-comment.ts
Normal file
125
ui/src/lib/system-notice-comment.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
403
ui/src/pages/SystemNoticeUxLab.tsx
Normal file
403
ui/src/pages/SystemNoticeUxLab.tsx
Normal 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 "System alert" 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 === "system"</code>{" "}
|
||||
or{" "}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">presentation.kind === "system_notice"</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="status"</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;
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
196
ui/storybook/stories/successful-run-handoff.stories.tsx
Normal file
196
ui/storybook/stories/successful-run-handoff.stories.tsx
Normal 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 /> };
|
||||
|
|
@ -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: () => (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue