Compare commits

..

8 Commits

Author SHA1 Message Date
Dotta
607622143b Show live workspace service in issue properties
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 21:24:24 -05:00
Dotta
cf50805bed Hide stopped stale workspace services
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 21:24:24 -05:00
Dotta
8680f38746 Fix stale workspace command matching
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 21:24:24 -05:00
Dotta
4cae308db8 Polish execution workspace configuration page
Use design system Card, Input, and Textarea components instead of raw
HTML elements. Group related fields into labeled sections with dividers.
Add focus rings, rounded corners, and better visual hierarchy.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 21:24:24 -05:00
Dotta
37ce7750c1 Add workspace navigation overview
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 21:24:24 -05:00
Dotta
7b686b2678 Square execution workspace configuration
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 21:24:24 -05:00
Dotta
dace1bc58e Make workspace metadata copyable
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 21:24:24 -05:00
Dotta
d716343c25 Polish project workspace cards
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 21:24:24 -05:00
485 changed files with 2261 additions and 89874 deletions

View File

@@ -154,14 +154,6 @@ Each AGENTS.md body should include not just what the agent does, but how they fi
This turns a collection of agents into an organization that actually works together. Without workflow context, agents operate in isolation — they do their job but don't know what happens before or after them.
Add a concise execution contract to every generated working agent:
- Start actionable work in the same heartbeat and do not stop at a plan unless planning was requested.
- Leave durable progress in comments, documents, or work products with the next action.
- Use child issues for long or parallel delegated work instead of polling agents, sessions, or processes.
- Mark blocked work with the unblock owner and action.
- Respect budget, pause/cancel, approval gates, and company boundaries.
### Step 5: Confirm Output Location
Ask the user where to write the package. Common options:

View File

