Files
PaperClipAI/ui/src/lib/interrupt-handoff.ts
T
c07e650cd7 feat(ui): single-source design tokens, visual regression suite, and theme retune (#9134)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Its UI is the operator's daily surface: task lists, boards, budgets,
agent status — all built on shadcn components and Tailwind
> - Visual values (colors, spacing, type sizes, radii) were hardcoded at
~1,600 call sites: the same "small gray label" was 9/10/11px depending
on the file, charts disagreed with chips about status colors, two
toggle-switch implementations coexisted in two greens, and there was no
visual regression coverage
> - This made the UI drift-prone and made any restyle a
hundreds-of-files project, which discourages design iteration
> - This pull request extracts visual values into a single token layer
in `ui/src/index.css`, adds a Storybook visual regression suite backed
by external immutable baseline archives, and then applies a deliberate
retune reviewed change-by-change on screenshot diffs
> - The benefit is that Paperclip's look becomes a config surface:
retheming is a token edit reviewed as a snapshot diff, drift is blocked
by a token gate, and future UI PRs can prove exactly what changed
visually without committing hundreds of PNGs

## Linked Issues or Issue Description

No existing public issue covers this work (searched "design tokens",
"visual regression", "design system" across issues and PRs). Related in
spirit: Refs #8982 (theming a hardcoded panel — a one-off instance of
the same problem class this PR addresses systematically).

**Problem (feature-request form):** UI visual values are hardcoded per
call site with no source of truth and no regression coverage;
consistency depends on reviewer memory, and restyling requires mass file
edits.
**Proposed solution (this PR):** a single token layer + enforcement gate
+ externally stored visual snapshot suite, then an intentional restyle
on top of that foundation.

## What Changed

- **Token extraction (zero visual change, machine-verified during
development):** committed codemods (`scripts/codemod-*.mjs`) moved
~1,600 hardcoded color/type/spacing/radius/shadow/misc values into named
tokens in a non-inline `:root` block of `ui/src/index.css`.
- **Visual regression suite:** `pnpm test:storybook-visual` covers 255
stories × light/dark = 510 Playwright screenshots at `maxDiffPixels: 0`,
plus new primitive-coverage stories and deterministic-render fixes.
- **External visual baselines:** committed PNG snapshots were removed.
`tests/storybook-visual/baseline-manifest.json` pins an immutable
archive URL/hash/size/count, and `scripts/storybook-visual-baseline.mjs`
handles `download`, `verify`, `pack`, and trusted maintainer `upload`
flows.
- **Opt-in visual CI artifacts:** added a `Storybook Visual` workflow
that runs on manual dispatch or PRs labeled `storybook-visual`,
downloads/verifies the baseline, runs Playwright, and uploads Playwright
report/test-result artifacts for review. Normal PR runs do not mutate
baseline objects.
- **Token gate:** `pnpm check:token-gates` — zero hex literals, zero
arbitrary bracket values, zero raw font-sizes in `ui/src/components/**`
and `ui/src/pages/**`, with a documented inline allowlist for legitimate
opt-outs.
- **Theme retune (intentional, snapshot-reviewed):** new base theme
values; radius ladder derived from a single `--radius` knob; micro-type
cluster collapsed to a named ladder (`--text-nano/micro/compact` +
Tailwind `text-xs`/`text-sm`); letter-spacing collapsed to named steps.
- **One status-color vocabulary:** charts, quota/budget bar fills,
RUNNING/live chips, and liveness indicators all use the canonical
`--status-*` hues. Light-mode legibility fixes for red alert surfaces
that used dark-tuned text classes.
- **One switch:** `ToggleSwitch` restyled to the registry capsule form,
second hand-rolled implementation removed, and all call sites unified.
- **Docs:** `DESIGN.md` is the design contract; `doc/design/` holds
audit reports, decision logs, and updated guidance for external baseline
review/update workflows.
- Dead code removed (`agentStatusBadge` duplicate map), byte-identical
contrast constants consolidated, semantic renames
(`--project-seed`/`--project-none`, `--liveness-blue`).

## Verification