@@ -105,13 +105,6 @@ Your responsibilities:
- Implement features and fix bugs
- Write tests and documentation
- Participate in code reviews
Execution contract:
- Start actionable implementation work in the same heartbeat; do not stop at a plan unless planning was requested.
- Leave durable progress with a clear next action.
- Use child issues for long or parallel delegated work instead of polling agents, sessions, or processes.
- Mark blocked work with the unblock owner and action.
```
## teams/engineering/TEAM.md

View File

@@ -548,7 +548,7 @@ Import from `@paperclipai/adapter-utils/server-utils`:
### Prompt Templates
- Support `promptTemplate` for every run
- Use `renderTemplate()` with the standard variable set
- Default prompt should use `DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE` from `@paperclipai/adapter-utils/server-utils` so local adapters share Paperclip's execution contract: act in the same heartbeat, avoid planning-only exits unless requested, leave durable progress and a next action, use child issues instead of polling, mark blockers with owner/action, and respect governance boundaries.
- Default prompt: `"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work."`
### Error Handling
- Differentiate timeout vs process error vs parse failure

View File

@@ -2,6 +2,3 @@ DATABASE_URL=postgres://paperclip:paperclip@localhost:5432/paperclip
PORT=3100
SERVE_UI=false
BETTER_AUTH_SECRET=paperclip-dev-secret
# Discord webhook for daily merge digest (scripts/discord-daily-digest.sh)
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...

1
.gitignore vendored
View File

@@ -35,7 +35,6 @@ server/src/**/*.d.ts
server/src/**/*.d.ts.map
tmp/
feedback-export-*
diagnostics/
# Editor / tool temp files
*.tmp

View File

@@ -256,7 +256,7 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
- ✅ Scheduled Routines
- ✅ Better Budgeting
- ✅ Agent Reviews and Approvals
- Multiple Human Users
- Multiple Human Users
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
- ⚪ Artifacts & Work Products
- ⚪ Memory / Knowledge

View File

@@ -44,7 +44,7 @@ Budgets are a core control-plane feature, not an afterthought. Better budgeting
Paperclip should support explicit review and approval stages as first-class workflow steps, not just ad hoc comments. That means reviewer routing, approval gates, change requests, and durable audit trails that fit the same task model as the rest of the control plane.
### Multiple Human Users
### Multiple Human Users
Paperclip needs a clearer path from solo operator to real human teams. That means shared board access, safer collaboration, and a better model for several humans supervising the same autonomous company.

View File

@@ -258,7 +258,7 @@ See [doc/DEVELOPING.md](https://github.com/paperclipai/paperclip/blob/master/doc
- ⚪ Artifacts & Deployments
- ⚪ CEO Chat
- ⚪ MAXIMIZER MODE
- Multiple Human Users
- Multiple Human Users
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
- ⚪ Cloud deployments
- ⚪ Desktop App

View File

@@ -287,11 +287,6 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: `CLI Export Source ${Date.now()}` }),
});
await api(apiBase, `/api/companies/${sourceCompany.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ requireBoardApprovalForNewAgents: false }),
});
const sourceAgent = await api<{ id: string; name: string }>(
apiBase,

View File

@@ -3,15 +3,12 @@ import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
agents,
authUsers,
companies,
createDb,
issueComments,
issues,
projects,
routines,
routineTriggers,
@@ -20,7 +17,6 @@ import {
copyGitHooksToWorktreeGitDir,
copySeededSecretsKey,
pauseSeededScheduledRoutines,
quarantineSeededWorktreeExecutionState,
readSourceAttachmentBody,
rebindWorkspaceCwd,
resolveSourceConfigPath,
@@ -286,138 +282,6 @@ describe("worktree helpers", () => {
expect(full.nullifyColumns).toEqual({});
});
itEmbeddedPostgres("quarantines copied live execution state in seeded worktree databases", async () => {
const tempDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-quarantine-");
const db = createDb(tempDb.connectionString);
const companyId = randomUUID();
const agentId = randomUUID();
const idleAgentId = randomUUID();
const inProgressIssueId = randomUUID();
const todoIssueId = randomUUID();
const reviewIssueId = randomUUID();
const userIssueId = randomUUID();
try {
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "WTQ",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values([
{
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "running",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {
heartbeat: { enabled: true, intervalSec: 60 },
wakeOnDemand: true,
},
permissions: {},
},
{
id: idleAgentId,
companyId,
name: "Reviewer",
role: "reviewer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: { heartbeat: { enabled: false, intervalSec: 300 } },
permissions: {},
},
]);
await db.insert(issues).values([
{
id: inProgressIssueId,
companyId,
title: "Copied in-flight issue",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
issueNumber: 1,
identifier: "WTQ-1",
executionAgentNameKey: "codexcoder",
executionLockedAt: new Date("2026-04-18T00:00:00.000Z"),
},
{
id: todoIssueId,
companyId,
title: "Copied assigned todo issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
issueNumber: 2,
identifier: "WTQ-2",
},
{
id: reviewIssueId,
companyId,
title: "Copied assigned review issue",
status: "in_review",
priority: "medium",
assigneeAgentId: idleAgentId,
issueNumber: 3,
identifier: "WTQ-3",
},
{
id: userIssueId,
companyId,
title: "Copied user issue",
status: "todo",
priority: "medium",
assigneeUserId: "user-1",
issueNumber: 4,
identifier: "WTQ-4",
},
]);
await expect(quarantineSeededWorktreeExecutionState(tempDb.connectionString)).resolves.toEqual({
disabledTimerHeartbeats: 1,
resetRunningAgents: 1,
quarantinedInProgressIssues: 1,
unassignedTodoIssues: 1,
unassignedReviewIssues: 1,
});
const [quarantinedAgent] = await db.select().from(agents).where(eq(agents.id, agentId));
expect(quarantinedAgent?.status).toBe("idle");
expect(quarantinedAgent?.runtimeConfig).toMatchObject({
heartbeat: { enabled: false, intervalSec: 60 },
wakeOnDemand: true,
});
const [inProgressIssue] = await db.select().from(issues).where(eq(issues.id, inProgressIssueId));
expect(inProgressIssue?.status).toBe("blocked");
expect(inProgressIssue?.assigneeAgentId).toBeNull();
expect(inProgressIssue?.executionAgentNameKey).toBeNull();
expect(inProgressIssue?.executionLockedAt).toBeNull();
const [todoIssue] = await db.select().from(issues).where(eq(issues.id, todoIssueId));
expect(todoIssue?.status).toBe("todo");
expect(todoIssue?.assigneeAgentId).toBeNull();
const [reviewIssue] = await db.select().from(issues).where(eq(issues.id, reviewIssueId));
expect(reviewIssue?.status).toBe("in_review");
expect(reviewIssue?.assigneeAgentId).toBeNull();
const [userIssue] = await db.select().from(issues).where(eq(issues.id, userIssueId));
expect(userIssue?.status).toBe("todo");
expect(userIssue?.assigneeUserId).toBe("user-1");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, inProgressIssueId));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("Quarantined during worktree seed");
} finally {
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
await tempDb.cleanup();
}
}, 20_000);
it("copies the source local_encrypted secrets key into the seeded worktree instance", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
const originalInlineMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;

View File

@@ -93,7 +93,6 @@ type WorktreeInitOptions = {
dbPort?: number;
seed?: boolean;
seedMode?: string;
preserveLiveWork?: boolean;
force?: boolean;
};
@@ -127,7 +126,6 @@ type WorktreeReseedOptions = {
fromDataDir?: string;
fromInstance?: string;
seedMode?: string;
preserveLiveWork?: boolean;
yes?: boolean;
allowLiveTarget?: boolean;
};
@@ -139,7 +137,6 @@ type WorktreeRepairOptions = {
fromDataDir?: string;
fromInstance?: string;
seedMode?: string;
preserveLiveWork?: boolean;
noSeed?: boolean;
allowLiveTarget?: boolean;
};
@@ -182,8 +179,6 @@ type CopiedGitHooksResult = {
type SeedWorktreeDatabaseResult = {
backupSummary: string;
pausedScheduledRoutines: number;
executionQuarantine: SeededWorktreeExecutionQuarantineSummary;
reboundWorkspaces: Array<{
name: string;
fromCwd: string;
@@ -191,14 +186,6 @@ type SeedWorktreeDatabaseResult = {
}>;
};
export type SeededWorktreeExecutionQuarantineSummary = {
disabledTimerHeartbeats: number;
resetRunningAgents: number;
quarantinedInProgressIssues: number;
unassignedTodoIssues: number;
unassignedReviewIssues: number;
};
function nonEmpty(value: string | null | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
@@ -211,18 +198,6 @@ function isCurrentSourceConfigPath(sourceConfigPath: string): boolean {
return path.resolve(currentConfigPath) === path.resolve(sourceConfigPath);
}
function formatSeededWorktreeExecutionQuarantineSummary(
summary: SeededWorktreeExecutionQuarantineSummary,
): string {
return [
`disabled timer heartbeats: ${summary.disabledTimerHeartbeats}`,
`reset running agents: ${summary.resetRunningAgents}`,
`quarantined in-progress issues: ${summary.quarantinedInProgressIssues}`,
`unassigned todo issues: ${summary.unassignedTodoIssues}`,
`unassigned review issues: ${summary.unassignedReviewIssues}`,
].join(", ");
}
const WORKTREE_NAME_PREFIX = "paperclip-";
function resolveWorktreeMakeName(name: string): string {
@@ -1144,133 +1119,6 @@ export async function pauseSeededScheduledRoutines(connectionString: string): Pr
}
}
const EMPTY_SEEDED_WORKTREE_EXECUTION_QUARANTINE_SUMMARY: SeededWorktreeExecutionQuarantineSummary = {
disabledTimerHeartbeats: 0,
resetRunningAgents: 0,
quarantinedInProgressIssues: 0,
unassignedTodoIssues: 0,
unassignedReviewIssues: 0,
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isEnabledValue(value: unknown): boolean {
return value === true || value === "true" || value === 1 || value === "1";
}
function normalizeWorktreeRuntimeConfig(runtimeConfig: unknown): {
runtimeConfig: Record<string, unknown>;
disabledTimerHeartbeat: boolean;
changed: boolean;
} {
const nextRuntimeConfig = isRecord(runtimeConfig) ? { ...runtimeConfig } : {};
const heartbeat = isRecord(nextRuntimeConfig.heartbeat) ? { ...nextRuntimeConfig.heartbeat } : null;
if (!heartbeat) {
return { runtimeConfig: nextRuntimeConfig, disabledTimerHeartbeat: false, changed: false };
}
const disabledTimerHeartbeat = isEnabledValue(heartbeat.enabled);
if (heartbeat.enabled !== false) {
heartbeat.enabled = false;
nextRuntimeConfig.heartbeat = heartbeat;
return { runtimeConfig: nextRuntimeConfig, disabledTimerHeartbeat, changed: true };
}
return { runtimeConfig: nextRuntimeConfig, disabledTimerHeartbeat: false, changed: false };
}
export async function quarantineSeededWorktreeExecutionState(
connectionString: string,
): Promise<SeededWorktreeExecutionQuarantineSummary> {
const db = createDb(connectionString);
const summary = { ...EMPTY_SEEDED_WORKTREE_EXECUTION_QUARANTINE_SUMMARY };
try {
await db.transaction(async (tx) => {
const seededAgents = await tx
.select({
id: agents.id,
status: agents.status,
runtimeConfig: agents.runtimeConfig,
})
.from(agents);
for (const agent of seededAgents) {
const normalized = normalizeWorktreeRuntimeConfig(agent.runtimeConfig);
const nextStatus = agent.status === "running" ? "idle" : agent.status;
if (normalized.disabledTimerHeartbeat) {
summary.disabledTimerHeartbeats += 1;
}
if (agent.status === "running") {
summary.resetRunningAgents += 1;
}
if (normalized.changed || nextStatus !== agent.status) {
await tx
.update(agents)
.set({
runtimeConfig: normalized.runtimeConfig,
status: nextStatus,
updatedAt: new Date(),
})
.where(eq(agents.id, agent.id));
}
}
const affectedIssues = await tx
.select({
id: issues.id,
companyId: issues.companyId,
status: issues.status,
})
.from(issues)
.where(
and(
sql`${issues.assigneeAgentId} is not null`,
sql`${issues.assigneeUserId} is null`,
inArray(issues.status, ["todo", "in_progress", "in_review"]),
),
);
for (const issue of affectedIssues) {
const nextStatus = issue.status === "in_progress" ? "blocked" : issue.status;
await tx
.update(issues)
.set({
status: nextStatus,
assigneeAgentId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
executionWorkspaceId: null,
updatedAt: new Date(),
})
.where(eq(issues.id, issue.id));
if (issue.status === "in_progress") {
summary.quarantinedInProgressIssues += 1;
await tx.insert(issueComments).values({
companyId: issue.companyId,
issueId: issue.id,
body:
"Quarantined during worktree seed so copied in-flight work does not auto-run in this isolated instance. " +
"Reassign or unblock here only if you intentionally want the worktree instance to own this task.",
});
} else if (issue.status === "todo") {
summary.unassignedTodoIssues += 1;
} else if (issue.status === "in_review") {
summary.unassignedReviewIssues += 1;
}
}
});
return summary;
} finally {
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
}
}
async function seedWorktreeDatabase(input: {
sourceConfigPath: string;
sourceConfig: PaperclipConfig;
@@ -1278,7 +1126,6 @@ async function seedWorktreeDatabase(input: {
targetPaths: WorktreeLocalPaths;
instanceId: string;
seedMode: WorktreeSeedMode;
preserveLiveWork?: boolean;
}): Promise<SeedWorktreeDatabaseResult> {
const seedPlan = resolveWorktreeSeedPlan(input.seedMode);
const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath);
@@ -1329,10 +1176,7 @@ async function seedWorktreeDatabase(input: {
backupFile: backup.backupFile,
});
await applyPendingMigrations(targetConnectionString);
const executionQuarantine = input.preserveLiveWork
? { ...EMPTY_SEEDED_WORKTREE_EXECUTION_QUARANTINE_SUMMARY }
: await quarantineSeededWorktreeExecutionState(targetConnectionString);
const pausedScheduledRoutines = await pauseSeededScheduledRoutines(targetConnectionString);
await pauseSeededScheduledRoutines(targetConnectionString);
const reboundWorkspaces = await rebindSeededProjectWorkspaces({
targetConnectionString,
currentCwd: input.targetPaths.cwd,
@@ -1340,8 +1184,6 @@ async function seedWorktreeDatabase(input: {
return {
backupSummary: formatDatabaseBackupResult(backup),
pausedScheduledRoutines,
executionQuarantine,
reboundWorkspaces,
};
} finally {
@@ -1420,8 +1262,6 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
const copiedGitHooks = copyGitHooksToWorktreeGitDir(cwd);
let seedSummary: string | null = null;
let seedExecutionQuarantineSummary: SeededWorktreeExecutionQuarantineSummary | null = null;
let pausedScheduledRoutineCount: number | null = null;
let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
if (opts.seed !== false) {
if (!sourceConfig) {
@@ -1439,11 +1279,8 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
targetPaths: paths,
instanceId,
seedMode,
preserveLiveWork: opts.preserveLiveWork,
});
seedSummary = seeded.backupSummary;
seedExecutionQuarantineSummary = seeded.executionQuarantine;
pausedScheduledRoutineCount = seeded.pausedScheduledRoutines;
reboundWorkspaceSummary = seeded.reboundWorkspaces;
spinner.stop(`Seeded isolated worktree database (${seedMode}).`);
} catch (error) {
@@ -1466,16 +1303,6 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
if (seedSummary) {
p.log.message(pc.dim(`Seed mode: ${seedMode}`));
p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`));
if (opts.preserveLiveWork) {
p.log.warning("Preserved copied live work; this worktree instance may auto-run source-instance assignments.");
} else if (seedExecutionQuarantineSummary) {
p.log.message(
pc.dim(`Seed execution quarantine: ${formatSeededWorktreeExecutionQuarantineSummary(seedExecutionQuarantineSummary)}`),
);
}
if (pausedScheduledRoutineCount != null) {
p.log.message(pc.dim(`Paused scheduled routines: ${pausedScheduledRoutineCount}`));
}
for (const rebound of reboundWorkspaceSummary) {
p.log.message(
pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`),
@@ -3120,20 +2947,11 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise<void> {
targetPaths,
instanceId: targetPaths.instanceId,
seedMode,
preserveLiveWork: opts.preserveLiveWork,
});
spinner.stop(`Reseeded ${targetEndpoint.label} (${seedMode}).`);
p.log.message(pc.dim(`Source: ${source.configPath}`));
p.log.message(pc.dim(`Target: ${targetEndpoint.configPath}`));
p.log.message(pc.dim(`Seed snapshot: ${seeded.backupSummary}`));
if (opts.preserveLiveWork) {
p.log.warning("Preserved copied live work; this worktree instance may auto-run source-instance assignments.");
} else {
p.log.message(
pc.dim(`Seed execution quarantine: ${formatSeededWorktreeExecutionQuarantineSummary(seeded.executionQuarantine)}`),
);
}
p.log.message(pc.dim(`Paused scheduled routines: ${seeded.pausedScheduledRoutines}`));
for (const rebound of seeded.reboundWorkspaces) {
p.log.message(
pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`),
@@ -3197,7 +3015,6 @@ export async function worktreeRepairCommand(opts: WorktreeRepairOptions): Promis
fromConfig: source.configPath,
to: target.rootPath,
seedMode,
preserveLiveWork: opts.preserveLiveWork,
yes: true,
allowLiveTarget: opts.allowLiveTarget,
});
@@ -3230,7 +3047,6 @@ export async function worktreeRepairCommand(opts: WorktreeRepairOptions): Promis
fromInstance: opts.fromInstance,
seed: opts.noSeed ? false : true,
seedMode,
preserveLiveWork: opts.preserveLiveWork,
force: true,
});
} finally {
@@ -3254,7 +3070,6 @@ export function registerWorktreeCommands(program: Command): void {
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues in the seeded worktree", false)
.option("--no-seed", "Skip database seeding from the source instance")
.option("--force", "Replace existing repo-local config and isolated instance data", false)
.action(worktreeMakeCommand);
@@ -3271,7 +3086,6 @@ export function registerWorktreeCommands(program: Command): void {
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues in the seeded worktree", false)
.option("--no-seed", "Skip database seeding from the source instance")
.option("--force", "Replace existing repo-local config and isolated instance data", false)
.action(worktreeInitCommand);
@@ -3311,7 +3125,6 @@ export function registerWorktreeCommands(program: Command): void {
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
.option("--from-instance <id>", "Source instance id when deriving the source config")
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: full)", "full")
.option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues in the seeded worktree", false)
.option("--yes", "Skip the destructive confirmation prompt", false)
.option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false)
.action(worktreeReseedCommand);
@@ -3325,7 +3138,6 @@ export function registerWorktreeCommands(program: Command): void {
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
.option("--from-instance <id>", "Source instance id when deriving the source config (default: default)")
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues in the seeded worktree", false)
.option("--no-seed", "Repair metadata only and skip reseeding when bootstrapping a missing worktree config", false)
.option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false)
.action(worktreeRepairCommand);

View File

@@ -27,18 +27,6 @@ pnpm db:migrate
When `DATABASE_URL` is unset, this command targets the current embedded PostgreSQL instance for your active Paperclip config/instance.
Issue reference mentions follow the normal migration path: the schema migration creates the tracking table, but it does not backfill historical issue titles, descriptions, comments, or documents automatically.
To backfill existing content manually after migrating, run:
```sh
pnpm issue-references:backfill
# optional: limit to one company
pnpm issue-references:backfill -- --company <company-id>
```
Future issue, comment, and document writes sync references automatically without running the backfill command.
This mode is ideal for local development and one-command installs.
Docker note: the Docker quickstart image also uses embedded PostgreSQL by default. Persist `/paperclip` to keep DB state across container restarts (see `doc/DOCKER.md`).
@@ -106,16 +94,6 @@ Set `DATABASE_URL` in your `.env`:
DATABASE_URL=postgres://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres
```
For hosted deployments that use a pooled runtime URL, set
`DATABASE_MIGRATION_URL` to the direct connection URL. Paperclip uses it for
startup schema checks/migrations and plugin namespace migrations, while the app
continues to use `DATABASE_URL` for runtime queries:
```sh
DATABASE_URL=postgres://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres
DATABASE_MIGRATION_URL=postgres://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:5432/postgres
```
If using connection pooling (port 6543), the `postgres` client must disable prepared statements. Update `packages/db/src/client.ts`:
```ts

View File

@@ -43,17 +43,6 @@ This starts:
`pnpm dev` and `pnpm dev:once` are now idempotent for the current repo and instance: if the matching Paperclip dev runner is already alive, Paperclip reports the existing process instead of starting a duplicate.
## Storybook
The board UI Storybook keeps stories and Storybook config under `ui/storybook/` so component review files stay out of the app source routes.
```sh
pnpm storybook
pnpm build-storybook
```
These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`.
Inspect or stop the current repo's managed dev runner:
```sh
@@ -220,8 +209,6 @@ Seed modes:
- `full` makes a full logical clone of the source instance
- `--no-seed` creates an empty isolated instance
Seeded worktree instances quarantine copied live execution by default for both `minimal` and `full` seeds. During restore, Paperclip disables copied agent timer heartbeats, resets copied `running` agents to `idle`, blocks and unassigns copied agent-owned `in_progress` issues, and unassigns copied agent-owned `todo`/`in_review` issues. This keeps a freshly booted worktree from starting agents for work already owned by the source instance. Pass `--preserve-live-work` only when you intentionally want the isolated worktree to resume copied assignments.
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
`pnpm dev` now fails fast in a linked git worktree when `.paperclip/.env` is missing, instead of silently booting against the default instance/port. If that happens, run `paperclipai worktree init` in the worktree first.
@@ -235,8 +222,6 @@ That repo-local env also sets:
- `PAPERCLIP_WORKTREE_COLOR=<hex-color>`
The server/UI use those values for worktree-specific branding such as the top banner and dynamically colored favicon.
Authenticated worktree servers also use the `PAPERCLIP_INSTANCE_ID` value to scope Better Auth cookie names.
Browser cookies are shared by host rather than port, so this prevents logging into one `127.0.0.1:<port>` worktree from replacing another worktree server's session cookie.
Print shell exports explicitly when needed:

View File

@@ -619,7 +619,7 @@ Per-agent schedule fields in `adapter_config`:
- `enabled` boolean
- `intervalSec` integer (minimum 30)
- `maxConcurrentRuns` integer; new agents default to `5`
- `maxConcurrentRuns` fixed at `1` for V1
Scheduler must skip invocation when:

View File

@@ -1,122 +0,0 @@
# Paperclip DS Extraction — Review
- **Generated:** 2026-04-21
- **Repo SHA:** `a26e1288b627e82c554445732c7d844648e6b5e1`
- **Branch:** `sockmonster-ds-extraction`
- **Discovery config:** [`_discovery.json`](./_discovery.json)
- **Scope:** `ui/` (`@paperclipai/ui`). Plugin SDK (`packages/plugins/sdk/src/ui/`) treated as contract surface, not implementation surface.
This is the entry point. Everything else is linked from here. Contents are ordered by **expected human value**, not by stage.
---
## Bottom line
One finding sits upstream of most of the others — resolving it moves four pattern docs from "pending" to "codifiable" and unblocks the single biggest token gap.
> **The app has a canonical status/priority color catalog (`ui/src/lib/status-colors.ts`) that bypasses the DS token layer and uses raw Tailwind palette classes across 11 hues and ~24 status keys.** Status indicators (`StatusIcon`, `StatusBadge`, `PriorityIcon`, `agentStatusDot`), chart colors (`ActivityCharts.tsx`, hardcoded hex), budget severity indicators (`BudgetPolicyCard`, `BudgetIncidentCard`, `BudgetSidebarMarker`), and quota fills (`QuotaBar`) are **four distinct systems encoding the same red/amber/green severity concept**, none of which share DS tokens.
A `--signal-*` token family would collapse four surfaces onto one vocabulary and make [status-display.md](./patterns/status-display.md), [quota-display.md](./patterns/quota-display.md), and the severity-indicator pattern opportunity all codifiable. See [tokens-review.md §4](./tokens/tokens-review.md#4-status-colorsts-is-a-canonical-semantic-color-catalog-that-bypasses-the-ds) and [patterns-review.md §6](./patterns/patterns-review.md#6-severity-indicator-3-level-health-display--pattern-opportunity).
Three other findings are high-value but smaller in scope:
- **`destructive-foreground` has a buggy light-mode value** equal to `destructive` itself (would render invisible if anyone used it — nobody does, so the bug is masked). [tokens-review.md §2](./tokens/tokens-review.md#2-destructive-foreground-has-a-wrong-light-mode-value-and-is-unused)
- **13 color tokens are dead** (all 5 `chart-*`, all 8 `sidebar-*`). Consolidating would drop color-token count from 32 → 19. [tokens-review.md §1, §3](./tokens/tokens-review.md#1-chart--tokens-are-dead)
- **The radius scale is non-monotonic and under-specified** — 227 uses of `rounded-lg` / `rounded-xl` resolve to square corners because `--radius-lg` / `--radius-xl` = 0. Needs a founder call on whether this is intentional flat-design or a stale migration state. [tokens-review.md §Radius scale](./tokens/tokens-review.md#radius-scale--under-founder-review)
---
## Recommended review order
Sequenced so each step unblocks the next. Total time estimated ~23 hours.
| # | Read | Decide | Est. |
|---|---|---|---|
| 1 | [tokens-review.md §High-confidence drift](./tokens/tokens-review.md#high-confidence-drift-likely-should-be-fixed) | Scope the signal-token work. Confirm dead-token deletions (chart-*, sidebar-*, destructive-foreground). | **25 min** |
| 2 | [tokens-review.md §Radius scale](./tokens/tokens-review.md#radius-scale--under-founder-review) | One call: intentional flat lg/xl, or restore a monotonic scale. | **15 min** |
| 3 | [components-review.md §Likely duplicates](./components/components-review.md#likely-duplicates) | Nine duplicate families. For each, note "merge/keep/defer" — patterns flow from the decisions. | **30 min** |
| 4 | [components-review.md §Plugin SDK contract gap](./components/components-review.md#plugin-sdk-contract-gap) | Choose: fulfill the 9 missing contracts, shrink them, or hybrid. | **15 min** |
| 5 | [patterns-review.md §Variance across documented patterns](./patterns/patterns-review.md#variance-across-documented-patterns-whats-inconsistent-between-instances) | Look at the status-element variance in detail pages (four different treatments across eight pages). | **15 min** |
| 6 | [patterns-review.md §Paperclip-domain patterns](./patterns/patterns-review.md#paperclip-domain-patterns-worth-calling-out-opportunities-not-ratified-patterns) | Reality-check the run-transcript / heartbeat / metric-cell opportunities before any codify step. | **20 min** |
| 7 | [components-review.md §Naming inconsistencies](./components/components-review.md#naming-inconsistencies) | Lower priority — no decision required today, but at least skim. | **10 min** |
| 8 | [components-review.md §Story coverage gaps](./components/components-review.md#story-coverage-gaps) | Shadcn primitives missing from `foundations.stories.tsx` (`collapsible`, `dropdown-menu`, `avatar`, `skeleton`, `scroll-area`) is a small, targeted fix. | **10 min** |
---
## Confidence
### High confidence (probably correct, spot-check only)
- **32 color tokens** extracted from `ui/src/index.css` (19 semantic surfaces, 5 chart, 8 sidebar).
- **5 radius tokens**, with value + definition-site recorded.
- **Usage counts per color and radius token** computed by unioning Tailwind-utility occurrences and `var(--token)` references across `ui/src/**/*.{ts,tsx,css}` (excluding the definition file itself). Counts are rough by intent — within ±10%.
- **135 component files** enumerated, classified into 22 primitives / 64 composites / 47 standalones / 2 non-component utilities.
- **104 components** cross-referenced against 14 Storybook files via import-graph parsing.
- **50 pages** enumerated; per-page import set captured.
- **11 plugin SDK ambient components** enumerated with host-implementation status.
- **4 components confirmed as storybook-only** (0 production uses): `AccountingModelCard`, `AgentProperties`, `CompanySwitcher`, `ExecutionParticipantPicker`.
### Medium confidence (review carefully)
- **Duplicate-family flags.** Eight families surfaced ([components-review.md §Likely duplicates](./components/components-review.md#likely-duplicates)) are based on name parallelism and/or shared imports. The strongest signals (entity-creation dialogs, subscription panels) need a side-by-side diff to confirm merge-ability; this extraction didn't do that.
- **`BillerSpendCard` vs `FinanceBillerCard` as likely-true-duplicate.** Flagged per directive. Not confirmed without a diff.
- **Pattern instance counts.** The `detail-page` and `list-page` patterns were identified by import-set intersection, which is a proxy for structural similarity. A page can import a component and not actually render it in the expected position; pattern shape is inferred, not verified pixel-by-pixel.
- **CVA variant extraction for primitives.** Parsed 3 files successfully (`button`, `badge`, one more). The rest of the primitives likely have variants that the static parser missed.
- **The severity-indicator pattern ([patterns-review.md §6](./patterns/patterns-review.md#6-severity-indicator-3-level-health-display--pattern-opportunity)).** Named as an *opportunity*, not a ratified pattern — cross-system evidence is strong but the four systems weren't compared pixel-for-pixel; they may not actually agree on what "warning" looks like.
- **Story coverage set.** Computed by parsing imports in `.stories.tsx` files. A component that's imported by a story but never actually rendered would falsely appear covered. Low risk given story-file structure but not validated.
### Low confidence (likely wrong, incomplete, or judgment-heavy)
- **Motion tokens.** None exist as variables — motion is inline `@keyframes` + `cubic-bezier()`. Pattern docs don't describe motion. The 5 keyframes in `index.css` are listed; their callers are not cross-referenced.
- **Typography.** No project-local font/type tokens found — the section is near-empty because Tailwind v4 defaults carry the load plus `@tailwindcss/typography`. If there are intended type-scale conventions in components that weren't captured by token extraction, those are missed.
- **Elevation / shadows.** No tokens, so no inventory. Ad-hoc `shadow-[…]` values across polished surfaces were enumerated in [tokens-review.md §9](./tokens/tokens-review.md#9-arbitrary-shadow-values-in-production-surfaces), but the list is not exhaustive.
- **Prop extraction for primitives using `React.ComponentProps<"button"> & VariantProps<...>`.** The static parser looks for `*Props` interfaces; inline-type components (most shadcn primitives) get "no Props interface found" in their detail files.
- **Per-component token consumption cross-reference.** Components/detail files don't list which specific tokens each component consumes (would require per-file class-attribute parsing). Token usage counts are global; per-component token drift is flagged only where specific drift was found.
- **Pattern: "detail-page header."** Called out as a sub-pattern inside detail-page doc but not given its own file — instances share only 45 imports, not a complete shape.
---
## Known scope limitations
- **Plugin SDK UI.** In-scope as a contract surface (documented in [components/index.md §Plugin SDK contracts](./components/index.md#plugin-sdk-contracts-11)). Not in-scope for pattern extraction — host implementations are covered; plugin-side usage patterns are not.
- **Low-usage components (12 code imports, 76 of them).** Listed in [components/index.md](./components/index.md) with status marker `📘 below-threshold`; no dedicated detail file. Per the directive: *nothing gets silently dropped*.
- **Pattern documentation:** capped at 10 real patterns. Eleven pattern files exist because the duplicate-family directive required documenting three below-threshold pairs (subscription-panel, sidebar-menu pair inside sidebar-chrome, quota-display). Pattern opportunities surfaced in patterns-review.md are not yet pattern files.
- **UX Lab pages (`InviteUxLab`, `IssueChatUxLab`, `RunTranscriptUxLab`).** Acknowledged prototypes with distinct visual language. Excluded from pattern extraction. Their raw-palette usage is counted in drift stats but not pursued.
- **Hermes / adapter code.** `ui/src/adapters/` contains per-adapter config fields. Not a DS concern; skipped.
- **Mobile treatments.** `MobileBottomNav` and `SwipeToArchive` are noted but not extracted as their own pattern. Mobile patterns appear to live inside individual list/detail pages rather than as shared primitives.
- **Diff mode.** This is a fresh run; `doc/design-system/` did not exist before. No diff was generated. Subsequent re-runs should run in diff mode (see [ds-extraction skill §Diff mode](../../.agents/skills/ds-extraction/SKILL.md#diff-mode)).
---
## What's on disk
```
doc/design-system/
├── REVIEW.md ← you are here
├── _discovery.json ← Stage 0 output
├── _pages.json ← Stage 2 scratch (50 pages)
├── _composition-graph.json ← Stage 2 scratch (135 components)
├── _stories.json ← Stage 2 scratch (14 stories)
├── tokens/
│ ├── tokens.md ← canonical human-readable inventory
│ ├── tokens.json ← machine-readable for downstream tooling
│ └── tokens-review.md ← the high-value drift artifact
├── components/
│ ├── index.md ← all 135 files + 11 SDK contracts, with status markers
│ ├── components-review.md ← duplicates, naming, token non-compliance, story gaps, SDK gap
│ └── [ComponentName].md × 53 ← per-component detail files (3+ uses threshold)
└── patterns/
├── index.md
├── patterns-review.md ← variance, opportunities, what to resolve before re-running
├── list-page.md ← 12 instances
├── detail-page.md ← 8 instances
├── sidebar-chrome.md ← 6 + 2 instances
├── finance-card.md ← 5 instances
├── entity-properties-panel.md ← 4 + 1 instances (open Q on generic)
├── entity-creation-dialog.md ← 4 instances
├── status-display.md ← 3 components + catalog (pending signal tokens)
├── entity-row.md ← 3 instances
├── subscription-panel.md ← 2 instances (below threshold — documented)
└── quota-display.md ← 2 instances (below threshold — documented)
```
Total: **~80 files**.

File diff suppressed because it is too large Load Diff

View File

@@ -1,229 +0,0 @@
{
"generated_at": "2026-04-21T00:00:00Z",
"repo_sha": "a26e1288b627e82c554445732c7d844648e6b5e1",
"branch": "sockmonster-ds-extraction",
"styling": {
"tailwind_version": "v4",
"tailwind_config": null,
"tailwind_config_note": "No tailwind.config.* file. Tailwind v4 CSS-first config: theme is declared via @theme inline blocks in ui/src/index.css. Build integration via @tailwindcss/vite.",
"css_variables_file": "ui/src/index.css",
"uses_css_variables": true,
"uses_cva": true,
"uses_cn_helper": true,
"cn_helper_location": "ui/src/lib/utils.ts:6",
"shadcn_present": true,
"shadcn_style": "new-york",
"shadcn_base_color": "neutral",
"shadcn_css_variables": true,
"shadcn_rsc": false,
"shadcn_icon_library": "lucide",
"shadcn_aliases": {
"components": "@/components",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks",
"utils": "@/lib/utils"
},
"shadcn_skill_path": ".agents/skills/shadcn/SKILL.md",
"other_styling": [
{
"library": "@tailwindcss/typography",
"usage": "@plugin in ui/src/index.css; prose class styling for markdown"
}
],
"notes": "Tailwind v4 with shadcn/ui in new-york style. components.json present. cn() = clsx + tailwind-merge. Custom @custom-variant dark (&:is(.dark *)). No tailwindcss-animate plugin."
},
"tokens": {
"color": {
"sources": ["ui/src/index.css"],
"authoritative_source": "ui/src/index.css",
"definition_blocks": [
{ "block": "@theme inline", "role": "exposes --color-* aliases to Tailwind", "line_range": "6-43" },
{ "block": ":root", "role": "authoritative light-mode values", "line_range": "45-80" },
{ "block": ".dark", "role": "dark-mode overrides", "line_range": "82-115" }
],
"count_estimate": 32,
"categories": {
"semantic_neutral_and_intent": [
"background", "foreground", "card", "card-foreground", "popover", "popover-foreground",
"primary", "primary-foreground", "secondary", "secondary-foreground",
"muted", "muted-foreground", "accent", "accent-foreground",
"destructive", "destructive-foreground",
"border", "input", "ring"
],
"chart": ["chart-1", "chart-2", "chart-3", "chart-4", "chart-5"],
"sidebar": [
"sidebar", "sidebar-foreground",
"sidebar-primary", "sidebar-primary-foreground",
"sidebar-accent", "sidebar-accent-foreground",
"sidebar-border", "sidebar-ring"
]
},
"includes_signal_green": false,
"value_format": "oklch",
"dark_mode_convention": ".dark class selector via @custom-variant"
},
"spacing": {
"sources": [],
"authoritative_source": null,
"count_estimate": 0,
"note": "No project-local spacing tokens. Spacing uses Tailwind v4 defaults inherited from tailwindcss package."
},
"type": {
"sources": [],
"authoritative_source": null,
"count_estimate": 0,
"font_faces": [],
"google_fonts": [],
"note": "No project-local font/type tokens. Typography uses Tailwind v4 defaults + @tailwindcss/typography plugin. Markdown styling via `.paperclip-markdown` and `.paperclip-mdxeditor-content` classes with hardcoded font-size/line-height values in index.css."
},
"radius": {
"sources": ["ui/src/index.css"],
"authoritative_source": "ui/src/index.css",
"count_estimate": 5,
"tokens": [
{ "name": "--radius", "value": "0", "defined_at": "ui/src/index.css:47", "scope": ":root" },
{ "name": "--radius-sm", "value": "0.375rem", "defined_at": "ui/src/index.css:39", "scope": "@theme" },
{ "name": "--radius-md", "value": "0.5rem", "defined_at": "ui/src/index.css:40", "scope": "@theme" },
{ "name": "--radius-lg", "value": "0px", "defined_at": "ui/src/index.css:41", "scope": "@theme" },
{ "name": "--radius-xl", "value": "0px", "defined_at": "ui/src/index.css:42", "scope": "@theme" }
],
"note": "Unusual: --radius-lg and --radius-xl are 0px while --radius-sm and --radius-md are non-zero. Likely intentional flat-design choice at the outer scale, but worth confirming. Also --radius (base, :root) = 0 used by MDXEditor integration; not part of @theme."
},
"motion": {
"sources": ["ui/src/index.css"],
"authoritative_source": null,
"count_estimate": 0,
"css_variable_tokens": [],
"keyframes": [
{ "name": "dashboard-activity-enter", "defined_at": "ui/src/index.css:228" },
{ "name": "dashboard-activity-highlight", "defined_at": "ui/src/index.css:246" },
{ "name": "cot-line-slide-in", "defined_at": "ui/src/index.css:272" },
{ "name": "cot-line-slide-out", "defined_at": "ui/src/index.css:277" },
{ "name": "shimmer-text-slide", "defined_at": "ui/src/index.css:298" }
],
"tailwindcss_animate_plugin": false,
"note": "No --motion-* or --duration-* tokens. Motion is defined as inline @keyframes + inline cubic-bezier values (commonly cubic-bezier(0.16, 1, 0.3, 1) and cubic-bezier(0.4, 0, 0.2, 1)). prefers-reduced-motion respected."
},
"elevation": {
"sources": [],
"authoritative_source": null,
"count_estimate": 0,
"note": "No --shadow-* tokens and no theme.boxShadow. Project appears to avoid shadows as a design choice (borders and background shifts carry elevation). Verify during extraction by grepping for box-shadow usage in components."
},
"scoped_non_ds_variables": [
{
"group": "MDXEditor theme bridge",
"selector": ".paperclip-mdxeditor-scope, .paperclip-mdxeditor",
"line_range": "332-361",
"variable_count": 24,
"role": "Maps host DS tokens onto MDXEditor's internal token names (--baseBase, --accentSolid, etc.). Consumed alias layer, not authoritative DS tokens."
},
{
"group": "Shimmer text effect",
"selector": ".shimmer-text",
"variable_count": 2,
"role": "Component-local (--shimmer-base, --shimmer-highlight)."
}
]
},
"components": {
"primary_root": "ui/src/components/",
"layout": "mixed",
"layout_notes": "Not purely flat and not purely nested. Subdirectories exist for a specific subset; the majority live flat at the top level. Naming convention for primitives is lowercase-kebab (button.tsx, dropdown-menu.tsx); composites/features use PascalCase (AgentConfigForm.tsx).",
"subdirectories": [
{ "path": "ui/src/components/ui/", "role": "shadcn primitives", "file_count": 22 },
{ "path": "ui/src/components/access/", "role": "access-control feature cluster", "file_count": 3 },
{ "path": "ui/src/components/transcript/", "role": "run transcript feature cluster", "file_count": 2 }
],
"top_level_tsx_count": 108,
"primitives_in_components_ui": [
"avatar", "badge", "breadcrumb", "button", "card", "checkbox", "collapsible",
"command", "dialog", "dropdown-menu", "input", "label", "popover",
"scroll-area", "select", "separator", "sheet", "skeleton", "tabs",
"textarea", "toggle-switch", "tooltip"
],
"count_estimate": 133,
"plugin_sdk_components": {
"path": "packages/plugins/sdk/src/ui/components.ts",
"status": "ambient-types-only",
"count": 11,
"declared_components": [
"MetricCard", "StatusBadge", "DataTable", "TimeseriesChart", "MarkdownBlock",
"KeyValueList", "ActionBar", "LogView", "JsonTree", "Spinner", "ErrorBoundary"
],
"runtime_model": "Host provides implementations via renderSdkUiComponent(name, props) runtime injection. Plugin bundles ship type declarations only.",
"name_collision_check": "MetricCard.tsx and StatusBadge.tsx exist at ui/src/components/ top-level. The other 9 declared components have no obvious matching file — may exist under different names (e.g., MarkdownBody ≈ MarkdownBlock, JsonSchemaForm unrelated) or may not be implemented yet."
}
},
"usage_surfaces": {
"pages_root": "ui/src/pages/",
"page_count_estimate": 50,
"page_count_method": "find ui/src/pages -name '*.tsx' -not -name '*.test.tsx'",
"other_surfaces": [
{ "path": "ui/src/App.tsx", "role": "root router" },
{ "path": "ui/src/plugins/", "role": "plugin slot & launcher rendering (slots.tsx, launchers.tsx)" }
],
"notable_pages": {
"design_guide": "ui/src/pages/DesignGuide.tsx — an existing in-app design reference page. Imports shadcn primitives; worth cross-referencing during Stage 3 for intended-vs-actual primitive usage. Its presence means a partial DS narrative already exists in-code.",
"ux_labs": [
"ui/src/pages/InviteUxLab.tsx",
"ui/src/pages/IssueChatUxLab.tsx",
"ui/src/pages/RunTranscriptUxLab.tsx"
]
}
},
"storybook": {
"present": true,
"version": "10.3.5",
"config_path": "ui/storybook/.storybook/main.ts",
"stories_location": "centralized",
"stories_glob": "ui/storybook/stories/**/*.stories.@(ts|tsx|mdx)",
"story_file_count": 14,
"story_organization": "thematic",
"story_organization_note": "Stories are organized by domain/theme (foundations, navigation-layout, dialogs-modals, chat-comments, forms-editors, status-language, data-viz-misc, agent-management, issue-management, projects-goals-workspaces, budget-finance, control-plane-surfaces, ux-labs, overview) — NOT one-story-per-component. A single .stories.tsx file typically imports and composes many components. 'Component covered by story' must be computed by parsing import graphs of story files, not by file naming.",
"story_files": [
"foundations.stories.tsx",
"overview.stories.tsx",
"status-language.stories.tsx",
"navigation-layout.stories.tsx",
"dialogs-modals.stories.tsx",
"forms-editors.stories.tsx",
"chat-comments.stories.tsx",
"data-viz-misc.stories.tsx",
"agent-management.stories.tsx",
"issue-management.stories.tsx",
"projects-goals-workspaces.stories.tsx",
"budget-finance.stories.tsx",
"control-plane-surfaces.stories.tsx",
"ux-labs.stories.tsx"
],
"addons": ["@storybook/addon-docs", "@storybook/addon-a11y"],
"covered_components": null,
"covered_components_note": "Deferred to Stage 2 (parse story imports). Discovery only confirms stories exist, not per-component coverage."
},
"existing_docs": {
"design_system_dir_present": false,
"locations": [
{ "path": "ui/README.md", "role": "package readme; non-DS" },
{ "path": "ui/src/pages/DesignGuide.tsx", "role": "in-app design reference page (component-level showcase)" }
],
"figma_sync_config": null,
"style_dictionary_config": null,
"tokens_studio_config": null
},
"known_gaps": [
"Plugin SDK UI is a types-only ambient bridge (packages/plugins/sdk/src/ui/components.ts). The host-provided component kit promised by the SDK is partial: only MetricCard and StatusBadge have matching host implementations by name. 9 other declared components (DataTable, TimeseriesChart, MarkdownBlock, KeyValueList, ActionBar, LogView, JsonTree, Spinner, ErrorBoundary) have no obvious host implementation. PLUGIN_SPEC.md:30 confirms: 'The current runtime does not yet ship a real host-provided plugin UI component kit'.",
"No dedicated spacing, type, or elevation tokens. Those categories rely on Tailwind v4 defaults. Extraction should not synthesize repo-specific tokens where none exist.",
"No central motion token language. Motion is expressed as per-feature @keyframes with inline easing. Treat motion as a candidate for future tokenization rather than documenting a current system.",
"No Figma/style-dictionary/tokens-studio integration. The design system is code-authored, not design-tool-synced."
],
"uncertainties": [
"Storybook organization is thematic (14 composite stories), not per-component. The extraction skill's 'covered_by_story' signal needs to be computed by parsing each story file's import graph and surfacing components used inside render bodies. Flag before Stage 2 so the skill doesn't default to file-name matching.",
"Radius scale is non-monotonic: --radius-sm = 0.375rem, --radius-md = 0.5rem, --radius-lg = 0px, --radius-xl = 0px. Flat-design choice or stale values? Also --radius (base) = 0 at :root coexists with the @theme tokens; which is canonical for Tailwind rounded utilities? Worth confirming before Stage 1 drift analysis flags every rounded-lg usage.",
"Plugin SDK UI contracts 11 shared components but only 2 appear to be implemented by that name in ui/. For extraction scope, should we (a) treat the SDK declarations as DS contract and flag missing implementations as gaps, or (b) ignore the SDK and document only what exists in ui/? Recommendation: (a) — it's higher-value signal for the human reviewer.",
"ui/src/components/ has a mixed layout: 22 shadcn primitives in a 'ui/' subdirectory and 108 components flat at the top level. The top-level mix contains features, composites, one-off pages pieces, and true reusable patterns. Stage 3 will need a heuristic (composition-graph-based) rather than directory-based category inference.",
"Total component count (~133) is meaningfully larger than the skill's illustrative example (87). With the 3+-usage threshold for detail files, output should stay tractable, but the skill should confirm the threshold is right at this scale before Stage 3 starts generating per-component .md files.",
"MDXEditor CSS variable bridge (.paperclip-mdxeditor-scope, 24 --base*/--accent* variables) is DS-adjacent — it consumes host tokens and maps them to MDXEditor internals. Should Stage 1 include these in tokens.json? Recommendation: no — they are consumed aliases, not authoritative tokens. Flag them in tokens-review.md as 'integration layer' rather than as drift."
]
}

View File

@@ -1,646 +0,0 @@
{
"generated_at": "2026-04-21T00:00:00Z",
"repo_sha": "a26e1288b627e82c554445732c7d844648e6b5e1",
"page_count": 50,
"method": "Import-graph extraction: 'from \"@/components/<name>\"' and relative imports. Top-level JSX tree not parsed \u2014 import set is the proxy for rendered-components set. Components a page imports but never renders would inflate the count slightly; low risk here given app style.",
"pages": {
"Activity": {
"path": "ui/src/pages/Activity.tsx",
"components_imported": [
"ActivityRow",
"EmptyState",
"PageSkeleton",
"select"
],
"component_count": 4
},
"AdapterManager": {
"path": "ui/src/pages/AdapterManager.tsx",
"components_imported": [
"PathInstructionsModal",
"badge",
"button",
"card",
"dialog",
"input",
"label"
],
"component_count": 7
},
"AgentDetail": {
"path": "ui/src/pages/AgentDetail.tsx",
"components_imported": [
"ActivityCharts",
"AgentActionButtons",
"AgentConfigForm",
"AgentIconPicker",
"BudgetPolicyCard",
"CopyText",
"EntityRow",
"Identity",
"MarkdownBody",
"MarkdownEditor",
"PackageFileTree",
"PageSkeleton",
"PageTabBar",
"RunTranscriptView",
"ScrollToBottom",
"StatusBadge",
"agent-config-primitives",
"button",
"collapsible",
"input",
"popover",
"skeleton",
"tabs",
"toggle-switch",
"tooltip"
],
"component_count": 25
},
"Agents": {
"path": "ui/src/pages/Agents.tsx",
"components_imported": [
"EmptyState",
"EntityRow",
"PageSkeleton",
"PageTabBar",
"StatusBadge",
"button",
"tabs"
],
"component_count": 7
},
"ApprovalDetail": {
"path": "ui/src/pages/ApprovalDetail.tsx",
"components_imported": [
"ApprovalPayload",
"Identity",
"MarkdownBody",
"PageSkeleton",
"StatusBadge",
"button",
"textarea"
],
"component_count": 7
},
"Approvals": {
"path": "ui/src/pages/Approvals.tsx",
"components_imported": [
"ApprovalCard",
"PageSkeleton",
"PageTabBar",
"tabs"
],
"component_count": 4
},
"Auth": {
"path": "ui/src/pages/Auth.tsx",
"components_imported": [
"AsciiArtAnimation",
"button"
],
"component_count": 2
},
"BoardClaim": {
"path": "ui/src/pages/BoardClaim.tsx",
"components_imported": [
"button"
],
"component_count": 1
},
"CliAuth": {
"path": "ui/src/pages/CliAuth.tsx",
"components_imported": [
"button"
],
"component_count": 1
},
"Companies": {
"path": "ui/src/pages/Companies.tsx",
"components_imported": [
"button",
"dropdown-menu",
"input"
],
"component_count": 3
},
"CompanyAccess": {
"path": "ui/src/pages/CompanyAccess.tsx",
"components_imported": [
"badge",
"button",
"checkbox",
"dialog"
],
"component_count": 4
},
"CompanyExport": {
"path": "ui/src/pages/CompanyExport.tsx",
"components_imported": [
"EmptyState",
"MarkdownBody",
"PackageFileTree",
"PageSkeleton",
"button"
],
"component_count": 5
},
"CompanyImport": {
"path": "ui/src/pages/CompanyImport.tsx",
"components_imported": [
"AgentConfigForm",
"EmptyState",
"MarkdownBody",
"PackageFileTree",
"agent-config-defaults",
"agent-config-primitives",
"button"
],
"component_count": 7
},
"CompanyInvites": {
"path": "ui/src/pages/CompanyInvites.tsx",
"components_imported": [
"button"
],
"component_count": 1
},
"CompanySettings": {
"path": "ui/src/pages/CompanySettings.tsx",
"components_imported": [
"CompanyPatternIcon",
"agent-config-primitives",
"button"
],
"component_count": 3
},
"CompanySkills": {
"path": "ui/src/pages/CompanySkills.tsx",
"components_imported": [
"EmptyState",
"MarkdownBody",
"MarkdownEditor",
"PageSkeleton",
"button",
"dialog",
"input",
"textarea",
"tooltip"
],
"component_count": 9
},
"Costs": {
"path": "ui/src/pages/Costs.tsx",
"components_imported": [
"BillerSpendCard",
"BudgetIncidentCard",
"BudgetPolicyCard",
"EmptyState",
"FinanceBillerCard",
"FinanceKindCard",
"FinanceTimelineCard",
"Identity",
"PageSkeleton",
"PageTabBar",
"ProviderQuotaCard",
"StatusBadge",
"button",
"card",
"tabs"
],
"component_count": 15
},
"Dashboard": {
"path": "ui/src/pages/Dashboard.tsx",
"components_imported": [
"ActiveAgentsPanel",
"ActivityCharts",
"ActivityRow",
"EmptyState",
"Identity",
"MetricCard",
"PageSkeleton",
"StatusIcon"
],
"component_count": 8
},
"DesignGuide": {
"path": "ui/src/pages/DesignGuide.tsx",
"components_imported": [
"EmptyState",
"EntityRow",
"FilterBar",
"Identity",
"InlineEditor",
"IssueReferencePill",
"MetricCard",
"PageSkeleton",
"PriorityIcon",
"StatusBadge",
"StatusIcon",
"avatar",
"badge",
"breadcrumb",
"button",
"card",
"checkbox",
"collapsible",
"command",
"dialog",
"dropdown-menu",
"input",
"label",
"popover",
"scroll-area",
"select",
"separator",
"sheet",
"skeleton",
"tabs",
"textarea",
"tooltip"
],
"component_count": 32
},
"ExecutionWorkspaceDetail": {
"path": "ui/src/pages/ExecutionWorkspaceDetail.tsx",
"components_imported": [
"CopyText",
"ExecutionWorkspaceCloseDialog",
"IssuesList",
"PageTabBar",
"WorkspaceRuntimeControls",
"button",
"card",
"input",
"separator",
"tabs",
"textarea"
],
"component_count": 11
},
"GoalDetail": {
"path": "ui/src/pages/GoalDetail.tsx",
"components_imported": [
"EntityRow",
"GoalProperties",
"GoalTree",
"InlineEditor",
"PageSkeleton",
"StatusBadge",
"button",
"tabs"
],
"component_count": 8
},
"Goals": {
"path": "ui/src/pages/Goals.tsx",
"components_imported": [
"EmptyState",
"GoalTree",
"PageSkeleton",
"button"
],
"component_count": 4
},
"Inbox": {
"path": "ui/src/pages/Inbox.tsx",
"components_imported": [
"ApprovalPayload",
"EmptyState",
"IssueColumns",
"IssueFiltersPopover",
"IssueGroupHeader",
"IssueRow",
"PageSkeleton",
"PageTabBar",
"StatusBadge",
"StatusIcon",
"SwipeToArchive",
"button",
"dialog",
"input",
"popover",
"select",
"separator",
"tabs"
],
"component_count": 18
},
"InstanceAccess": {
"path": "ui/src/pages/InstanceAccess.tsx",
"components_imported": [
"button",
"checkbox"
],
"component_count": 2
},
"InstanceExperimentalSettings": {
"path": "ui/src/pages/InstanceExperimentalSettings.tsx",
"components_imported": [
"toggle-switch"
],
"component_count": 1
},
"InstanceGeneralSettings": {
"path": "ui/src/pages/InstanceGeneralSettings.tsx",
"components_imported": [
"ModeBadge",
"button",
"toggle-switch"
],
"component_count": 3
},
"InstanceSettings": {
"path": "ui/src/pages/InstanceSettings.tsx",
"components_imported": [
"EmptyState",
"badge",
"button",
"card"
],
"component_count": 4
},
"InviteLanding": {
"path": "ui/src/pages/InviteLanding.tsx",
"components_imported": [
"CompanyPatternIcon",
"button"
],
"component_count": 2
},
"InviteUxLab": {
"path": "ui/src/pages/InviteUxLab.tsx",
"components_imported": [
"CompanyPatternIcon",
"badge",
"button",
"card"
],
"component_count": 4
},
"IssueChatUxLab": {
"path": "ui/src/pages/IssueChatUxLab.tsx",
"components_imported": [
"IssueChatThread",
"badge",
"button",
"card"
],
"component_count": 4
},
"IssueDetail": {
"path": "ui/src/pages/IssueDetail.tsx",
"components_imported": [
"ApprovalCard",
"Identity",
"ImageGalleryModal",
"InlineEditor",
"IssueChatThread",
"IssueContinuationHandoff",
"IssueDocumentsSection",
"IssueProperties",
"IssueReferenceActivitySummary",
"IssueRelatedWorkPanel",
"IssueRunLedger",
"IssueWorkspaceCard",
"IssuesList",
"MarkdownEditor",
"PriorityIcon",
"ScrollToBottom",
"StatusIcon",
"button",
"popover",
"scroll-area",
"separator",
"sheet",
"skeleton",
"tabs"
],
"component_count": 24
},
"Issues": {
"path": "ui/src/pages/Issues.tsx",
"components_imported": [
"EmptyState",
"IssuesList"
],
"component_count": 2
},
"JoinRequestQueue": {
"path": "ui/src/pages/JoinRequestQueue.tsx",
"components_imported": [
"badge",
"button"
],
"component_count": 2
},
"MyIssues": {
"path": "ui/src/pages/MyIssues.tsx",
"components_imported": [
"EmptyState",
"EntityRow",
"PageSkeleton",
"StatusIcon"
],
"component_count": 4
},
"NewAgent": {
"path": "ui/src/pages/NewAgent.tsx",
"components_imported": [
"AgentConfigForm",
"ReportsToPicker",
"agent-config-defaults",
"agent-config-primitives",
"button",
"checkbox",
"popover"
],
"component_count": 7
},
"NotFound": {
"path": "ui/src/pages/NotFound.tsx",
"components_imported": [
"button"
],
"component_count": 1
},
"Org": {
"path": "ui/src/pages/Org.tsx",
"components_imported": [
"EmptyState",
"PageSkeleton",
"StatusBadge"
],
"component_count": 3
},
"OrgChart": {
"path": "ui/src/pages/OrgChart.tsx",
"components_imported": [
"AgentIconPicker",
"EmptyState",
"PageSkeleton",
"button"
],
"component_count": 4
},
"PluginManager": {
"path": "ui/src/pages/PluginManager.tsx",
"components_imported": [
"badge",
"button",
"card",
"dialog",
"input",
"label"
],
"component_count": 6
},
"PluginPage": {
"path": "ui/src/pages/PluginPage.tsx",
"components_imported": [
"button"
],
"component_count": 1
},
"PluginSettings": {
"path": "ui/src/pages/PluginSettings.tsx",
"components_imported": [
"JsonSchemaForm",
"PageTabBar",
"badge",
"button",
"card",
"separator",
"tabs"
],
"component_count": 7
},
"ProfileSettings": {
"path": "ui/src/pages/ProfileSettings.tsx",
"components_imported": [
"avatar",
"button",
"input",
"label"
],
"component_count": 4
},
"ProjectDetail": {
"path": "ui/src/pages/ProjectDetail.tsx",
"components_imported": [
"BudgetPolicyCard",
"InlineEditor",
"IssuesList",
"PageSkeleton",
"PageTabBar",
"ProjectProperties",
"ProjectWorkspacesContent",
"StatusBadge",
"button",
"tabs"
],
"component_count": 10
},
"ProjectWorkspaceDetail": {
"path": "ui/src/pages/ProjectWorkspaceDetail.tsx",
"components_imported": [
"PathInstructionsModal",
"WorkspaceRuntimeControls",
"button",
"separator"
],
"component_count": 4
},
"Projects": {
"path": "ui/src/pages/Projects.tsx",
"components_imported": [
"EmptyState",
"EntityRow",
"PageSkeleton",
"StatusBadge",
"button"
],
"component_count": 5
},
"RoutineDetail": {
"path": "ui/src/pages/RoutineDetail.tsx",
"components_imported": [
"AgentActionButtons",
"AgentIconPicker",
"EmptyState",
"InlineEntitySelector",
"LiveRunWidget",
"MarkdownEditor",
"PageSkeleton",
"RoutineRunVariablesDialog",
"RoutineVariablesEditor",
"ScheduleEditor",
"badge",
"button",
"collapsible",
"input",
"label",
"select",
"separator",
"tabs",
"toggle-switch"
],
"component_count": 19
},
"Routines": {
"path": "ui/src/pages/Routines.tsx",
"components_imported": [
"AgentIconPicker",
"EmptyState",
"InlineEntitySelector",
"IssuesList",
"MarkdownEditor",
"PageSkeleton",
"PageTabBar",
"RoutineRunVariablesDialog",
"RoutineVariablesEditor",
"button",
"card",
"collapsible",
"dialog",
"dropdown-menu",
"popover",
"select",
"tabs",
"toggle-switch"
],
"component_count": 18
},
"RunTranscriptUxLab": {
"path": "ui/src/pages/RunTranscriptUxLab.tsx",
"components_imported": [
"Identity",
"RunTranscriptView",
"StatusBadge",
"badge",
"button"
],
"component_count": 5
},
"UserProfile": {
"path": "ui/src/pages/UserProfile.tsx",
"components_imported": [
"EmptyState",
"PageSkeleton",
"StatusBadge",
"avatar"
],
"component_count": 4
},
"Workspaces": {
"path": "ui/src/pages/Workspaces.tsx",
"components_imported": [
"PageSkeleton",
"ProjectWorkspacesContent"
],
"component_count": 2
}
}
}

View File

@@ -1,338 +0,0 @@
{
"generated_at": "2026-04-21T00:00:00Z",
"repo_sha": "a26e1288b627e82c554445732c7d844648e6b5e1",
"story_files": {
"agent-management.stories.tsx": {
"path": "ui/storybook/stories/agent-management.stories.tsx",
"components_imported": [
"ActiveAgentsPanel",
"AgentActionButtons",
"AgentConfigForm",
"AgentIconPicker",
"AgentProperties",
"agent-config-defaults",
"agent-config-primitives",
"badge",
"button",
"card",
"select",
"separator"
],
"component_count": 12
},
"budget-finance.stories.tsx": {
"path": "ui/storybook/stories/budget-finance.stories.tsx",
"components_imported": [
"AccountingModelCard",
"BillerSpendCard",
"BudgetIncidentCard",
"BudgetSidebarMarker",
"ClaudeSubscriptionPanel",
"CodexSubscriptionPanel",
"FinanceBillerCard",
"FinanceKindCard",
"FinanceTimelineCard",
"ProviderQuotaCard",
"badge",
"card"
],
"component_count": 12
},
"chat-comments.stories.tsx": {
"path": "ui/storybook/stories/chat-comments.stories.tsx",
"components_imported": [
"CommentThread",
"InlineEntitySelector",
"IssueChatThread",
"MarkdownEditor",
"RunChatSurface",
"badge",
"card"
],
"component_count": 7
},
"control-plane-surfaces.stories.tsx": {
"path": "ui/storybook/stories/control-plane-surfaces.stories.tsx",
"components_imported": [
"ActivityRow",
"ApprovalCard",
"BudgetPolicyCard",
"Identity",
"IssueRow",
"PriorityIcon",
"StatusBadge",
"badge",
"card"
],
"component_count": 9
},
"data-viz-misc.stories.tsx": {
"path": "ui/storybook/stories/data-viz-misc.stories.tsx",
"components_imported": [
"ActivityCharts",
"AsciiArtAnimation",
"CompanyPatternIcon",
"EntityRow",
"FilterBar",
"KanbanBoard",
"LiveRunWidget",
"OnboardingWizard",
"PackageFileTree",
"PageSkeleton",
"StatusBadge",
"SwipeToArchive",
"badge",
"button",
"card"
],
"component_count": 15
},
"dialogs-modals.stories.tsx": {
"path": "ui/storybook/stories/dialogs-modals.stories.tsx",
"components_imported": [
"DocumentDiffModal",
"ExecutionWorkspaceCloseDialog",
"ImageGalleryModal",
"NewAgentDialog",
"NewGoalDialog",
"NewIssueDialog",
"NewProjectDialog",
"PathInstructionsModal",
"badge"
],
"component_count": 9
},
"forms-editors.stories.tsx": {
"path": "ui/storybook/stories/forms-editors.stories.tsx",
"components_imported": [
"EnvVarEditor",
"ExecutionParticipantPicker",
"InlineEditor",
"InlineEntitySelector",
"JsonSchemaForm",
"MarkdownBody",
"MarkdownEditor",
"ReportsToPicker",
"RoutineRunVariablesDialog",
"RoutineVariablesEditor",
"ScheduleEditor",
"badge",
"button"
],
"component_count": 13
},
"foundations.stories.tsx": {
"path": "ui/storybook/stories/foundations.stories.tsx",
"components_imported": [
"badge",
"button",
"card",
"checkbox",
"dialog",
"input",
"label",
"popover",
"select",
"separator",
"tabs",
"textarea",
"toggle-switch",
"tooltip"
],
"component_count": 14
},
"issue-management.stories.tsx": {
"path": "ui/storybook/stories/issue-management.stories.tsx",
"components_imported": [
"Identity",
"IssueColumns",
"IssueContinuationHandoff",
"IssueDocumentsSection",
"IssueFiltersPopover",
"IssueGroupHeader",
"IssueLinkQuicklook",
"IssueProperties",
"IssueRunLedger",
"IssueWorkspaceCard",
"IssuesList",
"IssuesQuicklook",
"PriorityIcon",
"StatusBadge",
"badge",
"button",
"card"
],
"component_count": 17
},
"navigation-layout.stories.tsx": {
"path": "ui/storybook/stories/navigation-layout.stories.tsx",
"components_imported": [
"BreadcrumbBar",
"CommandPalette",
"CompanyRail",
"CompanySwitcher",
"KeyboardShortcutsCheatsheet",
"MobileBottomNav",
"PageTabBar",
"Sidebar",
"SidebarAccountMenu",
"SidebarCompanyMenu",
"StatusBadge",
"badge",
"command",
"tabs"
],
"component_count": 14
},
"overview.stories.tsx": {
"path": "ui/storybook/stories/overview.stories.tsx",
"components_imported": [
"badge",
"card"
],
"component_count": 2
},
"projects-goals-workspaces.stories.tsx": {
"path": "ui/storybook/stories/projects-goals-workspaces.stories.tsx",
"components_imported": [
"GoalProperties",
"GoalTree",
"ProjectProperties",
"ProjectWorkspaceSummaryCard",
"ProjectWorkspacesContent",
"WorkspaceRuntimeControls",
"WorktreeBanner",
"badge",
"card"
],
"component_count": 9
},
"status-language.stories.tsx": {
"path": "ui/storybook/stories/status-language.stories.tsx",
"components_imported": [
"CopyText",
"EmptyState",
"Identity",
"MetricCard",
"PriorityIcon",
"QuotaBar",
"StatusBadge",
"card"
],
"component_count": 8
},
"ux-labs.stories.tsx": {
"path": "ui/storybook/stories/ux-labs.stories.tsx",
"components_imported": [],
"component_count": 0
}
},
"covered_components_count": 104,
"covered_components": [
"AccountingModelCard",
"ActiveAgentsPanel",
"ActivityCharts",
"ActivityRow",
"AgentActionButtons",
"AgentConfigForm",
"AgentIconPicker",
"AgentProperties",
"ApprovalCard",
"AsciiArtAnimation",
"BillerSpendCard",
"BreadcrumbBar",
"BudgetIncidentCard",
"BudgetPolicyCard",
"BudgetSidebarMarker",
"ClaudeSubscriptionPanel",
"CodexSubscriptionPanel",
"CommandPalette",
"CommentThread",
"CompanyPatternIcon",
"CompanyRail",
"CompanySwitcher",
"CopyText",
"DocumentDiffModal",
"EmptyState",
"EntityRow",
"EnvVarEditor",
"ExecutionParticipantPicker",
"ExecutionWorkspaceCloseDialog",
"FilterBar",
"FinanceBillerCard",
"FinanceKindCard",
"FinanceTimelineCard",
"GoalProperties",
"GoalTree",
"Identity",
"ImageGalleryModal",
"InlineEditor",
"InlineEntitySelector",
"IssueChatThread",
"IssueColumns",
"IssueContinuationHandoff",
"IssueDocumentsSection",
"IssueFiltersPopover",
"IssueGroupHeader",
"IssueLinkQuicklook",
"IssueProperties",
"IssueRow",
"IssueRunLedger",
"IssueWorkspaceCard",
"IssuesList",
"IssuesQuicklook",
"JsonSchemaForm",
"KanbanBoard",
"KeyboardShortcutsCheatsheet",
"LiveRunWidget",
"MarkdownBody",
"MarkdownEditor",
"MetricCard",
"MobileBottomNav",
"NewAgentDialog",
"NewGoalDialog",
"NewIssueDialog",
"NewProjectDialog",
"OnboardingWizard",
"PackageFileTree",
"PageSkeleton",
"PageTabBar",
"PathInstructionsModal",
"PriorityIcon",
"ProjectProperties",
"ProjectWorkspaceSummaryCard",
"ProjectWorkspacesContent",
"ProviderQuotaCard",
"QuotaBar",
"ReportsToPicker",
"RoutineRunVariablesDialog",
"RoutineVariablesEditor",
"RunChatSurface",
"ScheduleEditor",
"Sidebar",
"SidebarAccountMenu",
"SidebarCompanyMenu",
"StatusBadge",
"SwipeToArchive",
"WorkspaceRuntimeControls",
"WorktreeBanner",
"agent-config-defaults",
"agent-config-primitives",
"badge",
"button",
"card",
"checkbox",
"command",
"dialog",
"input",
"label",
"popover",
"select",
"separator",
"tabs",
"textarea",
"toggle-switch",
"tooltip"
],
"covered_components_note": "Computed by parsing 'from \"@/components/...\"' and relative imports across all .stories.tsx files. Coverage is set membership \u2014 a component appears once if any story imports it, regardless of how many variants/states are rendered."
}

View File

@@ -1,21 +0,0 @@
# ActivityCharts
`ui/src/components/ActivityCharts.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 3 imports (2 pages, 1 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 274 lines
- **Sibling exports:** ChartCard, IssueStatusChart, PriorityChart, RunActivityChart, SuccessRateChart
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `AgentDetail`, `Dashboard`

View File

@@ -1,40 +0,0 @@
# AgentConfigForm
`ui/src/components/AgentConfigForm.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 5 imports (3 pages, 0 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 1403 lines
## Props
### `AgentConfigFormProps`
```ts
adapterModels?: AdapterModel[];
onDirtyChange?: (dirty: boolean) => void;
onSaveActionChange?: (save: (() => void) | null) => void;
onCancelActionChange?: (cancel: (() => void) | null) => void;
hideInlineSave?: boolean;
showAdapterTypeField?: boolean;
showAdapterTestEnvironmentButton?: boolean;
showCreateRunPolicySection?: boolean;
hideInstructionsFile?: boolean;
/** Hide the prompt template field from the Identity section (used when it's shown in a separate Prompts tab). */
hidePromptTemplate?: boolean;
/** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */
sectionLayout?: "inline" | "cards";
```
## Composes
- **Primitives:** [button](./Button.md), [popover](./Popover.md)
## Used by
- **Pages:** `AgentDetail`, `CompanyImport`, `NewAgent`

View File

@@ -1,38 +0,0 @@
# AgentIconPicker
`ui/src/components/AgentIconPicker.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 13 imports (4 pages, 9 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 81 lines
- **Sibling exports:** AgentIcon
## Props
### `AgentIconProps`
```ts
icon: string | null | undefined;
className?: string;
```
### `AgentIconPickerProps`
```ts
value: string | null | undefined;
onChange: (icon: string) => void;
children: React.ReactNode;
```
## Composes
- **Primitives:** [input](./Input.md), [popover](./Popover.md)
## Used by
- **Pages:** `AgentDetail`, `OrgChart`, `RoutineDetail`, `Routines`

View File

@@ -1,24 +0,0 @@
# ApprovalCard
`ui/src/components/ApprovalCard.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 3 imports (2 pages, 1 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 153 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Composes
- **Primitives:** [badge](./Badge.md), [button](./Button.md)
## Used by
- **Pages:** `Approvals`, `IssueDetail`

View File

@@ -1,21 +0,0 @@
# ApprovalPayload
`ui/src/components/ApprovalPayload.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 4 imports (2 pages, 2 components)
- **Storybook:** no
- **File size:** 248 lines
- **Sibling exports:** ApprovalPayloadRenderer, BoardApprovalPayload, BudgetOverridePayload, CeoStrategyPayload, HireAgentPayload
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `ApprovalDetail`, `Inbox`

View File

@@ -1,22 +0,0 @@
# Avatar
`ui/src/components/ui/avatar.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 7 imports (3 pages, 4 components)
- **Storybook:** no
- **File size:** 108 lines
- **Sibling exports:** AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `DesignGuide`, `ProfileSettings`, `UserProfile`
- **Components:** `CommentThread`, `Identity`, `IssueChatThread`, `SidebarAccountMenu`

View File

@@ -1,26 +0,0 @@
# Badge
`ui/src/components/ui/badge.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 18 imports (11 pages, 7 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 49 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Variants (CVA)
- **variant**: `default`, `secondary`, `destructive`, `outline`, `ghost`, `link`
## Used by
- **Pages:** `AdapterManager`, `CompanyAccess`, `DesignGuide`, `InstanceSettings`, `InviteUxLab` … (+6 more)
- **Components:** `ApprovalCard`, `BudgetIncidentCard`, `FilterBar`, `FinanceTimelineCard`, `IssueFiltersPopover` … (+2 more)

View File

@@ -1,24 +0,0 @@
# BudgetPolicyCard
`ui/src/components/BudgetPolicyCard.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 3 imports (3 pages, 0 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 220 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Composes
- **Primitives:** [button](./Button.md), [card](./Card.md), [input](./Input.md)
## Used by
- **Pages:** `AgentDetail`, `Costs`, `ProjectDetail`

View File

@@ -1,27 +0,0 @@
# Button
`ui/src/components/ui/button.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 81 imports (41 pages, 38 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 71 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Variants (CVA)
- **variant**: `default`, `destructive`, `outline`, `secondary`, `ghost`, `link`
- **size**: `default`, `xs`, `sm`, `lg`, `icon`, `icon-xs`, `icon-sm`, `icon-lg`
## Used by
- **Pages:** `AdapterManager`, `AgentDetail`, `Agents`, `ApprovalDetail`, `Auth` … (+36 more)
- **Components:** `AgentActionButtons`, `AgentConfigForm`, `ApprovalCard`, `BreadcrumbBar`, `BudgetIncidentCard` … (+33 more)

View File

@@ -1,22 +0,0 @@
# Card
`ui/src/components/ui/card.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 18 imports (10 pages, 8 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 93 lines
- **Sibling exports:** CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `AdapterManager`, `Costs`, `DesignGuide`, `ExecutionWorkspaceDetail`, `InstanceSettings` … (+5 more)
- **Components:** `AccountingModelCard`, `BillerSpendCard`, `BudgetIncidentCard`, `BudgetPolicyCard`, `FinanceBillerCard` … (+3 more)

View File

@@ -1,21 +0,0 @@
# Checkbox
`ui/src/components/ui/checkbox.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 6 imports (4 pages, 2 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 33 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `CompanyAccess`, `DesignGuide`, `InstanceAccess`, `NewAgent`
- **Components:** `IssueFiltersPopover`, `JsonSchemaForm`

View File

@@ -1,22 +0,0 @@
# Collapsible
`ui/src/components/ui/collapsible.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 8 imports (4 pages, 4 components)
- **Storybook:** no
- **File size:** 34 lines
- **Sibling exports:** CollapsibleContent, CollapsibleTrigger
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `AgentDetail`, `DesignGuide`, `RoutineDetail`, `Routines`
- **Components:** `IssuesList`, `RoutineVariablesEditor`, `SidebarAgents`, `SidebarProjects`

View File

@@ -1,28 +0,0 @@
# CompanyPatternIcon
`ui/src/components/CompanyPatternIcon.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 4 imports (3 pages, 1 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 218 lines
## Props
### `CompanyPatternIconProps`
```ts
companyName: string;
logoUrl?: string | null;
brandColor?: string | null;
className?: string;
logoFit?: "cover" | "contain";
```
## Used by
- **Pages:** `CompanySettings`, `InviteLanding`, `InviteUxLab`

View File

@@ -1,32 +0,0 @@
# CopyText
`ui/src/components/CopyText.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 3 imports (2 pages, 1 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 88 lines
## Props
### `CopyTextProps`
```ts
text: string;
/** What to display. Defaults to `text`. */
children?: React.ReactNode;
containerClassName?: string;
className?: string;
ariaLabel?: string;
title?: string;
/** Tooltip message shown after copying. Default: "Copied!" */
copiedLabel?: string;
```
## Used by
- **Pages:** `AgentDetail`, `ExecutionWorkspaceDetail`

View File

@@ -1,26 +0,0 @@
# Dialog
`ui/src/components/ui/dialog.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 21 imports (7 pages, 14 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 157 lines
- **Sibling exports:** DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Composes
- **Primitives:** [button](./Button.md)
## Used by
- **Pages:** `AdapterManager`, `CompanyAccess`, `CompanySkills`, `DesignGuide`, `Inbox` … (+2 more)
- **Components:** `DocumentDiffModal`, `IssueChatThread`, `KeyboardShortcutsCheatsheet`, `NewAgentDialog`, `NewGoalDialog` … (+9 more)

View File

@@ -1,22 +0,0 @@
# DropdownMenu
`ui/src/components/ui/dropdown-menu.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 8 imports (3 pages, 5 components)
- **Storybook:** no
- **File size:** 258 lines
- **Sibling exports:** DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `Companies`, `DesignGuide`, `Routines`
- **Components:** `CompanySwitcher`, `IssueChatThread`, `IssueColumns`, `IssueDocumentsSection`, `SidebarCompanyMenu`

View File

@@ -1,31 +0,0 @@
# EmptyState
`ui/src/components/EmptyState.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 20 imports (19 pages, 1 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 28 lines
## Props
### `EmptyStateProps`
```ts
icon: LucideIcon;
message: string;
action?: string;
onAction?: () => void;
```
## Composes
- **Primitives:** [button](./Button.md)
## Used by
- **Pages:** `Activity`, `Agents`, `CompanyExport`, `CompanyImport`, `CompanySkills` … (+14 more)

View File

@@ -1,32 +0,0 @@
# EntityRow
`ui/src/components/EntityRow.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 6 imports (6 pages, 0 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 70 lines
## Props
### `EntityRowProps`
```ts
leading?: ReactNode;
identifier?: string;
title: string;
subtitle?: string;
trailing?: ReactNode;
selected?: boolean;
to?: string;
onClick?: () => void;
className?: string;
```
## Used by
- **Pages:** `AgentDetail`, `Agents`, `DesignGuide`, `GoalDetail`, `MyIssues` … (+1 more)

View File

@@ -1,32 +0,0 @@
# Identity
`ui/src/components/Identity.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 19 imports (7 pages, 12 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 40 lines
## Props
### `IdentityProps`
```ts
name: string;
avatarUrl?: string | null;
initials?: string;
size?: IdentitySize;
className?: string;
```
## Composes
- **Primitives:** [avatar](./Avatar.md)
## Used by
- **Pages:** `AgentDetail`, `ApprovalDetail`, `Costs`, `Dashboard`, `DesignGuide` … (+2 more)

View File

@@ -1,34 +0,0 @@
# InlineEditor
`ui/src/components/InlineEditor.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 6 imports (4 pages, 2 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 310 lines
## Props
### `InlineEditorProps`
```ts
value: string;
onSave: (value: string) => void | Promise<unknown>;
as?: "h1" | "h2" | "p" | "span";
className?: string;
placeholder?: string;
multiline?: boolean;
imageUploadHandler?: (file: File) => Promise<string>;
/** Called when a non-image file is dropped onto the editor. */
onDropFile?: (file: File) => Promise<void>;
mentions?: MentionOption[];
nullable?: boolean;
```
## Used by
- **Pages:** `DesignGuide`, `GoalDetail`, `IssueDetail`, `ProjectDetail`

View File

@@ -1,43 +0,0 @@
# InlineEntitySelector
`ui/src/components/InlineEntitySelector.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 8 imports (2 pages, 4 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 215 lines
## Props
### `InlineEntitySelectorProps`
```ts
value: string;
options: InlineEntityOption[];
placeholder: string;
noneLabel: string;
searchPlaceholder: string;
emptyMessage: string;
onChange: (id: string) => void;
onConfirm?: () => void;
className?: string;
renderTriggerValue?: (option: InlineEntityOption | null) => ReactNode;
renderOption?: (option: InlineEntityOption, isSelected: boolean) => ReactNode;
recentOptionIds?: string[];
/** Skip the Portal so the popover stays in the DOM tree (fixes scroll inside Dialogs). */
disablePortal?: boolean;
/** Open the popover when the trigger receives keyboard/programmatic focus. */
openOnFocus?: boolean;
```
## Composes
- **Primitives:** [popover](./Popover.md)
## Used by
- **Pages:** `RoutineDetail`, `Routines`

View File

@@ -1,21 +0,0 @@
# Input
`ui/src/components/ui/input.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 20 imports (10 pages, 10 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 22 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `AdapterManager`, `AgentDetail`, `Companies`, `CompanySkills`, `DesignGuide` … (+5 more)
- **Components:** `AgentIconPicker`, `BudgetIncidentCard`, `BudgetPolicyCard`, `IssueDocumentsSection`, `IssueFiltersPopover` … (+5 more)

View File

@@ -1,73 +0,0 @@
# IssueChatThread
`ui/src/components/IssueChatThread.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 4 imports (2 pages, 2 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 2399 lines
## Props
### `IssueChatComposerProps`
```ts
onImageUpload?: (file: File) => Promise<string>;
onAttachImage?: (file: File) => Promise<void>;
draftKey?: string;
enableReassign?: boolean;
reassignOptions?: InlineEntityOption[];
currentAssigneeValue?: string;
suggestedAssigneeValue?: string;
mentions?: MentionOption[];
agentMap?: Map<string, Agent>;
composerDisabledReason?: string | null;
issueStatus?: string;
```
### `IssueChatThreadProps`
```ts
comments: IssueChatComment[];
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
linkedRuns?: IssueChatLinkedRun[];
timelineEvents?: IssueTimelineEvent[];
liveRuns?: LiveRunForIssue[];
activeRun?: ActiveRunForIssue | null;
blockedBy?: IssueRelationIssueSummary[];
companyId?: string | null;
projectId?: string | null;
issueStatus?: string;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
onVote?: (
commentId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string
```
### `IssueChatErrorBoundaryProps`
```ts
resetKey: string;
messages: readonly ThreadMessage[];
emptyMessage: string;
variant: "full" | "embedded";
children: ReactNode;
```
## Composes
- **Primitives:** [avatar](./Avatar.md), [button](./Button.md), [dialog](./Dialog.md), `dropdown-menu`, [popover](./Popover.md), [textarea](./Textarea.md), [tooltip](./Tooltip.md)
## Used by
- **Pages:** `IssueChatUxLab`, `IssueDetail`

View File

@@ -1,24 +0,0 @@
# IssueFiltersPopover
`ui/src/components/IssueFiltersPopover.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 3 imports (1 pages, 2 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 366 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Composes
- **Primitives:** [badge](./Badge.md), [button](./Button.md), [checkbox](./Checkbox.md), [input](./Input.md), [popover](./Popover.md)
## Used by
- **Pages:** `Inbox`

View File

@@ -1,25 +0,0 @@
# IssueLinkQuicklook
`ui/src/components/IssueLinkQuicklook.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 3 imports (0 pages, 2 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 182 lines
- **Sibling exports:** IssueQuicklookCard
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Composes
- **Primitives:** [popover](./Popover.md)
- **Composites:** [StatusIcon](./StatusIcon.md)
## Used by

View File

@@ -1,20 +0,0 @@
# IssueReferencePill
`ui/src/components/IssueReferencePill.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 4 imports (1 pages, 3 components)
- **Storybook:** no
- **File size:** 56 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `DesignGuide`

View File

@@ -1,38 +0,0 @@
# IssueRow
`ui/src/components/IssueRow.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 3 imports (1 pages, 2 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 169 lines
## Props
### `IssueRowProps`
```ts
issue: Issue;
issueLinkState?: unknown;
selected?: boolean;
mobileLeading?: ReactNode;
desktopMetaLeading?: ReactNode;
desktopLeadingSpacer?: boolean;
mobileMeta?: ReactNode;
desktopTrailing?: ReactNode;
trailingMeta?: ReactNode;
titleSuffix?: ReactNode;
unreadState?: UnreadState | null;
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
className?: string;
```
## Used by
- **Pages:** `Inbox`

View File

@@ -1,41 +0,0 @@
# IssueWorkspaceCard
`ui/src/components/IssueWorkspaceCard.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 3 imports (1 pages, 2 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 522 lines
## Props
### `IssueWorkspaceCardProps`
```ts
issue: Omit<
Pick<
Issue,
| "companyId"
| "projectId"
| "projectWorkspaceId"
| "executionWorkspaceId"
| "executionWorkspacePreference"
| "executionWorkspaceSettings"
>,
"companyId"
> & {
companyId: string | null;
currentExecutionWorkspace?: ExecutionWorkspace | null;
```
## Composes
- **Primitives:** [button](./Button.md), [skeleton](./Skeleton.md)
## Used by
- **Pages:** `IssueDetail`

View File

@@ -1,45 +0,0 @@
# IssuesList
`ui/src/components/IssuesList.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 6 imports (5 pages, 1 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 1170 lines
## Props
### `IssuesListProps`
```ts
issues: Issue[];
isLoading?: boolean;
error?: Error | null;
agents?: Agent[];
projects?: ProjectOption[];
liveIssueIds?: Set<string>;
projectId?: string;
viewStateKey: string;
issueLinkState?: unknown;
initialAssignees?: string[];
initialWorkspaces?: string[];
initialSearch?: string;
searchFilters?: Omit<IssueListRequestFilters, "q" | "projectId" | "limit" | "includeRoutineExecutions">;
baseCreateIssueDefaults?: Record<string, unknown>;
createIssueLabel?: string;
enableRoutineVisibilityFilter?: boolean;
onSearchChange?: (search: string) => void;
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
```
## Composes
- **Primitives:** [button](./Button.md), [collapsible](./Collapsible.md), [input](./Input.md), [popover](./Popover.md)
## Used by
- **Pages:** `ExecutionWorkspaceDetail`, `IssueDetail`, `Issues`, `ProjectDetail`, `Routines`

View File

@@ -1,21 +0,0 @@
# Label
`ui/src/components/ui/label.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 8 imports (5 pages, 3 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 23 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `AdapterManager`, `DesignGuide`, `PluginManager`, `ProfileSettings`, `RoutineDetail`
- **Components:** `JsonSchemaForm`, `RoutineRunVariablesDialog`, `RoutineVariablesEditor`

View File

@@ -1,32 +0,0 @@
# MarkdownBody
`ui/src/components/MarkdownBody.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 11 imports (5 pages, 6 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 326 lines
## Props
### `MarkdownBodyProps`
```ts
children: string;
className?: string;
style?: React.CSSProperties;
softBreaks?: boolean;
linkIssueReferences?: boolean;
/** Optional resolver for relative image paths (e.g. within export packages) */
resolveImageSrc?: (src: string) => string | null;
/** Called when a user clicks an inline image */
onImageClick?: (src: string) => void;
```
## Used by
- **Pages:** `AgentDetail`, `ApprovalDetail`, `CompanyExport`, `CompanyImport`, `CompanySkills`

View File

@@ -1,39 +0,0 @@
# MarkdownEditor
`ui/src/components/MarkdownEditor.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 16 imports (5 pages, 9 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 1204 lines
## Props
### `MarkdownEditorProps`
```ts
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
contentClassName?: string;
onBlur?: () => void;
imageUploadHandler?: (file: File) => Promise<string>;
/** Called when a non-image file is dropped onto the editor (e.g. .zip). */
onDropFile?: (file: File) => Promise<void>;
bordered?: boolean;
/** List of mentionable entities. Enables @-mention autocomplete. */
mentions?: MentionOption[];
/** Called on Cmd/Ctrl+Enter */
onSubmit?: () => void;
/** Render the rich editor without allowing edits. */
readOnly?: boolean;
```
## Used by
- **Pages:** `AgentDetail`, `CompanySkills`, `IssueDetail`, `RoutineDetail`, `Routines`

View File

@@ -1,21 +0,0 @@
# PackageFileTree
`ui/src/components/PackageFileTree.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 3 imports (3 pages, 0 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 327 lines
- **Sibling exports:** FRONTMATTER_FIELD_LABELS
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `AgentDetail`, `CompanyExport`, `CompanyImport`

View File

@@ -1,36 +0,0 @@
# PageSkeleton
`ui/src/components/PageSkeleton.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 23 imports (22 pages, 1 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 181 lines
## Props
### `PageSkeletonProps`
```ts
variant?:
| "list"
| "issues-list"
| "detail"
| "dashboard"
| "approvals"
| "costs"
| "inbox"
| "org-chart";
```
## Composes
- **Primitives:** [skeleton](./Skeleton.md)
## Used by
- **Pages:** `Activity`, `AgentDetail`, `Agents`, `ApprovalDetail`, `Approvals` … (+17 more)

View File

@@ -1,32 +0,0 @@
# PageTabBar
`ui/src/components/PageTabBar.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 10 imports (9 pages, 1 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 46 lines
## Props
### `PageTabBarProps`
```ts
items: PageTabItem[];
value?: string;
onValueChange?: (value: string) => void;
align?: "center" | "start";
```
## Composes
- **Primitives:** [tabs](./Tabs.md)
## Used by
- **Pages:** `AgentDetail`, `Agents`, `Approvals`, `Costs`, `ExecutionWorkspaceDetail` … (+4 more)
- **Components:** `CompanySettingsNav`

View File

@@ -1,30 +0,0 @@
# PathInstructionsModal
`ui/src/components/PathInstructionsModal.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 12 imports (2 pages, 3 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 144 lines
- **Sibling exports:** ChoosePathButton
## Props
### `PathInstructionsModalProps`
```ts
open: boolean;
onOpenChange: (open: boolean) => void;
```
## Composes
- **Primitives:** [dialog](./Dialog.md)
## Used by
- **Pages:** `AdapterManager`, `ProjectWorkspaceDetail`

View File

@@ -1,22 +0,0 @@
# Popover
`ui/src/components/ui/popover.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 27 imports (6 pages, 20 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 89 lines
- **Sibling exports:** PopoverAnchor, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `AgentDetail`, `DesignGuide`, `Inbox`, `IssueDetail`, `NewAgent` … (+1 more)
- **Components:** `AgentConfigForm`, `AgentIconPicker`, `ExecutionParticipantPicker`, `GoalProperties`, `InlineEntitySelector` … (+15 more)

View File

@@ -1,31 +0,0 @@
# PriorityIcon
`ui/src/components/PriorityIcon.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 5 imports (2 pages, 3 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 78 lines
## Props
### `PriorityIconProps`
```ts
priority: string;
onChange?: (priority: string) => void;
className?: string;
showLabel?: boolean;
```
## Composes
- **Primitives:** [button](./Button.md), [popover](./Popover.md)
## Used by
- **Pages:** `DesignGuide`, `IssueDetail`

View File

@@ -1,24 +0,0 @@
# RoutineRunVariablesDialog
`ui/src/components/RoutineRunVariablesDialog.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 3 imports (2 pages, 1 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 519 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Composes
- **Primitives:** [button](./Button.md), [dialog](./Dialog.md), [input](./Input.md), [label](./Label.md), [select](./Select.md), [textarea](./Textarea.md)
## Used by
- **Pages:** `RoutineDetail`, `Routines`

View File

@@ -1,32 +0,0 @@
# RunTranscriptView
`ui/src/components/transcript/RunTranscriptView.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 3 imports (2 pages, 1 components)
- **Storybook:** no
- **File size:** 1446 lines
## Props
### `RunTranscriptViewProps`
```ts
entries: TranscriptEntry[];
mode?: TranscriptMode;
density?: TranscriptDensity;
limit?: number;
streaming?: boolean;
collapseStdout?: boolean;
emptyMessage?: string;
className?: string;
thinkingClassName?: string;
```
## Used by
- **Pages:** `AgentDetail`, `RunTranscriptUxLab`

View File

@@ -1,22 +0,0 @@
# ScrollArea
`ui/src/components/ui/scroll-area.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 3 imports (2 pages, 1 components)
- **Storybook:** no
- **File size:** 57 lines
- **Sibling exports:** ScrollBar
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `DesignGuide`, `IssueDetail`
- **Components:** `PropertiesPanel`

View File

@@ -1,22 +0,0 @@
# Select
`ui/src/components/ui/select.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 10 imports (5 pages, 5 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 189 lines
- **Sibling exports:** SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `Activity`, `DesignGuide`, `Inbox`, `RoutineDetail`, `Routines`
- **Components:** `DocumentDiffModal`, `JsonSchemaForm`, `RoutineRunVariablesDialog`, `RoutineVariablesEditor`, `ScheduleEditor`

View File

@@ -1,21 +0,0 @@
# Separator
`ui/src/components/ui/separator.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 11 imports (7 pages, 4 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 29 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `DesignGuide`, `ExecutionWorkspaceDetail`, `Inbox`, `IssueDetail`, `PluginSettings` … (+2 more)
- **Components:** `AgentProperties`, `GoalProperties`, `IssueProperties`, `ProjectProperties`

View File

@@ -1,33 +0,0 @@
# SidebarNavItem
`ui/src/components/SidebarNavItem.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 3 imports (0 pages, 3 components)
- **Storybook:** no
- **File size:** 95 lines
## Props
### `SidebarNavItemProps`
```ts
to: string;
label: string;
icon: LucideIcon;
end?: boolean;
className?: string;
badge?: number;
badgeTone?: "default" | "danger";
textBadge?: string;
textBadgeTone?: "default" | "amber";
alert?: boolean;
liveCount?: number;
```
## Used by

View File

@@ -1,21 +0,0 @@
# Skeleton
`ui/src/components/ui/skeleton.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 6 imports (3 pages, 3 components)
- **Storybook:** no
- **File size:** 14 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `AgentDetail`, `DesignGuide`, `IssueDetail`
- **Components:** `IssueWorkspaceCard`, `PageSkeleton`, `ProviderQuotaCard`

View File

@@ -1,20 +0,0 @@
# StatusBadge
`ui/src/components/StatusBadge.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 19 imports (12 pages, 7 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 16 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `AgentDetail`, `Agents`, `ApprovalDetail`, `Costs`, `DesignGuide` … (+7 more)

View File

@@ -1,32 +0,0 @@
# StatusIcon
`ui/src/components/StatusIcon.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 14 imports (5 pages, 9 components)
- **Storybook:** no
- **File size:** 72 lines
## Props
### `StatusIconProps`
```ts
status: string;
onChange?: (status: string) => void;
className?: string;
showLabel?: boolean;
```
## Composes
- **Primitives:** [button](./Button.md), [popover](./Popover.md)
## Used by
- **Pages:** `Dashboard`, `DesignGuide`, `Inbox`, `IssueDetail`, `MyIssues`
- **Components:** `IssueLinkQuicklook` … (+8 more)

View File

@@ -1,27 +0,0 @@
# Tabs
`ui/src/components/ui/tabs.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 15 imports (13 pages, 2 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 90 lines
- **Sibling exports:** TabsContent, TabsList, TabsTrigger
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Variants (CVA)
- **variant**: `default`, `line`
## Used by
- **Pages:** `AgentDetail`, `Agents`, `Approvals`, `Costs`, `DesignGuide` … (+8 more)
- **Components:** `CompanySettingsNav`, `PageTabBar`

View File

@@ -1,21 +0,0 @@
# Textarea
`ui/src/components/ui/textarea.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 9 imports (4 pages, 5 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 19 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `ApprovalDetail`, `CompanySkills`, `DesignGuide`, `ExecutionWorkspaceDetail`
- **Components:** `IssueChatThread`, `JsonSchemaForm`, `OutputFeedbackButtons`, `RoutineRunVariablesDialog`, `RoutineVariablesEditor`

View File

@@ -1,21 +0,0 @@
# ToggleSwitch
`ui/src/components/ui/toggle-switch.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 8 imports (5 pages, 3 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 60 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `AgentDetail`, `InstanceExperimentalSettings`, `InstanceGeneralSettings`, `RoutineDetail`, `Routines`
- **Components:** `NewIssueDialog`, `ProjectProperties`, `agent-config-primitives`

View File

@@ -1,22 +0,0 @@
# Tooltip
`ui/src/components/ui/tooltip.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 11 imports (3 pages, 7 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 58 lines
- **Sibling exports:** TooltipContent, TooltipProvider, TooltipTrigger
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `AgentDetail`, `CompanySkills`, `DesignGuide`
- **Components:** `CompanyRail`, `IssueChatThread`, `IssueColumns`, `NewProjectDialog`, `ProjectProperties` … (+2 more)

View File

@@ -1,38 +0,0 @@
# WorkspaceRuntimeControls
`ui/src/components/WorkspaceRuntimeControls.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 3 imports (2 pages, 1 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 454 lines
## Props
### `WorkspaceRuntimeControlsProps`
```ts
sections: WorkspaceRuntimeControlSections;
items?: never;
isPending?: boolean;
pendingRequest?: WorkspaceRuntimeControlRequest | null;
serviceEmptyMessage?: string;
jobEmptyMessage?: string;
emptyMessage?: never;
disabledHint?: string | null;
onAction: (request: WorkspaceRuntimeControlRequest) => void;
className?: string;
square?: boolean;
```
## Composes
- **Primitives:** [button](./Button.md)
## Used by
- **Pages:** `ExecutionWorkspaceDetail`, `ProjectWorkspaceDetail`

View File

@@ -1,25 +0,0 @@
# agent-config-primitives
`ui/src/components/agent-config-primitives.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 19 imports (4 pages, 3 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 464 lines
- **Sibling exports:** AutoExpandTextarea, ChoosePathButton, CollapsibleSection, DraftInput, DraftNumberInput, DraftTextarea
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Composes
- **Primitives:** [button](./Button.md), [dialog](./Dialog.md), `toggle-switch`, [tooltip](./Tooltip.md)
## Used by
- **Pages:** `AgentDetail`, `CompanyImport`, `CompanySettings`, `NewAgent`

View File

@@ -1,393 +0,0 @@
# Components Review
- **Generated:** 2026-04-21
- **Repo SHA:** a26e1288b627e82c554445732c7d844648e6b5e1
- **Inventory:** [index.md](./index.md)
- **Token drift feeding components:** [../tokens/tokens-review.md](../tokens/tokens-review.md)
- **Pattern docs drawn from this review:** [../patterns/index.md](../patterns/index.md)
---
## How to read this document
This is a **persistent backlog**, not just a snapshot review. It captures opportunities identified during DS extraction. **All items are intentionally deferred** per the 2026-04-21 decision: no component merges, no renames, no file-casing changes were made. Address opportunistically when the relevant code is touched for other reasons, or schedule as standalone follow-up projects.
Each entry below includes:
- A **problem statement** — what was observed and why it might matter.
- **Affected files** — so the next developer doesn't have to re-trace.
- A **suggested resolution** — or "needs discussion" when the right call is not obvious.
Entries are ordered by expected value, not by stage. Sections are self-contained — you can pick one up cold without reading the rest.
---
## Likely duplicates
Pairs or families whose names, compositions, or role overlap enough that they may be consolidatable. None of these are automatic merges — each is a judgment call. Items 17 and 9 are documented as single patterns in [../patterns/](../patterns/) so the family is visible to future pattern extraction; this section captures the consolidation opportunity.
### 1. Entity-creation dialog family: `NewAgentDialog` / `NewGoalDialog` / `NewIssueDialog` / `NewProjectDialog`
Four parallel "create new X" dialogs, each used 12 times, each a composite that wraps a form-in-a-dialog. Strong candidate for a single generic `NewEntityDialog` or a `useNewEntityForm` hook plus entity-specific field sets.
| File | Uses |
|---|---|
| `ui/src/components/NewAgentDialog.tsx` | 1 |
| `ui/src/components/NewGoalDialog.tsx` | 1 |
| `ui/src/components/NewIssueDialog.tsx` | 2 |
| `ui/src/components/NewProjectDialog.tsx` | 1 |
**Verify:** open the four files and diff them. If ≥60% body overlap, consolidate. Impact on Stage 4: otherwise a "New-entity dialog" pattern would be proposed with 4 instances, which is better represented as a single component.
### 2. Properties-panel family: `AgentProperties` / `GoalProperties` / `IssueProperties` / `ProjectProperties` (+ generic `PropertiesPanel`)
**Problem.** Four entity-specific property-panel components sit next to a generic `PropertiesPanel`. It is unclear whether `PropertiesPanel` **composes** the four (i.e., hosts their body via a context slot — which is what the 29-line `PropertiesPanel.tsx` appears to do) or whether the four **duplicate** the outer-chrome work that `PropertiesPanel` could own. `AgentProperties` is also unused in production (Storybook-only).
**Affected files.**
| File | Uses | Notes |
|---|---|---|
| `ui/src/components/PropertiesPanel.tsx` | 1 | 29-line generic chrome; reads `panelContent` from `usePanel()` context |
| `ui/src/components/IssueProperties.tsx` | 2 | 1370-line entity-specific body |
| `ui/src/components/GoalProperties.tsx` | 1 | Entity-specific body |
| `ui/src/components/ProjectProperties.tsx` | 1 | 1140-line entity-specific body |
| `ui/src/components/AgentProperties.tsx` | **0** | Storybook-only — see §Unused components |
**Suggested resolution: needs discussion.** Per the 2026-04-21 decision, the open question of "composes vs duplicates" is **flagged but not resolved**. Reading the source suggests `PropertiesPanel` is the outer slot and the four bodies are what gets passed into it — so not a literal duplicate. But the four bodies each re-roll section headers, separators, save-state plumbing, and field layout; those might be factor-able into a shared `<PropertiesPanelBody>` helper. Open `IssueProperties.tsx` and `ProjectProperties.tsx` side-by-side before making any call. Documented as-is in [../patterns/entity-properties-panel.md](../patterns/entity-properties-panel.md).
### 3. Subscription panel pair: `ClaudeSubscriptionPanel` ↔ `CodexSubscriptionPanel`
**Problem.** Two components with parallel names, parallel props shape (both accept `windows: QuotaWindow[]` + optional `source` / `error`), rendering ordered subscription-quota windows for a single provider. Both used exactly once — always dispatched from `ProviderQuotaCard`. When a third vendor (Gemini? Cursor?) is added, the pattern becomes "a third copy-paste" unless consolidated. Today's cost of keeping them separate is minor because the pair is only 2.
**Affected files.**
- `ui/src/components/ClaudeSubscriptionPanel.tsx` (1 use — 140 lines)
- `ui/src/components/CodexSubscriptionPanel.tsx` (1 use)
- `ui/src/components/ProviderQuotaCard.tsx` — composes both, dispatches by vendor
**Suggested resolution.** Diff-test the two files. If ≥70% body overlap, collapse to `SubscriptionPanel({ vendor: "claude" | "codex" | … })` with per-vendor window-key config as data. If divergence is higher, keep the pair and let `ProviderQuotaCard` continue to dispatch. Documented as-is in [../patterns/subscription-panel.md](../patterns/subscription-panel.md).
### 4. Sidebar-menu pair: `SidebarAccountMenu` ↔ `SidebarCompanyMenu`
**Problem.** Two dropdown components anchored to the sidebar slot — one for account actions (user profile, sign out), one for company actions (switch company, settings). Same visual affordance (dropdown triggered from a sidebar button), different data subject. Each used twice; neither is a hotspot.
**Affected files.**
- `ui/src/components/SidebarAccountMenu.tsx` (2 uses)
- `ui/src/components/SidebarCompanyMenu.tsx` (2 uses)
**Suggested resolution.** Read both and check the body overlap. Natural consolidations if similar: `<SidebarMenu kind="account" | "company">` driven by kind, or a more general `<SidebarMenu>` that accepts items via a `children` slot. If the bodies are genuinely different (different menu items, different trigger layout), the pair stays and the shared pattern is the sidebar-slot anchoring — which is already captured by `SidebarNavItem`. Documented as-is in [../patterns/sidebar-chrome.md — §The sidebar-menu pair](../patterns/sidebar-chrome.md#the-sidebar-menu-pair).
### 5. Finance card family: `BillerSpendCard` / `FinanceBillerCard` / `FinanceKindCard` / `FinanceTimelineCard` / `AccountingModelCard`
**Problem.** Five cards in the finance/accounting surface, each used 01 times, all sharing the shadcn `Card` family as substrate. Two specific flags surfaced per the 2026-04-21 directive:
1. **`BillerSpendCard``FinanceBillerCard` — likely a true duplicate.** Both name "Biller" in their filename. Both render a per-biller financial summary. They consume **different data models** (`CostByBiller` vs `FinanceByBiller`) — either two genuinely different reporting concepts whose names fail to distinguish them, or one superseded the other and the predecessor survived. Line counts differ (145 vs 44), consistent with a "rich" / "slim" pair.
2. **`AccountingModelCard` is unused.** Zero imports anywhere in the codebase; only appears in Storybook. Either abandoned or awaiting a page.
**Affected files.**
| File | Uses | Data model | Lines |
|---|---|---|---|
| `ui/src/components/BillerSpendCard.tsx` | 1 | `CostByBiller` + `CostByProviderModel` | 145 |
| `ui/src/components/FinanceBillerCard.tsx` | 1 | `FinanceByBiller` | 44 |
| `ui/src/components/FinanceKindCard.tsx` | 1 | `FinanceByKind` (inferred) | — |
| `ui/src/components/FinanceTimelineCard.tsx` | 1 | timeline rollup | — |
| `ui/src/components/AccountingModelCard.tsx` | **0** | — | — |
**Suggested resolution.**
- Diff `BillerSpendCard` vs `FinanceBillerCard` side-by-side. Rename for clarity, merge, or confirm as distinct-but-adjacent.
- Decide `AccountingModelCard`'s fate — adopt it into a page, or delete.
- The broader family (4 cards) is naturally a "Finance / accounting card" pattern. Documented as-is in [../patterns/finance-card.md](../patterns/finance-card.md).
### 6. Row family: `ActivityRow` / `EntityRow` / `IssueRow`
**Problem.** Three row components, one generic and two entity-specific. `EntityRow` is the generic (6 uses), `IssueRow` is issue-specific with an extensive slot interface (3 uses — Inbox + SwipeToArchive), `ActivityRow` is activity-event-specific (2 uses — Activity page). `IssueRow`'s 15-field prop shape (six of them optional `ReactNode` slot props) looks like it could be expressed as `<EntityRow kind="issue" />` plus issue-specific defaults.
**Affected files.**
| File | Uses | Notes |
|---|---|---|
| `ui/src/components/EntityRow.tsx` | 6 | Truly generic — leading / identifier / title / subtitle / trailing slot API |
| `ui/src/components/IssueRow.tsx` | 3 | Composes `StatusIcon`; unread-state + archive action + mobile/desktop split |
| `ui/src/components/ActivityRow.tsx` | 2 | Composes `Identity`, `IssueReferenceActivitySummary` — activity-event-specific |
**Suggested resolution.** Compare `IssueRow` against `EntityRow` directly. If the six slot props plus `unreadState` / `onArchive` can be implemented as `EntityRow` extensions (default slots keyed on `issue`), collapse. `ActivityRow`'s activity-verb formatting is genuinely specialized and is likely worth keeping separate even if `IssueRow` consolidates. Also relevant: the main list pages (Issues, Agents, Projects, …) don't use `EntityRow` today — see [../patterns/list-page.md — Open questions](../patterns/list-page.md#open-questions--risks). Documented as-is in [../patterns/entity-row.md](../patterns/entity-row.md).
### 7. Sidebar triad: `Sidebar` / `InstanceSidebar` / `CompanySettingsSidebar` + `CompanyRail` + `CompanySettingsNav` + `MobileBottomNav`
**Problem.** Six components with overlapping responsibilities — all are "chrome around the main view." Not literal duplicates because each targets a different surface (main nav vs instance settings vs company settings vs company switcher vs tab nav vs mobile bottom bar), but the three-word vocabulary (Sidebar / Rail / Nav) obscures whether these are variants of one structural pattern or separate components that happen to live near each other. They share navigation primitives (`SidebarNavItem`, `SidebarSection`) but not a unifying wrapper. The dead `sidebar-*` tokens in `ui/src/index.css` were designed for this family and none of these consume them (see [tokens-review.md §3](../tokens/tokens-review.md#3-sidebar--tokens-are-dead)).
**Affected files.**
| File | Uses | Role (inferred) |
|---|---|---|
| `ui/src/components/Sidebar.tsx` | ≥3 | Main app navigation |
| `ui/src/components/InstanceSidebar.tsx` | 1 | Instance-settings scope |
| `ui/src/components/CompanySettingsSidebar.tsx` | 2 | Company-settings scope |
| `ui/src/components/CompanyRail.tsx` | 1 (260 lines, dnd-kit-driven) | Sortable company switcher rail |
| `ui/src/components/access/CompanySettingsNav.tsx` | 1 | Settings-page top tab nav |
| `ui/src/components/MobileBottomNav.tsx` | ≥3 | Mobile-bottom-tab alternative |
**Suggested resolution: needs discussion.** Three possible framings:
- **(a) They are genuinely different components** (different layouts, different primitives, different affordances) and the naming convergence is coincidental. Closest to how the code reads today.
- **(b) They are variants of a `<Sidebar variant="main" | "settings" | "rail" | …>`** and should consolidate under one name. Requires auditing their visual shape.
- **(c) They are three distinct patterns** — "sidebar" (persistent rail), "rail" (narrow-strip), "nav" (tab-bar) — and the current spread is correct but the naming convention for picking between them isn't written down anywhere.
Tied to [§Naming inconsistencies — Sidebar / Rail / Nav](#sidebar--rail--nav). Documented as-is in [../patterns/sidebar-chrome.md](../patterns/sidebar-chrome.md).
### 8. Status display triad: `StatusIcon` / `StatusBadge` / `PriorityIcon`
**Problem.** Three components render entity status/priority across the app with the same visual language but different affordances. All three consume `ui/src/lib/status-colors.ts` — a canonical TypeScript catalog mapping status strings to raw Tailwind-palette classes. Two of the three (`StatusIcon`, `PriorityIcon`) type their primary prop as an **untyped string**; `StatusBadge` uses a typed variant. This inconsistency plus the fact that the catalog bypasses the DS token layer makes this a pattern-shape-pending item rather than a straightforward duplicate flag.
**Affected files.**
| File | Uses | Prop type | Notes |
|---|---|---|---|
| `ui/src/components/StatusIcon.tsx` | 14 | `status: string` (untyped) | Circle + popover picker |
| `ui/src/components/StatusBadge.tsx` | 19 | `{ status: string }` wrapping `statusBadge[status]` | 15-line pill |
| `ui/src/components/PriorityIcon.tsx` | 5 | `priority: string` (untyped) | Arrow/triangle + popover picker |
| `ui/src/lib/status-colors.ts` | — | — | Catalog consumed by all three |
| `ui/src/components/AgentActionButtons.tsx` | — | — | Consumes `agentStatusDot` from the same catalog |
**2026-04-21 status.** The token-side of this problem was **partially addressed**: `--signal-success` / `--signal-success-foreground` landed as action-severity tokens paired with `--destructive` (see [tokens-review.md §4](../tokens/tokens-review.md#4-status-colorsts-is-a-canonical-semantic-color-catalog-that-bypasses-the-ds)). `status-colors.ts` itself is **not** touched — tokenizing its entity-state coloring into a `--status-*` family is a deferred future project. So the two problems here are now independent:
- **Signal (action severity)** = tokens exist, no consumers yet, opt-in.
- **Status (entity state)** = catalog stays as raw Tailwind palette, unchanged.
**Suggested resolution: do not codify a unified status-display pattern in this DS pass.** Pattern shape is explicitly pending the eventual signal-token / status-token scoping. When that project happens, the shape of these three components (typed enum props, class naming, default-fallback behavior) will want to change in sync. Documented as-is in [../patterns/status-display.md](../patterns/status-display.md).
Separately — and independent of the tokens — the `status: string` / `priority: string` untyped props could be typed as string literal unions over the keys of `status-colors.ts`'s records without touching colors. That's a small, self-contained follow-up.
### 9. Quota display: `ProviderQuotaCard` ↔ `QuotaBar`
**Problem.** Two components share the "quota" root name but differ in suffix and in role. `QuotaBar` is a rendering primitive — one horizontal bar with a percent-used fill and a three-level color threshold. `ProviderQuotaCard` is a card composer that *uses* `QuotaBar` (multiple times, for different time windows) plus `ClaudeSubscriptionPanel` / `CodexSubscriptionPanel`. Functionally different, but the `-Bar` / `-Card` naming spread suggests parallelism that isn't there.
Secondary concern: `QuotaBar` hardcodes three-level severity as raw Tailwind palette (`bg-red-400`, `bg-yellow-400`, `bg-green-400`). It is one of four places in the codebase that encode the same red/amber/green severity language without shared tokens — see [../tokens/tokens-review.md §4](../tokens/tokens-review.md#4-status-colorsts-is-a-canonical-semantic-color-catalog-that-bypasses-the-ds) and [../patterns/patterns-review.md §6 — Severity indicator](../patterns/patterns-review.md#6-severity-indicator-3-level-health-display--pattern-opportunity).
**Affected files.**
- `ui/src/components/QuotaBar.tsx` (2 uses — 65 lines — primitive bar)
- `ui/src/components/ProviderQuotaCard.tsx` (1 use — 416 lines — card composer that embeds `QuotaBar`)
**Suggested resolution.** Not a consolidation target — different roles. The naming can be clearer if a future refactor happens (e.g., rename the composer to `ProviderQuotaSummary` or `QuotaOverviewCard`). Separate concern: the three-level severity coloring in `QuotaBar` would collapse onto signal/status tokens when that broader work lands. Documented as-is in [../patterns/quota-display.md](../patterns/quota-display.md).
---
## Naming inconsistencies
**Status: deferred (2026-04-21).** All vocabulary decisions below (Dialog-vs-Modal, Picker-vs-Selector, Editor-vs-Form, Sidebar-vs-Rail-vs-Nav, Card-vs-Panel-vs-Widget, file-name casing, prop-vocabulary conventions) are intentionally unresolved. Addressing opportunistically when the relevant code is touched for other reasons, or as a standalone naming-pass project. Each subsection is self-contained — pick any one up cold without reading the others. In pattern docs under `../patterns/`, vocabulary is noted as observed variance (e.g., "dialog pattern — some implementations named `*Modal`, same primitive") rather than as a canonical prescription.
### Container-word proliferation: `Card` vs `Panel` vs `Widget` vs `Modal` vs `Dialog`
Counts of components by suffix:
| Suffix | Count | Examples |
|---|---|---|
| `Card` | 12 | `ApprovalCard`, `BudgetPolicyCard`, `MetricCard`, … |
| `Panel` | 5 | `ActiveAgentsPanel`, `ClaudeSubscriptionPanel`, `PropertiesPanel`, … |
| `Widget` | 1 | `LiveRunWidget` |
| `Modal` | 3 | `DocumentDiffModal`, `ImageGalleryModal`, `PathInstructionsModal` |
| `Dialog` | 6 | `NewAgentDialog`, `ExecutionWorkspaceCloseDialog`, `RoutineRunVariablesDialog`, … |
**Dialog vs Modal:** both Modal- and Dialog-named components use the same `dialog.tsx` primitive. There's no structural distinction — just two names for the same thing. Pick one. The shadcn default is `Dialog`; keeping `Dialog` is the lower-friction move.
**Card vs Panel:** less clear-cut. Rough pattern in this codebase:
- `*Card` when the thing is a discrete piece of content in a grid (`BudgetPolicyCard`, `FinanceBillerCard`).
- `*Panel` when the thing is a larger region that groups related content (`ActiveAgentsPanel`, `PropertiesPanel`).
- But there are violations: `ClaudeSubscriptionPanel` and `AccountingModelCard` look alike structurally and are adjacent in usage.
**Widget (1):** only `LiveRunWidget` uses this. Either absorb into `Card`/`Panel` or codify `Widget` as a distinct concept (e.g., "dashboard-tile with its own data fetch and refresh cadence") and use it consistently.
### `Picker` vs `Selector`
| Name | Uses | Picks what |
|---|---|---|
| `AgentIconPicker` | 13 | An icon |
| `ExecutionParticipantPicker` | 0 (unused) | A participant |
| `ReportsToPicker` | 2 | An agent |
| `InlineEntitySelector` | 1 | An entity |
All four wrap a popover-plus-list. Picker and Selector are synonymous here. Pick one term.
### `Editor` vs `Form`
| Name | Uses | Purpose |
|---|---|---|
| `MarkdownEditor` | 16 | Markdown input |
| `InlineEditor` | 6 | Generic text editor |
| `EnvVarEditor` | 2 | Structured key=value list |
| `RoutineVariablesEditor` | 2 | Structured variable list |
| `ScheduleEditor` | 1 | Cron schedule |
| `AgentConfigForm` | 5 | Full agent config |
| `JsonSchemaForm` | 1 | Schema-driven form |
The line between Editor and Form is fuzzy: `RoutineVariablesEditor` looks like a form, `JsonSchemaForm` could have been named `JsonSchemaEditor`. Suggested rule: **Editor** for content inputs (text, schedule, markdown); **Form** for labeled-field structured forms. Audit whether any renaming is worth the churn; otherwise document the rule and enforce for new additions.
### Sidebar / Rail / Nav
For what are structurally all "chrome around the main content area":
- `Sidebar.tsx`
- `InstanceSidebar.tsx`
- `CompanySettingsSidebar.tsx`
- `CompanyRail.tsx`
- `MobileBottomNav.tsx`
- `access/CompanySettingsNav.tsx`
Pick a default term (`Sidebar`) and use a prefix for variants (`InstanceSidebar`, `CompanySettingsSidebar`). Reserve `Rail` for genuinely different (narrow-strip) affordances, `Nav` for tab-bar-style navigation. The current split is inconsistent with itself.
### File-naming convention is inconsistent
Most composites are `PascalCase.tsx`. Shadcn primitives are `kebab-case.tsx`. But `agent-config-primitives.tsx` and `agent-config-defaults.ts` sit in the top-level composite directory in kebab-case — they don't belong with the shadcn primitives (not in `ui/`) but also don't match the PascalCase of their neighbors.
Suggested fix: rename `agent-config-primitives.tsx``AgentConfigPrimitives.tsx` (or split into per-component files if the 11 exports warrant it) and `agent-config-defaults.ts``agentConfigDefaults.ts` to match JS convention for non-component modules.
### Prop vocabulary is underspecified
A static scan for conventional variant-shaping props (`variant`, `size`, `intent`, `tone`, `kind`, `state`, `status`, `mode`, `level`, `severity`, `priority`) found them used in just **7** components across the codebase (excluding shadcn primitives where they're well-defined):
| Prop | Component | Type |
|---|---|---|
| `variant` | `IssueChatThread` | `"full" \| "embedded"` |
| `variant` | `PageSkeleton` | `... \| "list"` (opaque) |
| `size` | `Identity` | `IdentitySize` |
| `kind` | `ProjectWorkspaceSummaryCard` | `"project_workspace" \| "execution_workspace"` |
| `status` | `StatusIcon` | `string` (**untyped**) |
| `mode` | `RunTranscriptView` | `TranscriptMode` |
| `priority` | `PriorityIcon` | `string` (**untyped**) |
Observations:
- `status: string` in `StatusIcon` and `priority: string` in `PriorityIcon` should be typed enums (matching the keys of `status-colors.ts`).
- `variant` is used for completely unrelated concepts in different components — that's fine semantically, but reinforces that there's no shared prop-vocabulary convention.
- Shadcn primitives (`button`, `badge`) use `variant`/`size` with well-defined CVA enums — these are the model.
---
## Token non-compliance
(Mirror of the Stage 1 token drift findings, but attributed per-component so Stage 3 reviewers can target the worst offenders.)
### Components that hardcode chart/status colors
From [tokens-review.md §1 and §4](../tokens/tokens-review.md):
| Component | Drift |
|---|---|
| `ActivityCharts.tsx` | 17 hardcoded Tailwind-palette hex values for status/priority chart colors; uses `chart-*` tokens zero times. |
| `OrgChart.tsx` (a page) | 6 hardcoded hex values for agent status dot colors. |
| `StatusIcon.tsx` (14 uses) | Consumes `issueStatusIcon` from `status-colors.ts` — raw Tailwind palette classes (`text-blue-600`, `border-violet-600`, etc.). |
| `StatusBadge.tsx` (19 uses) | Consumes `statusBadge` from `status-colors.ts` — raw Tailwind palette classes. |
| `PriorityIcon.tsx` (5 uses) | Consumes `priorityColor` — raw Tailwind palette. |
| `AgentActionButtons.tsx` | Uses `agentStatusDot` — raw Tailwind palette. |
### Components with heavy raw-palette styling
From Stage 1's 659-hit analysis:
- `AgentDetail.tsx` (75 palette hits) — production page
- `RunTranscriptView.tsx` (47 hits) — production component
- `IssueChatThread.tsx` (22 hits) — production component
### Components with arbitrary radius values
See [tokens-review.md §7](../tokens/tokens-review.md#7-arbitrary-radius-values-bypass-the-scale-18-occurrences) for the full list. Production components in the list:
- `CompanyRail.tsx``rounded-[14px]`, `rounded-[22px]`
- Several UxLab pages (acceptable as prototypes)
### Recommendation
Do not extract per-component detail docs for `StatusIcon`, `StatusBadge`, `PriorityIcon`, `AgentActionButtons` as final specs — their color language is blocked on the signal-token decision from [tokens-review.md §4](../tokens/tokens-review.md#4-status-colorsts-is-a-canonical-semantic-color-catalog-that-bypasses-the-ds). Stage-4 patterns that lean on these (status indicator, priority indicator, agent card with status) will change shape once signal tokens land.
---
## Story coverage gaps
Components with **3+ code uses and no Storybook coverage** — the highest-priority story gaps:
| Component | Uses | Notes |
|---|---|---|
| `StatusIcon` | 14 | Central status-color consumer. Coverage gap tied to §Token non-compliance. |
| `collapsible` (primitive) | 8 | Shadcn primitive. `foundations.stories.tsx` covers many primitives but skips collapsible. |
| `dropdown-menu` (primitive) | 8 | Same. |
| `avatar` (primitive) | 7 | Same — despite Avatar being one of the few components with sub-parts (`AvatarGroup`, `AvatarFallback`, `AvatarBadge`). |
| `skeleton` (primitive) | 6 | Same. |
| `scroll-area` (primitive) | 3 | Same. |
| `ApprovalPayload` | 4 | Feature. |
| `IssueReferencePill` | 4 | Feature. |
| `SidebarNavItem` | 3 | Structural. |
| `RunTranscriptView` | 3 | Feature — the transcript rendering. |
Two categories:
1. **Shadcn primitives missing from `foundations.stories.tsx`.** Small, targeted fix — add them to the existing foundations story.
2. **Production features without a story.** `StatusIcon` is the highest value given its role across 14 call sites.
---
## Unused / low-signal components
### Truly dead (0 imports, no Storybook coverage): 0
None. Every file in `ui/src/components/` either gets imported somewhere or appears in a story.
### Storybook-only (0 imports, appears in a story): 4
These are rendered in a story file but never imported by any page or other component:
| Component | Storybook location (approx.) |
|---|---|
| `AccountingModelCard` | financial/accounting-related story |
| `AgentProperties` | agent-management story |
| `CompanySwitcher` | navigation-layout story |
| `ExecutionParticipantPicker` | (story-referenced; unused in app) |
**Interpretation:** these are either (a) abandoned experiments still living in Storybook, (b) components waiting for the page that uses them, or (c) genuinely unused and should be deleted. Recommend: owner disposition per file. `AgentProperties` is especially surprising given the existence of sibling `IssueProperties`/`GoalProperties`/`ProjectProperties` — possibly a planned-but-not-wired variant of the properties family.
### Below-threshold (12 code uses, no detail file): 76
Not drift — many are legitimately single-use (one-off dialogs, one-off banners). But at 76 out of ~130 it's worth noting: this codebase heavily favors single-use components. A generic-component consolidation pass would likely shrink this group by ~30%. The candidates from §Likely duplicates above are the best places to start.
---
## Plugin SDK hybrid status, prioritization deferred
**Status: RESOLVED as intentional hybrid (2026-04-21).** Not drift. The plugin SDK (`packages/plugins/sdk/src/ui/components.ts`) declares 11 ambient component types; the host implements 2 (`MetricCard`, `StatusBadge`) and leaves the other 9 as contract-only. The 9 unimplemented components now carry a `@status contract-only` JSDoc tag in the SDK source so plugin authors see the status in IDE tooltips at call sites.
Prioritization of which of the 9 to build first is a **separate plugin-SDK roadmap conversation** — not a DS decision and not in scope here. This section captures the current state and the most likely first-implementations when that conversation happens.
### Current state
| SDK contract | Host implementation | Status |
|---|---|---|
| `MetricCard` | [`ui/src/components/MetricCard.tsx`](../../../ui/src/components/MetricCard.tsx) | ✅ implemented |
| `StatusBadge` | [`ui/src/components/StatusBadge.tsx`](../../../ui/src/components/StatusBadge.tsx) | ✅ implemented |
| `DataTable` | — | 🔌 contract-only |
| `TimeseriesChart` | — | 🔌 contract-only |
| `MarkdownBlock` | — | 🔌 contract-only |
| `KeyValueList` | — | 🔌 contract-only |
| `ActionBar` | — | 🔌 contract-only |
| `LogView` | — | 🔌 contract-only |
| `JsonTree` | — | 🔌 contract-only |
| `Spinner` | — | 🔌 contract-only |
| `ErrorBoundary` | — | 🔌 contract-only |
Each of the 9 contract-only entries has a JSDoc block in [`packages/plugins/sdk/src/ui/components.ts`](../../../packages/plugins/sdk/src/ui/components.ts) (lines 253316) that tells plugin authors the runtime will fail and points here.
`PLUGIN_SPEC.md:30` already acknowledged this before the DS extraction: _"The current runtime does not yet ship a real host-provided plugin UI component kit."_
### Implementation notes (for when prioritization happens)
Not prescriptive — candidates surfaced during the 2026-04 extraction:
- **`MarkdownBlock`** — thinnest wrapper around `ui/src/components/MarkdownBody.tsx`. Possibly an alias rather than a new component.
- **`Spinner`** — no matching host component. A ~10-line shadcn-style primitive would be the simplest new build.
- **`KeyValueList`** — patterns exist ad-hoc inside `EntityRow`, `PropertiesPanel` bodies, and `FinanceBillerCard`. Candidate for extraction into a shared primitive.
- **`LogView`** — no counterpart. Transcript rendering is tightly coupled to `RunTranscriptView`; a generic log viewer is a genuine new build.
- **`JsonTree`** — no counterpart. A new build.
- **`ErrorBoundary`** — standard React pattern; a thin wrapper around a React error boundary class.
- **`ActionBar`**, **`DataTable`**, **`TimeseriesChart`** — each is a real component's worth of surface area. Not thin builds.
### Affected files if the prioritization conversation opens
- `packages/plugins/sdk/src/ui/components.ts` (SDK declarations + @status tags)
- `packages/plugins/sdk/src/ui/runtime.ts` (runtime bridge — `renderSdkUiComponent`)
- New host files in `ui/src/components/` for each implemented contract
- [`components/index.md` — Plugin SDK contracts table](./index.md#plugin-sdk-contracts-11) (update implementation column as each lands)

View File

@@ -1,228 +0,0 @@
# Components — Index
- **Generated:** 2026-04-21
- **Repo SHA:** a26e1288b627e82c554445732c7d844648e6b5e1
- **Scope:** `ui/` + plugin SDK contracts
- **Review:** [components-review.md](./components-review.md)
## Counts
- **Total component files:** 135
- **With dedicated detail files (3+ code uses):** 53
- **Below threshold (12 uses):** 76
- **Storybook-only (in stories, 0 code uses):** 4
- **Dead (no uses, no stories):** 0
- **Non-component files (hooks, defaults):** 2
- **Plugin SDK contracts:** 11 (2 implemented by name, 9 contract-only — see §Plugin SDK contracts below)
### By category
| Category | Count |
|---|---|
| composite | 64 |
| primitive | 22 |
| standalone | 47 |
| utility-or-hook | 2 |
**Status markers in the tables below:**
- 📗 **documented** — ≥3 imports, has its own detail file in this directory
- 📘 **below-threshold** — 12 imports, no detail file
- 📙 **storybook-only** — 0 code imports, but appears in a story file
- ☠️ **dead** — 0 imports, 0 stories
- 🔌 **contract-only** — plugin SDK ambient declaration with no matching host implementation
---
## Primitives — `ui/src/components/ui/` (shadcn, 22)
All 22 shadcn primitives, by file name. These are the non-negotiable UI vocabulary — composites should consume these before reaching for custom markup.
| Component | Path | Uses | Pages / Comps | Story |
|---|---|---|---|---|
| 📗 [Button](./Button.md) | `ui/src/components/ui/button.tsx` | 81 | 41 / 38 | ✓ |
| 📗 [Popover](./Popover.md) | `ui/src/components/ui/popover.tsx` | 27 | 6 / 20 | ✓ |
| 📗 [Dialog](./Dialog.md) | `ui/src/components/ui/dialog.tsx` | 21 | 7 / 14 | ✓ |
| 📗 [Input](./Input.md) | `ui/src/components/ui/input.tsx` | 20 | 10 / 10 | ✓ |
| 📗 [Badge](./Badge.md) | `ui/src/components/ui/badge.tsx` | 18 | 11 / 7 | ✓ |
| 📗 [Card](./Card.md) | `ui/src/components/ui/card.tsx` | 18 | 10 / 8 | ✓ |
| 📗 [Tabs](./Tabs.md) | `ui/src/components/ui/tabs.tsx` | 15 | 13 / 2 | ✓ |
| 📗 [Separator](./Separator.md) | `ui/src/components/ui/separator.tsx` | 11 | 7 / 4 | ✓ |
| 📗 [Tooltip](./Tooltip.md) | `ui/src/components/ui/tooltip.tsx` | 11 | 3 / 7 | ✓ |
| 📗 [Select](./Select.md) | `ui/src/components/ui/select.tsx` | 10 | 5 / 5 | ✓ |
| 📗 [Textarea](./Textarea.md) | `ui/src/components/ui/textarea.tsx` | 9 | 4 / 5 | ✓ |
| 📗 [Collapsible](./Collapsible.md) | `ui/src/components/ui/collapsible.tsx` | 8 | 4 / 4 | — |
| 📗 [DropdownMenu](./DropdownMenu.md) | `ui/src/components/ui/dropdown-menu.tsx` | 8 | 3 / 5 | — |
| 📗 [Label](./Label.md) | `ui/src/components/ui/label.tsx` | 8 | 5 / 3 | ✓ |
| 📗 [ToggleSwitch](./ToggleSwitch.md) | `ui/src/components/ui/toggle-switch.tsx` | 8 | 5 / 3 | ✓ |
| 📗 [Avatar](./Avatar.md) | `ui/src/components/ui/avatar.tsx` | 7 | 3 / 4 | — |
| 📗 [Checkbox](./Checkbox.md) | `ui/src/components/ui/checkbox.tsx` | 6 | 4 / 2 | ✓ |
| 📗 [Skeleton](./Skeleton.md) | `ui/src/components/ui/skeleton.tsx` | 6 | 3 / 3 | — |
| 📗 [ScrollArea](./ScrollArea.md) | `ui/src/components/ui/scroll-area.tsx` | 3 | 2 / 1 | — |
| 📘 Breadcrumb | `ui/src/components/ui/breadcrumb.tsx` | 2 | 1 / 1 | — |
| 📘 Command | `ui/src/components/ui/command.tsx` | 2 | 1 / 1 | ✓ |
| 📘 Sheet | `ui/src/components/ui/sheet.tsx` | 2 | 2 / 0 | — |
---
## Composites (64)
Components that import 1+ other component from `@/components/*`. Application-level feature UI.
| Component | Path | Uses | Pages / Comps | Story |
|---|---|---|---|---|
| 📗 [PageSkeleton](./PageSkeleton.md) | `ui/src/components/PageSkeleton.tsx` | 23 | 22 / 1 | ✓ |
| 📗 [EmptyState](./EmptyState.md) | `ui/src/components/EmptyState.tsx` | 20 | 19 / 1 | ✓ |
| 📗 [agent-config-primitives](./agent-config-primitives.md) | `ui/src/components/agent-config-primitives.tsx` | 19 | 4 / 3 | ✓ |
| 📗 [Identity](./Identity.md) | `ui/src/components/Identity.tsx` | 19 | 7 / 12 | ✓ |
| 📗 [StatusIcon](./StatusIcon.md) | `ui/src/components/StatusIcon.tsx` | 14 | 5 / 9 | — |
| 📗 [AgentIconPicker](./AgentIconPicker.md) | `ui/src/components/AgentIconPicker.tsx` | 13 | 4 / 9 | ✓ |
| 📗 [PathInstructionsModal](./PathInstructionsModal.md) | `ui/src/components/PathInstructionsModal.tsx` | 12 | 2 / 3 | ✓ |
| 📗 [PageTabBar](./PageTabBar.md) | `ui/src/components/PageTabBar.tsx` | 10 | 9 / 1 | ✓ |
| 📗 [InlineEntitySelector](./InlineEntitySelector.md) | `ui/src/components/InlineEntitySelector.tsx` | 8 | 2 / 4 | ✓ |
| 📗 [IssuesList](./IssuesList.md) | `ui/src/components/IssuesList.tsx` | 6 | 5 / 1 | ✓ |
| 📗 [AgentConfigForm](./AgentConfigForm.md) | `ui/src/components/AgentConfigForm.tsx` | 5 | 3 / 0 | ✓ |
| 📗 [PriorityIcon](./PriorityIcon.md) | `ui/src/components/PriorityIcon.tsx` | 5 | 2 / 3 | ✓ |
| 📗 [IssueChatThread](./IssueChatThread.md) | `ui/src/components/IssueChatThread.tsx` | 4 | 2 / 2 | ✓ |
| 📗 [ApprovalCard](./ApprovalCard.md) | `ui/src/components/ApprovalCard.tsx` | 3 | 2 / 1 | ✓ |
| 📗 [BudgetPolicyCard](./BudgetPolicyCard.md) | `ui/src/components/BudgetPolicyCard.tsx` | 3 | 3 / 0 | ✓ |
| 📗 [IssueFiltersPopover](./IssueFiltersPopover.md) | `ui/src/components/IssueFiltersPopover.tsx` | 3 | 1 / 2 | ✓ |
| 📗 [IssueLinkQuicklook](./IssueLinkQuicklook.md) | `ui/src/components/IssueLinkQuicklook.tsx` | 3 | 0 / 2 | ✓ |
| 📗 [IssueWorkspaceCard](./IssueWorkspaceCard.md) | `ui/src/components/IssueWorkspaceCard.tsx` | 3 | 1 / 2 | ✓ |
| 📗 [RoutineRunVariablesDialog](./RoutineRunVariablesDialog.md) | `ui/src/components/RoutineRunVariablesDialog.tsx` | 3 | 2 / 1 | ✓ |
| 📗 [WorkspaceRuntimeControls](./WorkspaceRuntimeControls.md) | `ui/src/components/WorkspaceRuntimeControls.tsx` | 3 | 2 / 1 | ✓ |
| 📘 AgentActionButtons | `ui/src/components/AgentActionButtons.tsx` | 2 | 2 / 0 | ✓ |
| 📘 CommandPalette | `ui/src/components/CommandPalette.tsx` | 2 | 0 / 2 | ✓ |
| 📘 IssueColumns | `ui/src/components/IssueColumns.tsx` | 2 | 1 / 1 | ✓ |
| 📘 IssueContinuationHandoff | `ui/src/components/IssueContinuationHandoff.tsx` | 2 | 1 / 1 | ✓ |
| 📘 IssueDocumentsSection | `ui/src/components/IssueDocumentsSection.tsx` | 2 | 1 / 1 | ✓ |
| 📘 IssueProperties | `ui/src/components/IssueProperties.tsx` | 2 | 1 / 1 | ✓ |
| 📘 NewIssueDialog | `ui/src/components/NewIssueDialog.tsx` | 2 | 0 / 2 | ✓ |
| 📘 OutputFeedbackButtons | `ui/src/components/OutputFeedbackButtons.tsx` | 2 | 0 / 2 | — |
| 📘 ProjectWorkspaceSummaryCard | `ui/src/components/ProjectWorkspaceSummaryCard.tsx` | 2 | 0 / 2 | ✓ |
| 📘 ReportsToPicker | `ui/src/components/ReportsToPicker.tsx` | 2 | 1 / 1 | ✓ |
| 📘 RoutineVariablesEditor | `ui/src/components/RoutineVariablesEditor.tsx` | 2 | 2 / 0 | ✓ |
| 📘 Sidebar | `ui/src/components/Sidebar.tsx` | 2 | 0 / 2 | ✓ |
| 📘 SidebarAccountMenu | `ui/src/components/SidebarAccountMenu.tsx` | 2 | 0 / 2 | ✓ |
| 📘 SidebarCompanyMenu | `ui/src/components/SidebarCompanyMenu.tsx` | 2 | 0 / 2 | ✓ |
| 📘 CompanySettingsNav | `ui/src/components/access/CompanySettingsNav.tsx` | 1 | 0 / 1 | — |
| 📘 ModeBadge | `ui/src/components/access/ModeBadge.tsx` | 1 | 1 / 0 | — |
| 📘 BillerSpendCard | `ui/src/components/BillerSpendCard.tsx` | 1 | 1 / 0 | ✓ |
| 📘 BreadcrumbBar | `ui/src/components/BreadcrumbBar.tsx` | 1 | 0 / 1 | ✓ |
| 📘 BudgetIncidentCard | `ui/src/components/BudgetIncidentCard.tsx` | 1 | 1 / 0 | ✓ |
| 📘 CommentThread | `ui/src/components/CommentThread.tsx` | 1 | 0 / 1 | ✓ |
| 📘 CompanyRail | `ui/src/components/CompanyRail.tsx` | 1 | 0 / 1 | ✓ |
| 📘 DocumentDiffModal | `ui/src/components/DocumentDiffModal.tsx` | 1 | 0 / 1 | ✓ |
| 📘 FilterBar | `ui/src/components/FilterBar.tsx` | 1 | 1 / 0 | ✓ |
| 📘 FinanceBillerCard | `ui/src/components/FinanceBillerCard.tsx` | 1 | 1 / 0 | ✓ |
| 📘 FinanceKindCard | `ui/src/components/FinanceKindCard.tsx` | 1 | 1 / 0 | ✓ |
| 📘 FinanceTimelineCard | `ui/src/components/FinanceTimelineCard.tsx` | 1 | 1 / 0 | ✓ |
| 📘 GoalProperties | `ui/src/components/GoalProperties.tsx` | 1 | 1 / 0 | ✓ |
| 📘 IssuesQuicklook | `ui/src/components/IssuesQuicklook.tsx` | 1 | 0 / 1 | ✓ |
| 📘 JsonSchemaForm | `ui/src/components/JsonSchemaForm.tsx` | 1 | 1 / 0 | ✓ |
| 📘 KeyboardShortcutsCheatsheet | `ui/src/components/KeyboardShortcutsCheatsheet.tsx` | 1 | 0 / 1 | ✓ |
| 📘 NewAgentDialog | `ui/src/components/NewAgentDialog.tsx` | 1 | 0 / 1 | ✓ |
| 📘 NewGoalDialog | `ui/src/components/NewGoalDialog.tsx` | 1 | 0 / 1 | ✓ |
| 📘 NewProjectDialog | `ui/src/components/NewProjectDialog.tsx` | 1 | 0 / 1 | ✓ |
| 📘 OnboardingWizard | `ui/src/components/OnboardingWizard.tsx` | 1 | 0 / 0 | ✓ |
| 📘 ProjectProperties | `ui/src/components/ProjectProperties.tsx` | 1 | 1 / 0 | ✓ |
| 📘 PropertiesPanel | `ui/src/components/PropertiesPanel.tsx` | 1 | 0 / 1 | — |
| 📘 ProviderQuotaCard | `ui/src/components/ProviderQuotaCard.tsx` | 1 | 1 / 0 | ✓ |
| 📘 ScheduleEditor | `ui/src/components/ScheduleEditor.tsx` | 1 | 1 / 0 | ✓ |
| 📘 SidebarAgents | `ui/src/components/SidebarAgents.tsx` | 1 | 0 / 1 | — |
| 📘 SidebarProjects | `ui/src/components/SidebarProjects.tsx` | 1 | 0 / 1 | — |
| 📙 AccountingModelCard | `ui/src/components/AccountingModelCard.tsx` | 0 | 0 / 0 | ✓ |
| 📙 AgentProperties | `ui/src/components/AgentProperties.tsx` | 0 | 0 / 0 | ✓ |
| 📙 CompanySwitcher | `ui/src/components/CompanySwitcher.tsx` | 0 | 0 / 0 | ✓ |
| 📙 ExecutionParticipantPicker | `ui/src/components/ExecutionParticipantPicker.tsx` | 0 | 0 / 0 | ✓ |
---
## Standalones (47)
Components that import no other `@/components/*`. Usually: icons, self-contained widgets, components that only depend on radix / lucide / local libs. The fact that they import zero composites or primitives is itself a data point — some of these probably should be using primitives.
| Component | Path | Uses | Pages / Comps | Story |
|---|---|---|---|---|
| 📗 [StatusBadge](./StatusBadge.md) | `ui/src/components/StatusBadge.tsx` | 19 | 12 / 7 | ✓ |
| 📗 [MarkdownEditor](./MarkdownEditor.md) | `ui/src/components/MarkdownEditor.tsx` | 16 | 5 / 9 | ✓ |
| 📗 [MarkdownBody](./MarkdownBody.md) | `ui/src/components/MarkdownBody.tsx` | 11 | 5 / 6 | ✓ |
| 📗 [EntityRow](./EntityRow.md) | `ui/src/components/EntityRow.tsx` | 6 | 6 / 0 | ✓ |
| 📗 [InlineEditor](./InlineEditor.md) | `ui/src/components/InlineEditor.tsx` | 6 | 4 / 2 | ✓ |
| 📗 [ApprovalPayload](./ApprovalPayload.md) | `ui/src/components/ApprovalPayload.tsx` | 4 | 2 / 2 | — |
| 📗 [CompanyPatternIcon](./CompanyPatternIcon.md) | `ui/src/components/CompanyPatternIcon.tsx` | 4 | 3 / 1 | ✓ |
| 📗 [IssueReferencePill](./IssueReferencePill.md) | `ui/src/components/IssueReferencePill.tsx` | 4 | 1 / 3 | — |
| 📗 [ActivityCharts](./ActivityCharts.md) | `ui/src/components/ActivityCharts.tsx` | 3 | 2 / 1 | ✓ |
| 📗 [CopyText](./CopyText.md) | `ui/src/components/CopyText.tsx` | 3 | 2 / 1 | ✓ |
| 📗 [IssueRow](./IssueRow.md) | `ui/src/components/IssueRow.tsx` | 3 | 1 / 2 | ✓ |
| 📗 [PackageFileTree](./PackageFileTree.md) | `ui/src/components/PackageFileTree.tsx` | 3 | 3 / 0 | ✓ |
| 📗 [SidebarNavItem](./SidebarNavItem.md) | `ui/src/components/SidebarNavItem.tsx` | 3 | 0 / 3 | — |
| 📗 [RunTranscriptView](./RunTranscriptView.md) | `ui/src/components/transcript/RunTranscriptView.tsx` | 3 | 2 / 1 | — |
| 📘 ActivityRow | `ui/src/components/ActivityRow.tsx` | 2 | 2 / 0 | ✓ |
| 📘 AsciiArtAnimation | `ui/src/components/AsciiArtAnimation.tsx` | 2 | 1 / 1 | ✓ |
| 📘 BudgetSidebarMarker | `ui/src/components/BudgetSidebarMarker.tsx` | 2 | 0 / 2 | ✓ |
| 📘 CloudAccessGate | `ui/src/components/CloudAccessGate.tsx` | 2 | 0 / 0 | — |
| 📘 CompanySettingsSidebar | `ui/src/components/CompanySettingsSidebar.tsx` | 2 | 0 / 2 | — |
| 📘 EnvVarEditor | `ui/src/components/EnvVarEditor.tsx` | 2 | 0 / 2 | ✓ |
| 📘 ExecutionWorkspaceCloseDialog | `ui/src/components/ExecutionWorkspaceCloseDialog.tsx` | 2 | 1 / 1 | ✓ |
| 📘 GoalTree | `ui/src/components/GoalTree.tsx` | 2 | 2 / 0 | ✓ |
| 📘 IssueGroupHeader | `ui/src/components/IssueGroupHeader.tsx` | 2 | 1 / 1 | ✓ |
| 📘 IssueReferenceActivitySummary | `ui/src/components/IssueReferenceActivitySummary.tsx` | 2 | 1 / 1 | — |
| 📘 IssueRelatedWorkPanel | `ui/src/components/IssueRelatedWorkPanel.tsx` | 2 | 1 / 1 | — |
| 📘 IssueRunLedger | `ui/src/components/IssueRunLedger.tsx` | 2 | 1 / 1 | ✓ |
| 📘 Layout | `ui/src/components/Layout.tsx` | 2 | 0 / 1 | — |
| 📘 MetricCard | `ui/src/components/MetricCard.tsx` | 2 | 2 / 0 | ✓ |
| 📘 OpenCodeLogoIcon | `ui/src/components/OpenCodeLogoIcon.tsx` | 2 | 0 / 1 | — |
| 📘 ProjectWorkspacesContent | `ui/src/components/ProjectWorkspacesContent.tsx` | 2 | 2 / 0 | ✓ |
| 📘 QuotaBar | `ui/src/components/QuotaBar.tsx` | 2 | 0 / 2 | ✓ |
| 📘 RunChatSurface | `ui/src/components/RunChatSurface.tsx` | 2 | 0 / 2 | ✓ |
| 📘 ScrollToBottom | `ui/src/components/ScrollToBottom.tsx` | 2 | 2 / 0 | — |
| 📘 SwipeToArchive | `ui/src/components/SwipeToArchive.tsx` | 2 | 1 / 1 | ✓ |
| 📘 ActiveAgentsPanel | `ui/src/components/ActiveAgentsPanel.tsx` | 1 | 1 / 0 | ✓ |
| 📘 ClaudeSubscriptionPanel | `ui/src/components/ClaudeSubscriptionPanel.tsx` | 1 | 0 / 1 | ✓ |
| 📘 CodexSubscriptionPanel | `ui/src/components/CodexSubscriptionPanel.tsx` | 1 | 0 / 1 | ✓ |
| 📘 DevRestartBanner | `ui/src/components/DevRestartBanner.tsx` | 1 | 0 / 1 | — |
| 📘 HermesIcon | `ui/src/components/HermesIcon.tsx` | 1 | 0 / 0 | — |
| 📘 ImageGalleryModal | `ui/src/components/ImageGalleryModal.tsx` | 1 | 1 / 0 | ✓ |
| 📘 InstanceSidebar | `ui/src/components/InstanceSidebar.tsx` | 1 | 0 / 1 | — |
| 📘 KanbanBoard | `ui/src/components/KanbanBoard.tsx` | 1 | 0 / 1 | ✓ |
| 📘 LiveRunWidget | `ui/src/components/LiveRunWidget.tsx` | 1 | 1 / 0 | ✓ |
| 📘 MobileBottomNav | `ui/src/components/MobileBottomNav.tsx` | 1 | 0 / 1 | ✓ |
| 📘 SidebarSection | `ui/src/components/SidebarSection.tsx` | 1 | 0 / 1 | — |
| 📘 ToastViewport | `ui/src/components/ToastViewport.tsx` | 1 | 0 / 1 | — |
| 📘 WorktreeBanner | `ui/src/components/WorktreeBanner.tsx` | 1 | 0 / 1 | ✓ |
---
## Non-component files (2)
These live in `ui/src/components/` by convention but don't export React components.
| File | Path | Role |
|---|---|---|
| `agent-config-defaults` | `ui/src/components/agent-config-defaults.ts` | module with shared constants/defaults |
| `useLiveRunTranscripts` | `ui/src/components/transcript/useLiveRunTranscripts.ts` | React hook |
---
## Plugin SDK contracts (11)
Ambient component declarations from [`packages/plugins/sdk/src/ui/components.ts`](../../../packages/plugins/sdk/src/ui/components.ts). These are types-only; the host provides implementations at runtime via `renderSdkUiComponent(name, props)`.
> **Hybrid status is intentional (2026-04-21 decision).** Two components are implemented by the host. The other nine are **contract-only** — the types exist so plugin authors can code against them, but rendering today will fail at runtime. The 9 contract-only components carry a `@status contract-only` JSDoc tag in the SDK source, which appears in IDE tooltips at call sites. Prioritization of which to implement first is a separate plugin-SDK roadmap conversation, not a DS decision. See [components-review.md §Plugin SDK hybrid status](./components-review.md#plugin-sdk-hybrid-status-prioritization-deferred).
| SDK Component | Implementation | Status |
|---|---|---|
| `MetricCard` | [`ui/src/components/MetricCard.tsx`](../../../ui/src/components/MetricCard.tsx) | 📗 **implemented** |
| `StatusBadge` | [`ui/src/components/StatusBadge.tsx`](../../../ui/src/components/StatusBadge.tsx) | 📗 **implemented** |
| `DataTable` | — | 🔌 **contract-only** |
| `TimeseriesChart` | — | 🔌 **contract-only** (distinct from `ActivityCharts.tsx`, which has a different API) |
| `MarkdownBlock` | — | 🔌 **contract-only** (`MarkdownBody.tsx` is the host's markdown renderer, name differs) |
| `KeyValueList` | — | 🔌 **contract-only** |
| `ActionBar` | — | 🔌 **contract-only** (`AgentActionButtons.tsx` is role-specific, not a match) |
| `LogView` | — | 🔌 **contract-only** |
| `JsonTree` | — | 🔌 **contract-only** |
| `Spinner` | — | 🔌 **contract-only** |
| `ErrorBoundary` | — | 🔌 **contract-only** |
All 9 contract-only entries carry `@status contract-only` in their JSDoc block (see [`packages/plugins/sdk/src/ui/components.ts`](../../../packages/plugins/sdk/src/ui/components.ts) lines 253316).

View File

@@ -1,69 +0,0 @@
# Detail Page
Full-page layout for viewing and editing a single entity (agent, issue, project, goal, routine, approval, or execution/project workspace).
**Instances: 8.** `AgentDetail`, `IssueDetail`, `ProjectDetail`, `GoalDetail`, `RoutineDetail`, `ApprovalDetail`, `ExecutionWorkspaceDetail`, `ProjectWorkspaceDetail`.
## Composition (shared baseline)
Measured across the 8 instances by import intersection:
- **`button`** — 8/8 (every detail page has actions in its header)
- **`tabs`** — 6/8 (detail pages split sub-views by tab)
- **`PageSkeleton`** — 5/8 (loading state while the entity is being fetched)
- **`StatusBadge`** — 4/8 (status is surfaced in the header area)
- **`separator`** — 4/8
- Breadcrumb context (`useBreadcrumbs`) — all 8 set a breadcrumb trail for the entity.
[INFER] Structural template, from reading AgentDetail and IssueDetail:
```
<PageSkeleton or <ContentLoaded>
<Breadcrumb / back-nav>
<Header>
<Title> — entity name + identifier
<StatusBadge> — where applicable (issue, run, approval, agent)
<Actions> — edit, archive, more-menu
</Header>
<Tabs> — 25 tabs (overview, config, activity, …)
<TabContent>
… entity-specific body (properties, related work, charts, transcripts)
</TabContent>
</Tabs>
</>
```
## Canonical instance
`ui/src/pages/IssueDetail.tsx` is the most mature and most-cross-referenced implementation. `ui/src/pages/AgentDetail.tsx` is second and shows the tab-bar with many sub-surfaces.
## Variance across instances
Observed differences that may be intentional (different entity domain) or may be drift:
| Instance | Breadcrumbs | Tabs | Status element | Loading state | Notes |
|---|---|---|---|---|---|
| `IssueDetail` | yes | yes | `StatusIcon` + `StatusBadge` | custom | largest file; widely-referenced |
| `AgentDetail` | yes | yes | `StatusBadge` + `agentStatusDot` | `PageSkeleton` | composes `AgentConfigForm`, `ActivityCharts`, `PackageFileTree`, `RunTranscriptView` |
| `ProjectDetail` | yes | yes | `StatusBadge` | `PageSkeleton` | |
| `GoalDetail` | yes | (unconfirmed) | `StatusBadge` | `PageSkeleton` | |
| `RoutineDetail` | yes | yes | (unconfirmed) | `PageSkeleton` | |
| `ApprovalDetail` | yes | (none) | tone-coded via `ApprovalCard` | `PageSkeleton` | diverges — simpler shape |
| `ExecutionWorkspaceDetail` | yes | — | — | — | newer; diverges |
| `ProjectWorkspaceDetail` | yes | — | — | — | newer; diverges |
- **3 of 8 don't use `PageSkeleton`** — worth confirming each has an equivalent loading state.
- **`ApprovalDetail` skips tabs** — likely correct for its single-surface nature.
- **Status element is inconsistent** — `StatusBadge` alone, `StatusIcon + StatusBadge`, `StatusBadge + agentStatusDot` (separate dot helper), `ApprovalCard` tone encoding. Tied to [status-display.md](./status-display.md) and [../tokens/tokens-review.md §4](../tokens/tokens-review.md#4-status-colorsts-is-a-canonical-semantic-color-catalog-that-bypasses-the-ds).
## Related components and patterns
- Loading state: [`PageSkeleton`](../components/index.md) (documented as a component — used in 22+ places)
- Empty state: [`EmptyState`](../components/index.md) (mostly used on list pages)
- Status badge: [`StatusBadge`](../components/StatusBadge.md), [`StatusIcon`](../components/index.md) — see [status-display.md](./status-display.md)
- Tab bar: shadcn `tabs`, and a custom [`PageTabBar`](../components/PageTabBar.md) used on some pages
## Open questions / risks
- Whether to codify a `<DetailPageHeader>` composite (title + status + actions block) to reduce per-page drift. Four detail pages already diverge on which status element they use.
- The new `*WorkspaceDetail` pages do not yet share much structure with the older four. Check before Stage-4 pattern extraction runs again in a future quarter.

View File

@@ -1,51 +0,0 @@
# Entity-Creation Dialog
Dialog surface for creating a new entity (agent, goal, issue, project).
**Instances: 4.** `NewAgentDialog`, `NewGoalDialog`, `NewIssueDialog`, `NewProjectDialog`.
> **Extraction-only pass.** This pattern document records the family as it exists today. It does not prescribe a merge into a single generic `NewEntityDialog`. See [components-review.md §Likely duplicates #1](../components/components-review.md#1-entity-creation-dialog-family-newagentdialog--newgoaldialog--newissuedialog--newprojectdialog) for the open-question treatment.
## Instances
| File | Lines | Uses | Opened via |
|---|---|---|---|
| `ui/src/components/NewAgentDialog.tsx` | 210 | 1 | `useDialog().newAgentOpen` |
| `ui/src/components/NewGoalDialog.tsx` | (unread) | 1 | `useDialog()` |
| `ui/src/components/NewIssueDialog.tsx` | 1699 | 2 | `useDialog()` |
| `ui/src/components/NewProjectDialog.tsx` | (unread) | 1 | `useDialog()` |
## Composition (shared)
All four:
- Import `Dialog` + `DialogContent` from `@/components/ui/dialog` (primitive).
- Consume a central `useDialog()` context from `ui/src/context/DialogContext` that exposes open/close flags per entity type.
- Call an entity-specific API on submit (`agentsApi.create`, `issuesApi.create`, …) via `useMutation`.
- Dismiss via `closeNewX()` from the same context.
## Shape divergence
The instances are **not structurally equivalent.** Line counts alone:
- `NewAgentDialog` = 210 lines (adapter picker → create stub)
- `NewIssueDialog` = 1699 lines (rich form: assignees, projects, policies, mentions, dragdrop, advanced panel)
Other divergence indicators from imports:
- `NewIssueDialog` imports `agent-config-primitives` (`DraftInput`, `ChoosePathButton`, etc.), `ToggleSwitch`, `Popover`, large piece of `@dnd-kit`, markdown editors — i.e. a full inline form.
- `NewAgentDialog` imports `Dialog`, `Button`, adapter-registry helpers — a chooser, not a full form.
- `NewGoalDialog` and `NewProjectDialog` not examined in detail here; their size is likely between the two extremes.
## Open questions / risks
- Is `NewIssueDialog` intended to be "the" form and the others are just chooser-stubs that redirect to a detail page? That shape would be load-bearing. Currently unclear from static reading.
- Without a generic base, adding a fifth entity (e.g. `NewRoutineDialog`) means another copy of the dialog-open-context wiring. The `useDialog()` context already carries the per-entity open/close flags — it would be the natural integration point if consolidation is pursued.
## Pattern use in Stage 4 analysis
If Stage 4 were to name a composition "new-entity-dialog" for future reference, the canonical definition would be:
> A `<Dialog>` opened from the `useDialog()` context, closed via a per-entity handler, containing an entity-specific body that submits through the matching API and invalidates the matching `queryKeys` on success.
Not yet codified as code. Documented here only.

View File

@@ -1,62 +0,0 @@
# Entity Properties Panel
Side-panel content that shows an entity's metadata, lets the user edit inline, and drives per-field save state.
**Instances: 4 entity-specific panels + 1 generic panel.**
`AgentProperties`, `GoalProperties`, `IssueProperties`, `ProjectProperties` + `PropertiesPanel`.
> **Extraction-only pass.** This pattern does not prescribe a merge. The open question about whether `PropertiesPanel` composes or duplicates the four entity-specific panels is surfaced — not resolved. See [components-review.md §Likely duplicates #2](../components/components-review.md#2-properties-panel-family-agentproperties--goalproperties--issueproperties--projectproperties--generic-propertiespanel).
## Instances
| Component | Lines | Uses | Role |
|---|---|---|---|
| `PropertiesPanel.tsx` | 29 | 1 | **Generic chrome.** A slide-in `<aside>` that reads `panelContent` from `usePanel()` context and renders whatever the caller has set. |
| `AgentProperties.tsx` | (unread) | **0** | Entity-specific body (currently unused in production — Storybook-only). |
| `GoalProperties.tsx` | (unread) | 1 | Entity-specific body. |
| `IssueProperties.tsx` | 1370 | 2 | Entity-specific body; imports `StatusIcon`, `PriorityIcon`, `Identity`, `IssueReferencePill`, plus form primitives. |
| `ProjectProperties.tsx` | 1140 | 1 | Entity-specific body; imports `StatusBadge`, status-colors consumers, `InlineEditor`, `EnvVarEditor`. |
## Relationship: chrome vs content
Reading `PropertiesPanel.tsx` (29 lines):
```tsx
export function PropertiesPanel() {
const { panelContent, panelVisible, setPanelVisible } = usePanel();
if (!panelContent) return null;
return (
<aside className="… bg-card …">
<div className="… flex flex-col …">
<Header><Button icon="X" onClick={close} /></Header>
<ScrollArea><div className="p-4">{panelContent}</div></ScrollArea>
</div>
</aside>
);
}
```
So `PropertiesPanel` is a **slot**, not a content template. The four `*Properties` components are **contents** that get passed into that slot via `usePanel().setPanelContent(...)`.
## Open question (not resolved here)
Does `PropertiesPanel` already compose the four entity-specific panels, or do the four duplicate work that `PropertiesPanel` could own?
Evidence either way from a static read:
- **For "composes"**: the four entity-specific panels don't render a dialog/drawer wrapper themselves — each emits just the body content. They rely on some parent (either `PropertiesPanel` or a page-owned slot) to provide the outer container.
- **For "duplicates"**: the header layout (`Properties` title + close button) is in `PropertiesPanel`, but each of the four is 1100+ lines of its own scaffolding (section headers, separators, form fields, save-state handling) that *could* be factored into a `<PropertiesPanelBody>` helper.
**Not a call to make in this extraction.** The founder should open `IssueProperties.tsx` and `ProjectProperties.tsx` side-by-side and judge.
## Also noted
- `AgentProperties.tsx` has **0 production uses** (Storybook-only). Either abandoned or waiting for a page. See [components-review.md §Unused components](../components/components-review.md#unused--low-signal-components).
- Each entity-specific panel consumes `status-colors.ts` for status-color rendering (directly or via `StatusBadge`/`StatusIcon`), inheriting the token drift from [tokens-review.md §4](../tokens/tokens-review.md#4-status-colorsts-is-a-canonical-semantic-color-catalog-that-bypasses-the-ds).
- File sizes (11001370 lines each) suggest each panel handles its own save pipeline, field-level error states, mutations, and recent-selection tracking (indirectly observed via imports from `recent-assignees`, `recent-projects`, etc.). Whether that logic is shareable is the real design question underneath the styling question.
## Related components and patterns
- Chrome slot: `PropertiesPanel` and the [`usePanel()` context](../../../ui/src/context/PanelContext.tsx)
- Status coloring: [status-display.md](./status-display.md)
- Inline field editing primitives in [`agent-config-primitives.tsx`](../components/agent-config-primitives.md) (`DraftInput`, `InlineField`, `ToggleField`, `ToggleWithNumber`)

View File

@@ -1,80 +0,0 @@
# Entity Row
Row element for listing items in a scrollable collection (inbox, activity feed, list pages).
**Instances: 3.** `ActivityRow`, `EntityRow`, `IssueRow`.
> **Extraction-only pass.** Documents the family; does not prescribe the merge suggested in components-review. See [components-review.md §Likely duplicates #6](../components/components-review.md#6-row-family-activityrow--entityrow--issuerow).
## Instances
| Component | Lines | Uses | Role |
|---|---|---|---|
| `EntityRow.tsx` | 69 | 6 | Generic slot-based row (`leading` / `identifier` / `title` / `subtitle` / `trailing`) |
| `ActivityRow.tsx` | 92 | 2 | Activity-event-specific — renders an `ActivityEvent` with actor identity + action verb + entity link |
| `IssueRow.tsx` | 168 | 3 | Issue-specific — renders an `Issue` with `StatusIcon`, mobile/desktop slot variants, unread state, archive action |
## Composition
**`EntityRow`** — truly generic:
```tsx
interface EntityRowProps {
leading?: ReactNode;
identifier?: string;
title: string;
subtitle?: string;
trailing?: ReactNode;
selected?: boolean;
to?: string;
onClick?: () => void;
className?: string;
}
```
Renders as `<Link>` or `<div>` depending on click-ability. No status, no unread state, no mobile/desktop split — just slots.
**`ActivityRow`** — imports `Identity`, `IssueReferenceActivitySummary`. Specific to activity events.
**`IssueRow`** — imports `StatusIcon`. Props interface has 15 fields, most of them optional `ReactNode` slots:
```ts
interface IssueRowProps {
issue: Issue;
issueLinkState?: unknown;
selected?: boolean;
mobileLeading?: ReactNode; // slot
desktopMetaLeading?: ReactNode; // slot
desktopLeadingSpacer?: boolean;
mobileMeta?: ReactNode; // slot
desktopTrailing?: ReactNode; // slot
trailingMeta?: ReactNode; // slot
titleSuffix?: ReactNode; // slot
unreadState?: UnreadState | null;
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
className?: string;
}
```
Six slot props plus an untyped `issueLinkState` — this shape is nearly "`<EntityRow>` + issue-specific defaults."
## Observations
- `EntityRow` is used in 6 composites (not examined here — see composition graph), but **not used on any main list page**. Main list pages roll their own row rendering.
- `IssueRow` is only used in 2 places: the `Inbox` page and `SwipeToArchive`. Not used on the `Issues` page (which uses `IssueColumns` + custom row rendering per column).
- The gap is: `EntityRow` covers the "slot-based row" role generically, but the list pages don't adopt it.
## Variance
- **Mobile/desktop split lives only in `IssueRow`.** Whether other pages need it or have their own responsive handling is unknown from static analysis.
- **Unread state lives only in `IssueRow`.** Inbox-specific; would not generalize.
- **Activity-specific text (verb, link target) lives only in `ActivityRow`.** Legitimate domain specialization.
## Open questions
- Could `IssueRow` be expressed as `<EntityRow kind="issue" ... />`? Its slot shape already matches `EntityRow`'s role; the issue-specific bits (StatusIcon, unread state, archive) are add-ons, not structural differences.
- Why do the main list pages (`Issues`, `Agents`, `Projects`, …) avoid `EntityRow`? If there's a good reason it should be documented; if not, adoption would retire a lot of per-page row code.
Answers to these are not required for this extraction. The pattern is noted as documentation-relevant.

View File

@@ -1,76 +0,0 @@
# Finance / Accounting Card
Card surface for summarizing a financial or accounting slice: per-biller spend, per-kind spend, timeline totals, accounting-model totals.
**Instances: 5.** `BillerSpendCard`, `FinanceBillerCard`, `FinanceKindCard`, `FinanceTimelineCard`, `AccountingModelCard`.
> **Extraction-only pass.** Documents the family as it exists. Two specific items are flagged for the founder below; neither is auto-resolved.
## Instances
| Component | Lines | Uses | Data type (inferred) |
|---|---|---|---|
| `BillerSpendCard` | 145 | 1 | `CostByBiller` (+ `CostByProviderModel` breakdown) |
| `FinanceBillerCard` | 44 | 1 | `FinanceByBiller` |
| `FinanceKindCard` | (unread) | 1 | `FinanceByKind` (inferred) |
| `FinanceTimelineCard` | (unread) | 1 | timeline roll-up |
| `AccountingModelCard` | (unread) | **0** | (unknown — unused) |
## Composition (shared)
All five are composites that import the shadcn `Card` family (`Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`) and render a titled card with a body.
`BillerSpendCard` additionally composes `QuotaBar`. That's the richer card in the family — quota visualization + provider breakdown + billing-type breakdown.
`FinanceBillerCard` is a plain summary card with a three-cell metric grid (`debits` / `credits` / `estimated`).
## Flag 1 — Likely true duplicate: `BillerSpendCard` ↔ `FinanceBillerCard`
Per the founder's directive in Stage 3, this pair is flagged for diff review.
- Both have "Biller" in the name.
- Both summarize per-biller financials.
- They consume **different** data models (`CostByBiller` vs `FinanceByBiller`), which suggests either (a) two different reporting concepts the names fail to distinguish, or (b) one of them is a stale parallel implementation of the other.
- Line counts differ significantly (145 vs 44), but that could mean `BillerSpendCard` is the richer one *and* `FinanceBillerCard` is the slimmed-down version of the same concept.
**Action suggested (not taken here):** open both side by side and judge whether they represent two legitimately different reports, or whether one superseded the other and the older survived.
## Flag 2 — `AccountingModelCard` is unused
Zero imports across the codebase. Storybook-only coverage. See [components-review.md §Unused](../components/components-review.md#unused--low-signal-components). Delete or adopt.
## Composition template (common shape)
[INFER] From `FinanceBillerCard` (the cleanest example):
```
<Card>
<CardHeader>
<div flex between>
<div>
<CardTitle>{providerDisplayName(row.biller)}</CardTitle>
<CardDescription>{eventCount}, {kindCount} kinds</CardDescription>
</div>
<div text-right>
<div text-lg tabular-nums>{formatCents(row.netCents)}</div>
<div uppercase tracking-wide muted>net</div>
</div>
</div>
</CardHeader>
<CardContent>
<grid 3-column>
<Cell label="debits" value={formatCents(...)} />
<Cell label="credits" value={formatCents(...)} />
<Cell label="estimated" value={formatCents(...)} />
</grid>
</CardContent>
</Card>
```
The recurring sub-element — a small metric cell with uppercase-tracked muted label + tabular-num value — is a micro-pattern worth noting. It appears here and (less formally) on list pages. Candidate for a `<MetricCell>` helper.
## Variance
- `BillerSpendCard` composes `QuotaBar`; the others don't.
- Different data models (`CostByBiller` vs `FinanceByBiller` vs `FinanceByKind`) — confirm whether the shared shape is intentional convergence or a sign that they should share a common `FinanceCardRow` interface.
- Per-card formatting helpers (`formatCents`, `formatTokens`, `providerDisplayName`) live in `@/lib/utils` — shared. Good.

View File

@@ -1,43 +0,0 @@
# Patterns — Index
- **Generated:** 2026-04-21
- **Repo SHA:** a26e1288b627e82c554445732c7d844648e6b5e1
- **Scope:** `ui/` (@paperclipai/ui) — pages + components
- **Review:** [patterns-review.md](./patterns-review.md)
## What a pattern is
A *pattern* is a composition of components that recurs across pages or across composites — something that has a shape, not just a component name. Pattern documents describe the shape and list the current instances. They do not prescribe refactors.
Patterns were identified by:
1. Reading `_pages.json` and `_composition-graph.json` (Stage 2 scratch).
2. Looking for import-set intersections across pages or composition-graph neighborhoods.
3. Cross-referencing the duplicate families surfaced in [components-review.md §Likely duplicates](../components/components-review.md#likely-duplicates).
4. Checking the Paperclip-domain checklist from the extraction skill (heartbeat, run-transcript row, agent card, approval gate, cost display, metadata grid).
## Pattern inventory
Sorted by instance count. Patterns with ≥3 instances get their own detail doc; pairs below the threshold are included per directive but called out.
| Pattern | Instances | Doc |
|---|---|---|
| [List page](./list-page.md) | 12 | ✓ |
| [Detail page](./detail-page.md) | 8 | ✓ |
| [Sidebar chrome](./sidebar-chrome.md) | 6 outer + 2 menus | ✓ |
| [Finance / accounting card](./finance-card.md) | 5 | ✓ |
| [Entity properties panel](./entity-properties-panel.md) | 4 entity-specific + 1 generic chrome | ✓ |
| [Entity-creation dialog](./entity-creation-dialog.md) | 4 | ✓ |
| [Status display](./status-display.md) | 3 components + 1 catalog | ✓ |
| [Entity row](./entity-row.md) | 3 | ✓ |
| [Subscription panel](./subscription-panel.md) | 2 (below threshold — documented) | ✓ |
| [Quota display](./quota-display.md) | 2 (below threshold — documented) | ✓ |
See [patterns-review.md](./patterns-review.md) for:
- Pattern opportunities that don't yet meet the threshold but are domain-relevant (heartbeat, run-transcript row, agent card, approval gate, cost display, metric cell, severity indicator).
- Variance analysis across patterns.
- Which patterns are safe to codify and which are blocked on upstream token or naming decisions.
## Scope notes
- **Out of this pass:** deep pattern extraction from the UX Lab pages (`IssueChatUxLab`, `RunTranscriptUxLab`, `InviteUxLab`). Those are acknowledged prototypes — pattern work there should follow explicit founder direction, not auto-extraction.
- **Out of this pass:** the plugin SDK contract surface. Patterns emerge from the host `ui/`; if the SDK contract is fulfilled (see [components-review.md §Plugin SDK contract gap](../components/components-review.md#plugin-sdk-contract-gap)), those host implementations become additional pattern instances and this doc will need a re-run.

View File

@@ -1,60 +0,0 @@
# List Page
Full-page layout for browsing a collection of entities (agents, issues, projects, goals, routines, approvals, workspaces).
**Instances: 12.** `Activity`, `Agents`, `Approvals`, `Companies`, `Goals`, `Inbox`, `Issues`, `MyIssues`, `Projects`, `Routines`, `Workspaces`, `JoinRequestQueue`.
## Composition (shared baseline)
Measured across 12 instances by import intersection:
- **`PageSkeleton`** — 9/12 (the loading shell)
- **`EmptyState`** — 8/12 (shown when the collection is empty)
- **`button`** — 7/12 (action buttons: new-X, filters, view-toggle)
- **`PageTabBar`** — 4/12 (views split into named lists)
- **`tabs`** — 4/12
[INFER] Structural template, from reading Issues / Agents / Projects:
```
<Page>
<Header>
<Title> — "Issues", "Agents", …
<FilterOrTabBar> — PageTabBar or tabs for sub-collections
<ActionButton> — "New Issue", "Invite", etc.
</Header>
<Body>
{loading ? <PageSkeleton />
empty ? <EmptyState title message action />
: <List / Grid / Kanban>
{items.map(item =>
<IssueRow or EntityRow or domain-specific row />)}
</>
}
</Body>
</Page>
```
## Canonical instance
No single clean instance — `Issues.tsx` and `Agents.tsx` are both representative but each mixes in substantial custom logic. `Agents.tsx` shows the pattern with the least domain-specific clutter.
## Variance across instances
- **Row rendering diverges.** `Issues` uses `IssueRow`, `Agents` builds a grid of cards, `Approvals` uses a list of cards, `Goals` uses `GoalTree`. Different collections legitimately need different affordances, but none of them use the generic `EntityRow` — which is 6 uses spread across feature subsurfaces, not pages.
- **Empty-state content is ad-hoc.** No shared "empty collection" copy or illustration; each page passes its own strings to `EmptyState`.
- **Sorting / filtering surface is not shared.** Some pages have a `FilterBar`, some have in-header popovers, some have `IssueFiltersPopover` specifically for Issues. See also [components-review.md §Naming inconsistencies](../components/components-review.md#naming-inconsistencies) on `*Bar` vs popover filters.
- **`Activity` is borderline.** It's a chronological feed rather than a collection — composes `ActivityRow` only.
- **Loading state absent on 3 pages** — confirm each has its own mechanism.
## Related components and patterns
- [`EmptyState`](../components/index.md) (used in 19 places across the app)
- [`PageSkeleton`](../components/index.md) (22 places)
- [`PageTabBar`](../components/PageTabBar.md) (10 uses)
- [entity-row pattern](./entity-row.md) — generic row, currently underused on list pages
## Open questions / risks
- `EntityRow` (6 uses, generic) is never used on the main list pages. The main list pages roll their own row. Worth asking whether that's by choice or by miss. See also [entity-row.md](./entity-row.md).
- The mobile list experience is handled per-page; no shared mobile list pattern found. See [`SwipeToArchive.tsx`](../components/index.md) which only `Inbox` uses.

View File

@@ -1,138 +0,0 @@
# Patterns Review
Cross-cutting findings from Stage 4. Ordered by expected human value.
- **Generated:** 2026-04-21
- **Inventory:** [index.md](./index.md)
- **Upstream dependencies:** [../tokens/tokens-review.md](../tokens/tokens-review.md), [../components/components-review.md](../components/components-review.md)
---
## Variance across documented patterns (what's inconsistent between instances)
### Status-element variance in detail pages ([detail-page.md](./detail-page.md))
Four different ways detail pages render the entity's status:
| Detail page | Status treatment |
|---|---|
| `IssueDetail` | `StatusIcon` + `StatusBadge` |
| `AgentDetail` | `StatusBadge` + `agentStatusDot` (a helper in `status-colors.ts`, not a component) |
| `ProjectDetail`, `GoalDetail` | `StatusBadge` alone |
| `ApprovalDetail` | Tone encoding inside `ApprovalCard`, no badge |
That's four variations across eight pages. Tied to the broader [status-display.md](./status-display.md) and [tokens-review.md §4](../tokens/tokens-review.md#4-status-colorsts-is-a-canonical-semantic-color-catalog-that-bypasses-the-ds) issues.
### Loading-state variance across list and detail pages
`PageSkeleton` is used in 22 places, but 3 of 8 detail pages and 3 of 12 list pages skip it. Each of those pages has its own ad-hoc loading handling (or doesn't render until data arrives). Worth auditing for consistency.
### Row variance across list pages ([list-page.md](./list-page.md) + [entity-row.md](./entity-row.md))
`EntityRow` (generic, 6 uses) is never used on any main list page. Every list page rolls its own row: `IssueRow` for Inbox/Swipe, grid cards for Agents, Kanban columns for Issues, `GoalTree` for Goals, etc. Either the list pages should adopt `EntityRow` or `EntityRow` has a gap that keeps it from fitting (status/unread/mobile-responsive).
---
## Paperclip-domain patterns worth calling out (opportunities, not ratified patterns)
These are the checklist patterns from the extraction skill — run-transcript row, heartbeat indicator, agent card, cost display, approval gate, metric cell. Each is examined for whether it currently exists as a 3+ pattern, a thinner-than-threshold pattern, or a clear opportunity.
### 1. Run transcript row
**Status:** a single parent component (`RunTranscriptView.tsx`, 3 uses) owns the rendering. Not a cross-cutting pattern at the instance level.
- `RunTranscriptView` lives in `ui/src/components/transcript/`.
- Used by `AgentDetail`, `IssueDetail`, `RunTranscriptUxLab`.
- Has its own test (`RunTranscriptView.test.tsx`) and a sibling hook (`useLiveRunTranscripts.ts`).
- No Storybook story — see [components-review.md §Story coverage gaps](../components/components-review.md#story-coverage-gaps).
**Opportunity:** if transcript rows (agent action + timestamp + tool call summary) were exposed as a `<TranscriptRow>` primitive, downstream surfaces (embedded transcripts, search results, activity feed) could reuse it. Currently the rendering is internal to `RunTranscriptView`.
### 2. Heartbeat / liveness indicator
**Status:** one component (`LiveRunWidget`, 1 use) plus scattered inline usage in `AgentDetail` (imports `heartbeatsApi`). Not a pattern.
- `LiveRunWidget.tsx` is a "live" indicator with cyan border glow; 90 lines; only used on Dashboard.
- `AgentDetail` renders its own live-status dot (`border-cyan-500/30 shadow-[0_0_12px_rgba(6,182,212,0.08)]` hardcoded).
- `ActiveAgentsPanel` also renders live markers.
**Opportunity:** extract a `<Heartbeat live={bool} lastSeenAt={Date}>` primitive. Today the three surfaces each reinvent the visual treatment.
### 3. Agent card
**Status:** fragmented across `ActiveAgentsPanel` (1 use, composite), `Agents` page (rolls its own), and `SidebarAgents` (1 use). No shared `AgentCard`.
**Opportunity:** unify into a single card pattern if Stage 3's "Card vs Panel vs Widget" naming question gets resolved.
### 4. Approval gate UI
**Status:** `ApprovalCard` (3 uses), `ApprovalPayload` (4 uses), `OutputFeedbackButtons` (2 uses). Three components, all in the approval flow, but each a single surface — not repeated across three independent contexts.
- `ApprovalCard` and `ApprovalPayload` appear in `ApprovalDetail` page and in `Approvals` list. `OutputFeedbackButtons` appears in run detail surfaces.
**Status call:** not enough cross-cutting instance count to formalize as a pattern today. The trio is **already** the approval-gate pattern — just not repeated enough to name.
### 5. Cost / budget display
**Status:** six related components (`BudgetPolicyCard`, `BudgetIncidentCard`, `BudgetSidebarMarker`, `QuotaBar`, `ProviderQuotaCard`, `BillerSpendCard`) span two adjacent pattern docs ([finance-card.md](./finance-card.md), [quota-display.md](./quota-display.md)).
**Not merged here** — the finance / accounting slice documents the "reporting card" shape; the quota slice documents the "used vs budget" shape; the budget slice hand-rolls its own 3-level severity indicator (see §6). These three related but distinct domains would benefit from a shared glossary before pattern consolidation.
### 6. Severity indicator (3-level health display) — **pattern opportunity**
[Not documented as a dedicated pattern file in this pass; surfaced here as the most impactful opportunity.]
The app has **four distinct systems** for encoding "healthy / warning / critical":
| System | Location | Example |
|---|---|---|
| `status-colors.ts` | `issueStatusText.todo = "text-blue-600 dark:text-blue-400"` etc. | 11 hues, -600/-400 weights |
| `ActivityCharts.tsx` hardcoded hex | `critical: "#ef4444"` | Raw hex, no dark-mode variant |
| `BudgetPolicyCard` / `BudgetIncidentCard` | `"text-red-300 border-red-500/30 bg-red-500/10"` | 3-level with 300/500/10% alpha |
| `BudgetSidebarMarker` | `"bg-emerald-500/90 text-white"` | 3-level with 500/90% alpha |
| `QuotaBar` | `"bg-red-400"` etc. | 3-level solid 400 |
Each encodes the same *concept* (red/amber/green severity), each chooses different *Tailwind utility classes*. None of them reference the DS tokens — because there are no DS tokens for this. See [tokens-review.md §4 — status-colors.ts is a canonical semantic-color catalog that bypasses the DS](../tokens/tokens-review.md#4-status-colorsts-is-a-canonical-semantic-color-catalog-that-bypasses-the-ds).
**Prerequisite:** a `--signal-*` token family (`--signal-success`, `--signal-warning`, `--signal-error`, each with `-bg`/`-text`/`-border`/`-subtle` variants). Once tokens exist, a `<SeverityIndicator level="ok|warn|critical">` primitive becomes writable and all four sites collapse onto it.
**Why this is the highest-leverage pattern opportunity:** it sits downstream of Stage 1's biggest finding (§4) and unblocks at least three patterns at once (status-display, quota-display, cost-display). Do not try to codify those three separately first.
### 7. Metric cell (small uppercase label + tabular-num value)
Recurring micro-pattern spotted in `FinanceBillerCard`:
```tsx
<div className="border border-border p-3">
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">debits</div>
<div className="mt-1 font-medium tabular-nums">{formatCents(...)}</div>
</div>
```
Appears inline in `FinanceBillerCard`, `BillerSpendCard`, `ProviderQuotaCard`, and (with small variations) in some detail-page headers and dashboard surfaces. Used ad hoc rather than as a reusable `<MetricCell>` primitive. Pattern exists informally; could be extracted to ~15 lines.
### 8. Metadata grid (label-value pairs, common in detail views)
Per the skill checklist. Not surfaced as a recurring shape in the current codebase's detail pages — each detail page renders its metadata inline. `KeyValueList` is contracted in the plugin SDK but not implemented. Weak pattern; opportunity.
---
## Candidates for future documentation (below threshold today)
Patterns glimpsed at 12 instances. If usage grows, they'd warrant dedicated docs:
- **Toolbar / filter bar** — `FilterBar` (1), `IssueFiltersPopover` (2), inline filter popovers on several list pages. No shared shape yet.
- **Inline editor** — `InlineEditor` (6 uses) plus the `DraftInput` / `DraftTextarea` primitives from `agent-config-primitives`. The inline-edit interaction has a shape; not yet abstracted as a named pattern.
- **Modal image / file viewer** — `ImageGalleryModal`, `DocumentDiffModal` (and `PathInstructionsModal`). Three modals with different content, possibly sharing chrome.
- **Kanban column** — `KanbanBoard` (2 uses) + `IssueColumns` (4 uses) + `IssueGroupHeader` (1). Issue-specific; pattern lives inside the issue domain.
---
## What to resolve before Stage 4 would run cleanly on re-extraction
Pattern extraction is limited by upstream ambiguity. The following decisions, if made, would let the next pattern re-run produce materially tighter docs:
1. **Signal token family** ([tokens-review.md §4](../tokens/tokens-review.md#4-status-colorsts-is-a-canonical-semantic-color-catalog-that-bypasses-the-ds)). Blocks [status-display.md](./status-display.md), part of [quota-display.md](./quota-display.md), and §6 above.
2. **Radius scale decision** ([tokens-review.md §Radius scale](../tokens/tokens-review.md#radius-scale--under-founder-review)). Once `rounded-lg` / `rounded-xl` behave predictably, the UxLab pages can be brought into the main DS conversation instead of ignored.
3. **Plugin SDK contract disposition** ([components-review.md §Plugin SDK contract gap](../components/components-review.md#plugin-sdk-contract-gap)). Dictates whether `KeyValueList` / `LogView` / `ActionBar` / `Spinner` / `DataTable` / `TimeseriesChart` become host patterns or stay aspirational.
4. **Naming vocabulary** ([components-review.md §Naming inconsistencies](../components/components-review.md#naming-inconsistencies)). Not strictly required for patterns to exist, but the docs here have to use neutral language (_"dialog pattern — some implementations named *Modal, same primitive"_) because the vocabulary is unresolved. A canonical decision would tighten pattern prose.

View File

@@ -1,71 +0,0 @@
# Quota Display
Visual representation of "used vs budget" for provider quotas, subscription quotas, or billing windows.
**Instances: 2 (below the 3+ threshold, documented per directive).**
`QuotaBar` (2 uses), `ProviderQuotaCard` (1 use).
> **Extraction-only pass.** Documents the pair; does not prescribe a merge. See [components-review.md §Likely duplicates #9](../components/components-review.md#9-quota-display-providerquotacard--quotabar).
## Instances
| Component | Lines | Uses | Role |
|---|---|---|---|
| `QuotaBar.tsx` | 65 | 2 | Single horizontal bar — % used with optional deficit notch |
| `ProviderQuotaCard.tsx` | 416 | 1 | Full per-provider card — composes `QuotaBar` + `ClaudeSubscriptionPanel`/`CodexSubscriptionPanel` |
## Composition
`QuotaBar`:
```ts
interface QuotaBarProps {
label: string;
percentUsed: number; // 0100
leftLabel: string;
rightLabel?: string;
showDeficitNotch?: boolean;
className?: string;
}
```
- Single horizontal bar with a filled portion.
- Fill color is computed from a threshold function:
- `>90% → bg-red-400`
- `>70% → bg-yellow-400`
- `else → bg-green-400`
`ProviderQuotaCard`:
- Composes `QuotaBar` (multiple times for different windows: 5h / 24h / 7d rolling + period budget), plus `ClaudeSubscriptionPanel` / `CodexSubscriptionPanel` for vendor-native quota windows.
- Three-level wrapper around `QuotaBar`.
## Token drift inherent to this pattern
`QuotaBar` hardcodes `bg-red-400`, `bg-yellow-400`, `bg-green-400` — raw Tailwind palette. This is a **fourth** place where the app encodes three-level severity:
1. `status-colors.ts` — red-600/500/400, amber-400, yellow-400, etc.
2. `ActivityCharts.tsx` — direct hex values
3. `BudgetPolicyCard` / `BudgetIncidentCard` / `BudgetSidebarMarker` — 3-level severity with 500/90% alpha
4. `QuotaBar` — 3-level severity with solid 400s
All four encode "health / warn / hard-stop" but none share a token. See [tokens-review.md §1, §4](../tokens/tokens-review.md) for the token-drift picture and [patterns-review.md](./patterns-review.md) for the severity-indicator pattern opportunity.
## Different affordances, not a duplicate
The pair is **not** a duplicate in the literal sense — `QuotaBar` is a rendering primitive, `ProviderQuotaCard` is a composer. The naming suggests parallels (`-Bar` vs `-Card`) but their roles are distinct:
- Use `QuotaBar` for a single measured ratio.
- Use `ProviderQuotaCard` for a full provider's worth of quotas.
What the name spread **does** flag: there's no shared concept of "quota display primitive" — `QuotaBar` is one, but the finance/accounting cards use their own bar-less metric cells (`<Cell label value />` triples), and `BudgetPolicyCard` uses hand-rolled borders. The family would benefit from either promoting `QuotaBar` into a shared DS primitive for all "used vs budget" visualizations, or acknowledging that each surface wants a different look.
## Scale caveat
Only 2 instances — the pattern is thinly attested. Listed here per founder directive; a real "quota display" DS pattern would need a third independent caller before it's real.
## Related
- [finance-card.md](./finance-card.md) — `BillerSpendCard` also composes `QuotaBar`.
- [subscription-panel.md](./subscription-panel.md) — `ProviderQuotaCard` composes both subscription panels.
- [patterns-review.md §Severity indicator](./patterns-review.md) — the wider family of 3-level severity displays.

View File

@@ -1,63 +0,0 @@
# Sidebar Chrome
Left-rail and settings-sidebar UI that wraps the main app surface.
**Instances: 6 "outer" + 2 sidebar menus = 8 components in the family.**
`Sidebar`, `InstanceSidebar`, `CompanySettingsSidebar`, `CompanyRail`, `access/CompanySettingsNav`, `MobileBottomNav` + `SidebarAccountMenu`, `SidebarCompanyMenu`.
> **Extraction-only pass.** Does not resolve the naming inconsistencies (Sidebar vs Rail vs Nav). See [components-review.md §Naming inconsistencies — Sidebar / Rail / Nav](../components/components-review.md#sidebar--rail--nav) and [§Likely duplicates #4 and #7](../components/components-review.md#4-sidebar-menu-pair-sidebaraccountmenu--sidebarcompanymenu).
## Instances
**Outer chrome (6):**
| Component | Lines | Uses | Role (inferred) |
|---|---|---|---|
| `Sidebar.tsx` | (unread) | ≥3 | Main app navigation sidebar |
| `InstanceSidebar.tsx` | (unread) | 1 | Instance-settings scope |
| `CompanySettingsSidebar.tsx` | (unread) | 2 | Company-settings scope |
| `CompanyRail.tsx` | 260 | 1 | Narrow vertical rail of sortable companies (dnd-kit) |
| `access/CompanySettingsNav.tsx` | (unread) | 1 | Tab-bar-style nav at the top of a settings surface |
| `MobileBottomNav.tsx` | (unread) | ≥3 | Mobile-bottom-tab alternative to the desktop sidebar |
**Sidebar menus (2):**
| Component | Uses | Role (inferred) |
|---|---|---|
| `SidebarAccountMenu.tsx` | 2 | Account dropdown anchored to the sidebar |
| `SidebarCompanyMenu.tsx` | 2 | Company dropdown anchored to the sidebar |
## Composition
Each of the six outer-chrome components solves a different surface problem (main nav vs settings nav vs rail vs bottom-bar), so the set is not simply a duplicate family. What they share is:
- They all attach to an app-level layout slot (`Layout.tsx`).
- They all render navigation items (`SidebarNavItem` is a shared primitive, 3 uses).
- Most compose `SidebarSection` as a grouping primitive.
- `Sidebar` + `InstanceSidebar` + `CompanySettingsSidebar` share the "aside" shape; `CompanyRail` is a narrower 3rd-dimension variant; `MobileBottomNav` is a horizontal mobile variant; `CompanySettingsNav` is a top-of-page tab bar and arguably belongs in a different family ("page tab nav") rather than "sidebar chrome."
## Token note — none of these consume `sidebar-*` tokens
From [tokens-review.md §3](../tokens/tokens-review.md#3-sidebar--tokens-are-dead): the 8 `sidebar-*` color tokens defined in `index.css` (`--sidebar`, `--sidebar-foreground`, `--sidebar-primary`, `--sidebar-primary-foreground`, `--sidebar-accent`, `--sidebar-accent-foreground`, `--sidebar-border`, `--sidebar-ring`) have **0 code usages**. All six chrome components consume the general semantic tokens (`background`, `accent`, `border`) directly.
Either the `sidebar-*` family should be adopted (to enable theming the sidebar independently of the main surface), or deleted (since nothing uses it).
## The sidebar-menu pair
`SidebarAccountMenu` and `SidebarCompanyMenu` are the dropdown menus triggered from the sidebar (account actions, company switching). Both used exactly twice. Per the duplicate directive, documented as a pair here, not auto-merged. If merged, the natural shape would be `<SidebarMenu kind="account" | "company">` or `<SidebarMenu>` with `children` slots.
## Vocabulary observation (not resolved)
The chrome family uses three different names for the same category of affordance:
- **Sidebar** (3 of the 6): the persistent rail
- **Rail** (1): `CompanyRail` — narrower / sortable
- **Nav** (2): `MobileBottomNav` + `CompanySettingsNav`
Whether these reflect real category distinctions or casual naming is a call for the founder. Pattern extraction records the observation and moves on.
## Related components and patterns
- `SidebarNavItem` — shared nav-item primitive (3 uses; no story — see [components-review.md §Story gaps](../components/components-review.md#story-coverage-gaps))
- `SidebarSection` — shared grouping primitive (1 use; below detail-file threshold)
- [entity-row.md](./entity-row.md) — tangential, since the item in a sidebar is closer to a nav-item than an entity row

View File

@@ -1,105 +0,0 @@
# Status Display
How the app renders entity status (for issues, runs, agents, goals, approvals, projects) and priority (for issues).
**Instances: 3 components + 1 module.**
`StatusIcon` (14 uses), `StatusBadge` (19 uses), `PriorityIcon` (5 uses), and `status-colors.ts` (the canonical catalog that all three consume).
> **Pattern shape pending signal-token scoping** — see [tokens-review.md §4](../tokens/tokens-review.md#4-status-colorsts-is-a-canonical-semantic-color-catalog-that-bypasses-the-ds). Any pattern codification that fixes the current prop shape or color-binding will likely change once the signal-token family lands.
## Instances
### `StatusIcon` (14 uses, `ui/src/components/StatusIcon.tsx`)
Small circular status indicator for issue status.
```ts
interface StatusIconProps {
status: string; // untyped — see Open questions
onChange?: (status: string) => void;
className?: string;
showLabel?: boolean;
}
```
- Renders a `<span>` shaped as a circle with `border-2` and a hue from `issueStatusIcon[status]`.
- Includes a special "done" variant that fills the circle.
- If `onChange` is provided, wraps in a `<Popover>` with a picker of all statuses.
- Used across issue lists, properties panels, inbox, issue detail.
### `StatusBadge` (19 uses, `ui/src/components/StatusBadge.tsx`)
Pill-shaped status badge for any entity type.
```ts
interface Props {
status: string; // untyped — see Open questions
}
```
- 15-line component — wraps a `<span>` with `statusBadge[status]` classes.
- Uses `rounded-full px-2.5 py-0.5 text-xs` — a fixed shape across all statuses.
- `status` prop accepts an open string. The `statusBadge` record in `status-colors.ts` has 24 known keys covering agent / goal / run / approval / issue domains.
### `PriorityIcon` (5 uses, `ui/src/components/PriorityIcon.tsx`)
Small priority indicator (up/down/flat arrow or warning triangle).
```ts
interface PriorityIconProps {
priority: string; // untyped — see Open questions
onChange?: (priority: string) => void;
className?: string;
showLabel?: boolean;
}
```
- Renders one of four lucide icons (`ArrowUp`, `ArrowDown`, `Minus`, `AlertTriangle`) per priority level.
- Same popover-when-onChange pattern as `StatusIcon`.
- Priority values: `critical`, `high`, `medium`, `low`.
### `status-colors.ts` (`ui/src/lib/status-colors.ts`)
Canonical status-and-priority color catalog:
```ts
export const issueStatusIcon: Record<string, string> = { };
export const issueStatusText: Record<string, string> = { };
export const statusBadge: Record<string, string> = { };
export const agentStatusDot: Record<string, string> = { };
export const priorityColor: Record<string, string> = { };
```
The file's header says _"Every component that renders a status indicator should import from here so colors stay consistent."_ — the three components above do. So does `AgentActionButtons`, `ProjectProperties`, `AgentDetail`, and a handful of pages.
## Composition (shared pattern)
```
<statusValue-prop> → <record lookup in status-colors.ts> → <raw Tailwind-palette classes> → <render>
```
All three components follow this template. The pattern is correct in spirit — there is a central catalog. But the catalog contains raw Tailwind palette values (`text-blue-600 dark:text-blue-400`, `bg-green-100 text-green-700`, etc.), not DS tokens.
## Untokenized colors
Per [tokens-review.md §4](../tokens/tokens-review.md#4-status-colorsts-is-a-canonical-semantic-color-catalog-that-bypasses-the-ds), this entire pattern bypasses the DS token layer. The 11 hues currently in play (`blue`, `cyan`, `sky`, `green`, `emerald`, `amber`, `orange`, `yellow`, `violet`, `red`, `neutral`) across 24+ status keys could consolidate into a `--signal-*` token family:
- `--signal-success`, `--signal-warning`, `--signal-error`, `--signal-info`, `--signal-in-progress`, `--signal-in-review`, `--signal-neutral` — each with `-bg`, `-text`, `-border` variants.
Not proposed as a concrete change here. Flagged as the token gap that blocks this pattern from being codifiable.
## Props are untyped strings
Both `StatusIcon.status: string` and `PriorityIcon.priority: string` are open strings — TypeScript doesn't prevent callers from passing `"banana"`. The records in `status-colors.ts` fall through to a `*Default` class when a key is missing, so bad input degrades silently.
Once signal tokens exist, these props should be typed enums whose members match the signal-token keys. Any callers passing arbitrary strings will then light up at compile time.
## Related patterns and token drift
- Page-level: [detail-page.md](./detail-page.md) (status appears in detail-page headers)
- The "severity indicator" family — `BudgetPolicyCard`, `BudgetIncidentCard`, `BudgetSidebarMarker`, `QuotaBar` — uses a **third** color system (hand-picked red/amber/emerald at varying opacities), distinct from `status-colors.ts` and from the dead `--chart-*` tokens. See [patterns-review.md §Candidates](./patterns-review.md) for notes.
- `AgentActionButtons` and `ActivityCharts` also consume `status-colors.ts` / hardcoded hex — see [components-review.md §Token non-compliance](../components/components-review.md#token-non-compliance).
## Do-not-codify (yet)
Do not propose concrete refactors of `StatusIcon` / `StatusBadge` / `PriorityIcon` shape before the signal-token work. The prop API (typed enums), class naming, and default-fallback behavior all depend on what signal tokens look like.

View File

@@ -1,56 +0,0 @@
# Subscription Panel
Panel summarizing a per-vendor subscription quota (rolling windows, reset times, session vs weekly).
**Instances: 2 (below the 3+ threshold, documented per directive).**
`ClaudeSubscriptionPanel`, `CodexSubscriptionPanel`.
> **Extraction-only pass.** Documents the pair as it exists; does not prescribe a collapse to a single `SubscriptionPanel({ vendor })`. See [components-review.md §Likely duplicates #3](../components/components-review.md#3-subscription-panel-pair-claudesubscriptionpanel--codexsubscriptionpanel).
## Instances
| Component | Lines | Uses | Vendor |
|---|---|---|---|
| `ClaudeSubscriptionPanel.tsx` | 140 | 1 | Anthropic Claude |
| `CodexSubscriptionPanel.tsx` | (unread) | 1 | OpenAI Codex |
## Composition
`ClaudeSubscriptionPanel` signature:
```ts
interface ClaudeSubscriptionPanelProps {
windows: QuotaWindow[];
source?: string | null;
error?: string | null;
}
```
- Takes an array of `QuotaWindow` (from `@paperclipai/shared`).
- Renders ordered windows (session, week-all-models, week-sonnet-only, week-opus-only, extra-usage).
- Shows a reset timestamp per window using `toLocaleString`.
`CodexSubscriptionPanel` is parallel — same shape of inputs, same conceptual layout, different window ordering and different label normalization presumably.
Both are composed by `ProviderQuotaCard` — the host that decides "this is a Claude provider, render `ClaudeSubscriptionPanel`; this is a Codex provider, render `CodexSubscriptionPanel`."
## Variance
- **Window keys differ** (`currentsession`, `currentweekallmodels`, `currentweeksonnetonly`, … in Claude; likely a different set in Codex).
- **Label rules differ** — each has its own `normalizeLabel`.
- **Reset-time rendering is identical pattern** (`window.resetsAt → toLocaleString`).
- **Error surface is the same prop**.
## Scale caveat
Only 2 instances. The "pattern" shape is strong because the components look parallel, but there's no third point to triangulate from. If a third vendor is added (Gemini? Cursor?), the pattern becomes real; until then, two parallel files that share ~60% structure.
## Open questions
- Should `ProviderQuotaCard` own the vendor-specific rendering directly, or should the pair stay as separate files the card dispatches to?
- Are the two files diffable down to a `windowsConfig` data shape + a shared renderer? This would be the path if consolidation is pursued.
## Related
- [finance-card.md](./finance-card.md) — `BillerSpendCard` and `ProviderQuotaCard` are the orchestrator cards that consume these subscription panels.
- [tokens-review.md §4](../tokens/tokens-review.md) — subscription panels may consume status-color logic for "over-quota" state; confirm during any future consolidation.

View File

@@ -1,402 +0,0 @@
# Token Review
This is the high-value artifact from Stage 1. It lists what looks wrong, inconsistent, or underdetermined about the token set. Ordered by expected human value, not by stage.
- **Generated:** 2026-04-21
- **Repo SHA:** a26e1288b627e82c554445732c7d844648e6b5e1
- **Scope:** `ui/` (`@paperclipai/ui`)
- **Inventory:** [tokens.md](./tokens.md), [tokens.json](./tokens.json)
---
## High-confidence drift (likely should be fixed)
### 1. `chart-*` tokens — reserved
**Status: RECLASSIFIED (2026-04-21).** Tokens preserved as **reserved** for the future chart-tokenization project. Not considered drift; do not consume today. Comment added above the `@theme` block in `ui/src/index.css`. Original finding retained below for history.
**Original finding:** `--chart-1` through `--chart-5` have **0 code usages** (excluding their definitions and the `DesignGuide.tsx` swatch showcase). Yet the app renders charts — `ui/src/components/ActivityCharts.tsx` and `ui/src/pages/OrgChart.tsx` carry the chart color logic.
**What they use instead:** raw Tailwind-palette hex values, typed directly into TSX.
```
ui/src/components/ActivityCharts.tsx:125-128
critical: "#ef4444", high: "#f97316", medium: "#eab308", low: "#6b7280",
ui/src/components/ActivityCharts.tsx:178-184
todo: "#3b82f6", in_progress: "#8b5cf6", in_review: "#a855f7",
done: "#10b981", blocked: "#ef4444", cancelled: "#6b7280", backlog: "#64748b",
ui/src/components/ActivityCharts.tsx:258
color = rate >= 0.8 ? "#10b981" : rate >= 0.5 ? "#eab308" : "#ef4444";
ui/src/pages/OrgChart.tsx:162-169
running: "#22d3ee", active: "#4ade80", paused: "#facc15",
idle: "#facc15", error: "#f87171", terminated: "#a3a3a3",
```
**Why it matters:** `--chart-1..5` were reserved by the shadcn default theme for "data visualization tokens", but the actual chart layer never migrated onto them. Chart color is currently a parallel system, bound to Tailwind palette + status semantics rather than to the DS.
**Suggested direction (not decided here):** Replace `chart-1..5` with a semantic status-token family (see §4) and have `ActivityCharts` / `OrgChart` consume those. Or: delete `chart-1..5` if there's no chart design language yet and note the gap.
### 2. `destructive-foreground` has a wrong light-mode value AND is unused
**Status: RESOLVED (2026-04-21).** Light-mode value corrected to `oklch(0.985 0 0)` to match the dark-mode pattern. `ui/src/index.css:63`. Zero-risk change (0 production consumers confirmed). Original finding retained below for history.
**Original finding:** The light-mode value of `--destructive-foreground` was `oklch(0.577 0.245 27.325)` — the same red as `--destructive` itself. That means light-mode destructive text rendered over a destructive background would be invisible. The dark-mode value `oklch(0.985 0 0)` (white) is correct.
```
ui/src/index.css:62 --destructive: oklch(0.577 0.245 27.325);
ui/src/index.css:63 --destructive-foreground: oklch(0.577 0.245 27.325); ← likely bug
ui/src/index.css:97 --destructive: oklch(0.637 0.237 25.331);
ui/src/index.css:98 --destructive-foreground: oklch(0.985 0 0); ← correct
```
**Why no one has noticed:** `--destructive-foreground` has **0 code usages**. Components using destructive color use `bg-destructive` with `text-destructive` or implicit foreground, never `text-destructive-foreground` on a destructive-filled surface. So the bug is masked by non-adoption.
**Suggested direction:** Either delete the token, or fix its light value (likely `oklch(0.985 0 0)`) and actually use it on destructive buttons / filled badges.
### 3. `sidebar-*` tokens — reserved
**Status: RECLASSIFIED (2026-04-21).** Tokens preserved as **reserved** for shadcn sidebar primitive compatibility in case that primitive is reintroduced. Not considered drift; do not consume today. Comment added above the `@theme` block in `ui/src/index.css`. Original finding retained below for history.
**Original finding:** All 8 sidebar tokens (`sidebar`, `sidebar-foreground`, `sidebar-primary`, `sidebar-primary-foreground`, `sidebar-accent`, `sidebar-accent-foreground`, `sidebar-border`, `sidebar-ring`) have **0 code usages**. Only references are the `@theme inline` alias block and the `DesignGuide.tsx` swatch showcase.
```
rg -P "bg-sidebar|text-sidebar|border-sidebar|--sidebar" ui/src
→ only hits in ui/pages/DesignGuide.tsx and ui/src/index.css
```
**Why:** The shadcn `Sidebar` primitive was not installed (no `ui/src/components/ui/sidebar.tsx`). The app has a custom `ui/src/components/Sidebar.tsx` that consumes the general semantic tokens (`background`, `accent`, `border`, etc.) directly. The `sidebar-*` family came from the shadcn default `components.json` theme generation and was never adopted.
**Suggested direction:** Either (a) delete the `sidebar-*` family from `index.css`, or (b) refactor `Sidebar.tsx` to consume them so the sidebar can be themed independently of the main surface. Status quo is dead code.
Note: in light mode, every `sidebar-*` value equals a semantic-surface value except the first — `sidebar` = `oklch(0.985 0 0)` vs `background` = `oklch(1 0 0)`. So consolidation would be nearly free if option (a) is chosen.
### 4. `status-colors.ts` is a canonical semantic-color catalog that bypasses the DS
**Status: PARTIALLY ADDRESSED (2026-04-21).** Two new action-severity tokens added — `--signal-success` and `--signal-success-foreground` — paired with the existing `--destructive` / `--destructive-foreground` as the DS's solid-accent severity vocabulary. `status-colors.ts` itself is **not touched** in this pass; tokenizing its entity-state coloring as a `--status-*` family is a deferred future project. `--signal-warning` and `--signal-info` are intentionally not added (see §Deferred signal variants below). Original finding retained below for history.
**Separation of concerns now documented.** `destructive` / `signal-success` = **action severity** (solid buttons, toasts). `status-colors.ts` = **entity state** (issue status, agent status, priority) — stays as a TypeScript catalog. `--chart-*` and `--sidebar-*` remain reserved (see §1, §3).
**Original finding:**
**Finding:** `ui/src/lib/status-colors.ts` defines the color language for all entity statuses and priorities (issues, agents, goals, runs, approvals) and then renders them with **raw Tailwind palette classes**. Every one of ~24 status/priority entries looks like:
```ts
// ui/src/lib/status-colors.ts (excerpt)
issueStatusIcon.todo = "text-blue-600 border-blue-600 dark:text-blue-400 dark:border-blue-400"
statusBadge.running = "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300"
statusBadge.pending_approval = "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300"
priorityColor.critical = "text-red-600 dark:text-red-400"
agentStatusDot.running = "bg-cyan-400 animate-pulse"
```
The file header says _"Every component that renders a status indicator … should import from here so colors stay consistent"_ — i.e. status color IS treated as part of the design system, but it lives in TypeScript, not in CSS tokens, and uses raw Tailwind scales rather than semantic tokens.
**Hues in active status use:** blue, cyan, sky, green, emerald, amber, orange, yellow, violet, red, neutral. That's 11 hues — more than the shadcn default palette anticipates.
**Why it matters:** This is the single largest DS gap found in Stage 1. A token family like `--signal-success`, `--signal-warning`, `--signal-error`, `--signal-info`, `--signal-in-progress`, `--signal-review`, `--signal-neutral` (each with `-bg`, `-text`, `-border` variants) would unify the status color language, make dark-mode pairing automatic, and close the drift feeding into `ActivityCharts.tsx` (§1).
**Suggested direction:** Design a signal/status token family and migrate `status-colors.ts` onto it. Do this **before** Stage 3 extracts `StatusBadge`, `StatusIcon`, `PriorityIcon`, `AgentStatusDot` — otherwise those component docs will bake in the raw-palette drift.
### 5. Theme-color meta tag uses hardcoded hex
```
ui/src/context/ThemeContext.tsx:20 const DARK_THEME_COLOR = "#18181b";
ui/src/context/ThemeContext.tsx:21 const LIGHT_THEME_COLOR = "#ffffff";
```
These drive the browser's `<meta name="theme-color">` tag so the mobile browser chrome matches the app. They're sort-of the inverse of `--background`:
- Light: `--background = oklch(1 0 0) = #ffffff` — the hardcoded value matches.
- Dark: `--background = oklch(0.145 0 0) ≈ #252525`, but the hardcoded is `#18181b` (zinc-900). **Mismatch.**
**Suggested direction:** Compute from `--background` at runtime, or keep the hardcoded values but make them match `--background` exactly. Currently the mobile chrome is a different dark than the app's background.
---
## Medium-confidence drift
### 6. Raw Tailwind palette usage: 659 occurrences across 83 files
The codebase bypasses semantic tokens for a large number of styling decisions:
```
rg -P "\b(bg|text|border|...)-(neutral|gray|zinc|slate|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(50|100|…|950)\b" ui/src
→ 659 occurrences across 83 files
```
**Heavy offenders (ranked):**
| File | Hits | Notes |
|---|---|---|
| `ui/src/pages/AgentDetail.tsx` | 75 | Production page — worth migrating. |
| `ui/src/pages/InviteLanding.tsx` | 58 | Auth surface with zinc-based dark palette; deliberately different visual language than the app. |
| `ui/src/pages/InviteUxLab.tsx` | 55 | UX lab prototype. Acceptable scratch. |
| `ui/src/lib/status-colors.ts` | 47 | See §4. |
| `ui/src/components/transcript/RunTranscriptView.tsx` | 47 | Feature uses palette for agent action differentiation — tokenize. |
| `ui/src/components/IssueChatThread.tsx` | 22 | Production component. |
| `ui/src/pages/Inbox.tsx` | 16 | |
| `ui/src/pages/DesignGuide.tsx` | 13 | Swatch page — presentational, acceptable. |
| `ui/src/pages/IssueChatUxLab.tsx` | 6 | UX lab. |
| `ui/src/pages/RunTranscriptUxLab.tsx` | 10 | UX lab. |
**Buckets:**
- **Clearly intentional scratch / auth surface:** UX Labs (`IssueChatUxLab`, `InviteUxLab`, `RunTranscriptUxLab`), `InviteLanding` — probably should stay.
- **Status color catalog:** `status-colors.ts` + `ActivityCharts.tsx` — covered by §4, §1.
- **Production surfaces that silently diverge:** `AgentDetail.tsx`, `RunTranscriptView.tsx`, `IssueChatThread.tsx`, `Inbox.tsx` — highest ROI to migrate.
### 7. Arbitrary radius values bypass the scale (18 occurrences)
```
ui/src/pages/InviteUxLab.tsx rounded-[28px], rounded-[24px], rounded-[32px]
ui/src/pages/IssueChatUxLab.tsx rounded-[28px], rounded-[32px]
ui/src/pages/RunTranscriptUxLab.tsx rounded-xl (via theme), rounded-2xl (via TW default)
ui/src/pages/ProfileSettings.tsx rounded-[28px], rounded-[24px]
ui/src/pages/CompanySettings.tsx rounded-[14px]
ui/src/components/CompanyRail.tsx rounded-[14px], rounded-[22px]
```
These are almost all in pages that already opt out of the default radius scale. See §Radius scale below for the likely reason.
### 8. Project/label color fallbacks fragmented across 10+ files
Two default fallback hexes for user-picked project/label colors:
- `#6366f1` (indigo-500) — in `IssueProperties.tsx`, `SidebarProjects.tsx`, `NewIssueDialog.tsx`, `ProjectDetail.tsx`, `CompanySettings.tsx`
- `#64748b` (slate-500) — in `IssueColumns.tsx`, `RoutineRunVariablesDialog.tsx`, `RoutineDetail.tsx`, `Routines.tsx`, `MarkdownEditor.tsx`
Not strictly drift — these are fallbacks for a user-supplied field, not the DS. But they disagree with each other, and every caller has duplicated the literal. A single `const DEFAULT_PROJECT_COLOR` (or better, a DS token + utility) would fix it.
### 9. Arbitrary shadow values in production surfaces
```
shadow-[0_24px_60px_rgba(15,23,42,0.08)] UxLab pages, ProfileSettings
shadow-[0_20px_80px_-40px_rgba(0,0,0,0.55)] BudgetPolicyCard
shadow-[0_0_12px_rgba(6,182,212,0.08)] AgentDetail (live indicator)
shadow-[0_18px_50px_rgba(6,182,212,0.08)] LiveRunWidget
```
No `--shadow-*` tokens exist, so there's nothing to migrate _to_, but these are the signal that a shadow/elevation token family would pay for itself quickly.
---
## Low-confidence drift (candidates for new tokens)
### 10. Code block theme is a hardcoded Catppuccin Mocha palette
```
ui/src/index.css:553 background: #1e1e2e; ← code bg
ui/src/index.css:554 color: #cdd6f4; ← code fg
ui/src/index.css:567 background-color: #181825; ← gutter bg
ui/src/index.css:568 color: #585b70; ← gutter fg
ui/src/index.css:569 border-right: 1px solid #313244;
ui/src/index.css:614 background-color: #313244; ← language selector bg
ui/src/index.css:616 border-color: #45475a;
ui/src/index.css:586 color-mix(…, #89b4fa 25%, transparent) ← selection
```
This is a deliberate choice (Catppuccin Mocha is a widely-loved dev theme and it's consistent across MDXEditor + rendered markdown + CodeMirror). But the choice is buried in selectors and spread across 30+ lines of `index.css`. Candidate `--code-bg`, `--code-fg`, `--code-gutter-bg`, `--code-gutter-fg`, `--code-border`, `--code-selection` tokens would:
- Make the choice legible.
- Make "port to Catppuccin Latte for light mode" a one-line change.
- Let MDXEditor and the `.paperclip-markdown` renderer reference the same tokens rather than copy the values.
### 11. GitHub link colors hardcoded
```
ui/src/index.css:429 color: color-mix(in oklab, var(--foreground) 76%, #0969da 24%);
ui/src/index.css:436 color: color-mix(in oklab, var(--foreground) 80%, #58a6ff 20%); (.dark)
ui/src/index.css:756 (duplicated for .paperclip-markdown)
ui/src/index.css:772 (duplicated for .paperclip-markdown dark)
```
Candidate `--link` / `--link-foreground` tokens.
### 12. MDXEditor editor text colors hardcoded
The CodeMirror-inside-MDXEditor rules (`index.css:560618`) hardcode `#cdd6f4`, `#89b4fa`, `#313244` etc. for cursor, selection, gutter. Covered by §10 if `--code-*` tokens are added.
---
## Reserved & near-dead tokens
Summary of tokens whose production usage is at or near zero. **As of 2026-04-21, the 13 previously-flagged "dead" tokens are reclassified as Reserved.** They remain defined in `ui/src/index.css` with explanatory comments; they are not a consolidation opportunity.
| Token | Code usage | Disposition |
|---|---|---|
| `chart-1..5` | 0 each | **Reserved** for future chart tokenization (see §1) |
| `sidebar`, `sidebar-foreground`, `sidebar-primary`, `sidebar-primary-foreground`, `sidebar-accent`, `sidebar-accent-foreground`, `sidebar-border`, `sidebar-ring` | 0 each | **Reserved** for shadcn sidebar compatibility (see §3) |
| `destructive-foreground` | 0 | Unused but light value corrected on 2026-04-21 (see §2). Paired with `--destructive` and with the new `--signal-success-foreground`; kept as part of the action-severity vocabulary. |
| `card-foreground` | 1 | Near-dead. Not necessarily wrong — `card-foreground` equals `foreground` in both modes, so `text-foreground` inside a card is sufficient. |
| `secondary-foreground` | 2 | Near-dead |
| `secondary` | 3 | Near-dead |
| `popover-foreground` | 5 | Low use — likely limited to `popover.tsx` primitive |
No longer flagged as a consolidation opportunity. The 34-token color set is the intended surface.
---
## Duplicate values
These groups of tokens share identical values. Most are classic shadcn-default structural overlaps (intentional, so `*-foreground` patterns match across surfaces). A few are suspicious.
**Light mode:**
| Value | Tokens |
|---|---|
| `oklch(1 0 0)` | `background`, `card`, `popover` |
| `oklch(0.145 0 0)` | `foreground`, `card-foreground`, `popover-foreground`, `sidebar-foreground` |
| `oklch(0.205 0 0)` | `primary`, `secondary-foreground`, `accent-foreground`, `sidebar-primary` |
| `oklch(0.985 0 0)` | `primary-foreground`, `sidebar`, `sidebar-primary-foreground`, `sidebar-accent-foreground` |
| `oklch(0.97 0 0)` | `secondary`, `muted`, `accent`, `sidebar-accent` |
| `oklch(0.922 0 0)` | `border`, `input`, `sidebar-border` |
| `oklch(0.708 0 0)` | `ring`, `sidebar-ring` |
| `oklch(0.577 0.245 27.325)` | `destructive`, `destructive-foreground` ⚠ (see §2) |
**Dark mode** (analogous structure — all sidebar tokens match their non-sidebar counterparts except `sidebar-primary = oklch(0.488 0.243 264.376)`, a distinct blue).
**Takeaway:** In light mode, every `sidebar-*` value equals its non-sidebar counterpart; in dark mode, `sidebar-primary` drifts (blue) from `primary` (white). Per the 2026-04-21 decision, the sidebar family is **preserved as reserved** (see §3) rather than collapsed — the identity with non-sidebar tokens is fine; the value of reserving is keeping the option of theming the sidebar independently if that need emerges.
---
## Non-semantic color usage
(Covered by §6 above and enumerated there with offender files and hit counts.)
---
## Radius scale
**Status: RESOLVED (2026-04-21).** Scale restored to monotonic `sm=6px, md=8px, lg=10px, xl=12px`. 226 call sites in `ui/src/` migrated from `rounded-lg` / `rounded-xl``rounded-none` to preserve the existing flat-Swiss aesthetic on dashboard surfaces. Shadcn primitives (`ui/src/components/ui/**`) excluded from migration — `dialog.tsx` retains `rounded-lg` so `DialogContent` now renders with real 10px-rounded corners (the first observable visual change from the radius work, intentional). One test assertion updated in lockstep: `ProjectWorkspaceSummaryCard.test.tsx:135` (`rounded-lg``rounded-none`).
Original Stage 1 observations retained below for history.
### What's defined
```
ui/src/index.css:39 --radius-sm: 0.375rem; (6px) @theme inline
ui/src/index.css:40 --radius-md: 0.5rem; (8px) @theme inline
ui/src/index.css:41 --radius-lg: 0px; @theme inline
ui/src/index.css:42 --radius-xl: 0px; @theme inline
ui/src/index.css:47 --radius: 0; :root (not @theme)
```
### What's non-standard
1. **Non-monotonic progression.** Almost every design-system radius scale is `sm ≤ md ≤ lg ≤ xl`. Here it's `sm=6, md=8, lg=0, xl=0`. Two possibilities:
- Intentional flattening at the outer scale (keep small things rounded for friendliness, keep large surfaces square for an editorial look).
- Mid-migration artifact from flipping the scale to 0 from the shadcn defaults (`lg=0.5rem`, `xl=0.75rem`) and not yet reconciling.
2. **Two coexisting radius bases.** `--radius` (value 0) lives at `:root` and is referenced by `index.css`'s own `calc(var(--radius) - 2px)` expressions and by the MDXEditor bridge (`--baseRadius: var(--radius)`). But the Tailwind-visible scale is the `@theme`-scoped `--radius-sm/md/lg/xl`. These never cross-reference. An editor reading the file might reasonably expect `--radius-md` to be derived from `--radius`, but they're independent.
3. **Heavy use of utilities that resolve to 0.**
| Utility | Value | Uses |
|---|---|---|
| `rounded-sm` | 0.375rem | 49 |
| `rounded-md` | 0.5rem | 310 |
| `rounded-lg` | **0px** | **146** |
| `rounded-xl` | **0px** | **81** |
| `rounded-2xl` | (Tailwind default, not themed) | 21 |
| `rounded-full` | full | 208 |
| `rounded-none` | 0 | 36 |
| `rounded` (no suffix) | (Tailwind default) | 127 |
So **227 places** in the codebase write `rounded-lg` or `rounded-xl` and get square corners. Without context on whether that's the intended look, this is ambiguous — if intentional, all those sites are fine; if unintentional, every one of them is a small visual bug.
4. **Arbitrary radius values (see §7).** 18 occurrences of `rounded-[14px]`, `rounded-[22px]`, `rounded-[24px]`, `rounded-[28px]`, `rounded-[32px]` — concentrated in pages that want actually-rounded corners. If `rounded-lg` resolved to ~12px, some of these would go away.
### Open question for the founder
_Is the radius scale intentionally flattening at the top (lg/xl = 0), or is that a stale state from earlier shadcn defaults that should be replaced with a monotonic scale?_
**Resolved 2026-04-21.** Hybrid: dashboard surfaces stay flat (`rounded-none`), the scale is restored monotonically for newer surfaces and for the `dialog.tsx` primitive. 226 host-code call sites migrated.
---
### Radius workaround audit: 18 occurrences, all retained
Per the decision to audit each `rounded-[Npx]` workaround: none match the new scale (6 / 8 / 10 / 12 px). All are intentional. Table below captures the disposition.
| File | Line | Value | Context | Disposition |
|---|---|---|---|---|
| `ui/src/pages/IssueChatUxLab.tsx` | 52 | `rounded-[28px]` | Chat-surface outer container (prototype) | **Keep** — editorial pill > xl scale; UX Lab prototype |
| `ui/src/pages/IssueChatUxLab.tsx` | 139 | `rounded-[32px]` | Hero container with layered gradients (prototype) | **Keep** — ditto |
| `ui/src/pages/InviteUxLab.tsx` | 112 | `rounded-[28px]` | Invite-panel outer (prototype) | **Keep** — UX Lab |
| `ui/src/pages/InviteUxLab.tsx` | 149 | `rounded-[24px]` | Tone card (prototype) | **Keep** — UX Lab |
| `ui/src/pages/InviteUxLab.tsx` | 171 | `rounded-[28px]` | Dark invite capsule (prototype) | **Keep** — UX Lab |
| `ui/src/pages/InviteUxLab.tsx` | 454 | `rounded-[28px]` | Alt invite panel (prototype) | **Keep** — UX Lab |
| `ui/src/pages/InviteUxLab.tsx` | 529 | `rounded-[28px]` | Tone card variant (prototype) | **Keep** — UX Lab |
| `ui/src/pages/InviteUxLab.tsx` | 612 | `rounded-[28px]` | Tone card variant (prototype) | **Keep** — UX Lab |
| `ui/src/pages/InviteUxLab.tsx` | 700 | `rounded-[32px]` | Hero gradient container (prototype) | **Keep** — UX Lab |
| `ui/src/pages/ProfileSettings.tsx` | 159 | `rounded-[28px]` | Profile header hero card | **Keep** — intentional oversize hero radius |
| `ui/src/pages/ProfileSettings.tsx` | 163 | `rounded-[24px]` | Profile identity panel | **Keep** — matches hero language |
| `ui/src/pages/CompanySettings.tsx` | 296 | `rounded-[14px]` | Brand-color preview swatch | **Keep** — matches CompanyRail icon scale |
| `ui/src/components/CompanyRail.tsx` | 99 | `rounded-[14px]` | Company icon (selected state) | **Keep** — iOS-icon-scale, morphing-radius pattern |
| `ui/src/components/CompanyRail.tsx` | 100 | `rounded-[22px]` | Company icon (unselected state) | **Keep** — part of the hover-morph pair |
| `ui/src/components/CompanyRail.tsx` | 247 | `rounded-[22px]``[14px]` on hover | "New company" button | **Keep** — same morph pattern |
| `ui/src/components/ui/checkbox.tsx` | 17 | `rounded-[4px]` | Checkbox control | **Keep** — shadcn primitive; excluded from migration |
| `ui/src/components/ui/tooltip.tsx` | 51 | `rounded-[2px]` | Tooltip arrow | **Keep** — shadcn primitive; excluded from migration |
| `ui/src/components/ui/scroll-area.tsx` | 19 | `rounded-[inherit]` | Scroll viewport | **Keep** — non-numeric special value; can't match the scale by construction |
Summary:
- **9 in UX Lab pages** (`InviteUxLab`, `IssueChatUxLab`) — prototypes, not pursued.
- **2 in ProfileSettings** — intentional editorial hero radii (24px / 28px).
- **1 in CompanySettings** — brand-swatch match to the rail.
- **3 in CompanyRail** — morphing-radius iOS-icon pattern (22px ↔ 14px on hover).
- **3 in shadcn primitives** — out of scope per the migration rule.
No rewrites. No consolidations. All values sit outside the new 6-8-10-12 scale either because they're editorial (20-30px range), because they're part of a morphing-radius interaction (14/22), or because they're shadcn-internal micro-radii (2/4 px, or `inherit`).
---
## Deferred signal variants
Captured here so they don't get forgotten. **Intentionally not added** in the 2026-04-21 pass:
- **`--signal-success-soft` / `--signal-success-subtle`** — a lower-weight variant for use on toast surfaces and inline success banners. Current soft-success rendering uses `bg-emerald-50 text-emerald-900` (light) and `bg-emerald-950/60 text-emerald-100` (dark) in [`ToastViewport.tsx`](../../../ui/src/components/ToastViewport.tsx) — a different hue family (emerald) than the new solid `--signal-success` (green-700/600). A soft variant would let the toast adopt the DS token without shifting visual weight. Add when a concrete refactor needs it.
- **`--signal-warning` / `--signal-info`** — not defined. No current consumer that couldn't go on using its local palette. Add when a real use case appears (e.g., a standardized warning toast variant).
- **Green vs emerald reconciliation.** Approve buttons use `green-700/600`; soft success surfaces use `emerald-50/900`. `--signal-success` sourced from the button family. If a soft-variant lands, the emerald surfaces become the migration candidate — visible as a small hue shift on the toast success background. Document the shift when migrating.
## Integration layer (not drift)
### MDXEditor CSS-variable bridge
**Lines:** `ui/src/index.css:332361`. **24 variables**, scoped to `.paperclip-mdxeditor-scope, .paperclip-mdxeditor`.
These are not tokens. They are an explicit bridge between Paperclip's DS tokens and MDXEditor's internal token vocabulary. Every one of the 24 values is a `var(--host-token)` reference or a `color-mix()` over host tokens — no hardcoded color values. Documenting the mapping for traceability:
| MDXEditor var | Maps to |
|---|---|
| `--baseBase` | `var(--background)` |
| `--baseBg` | `transparent` |
| `--baseBgSubtle` | `color-mix(in oklab, var(--accent) 35%, transparent)` |
| `--baseLine` | `var(--border)` |
| `--baseSolid` | `var(--muted-foreground)` |
| `--baseSolidHover` | `var(--foreground)` |
| `--baseText` | `var(--muted-foreground)` |
| `--baseBorderColor` | `var(--border)` |
| `--baseBorder` | `var(--border)` |
| `--baseBorderHover` | `var(--ring)` |
| `--baseTextContrast` | `var(--foreground)` |
| `--baseTextContrastMuted` | `var(--muted-foreground)` |
| `--baseTextEmphasis` | `var(--foreground)` |
| `--basePageBg` | `var(--background)` |
| `--baseRadius` | `var(--radius)` (the `:root`-scoped 0-value, not a `@theme` radius) |
| `--baseLineHeight` | `1.5` (hardcoded numeric — the only non-bridge value in the block) |
| `--accentBorder` | `color-mix(in oklab, var(--primary) 35%, var(--border))` |
| `--accentSolid` | `var(--primary)` |
| `--accentSolidHover` | `var(--primary)` |
| `--accentLine` | `color-mix(in oklab, var(--primary) 20%, transparent)` |
| `--accentBg` | `var(--accent)` |
| `--accentBgHover` | `color-mix(in oklab, var(--accent) 80%, var(--background))` |
| `--accentBgActive` | `color-mix(in oklab, var(--accent) 72%, var(--background))` |
| `--accentText` | `var(--accent-foreground)` |
The bridge is sound. If DS tokens move, the editor moves with them. Nothing to do.
### Scrollbar oklch values
`index.css:172219`. Hand-picked greys for light/dark scrollbar tracks and thumbs. Not tokens, but candidates for tokenization if scrollbar theming becomes a DS concern.

View File

@@ -1,765 +0,0 @@
{
"generated_at": "2026-04-21T00:00:00Z",
"repo_sha": "a26e1288b627e82c554445732c7d844648e6b5e1",
"scope": "ui/",
"authoritative_source": "ui/src/index.css",
"tailwind_version": "v4",
"usage_count_method": "Sum of (a) Tailwind utility occurrences \\b(bg|text|border|ring|fill|stroke|from|to|via|outline|decoration|placeholder|caret|accent|shadow|divide)-<token>(?![\\w-]) and (b) literal var(--<token>) references, both EXCLUDING the definition file ui/src/index.css. Story coverage is deferred to Stage 2 (covered_by_story=null until then).",
"tokens": [
{
"name": "background",
"category": "color",
"value": "oklch(1 0 0)",
"dark_value": "oklch(0.145 0 0)",
"defined_at": "ui/src/index.css:57 (light) / ui/src/index.css:96 (dark)",
"tailwind_alias_at": "ui/src/index.css:7",
"usage_count": 183,
"used_in_components": 36,
"used_in_pages": 20,
"covered_by_story": null,
"aliases": [
"--background",
"--color-background",
"bg-background",
"text-background",
"border-background"
]
},
{
"name": "foreground",
"category": "color",
"value": "oklch(0.145 0 0)",
"dark_value": "oklch(0.985 0 0)",
"defined_at": "ui/src/index.css:58 (light) / ui/src/index.css:97 (dark)",
"tailwind_alias_at": "ui/src/index.css:8",
"usage_count": 372,
"used_in_components": 57,
"used_in_pages": 31,
"covered_by_story": null,
"aliases": [
"--foreground",
"--color-foreground"
]
},
{
"name": "card",
"category": "color",
"value": "oklch(1 0 0)",
"dark_value": "oklch(0.205 0 0)",
"defined_at": "ui/src/index.css:59 (light) / ui/src/index.css:98 (dark)",
"tailwind_alias_at": "ui/src/index.css:9",
"usage_count": 52,
"used_in_components": 9,
"used_in_pages": 17,
"covered_by_story": null,
"aliases": [
"--card",
"--color-card"
]
},
{
"name": "card-foreground",
"category": "color",
"value": "oklch(0.145 0 0)",
"dark_value": "oklch(0.985 0 0)",
"defined_at": "ui/src/index.css:60 (light) / ui/src/index.css:99 (dark)",
"tailwind_alias_at": "ui/src/index.css:10",
"usage_count": 1,
"used_in_components": 1,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--card-foreground",
"--color-card-foreground"
]
},
{
"name": "popover",
"category": "color",
"value": "oklch(1 0 0)",
"dark_value": "oklch(0.205 0 0)",
"defined_at": "ui/src/index.css:61 (light) / ui/src/index.css:100 (dark)",
"tailwind_alias_at": "ui/src/index.css:11",
"usage_count": 11,
"used_in_components": 7,
"used_in_pages": 2,
"covered_by_story": null,
"aliases": [
"--popover",
"--color-popover"
]
},
{
"name": "popover-foreground",
"category": "color",
"value": "oklch(0.145 0 0)",
"dark_value": "oklch(0.985 0 0)",
"defined_at": "ui/src/index.css:62 (light) / ui/src/index.css:101 (dark)",
"tailwind_alias_at": "ui/src/index.css:12",
"usage_count": 5,
"used_in_components": 4,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--popover-foreground",
"--color-popover-foreground"
]
},
{
"name": "primary",
"category": "color",
"value": "oklch(0.205 0 0)",
"dark_value": "oklch(0.985 0 0)",
"defined_at": "ui/src/index.css:63 (light) / ui/src/index.css:102 (dark)",
"tailwind_alias_at": "ui/src/index.css:13",
"usage_count": 35,
"used_in_components": 19,
"used_in_pages": 5,
"covered_by_story": null,
"aliases": [
"--primary",
"--color-primary"
]
},
{
"name": "primary-foreground",
"category": "color",
"value": "oklch(0.985 0 0)",
"dark_value": "oklch(0.205 0 0)",
"defined_at": "ui/src/index.css:64 (light) / ui/src/index.css:103 (dark)",
"tailwind_alias_at": "ui/src/index.css:14",
"usage_count": 10,
"used_in_components": 8,
"used_in_pages": 2,
"covered_by_story": null,
"aliases": [
"--primary-foreground",
"--color-primary-foreground"
]
},
{
"name": "secondary",
"category": "color",
"value": "oklch(0.97 0 0)",
"dark_value": "oklch(0.269 0 0)",
"defined_at": "ui/src/index.css:65 (light) / ui/src/index.css:104 (dark)",
"tailwind_alias_at": "ui/src/index.css:15",
"usage_count": 3,
"used_in_components": 3,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--secondary",
"--color-secondary"
]
},
{
"name": "secondary-foreground",
"category": "color",
"value": "oklch(0.205 0 0)",
"dark_value": "oklch(0.985 0 0)",
"defined_at": "ui/src/index.css:66 (light) / ui/src/index.css:105 (dark)",
"tailwind_alias_at": "ui/src/index.css:16",
"usage_count": 2,
"used_in_components": 2,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--secondary-foreground",
"--color-secondary-foreground"
]
},
{
"name": "muted",
"category": "color",
"value": "oklch(0.97 0 0)",
"dark_value": "oklch(0.269 0 0)",
"defined_at": "ui/src/index.css:67 (light) / ui/src/index.css:106 (dark)",
"tailwind_alias_at": "ui/src/index.css:17",
"usage_count": 90,
"used_in_components": 31,
"used_in_pages": 15,
"covered_by_story": null,
"aliases": [
"--muted",
"--color-muted"
]
},
{
"name": "muted-foreground",
"category": "color",
"value": "oklch(0.556 0 0)",
"dark_value": "oklch(0.708 0 0)",
"defined_at": "ui/src/index.css:68 (light) / ui/src/index.css:107 (dark)",
"tailwind_alias_at": "ui/src/index.css:18",
"usage_count": 1540,
"used_in_components": 98,
"used_in_pages": 48,
"covered_by_story": null,
"aliases": [
"--muted-foreground",
"--color-muted-foreground"
]
},
{
"name": "accent",
"category": "color",
"value": "oklch(0.97 0 0)",
"dark_value": "oklch(0.269 0 0)",
"defined_at": "ui/src/index.css:69 (light) / ui/src/index.css:108 (dark)",
"tailwind_alias_at": "ui/src/index.css:19",
"usage_count": 340,
"used_in_components": 57,
"used_in_pages": 21,
"covered_by_story": null,
"aliases": [
"--accent",
"--color-accent"
]
},
{
"name": "accent-foreground",
"category": "color",
"value": "oklch(0.205 0 0)",
"dark_value": "oklch(0.985 0 0)",
"defined_at": "ui/src/index.css:70 (light) / ui/src/index.css:109 (dark)",
"tailwind_alias_at": "ui/src/index.css:20",
"usage_count": 14,
"used_in_components": 5,
"used_in_pages": 1,
"covered_by_story": null,
"aliases": [
"--accent-foreground",
"--color-accent-foreground"
]
},
{
"name": "destructive",
"category": "color",
"value": "oklch(0.577 0.245 27.325)",
"dark_value": "oklch(0.637 0.237 25.331)",
"defined_at": "ui/src/index.css:71 (light) / ui/src/index.css:110 (dark)",
"tailwind_alias_at": "ui/src/index.css:21",
"usage_count": 160,
"used_in_components": 28,
"used_in_pages": 43,
"covered_by_story": null,
"aliases": [
"--destructive",
"--color-destructive"
]
},
{
"name": "destructive-foreground",
"category": "color",
"value": "oklch(0.985 0 0)",
"dark_value": "oklch(0.985 0 0)",
"defined_at": "ui/src/index.css:72 (light) / ui/src/index.css:111 (dark)",
"tailwind_alias_at": "ui/src/index.css:22",
"usage_count": 0,
"used_in_components": 0,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--destructive-foreground",
"--color-destructive-foreground"
],
"review_flag": null,
"history": "Light-mode value corrected from oklch(0.577 0.245 27.325) (equalled --destructive) to oklch(0.985 0 0) on 2026-04-21."
},
{
"name": "signal-success",
"category": "color",
"value": "oklch(0.527 0.154 150.069)",
"dark_value": "oklch(0.627 0.194 149.214)",
"defined_at": "ui/src/index.css:73 (light) / ui/src/index.css:112 (dark)",
"tailwind_alias_at": "ui/src/index.css:23",
"usage_count": 0,
"used_in_components": 0,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--signal-success",
"--color-signal-success"
],
"review_flag": null,
"notes": "Added 2026-04-21. Action-severity token paired with --destructive. Sourced from the approve-button treatment (bg-green-700 / bg-green-600) in ApprovalCard, ApprovalDetail, Inbox. No call sites migrated yet."
},
{
"name": "signal-success-foreground",
"category": "color",
"value": "oklch(0.985 0 0)",
"dark_value": "oklch(0.985 0 0)",
"defined_at": "ui/src/index.css:74 (light) / ui/src/index.css:113 (dark)",
"tailwind_alias_at": "ui/src/index.css:24",
"usage_count": 0,
"used_in_components": 0,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--signal-success-foreground",
"--color-signal-success-foreground"
],
"review_flag": null,
"notes": "Added 2026-04-21. Text-on-signal-success-surface (white)."
},
{
"name": "border",
"category": "color",
"value": "oklch(0.922 0 0)",
"dark_value": "oklch(0.269 0 0)",
"defined_at": "ui/src/index.css:75 (light) / ui/src/index.css:114 (dark)",
"tailwind_alias_at": "ui/src/index.css:25",
"usage_count": 701,
"used_in_components": 72,
"used_in_pages": 43,
"covered_by_story": null,
"aliases": [
"--border",
"--color-border"
]
},
{
"name": "input",
"category": "color",
"value": "oklch(0.922 0 0)",
"dark_value": "oklch(0.269 0 0)",
"defined_at": "ui/src/index.css:76 (light) / ui/src/index.css:115 (dark)",
"tailwind_alias_at": "ui/src/index.css:26",
"usage_count": 8,
"used_in_components": 6,
"used_in_pages": 1,
"covered_by_story": null,
"aliases": [
"--input",
"--color-input"
]
},
{
"name": "ring",
"category": "color",
"value": "oklch(0.708 0 0)",
"dark_value": "oklch(0.439 0 0)",
"defined_at": "ui/src/index.css:77 (light) / ui/src/index.css:116 (dark)",
"tailwind_alias_at": "ui/src/index.css:27",
"usage_count": 26,
"used_in_components": 17,
"used_in_pages": 2,
"covered_by_story": null,
"aliases": [
"--ring",
"--color-ring"
]
},
{
"name": "chart-1",
"category": "color",
"value": "oklch(0.646 0.222 41.116)",
"dark_value": "oklch(0.488 0.243 264.376)",
"defined_at": "ui/src/index.css:79 (light) / ui/src/index.css:118 (dark)",
"tailwind_alias_at": "ui/src/index.css:32",
"usage_count": 0,
"used_in_components": 0,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--chart-1",
"--color-chart-1"
],
"review_flag": "RESERVED \u2014 reserved for future chart tokenization"
},
{
"name": "chart-2",
"category": "color",
"value": "oklch(0.6 0.118 184.704)",
"dark_value": "oklch(0.696 0.17 162.48)",
"defined_at": "ui/src/index.css:80 (light) / ui/src/index.css:119 (dark)",
"tailwind_alias_at": "ui/src/index.css:33",
"usage_count": 0,
"used_in_components": 0,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--chart-2",
"--color-chart-2"
],
"review_flag": "RESERVED \u2014 reserved for future chart tokenization"
},
{
"name": "chart-3",
"category": "color",
"value": "oklch(0.398 0.07 227.392)",
"dark_value": "oklch(0.769 0.188 70.08)",
"defined_at": "ui/src/index.css:81 (light) / ui/src/index.css:120 (dark)",
"tailwind_alias_at": "ui/src/index.css:34",
"usage_count": 0,
"used_in_components": 0,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--chart-3",
"--color-chart-3"
],
"review_flag": "RESERVED \u2014 reserved for future chart tokenization"
},
{
"name": "chart-4",
"category": "color",
"value": "oklch(0.828 0.189 84.429)",
"dark_value": "oklch(0.627 0.265 303.9)",
"defined_at": "ui/src/index.css:82 (light) / ui/src/index.css:121 (dark)",
"tailwind_alias_at": "ui/src/index.css:35",
"usage_count": 0,
"used_in_components": 0,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--chart-4",
"--color-chart-4"
],
"review_flag": "RESERVED \u2014 reserved for future chart tokenization"
},
{
"name": "chart-5",
"category": "color",
"value": "oklch(0.769 0.188 70.08)",
"dark_value": "oklch(0.645 0.246 16.439)",
"defined_at": "ui/src/index.css:83 (light) / ui/src/index.css:122 (dark)",
"tailwind_alias_at": "ui/src/index.css:36",
"usage_count": 0,
"used_in_components": 0,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--chart-5",
"--color-chart-5"
],
"review_flag": "RESERVED \u2014 reserved for future chart tokenization"
},
{
"name": "sidebar",
"category": "color",
"value": "oklch(0.985 0 0)",
"dark_value": "oklch(0.145 0 0)",
"defined_at": "ui/src/index.css:85 (light) / ui/src/index.css:124 (dark)",
"tailwind_alias_at": "ui/src/index.css:40",
"usage_count": 0,
"used_in_components": 0,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--sidebar",
"--color-sidebar"
],
"review_flag": "RESERVED \u2014 reserved for shadcn sidebar primitive compatibility"
},
{
"name": "sidebar-foreground",
"category": "color",
"value": "oklch(0.145 0 0)",
"dark_value": "oklch(0.985 0 0)",
"defined_at": "ui/src/index.css:86 (light) / ui/src/index.css:125 (dark)",
"tailwind_alias_at": "ui/src/index.css:41",
"usage_count": 0,
"used_in_components": 0,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--sidebar-foreground",
"--color-sidebar-foreground"
],
"review_flag": "RESERVED \u2014 reserved for shadcn sidebar primitive compatibility"
},
{
"name": "sidebar-primary",
"category": "color",
"value": "oklch(0.205 0 0)",
"dark_value": "oklch(0.488 0.243 264.376)",
"defined_at": "ui/src/index.css:87 (light) / ui/src/index.css:126 (dark)",
"tailwind_alias_at": "ui/src/index.css:42",
"usage_count": 0,
"used_in_components": 0,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--sidebar-primary",
"--color-sidebar-primary"
],
"review_flag": "RESERVED \u2014 reserved for shadcn sidebar primitive compatibility"
},
{
"name": "sidebar-primary-foreground",
"category": "color",
"value": "oklch(0.985 0 0)",
"dark_value": "oklch(0.985 0 0)",
"defined_at": "ui/src/index.css:88 (light) / ui/src/index.css:127 (dark)",
"tailwind_alias_at": "ui/src/index.css:43",
"usage_count": 0,
"used_in_components": 0,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--sidebar-primary-foreground",
"--color-sidebar-primary-foreground"
],
"review_flag": "RESERVED \u2014 reserved for shadcn sidebar primitive compatibility"
},
{
"name": "sidebar-accent",
"category": "color",
"value": "oklch(0.97 0 0)",
"dark_value": "oklch(0.269 0 0)",
"defined_at": "ui/src/index.css:89 (light) / ui/src/index.css:128 (dark)",
"tailwind_alias_at": "ui/src/index.css:44",
"usage_count": 0,
"used_in_components": 0,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--sidebar-accent",
"--color-sidebar-accent"
],
"review_flag": "RESERVED \u2014 reserved for shadcn sidebar primitive compatibility"
},
{
"name": "sidebar-accent-foreground",
"category": "color",
"value": "oklch(0.205 0 0)",
"dark_value": "oklch(0.985 0 0)",
"defined_at": "ui/src/index.css:90 (light) / ui/src/index.css:129 (dark)",
"tailwind_alias_at": "ui/src/index.css:45",
"usage_count": 0,
"used_in_components": 0,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--sidebar-accent-foreground",
"--color-sidebar-accent-foreground"
],
"review_flag": "RESERVED \u2014 reserved for shadcn sidebar primitive compatibility"
},
{
"name": "sidebar-border",
"category": "color",
"value": "oklch(0.922 0 0)",
"dark_value": "oklch(0.269 0 0)",
"defined_at": "ui/src/index.css:91 (light) / ui/src/index.css:130 (dark)",
"tailwind_alias_at": "ui/src/index.css:46",
"usage_count": 0,
"used_in_components": 0,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--sidebar-border",
"--color-sidebar-border"
],
"review_flag": "RESERVED \u2014 reserved for shadcn sidebar primitive compatibility"
},
{
"name": "sidebar-ring",
"category": "color",
"value": "oklch(0.708 0 0)",
"dark_value": "oklch(0.439 0 0)",
"defined_at": "ui/src/index.css:92 (light) / ui/src/index.css:131 (dark)",
"tailwind_alias_at": "ui/src/index.css:47",
"usage_count": 0,
"used_in_components": 0,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--sidebar-ring",
"--color-sidebar-ring"
],
"review_flag": "RESERVED \u2014 reserved for shadcn sidebar primitive compatibility"
},
{
"name": "radius",
"category": "radius",
"value": "0",
"dark_value": null,
"defined_at": "ui/src/index.css:56",
"tailwind_alias_at": null,
"scope": ":root (not in @theme)",
"usage_count": 127,
"used_in_components": 24,
"used_in_pages": 11,
"covered_by_story": null,
"aliases": [
"--radius"
],
"review_flag": null,
"usage_note": "Tailwind `rounded` utility (no suffix) resolves via Tailwind defaults. This :root token is also consumed by index.css calc() expressions and the MDXEditor bridge --baseRadius."
},
{
"name": "radius-sm",
"category": "radius",
"value": "0.375rem",
"dark_value": null,
"defined_at": "ui/src/index.css:48",
"tailwind_alias_at": "ui/src/index.css:48",
"scope": "@theme inline",
"usage_count": 49,
"used_in_components": 20,
"used_in_pages": 5,
"covered_by_story": null,
"aliases": [
"--radius-sm",
"rounded-sm"
],
"review_flag": null
},
{
"name": "radius-md",
"category": "radius",
"value": "0.5rem",
"dark_value": null,
"defined_at": "ui/src/index.css:49",
"tailwind_alias_at": "ui/src/index.css:49",
"scope": "@theme inline",
"usage_count": 310,
"used_in_components": 43,
"used_in_pages": 31,
"covered_by_story": null,
"aliases": [
"--radius-md",
"rounded-md"
],
"review_flag": null
},
{
"name": "radius-lg",
"category": "radius",
"value": "0.625rem",
"dark_value": null,
"defined_at": "ui/src/index.css:50",
"tailwind_alias_at": "ui/src/index.css:50",
"scope": "@theme inline",
"usage_count": 1,
"used_in_components": 1,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--radius-lg",
"rounded-lg"
],
"review_flag": null,
"history": "Value restored from 0px to 0.625rem (10px) on 2026-04-21 as part of the monotonic scale restore."
},
{
"name": "radius-xl",
"category": "radius",
"value": "0.75rem",
"dark_value": null,
"defined_at": "ui/src/index.css:51",
"tailwind_alias_at": "ui/src/index.css:51",
"scope": "@theme inline",
"usage_count": 0,
"used_in_components": 0,
"used_in_pages": 0,
"covered_by_story": null,
"aliases": [
"--radius-xl",
"rounded-xl"
],
"review_flag": null,
"history": "Value restored from 0px to 0.75rem (12px) on 2026-04-21. No code consumers; opt-in for new surfaces."
}
],
"tokens_by_category_count": {
"color": 34,
"radius": 5,
"spacing": 0,
"type": 0,
"motion": 0,
"elevation": 0
},
"excluded": {
"mdxeditor_bridge": {
"scope_selector": ".paperclip-mdxeditor-scope, .paperclip-mdxeditor",
"line_range": "ui/src/index.css:332-361",
"variable_count": 24,
"reason": "Integration-layer aliases that map host DS tokens to MDXEditor internal token names. Every value is `var(--host-token)` or color-mix() over host tokens. Not authoritative DS tokens. Documented as a bridge in tokens-review.md, not as drift.",
"bridge_mapping_sample": [
{
"mdx_var": "--baseBase",
"maps_to": "var(--background)"
},
{
"mdx_var": "--baseLine",
"maps_to": "var(--border)"
},
{
"mdx_var": "--baseSolid",
"maps_to": "var(--muted-foreground)"
},
{
"mdx_var": "--baseText",
"maps_to": "var(--muted-foreground)"
},
{
"mdx_var": "--baseBorderHover",
"maps_to": "var(--ring)"
},
{
"mdx_var": "--baseTextContrast",
"maps_to": "var(--foreground)"
},
{
"mdx_var": "--baseRadius",
"maps_to": "var(--radius)"
},
{
"mdx_var": "--accentSolid",
"maps_to": "var(--primary)"
},
{
"mdx_var": "--accentBg",
"maps_to": "var(--accent)"
},
{
"mdx_var": "--accentText",
"maps_to": "var(--accent-foreground)"
}
]
},
"scrollbar_oklch": {
"line_range": "ui/src/index.css:172-219",
"reason": "Intentional scrollbar styling using raw oklch values (oklch(0.205 0 0), oklch(0.4 0 0), oklch(0.5 0 0), oklch(0.92 0 0), oklch(0.7 0 0), oklch(0.6 0 0)). Candidate for tokenization but currently out of scope."
},
"shimmer_component_vars": {
"scope_selector": ".shimmer-text",
"line_range": "ui/src/index.css:305-306",
"reason": "Component-local CSS variables (--shimmer-base, --shimmer-highlight)."
},
"code_block_hex_palette": {
"reason": "Hardcoded Catppuccin Mocha hex values for code-block theming (#1e1e2e, #cdd6f4, #181825, #585b70, #313244, #45475a, #89b4fa). Flagged as drift candidate in tokens-review.md; not extracted as tokens yet."
}
},
"keyframes": [
{
"name": "dashboard-activity-enter",
"defined_at": "ui/src/index.css:228",
"used_by": ".activity-row-enter (520ms cubic-bezier(0.16, 1, 0.3, 1))"
},
{
"name": "dashboard-activity-highlight",
"defined_at": "ui/src/index.css:246",
"used_by": ".activity-row-enter (920ms cubic-bezier(0.16, 1, 0.3, 1))"
},
{
"name": "cot-line-slide-in",
"defined_at": "ui/src/index.css:272",
"used_by": ".cot-line-enter (300ms cubic-bezier(0.4, 0, 0.2, 1))"
},
{
"name": "cot-line-slide-out",
"defined_at": "ui/src/index.css:277",
"used_by": ".cot-line-exit (300ms cubic-bezier(0.4, 0, 0.2, 1))"
},
{
"name": "shimmer-text-slide",
"defined_at": "ui/src/index.css:298",
"used_by": ".shimmer-text (2.5s linear infinite)"
}
]
}

View File

@@ -1,157 +0,0 @@
# Paperclip Design System — Tokens
- **Generated:** 2026-04-21
- **Repo SHA:** a26e1288b627e82c554445732c7d844648e6b5e1
- **Scope:** `ui/` (`@paperclipai/ui`)
- **Authoritative source:** [`ui/src/index.css`](../../../ui/src/index.css)
- **Review doc (drift & open questions):** [tokens-review.md](./tokens-review.md)
## Source & conventions
- **Tailwind v4** (CSS-first config). No `tailwind.config.*` file.
- **shadcn/ui** (`new-york` style, `neutral` base, `cssVariables: true`, `iconLibrary: lucide`, non-RSC).
- **Color space:** `oklch()` throughout.
- **Theme scoping:** `:root` for light mode; `.dark` for dark overrides. Dark mode is opt-in via the `dark` class through the custom variant `@custom-variant dark (&:is(.dark *))`.
- **Tailwind alias layer:** `@theme inline { --color-*: var(--<token>); }` exposes every semantic token as a Tailwind utility (`bg-background`, `text-foreground`, …). That's the mechanism — the `--<token>` at `:root` is authoritative.
## Token counts
| Category | Count | Notes |
|------------|-------|-------|
| Color | 34 | 19 semantic surfaces + 2 signal + 5 chart + 8 sidebar |
| Radius | 5 | Scale under review — non-monotonic values. See [tokens-review.md](./tokens-review.md#radius-scale--under-founder-review). |
| Spacing | 0 | Tailwind v4 defaults only. |
| Type | 0 | Tailwind v4 defaults + `@tailwindcss/typography`. Markdown styling is hand-rolled via `.paperclip-markdown` / `.paperclip-mdxeditor-content` classes in `index.css`. |
| Motion | 0 | No `--motion-*` / `--duration-*` variables. 5 named `@keyframes`; easing and duration expressed inline at use sites. |
| Elevation | 0 | No `--shadow-*` tokens. Project uses borders + background shifts for elevation; occasional arbitrary `shadow-[…]` in UxLab / polished surfaces. |
## Color (34)
Each token has a light value (`:root`) and a dark value (`.dark`). Tailwind alias is `bg-<name>`, `text-<name>`, etc.
> **Separation of concerns.** `destructive` / `destructive-foreground` and `signal-success` / `signal-success-foreground` are **action-severity** tokens — used on buttons, toasts, and solid accent fills where the intent is "this click is destructive/successful." They are paired in `{base, -foreground}` form for solid-bg + contrasting-text use. They are **not** status-indicator colors for entity state (issue status, agent status, priority). Entity-state coloring lives in [`ui/src/lib/status-colors.ts`](../../../ui/src/lib/status-colors.ts) as a TypeScript catalog using raw Tailwind palette classes — deliberately separate from DS tokens. Tokenizing `status-colors.ts` as a `--status-*` family is a deferred future project; see [tokens-review.md §4](./tokens-review.md#4-status-colorsts-is-a-canonical-semantic-color-catalog-that-bypasses-the-ds).
### Semantic surfaces (19)
| Token | Light | Dark | Uses |
|---|---|---|---|
| `background` | `oklch(1 0 0)` | `oklch(0.145 0 0)` | 183 |
| `foreground` | `oklch(0.145 0 0)` | `oklch(0.985 0 0)` | 372 |
| `card` | `oklch(1 0 0)` | `oklch(0.205 0 0)` | 52 |
| `card-foreground` | `oklch(0.145 0 0)` | `oklch(0.985 0 0)` | 1 ⚠ |
| `popover` | `oklch(1 0 0)` | `oklch(0.205 0 0)` | 11 |
| `popover-foreground` | `oklch(0.145 0 0)` | `oklch(0.985 0 0)` | 5 |
| `primary` | `oklch(0.205 0 0)` | `oklch(0.985 0 0)` | 35 |
| `primary-foreground` | `oklch(0.985 0 0)` | `oklch(0.205 0 0)` | 10 |
| `secondary` | `oklch(0.97 0 0)` | `oklch(0.269 0 0)` | 3 ⚠ |
| `secondary-foreground` | `oklch(0.205 0 0)` | `oklch(0.985 0 0)` | 2 ⚠ |
| `muted` | `oklch(0.97 0 0)` | `oklch(0.269 0 0)` | 90 |
| `muted-foreground` | `oklch(0.556 0 0)` | `oklch(0.708 0 0)` | 1540 |
| `accent` | `oklch(0.97 0 0)` | `oklch(0.269 0 0)` | 340 |
| `accent-foreground` | `oklch(0.205 0 0)` | `oklch(0.985 0 0)` | 14 |
| `destructive` | `oklch(0.577 0.245 27.325)` | `oklch(0.637 0.237 25.331)` | 160 |
| `destructive-foreground` | `oklch(0.985 0 0)` | `oklch(0.985 0 0)` | 0 |
| `border` | `oklch(0.922 0 0)` | `oklch(0.269 0 0)` | 701 |
| `input` | `oklch(0.922 0 0)` | `oklch(0.269 0 0)` | 8 |
| `ring` | `oklch(0.708 0 0)` | `oklch(0.439 0 0)` | 26 |
⚠ Flagged for review. See [tokens-review.md](./tokens-review.md).
### Signal (2)
Action-severity tokens paired with `destructive`. Intended for solid-accent fills on buttons, toasts, and confirmation surfaces where the action is semantically "success" (approve, confirm, ship).
| Token | Light | Dark | Uses |
|---|---|---|---|
| `signal-success` | `oklch(0.527 0.154 150.069)` | `oklch(0.627 0.194 149.214)` | 0 (new) |
| `signal-success-foreground` | `oklch(0.985 0 0)` | `oklch(0.985 0 0)` | 0 (new) |
Sourced from the canonical approve-action button treatment (`bg-green-700 hover:bg-green-600 text-white` across `ApprovalCard`, `ApprovalDetail`, `Inbox`). Tailwind aliases are `bg-signal-success`, `text-signal-success-foreground`, etc. No call sites migrated yet — tokens land as primitives for opt-in adoption.
`--signal-warning` and `--signal-info` are intentionally **not** defined — defer until a real use case appears. See [tokens-review.md §Deferred variants](./tokens-review.md#deferred-signal-variants).
### Chart (5) — Reserved
> **Status: Reserved.** These five tokens are preserved in `ui/src/index.css` for the future chart-tokenization project. **Do not consume them today.** Current chart implementations (`ActivityCharts.tsx`, `OrgChart.tsx`) use hardcoded Tailwind-palette hex values directly; those call sites will migrate onto the chart tokens (or onto a `--status-*` family) in a separate future project. Keeping the tokens here is a deliberate placeholder. See [tokens-review.md §Chart tokens — reserved](./tokens-review.md#1-chart--tokens-are-dead).
| Token | Light | Dark | Status |
|---|---|---|---|
| `chart-1` | `oklch(0.646 0.222 41.116)` | `oklch(0.488 0.243 264.376)` | Reserved |
| `chart-2` | `oklch(0.6 0.118 184.704)` | `oklch(0.696 0.17 162.48)` | Reserved |
| `chart-3` | `oklch(0.398 0.07 227.392)` | `oklch(0.769 0.188 70.08)` | Reserved |
| `chart-4` | `oklch(0.828 0.189 84.429)` | `oklch(0.627 0.265 303.9)` | Reserved |
| `chart-5` | `oklch(0.769 0.188 70.08)` | `oklch(0.645 0.246 16.439)` | Reserved |
### Sidebar (8) — Reserved
> **Status: Reserved.** Preserved for shadcn `Sidebar` primitive compatibility in case that primitive is reintroduced. The current custom `Sidebar.tsx` consumes the semantic surface tokens (`background`, `foreground`, `accent`, `border`) directly. **Do not consume these tokens today** unless a sidebar variant that needs its own theming is explicitly being built. See [tokens-review.md §Sidebar tokens — reserved](./tokens-review.md#3-sidebar--tokens-are-dead).
| Token | Light | Dark | Status |
|---|---|---|---|
| `sidebar` | `oklch(0.985 0 0)` | `oklch(0.145 0 0)` | Reserved |
| `sidebar-foreground` | `oklch(0.145 0 0)` | `oklch(0.985 0 0)` | Reserved |
| `sidebar-primary` | `oklch(0.205 0 0)` | `oklch(0.488 0.243 264.376)` | Reserved |
| `sidebar-primary-foreground` | `oklch(0.985 0 0)` | `oklch(0.985 0 0)` | Reserved |
| `sidebar-accent` | `oklch(0.97 0 0)` | `oklch(0.269 0 0)` | Reserved |
| `sidebar-accent-foreground` | `oklch(0.205 0 0)` | `oklch(0.985 0 0)` | Reserved |
| `sidebar-border` | `oklch(0.922 0 0)` | `oklch(0.269 0 0)` | Reserved |
| `sidebar-ring` | `oklch(0.708 0 0)` | `oklch(0.439 0 0)` | Reserved |
## Radius (5)
> **Scale resolved 2026-04-21.** Existing dashboard surfaces use `rounded-none` by intent (sharp, Swiss aesthetic) — 226 call sites in `ui/src/` migrated off `rounded-lg` / `rounded-xl`. The `lg` and `xl` values are restored to a monotonic scale for newer surfaces (dialogs, chat bubbles, card explorations) and for the one shadcn primitive that consumes them (`dialog.tsx`, which now renders with 10px-rounded corners).
| Token | Value | Scope | Maps to Tailwind | Uses (post-migration) |
|---|---|---|---|---|
| `--radius` | `0` | `:root` | `rounded` (no suffix, via default) | 127 |
| `--radius-sm` | `0.375rem` (6px) | `@theme inline` | `rounded-sm` | 49 |
| `--radius-md` | `0.5rem` (8px) | `@theme inline` | `rounded-md` | 310 |
| `--radius-lg` | `0.625rem` (10px) | `@theme inline` | `rounded-lg` | 1 (dialog.tsx primitive) |
| `--radius-xl` | `0.75rem` (12px) | `@theme inline` | `rounded-xl` | 0 (opt-in for new surfaces) |
`--radius` (`:root`, = 0) is the legacy shadcn base; it's consumed by the MDXEditor bridge (`--baseRadius`) and by a few `calc(var(--radius) ± Npx)` expressions inside `index.css`. The `@theme` scale is what Tailwind utilities resolve to.
### 18 arbitrary `rounded-[Npx]` exceptions — all intentional, preserved
None of the 18 values below match the new scale (6 / 8 / 10 / 12 px). Kept as-is per design intent. See [tokens-review.md §Radius workaround audit](./tokens-review.md#radius-workaround-audit-18-occurrences-all-retained) for the per-file disposition.
## Spacing (0)
No project-local spacing tokens. Uses the Tailwind v4 default scale (`p-1`, `gap-4`, etc., driven by the Tailwind-shipped `--spacing` base of 0.25rem).
## Type (0)
No project-local font-family, font-size, or line-height tokens. Typography sources:
- Tailwind v4 defaults (the `--text-*` family ships with `tailwindcss`).
- `@tailwindcss/typography` plugin (prose).
- Hand-authored rules on `.paperclip-markdown` and `.paperclip-mdxeditor-content` in `ui/src/index.css` (hardcoded `font-size`, `line-height`, `margin`). These are per-surface overrides — not tokens.
- Code blocks use a hardcoded Catppuccin-Mocha palette in `index.css`. Flagged as drift candidate in [tokens-review.md — §Low-confidence drift](./tokens-review.md#low-confidence-drift-candidates-for-new-tokens).
## Motion (0)
No `--motion-*` or `--duration-*` variables. Motion is expressed as per-feature `@keyframes` with inline duration and `cubic-bezier()`.
**Keyframes defined in `ui/src/index.css`:**
| Name | Used by | Duration / easing |
|---|---|---|
| `dashboard-activity-enter` | `.activity-row-enter` | 520ms `cubic-bezier(0.16, 1, 0.3, 1)` |
| `dashboard-activity-highlight` | `.activity-row-enter` | 920ms `cubic-bezier(0.16, 1, 0.3, 1)` |
| `cot-line-slide-in` | `.cot-line-enter` | 300ms `cubic-bezier(0.4, 0, 0.2, 1)` |
| `cot-line-slide-out` | `.cot-line-exit` | 300ms `cubic-bezier(0.4, 0, 0.2, 1)` |
| `shimmer-text-slide` | `.shimmer-text` | 2.5s linear infinite |
Two easing curves recur (`cubic-bezier(0.16, 1, 0.3, 1)` and `cubic-bezier(0.4, 0, 0.2, 1)`). No duration pattern repeats. All animations honor `prefers-reduced-motion`.
## Elevation (0)
No `--shadow-*` tokens. The project's default visual elevation is border-based. Arbitrary `shadow-[…]` values appear in polished surfaces and UxLab prototypes; see [tokens-review.md — §Medium-confidence drift](./tokens-review.md#medium-confidence-drift).
## Out of scope / excluded from tokens.json
- **MDXEditor theme bridge** — 24 variables at `.paperclip-mdxeditor-scope, .paperclip-mdxeditor` (`index.css:332361`). Every value is `var(--host-token)` or `color-mix(in oklab, var(--host-token) N%, …)` — a deliberate integration-layer alias, not a hardcoded theme. Documented as a bridge in [tokens-review.md — §Integration layer](./tokens-review.md#integration-layer-not-drift).
- **Scrollbar oklch values** — `index.css:172219`. Hand-picked greys for light/dark scrollbar track/thumb. Candidate for tokenization; not done yet.
- **Component-local vars** — `.shimmer-text` (`--shimmer-base`, `--shimmer-highlight`) and `.paperclip-mermaid*` classes.
- **Hardcoded chart hex palette** in `ActivityCharts.tsx` / `OrgChart.tsx` — treated as drift, not as tokens. See [tokens-review.md](./tokens-review.md).
- **Raw Tailwind palette usage** across the codebase — treated as non-semantic drift. See [tokens-review.md — §Non-semantic color usage](./tokens-review.md#non-semantic-color-usage).

View File

@@ -146,8 +146,6 @@ Use it for:
- explicit waiting relationships
- automatic wakeups when all blockers resolve
Blocked issues should stay idle while blockers remain unresolved. Paperclip should not create a queued heartbeat run for that issue until the final blocker is done and the `issue_blockers_resolved` wake can start real work.
If a parent is truly waiting on a child, model that with blockers. Do not rely on the parent/child relationship alone.
## 7. Consistent Execution Path Rules

View File

@@ -10,9 +10,6 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i
- Plugin UI runs as same-origin JavaScript inside the main Paperclip app.
- Worker-side host APIs are capability-gated.
- Plugin UI is not sandboxed by manifest capabilities.
- Plugin database migrations are restricted to a host-derived plugin namespace.
- Plugin-owned JSON API routes must be declared in the manifest and are mounted
only under `/api/plugins/:pluginId/api/*`.
- There is no host-provided shared React component kit for plugins yet.
- `ctx.assets` is not supported in the current runtime.
@@ -80,12 +77,10 @@ Worker:
- secrets
- activity
- state
- database namespace via `ctx.db`
- scoped JSON API routes declared with `apiRoutes`
- entities
- projects and project workspaces
- companies
- issues, comments, namespaced `plugin:<pluginKey>` origins, blocker relations, checkout assertions, assignment wakeups, and orchestration summaries
- issues and comments
- agents and agent sessions
- goals
- data/actions
@@ -94,55 +89,6 @@ Worker:
- metrics
- logger
### Plugin database declarations
First-party or otherwise trusted orchestration plugins can declare:
```ts
database: {
migrationsDir: "migrations",
coreReadTables: ["issues"],
}
```
Required capabilities are `database.namespace.migrate` and
`database.namespace.read`; add `database.namespace.write` for runtime mutations.
The host derives `ctx.db.namespace`, runs SQL files in filename order before the
worker starts, records checksums in `plugin_migrations`, and rejects changed
already-applied migrations.
Migration SQL may create or alter objects only inside `ctx.db.namespace`. It may
reference whitelisted `public` core tables for foreign keys or read-only views,
but may not mutate/alter/drop/truncate public tables, create extensions,
triggers, untrusted languages, or runtime multi-statement SQL. Runtime
`ctx.db.query()` is restricted to `SELECT`; runtime `ctx.db.execute()` is
restricted to namespace-local `INSERT`, `UPDATE`, and `DELETE`.
### Scoped plugin API routes
Plugins can expose JSON-only routes under their own namespace:
```ts
apiRoutes: [
{
routeKey: "initialize",
method: "POST",
path: "/issues/:issueId/smoke",
auth: "board-or-agent",
capability: "api.routes.register",
checkoutPolicy: "required-for-agent-in-progress",
companyResolution: { from: "issue", param: "issueId" },
},
]
```
The host resolves the plugin, checks that it is ready, enforces
`api.routes.register`, matches the declared method/path, resolves company access,
and applies checkout policy before dispatching to the worker's `onApiRequest`
handler. The worker receives sanitized headers, route params, query, parsed JSON
body, actor context, and company id. Do not use plugin routes to claim core
paths; they always remain under `/api/plugins/:pluginId/api/*`.
UI:
- `usePluginData`

View File

@@ -28,9 +28,6 @@ Current limitations to keep in mind:
- The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build.
- Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet.
- The current runtime does not yet ship a real host-provided plugin UI component kit, and it does not support plugin asset uploads/reads. Treat those as future-scope ideas in this spec, not current implementation promises.
- Scoped plugin API routes are JSON-only and must be declared in `apiRoutes`.
They mount under `/api/plugins/:pluginId/api/*`; plugins cannot shadow core
API routes.
In practice, that means the current implementation is a good fit for local development and self-hosted persistent deployments, but not yet for multi-instance cloud plugin distribution.
@@ -627,46 +624,7 @@ Required SDK clients:
Plugins that need filesystem, git, terminal, or process operations handle those directly using standard Node APIs or libraries. The host provides project workspace metadata through `ctx.projects` so plugins can resolve workspace paths, but the host does not proxy low-level OS operations.
## 14.1 Issue Orchestration APIs
Trusted orchestration plugins can create and update Paperclip issues through `ctx.issues` instead of importing server internals. The public issue contract includes parent/project/goal links, board or agent assignees, blocker IDs, labels, billing code, request depth, execution workspace inheritance, and plugin origin metadata.
Origin rules:
- Built-in core issues keep built-in origins such as `manual` and `routine_execution`.
- Plugin-managed issues use `plugin:<pluginKey>` or a sub-kind such as `plugin:<pluginKey>:feature`.
- The host derives the default plugin origin from the installed plugin key and rejects attempts to set `plugin:<otherPluginKey>` origins.
- `originId` is plugin-defined and should be stable for idempotent generated work.
Relation and read helpers:
- `ctx.issues.relations.get(issueId, companyId)`
- `ctx.issues.relations.setBlockedBy(issueId, blockerIssueIds, companyId)`
- `ctx.issues.relations.addBlockers(issueId, blockerIssueIds, companyId)`
- `ctx.issues.relations.removeBlockers(issueId, blockerIssueIds, companyId)`
- `ctx.issues.getSubtree(issueId, companyId, options)`
- `ctx.issues.summaries.getOrchestration({ issueId, companyId, includeSubtree, billingCode })`
Governance helpers:
- `ctx.issues.assertCheckoutOwner({ issueId, companyId, actorAgentId, actorRunId })` lets plugin actions preserve agent-run checkout ownership.
- `ctx.issues.requestWakeup(issueId, companyId, options)` requests assignment wakeups through host heartbeat semantics, including terminal-status, blocker, assignee, and budget hard-stop checks.
- `ctx.issues.requestWakeups(issueIds, companyId, options)` applies the same host-owned wakeup semantics to a batch and may use an idempotency key prefix for stable coordinator retries.
Plugin-originated issue, relation, document, comment, and wakeup mutations must write activity entries with `actorType: "plugin"` and details fields for `sourcePluginId`, `sourcePluginKey`, `initiatingActorType`, `initiatingActorId`, and `initiatingRunId` when a user or agent run initiated the plugin work.
Scoped API routes:
- `apiRoutes[]` declares `routeKey`, `method`, plugin-local `path`, `auth`,
`capability`, optional checkout policy, and company resolution.
- The host enforces auth, company access, `api.routes.register`, route matching,
and checkout policy before worker dispatch.
- The worker implements `onApiRequest(input)` and returns a JSON response shape
`{ status?, headers?, body? }`.
- Only safe request headers are forwarded; auth/cookie headers are never passed
to the worker.
## 14.2 Example SDK Shape
## 14.1 Example SDK Shape
```ts
/** Top-level helper for defining a plugin with type checking */
@@ -738,24 +696,16 @@ The host enforces capabilities in the SDK layer and refuses calls outside the gr
- `project.workspaces.read`
- `issues.read`
- `issue.comments.read`
- `issue.documents.read`
- `issue.relations.read`
- `issue.subtree.read`
- `agents.read`
- `goals.read`
- `activity.read`
- `costs.read`
- `issues.orchestration.read`
### Data Write
- `issues.create`
- `issues.update`
- `issue.comments.create`
- `issue.documents.write`
- `issue.relations.write`
- `issues.checkout`
- `issues.wakeup`
- `assets.write`
- `assets.read`
- `activity.log.write`
@@ -822,13 +772,6 @@ Minimum event set:
- `issue.created`
- `issue.updated`
- `issue.comment.created`
- `issue.document.created`
- `issue.document.updated`
- `issue.document.deleted`
- `issue.relations.updated`
- `issue.checked_out`
- `issue.released`
- `issue.assignment_wakeup_requested`
- `agent.created`
- `agent.updated`
- `agent.status_changed`
@@ -838,8 +781,6 @@ Minimum event set:
- `agent.run.cancelled`
- `approval.created`
- `approval.decided`
- `budget.incident.opened`
- `budget.incident.resolved`
- `cost_event.created`
- `activity.logged`
@@ -1297,8 +1238,6 @@ Plugin-originated mutations should write:
- `actor_type = plugin`
- `actor_id = <plugin-id>`
- details include `sourcePluginId` and `sourcePluginKey`
- details include `initiatingActorType`, `initiatingActorId`, and `initiatingRunId` when a user or agent run triggered the plugin work
## 21.5 Plugin Migrations

View File

@@ -114,14 +114,14 @@ If the connection drops, the UI reconnects automatically.
1. Enable timer wakeups (for example every 300s)
2. Keep assignment wakeups on
3. Use a focused prompt template that tells agents to act in the same heartbeat, leave durable progress, and mark blocked work with an owner/action
3. Use a focused prompt template
4. Watch run logs and adjust prompt/config over time
## 7.2 Event-driven loop (less constant polling)
1. Disable timer or set a long interval
2. Keep wake-on-assignment enabled
3. Use child issues, comments, and on-demand wakeups for handoffs instead of loops that poll agents, sessions, or processes
3. Use on-demand wakeups for manual nudges
## 7.3 Safety-first loop

View File

@@ -124,14 +124,14 @@ If the connection drops, the UI reconnects automatically.
1. Enable timer wakeups (for example every 300s)
2. Keep assignment wakeups on
3. Use a focused prompt template that tells agents to act in the same heartbeat, leave durable progress, and mark blocked work with an owner/action
3. Use a focused prompt template
4. Watch run logs and adjust prompt/config over time
## 7.2 Event-driven loop (less constant polling)
1. Disable timer or set a long interval
2. Keep wake-on-assignment enabled
3. Use child issues, comments, and on-demand wakeups for handoffs instead of loops that poll agents, sessions, or processes
3. Use on-demand wakeups for manual nudges
## 7.3 Safety-first loop

View File

@@ -66,9 +66,7 @@ Read ancestors to understand why this task exists. If woken by a specific commen
### Step 7: Do the Work
Use your tools and capabilities to complete the task. If the issue is actionable, take a concrete action in the same heartbeat. Do not stop at a plan unless the issue asked for planning.
Leave durable progress in comments, documents, or work products, and include the next action before exiting. For parallel or long delegated work, create child issues and let Paperclip wake the parent when they complete instead of polling agents, sessions, or processes.
Use your tools and capabilities to complete the task.
### Step 8: Update Status
@@ -104,22 +102,6 @@ Always set `parentId` and `goalId` on subtasks.
- **Always checkout** before working — never PATCH to `in_progress` manually
- **Never retry a 409** — the task belongs to someone else
- **Always comment** on in-progress work before exiting a heartbeat
- **Start actionable work** in the same heartbeat; planning-only exits are for planning tasks
- **Leave a clear next action** in durable issue context
- **Use child issues instead of polling** for long or parallel delegated work
- **Always set parentId** on subtasks
- **Never cancel cross-team tasks** — reassign to your manager
- **Escalate when stuck** — use your chain of command
## Run Liveness
Paperclip records run liveness as metadata on heartbeat runs. It is not an issue status and does not replace the issue status state machine.
- Issue status remains authoritative for workflow: `todo`, `in_progress`, `blocked`, `in_review`, `done`, and related states.
- Run liveness describes the latest run outcome: for example `completed`, `advanced`, `plan_only`, `empty_response`, `blocked`, `failed`, or `needs_followup`.
- Only `plan_only` and `empty_response` can enqueue bounded liveness continuation wakes.
- Continuations re-wake the same assigned agent on the same issue when the issue is still active and budget/execution policy allow it.
- `continuationAttempt` counts semantic liveness continuations for a source run chain. It is separate from process recovery, queued wake delivery, adapter session resume, and other operational retries.
- Liveness continuation wake prompts include the attempt, source run, liveness state, liveness reason, and the instruction for the next heartbeat.
- Continuations do not mark the issue `blocked` or `done`. If automatic continuations are exhausted, Paperclip leaves an audit comment so a human or manager can clarify, block, or assign follow-up work.
- Workspace provisioning alone is not treated as concrete task progress. Durable progress should appear as tool/action events, issue comments, document or work-product revisions, activity log entries, commits, or tests.

View File

@@ -20,13 +20,6 @@ The Heartbeat Procedure:
8. Update status: PATCH /api/issues/{issueId} with status and comment
9. Delegate if needed: POST /api/companies/{companyId}/issues
Execution Contract:
- If the issue is actionable, start concrete work in this heartbeat. Do not stop at a plan unless the issue asks for planning.
- Leave durable progress in comments, documents, or work products, with a clear next action.
- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.
- If blocked, PATCH the issue to blocked and name the unblock owner and action.
- Respect budget, pause/cancel, approval gates, and company boundaries.
Critical Rules:
- Always checkout before working. Never PATCH to in_progress manually.
- Never retry a 409. The task belongs to someone else.

View File

@@ -11,8 +11,6 @@
"dev:stop": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-service.ts stop",
"dev:server": "pnpm --filter @paperclipai/server dev",
"dev:ui": "pnpm --filter @paperclipai/ui dev",
"storybook": "pnpm --filter @paperclipai/ui storybook",
"build-storybook": "pnpm --filter @paperclipai/ui build-storybook",
"build": "pnpm run preflight:workspace-links && pnpm -r build",
"typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck",
"test": "pnpm run test:run",
@@ -20,7 +18,6 @@
"test:run": "pnpm run preflight:workspace-links && vitest run",
"db:generate": "pnpm --filter @paperclipai/db generate",
"db:migrate": "pnpm --filter @paperclipai/db migrate",
"issue-references:backfill": "pnpm run preflight:workspace-links && tsx scripts/backfill-issue-reference-mentions.ts",
"secrets:migrate-inline-env": "tsx scripts/migrate-inline-env-secrets.ts",
"db:backup": "./scripts/backup-db.sh",
"paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts",

View File

@@ -1,13 +1,6 @@
import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
appendWithByteCap,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
renderPaperclipWakePrompt,
runningProcesses,
runChildProcess,
stringifyPaperclipWakePayload,
} from "./server-utils.js";
import { runChildProcess } from "./server-utils.js";
function isPidAlive(pid: number) {
try {
@@ -27,37 +20,7 @@ async function waitForPidExit(pid: number, timeoutMs = 2_000) {
return !isPidAlive(pid);
}
async function waitForTextMatch(read: () => string, pattern: RegExp, timeoutMs = 1_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const value = read();
const match = value.match(pattern);
if (match) return match;
await new Promise((resolve) => setTimeout(resolve, 25));
}
return read().match(pattern);
}
describe("runChildProcess", () => {
it("does not arm a timeout when timeoutSec is 0", async () => {
const result = await runChildProcess(
randomUUID(),
process.execPath,
["-e", "setTimeout(() => process.stdout.write('done'), 150);"],
{
cwd: process.cwd(),
env: {},
timeoutSec: 0,
graceSec: 1,
onLog: async () => {},
},
);
expect(result.exitCode).toBe(0);
expect(result.timedOut).toBe(false);
expect(result.stdout).toBe("done");
});
it("waits for onSpawn before sending stdin to the child", async () => {
const spawnDelayMs = 150;
const startedAt = Date.now();
@@ -122,252 +85,4 @@ describe("runChildProcess", () => {
expect(await waitForPidExit(descendantPid!, 2_000)).toBe(true);
});
it.skipIf(process.platform === "win32")("cleans up a lingering process group after terminal output and child exit", async () => {
const result = await runChildProcess(
randomUUID(),
process.execPath,
[
"-e",
[
"const { spawn } = require('node:child_process');",
"const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: ['ignore', 'inherit', 'ignore'] });",
"process.stdout.write(`descendant:${child.pid}\\n`);",
"process.stdout.write(`${JSON.stringify({ type: 'result', result: 'done' })}\\n`);",
"setTimeout(() => process.exit(0), 25);",
].join(" "),
],
{
cwd: process.cwd(),
env: {},
timeoutSec: 0,
graceSec: 1,
onLog: async () => {},
terminalResultCleanup: {
graceMs: 100,
hasTerminalResult: ({ stdout }) => stdout.includes('"type":"result"'),
},
},
);
const descendantPid = Number.parseInt(result.stdout.match(/descendant:(\d+)/)?.[1] ?? "", 10);
expect(result.timedOut).toBe(false);
expect(result.exitCode).toBe(0);
expect(Number.isInteger(descendantPid) && descendantPid > 0).toBe(true);
expect(await waitForPidExit(descendantPid, 2_000)).toBe(true);
});
it.skipIf(process.platform === "win32")("does not clean up noisy runs that have no terminal output", async () => {
const runId = randomUUID();
let observed = "";
const resultPromise = runChildProcess(
runId,
process.execPath,
[
"-e",
[
"const { spawn } = require('node:child_process');",
"const child = spawn(process.execPath, ['-e', \"setInterval(() => process.stdout.write('noise\\\\n'), 50)\"], { stdio: ['ignore', 'inherit', 'ignore'] });",
"process.stdout.write(`descendant:${child.pid}\\n`);",
"setTimeout(() => process.exit(0), 25);",
].join(" "),
],
{
cwd: process.cwd(),
env: {},
timeoutSec: 0,
graceSec: 1,
onLog: async (_stream, chunk) => {
observed += chunk;
},
terminalResultCleanup: {
graceMs: 50,
hasTerminalResult: ({ stdout }) => stdout.includes('"type":"result"'),
},
},
);
const pidMatch = await waitForTextMatch(() => observed, /descendant:(\d+)/);
const descendantPid = Number.parseInt(pidMatch?.[1] ?? "", 10);
expect(Number.isInteger(descendantPid) && descendantPid > 0).toBe(true);
const race = await Promise.race([
resultPromise.then(() => "settled" as const),
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 300)),
]);
expect(race).toBe("pending");
expect(isPidAlive(descendantPid)).toBe(true);
const running = runningProcesses.get(runId) as
| { child: { kill(signal: NodeJS.Signals): boolean }; processGroupId: number | null }
| undefined;
try {
if (running?.processGroupId) {
process.kill(-running.processGroupId, "SIGKILL");
} else {
running?.child.kill("SIGKILL");
}
await resultPromise;
} finally {
runningProcesses.delete(runId);
if (isPidAlive(descendantPid)) {
try {
process.kill(descendantPid, "SIGKILL");
} catch {
// Ignore cleanup races.
}
}
}
});
});
describe("renderPaperclipWakePrompt", () => {
it("keeps the default local-agent prompt action-oriented", () => {
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Start actionable work in this heartbeat");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("do not stop at a plan");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Use child issues");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("instead of polling agents, sessions, or processes");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain(
"Respect budget, pause/cancel, approval gates, and company boundaries",
);
});
it("adds the execution contract to scoped wake prompts", () => {
const prompt = renderPaperclipWakePrompt({
reason: "issue_assigned",
issue: {
id: "issue-1",
identifier: "PAP-1580",
title: "Update prompts",
status: "in_progress",
},
commentWindow: {
requestedCount: 0,
includedCount: 0,
missingCount: 0,
},
comments: [],
fallbackFetchNeeded: false,
});
expect(prompt).toContain("## Paperclip Wake Payload");
expect(prompt).toContain("Execution contract: take concrete action in this heartbeat");
expect(prompt).toContain("use child issues instead of polling");
expect(prompt).toContain("mark blocked work with the unblock owner/action");
});
it("renders dependency-blocked interaction guidance", () => {
const prompt = renderPaperclipWakePrompt({
reason: "issue_commented",
issue: {
id: "issue-1",
identifier: "PAP-1703",
title: "Blocked parent",
status: "todo",
},
dependencyBlockedInteraction: true,
unresolvedBlockerIssueIds: ["blocker-1"],
unresolvedBlockerSummaries: [
{
id: "blocker-1",
identifier: "PAP-1723",
title: "Finish blocker",
status: "todo",
priority: "medium",
},
],
commentWindow: {
requestedCount: 1,
includedCount: 1,
missingCount: 0,
},
commentIds: ["comment-1"],
latestCommentId: "comment-1",
comments: [{ id: "comment-1", body: "hello" }],
fallbackFetchNeeded: false,
});
expect(prompt).toContain("dependency-blocked interaction: yes");
expect(prompt).toContain("respond or triage the human comment");
expect(prompt).toContain("PAP-1723 Finish blocker (todo)");
});
it("includes continuation and child issue summaries in structured wake context", () => {
const payload = {
reason: "issue_children_completed",
issue: {
id: "parent-1",
identifier: "PAP-100",
title: "Integrate child work",
status: "in_progress",
priority: "medium",
},
continuationSummary: {
key: "continuation-summary",
title: "Continuation Summary",
body: "# Continuation Summary\n\n## Next Action\n\n- Integrate child outputs.",
updatedAt: "2026-04-18T12:00:00.000Z",
},
livenessContinuation: {
attempt: 2,
maxAttempts: 2,
sourceRunId: "run-1",
state: "plan_only",
reason: "Run described future work without concrete action evidence",
instruction: "Take the first concrete action now.",
},
childIssueSummaries: [
{
id: "child-1",
identifier: "PAP-101",
title: "Implement helper",
status: "done",
priority: "medium",
summary: "Added the helper route and tests.",
},
],
};
expect(JSON.parse(stringifyPaperclipWakePayload(payload) ?? "{}")).toMatchObject({
continuationSummary: {
body: expect.stringContaining("Continuation Summary"),
},
livenessContinuation: {
attempt: 2,
maxAttempts: 2,
sourceRunId: "run-1",
state: "plan_only",
instruction: "Take the first concrete action now.",
},
childIssueSummaries: [
{
identifier: "PAP-101",
summary: "Added the helper route and tests.",
},
],
});
const prompt = renderPaperclipWakePrompt(payload);
expect(prompt).toContain("Issue continuation summary:");
expect(prompt).toContain("Integrate child outputs.");
expect(prompt).toContain("Run liveness continuation:");
expect(prompt).toContain("- attempt: 2/2");
expect(prompt).toContain("- source run: run-1");
expect(prompt).toContain("- liveness state: plan_only");
expect(prompt).toContain("- reason: Run described future work without concrete action evidence");
expect(prompt).toContain("- instruction: Take the first concrete action now.");
expect(prompt).toContain("Direct child issue summaries:");
expect(prompt).toContain("PAP-101 Implement helper (done)");
expect(prompt).toContain("Added the helper route and tests.");
});
});
describe("appendWithByteCap", () => {
it("keeps valid UTF-8 when trimming through multibyte text", () => {
const output = appendWithByteCap("prefix ", "hello — world", 7);
expect(output).not.toContain("\uFFFD");
expect(Buffer.from(output, "utf8").toString("utf8")).toBe(output);
expect(Buffer.byteLength(output, "utf8")).toBeLessThanOrEqual(7);
});
});

View File

@@ -16,11 +16,6 @@ export interface RunProcessResult {
startedAt: string | null;
}
export interface TerminalResultCleanupOptions {
hasTerminalResult: (output: { stdout: string; stderr: string }) => boolean;
graceMs?: number;
}
interface RunningProcess {
child: ChildProcess;
graceSec: number;
@@ -34,10 +29,6 @@ interface SpawnTarget {
type ChildProcessWithEvents = ChildProcess & {
on(event: "error", listener: (err: Error) => void): ChildProcess;
on(
event: "exit",
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
): ChildProcess;
on(
event: "close",
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
@@ -69,25 +60,12 @@ function signalRunningProcess(
export const runningProcesses = new Map<string, RunningProcess>();
export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
export const MAX_EXCERPT_BYTES = 32 * 1024;
const TERMINAL_RESULT_SCAN_OVERLAP_CHARS = 64 * 1024;
const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i;
const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [
"../../skills",
"../../../../../skills",
];
export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
"",
"Execution contract:",
"- Start actionable work in this heartbeat; do not stop at a plan unless the issue asks for planning.",
"- Leave durable progress in comments, documents, or work products with a clear next action.",
"- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.",
"- If woken by a human comment on a dependency-blocked issue, respond or triage the comment without treating the blocked deliverable work as unblocked.",
"- If blocked, mark the issue blocked and name the unblock owner and action.",
"- Respect budget, pause/cancel, approval gates, and company boundaries.",
].join("\n");
export interface PaperclipSkillEntry {
key: string;
runtimeName: string;
@@ -202,22 +180,6 @@ export function appendWithCap(prev: string, chunk: string, cap = MAX_CAPTURE_BYT
return combined.length > cap ? combined.slice(combined.length - cap) : combined;
}
export function appendWithByteCap(prev: string, chunk: string, cap = MAX_CAPTURE_BYTES) {
const combined = prev + chunk;
const bytes = Buffer.byteLength(combined, "utf8");
if (bytes <= cap) return combined;
const buffer = Buffer.from(combined, "utf8");
let start = Math.max(0, bytes - cap);
while (start < buffer.length && (buffer[start]! & 0xc0) === 0x80) start += 1;
return buffer.subarray(start).toString("utf8");
}
function resumeReadable(readable: { resume: () => unknown; destroyed?: boolean } | null | undefined) {
if (!readable || readable.destroyed) return;
readable.resume();
}
export function resolvePathValue(obj: Record<string, unknown>, dottedPath: string) {
const parts = dottedPath.split(".");
let cursor: unknown = obj;
@@ -288,52 +250,11 @@ type PaperclipWakeComment = {
authorId: string | null;
};
type PaperclipWakeContinuationSummary = {
key: string | null;
title: string | null;
body: string;
bodyTruncated: boolean;
updatedAt: string | null;
};
type PaperclipWakeLivenessContinuation = {
attempt: number | null;
maxAttempts: number | null;
sourceRunId: string | null;
state: string | null;
reason: string | null;
instruction: string | null;
};
type PaperclipWakeChildIssueSummary = {
id: string | null;
identifier: string | null;
title: string | null;
status: string | null;
priority: string | null;
summary: string | null;
};
type PaperclipWakeBlockerSummary = {
id: string | null;
identifier: string | null;
title: string | null;
status: string | null;
priority: string | null;
};
type PaperclipWakePayload = {
reason: string | null;
issue: PaperclipWakeIssue | null;
checkedOutByHarness: boolean;
dependencyBlockedInteraction: boolean;
unresolvedBlockerIssueIds: string[];
unresolvedBlockerSummaries: PaperclipWakeBlockerSummary[];
executionStage: PaperclipWakeExecutionStage | null;
continuationSummary: PaperclipWakeContinuationSummary | null;
livenessContinuation: PaperclipWakeLivenessContinuation | null;
childIssueSummaries: PaperclipWakeChildIssueSummary[];
childIssueSummaryTruncated: boolean;
commentIds: string[];
latestCommentId: string | null;
comments: PaperclipWakeComment[];
@@ -377,61 +298,6 @@ function normalizePaperclipWakeComment(value: unknown): PaperclipWakeComment | n
};
}
function normalizePaperclipWakeContinuationSummary(value: unknown): PaperclipWakeContinuationSummary | null {
const summary = parseObject(value);
const body = asString(summary.body, "").trim();
if (!body) return null;
return {
key: asString(summary.key, "").trim() || null,
title: asString(summary.title, "").trim() || null,
body,
bodyTruncated: asBoolean(summary.bodyTruncated, false),
updatedAt: asString(summary.updatedAt, "").trim() || null,
};
}
function normalizePaperclipWakeLivenessContinuation(value: unknown): PaperclipWakeLivenessContinuation | null {
const continuation = parseObject(value);
const attempt = asNumber(continuation.attempt, 0);
const maxAttempts = asNumber(continuation.maxAttempts, 0);
const sourceRunId = asString(continuation.sourceRunId, "").trim() || null;
const state = asString(continuation.state, "").trim() || null;
const reason = asString(continuation.reason, "").trim() || null;
const instruction = asString(continuation.instruction, "").trim() || null;
if (!attempt && !maxAttempts && !sourceRunId && !state && !reason && !instruction) return null;
return {
attempt: attempt > 0 ? attempt : null,
maxAttempts: maxAttempts > 0 ? maxAttempts : null,
sourceRunId,
state,
reason,
instruction,
};
}
function normalizePaperclipWakeChildIssueSummary(value: unknown): PaperclipWakeChildIssueSummary | null {
const child = parseObject(value);
const id = asString(child.id, "").trim() || null;
const identifier = asString(child.identifier, "").trim() || null;
const title = asString(child.title, "").trim() || null;
const status = asString(child.status, "").trim() || null;
const priority = asString(child.priority, "").trim() || null;
const summary = asString(child.summary, "").trim() || null;
if (!id && !identifier && !title && !status && !summary) return null;
return { id, identifier, title, status, priority, summary };
}
function normalizePaperclipWakeBlockerSummary(value: unknown): PaperclipWakeBlockerSummary | null {
const blocker = parseObject(value);
const id = asString(blocker.id, "").trim() || null;
const identifier = asString(blocker.identifier, "").trim() || null;
const title = asString(blocker.title, "").trim() || null;
const status = asString(blocker.status, "").trim() || null;
const priority = asString(blocker.priority, "").trim() || null;
if (!id && !identifier && !title && !status) return null;
return { id, identifier, title, status, priority };
}
function normalizePaperclipWakeExecutionPrincipal(value: unknown): PaperclipWakeExecutionPrincipal | null {
const principal = parseObject(value);
const typeRaw = asString(principal.type, "").trim().toLowerCase();
@@ -490,25 +356,8 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
.map((entry) => entry.trim())
: [];
const executionStage = normalizePaperclipWakeExecutionStage(payload.executionStage);
const continuationSummary = normalizePaperclipWakeContinuationSummary(payload.continuationSummary);
const livenessContinuation = normalizePaperclipWakeLivenessContinuation(payload.livenessContinuation);
const childIssueSummaries = Array.isArray(payload.childIssueSummaries)
? payload.childIssueSummaries
.map((entry) => normalizePaperclipWakeChildIssueSummary(entry))
.filter((entry): entry is PaperclipWakeChildIssueSummary => Boolean(entry))
: [];
const unresolvedBlockerIssueIds = Array.isArray(payload.unresolvedBlockerIssueIds)
? payload.unresolvedBlockerIssueIds
.map((entry) => asString(entry, "").trim())
.filter(Boolean)
: [];
const unresolvedBlockerSummaries = Array.isArray(payload.unresolvedBlockerSummaries)
? payload.unresolvedBlockerSummaries
.map((entry) => normalizePaperclipWakeBlockerSummary(entry))
.filter((entry): entry is PaperclipWakeBlockerSummary => Boolean(entry))
: [];
if (comments.length === 0 && commentIds.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !executionStage && !continuationSummary && !livenessContinuation && !normalizePaperclipWakeIssue(payload.issue)) {
if (comments.length === 0 && commentIds.length === 0 && !executionStage && !normalizePaperclipWakeIssue(payload.issue)) {
return null;
}
@@ -516,14 +365,7 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
reason: asString(payload.reason, "").trim() || null,
issue: normalizePaperclipWakeIssue(payload.issue),
checkedOutByHarness: asBoolean(payload.checkedOutByHarness, false),
dependencyBlockedInteraction: asBoolean(payload.dependencyBlockedInteraction, false),
unresolvedBlockerIssueIds,
unresolvedBlockerSummaries,
executionStage,
continuationSummary,
livenessContinuation,
childIssueSummaries,
childIssueSummaryTruncated: asBoolean(payload.childIssueSummaryTruncated, false),
commentIds,
latestCommentId: asString(payload.latestCommentId, "").trim() || null,
comments,
@@ -564,8 +406,6 @@ export function renderPaperclipWakePrompt(
"Focus on the new wake delta below and continue the current task without restating the full heartbeat boilerplate.",
"Fetch the API thread only when `fallbackFetchNeeded` is true or you need broader history than this batch.",
"",
"Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress with a clear next action, use child issues instead of polling for long or parallel work, and mark blocked work with the unblock owner/action.",
"",
`- reason: ${normalized.reason ?? "unknown"}`,
`- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`,
`- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`,
@@ -581,8 +421,6 @@ export function renderPaperclipWakePrompt(
"Use this inline wake data first before refetching the issue thread.",
"Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch.",
"",
"Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress with a clear next action, use child issues instead of polling for long or parallel work, and mark blocked work with the unblock owner/action.",
"",
`- reason: ${normalized.reason ?? "unknown"}`,
`- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`,
`- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`,
@@ -599,18 +437,6 @@ export function renderPaperclipWakePrompt(
if (normalized.checkedOutByHarness) {
lines.push("- checkout: already claimed by the harness for this run");
}
if (normalized.dependencyBlockedInteraction) {
lines.push("- dependency-blocked interaction: yes");
lines.push("- execution scope: respond or triage the human comment; do not treat blocker-dependent deliverable work as unblocked");
if (normalized.unresolvedBlockerSummaries.length > 0) {
const blockers = normalized.unresolvedBlockerSummaries
.map((blocker) => `${blocker.identifier ?? blocker.id ?? "unknown"}${blocker.title ? ` ${blocker.title}` : ""}${blocker.status ? ` (${blocker.status})` : ""}`)
.join("; ");
lines.push(`- unresolved blockers: ${blockers}`);
} else if (normalized.unresolvedBlockerIssueIds.length > 0) {
lines.push(`- unresolved blocker issue ids: ${normalized.unresolvedBlockerIssueIds.join(", ")}`);
}
}
if (normalized.missingCount > 0) {
lines.push(`- omitted comments: ${normalized.missingCount}`);
}
@@ -644,55 +470,6 @@ export function renderPaperclipWakePrompt(
}
}
if (normalized.continuationSummary) {
lines.push(
"",
"Issue continuation summary:",
normalized.continuationSummary.body,
);
if (normalized.continuationSummary.bodyTruncated) {
lines.push("[continuation summary truncated]");
}
}
if (normalized.livenessContinuation) {
const continuation = normalized.livenessContinuation;
lines.push("", "Run liveness continuation:");
if (continuation.attempt) {
lines.push(
`- attempt: ${continuation.attempt}${continuation.maxAttempts ? `/${continuation.maxAttempts}` : ""}`,
);
}
if (continuation.sourceRunId) {
lines.push(`- source run: ${continuation.sourceRunId}`);
}
if (continuation.state) {
lines.push(`- liveness state: ${continuation.state}`);
}
if (continuation.reason) {
lines.push(`- reason: ${continuation.reason}`);
}
if (continuation.instruction) {
lines.push(`- instruction: ${continuation.instruction}`);
}
}
if (normalized.childIssueSummaries.length > 0) {
lines.push("", "Direct child issue summaries:");
for (const child of normalized.childIssueSummaries) {
const label = child.identifier ?? child.id ?? "unknown";
lines.push(
`- ${label}${child.title ? ` ${child.title}` : ""}${child.status ? ` (${child.status})` : ""}`,
);
if (child.summary) {
lines.push(` ${child.summary}`);
}
}
if (normalized.childIssueSummaryTruncated) {
lines.push("[child issue summaries truncated]");
}
}
if (normalized.checkedOutByHarness) {
lines.push(
"",
@@ -1295,7 +1072,6 @@ export async function runChildProcess(
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onLogError?: (err: unknown, runId: string, message: string) => void;
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
terminalResultCleanup?: TerminalResultCleanupOptions;
stdin?: string;
},
): Promise<RunProcessResult> {
@@ -1345,61 +1121,11 @@ export async function runChildProcess(
let stdout = "";
let stderr = "";
let logChain: Promise<void> = Promise.resolve();
let childExited = false;
let terminalResultSeen = false;
let terminalCleanupStarted = false;
let terminalCleanupTimer: NodeJS.Timeout | null = null;
let terminalCleanupKillTimer: NodeJS.Timeout | null = null;
let terminalResultStdoutScanOffset = 0;
let terminalResultStderrScanOffset = 0;
const clearTerminalCleanupTimers = () => {
if (terminalCleanupTimer) clearTimeout(terminalCleanupTimer);
if (terminalCleanupKillTimer) clearTimeout(terminalCleanupKillTimer);
terminalCleanupTimer = null;
terminalCleanupKillTimer = null;
};
const maybeArmTerminalResultCleanup = () => {
const terminalCleanup = opts.terminalResultCleanup;
if (!terminalCleanup || terminalCleanupStarted || timedOut) return;
if (!terminalResultSeen) {
const stdoutStart = Math.max(0, terminalResultStdoutScanOffset - TERMINAL_RESULT_SCAN_OVERLAP_CHARS);
const stderrStart = Math.max(0, terminalResultStderrScanOffset - TERMINAL_RESULT_SCAN_OVERLAP_CHARS);
const scanOutput = {
stdout: stdout.slice(stdoutStart),
stderr: stderr.slice(stderrStart),
};
terminalResultStdoutScanOffset = stdout.length;
terminalResultStderrScanOffset = stderr.length;
if (scanOutput.stdout.length === 0 && scanOutput.stderr.length === 0) return;
try {
terminalResultSeen = terminalCleanup.hasTerminalResult(scanOutput);
} catch (err) {
onLogError(err, runId, "failed to inspect terminal adapter output");
}
}
if (!terminalResultSeen || !childExited) return;
if (terminalCleanupTimer) return;
const graceMs = Math.max(0, terminalCleanup.graceMs ?? 5_000);
terminalCleanupTimer = setTimeout(() => {
terminalCleanupTimer = null;
if (terminalCleanupStarted || timedOut) return;
terminalCleanupStarted = true;
signalRunningProcess({ child, processGroupId }, "SIGTERM");
terminalCleanupKillTimer = setTimeout(() => {
terminalCleanupKillTimer = null;
signalRunningProcess({ child, processGroupId }, "SIGKILL");
}, Math.max(1, opts.graceSec) * 1000);
}, graceMs);
};
const timeout =
opts.timeoutSec > 0
? setTimeout(() => {
timedOut = true;
clearTerminalCleanupTimers();
signalRunningProcess({ child, processGroupId }, "SIGTERM");
setTimeout(() => {
signalRunningProcess({ child, processGroupId }, "SIGKILL");
@@ -1408,35 +1134,19 @@ export async function runChildProcess(
: null;
child.stdout?.on("data", (chunk: unknown) => {
const readable = child.stdout;
if (!readable) return;
readable.pause();
const text = String(chunk);
stdout = appendWithCap(stdout, text);
maybeArmTerminalResultCleanup();
logChain = logChain
.then(() => opts.onLog("stdout", text))
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"))
.finally(() => {
maybeArmTerminalResultCleanup();
resumeReadable(readable);
});
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"));
});
child.stderr?.on("data", (chunk: unknown) => {
const readable = child.stderr;
if (!readable) return;
readable.pause();
const text = String(chunk);
stderr = appendWithCap(stderr, text);
maybeArmTerminalResultCleanup();
logChain = logChain
.then(() => opts.onLog("stderr", text))
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"))
.finally(() => {
maybeArmTerminalResultCleanup();
resumeReadable(readable);
});
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
});
const stdin = child.stdin;
@@ -1450,7 +1160,6 @@ export async function runChildProcess(
child.on("error", (err: Error) => {
if (timeout) clearTimeout(timeout);
clearTerminalCleanupTimers();
runningProcesses.delete(runId);
const errno = (err as NodeJS.ErrnoException).code;
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
@@ -1461,14 +1170,8 @@ export async function runChildProcess(
reject(new Error(msg));
});
child.on("exit", () => {
childExited = true;
maybeArmTerminalResultCleanup();
});
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
if (timeout) clearTimeout(timeout);
clearTerminalCleanupTimers();
runningProcesses.delete(runId);
void logChain.finally(() => {
resolve({

View File

@@ -21,7 +21,6 @@ import {
renderTemplate,
renderPaperclipWakePrompt,
stringifyPaperclipWakePayload,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import {
@@ -301,7 +300,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const promptTemplate = asString(
config.promptTemplate,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
);
const model = asString(config.model, "");
const effort = asString(config.effort, "");
@@ -330,10 +329,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
graceSec,
extraArgs,
} = runtimeConfig;
const terminalResultCleanupGraceMs = Math.max(
0,
asNumber(config.terminalResultCleanupGraceMs, 5_000),
);
const effectiveEnv = Object.fromEntries(
Object.entries({ ...process.env, ...env }).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
@@ -507,10 +502,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
graceSec,
onSpawn,
onLog,
terminalResultCleanup: {
graceMs: terminalResultCleanupGraceMs,
hasTerminalResult: ({ stdout }) => parseClaudeStreamJson(stdout).resultJson !== null,
},
});
const parsedStream = parseClaudeStreamJson(proc.stdout);

Some files were not shown because too many files have changed in this diff Show More