- `pnpm check:token-gates` — 3/3 gates CLEAN during the design-system
run
- `pnpm typecheck` && `pnpm --filter @paperclipai/ui build` — green
during the design-system run
- `node --test scripts/__tests__/storybook-visual-baseline.test.mjs` —
pass after external-baseline rework
- `pnpm exec tsc --noEmit --pretty false --module NodeNext
--moduleResolution NodeNext --target ES2022 --types
node,@playwright/test tests/storybook-visual/playwright.config.ts
tests/storybook-visual/storybook-visual.spec.ts` — pass after
external-baseline rework
- `git diff --check origin/pr/9134..HEAD` — pass after external-baseline
rework
- `find tests/storybook-visual -type f -name '*.png' -print | wc -l` —
`0`
- `node scripts/storybook-visual-baseline.mjs verify` — intentionally
fails closed until the first trusted maintainer publishes the baseline
archive and updates `baseline-manifest.json`

## Risks

- **Large but shallow:** the PR still touches many UI files due to
mechanical token extraction and retune work, but committed PNG snapshot
churn has been removed from the branch.
- **Baseline publication required before the visual suite can pass in
clean clones:** the manifest currently has placeholder archive metadata.
A trusted maintainer must publish the first immutable archive, then
update `baseline-manifest.json`.
- **Rendering platform variance:** the external baseline should be
captured in the documented Linux/Chromium environment. Future CI runs
verify against the pinned archive and fail closed on checksum/count
mismatch.
- **Visual CI is opt-in while stabilizing:** add the `storybook-visual`
label or dispatch the workflow manually to produce downloadable
Playwright report/test-result artifacts.
- **Scheduled follow-ups, deliberately out of scope:** Tailwind palette
classes map to semantic tokens in a dedicated pass; card/pill component
consolidation; ESLint ratchet. Tracked in
`doc/design/DECISION-SHEET.md`.

## Model Used

Claude Fable 5 (Anthropic, `claude-fable-5`, Mythos-class tier) with
extended thinking, running in Claude Code with tool use; mechanical
phases delegated to Claude Sonnet subagents. Follow-up external-baseline
rework assisted by OpenAI Codex (`gpt-5` coding agent with repository,
terminal, and GitHub tool use). All bulk rewrites executed via
deterministic, idempotent scripts committed in `scripts/`; intentional
visual changes were human-reviewed on screenshot contact sheets.

## Checklist

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

🤖 Generated with [Claude Code](https://claude.com/claude-code) and
OpenAI Codex

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Dotta <bippadotta@protonmail.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-07 16:22:16 -05:00

440 lines
14 KiB
TypeScript

import { parseAgentMentionHref } from "@paperclipai/shared";
/**
* Shared logic for the "interrupt handoff" UX clarity surfaces (PAP-10669).
*
* The single rule every surface enforces: an *agent* appearance — agent chip,
* "handed to <agent>" copy, a queued wake — is reserved for either (a) a durable
* `assigneeAgentId` mutation, or (b) a structured agent mention (`agent://<id>`).
* Plain text such as `QA` or `please get QA on this` never implies an agent wake.
*
* Backend interrupt semantics (see server/src/routes/issues.ts
* `operatorInterruptCancelOptions`): an operator-triggered interrupt cancels the
* active run with `errorCode: "operator_interrupted"` and
* `resultJson.operatorInterrupted = true` /
* `resultJson.interruptionSource = "issue_comment_interrupt"`.
*/
// --- Run interruption ---------------------------------------------------------
/**
* Whether a run's terminal record reflects an intentional operator interrupt
* (a board comment that cancelled the active run) rather than an unexplained
* failure or a plain control-plane cancel.
*/
export function isOperatorInterruptedRun(
resultJson: Record<string, unknown> | null | undefined,
errorCode?: string | null,
): boolean {
if (errorCode === "operator_interrupted") return true;
if (!resultJson || typeof resultJson !== "object") return false;
if (resultJson.operatorInterrupted === true) return true;
return resultJson.interruptionSource === "issue_comment_interrupt";
}
export function runStatusClassName(status: string): string {
switch (status) {
case "succeeded":
return "text-green-700 dark:text-green-300";
case "failed":
case "error":
return "text-red-700 dark:text-red-300";
case "timed_out":
return "text-orange-700 dark:text-orange-300";
case "running":
return "text-blue-700 dark:text-blue-300";
case "queued":
case "pending":
return "text-amber-700 dark:text-amber-300";
case "cancelled":
return "text-muted-foreground";
default:
return "text-foreground";
}
}
export interface RunStatusPresentation {
label: string;
className: string;
/** Screen-reader-only clarifier, or null. */
srHint: string | null;
}
/**
* Resolve the visible run status. A board-triggered interrupt reads as
* "interrupted" (amber, operator-intentional) instead of a muted "cancelled"
* that looks like an adapter failure.
*/
export function resolveRunStatusPresentation(
status: string,
opts: { operatorInterrupted?: boolean } = {},
): RunStatusPresentation {
if (status === "cancelled" && opts.operatorInterrupted) {
return {
label: "interrupted",
className: "text-amber-700 dark:text-amber-300",
srHint: "interrupted by board comment",
};
}
return {
label: status === "timed_out" ? "timed out" : status.replace(/_/g, " "),
className: runStatusClassName(status),
srHint: null,
};
}
// --- Structured mention vs plain text -----------------------------------------
const MARKDOWN_LINK_RE = /\[[^\]]*\]\(([^)]*)\)/g;
/** Ordered list of agent ids referenced via structured `agent://` mentions. */
export function extractAgentMentionIds(body: string): string[] {
const ids: string[] = [];
if (!body) return ids;
for (const match of body.matchAll(MARKDOWN_LINK_RE)) {
const href = match[1] ?? "";
const parsed = parseAgentMentionHref(href);
if (parsed?.agentId && !ids.includes(parsed.agentId)) {
ids.push(parsed.agentId);
}
}
return ids;
}
export function bodyHasAgentMention(body: string): boolean {
return extractAgentMentionIds(body).length > 0;
}
/** Strip every markdown link so chip labels/hrefs are not mistaken for plain text. */
function plainTextOutsideLinks(body: string): string {
return body.replace(MARKDOWN_LINK_RE, " ");
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export interface HandoffAgentMention {
agentId: string;
name: string;
/** Optional role label (e.g. "QA"). */
role?: string | null;
}
export interface PlainAgentNameCandidate {
agentId: string;
/** The token (agent name or role) that matched in the body. */
matchedText: string;
}
/**
* Find a plain-text token in `body` that names a known agent (by display name or
* role) but is *not* a structured `agent://` mention. This is the signal the
* composer coach uses to offer an "Insert mention" upgrade. Returns the
* highest-confidence (longest-token) match, or null.
*/
export function findPlainAgentNameCandidate(
body: string,
mentions: readonly HandoffAgentMention[],
): PlainAgentNameCandidate | null {
if (!body.trim() || mentions.length === 0) return null;
const text = plainTextOutsideLinks(body);
let best: PlainAgentNameCandidate | null = null;
for (const mention of mentions) {
const tokens = [mention.name, mention.role ?? ""].filter((t) => t && t.trim().length >= 2);
for (const token of tokens) {
const re = new RegExp(`(?<![\\w@/])${escapeRegExp(token)}(?![\\w/])`, "i");
if (re.test(text)) {
if (!best || token.length > best.matchedText.length) {
best = { agentId: mention.agentId, matchedText: token };
}
}
}
}
return best;
}
// --- Composer interpretation preview ------------------------------------------
export type HandoffPreviewTone = "neutral" | "warn";
export type ComposerHandoffPreviewKind =
| "interrupt_handoff_agent"
| "wake_agent"
| "user_handoff"
| "clear_assignee"
| "notify_agent"
| "plain_text_only"
| "none";
export interface ComposerHandoffPreview {
kind: ComposerHandoffPreviewKind;
tone: HandoffPreviewTone;
/** Copy rendered before the optional chip. */
text: string;
/** Copy rendered after the optional chip. */
suffix?: string;
/** Entity to render as a mini chip, if any. */
chip?: { kind: "agent" | "user"; id: string };
}
function parseReassignValue(value: string): { kind: "agent" | "user" | "none"; id: string | null } {
if (!value || value === "__none__") return { kind: "none", id: null };
if (value.startsWith("agent:")) {
const id = value.slice("agent:".length);
return { kind: "agent", id: id || null };
}
if (value.startsWith("user:")) {
const id = value.slice("user:".length);
return { kind: "user", id: id || null };
}
return { kind: "none", id: null };
}
export interface ComposerHandoffPreviewInput {
/** Current picker value, e.g. "agent:<id>", "user:<id>", "__none__", "". */
reassignTarget: string;
/** The issue's current assignee value in the same encoding. */
currentAssigneeValue: string;
/** Whether an agent run is currently in flight on this issue. */
hasActiveRun: boolean;
/** Whether the comment body contains a structured agent mention. */
bodyHasAgentMention: boolean;
/** First agent id structurally mentioned in the body, if any. */
mentionedAgentId?: string | null;
/** A plain-text agent-name candidate detected in the body, if any. */
plainNameCandidate?: PlainAgentNameCandidate | null;
}
/**
* Compute the one-line interpretation of what submitting this comment will
* durably do. This is the composer footer preview (design surface 1c) and the
* core of the agent-vs-user disambiguation.
*/
export function computeComposerHandoffPreview(
input: ComposerHandoffPreviewInput,
): ComposerHandoffPreview {
const hasReassignment = input.reassignTarget !== input.currentAssigneeValue;
if (hasReassignment) {
const target = parseReassignValue(input.reassignTarget);
if (target.kind === "agent" && target.id) {
return input.hasActiveRun
? {
kind: "interrupt_handoff_agent",
tone: "neutral",
text: "Interrupt current run, hand off to",
chip: { kind: "agent", id: target.id },
}
: {
kind: "wake_agent",
tone: "neutral",
text: "Wake",
chip: { kind: "agent", id: target.id },
};
}
if (target.kind === "user" && target.id) {
return {
kind: "user_handoff",
tone: "neutral",
text: "Hand off to",
chip: { kind: "user", id: target.id },
suffix: "— no agent will be notified",
};
}
// Cleared / no target chosen for the mutation.
return {
kind: "clear_assignee",
tone: "neutral",
text: "Clear responsible — no agent will be notified",
};
}
if (input.bodyHasAgentMention) {
return {
kind: "notify_agent",
tone: "neutral",
text: "Notify",
chip: input.mentionedAgentId ? { kind: "agent", id: input.mentionedAgentId } : undefined,
suffix: input.mentionedAgentId ? undefined : "the mentioned agent",
};
}
if (input.plainNameCandidate) {
return {
kind: "plain_text_only",
tone: "warn",
text: "No agent will be notified. Use @ to mention an agent.",
};
}
return { kind: "none", tone: "neutral", text: "" };
}
// --- Timeline assignee handoff / wake classification --------------------------
export type AssigneeHandoffKind = "agent_wake" | "user_handoff" | "unassigned";
export interface TimelineAssigneeLike {
agentId: string | null;
userId: string | null;
}
export interface AssigneeHandoffInfo {
kind: AssigneeHandoffKind;
/** Copy rendered after the "Wake" label in the activity card. */
wakeText: string;
}
/**
* Classify the wake outcome of an assignee change, given the *destination*
* assignee. This drives the timeline "Wake" sub-row so the three required
* states are self-describing in the activity log.
*/
export function classifyAssigneeHandoff(
to: TimelineAssigneeLike,
opts: { agentName?: string | null; interruptedRunAttached?: boolean } = {},
): AssigneeHandoffInfo {
if (to.agentId) {
const who = opts.agentName ?? "the responsible agent";
const suffix = opts.interruptedRunAttached ? " (interrupted run attached)" : "";
return { kind: "agent_wake", wakeText: `queued for ${who}${suffix}` };
}
if (to.userId) {
return {
kind: "user_handoff",
wakeText: "not created — this is a handoff to a board user",
};
}
return {
kind: "unassigned",
wakeText: "not created — no agent selected. Mention @agent or pick a responsible to dispatch.",
};
}
// --- Standalone assignee picker interrupt (PAP-10675, design surface 2) -------
export interface ReassignInterruptCopy {
/** `role=status` banner shown while a run is live and the picker is open. */
banner: string;
/** Heading for the "Interrupt & assign" confirm step. */
confirmTitle: string;
/** Primary action label for the confirm step. */
confirmAction: string;
/** Label for backing out of the confirm step. */
cancelAction: string;
}
/**
* Copy for the responsible picker's live-run states: a banner warning that an
* in-flight run will be interrupted, and the confirm step shown when the
* operator picks a *different* target mid-run. Naming the running agent keeps
* the interrupt consequence concrete instead of a bare "are you sure".
*/
export function describeReassignInterrupt(opts: { runningAgentName?: string | null } = {}): ReassignInterruptCopy {
const who = opts.runningAgentName?.trim() || "An agent";
return {
banner: `${who} is running — changing the responsible will interrupt this run.`,
confirmTitle: "Interrupt the current run?",
confirmAction: "Interrupt & assign",
cancelAction: "Cancel",
};
}
// --- Pause/hold "What this affects" buckets (PAP-10675, design surface 4) ------
export type PauseAffectsBucketKey =
| "live_runs"
| "queued_wakes"
| "agent_owned"
| "human_owned"
| "static";
export interface PauseAffectsIssueLike {
assigneeAgentId: string | null;
assigneeUserId: string | null;
activeRun: { status: "queued" | "running" } | null;
skipped?: boolean;
}
export interface PauseAffectsBucket {
key: PauseAffectsBucketKey;
label: string;
count: number;
/** One-line clarifier of what pausing does to this bucket. */
detail: string;
}
export interface PauseAffectsSummary {
buckets: PauseAffectsBucket[];
/** Total non-skipped issues the operation affects. */
affectedIssueCount: number;
/** True when no run is live or queued — there is nothing to interrupt. */
nothingLive: boolean;
}
const PAUSE_BUCKET_LABEL: Record<PauseAffectsBucketKey, string> = {
live_runs: "Live agent runs",
queued_wakes: "Queued wakes",
agent_owned: "Agent-owned",
human_owned: "Human-owned",
static: "Static",
};
const PAUSE_BUCKET_DETAIL: Record<PauseAffectsBucketKey, string> = {
live_runs: "interrupted now, re-queued when you resume",
queued_wakes: "held — they won't start until you resume",
agent_owned: "responsible agent; no run is live",
human_owned: "owned by a board user; pausing won't notify them",
static: "no responsible; nothing was going to run",
};
/**
* Partition the issues an operation affects into the five disjoint buckets the
* pause dialog summarises. Each non-skipped issue lands in exactly one bucket:
* a live run, a queued wake, or — when nothing is in flight — by owner kind.
*/
export function computePauseAffectsSummary(
issues: readonly PauseAffectsIssueLike[],
): PauseAffectsSummary {
const counts: Record<PauseAffectsBucketKey, number> = {
live_runs: 0,
queued_wakes: 0,
agent_owned: 0,
human_owned: 0,
static: 0,
};
let affectedIssueCount = 0;
for (const issue of issues) {
if (issue.skipped) continue;
affectedIssueCount += 1;
if (issue.activeRun?.status === "running") counts.live_runs += 1;
else if (issue.activeRun?.status === "queued") counts.queued_wakes += 1;
else if (issue.assigneeAgentId) counts.agent_owned += 1;
else if (issue.assigneeUserId) counts.human_owned += 1;
else counts.static += 1;
}
const order: PauseAffectsBucketKey[] = [
"live_runs",
"queued_wakes",
"agent_owned",
"human_owned",
"static",
];
return {
buckets: order.map((key) => ({
key,
label: PAUSE_BUCKET_LABEL[key],
count: counts[key],
detail: PAUSE_BUCKET_DETAIL[key],
})),
affectedIssueCount,
nothingLive: counts.live_runs === 0 && counts.queued_wakes === 0,
};
}