Merge remote-tracking branch 'origin/master' into PAP-12383-what-are-the-organizational-patterns-for-things-like-zapier-and-other-similar-tools

# Conflicts:
#	packages/db/src/migrations/meta/_journal.json
This commit is contained in:
Dotta committed 2026-07-06 19:53:35 +00:00
commit ae04bca734
71 files changed
+7354 -329

No files matched your search

+12
View File
@@ -56,6 +56,18 @@ pnpm build-storybook
These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`.
## UI Fonts And Screenshots
The board UI ships its own sans-serif webfont assets in `ui/public/fonts/`.
`ui/src/index.css` declares Inter v4.1 variable regular and italic faces and wires
the Tailwind `font-sans` token to those bundled files before system fallbacks.
Linux screenshot or Storybook capture jobs should not install host Inter packages
or inject external font CSS to make Paperclip text render correctly.
Font assets live in Vite's public directory so `pnpm --filter @paperclipai/ui build`
emits them under `ui/dist/fonts/`. The server package copies the same output into
`server/ui-dist/fonts/` through `scripts/prepare-server-ui-dist.sh`.
Inspect or stop the current repo's managed dev runner:
```sh
+2 -1
View File
@@ -40,7 +40,8 @@ Priority indicators:
### Typography
- **Font:** System font stack (Inter if loaded, else `-apple-system, BlinkMacSystemFont, 'Segoe UI'`)
- **Font:** Bundled Inter v4.1 variable WOFF2 for sans text, loaded from `/fonts/InterVariable.woff2` and `/fonts/InterVariable-Italic.woff2`, with `Inter`, `ui-sans-serif`, `system-ui`, `-apple-system`, `BlinkMacSystemFont`, and `'Segoe UI'` fallbacks.
- **Mono:** System monospace stack via the `font-mono` token (`ui-monospace`, `SFMono-Regular`, `Menlo`, `Monaco`, `Consolas`, `Liberation Mono`, `Courier New`, `monospace`).
- **Body:** 13px / 1.5 line-height
- **Labels/metadata:** 11px / uppercase tracking
- **Headings:** 14-18px / semi-bold, never all-caps
+10 -3
View File
@@ -1,5 +1,11 @@
"use strict";
function stripAnsi(text) {
return text
.replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "")
.replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
}
function safeJsonParse(text) {
try {
return JSON.parse(text);
@@ -18,7 +24,8 @@ function asString(value) {
}
function parseStdoutLine(line, ts) {
const trimmed = line.trim();
const cleaned = stripAnsi(line);
const trimmed = cleaned.trim();
if (!trimmed) return [];
const eventMatch = trimmed.match(/^\[hermes-gateway:event\]\s+run=([^\s]+)\s+event=([^\s]+)\s+data=(.*)$/s);
@@ -27,7 +34,7 @@ function parseStdoutLine(line, ts) {
const data = asRecord(safeJsonParse(eventMatch[3]));
if (eventName === "message.delta") {
const delta = asString(data && data.delta) || asString(data && data.text_delta);
return delta ? [{ kind: "assistant", ts, text: delta, delta: true }] : [];
return delta ? [{ kind: "assistant", ts, text: stripAnsi(delta), delta: true }] : [];
}
if (eventName === "run.failed" || eventName === "run.error") {
const message = asString(data && data.error) || asString(data && data.message) || "Hermes run failed";
@@ -43,7 +50,7 @@ function parseStdoutLine(line, ts) {
return [{ kind: "system", ts, text: trimmed.replace(/^\[hermes-gateway\]\s*/, "") }];
}
return [{ kind: "stdout", ts, text: line }];
return [{ kind: "stdout", ts, text: cleaned }];
}
module.exports = { parseStdoutLine };
@@ -1,5 +1,15 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
/**
* Strip ANSI escape sequences (CSI, OSC) from terminal text.
* Same pattern used in claude-local adapter quota.ts.
*/
function stripAnsi(text: string): string {
return text
.replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "")
.replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
}
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
@@ -18,7 +28,8 @@ function asString(value: unknown): string {
}
export function parseHermesGatewayStdoutLine(line: string, ts: string): TranscriptEntry[] {
const trimmed = line.trim();
const cleaned = stripAnsi(line);
const trimmed = cleaned.trim();
if (!trimmed) return [];
const eventMatch = trimmed.match(/^\[hermes-gateway:event\]\s+run=([^\s]+)\s+event=([^\s]+)\s+data=(.*)$/s);
@@ -27,7 +38,7 @@ export function parseHermesGatewayStdoutLine(line: string, ts: string): Transcri
const data = asRecord(safeJsonParse(eventMatch[3]));
if (eventName === "message.delta") {
const delta = asString(data?.delta) || asString(data?.text_delta);
return delta ? [{ kind: "assistant", ts, text: delta, delta: true }] : [];
return delta ? [{ kind: "assistant", ts, text: stripAnsi(delta), delta: true }] : [];
}
if (eventName === "run.failed" || eventName === "run.error") {
const message = asString(data?.error) || asString(data?.message) || "Hermes run failed";
@@ -43,5 +54,5 @@ export function parseHermesGatewayStdoutLine(line: string, ts: string): Transcri
return [{ kind: "system", ts, text: trimmed.replace(/^\[hermes-gateway\]\s*/, "") }];
}
return [{ kind: "stdout", ts, text: line }];
return [{ kind: "stdout", ts, text: cleaned }];
}
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { parseHermesStdoutLine } from "./parse-stdout.js";
const TS = "2026-06-29T12:00:00.000Z";
describe("parseHermesStdoutLine — ANSI stripping", () => {
it("strips 24-bit foreground + background color CSI sequences", () => {
const result = parseHermesStdoutLine(
"\x1b[38;2;255;255;255;48;2;19;87;20m+r = curl(\"POST\", \"/api/issues/d7b08cc5/comments\",\x1b[0m",
TS,
);
expect(result.length).toBeGreaterThan(0);
for (const entry of result) {
for (const v of Object.values(entry)) {
if (typeof v === "string") {
expect(v).not.toMatch(/\x1b\[/);
}
}
}
});
it("strips bold yellow CSI sequence from Hermes header", () => {
const result = parseHermesStdoutLine("\x1b[1;38;2;255;215;0m- Hermes\x1b[0m", TS);
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("text", "- Hermes");
});
it("strips light text CSI sequence", () => {
const result = parseHermesStdoutLine(
"\x1b[38;2;255;248;220mAll done. Now let me verify.\x1b[0m",
TS,
);
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("text", "All done. Now let me verify.");
});
it("passes through clean text unchanged", () => {
const result = parseHermesStdoutLine("Normal text without ANSI", TS);
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("text", "Normal text without ANSI");
});
it("strips multiple CSI sequences on a single line", () => {
const result = parseHermesStdoutLine(
"\x1b[38;2;255;255;255;48;2;19;87;20m+ \"priority\": \"highest\",\x1b[0m \x1b[38;2;255;255;255;48;2;19;87;20m+r = curl(\"PATCH\", ...\x1b[0m",
TS,
);
expect(result.length).toBeGreaterThan(0);
for (const entry of result) {
for (const v of Object.values(entry)) {
if (typeof v === "string") {
expect(v).not.toMatch(/\x1b\[/);
}
}
}
});
it("still parses tool completion lines correctly after stripping", () => {
const result = parseHermesStdoutLine("\u250a \u{1f50d} search \"pattern\" 0.5s", TS);
expect(result.length).toBeGreaterThanOrEqual(2);
const toolCall = result.find((e) => e.kind === "tool_call");
expect(toolCall?.name).toBe("search");
});
it("still parses shell tool lines correctly after stripping", () => {
const result = parseHermesStdoutLine("\u250a $ ls -la 0.3s", TS);
const toolCall = result.find((e) => e.kind === "tool_call");
expect(toolCall?.name).toBe("shell");
});
it("strips OSC title sequences", () => {
const result = parseHermesStdoutLine("\x1b]0;Terminal Title\x07Actual content", TS);
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("text", "Actual content");
});
it("handles empty lines after ANSI stripping", () => {
const result = parseHermesStdoutLine("\x1b[0m", TS);
expect(result).toHaveLength(0);
});
});
@@ -1,3 +1,13 @@
/**
* Strip ANSI escape sequences (CSI, OSC) from terminal text.
* Same pattern used in claude-local adapter quota.ts.
*/
function stripAnsi(text: string): string {
return text
.replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "")
.replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
}
/**
* Parse Hermes Agent stdout into TranscriptEntry objects for the Paperclip UI.
*
@@ -184,7 +194,7 @@ export function parseHermesStdoutLine(
line: string,
ts: string,
): TranscriptEntry[] {
const trimmed = line.trim();
const trimmed = stripAnsi(line).trim();
if (!trimmed) return [];
// ── System/adapter messages ────────────────────────────────────────────
+7 -1
View File
@@ -1,5 +1,11 @@
"use strict";
function stripAnsi(text) {
return text
.replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "")
.replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
}
const TOOL_OUTPUT_PREFIX = "\u250a";
function stripKaomoji(text) {
@@ -114,7 +120,7 @@ function isThinkingLine(line) {
}
function parseStdoutLine(line, ts) {
const trimmed = line.trim();
const trimmed = stripAnsi(line).trim();
if (!trimmed) return [];
if (trimmed.startsWith("[hermes]") || trimmed.startsWith("[paperclip]")) {
+127
View File
@@ -541,4 +541,131 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
},
20_000,
);
it(
"replays the run responsible user repair migration when heartbeat run issue refs are identifiers",
async () => {
const connectionString = await createTempDatabase();
await applyPendingMigrations(connectionString);
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
const runResponsibleUserRepairHash = await migrationHash(
"0131_repair_run_responsible_user_context_refs.sql",
);
await sql.unsafe(`
INSERT INTO "companies" ("id", "name", "issue_prefix", "created_at", "updated_at")
VALUES ('00000000-0000-0000-0000-000000000130', 'Migration Test Co', 'TST130', now(), now())
`);
await sql.unsafe(`
INSERT INTO "company_memberships" (
"id",
"company_id",
"principal_type",
"principal_id",
"status",
"membership_role",
"created_at",
"updated_at"
)
VALUES (
'00000000-0000-0000-0000-000000000131',
'00000000-0000-0000-0000-000000000130',
'user',
'owner-user',
'active',
'owner',
now(),
now()
)
`);
await sql.unsafe(`
INSERT INTO "agents" ("id", "company_id", "name", "role", "adapter_type", "created_at", "updated_at")
VALUES (
'00000000-0000-0000-0000-000000000132',
'00000000-0000-0000-0000-000000000130',
'Migration Agent',
'general',
'process',
now(),
now()
)
`);
await sql.unsafe(`
INSERT INTO "issues" (
"id",
"company_id",
"title",
"status",
"responsible_user_id",
"identifier",
"created_at",
"updated_at"
)
VALUES (
'00000000-0000-0000-0000-000000000133',
'00000000-0000-0000-0000-000000000130',
'Identifier referenced issue',
'todo',
'issue-user',
'TST130-1',
now(),
now()
)
`);
await sql.unsafe(`
INSERT INTO "heartbeat_runs" (
"id",
"company_id",
"agent_id",
"status",
"responsible_user_id",
"context_snapshot",
"created_at",
"updated_at"
)
VALUES (
'00000000-0000-0000-0000-000000000134',
'00000000-0000-0000-0000-000000000130',
'00000000-0000-0000-0000-000000000132',
'completed',
NULL,
'{"issueId":"TST130-1"}'::jsonb,
now(),
now()
)
`);
await sql.unsafe(
`DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${runResponsibleUserRepairHash}'`,
);
} finally {
await sql.end();
}
const pendingState = await inspectMigrations(connectionString);
expect(pendingState).toMatchObject({
status: "needsMigrations",
pendingMigrations: ["0131_repair_run_responsible_user_context_refs.sql"],
reason: "pending-migrations",
});
await applyPendingMigrations(connectionString);
const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
const runs = await verifySql.unsafe<{ responsible_user_id: string | null }[]>(`
SELECT "responsible_user_id"
FROM "heartbeat_runs"
WHERE "id" = '00000000-0000-0000-0000-000000000134'
`);
expect(runs).toEqual([{ responsible_user_id: "issue-user" }]);
} finally {
await verifySql.end();
}
},
20_000,
);
});
@@ -0,0 +1,345 @@
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import postgres from "postgres";
import {
applyPendingMigrations,
inspectMigrations,
} from "./client.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./test-embedded-postgres.js";
const DERIVED_ATTRIBUTION_MIGRATION = "0132_issue_comment_derived_attribution_fast.sql";
const cleanups: Array<() => Promise<void>> = [];
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
async function createTempDatabase(): Promise<string> {
const db = await startEmbeddedPostgresTestDatabase("paperclip-derived-attribution-");
cleanups.push(db.cleanup);
return db.connectionString;
}
async function migrationHash(migrationFile: string): Promise<string> {
const content = await fs.promises.readFile(
new URL(`./migrations/${migrationFile}`, import.meta.url),
"utf8",
);
return createHash("sha256").update(content).digest("hex");
}
async function makeDerivedAttributionMigrationPending(
sql: ReturnType<typeof postgres>,
): Promise<void> {
const hash = await migrationHash(DERIVED_ATTRIBUTION_MIGRATION);
await sql`
DELETE FROM "drizzle"."__drizzle_migrations"
WHERE "hash" = ${hash}
`;
}
async function dropDerivedAttributionSchema(sql: ReturnType<typeof postgres>): Promise<void> {
await sql`ALTER TABLE "issue_comments" DROP CONSTRAINT IF EXISTS "issue_comments_derived_author_agent_id_agents_id_fk"`;
await sql`ALTER TABLE "issue_comments" DROP CONSTRAINT IF EXISTS "issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk"`;
await sql`ALTER TABLE "issue_comments" DROP COLUMN IF EXISTS "derived_author_agent_id"`;
await sql`ALTER TABLE "issue_comments" DROP COLUMN IF EXISTS "derived_created_by_run_id"`;
await sql`ALTER TABLE "issue_comments" DROP COLUMN IF EXISTS "derived_author_source"`;
}
async function createSeedGraph(sql: ReturnType<typeof postgres>, label: string) {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const runId = randomUUID();
await sql`
INSERT INTO "companies" ("id", "name", "issue_prefix")
VALUES (${companyId}, ${`Company ${label}`}, ${`T${label}`})
`;
await sql`
INSERT INTO "agents" ("id", "company_id", "name", "role", "adapter_type", "adapter_config")
VALUES (${agentId}, ${companyId}, ${`Agent ${label}`}, 'engineer', 'process', '{}'::jsonb)
`;
await sql`
INSERT INTO "issues" ("id", "company_id", "title", "identifier")
VALUES (${issueId}, ${companyId}, ${`Issue ${label}`}, ${`T${label}-1`})
`;
await sql`
INSERT INTO "heartbeat_runs" ("id", "company_id", "agent_id", "status")
VALUES (${runId}, ${companyId}, ${agentId}, 'succeeded')
`;
return { companyId, agentId, issueId, runId };
}
async function expectDerivedAttributionSchema(sql: ReturnType<typeof postgres>): Promise<void> {
const columns = await sql<{ column_name: string; data_type: string; is_nullable: string }[]>`
SELECT "column_name", "data_type", "is_nullable"
FROM "information_schema"."columns"
WHERE "table_schema" = 'public'
AND "table_name" = 'issue_comments'
AND "column_name" IN (
'derived_author_agent_id',
'derived_created_by_run_id',
'derived_author_source'
)
ORDER BY "column_name"
`;
expect(columns).toEqual([
{ column_name: "derived_author_agent_id", data_type: "uuid", is_nullable: "YES" },
{ column_name: "derived_author_source", data_type: "text", is_nullable: "YES" },
{ column_name: "derived_created_by_run_id", data_type: "uuid", is_nullable: "YES" },
]);
const constraints = await sql<{ conname: string; delete_rule: string }[]>`
SELECT tc."constraint_name" AS "conname", rc."delete_rule"
FROM "information_schema"."table_constraints" tc
JOIN "information_schema"."referential_constraints" rc
ON rc."constraint_schema" = tc."constraint_schema"
AND rc."constraint_name" = tc."constraint_name"
WHERE tc."table_schema" = 'public'
AND tc."table_name" = 'issue_comments'
AND tc."constraint_name" IN (
'issue_comments_derived_author_agent_id_agents_id_fk',
'issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk'
)
ORDER BY tc."constraint_name"
`;
expect(constraints).toEqual([
{
conname: "issue_comments_derived_author_agent_id_agents_id_fk",
delete_rule: "SET NULL",
},
{
conname: "issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk",
delete_rule: "SET NULL",
},
]);
}
afterEach(async () => {
while (cleanups.length > 0) {
const cleanup = cleanups.pop();
await cleanup?.();
}
});
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres derived attribution migration tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("issue comment derived attribution migration", () => {
it(
"fresh installs include the relocated schema and no deleted 0126 migration",
async () => {
const connectionString = await createTempDatabase();
const state = await inspectMigrations(connectionString);
expect(state.status).toBe("upToDate");
expect(state.availableMigrations).not.toContain("0126_issue_comment_derived_attribution.sql");
expect(state.availableMigrations).toContain(DERIVED_ATTRIBUTION_MIGRATION);
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
await expectDerivedAttributionSchema(sql);
const supportIndexes = await sql<{ indexname: string }[]>`
SELECT "indexname"
FROM "pg_indexes"
WHERE "schemaname" = 'public'
AND "indexname" = 'issue_comments_derived_attribution_backfill_idx'
`;
expect(supportIndexes).toEqual([]);
} finally {
await sql.end();
}
},
20_000,
);
it(
"is idempotent for a database that already has 0126 schema and data",
async () => {
const connectionString = await createTempDatabase();
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
const alreadyBackfilledCommentId = randomUUID();
const timingTierCommentId = randomUUID();
const realUserCommentId = randomUUID();
try {
await makeDerivedAttributionMigrationPending(sql);
const { companyId, agentId, issueId, runId } = await createSeedGraph(sql, "OLD126");
await sql`
INSERT INTO "user" ("id", "name", "email", "email_verified", "created_at", "updated_at")
VALUES ('real-user', 'Real User', 'real-user@example.test', true, now(), now())
ON CONFLICT ("id") DO NOTHING
`;
await sql`
INSERT INTO "issue_comments" (
"id",
"company_id",
"issue_id",
"author_user_id",
"created_by_run_id",
"derived_author_agent_id",
"derived_created_by_run_id",
"derived_author_source",
"body"
)
VALUES
(${alreadyBackfilledCommentId}, ${companyId}, ${issueId}, 'local-board', ${runId}, ${agentId}, ${runId}, 'run_id', 'already attributed'),
(${timingTierCommentId}, ${companyId}, ${issueId}, 'local-board', NULL, ${agentId}, ${runId}, 'run_window_unique', 'timing tier'),
(${realUserCommentId}, ${companyId}, ${issueId}, 'real-user', ${runId}, NULL, NULL, NULL, 'human comment')
`;
} finally {
await sql.end();
}
const pendingState = await inspectMigrations(connectionString);
expect(pendingState).toMatchObject({
status: "needsMigrations",
pendingMigrations: [DERIVED_ATTRIBUTION_MIGRATION],
reason: "pending-migrations",
});
await applyPendingMigrations(connectionString);
const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
const rows = await verifySql<{
id: string;
derived_author_agent_id: string | null;
derived_created_by_run_id: string | null;
derived_author_source: string | null;
}[]>`
SELECT
"id",
"derived_author_agent_id",
"derived_created_by_run_id",
"derived_author_source"
FROM "issue_comments"
WHERE "id" IN (${alreadyBackfilledCommentId}, ${timingTierCommentId}, ${realUserCommentId})
ORDER BY "body"
`;
expect(rows).toEqual([
expect.objectContaining({
id: alreadyBackfilledCommentId,
derived_author_source: "run_id",
}),
{
id: realUserCommentId,
derived_author_agent_id: null,
derived_created_by_run_id: null,
derived_author_source: null,
},
{
id: timingTierCommentId,
derived_author_agent_id: null,
derived_created_by_run_id: null,
derived_author_source: null,
},
]);
} finally {
await verifySql.end();
}
const finalState = await inspectMigrations(connectionString);
expect(finalState.status).toBe("upToDate");
},
20_000,
);
it(
"completes a partially backfilled pre-0131 database",
async () => {
const connectionString = await createTempDatabase();
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
const eligibleCommentId = randomUUID();
const deletedUserCommentId = randomUUID();
const agentAuthoredCommentId = randomUUID();
try {
await dropDerivedAttributionSchema(sql);
await makeDerivedAttributionMigrationPending(sql);
const { companyId, agentId, issueId, runId } = await createSeedGraph(sql, "PARTIAL");
await sql`
INSERT INTO "issue_comments" (
"id",
"company_id",
"issue_id",
"author_user_id",
"created_by_run_id",
"body"
)
VALUES
(${eligibleCommentId}, ${companyId}, ${issueId}, 'local-board', ${runId}, 'eligible local-board'),
(${deletedUserCommentId}, ${companyId}, ${issueId}, 'deleted-user', ${runId}, 'eligible deleted user')
`;
await sql`
INSERT INTO "issue_comments" (
"id",
"company_id",
"issue_id",
"author_agent_id",
"author_user_id",
"created_by_run_id",
"body"
)
VALUES (${agentAuthoredCommentId}, ${companyId}, ${issueId}, ${agentId}, 'local-board', ${runId}, 'already agent-authored')
`;
} finally {
await sql.end();
}
await applyPendingMigrations(connectionString);
const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
await expectDerivedAttributionSchema(verifySql);
const rows = await verifySql<{
id: string;
derived_author_agent_id: string | null;
derived_created_by_run_id: string | null;
derived_author_source: string | null;
}[]>`
SELECT
"id",
"derived_author_agent_id",
"derived_created_by_run_id",
"derived_author_source"
FROM "issue_comments"
WHERE "id" IN (${eligibleCommentId}, ${deletedUserCommentId}, ${agentAuthoredCommentId})
ORDER BY "body"
`;
expect(rows).toEqual([
{
id: agentAuthoredCommentId,
derived_author_agent_id: null,
derived_created_by_run_id: null,
derived_author_source: null,
},
expect.objectContaining({
id: deletedUserCommentId,
derived_author_source: "run_id",
}),
expect.objectContaining({
id: eligibleCommentId,
derived_author_source: "run_id",
}),
]);
} finally {
await verifySql.end();
}
const finalState = await inspectMigrations(connectionString);
expect(finalState.status).toBe("upToDate");
},
20_000,
);
});
@@ -1,69 +0,0 @@
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_author_agent_id" uuid;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_created_by_run_id" uuid;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_author_source" text;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'issue_comments_derived_author_agent_id_agents_id_fk'
) THEN
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_derived_author_agent_id_agents_id_fk" FOREIGN KEY ("derived_author_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk'
) THEN
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("derived_created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
-- Backfill agent attribution for historical non-human ("Board") comments so old
-- threads stop rendering as blue board bubbles and the read path stops
-- re-scanning run logs. Two SQL-computable tiers, both guarded to NEVER touch a
-- comment whose author maps to a genuine user profile. The log-marker tier is
-- handled lazily on read (it needs object-storage log bodies).
--
-- Tier `run_id`: the comment's own authoring run resolves to an agent (lossless).
-- Batched to keep lock/WAL footprint bounded on large histories.
DO $$
DECLARE
affected integer;
BEGIN
LOOP
WITH batch AS (
SELECT c.id AS comment_id, hr.agent_id, hr.id AS run_id
FROM issue_comments c
JOIN heartbeat_runs hr ON hr.id = c.created_by_run_id
WHERE c.author_agent_id IS NULL
AND c.derived_author_agent_id IS NULL
AND c.author_user_id IS NOT NULL
-- Only the non-human board sentinel or non-`user` authors are
-- eligible. `local-board` IS a row in "user" (the implicit board
-- admin), so it must be allowed explicitly; genuine signups are not.
AND (c.author_user_id = 'local-board'
OR NOT EXISTS (SELECT 1 FROM "user" u WHERE u.id = c.author_user_id))
LIMIT 5000
)
UPDATE issue_comments c
SET derived_author_agent_id = b.agent_id,
derived_created_by_run_id = b.run_id,
derived_author_source = 'run_id'
FROM batch b
WHERE c.id = b.comment_id;
GET DIAGNOSTICS affected = ROW_COUNT;
EXIT WHEN affected = 0;
END LOOP;
END $$;--> statement-breakpoint
-- Option A: the pure run-window TIMING tiers are intentionally NOT applied.
-- Because agents post through the `local-board` subprocess, an agent comment and
-- a genuine human board comment are indistinguishable rows, so any timing-overlap
-- guess mis-attributes human board comments that merely coincided with an agent
-- run (e.g. a human board reply typed while an agent run was in flight).
-- Only the lossless `run_id` backfill above (and the read-path `run_log_comment_post`
-- tier) attribute history; everything else stays "Board".
--
-- This statement also reverts any attribution a PRIOR revision of this migration
-- persisted via the timing tiers, so re-applying / upgrading is idempotent.
UPDATE issue_comments
SET derived_author_agent_id = NULL,
derived_created_by_run_id = NULL,
derived_author_source = NULL
WHERE derived_author_source IN ('run_window_unique', 'run_window_agent_unique');
@@ -129,19 +129,74 @@ WHERE h."retry_of_run_id" = original."id"
AND h."responsible_user_id" IS NULL
AND original."responsible_user_id" IS NOT NULL;
--> statement-breakpoint
WITH extracted_run_refs AS (
SELECT
h."id" AS "run_id",
h."company_id",
NULLIF(h."context_snapshot" ->> 'issueId', '') AS "issue_ref",
1 AS "ref_priority"
FROM "heartbeat_runs" AS h
WHERE h."responsible_user_id" IS NULL
AND NULLIF(h."context_snapshot" ->> 'issueId', '') IS NOT NULL
UNION ALL
SELECT
h."id" AS "run_id",
h."company_id",
NULLIF(h."context_snapshot" ->> 'taskId', '') AS "issue_ref",
2 AS "ref_priority"
FROM "heartbeat_runs" AS h
WHERE h."responsible_user_id" IS NULL
AND NULLIF(h."context_snapshot" ->> 'taskId', '') IS NOT NULL
),
uuid_run_refs AS (
SELECT
"run_id",
"company_id",
"issue_ref"::uuid AS "issue_id",
"ref_priority"
FROM extracted_run_refs
WHERE "issue_ref" ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
),
resolved_run_users AS (
SELECT DISTINCT ON ("run_id")
"run_id",
"responsible_user_id"
FROM (
SELECT
refs."run_id",
i."responsible_user_id",
refs."ref_priority",
1 AS "match_priority"
FROM uuid_run_refs AS refs
JOIN "issues" AS i
ON i."id" = refs."issue_id"
AND i."company_id" = refs."company_id"
WHERE i."responsible_user_id" IS NOT NULL
UNION ALL
SELECT
refs."run_id",
i."responsible_user_id",
refs."ref_priority",
2 AS "match_priority"
FROM extracted_run_refs AS refs
JOIN "issues" AS i
ON i."identifier" = refs."issue_ref"
AND i."company_id" = refs."company_id"
WHERE refs."issue_ref" IS NOT NULL
AND i."responsible_user_id" IS NOT NULL
) AS candidates
ORDER BY "run_id", "ref_priority" ASC, "match_priority" ASC
)
UPDATE "heartbeat_runs" AS h
SET "responsible_user_id" = i."responsible_user_id",
SET "responsible_user_id" = resolved_run_users."responsible_user_id",
"updated_at" = now()
FROM "issues" AS i
WHERE h."company_id" = i."company_id"
AND h."responsible_user_id" IS NULL
AND i."responsible_user_id" IS NOT NULL
AND (
h."context_snapshot" ->> 'issueId' = i."id"::text
OR h."context_snapshot" ->> 'taskId' = i."id"::text
OR h."context_snapshot" ->> 'issueId' = i."identifier"
OR h."context_snapshot" ->> 'taskId' = i."identifier"
);
FROM resolved_run_users
WHERE h."id" = resolved_run_users."run_id"
AND h."responsible_user_id" IS NULL;
--> statement-breakpoint
UPDATE "heartbeat_runs" AS h
SET "responsible_user_id" = awr."requested_by_actor_id",
@@ -0,0 +1,67 @@
WITH extracted_run_refs AS (
SELECT
h."id" AS "run_id",
h."company_id",
NULLIF(h."context_snapshot" ->> 'issueId', '') AS "issue_ref",
1 AS "ref_priority"
FROM "heartbeat_runs" AS h
WHERE h."responsible_user_id" IS NULL
AND NULLIF(h."context_snapshot" ->> 'issueId', '') IS NOT NULL
UNION ALL
SELECT
h."id" AS "run_id",
h."company_id",
NULLIF(h."context_snapshot" ->> 'taskId', '') AS "issue_ref",
2 AS "ref_priority"
FROM "heartbeat_runs" AS h
WHERE h."responsible_user_id" IS NULL
AND NULLIF(h."context_snapshot" ->> 'taskId', '') IS NOT NULL
),
uuid_run_refs AS (
SELECT
"run_id",
"company_id",
"issue_ref"::uuid AS "issue_id",
"ref_priority"
FROM extracted_run_refs
WHERE "issue_ref" ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
),
resolved_run_users AS (
SELECT DISTINCT ON ("run_id")
"run_id",
"responsible_user_id"
FROM (
SELECT
refs."run_id",
i."responsible_user_id",
refs."ref_priority",
1 AS "match_priority"
FROM uuid_run_refs AS refs
JOIN "issues" AS i
ON i."id" = refs."issue_id"
AND i."company_id" = refs."company_id"
WHERE i."responsible_user_id" IS NOT NULL
UNION ALL
SELECT
refs."run_id",
i."responsible_user_id",
refs."ref_priority",
2 AS "match_priority"
FROM extracted_run_refs AS refs
JOIN "issues" AS i
ON i."identifier" = refs."issue_ref"
AND i."company_id" = refs."company_id"
WHERE i."responsible_user_id" IS NOT NULL
) AS candidates
ORDER BY "run_id", "ref_priority" ASC, "match_priority" ASC
)
UPDATE "heartbeat_runs" AS h
SET "responsible_user_id" = resolved_run_users."responsible_user_id",
"updated_at" = now()
FROM resolved_run_users
WHERE h."id" = resolved_run_users."run_id"
AND h."responsible_user_id" IS NULL;
@@ -0,0 +1,82 @@
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_author_agent_id" uuid;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_created_by_run_id" uuid;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_author_source" text;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'issue_comments_derived_author_agent_id_agents_id_fk'
) THEN
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_derived_author_agent_id_agents_id_fk" FOREIGN KEY ("derived_author_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk'
) THEN
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("derived_created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
-- Temporary support for the forward-only backfill. The keyset loop below
-- advances over this partial index by comment id, so each eligible slice is
-- visited once instead of re-scanning issue_comments from the beginning on
-- every batch.
CREATE INDEX IF NOT EXISTS "issue_comments_derived_attribution_backfill_idx"
ON "issue_comments" USING btree ("id")
WHERE "author_agent_id" IS NULL
AND "derived_author_agent_id" IS NULL
AND "author_user_id" IS NOT NULL
AND "created_by_run_id" IS NOT NULL;--> statement-breakpoint
ANALYZE "issue_comments";--> statement-breakpoint
DO $$
DECLARE
last_comment_id uuid := '00000000-0000-0000-0000-000000000000'::uuid;
next_last_comment_id uuid;
BEGIN
LOOP
next_last_comment_id := NULL;
WITH batch AS MATERIALIZED (
SELECT c."id" AS comment_id, hr."agent_id", hr."id" AS run_id
FROM "issue_comments" c
JOIN "heartbeat_runs" hr ON hr."id" = c."created_by_run_id"
LEFT JOIN "user" u ON u."id" = c."author_user_id"
WHERE c."id" > last_comment_id
AND c."author_agent_id" IS NULL
AND c."derived_author_agent_id" IS NULL
AND c."author_user_id" IS NOT NULL
AND c."created_by_run_id" IS NOT NULL
AND (
c."author_user_id" = 'local-board'
OR u."id" IS NULL
)
ORDER BY c."id"
LIMIT 5000
),
updated AS (
UPDATE "issue_comments" c
SET "derived_author_agent_id" = b."agent_id",
"derived_created_by_run_id" = b."run_id",
"derived_author_source" = 'run_id'
FROM batch b
WHERE c."id" = b."comment_id"
RETURNING c."id"
)
SELECT b."comment_id"
INTO next_last_comment_id
FROM batch b
LEFT JOIN updated u ON u."id" = b."comment_id"
ORDER BY b."comment_id" DESC
LIMIT 1;
EXIT WHEN next_last_comment_id IS NULL;
last_comment_id := next_last_comment_id;
END LOOP;
END $$;--> statement-breakpoint
DROP INDEX IF EXISTS "issue_comments_derived_attribution_backfill_idx";--> statement-breakpoint
-- Keep the Option-A cleanup at the end as well, matching the original 0126
-- terminal state if any timing-tier rows are introduced before this migration
-- is retried.
UPDATE "issue_comments"
SET "derived_author_agent_id" = NULL,
"derived_created_by_run_id" = NULL,
"derived_author_source" = NULL
WHERE "derived_author_source" IN ('run_window_unique', 'run_window_agent_unique');
@@ -0,0 +1,7 @@
ALTER TABLE "agent_memberships" ADD COLUMN IF NOT EXISTS "starred_at" timestamp with time zone;
--> statement-breakpoint
ALTER TABLE "project_memberships" ADD COLUMN IF NOT EXISTS "starred_at" timestamp with time zone;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "agent_memberships_company_user_starred_idx" ON "agent_memberships" USING btree ("company_id","user_id","starred_at");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "project_memberships_company_user_starred_idx" ON "project_memberships" USING btree ("company_id","user_id","starred_at");
+22 -8
View File
@@ -884,13 +884,6 @@
"tag": "0125_environment_custom_image_templates",
"breakpoints": true
},
{
"idx": 126,
"version": "7",
"when": 1782526400000,
"tag": "0126_issue_comment_derived_attribution",
"breakpoints": true
},
{
"idx": 127,
"version": "7",
@@ -922,8 +915,29 @@
{
"idx": 131,
"version": "7",
"when": 1783025324120,
"tag": "0131_repair_run_responsible_user_context_refs",
"breakpoints": true
},
{
"idx": 132,
"version": "7",
"when": 1783025424120,
"tag": "0132_issue_comment_derived_attribution_fast",
"breakpoints": true
},
{
"idx": 133,
"version": "7",
"when": 1783034521000,
"tag": "0133_resource_membership_stars",
"breakpoints": true
},
{
"idx": 134,
"version": "7",
"when": 1783250344742,
"tag": "0131_folders",
"tag": "0134_folders",
"breakpoints": true
}
]
@@ -10,11 +10,17 @@ export const agentMemberships = pgTable(
agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
userId: text("user_id").notNull(),
state: text("state").notNull().default("joined"),
starredAt: timestamp("starred_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyUserIdx: index("agent_memberships_company_user_idx").on(table.companyId, table.userId),
companyUserStarredIdx: index("agent_memberships_company_user_starred_idx").on(
table.companyId,
table.userId,
table.starredAt,
),
agentIdx: index("agent_memberships_agent_idx").on(table.agentId),
companyUserAgentUq: uniqueIndex("agent_memberships_company_user_agent_uq").on(
table.companyId,
@@ -10,11 +10,17 @@ export const projectMemberships = pgTable(
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
userId: text("user_id").notNull(),
state: text("state").notNull().default("joined"),
starredAt: timestamp("starred_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyUserIdx: index("project_memberships_company_user_idx").on(table.companyId, table.userId),
companyUserStarredIdx: index("project_memberships_company_user_starred_idx").on(
table.companyId,
table.userId,
table.starredAt,
),
projectIdx: index("project_memberships_project_idx").on(table.projectId),
companyUserProjectUq: uniqueIndex("project_memberships_company_user_project_uq").on(
table.companyId,
@@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { ADAPTER_AGNOSTIC_KEYS } from "./constants.js";
const EXPECTED_ADAPTER_AGNOSTIC_KEYS = [
"env",
"promptTemplate",
"instructionsFilePath",
"cwd",
"timeoutSec",
"graceSec",
"bootstrapPromptTemplate",
"paperclipSkillSync",
] as const;
function readRepoFile(pathFromRoot: string) {
return readFileSync(
fileURLToPath(new URL(`../../../${pathFromRoot}`, import.meta.url)),
"utf8",
);
}
describe("adapter-agnostic config keys", () => {
it("keeps the preserved adapter config keys explicit", () => {
expect(ADAPTER_AGNOSTIC_KEYS).toEqual(EXPECTED_ADAPTER_AGNOSTIC_KEYS);
});
it("is imported by the server and UI instead of being re-declared", () => {
const serverSource = readRepoFile("server/src/routes/agents.ts");
const uiSource = readRepoFile("ui/src/lib/agent-config-patch.ts");
expect(serverSource).toContain("ADAPTER_AGNOSTIC_KEYS");
expect(serverSource).toContain("from \"@paperclipai/shared\"");
expect(serverSource).not.toMatch(/const\s+ADAPTER_AGNOSTIC_KEYS\s*=/);
expect(uiSource).toContain("ADAPTER_AGNOSTIC_KEYS");
expect(uiSource).toContain("from \"@paperclipai/shared\"");
expect(uiSource).not.toMatch(/const\s+ADAPTER_AGNOSTIC_KEYS\s*=/);
});
});
+14
View File
@@ -78,6 +78,20 @@ export const AGENT_ROLE_LABELS: Record<AgentRole, string> = {
export const AGENT_DEFAULT_MAX_CONCURRENT_RUNS = 20;
export const WORKSPACE_BRANCH_ROUTINE_VARIABLE = "workspaceBranch";
// Config keys owned by Paperclip/company state rather than one concrete adapter.
// `paperclipSkillSync` is persisted in adapterConfig but must survive adapter swaps.
export const ADAPTER_AGNOSTIC_KEYS = [
"env",
"promptTemplate",
"instructionsFilePath",
"cwd",
"timeoutSec",
"graceSec",
"bootstrapPromptTemplate",
"paperclipSkillSync",
] as const;
export type AdapterAgnosticKey = (typeof ADAPTER_AGNOSTIC_KEYS)[number];
export const MODEL_PROFILE_KEYS = ["cheap"] as const;
export type ModelProfileKey = (typeof MODEL_PROFILE_KEYS)[number];
+2
View File
@@ -119,6 +119,7 @@ export {
AGENT_ROLE_LABELS,
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
ADAPTER_AGNOSTIC_KEYS,
MODEL_PROFILE_KEYS,
AGENT_ICON_NAMES,
PROJECT_ICON_NAMES,
@@ -265,6 +266,7 @@ export {
type AgentStatus,
type AgentAdapterType,
type AgentRole,
type AdapterAgnosticKey,
type ModelProfileKey,
type AgentIconName,
type ProjectIconName,
+163
View File
@@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import {
computePipelineHealth,
type PipelineHealthFailedAutomationInput,
type PipelineHealthInput,
} from "./pipeline-health.js";
describe("computePipelineHealth", () => {
const baseInput: PipelineHealthInput = {
pipelineId: "pipeline-1",
stages: [],
agentsById: {},
pipelinesById: {},
};
it("emits one warning per failed automation item and stage", () => {
const failure: PipelineHealthFailedAutomationInput = {
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
caseId: "case-1",
caseTitle: "Case 1",
error: "Routine timed out",
};
const report = computePipelineHealth({
...baseInput,
failedAutomations: [failure],
});
expect(report.warnings).toHaveLength(1);
expect(report.warnings[0]).toMatchObject({
code: "automation_failed",
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
href: "/pipelines/pipeline-1/items/case-1",
hrefLabel: "Open item",
message: `Automation failed on "Case 1". Open the item to inspect the log and retry it.`,
});
});
it("deduplicates duplicate failed automation rows for the same stage and case", () => {
const failure: PipelineHealthFailedAutomationInput = {
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
caseId: "case-1",
caseTitle: "Case 1",
error: "Routine timed out",
};
const duplicateFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
caseId: "case-1",
caseTitle: "Case 1",
error: "Routine timed out",
};
const report = computePipelineHealth({
...baseInput,
failedAutomations: [failure, duplicateFailure],
});
const automationWarnings = report.warnings.filter((warning) => warning.code === "automation_failed");
expect(automationWarnings).toHaveLength(1);
});
it("keeps separate warnings for different case IDs in the same stage", () => {
const firstFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
caseId: "case-1",
caseTitle: "Case 1",
error: "Routine timed out",
};
const secondFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
caseId: "case-2",
caseTitle: "Case 2",
error: "Routine timed out",
};
const report = computePipelineHealth({
...baseInput,
failedAutomations: [firstFailure, secondFailure],
});
const automationWarnings = report.warnings.filter((warning) => warning.code === "automation_failed");
expect(automationWarnings).toHaveLength(2);
expect(automationWarnings.map((warning) => warning.href)).toEqual([
"/pipelines/pipeline-1/items/case-1",
"/pipelines/pipeline-1/items/case-2",
]);
});
it("keeps separate warnings for the same case ID in different stages", () => {
const firstFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
caseId: "case-1",
caseTitle: "Case 1",
error: "Routine timed out",
};
const secondFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage-2",
stageKey: "verify",
stageName: "Verify",
caseId: "case-1",
caseTitle: "Case 1",
error: "Routine timed out",
};
const report = computePipelineHealth({
...baseInput,
failedAutomations: [firstFailure, secondFailure],
});
const automationWarnings = report.warnings.filter((warning) => warning.code === "automation_failed");
expect(automationWarnings).toHaveLength(2);
expect(automationWarnings.map((warning) => warning.stageId)).toEqual(["stage-1", "stage-2"]);
});
it("keeps separate warnings when stage and case IDs would collide with colon-delimited keys", () => {
const firstFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage:one",
stageKey: "build",
stageName: "Build",
caseId: "case",
caseTitle: "Case 1",
error: "Routine timed out",
};
const secondFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage",
stageKey: "verify",
stageName: "Verify",
caseId: "one:case",
caseTitle: "Case 2",
error: "Routine timed out",
};
const report = computePipelineHealth({
...baseInput,
failedAutomations: [firstFailure, secondFailure],
});
const automationWarnings = report.warnings.filter((warning) => warning.code === "automation_failed");
expect(automationWarnings).toHaveLength(2);
expect(automationWarnings.map((warning) => warning.href)).toEqual([
"/pipelines/pipeline-1/items/case",
"/pipelines/pipeline-1/items/one:case",
]);
});
});
+8
View File
@@ -339,7 +339,15 @@ export function computePipelineHealth(input: PipelineHealthInput): PipelineHealt
// the pipeline; a blank default in settings is a normal configuration.
}
const seenFailedAutomationCaseIdsByStage = new Map<string, Set<string>>();
for (const failure of input.failedAutomations ?? []) {
let seenCaseIds = seenFailedAutomationCaseIdsByStage.get(failure.stageId);
if (!seenCaseIds) {
seenCaseIds = new Set<string>();
seenFailedAutomationCaseIdsByStage.set(failure.stageId, seenCaseIds);
}
if (seenCaseIds.has(failure.caseId)) continue;
seenCaseIds.add(failure.caseId);
warnings.push({
code: "automation_failed",
stageId: failure.stageId,
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { updateResourceMembershipSchema } from "./validators/resource-memberships.js";
describe("resource membership contract", () => {
it("accepts legacy state-only membership updates", () => {
expect(updateResourceMembershipSchema.parse({ state: "left" })).toEqual({ state: "left" });
expect(updateResourceMembershipSchema.parse({ state: "joined" })).toEqual({ state: "joined" });
});
it("accepts star-only updates without requiring a state mutation", () => {
expect(updateResourceMembershipSchema.parse({ starred: true })).toEqual({ starred: true });
expect(updateResourceMembershipSchema.parse({ starred: false })).toEqual({ starred: false });
});
it("rejects empty or contradictory star/state updates", () => {
expect(() => updateResourceMembershipSchema.parse({})).toThrow("state or starred is required");
expect(() => updateResourceMembershipSchema.parse({ state: "left", starred: true })).toThrow(
"starred resources must be joined",
);
});
});
@@ -6,16 +6,22 @@ export type ResourceMembershipResourceType = "project" | "agent";
export interface ResourceMemberships {
projectMemberships: Record<string, ResourceMembershipState>;
agentMemberships: Record<string, ResourceMembershipState>;
starredProjectIds?: string[];
starredAgentIds?: string[];
projectStarredAt?: Record<string, Date>;
agentStarredAt?: Record<string, Date>;
updatedAt: Date | null;
}
export interface UpdateResourceMembership {
state: ResourceMembershipState;
state?: ResourceMembershipState;
starred?: boolean;
}
export interface ResourceMembershipUpdateResult {
resourceType: ResourceMembershipResourceType;
resourceId: string;
state: ResourceMembershipState;
starredAt: Date | null;
updatedAt: Date;
}
@@ -4,7 +4,13 @@ import { RESOURCE_MEMBERSHIP_STATES } from "../types/resource-memberships.js";
export const resourceMembershipStateSchema = z.enum(RESOURCE_MEMBERSHIP_STATES);
export const updateResourceMembershipSchema = z.object({
state: resourceMembershipStateSchema,
state: resourceMembershipStateSchema.optional(),
starred: z.boolean().optional(),
}).refine((value) => value.state !== undefined || value.starred !== undefined, {
message: "state or starred is required",
}).refine((value) => !(value.state === "left" && value.starred === true), {
message: "starred resources must be joined",
path: ["starred"],
});
export type UpdateResourceMembership = z.infer<typeof updateResourceMembershipSchema>;
@@ -52,5 +52,5 @@ describe("skills catalog package artifacts", () => {
expect(paths).toContain("catalog/bundled/software-development/github-pr-workflow/SKILL.md");
expect(paths).toContain("catalog/optional/browser/agent-browser/SKILL.md");
expect(paths).toContain("package.json");
}, 30_000);
}, 120_000);
});
+59 -1
View File
@@ -435,8 +435,60 @@ list_base_node_modules_paths() {
! -path './.paperclip/*' \
| sed 's#^\./##'
}
compute_pnpm_install_fingerprint() {
WORKTREE_CWD="$worktree_cwd" node <<'EOF'
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const root = process.env.WORKTREE_CWD;
const ignoredDirs = new Set([".git", ".paperclip", "node_modules", "dist", "storybook-static"]);
const files = [];
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (ignoredDirs.has(entry.name)) continue;
const absolutePath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(absolutePath);
continue;
}
if (
entry.isFile()
&& (entry.name === "package.json" || entry.name === "pnpm-lock.yaml" || entry.name === "pnpm-workspace.yaml")
) {
files.push(absolutePath);
}
}
}
walk(root);
files.sort((left, right) => path.relative(root, left).localeCompare(path.relative(root, right)));
const hash = crypto.createHash("sha256");
for (const file of files) {
const relativePath = path.relative(root, file).replaceAll(path.sep, "/");
hash.update(relativePath);
hash.update("\0");
hash.update(fs.readFileSync(file));
hash.update("\0");
}
process.stdout.write(hash.digest("hex"));
EOF
}
if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; then
needs_install=0
install_fingerprint_path="$paperclip_dir/pnpm-install-fingerprint"
current_install_fingerprint="$(compute_pnpm_install_fingerprint)"
previous_install_fingerprint=""
if [[ -f "$install_fingerprint_path" ]]; then
previous_install_fingerprint="$(cat "$install_fingerprint_path")"
fi
while IFS= read -r relative_path; do
[[ -n "$relative_path" ]] || continue
@@ -448,6 +500,10 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t
fi
done < <(list_base_node_modules_paths)
if [[ "$needs_install" -eq 0 && "$current_install_fingerprint" != "$previous_install_fingerprint" ]]; then
needs_install=1
fi
if [[ "$needs_install" -eq 1 ]]; then
backup_suffix=".paperclip-backup-${BASHPID:-$$}"
moved_symlink_paths=()
@@ -492,7 +548,7 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t
if (
cd "$worktree_cwd"
pnpm install "$@"
pnpm install --prod=false "$@"
) >"$stdout_path" 2>"$stderr_path"; then
cat "$stdout_path"
cat "$stderr_path" >&2
@@ -529,6 +585,8 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t
fi
cleanup_moved_symlinks
current_install_fingerprint="$(compute_pnpm_install_fingerprint)"
printf '%s\n' "$current_install_fingerprint" >"$install_fingerprint_path"
fi
exit 0
@@ -78,8 +78,10 @@ describeEmbeddedPostgres("resource membership routes", () => {
const otherCompanyId = randomUUID();
const projectId = randomUUID();
const otherProjectId = randomUUID();
const archivedProjectId = randomUUID();
const agentId = randomUUID();
const otherAgentId = randomUUID();
const terminatedAgentId = randomUUID();
await db.insert(companies).values([
{
id: companyId,
@@ -96,6 +98,7 @@ describeEmbeddedPostgres("resource membership routes", () => {
]);
await db.insert(projects).values([
{ id: projectId, companyId, name: "Growth", status: "in_progress" },
{ id: archivedProjectId, companyId, name: "Archived", status: "completed", archivedAt: new Date() },
{ id: otherProjectId, companyId: otherCompanyId, name: "Other", status: "in_progress" },
]);
await db.insert(agents).values([
@@ -121,8 +124,19 @@ describeEmbeddedPostgres("resource membership routes", () => {
runtimeConfig: {},
permissions: {},
},
{
id: terminatedAgentId,
companyId,
name: "Terminated",
role: "engineer",
status: "terminated",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
return { companyId, otherAgentId, otherProjectId, projectId, agentId };
return { archivedProjectId, companyId, otherAgentId, otherProjectId, projectId, agentId, terminatedAgentId };
}
it("defaults missing membership rows to joined", async () => {
@@ -135,6 +149,10 @@ describeEmbeddedPostgres("resource membership routes", () => {
expect(res.body).toEqual({
projectMemberships: {},
agentMemberships: {},
starredProjectIds: [],
starredAgentIds: [],
projectStarredAt: {},
agentStarredAt: {},
updatedAt: null,
});
});
@@ -151,7 +169,7 @@ describeEmbeddedPostgres("resource membership routes", () => {
.send({ state: "left" });
expect(first.status).toBe(200);
expect(first.body).toMatchObject({ resourceType: "project", resourceId: projectId, state: "left" });
expect(first.body).toMatchObject({ resourceType: "project", resourceId: projectId, state: "left", starredAt: null });
expect(second.status).toBe(200);
const rows = await db.select().from(projectMemberships);
@@ -170,6 +188,174 @@ describeEmbeddedPostgres("resource membership routes", () => {
});
});
it("stars projects idempotently and exposes starred project contract data", async () => {
const { companyId, projectId } = await seed();
const app = createApp(db, boardActor(companyId, "viewer"));
const first = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/projects/${projectId}`)
.send({ starred: true });
const second = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/projects/${projectId}`)
.send({ starred: true });
const list = await request(app).get(`/api/companies/${companyId}/resource-memberships/me`);
expect(first.status).toBe(200);
expect(first.body).toMatchObject({ resourceType: "project", resourceId: projectId, state: "joined" });
expect(first.body.starredAt).toEqual(expect.any(String));
expect(second.status).toBe(200);
expect(second.body.starredAt).toBe(first.body.starredAt);
expect(list.body.starredProjectIds).toEqual([projectId]);
expect(list.body.projectStarredAt[projectId]).toEqual(first.body.starredAt);
const rows = await db.select().from(projectMemberships);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ companyId, projectId, userId: "user-1", state: "joined" });
expect(rows[0]?.starredAt).toBeInstanceOf(Date);
const activity = await db.select().from(activityLog);
expect(activity).toHaveLength(1);
expect(activity[0]).toMatchObject({
action: "resource_membership.starred",
entityType: "project",
entityId: projectId,
});
expect(activity[0]?.details).toMatchObject({
userId: "user-1",
resourceType: "project",
resourceId: projectId,
state: "joined",
starred: true,
});
});
it("clears starred_at when leaving a starred resource", async () => {
const { companyId, projectId } = await seed();
const app = createApp(db, boardActor(companyId));
await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/projects/${projectId}`)
.send({ starred: true })
.expect(200);
const leave = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/projects/${projectId}`)
.send({ state: "left" });
expect(leave.status).toBe(200);
expect(leave.body).toMatchObject({ state: "left", starredAt: null });
const [row] = await db.select().from(projectMemberships);
expect(row).toMatchObject({ state: "left", starredAt: null });
const activity = await db.select().from(activityLog);
expect(activity.map((entry) => entry.action)).toEqual([
"resource_membership.starred",
"resource_membership.left",
]);
expect(activity[1]?.details).toMatchObject({ state: "left", starred: false, starredAt: null });
});
it("starring a left resource rejoins it", async () => {
const { companyId, agentId } = await seed();
const app = createApp(db, boardActor(companyId));
await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/agents/${agentId}`)
.send({ state: "left" })
.expect(200);
const star = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/agents/${agentId}`)
.send({ starred: true });
expect(star.status).toBe(200);
expect(star.body).toMatchObject({ resourceType: "agent", resourceId: agentId, state: "joined" });
expect(star.body.starredAt).toEqual(expect.any(String));
const [row] = await db.select().from(agentMemberships);
expect(row).toMatchObject({ state: "joined" });
expect(row?.starredAt).toBeInstanceOf(Date);
const activity = await db.select().from(activityLog);
expect(activity.map((entry) => entry.action)).toEqual([
"resource_membership.left",
"resource_membership.starred",
]);
});
it("unstars agents idempotently without requiring a state change", async () => {
const { companyId, agentId } = await seed();
const app = createApp(db, boardActor(companyId));
await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/agents/${agentId}`)
.send({ starred: true })
.expect(200);
const first = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/agents/${agentId}`)
.send({ starred: false });
const second = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/agents/${agentId}`)
.send({ starred: false });
expect(first.status).toBe(200);
expect(first.body).toMatchObject({ state: "joined", starredAt: null });
expect(second.status).toBe(200);
expect(second.body).toMatchObject({ state: "joined", starredAt: null });
const [row] = await db.select().from(agentMemberships);
expect(row).toMatchObject({ state: "joined", starredAt: null });
const activity = await db.select().from(activityLog);
expect(activity.map((entry) => entry.action)).toEqual([
"resource_membership.starred",
"resource_membership.unstarred",
]);
});
it("omits archived projects and terminated agents from starred sidebar data", async () => {
const { archivedProjectId, companyId, terminatedAgentId } = await seed();
const starredAt = new Date();
await db.insert(projectMemberships).values({
companyId,
projectId: archivedProjectId,
userId: "user-1",
state: "joined",
starredAt,
});
await db.insert(agentMemberships).values({
companyId,
agentId: terminatedAgentId,
userId: "user-1",
state: "joined",
starredAt,
});
const app = createApp(db, boardActor(companyId));
const res = await request(app).get(`/api/companies/${companyId}/resource-memberships/me`);
expect(res.status).toBe(200);
expect(res.body.projectMemberships[archivedProjectId]).toBe("joined");
expect(res.body.agentMemberships[terminatedAgentId]).toBe("joined");
expect(res.body.starredProjectIds).toEqual([]);
expect(res.body.starredAgentIds).toEqual([]);
expect(res.body.projectStarredAt).toEqual({});
expect(res.body.agentStarredAt).toEqual({});
});
it("rejects starring archived projects and terminated agents", async () => {
const { archivedProjectId, companyId, terminatedAgentId } = await seed();
const app = createApp(db, boardActor(companyId));
const projectRes = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/projects/${archivedProjectId}`)
.send({ starred: true });
const agentRes = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/agents/${terminatedAgentId}`)
.send({ starred: true });
expect(projectRes.status).toBe(404);
expect(agentRes.status).toBe(404);
});
it("rejects agent API key actors", async () => {
const { companyId, agentId } = await seed();
const app = createApp(db, {
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
import { parseGitDescribeVersion, resolveServerVersion } from "../version.js";
describe("parseGitDescribeVersion", () => {
it("reports drift from the nearest release tag as a PEP 440 local version", () => {
expect(parseGitDescribeVersion("v2026.626.0-58-g518fc71ce\n")).toBe(
"2026.626.0+58.git.518fc71ce",
);
});
it("collapses clean on-tag checkouts to the release version", () => {
expect(parseGitDescribeVersion("v2026.626.0-0-g012345678\n")).toBe("2026.626.0");
});
it("adds dirty state to the local version segment", () => {
expect(parseGitDescribeVersion("v2026.626.0-58-g518fc71ce-dirty\n")).toBe(
"2026.626.0+58.git.518fc71ce.dirty",
);
});
it("returns null for unparseable describe output so callers can fall back", () => {
expect(parseGitDescribeVersion("canary/v2026.706.0-canary.1")).toBeNull();
});
it("appends dirty suffix even when on-tag (zero commits since tag)", () => {
expect(parseGitDescribeVersion("v2026.626.0-0-g012345678-dirty\n")).toBe(
"2026.626.0+0.git.012345678.dirty",
);
});
});
describe("resolveServerVersion", () => {
it("returns the parsed git-derived version when git describe succeeds", () => {
expect(
resolveServerVersion({
packageVersion: "2026.706.0",
gitDescribeCommand: () => "v2026.626.0-58-g518fc71ce\n",
debugLog: vi.fn(),
}),
).toBe("2026.626.0+58.git.518fc71ce");
});
it("falls back to package version without throwing when git is unavailable", () => {
const debugLog = vi.fn();
expect(
resolveServerVersion({
packageVersion: "2026.706.0",
gitDescribeCommand: () => {
throw new Error("fatal: not a git repository");
},
debugLog,
}),
).toBe("2026.706.0");
expect(debugLog).toHaveBeenCalledWith(
expect.objectContaining({ reason: "git_describe_unavailable" }),
"falling back to package version for server version",
);
});
});
+95 -2
View File
@@ -1500,6 +1500,99 @@ describe("realizeExecutionWorkspace", () => {
);
}, 30_000);
it("reinstalls worktree-local pnpm dependencies when package metadata changes", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-stale-deps-"));
const baseRoot = path.join(tempRoot, "base");
const worktreeRoot = path.join(tempRoot, "worktree");
const fakeBin = path.join(tempRoot, "bin");
const fakePnpmPath = path.join(fakeBin, "pnpm");
const scriptPath = path.join(worktreeRoot, "provision-worktree.sh");
const installLogPath = path.join(tempRoot, "install.log");
try {
await fs.mkdir(path.join(baseRoot, "node_modules"), { recursive: true });
await fs.mkdir(path.join(worktreeRoot, "node_modules"), { recursive: true });
await fs.mkdir(path.join(worktreeRoot, "ui"), { recursive: true });
await fs.mkdir(fakeBin, { recursive: true });
await fs.copyFile(provisionWorktreeScriptPath, scriptPath);
await fs.chmod(scriptPath, 0o755);
await fs.writeFile(
path.join(worktreeRoot, "package.json"),
JSON.stringify(
{
name: "workspace-root",
private: true,
packageManager: "pnpm@9.15.4",
},
null,
2,
),
"utf8",
);
await fs.writeFile(
path.join(worktreeRoot, "pnpm-lock.yaml"),
["lockfileVersion: '9.0'", "", "importers:", " .: {}", ""].join("\n"),
"utf8",
);
await fs.writeFile(
path.join(worktreeRoot, "ui", "package.json"),
JSON.stringify({ name: "ui", private: true, dependencies: {} }, null, 2),
"utf8",
);
await fs.writeFile(
fakePnpmPath,
[
"#!/bin/sh",
"if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then",
" exit 1",
"fi",
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--prod=false\" ] && [ \"$3\" = \"--frozen-lockfile\" ]; then",
" mkdir -p \"$PWD/node_modules\"",
` echo "install:$*" >> ${JSON.stringify(installLogPath)}`,
" exit 0",
"fi",
"exit 1",
"",
].join("\n"),
"utf8",
);
await fs.chmod(fakePnpmPath, 0o755);
const runScript = () => execFileAsync(scriptPath, [], {
cwd: worktreeRoot,
env: {
...process.env,
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
PAPERCLIP_WORKSPACE_BASE_CWD: baseRoot,
PAPERCLIP_WORKSPACE_CWD: worktreeRoot,
},
});
await runScript();
await runScript();
await expect(fs.readFile(installLogPath, "utf8")).resolves.toBe(
"install:install --prod=false --frozen-lockfile\n",
);
await fs.writeFile(
path.join(worktreeRoot, "ui", "package.json"),
JSON.stringify(
{ name: "ui", private: true, dependencies: { "@xterm/addon-fit": "^0.11.0" } },
null,
2,
),
"utf8",
);
await runScript();
await expect(fs.readFile(installLogPath, "utf8")).resolves.toBe(
"install:install --prod=false --frozen-lockfile\ninstall:install --prod=false --frozen-lockfile\n",
);
} finally {
await fs.rm(tempRoot, { recursive: true, force: true });
}
}, 30_000);
it("fails instead of writing an unseeded fallback config when worktree init errors after CLI detection succeeds", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-provision-fail-"));
const baseRoot = path.join(tempRoot, "base");
@@ -1686,11 +1779,11 @@ describe("realizeExecutionWorkspace", () => {
"if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then",
" exit 1",
"fi",
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--frozen-lockfile\" ]; then",
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--prod=false\" ] && [ \"$3\" = \"--frozen-lockfile\" ]; then",
" echo \"ERR_PNPM_OUTDATED_LOCKFILE\" >&2",
" exit 1",
"fi",
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--no-frozen-lockfile\" ]; then",
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--prod=false\" ] && [ \"$3\" = \"--no-frozen-lockfile\" ]; then",
" mkdir -p \"$PWD/node_modules\"",
" : > \"$PWD/node_modules/.retry-success\"",
" exit 0",
+3 -8
View File
@@ -7,6 +7,7 @@ import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
import {
agentSkillSyncSchema,
agentMineInboxQuerySchema,
ADAPTER_AGNOSTIC_KEYS,
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
createAgentKeySchema,
createAgentHireSchema,
@@ -170,6 +171,7 @@ export function agentRoutes(
"instructionsFilePath",
"agentsMdPath",
] as const;
const KNOWN_INSTRUCTIONS_BUNDLE_KEY_SET: ReadonlySet<string> = new Set(KNOWN_INSTRUCTIONS_BUNDLE_KEYS);
const router = Router();
const svc = agentService(db);
@@ -2871,15 +2873,8 @@ export function agentRoutes(
// Preserve adapter-agnostic keys (env, cwd, etc.) from the existing config
// when the adapter type changes. Without this, a PATCH that includes
// adapterConfig but omits these keys would silently drop them.
// `paperclipSkillSync` holds the agent's desired-skill selection, which is
// a company-level (adapter-agnostic) choice even though it is persisted
// inside the per-adapter config; switching adapters must not wipe it.
const ADAPTER_AGNOSTIC_KEYS = [
"env", "cwd", "timeoutSec", "graceSec",
"promptTemplate", "bootstrapPromptTemplate",
"paperclipSkillSync",
] as const;
for (const key of ADAPTER_AGNOSTIC_KEYS) {
if (KNOWN_INSTRUCTIONS_BUNDLE_KEY_SET.has(key)) continue;
if (rawEffectiveAdapterConfig[key] === undefined && existingAdapterConfig[key] !== undefined) {
rawEffectiveAdapterConfig = { ...rawEffectiveAdapterConfig, [key]: existingAdapterConfig[key] };
}
+15 -5
View File
@@ -22,6 +22,8 @@ async function logMembershipChange(
resourceType: "project" | "agent";
resourceId: string;
state: "joined" | "left";
starredAt: Date | null;
changeKind: "joined" | "left" | "starred" | "unstarred";
policySource: string;
},
) {
@@ -32,7 +34,7 @@ async function logMembershipChange(
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: `resource_membership.${input.state}`,
action: `resource_membership.${input.changeKind}`,
entityType: input.resourceType,
entityId: input.resourceId,
details: {
@@ -40,6 +42,8 @@ async function logMembershipChange(
resourceType: input.resourceType,
resourceId: input.resourceId,
state: input.state,
starredAt: input.starredAt,
starred: input.starredAt !== null,
policySource: input.policySource,
},
});
@@ -69,19 +73,22 @@ export function resourceMembershipRoutes(db: Db) {
projectId,
userId,
state: req.body.state,
starred: req.body.starred,
actor: req.actor,
});
if (result.changed) {
if (result.changed && result.changeKind) {
await logMembershipChange(db, req, {
companyId,
userId,
resourceType: "project",
resourceId: projectId,
state: result.state,
starredAt: result.starredAt,
changeKind: result.changeKind,
policySource: result.policySource,
});
}
const { changed: _changed, policySource: _policySource, ...response } = result;
const { changed: _changed, changeKind: _changeKind, policySource: _policySource, ...response } = result;
res.json(response);
},
);
@@ -99,19 +106,22 @@ export function resourceMembershipRoutes(db: Db) {
agentId,
userId,
state: req.body.state,
starred: req.body.starred,
actor: req.actor,
});
if (result.changed) {
if (result.changed && result.changeKind) {
await logMembershipChange(db, req, {
companyId,
userId,
resourceType: "agent",
resourceId: agentId,
state: result.state,
starredAt: result.starredAt,
changeKind: result.changeKind,
policySource: result.policySource,
});
}
const { changed: _changed, policySource: _policySource, ...response } = result;
const { changed: _changed, changeKind: _changeKind, policySource: _policySource, ...response } = result;
res.json(response);
},
);
+126 -30
View File
@@ -41,12 +41,21 @@ export type ResourceMembershipPolicyHook = (input: {
resourceType: ResourceMembershipResourceType;
resourceId: string;
state: ResourceMembershipState;
starred?: boolean;
}) => Promise<PolicyDecision> | PolicyDecision;
type ResourceMembershipServiceOptions = {
policyHook?: ResourceMembershipPolicyHook | null;
};
type MembershipChangeKind = ResourceMembershipState | "starred" | "unstarred";
type MembershipUpdateResult = ResourceMembershipUpdateResult & {
changed: boolean;
changeKind: MembershipChangeKind | null;
policySource: string;
};
function defaultJoinedMap<T extends { projectId?: string; agentId?: string; state: string }>(
rows: T[],
key: "projectId" | "agentId",
@@ -60,6 +69,30 @@ function defaultJoinedMap<T extends { projectId?: string; agentId?: string; stat
return result;
}
function starredAtMap<T extends { projectId?: string; agentId?: string; starredAt: Date | null }>(
rows: T[],
key: "projectId" | "agentId",
): Record<string, Date> {
const result: Record<string, Date> = {};
for (const row of rows) {
const id = row[key];
if (typeof id !== "string" || !row.starredAt) continue;
result[id] = row.starredAt;
}
return result;
}
function starredIds<T extends { projectId?: string; agentId?: string; starredAt: Date | null }>(
rows: T[],
key: "projectId" | "agentId",
): string[] {
return rows
.filter((row) => row.starredAt)
.sort((a, b) => b.starredAt!.getTime() - a.starredAt!.getTime())
.map((row) => row[key])
.filter((id): id is string => typeof id === "string");
}
function latestDate(...dates: Array<Date | null | undefined>): Date | null {
let latest: Date | null = null;
for (const date of dates) {
@@ -116,6 +149,7 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
resourceType: ResourceMembershipResourceType;
resourceId: string;
state: ResourceMembershipState;
starred?: boolean;
}): Promise<PolicyDecision> {
assertBoardSelfMembershipAccess(input.actor, input.companyId, input.userId);
const decision = await evaluatePolicy(policyHook, input);
@@ -144,9 +178,15 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
.select({
projectId: projectMemberships.projectId,
state: projectMemberships.state,
starredAt: projectMemberships.starredAt,
updatedAt: projectMemberships.updatedAt,
projectArchivedAt: projects.archivedAt,
})
.from(projectMemberships)
.innerJoin(projects, and(
eq(projects.id, projectMemberships.projectId),
eq(projects.companyId, projectMemberships.companyId),
))
.where(and(
eq(projectMemberships.companyId, companyId),
eq(projectMemberships.userId, userId),
@@ -155,17 +195,29 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
.select({
agentId: agentMemberships.agentId,
state: agentMemberships.state,
starredAt: agentMemberships.starredAt,
updatedAt: agentMemberships.updatedAt,
agentStatus: agents.status,
})
.from(agentMemberships)
.innerJoin(agents, and(
eq(agents.id, agentMemberships.agentId),
eq(agents.companyId, agentMemberships.companyId),
))
.where(and(
eq(agentMemberships.companyId, companyId),
eq(agentMemberships.userId, userId),
)),
]);
const starEligibleProjectRows = projectRows.filter((row) => row.starredAt && !row.projectArchivedAt);
const starEligibleAgentRows = agentRows.filter((row) => row.starredAt && row.agentStatus !== "terminated");
return {
projectMemberships: defaultJoinedMap(projectRows, "projectId"),
agentMemberships: defaultJoinedMap(agentRows, "agentId"),
starredProjectIds: starredIds(starEligibleProjectRows, "projectId"),
starredAgentIds: starredIds(starEligibleAgentRows, "agentId"),
projectStarredAt: starredAtMap(starEligibleProjectRows, "projectId"),
agentStarredAt: starredAtMap(starEligibleAgentRows, "agentId"),
updatedAt: latestDate(
...projectRows.map((row) => row.updatedAt),
...agentRows.map((row) => row.updatedAt),
@@ -177,24 +229,17 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
companyId: string;
userId: string;
projectId: string;
state: ResourceMembershipState;
state?: ResourceMembershipState;
starred?: boolean;
actor: BoardActor;
}): Promise<ResourceMembershipUpdateResult & { changed: boolean; policySource: string }> {
}): Promise<MembershipUpdateResult> {
const project = await db.query.projects.findFirst({
where: and(
eq(projects.id, input.projectId),
eq(projects.companyId, input.companyId),
),
});
if (!project) throw notFound("Project not found");
const decision = await assertMutationAllowed({
actor: input.actor,
companyId: input.companyId,
userId: input.userId,
resourceType: "project",
resourceId: input.projectId,
state: input.state,
});
if (!project || project.archivedAt) throw notFound("Project not found");
const existing = await db.query.projectMemberships.findFirst({
where: and(
@@ -204,13 +249,36 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
),
});
const previousState: ResourceMembershipState = existing?.state === "left" ? "left" : "joined";
if (previousState === input.state) {
const previousStarredAt = existing?.starredAt ?? null;
const nextState: ResourceMembershipState = input.starred === true ? "joined" : input.state ?? previousState;
const nextStarredAt = nextState === "left"
? null
: input.starred === true
? previousStarredAt ?? new Date()
: input.starred === false
? null
: previousStarredAt;
const stateChanged = previousState !== nextState;
const starredChanged = (previousStarredAt?.getTime() ?? null) !== (nextStarredAt?.getTime() ?? null);
const decision = await assertMutationAllowed({
actor: input.actor,
companyId: input.companyId,
userId: input.userId,
resourceType: "project",
resourceId: input.projectId,
state: nextState,
starred: input.starred,
});
if (!stateChanged && !starredChanged) {
return {
resourceType: "project",
resourceId: input.projectId,
state: input.state,
state: nextState,
starredAt: previousStarredAt,
updatedAt: existing?.updatedAt ?? new Date(),
changed: false,
changeKind: null,
policySource: decision.source ?? "oss_default",
};
}
@@ -222,13 +290,15 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
companyId: input.companyId,
projectId: input.projectId,
userId: input.userId,
state: input.state,
state: nextState,
starredAt: nextStarredAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [projectMemberships.companyId, projectMemberships.userId, projectMemberships.projectId],
set: {
state: input.state,
state: nextState,
starredAt: nextStarredAt,
updatedAt: now,
},
})
@@ -238,8 +308,12 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
resourceType: "project",
resourceId: input.projectId,
state: row?.state === "left" ? "left" : "joined",
starredAt: row?.starredAt ?? null,
updatedAt: row?.updatedAt ?? now,
changed: true,
changeKind: input.starred !== undefined && starredChanged
? input.starred ? "starred" : "unstarred"
: stateChanged ? nextState : nextStarredAt ? "starred" : "unstarred",
policySource: decision.source ?? "oss_default",
};
},
@@ -248,24 +322,17 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
companyId: string;
userId: string;
agentId: string;
state: ResourceMembershipState;
state?: ResourceMembershipState;
starred?: boolean;
actor: BoardActor;
}): Promise<ResourceMembershipUpdateResult & { changed: boolean; policySource: string }> {
}): Promise<MembershipUpdateResult> {
const agent = await db.query.agents.findFirst({
where: and(
eq(agents.id, input.agentId),
eq(agents.companyId, input.companyId),
),
});
if (!agent) throw notFound("Agent not found");
const decision = await assertMutationAllowed({
actor: input.actor,
companyId: input.companyId,
userId: input.userId,
resourceType: "agent",
resourceId: input.agentId,
state: input.state,
});
if (!agent || agent.status === "terminated") throw notFound("Agent not found");
const existing = await db.query.agentMemberships.findFirst({
where: and(
@@ -275,13 +342,36 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
),
});
const previousState: ResourceMembershipState = existing?.state === "left" ? "left" : "joined";
if (previousState === input.state) {
const previousStarredAt = existing?.starredAt ?? null;
const nextState: ResourceMembershipState = input.starred === true ? "joined" : input.state ?? previousState;
const nextStarredAt = nextState === "left"
? null
: input.starred === true
? previousStarredAt ?? new Date()
: input.starred === false
? null
: previousStarredAt;
const stateChanged = previousState !== nextState;
const starredChanged = (previousStarredAt?.getTime() ?? null) !== (nextStarredAt?.getTime() ?? null);
const decision = await assertMutationAllowed({
actor: input.actor,
companyId: input.companyId,
userId: input.userId,
resourceType: "agent",
resourceId: input.agentId,
state: nextState,
starred: input.starred,
});
if (!stateChanged && !starredChanged) {
return {
resourceType: "agent",
resourceId: input.agentId,
state: input.state,
state: nextState,
starredAt: previousStarredAt,
updatedAt: existing?.updatedAt ?? new Date(),
changed: false,
changeKind: null,
policySource: decision.source ?? "oss_default",
};
}
@@ -293,13 +383,15 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
companyId: input.companyId,
agentId: input.agentId,
userId: input.userId,
state: input.state,
state: nextState,
starredAt: nextStarredAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [agentMemberships.companyId, agentMemberships.userId, agentMemberships.agentId],
set: {
state: input.state,
state: nextState,
starredAt: nextStarredAt,
updatedAt: now,
},
})
@@ -309,8 +401,12 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
resourceType: "agent",
resourceId: input.agentId,
state: row?.state === "left" ? "left" : "joined",
starredAt: row?.starredAt ?? null,
updatedAt: row?.updatedAt ?? now,
changed: true,
changeKind: input.starred !== undefined && starredChanged
? input.starred ? "starred" : "unstarred"
: stateChanged ? nextState : nextStarredAt ? "starred" : "unstarred",
policySource: decision.source ?? "oss_default",
};
},
+71 -3
View File
@@ -1,10 +1,78 @@
import { createRequire } from "node:module";
import { execFileSync } from "node:child_process";
type PackageJson = {
version?: string;
};
const require = createRequire(import.meta.url);
const pkg = require("../package.json") as PackageJson;
type GitDescribeCommand = () => string;
type DebugLog = (fields: Record<string, unknown>, message: string) => void;
export const serverVersion = pkg.version ?? "0.0.0";
const requirePackage = createRequire(import.meta.url);
const pkg = requirePackage("../package.json") as PackageJson;
const GIT_DESCRIBE_RE =
/^v(?<publicVersion>\d+\.\d+\.\d+)-(?<commitsSinceTag>\d+)-g(?<sha>[0-9a-f]{7,40})(?<dirty>-dirty)?$/i;
function defaultDebugLog(fields: Record<string, unknown>, message: string): void {
console.debug(message, fields);
}
function defaultGitDescribeCommand(): string {
return execFileSync(
"git",
["describe", "--tags", "--match", "v*", "--long", "--dirty"],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 1500,
},
);
}
export function parseGitDescribeVersion(output: string): string | null {
const match = output.trim().match(GIT_DESCRIBE_RE);
if (!match?.groups) return null;
const publicVersion = match.groups.publicVersion;
const commitsSinceTag = match.groups.commitsSinceTag;
const sha = match.groups.sha;
const isDirty = Boolean(match.groups.dirty);
if (commitsSinceTag === "0" && !isDirty) {
return publicVersion;
}
return `${publicVersion}+${commitsSinceTag}.git.${sha}${isDirty ? ".dirty" : ""}`;
}
export function resolveServerVersion(
opts: {
gitDescribeCommand?: GitDescribeCommand;
packageVersion?: string;
debugLog?: DebugLog;
} = {},
): string {
const packageVersion = opts.packageVersion ?? pkg.version ?? "0.0.0";
const gitDescribeCommand = opts.gitDescribeCommand ?? defaultGitDescribeCommand;
const debugLog = opts.debugLog ?? defaultDebugLog;
try {
const parsedVersion = parseGitDescribeVersion(gitDescribeCommand());
if (parsedVersion) return parsedVersion;
debugLog(
{ reason: "invalid_git_describe" },
"falling back to package version for server version",
);
} catch (err) {
debugLog(
{ err, reason: "git_describe_unavailable" },
"falling back to package version for server version",
);
}
return packageVersion;
}
export const serverVersion = resolveServerVersion();
Binary file not shown.
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
# Bundled UI Fonts
Paperclip bundles Inter for the board UI so screenshots and packaged installs use
the same sans-serif text stack without relying on host font packages.
## Inter
- Upstream project: https://github.com/rsms/inter
- Version: v4.1
- Source files:
- https://raw.githubusercontent.com/rsms/inter/v4.1/docs/font-files/InterVariable.woff2
- https://raw.githubusercontent.com/rsms/inter/v4.1/docs/font-files/InterVariable-Italic.woff2
- License: SIL Open Font License 1.1
- License text: https://github.com/rsms/inter/blob/v4.1/LICENSE.txt
Redistribution note: Inter is redistributed under the SIL Open Font License 1.1.
The bundled WOFF2 files are included unmodified from the upstream v4.1 release.
+2
View File
@@ -8,6 +8,7 @@ import { OnboardingWizardVariant } from "./components/OnboardingWizardVariant";
import { CloudAccessGate } from "./components/CloudAccessGate";
import { Dashboard } from "./pages/Dashboard";
import { DashboardLive } from "./pages/DashboardLive";
import { Timeline } from "./pages/Timeline";
import { Companies } from "./pages/Companies";
import { Agents } from "./pages/Agents";
import { AgentDetail } from "./pages/AgentDetail";
@@ -80,6 +81,7 @@ function boardRoutes() {
<Route index element={<Navigate to="dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="dashboard/live" element={<DashboardLive />} />
<Route path="timeline" element={<Timeline />} />
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} />
+31
View File
@@ -0,0 +1,31 @@
import type { WorkTimelineResult } from "@paperclipai/shared";
import { api } from "./client";
export interface WorkTimelineParams {
from?: string;
to?: string;
/** lens: work kicked off / touched by this user. */
userId?: string;
goalId?: string;
projectId?: string;
issueId?: string;
limit?: number;
}
function query(params: WorkTimelineParams): string {
const search = new URLSearchParams();
if (params.from) search.set("from", params.from);
if (params.to) search.set("to", params.to);
if (params.userId) search.set("userId", params.userId);
if (params.goalId) search.set("goalId", params.goalId);
if (params.projectId) search.set("projectId", params.projectId);
if (params.issueId) search.set("issueId", params.issueId);
if (params.limit) search.set("limit", String(params.limit));
const qs = search.toString();
return qs ? `?${qs}` : "";
}
export const workTimelineApi = {
get: (companyId: string, params: WorkTimelineParams = {}) =>
api.get<WorkTimelineResult>(`/companies/${companyId}/timeline${query(params)}`),
};
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarProvider, useSidebar } from "../context/SidebarContext";
@@ -30,11 +30,18 @@ function Harness({ onRoute }: { onRoute: boolean }) {
);
}
function render(onRoute: boolean): { root: Root; host: HTMLDivElement } {
async function flushReact() {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
flushSync(() => {});
}
async function render(onRoute: boolean): Promise<{ root: Root; host: HTMLDivElement }> {
const host = document.createElement("div");
document.body.appendChild(host);
const root = createRoot(host);
act(() => root.render(<Harness onRoute={onRoute} />));
flushSync(() => root.render(<Harness onRoute={onRoute} />));
await flushReact();
return { root, host };
}
@@ -68,45 +75,47 @@ describe("RequestCollapsedSidebar", () => {
afterEach(() => {
if (active) {
act(() => active!.root.unmount());
flushSync(() => active!.root.unmount());
active.host.remove();
active = null;
}
localStorage.clear();
});
it("requests collapsed while mounted when there is no user pin", () => {
active = render(true);
it("requests collapsed while mounted when there is no user pin", async () => {
active = await render(true);
expect(capturedValue?.routeRequestsCollapsed).toBe(true);
expect(capturedValue?.collapsed).toBe(true);
});
it("lets an explicit user pin override the route request", () => {
active = render(true);
it("lets an explicit user pin override the route request", async () => {
active = await render(true);
expect(capturedValue?.collapsed).toBe(true);
// User explicitly pins expanded — must win over the route's request.
act(() => capturedValue?.setCollapsed(false));
flushSync(() => capturedValue?.setCollapsed(false));
expect(capturedValue?.routeRequestsCollapsed).toBe(true);
expect(capturedValue?.collapsed).toBe(false);
});
it("clears the request on unmount, restoring the global default", () => {
active = render(true);
it("clears the request on unmount, restoring the global default", async () => {
active = await render(true);
expect(capturedValue?.collapsed).toBe(true);
// Navigate away: the route (and its <RequestCollapsedSidebar/>) unmounts.
act(() => active!.root.render(<Harness onRoute={false} />));
flushSync(() => active!.root.render(<Harness onRoute={false} />));
await flushReact();
expect(capturedValue?.routeRequestsCollapsed).toBe(false);
expect(capturedValue?.collapsed).toBe(false);
});
it("keeps a user pin after navigating away (pin persists, request cleared)", () => {
active = render(true);
act(() => capturedValue?.setCollapsed(true));
it("keeps a user pin after navigating away (pin persists, request cleared)", async () => {
active = await render(true);
flushSync(() => capturedValue?.setCollapsed(true));
expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("1");
act(() => active!.root.render(<Harness onRoute={false} />));
flushSync(() => active!.root.render(<Harness onRoute={false} />));
await flushReact();
// Route request gone, but the explicit collapsed pin still applies.
expect(capturedValue?.routeRequestsCollapsed).toBe(false);
expect(capturedValue?.collapsed).toBe(true);
+30 -11
View File
@@ -100,6 +100,10 @@ vi.mock("./SidebarProjects", () => ({
SidebarProjects: () => <div data-testid="sidebar-projects">Projects collapsible</div>,
}));
vi.mock("./SidebarStarredProjects", () => ({
SidebarStarredProjects: () => <div data-testid="sidebar-starred-projects" />,
}));
async function flushReact() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
@@ -223,27 +227,24 @@ describe("Sidebar", () => {
});
});
it("classic (flag OFF): New Task button, Tasks label, per-project collapsible, no top-level Projects link", async () => {
it("streamlined is now standard: a stale enableStreamlinedLeftNavigation=false opt-out is ignored", async () => {
// PAP-12472 retired the experimental opt-out; the streamlined sidebar is the
// only path, so an old `false` setting no longer restores classic mode.
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableStreamlinedLeftNavigation: false,
});
const root = await renderSidebar();
expect(container.textContent).toContain("New Task");
expect(container.textContent).not.toContain("New Issue");
const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim());
expect(navLabels).toContain("Tasks");
expect(navLabels).not.toContain("Issues");
// No top-level Projects nav link in classic mode (D5 option A).
expect(navLabels).not.toContain("Projects");
// Per-project collapsible restored below Work.
expect(container.querySelector('[data-testid="sidebar-projects"]')).not.toBeNull();
// Top-level Projects link + starred children stay, per-project collapsible gone.
expect(navLabels).toContain("Projects");
expect(container.querySelector('[data-testid="sidebar-projects"]')).toBeNull();
expect(container.querySelector('[data-testid="sidebar-starred-projects"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
).toBe("false");
).toBe("true");
flushSync(() => {
root.unmount();
@@ -310,6 +311,24 @@ describe("Sidebar", () => {
});
});
it("places Timeline in the Company section", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
const root = await renderSidebar();
const sections = [...container.querySelectorAll("nav > div")];
const workSection = sections.find((section) => section.textContent?.startsWith("Work"));
const companySection = sections.find((section) => section.textContent?.startsWith("Company"));
expect(workSection?.textContent).not.toContain("Timeline");
expect(companySection?.textContent).toContain("Timeline");
const timelineLink = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Timeline");
expect(timelineLink?.getAttribute("href")).toBe("/timeline");
flushSync(() => {
root.unmount();
});
});
it("shows the Conference Room nav item when conference room chat is enabled (PAP-137)", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
+13 -7
View File
@@ -18,6 +18,7 @@ import {
PanelLeftOpen,
Pin,
MessagesSquare,
GanttChartSquare,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { NavLink } from "@/lib/router";
@@ -25,6 +26,7 @@ import { SidebarSection } from "./SidebarSection";
import { SidebarNavItem } from "./SidebarNavItem";
import { SidebarAgents } from "./SidebarAgents";
import { SidebarProjects } from "./SidebarProjects";
import { SidebarStarredProjects } from "./SidebarStarredProjects";
import { useDialogActions } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
@@ -58,12 +60,12 @@ export function Sidebar() {
const liveRunCount = liveRuns?.length ?? 0;
const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true;
const showPipelines = experimentalSettings?.enablePipelines === true;
// IA flag: branch the sidebar nav presentation. Default ON =
// streamlined (top-level Projects link). Users can opt out in experiments to
// get classic (per-project collapsible, no Projects nav link). Issue/Task
// wording is split to PR #7651. Gating is navigation-only; all routes stay
// registered in both modes.
const streamlined = experimentalSettings?.enableStreamlinedLeftNavigation !== false;
// Streamlined left navigation (top-level Projects link + starred children) is
// now the standard product sidebar (PAP-12472). The former experimental
// opt-out was retired; classic per-project collapsible mode is no longer
// user-selectable. Kept as a constant so the classic branch below stays as a
// documented reference until it is fully removed. Routes are unaffected.
const streamlined = true;
// Conference Room Chat flag (PAP-136/PAP-137): the Conference Room nav item
// is a new surface, hidden entirely while the flag is off (same no-flash
// pattern as showWorkspacesLink above).
@@ -186,7 +188,10 @@ export function Sidebar() {
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
) : null}
{streamlined ? (
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
<>
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
<SidebarStarredProjects />
</>
) : null}
<PluginSlotOutlet
slotTypes={["sidebar"]}
@@ -210,6 +215,7 @@ export function Sidebar() {
<SidebarSection label="Company">
<SidebarNavItem to="/org" label="Org" icon={Network} />
<SidebarNavItem to="/timeline" label="Timeline" icon={GanttChartSquare} />
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
<SidebarNavItem to="/activity" label="Activity" icon={History} />
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
+94 -2
View File
@@ -233,18 +233,28 @@ describe("SidebarAgents", () => {
};
mockResourceMembershipsApi.listMine.mockImplementation(() => Promise.resolve(memberships));
mockResourceMembershipsApi.updateAgent.mockImplementation((_companyId, agentId, data) => {
const previousState = memberships.agentMemberships[agentId] ?? "joined";
const nextState = data.starred === true ? "joined" : data.state ?? previousState;
const starredAgentIds = memberships.starredAgentIds ?? [];
const nextStarredAgentIds = data.starred === true
? starredAgentIds.includes(agentId) ? starredAgentIds : [agentId, ...starredAgentIds]
: data.starred === false || nextState === "left"
? starredAgentIds.filter((id) => id !== agentId)
: starredAgentIds;
memberships = {
...memberships,
agentMemberships: {
...memberships.agentMemberships,
[agentId]: data.state,
[agentId]: nextState,
},
starredAgentIds: nextStarredAgentIds,
updatedAt: new Date(),
};
return Promise.resolve({
resourceType: "agent",
resourceId: agentId,
state: data.state,
state: nextState,
starredAt: data.starred === true ? new Date() : null,
});
});
localStorage.clear();
@@ -329,6 +339,88 @@ describe("SidebarAgents", () => {
expect(container.querySelector('button[aria-label="Agents section actions"]')).toBeNull();
});
it("pins starred agents at the top without subheadings and dedupes them from the recent list", async () => {
mockAgentsApi.list.mockResolvedValue([
makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }),
makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }),
]);
memberships = {
projectMemberships: {},
agentMemberships: {},
starredProjectIds: [],
starredAgentIds: ["agent-b"],
projectStarredAt: {},
agentStarredAt: {},
updatedAt: new Date(),
};
await renderSidebarAgents();
expect(container.textContent).not.toContain("Starred");
expect(container.textContent).not.toContain("Recently active");
// Bravo is starred -> shown once at the top, deduped from recent.
const labels = agentLinkLabels(container);
expect(labels.filter((label) => label === "Bravo")).toHaveLength(1);
expect(labels).toContain("Alpha");
// Starred order lands the starred agent first.
expect(labels[0]).toBe("Bravo");
// The starred row offers an explicit "Remove from starred" menu action.
await openAgentMenu("Open actions for Bravo");
expect(document.body.textContent).toContain("Remove from starred");
});
it("offers star agent from an unstarred sidebar agent menu", async () => {
await renderSidebarAgents();
await openAgentMenu();
const starItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("Star agent"));
expect(starItem).toBeTruthy();
await act(async () => {
starItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockResourceMembershipsApi.updateAgent).toHaveBeenCalledWith(
"company-1",
"agent-1",
{ state: undefined, starred: true },
);
expect(document.body.querySelector('button[aria-label="Unstar Alpha"]')).not.toBeNull();
});
it("keeps the agent starred and toasts when an unstar request fails", async () => {
mockAgentsApi.list.mockResolvedValue([makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" })]);
memberships = {
projectMemberships: {},
agentMemberships: { "agent-b": "joined" },
starredProjectIds: [],
starredAgentIds: ["agent-b"],
projectStarredAt: {},
agentStarredAt: {},
updatedAt: new Date(),
};
mockResourceMembershipsApi.updateAgent.mockRejectedValue(new Error("nope"));
await renderSidebarAgents();
const unstar = document.body.querySelector('button[aria-label="Unstar Bravo"]');
expect(unstar).not.toBeNull();
await act(async () => {
unstar?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
// Optimistic unstar is rolled back → the row stays in the starred group.
expect(document.body.querySelector('button[aria-label="Unstar Bravo"]')).not.toBeNull();
expect(mockPushToast).toHaveBeenCalledWith(
expect.objectContaining({ tone: "error" }),
);
});
it("keeps top mode in stored org-aware order", async () => {
localStorage.setItem("paperclip.agentOrder:company-1:user-1", JSON.stringify(["agent-b", "agent-a", "agent-c"]));
mockAgentsApi.list.mockResolvedValue([
+112 -22
View File
@@ -9,6 +9,7 @@ import {
Pencil,
PlayCircle,
Plus,
Star,
Users,
AlertTriangle,
} from "lucide-react";
@@ -23,7 +24,13 @@ import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { queryKeys } from "../lib/queryKeys";
import { cn, agentRouteRef, agentUrl, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import { useAgentOrder } from "../hooks/useAgentOrder";
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
import {
isStarred,
resourceMembershipState,
starredResourceIds,
useResourceMembershipMutation,
useResourceMemberships,
} from "../hooks/useResourceMemberships";
import {
AGENT_SORT_MODE_UPDATED_EVENT,
getAgentSortModeStorageKey,
@@ -35,6 +42,7 @@ import {
import { AgentIcon } from "./AgentIconPicker";
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection";
import { StarToggle } from "./StarToggle";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -87,6 +95,10 @@ function sortAgents(agents: Agent[], sortMode: AgentSidebarSortMode): Agent[] {
return sorted;
}
// Sidebar star reveals with the agent row's own group, not the shared group.
const AGENT_STAR_ROW_REVEAL =
"opacity-0 transition-opacity group-hover/agent:opacity-100 group-focus-within/agent:opacity-100";
function SidebarAgentItem({
activeAgentId,
activeTab,
@@ -99,6 +111,9 @@ function SidebarAgentItem({
rail,
runCount,
setSidebarOpen,
starred = false,
onToggleStar,
starPending = false,
}: {
activeAgentId: string | null;
activeTab: string | null;
@@ -111,6 +126,9 @@ function SidebarAgentItem({
rail: boolean;
runCount: number;
setSidebarOpen: (open: boolean) => void;
starred?: boolean;
onToggleStar?: (agent: Agent, starred: boolean) => void;
starPending?: boolean;
}) {
const routeRef = agentRouteRef(agent);
const href = activeTab ? `${agentUrl(agent)}/${activeTab}` : agentUrl(agent);
@@ -137,7 +155,9 @@ function SidebarAgentItem({
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 pr-8 text-[13px] font-medium transition-colors",
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 text-[13px] font-medium transition-colors",
// Reserve room for the ⋯ menu, plus the inline unstar star on starred rows.
starred && !isMobile ? "pr-14" : "pr-8",
isActive
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
@@ -187,6 +207,21 @@ function SidebarAgentItem({
link
)}
{!rail && starred && !isMobile && onToggleStar ? (
// Desktop: quiet inline unstar, left of the ⋯ menu, revealed on hover/focus.
<span className="absolute right-8 top-1/2 -translate-y-1/2">
<StarToggle
size="row"
quiet
starred
pending={starPending}
resourceName={agent.name}
onToggle={() => onToggleStar(agent, false)}
revealClassName={AGENT_STAR_ROW_REVEAL}
/>
</span>
) : null}
{!rail && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -204,7 +239,26 @@ function SidebarAgentItem({
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuContent align="end" className="w-48">
{onToggleStar ? (
<>
<DropdownMenuItem
onClick={() => {
if (starPending) return;
onToggleStar(agent, !starred);
}}
disabled={starPending}
>
{starPending ? (
<Loader2 className="size-4 motion-safe:animate-spin" />
) : (
<Star className={cn("size-4", starred && "fill-amber-500 text-amber-500")} />
)}
<span>{starred ? "Remove from starred" : "Star agent"}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuItem asChild>
<Link
to={editHref}
@@ -441,6 +495,59 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
[membershipMutation.isPending, membershipMutation.variables],
);
const toggleStarAgent = useCallback(
(agent: Agent, starred: boolean) => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
starred,
}),
[membershipMutation],
);
const agentStarPending = useCallback(
(agent: Agent) =>
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id &&
membershipMutation.variables.starred !== undefined,
[membershipMutation.isPending, membershipMutation.variables],
);
// Starred agents pin to the top of the section (name order), and are deduped
// out of the active/recent subset so no agent appears twice.
const starredAgentIdSet = useMemo(
() => new Set(starredResourceIds(membershipsQuery.data, "agent")),
[membershipsQuery.data],
);
const starredAgents = useMemo(
() => sortAgents(visibleAgents.filter((agent: Agent) => starredAgentIdSet.has(agent.id)), "alphabetical"),
[visibleAgents, starredAgentIdSet],
);
const dedupedDisplayedAgents = useMemo(
() => displayedAgents.filter((agent: Agent) => !starredAgentIdSet.has(agent.id)),
[displayedAgents, starredAgentIdSet],
);
const renderAgentRow = (agent: Agent, isStarredRow: boolean) => (
<SidebarAgentItem
key={agent.id}
activeAgentId={activeAgentId}
activeTab={activeTab}
agent={agent}
disabled={pendingAgentIds.has(agent.id)}
isMobile={isMobile}
leaving={agentLeaving(agent)}
onLeaveAgent={leaveAgent}
onPauseResume={(targetAgent, action) => pauseResumeAgent.mutate({ agent: targetAgent, action })}
rail={rail}
runCount={liveCountByAgent.get(agent.id) ?? 0}
setSidebarOpen={setSidebarOpen}
starred={isStarredRow || isStarred(membershipsQuery.data, "agent", agent.id)}
onToggleStar={toggleStarAgent}
starPending={agentStarPending(agent)}
/>
);
return (
<SidebarSection
label="Agents"
@@ -462,25 +569,8 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
onRadioValueChange: persistSortMode,
}}
>
{displayedAgents.map((agent: Agent) => {
const runCount = liveCountByAgent.get(agent.id) ?? 0;
return (
<SidebarAgentItem
key={agent.id}
activeAgentId={activeAgentId}
activeTab={activeTab}
agent={agent}
disabled={pendingAgentIds.has(agent.id)}
isMobile={isMobile}
leaving={agentLeaving(agent)}
onLeaveAgent={leaveAgent}
onPauseResume={(targetAgent, action) => pauseResumeAgent.mutate({ agent: targetAgent, action })}
rail={rail}
runCount={runCount}
setSidebarOpen={setSidebarOpen}
/>
);
})}
{starredAgents.map((agent: Agent) => renderAgentRow(agent, true))}
{dedupedDisplayedAgents.map((agent: Agent) => renderAgentRow(agent, false))}
{showSeeAllLink && (() => {
const seeAllLink = (
<Link
@@ -0,0 +1,187 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Project, ResourceMemberships } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarStarredProjects } from "./SidebarStarredProjects";
import { TooltipProvider } from "@/components/ui/tooltip";
const mockProjectsApi = vi.hoisted(() => ({ list: vi.fn() }));
const mockResourceMembershipsApi = vi.hoisted(() => ({ listMine: vi.fn(), updateProject: vi.fn() }));
const mockPushToast = vi.hoisted(() => vi.fn());
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
const mockSidebarState = vi.hoisted(() => ({ isMobile: false, collapsed: false, peeking: false }));
vi.mock("@/lib/router", () => ({
NavLink: ({ children, className, to, ...props }: {
children: ReactNode;
className?: string | ((state: { isActive: boolean }) => string);
to: string;
}) => (
<a href={to} className={typeof className === "function" ? className({ isActive: false }) : className} {...props}>
{children}
</a>
),
useLocation: () => ({ pathname: "/PAP/dashboard", search: "", hash: "", state: null }),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { id: "company-1", issuePrefix: "PAP" } }),
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({
isMobile: mockSidebarState.isMobile,
setSidebarOpen: mockSetSidebarOpen,
collapsed: mockSidebarState.collapsed,
peeking: mockSidebarState.peeking,
}),
}));
vi.mock("../context/ToastContext", () => ({
useToastActions: () => ({ pushToast: mockPushToast }),
}));
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
vi.mock("../api/resourceMemberships", () => ({ resourceMembershipsApi: mockResourceMembershipsApi }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
function makeProject(overrides: Partial<Project>): Project {
return {
id: "project-a",
companyId: "company-1",
urlKey: "alpha",
goalId: null,
goalIds: [],
goals: [],
name: "Alpha",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#ef4444",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/project-a",
effectiveLocalFolder: "/tmp/project-a",
origin: "local_folder",
},
workspaces: [],
primaryWorkspace: null,
managedByPlugin: null,
archivedAt: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
};
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
function projectLinkLabels(container: HTMLElement) {
return Array.from(container.querySelectorAll('a[href$="/issues"]'))
.map((anchor) => anchor.textContent?.trim())
.filter(Boolean);
}
describe("SidebarStarredProjects", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;
let queryClient: QueryClient;
let memberships: ResourceMemberships;
beforeEach(() => {
mockSidebarState.isMobile = false;
mockSidebarState.collapsed = false;
mockSidebarState.peeking = false;
container = document.createElement("div");
document.body.appendChild(container);
root = null;
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
memberships = {
projectMemberships: {},
agentMemberships: {},
starredProjectIds: [],
starredAgentIds: [],
projectStarredAt: {},
agentStarredAt: {},
updatedAt: null,
};
mockResourceMembershipsApi.listMine.mockImplementation(() => Promise.resolve(memberships));
mockProjectsApi.list.mockResolvedValue([]);
});
afterEach(async () => {
if (root) await act(async () => root?.unmount());
queryClient.clear();
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
async function render() {
root = createRoot(container);
await act(async () => {
root?.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<SidebarStarredProjects />
</TooltipProvider>
</QueryClientProvider>,
);
});
await flushReact();
}
it("renders only starred, non-archived projects with a quiet unstar control", async () => {
mockProjectsApi.list.mockResolvedValue([
makeProject({ id: "project-a", name: "Alpha", urlKey: "alpha" }),
makeProject({ id: "project-b", name: "Bravo", urlKey: "bravo" }),
makeProject({ id: "project-c", name: "Ghost", urlKey: "ghost", archivedAt: new Date() }),
]);
memberships = { ...memberships, starredProjectIds: ["project-b", "project-c"] };
await render();
// Only the starred, non-archived project renders (archived "Ghost" is filtered out).
expect(projectLinkLabels(container)).toEqual(["Bravo"]);
expect(document.body.querySelector('button[aria-label="Unstar Bravo"]')).not.toBeNull();
});
it("renders nothing when no projects are starred", async () => {
mockProjectsApi.list.mockResolvedValue([makeProject({ id: "project-a", name: "Alpha" })]);
await render();
expect(container.textContent).not.toContain("No starred projects yet");
expect(projectLinkLabels(container)).toEqual([]);
});
});
@@ -0,0 +1,217 @@
import { useCallback, useMemo } from "react";
import { NavLink, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { Loader2, LogOut, MoreHorizontal, Star } from "lucide-react";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
import { projectsApi } from "../api/projects";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { queryKeys } from "../lib/queryKeys";
import { cn, projectRouteRef, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import {
isStarred,
starredResourceIds,
useResourceMembershipMutation,
useResourceMemberships,
} from "../hooks/useResourceMemberships";
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
import { ProjectTile } from "./ProjectTile";
import { StarToggle } from "./StarToggle";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { Project } from "@paperclipai/shared";
// Sidebar star reveals with the row's own group, not the shared unnamed group.
const STAR_ROW_REVEAL =
"opacity-0 transition-opacity group-hover/starred-project:opacity-100 group-focus-within/starred-project:opacity-100";
/**
* Compact starred-project children rendered directly below the top-level
* `Projects` nav row in the streamlined sidebar. Starring/unstarring itself
* happens from browse/detail surfaces; here we only ever *remove* a star
* (plus the existing leave affordance). Archived projects are filtered out
* server-side, so a stale star never resurrects a hidden project.
*/
export function SidebarStarredProjects() {
const { selectedCompanyId } = useCompany();
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
const rail = collapsed && !peeking;
const location = useLocation();
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const membershipsQuery = useResourceMemberships(selectedCompanyId);
const membershipMutation = useResourceMembershipMutation(selectedCompanyId);
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
const activeProjectRef = projectMatch?.[1] ?? null;
const starredProjects = useMemo(() => {
if (!membershipsQuery.isSuccess) return [];
const starredIds = new Set(starredResourceIds(membershipsQuery.data, "project"));
if (starredIds.size === 0) return [];
const byId = new Map((projects ?? []).map((project: Project) => [project.id, project]));
return Array.from(starredIds)
.map((id) => byId.get(id))
.filter((project): project is Project => !!project && !project.archivedAt)
.sort((left, right) =>
left.name.localeCompare(right.name, undefined, { sensitivity: "base" }),
);
}, [membershipsQuery.data, membershipsQuery.isSuccess, projects]);
const unstar = useCallback(
(project: Project) => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
starred: false,
}),
[membershipMutation],
);
const leave = useCallback(
(project: Project) => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
state: "left",
}),
[membershipMutation],
);
const pendingFor = useCallback(
(project: Project) =>
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "project" &&
membershipMutation.variables.resourceId === project.id,
[membershipMutation.isPending, membershipMutation.variables],
);
// Don't render anything until memberships load — no skeleton flash in the nav.
if (!membershipsQuery.isSuccess) return null;
// Empty starred groups should not add a placeholder row or extra sidebar spacing.
if (starredProjects.length === 0) {
return null;
}
return (
<div className="flex flex-col gap-0.5" aria-label="Starred projects">
{starredProjects.map((project) => {
const routeRef = projectRouteRef(project);
const isActive = activeProjectRef === routeRef || activeProjectRef === project.id;
const pending = pendingFor(project);
const unstarPending = pending && membershipMutation.variables?.starred === false;
const leavePending = pending && membershipMutation.variables?.state === "left";
const starred = isStarred(membershipsQuery.data, "project", project.id);
const link = (
<NavLink
to={`/projects/${routeRef}/issues`}
state={SIDEBAR_SCROLL_RESET_STATE}
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pl-8 pointer-coarse:py-1 pr-8 text-[13px] font-medium transition-colors",
isActive
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
)}
>
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "flex-1 truncate"}>{project.name}</span>
{!rail && project.pauseReason === "budget" ? (
<BudgetSidebarMarker title="Project paused by budget" />
) : null}
</NavLink>
);
return (
<div key={project.id} className="group/starred-project relative flex items-center">
{rail ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="min-w-0 flex-1">{link}</div>
</TooltipTrigger>
<TooltipContent side="right">{project.name}</TooltipContent>
</Tooltip>
) : (
link
)}
{!rail && !isMobile ? (
// Desktop: quiet inline unstar revealed on hover/focus.
<span className="absolute right-1 top-1/2 -translate-y-1/2">
<StarToggle
size="row"
quiet
starred={starred}
pending={unstarPending}
resourceName={project.name}
onToggle={() => unstar(project)}
revealClassName={STAR_ROW_REVEAL}
/>
</span>
) : null}
{!rail && isMobile ? (
// Touch: explicit ⋯ menu (no hover). Star action + separated Leave.
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 opacity-100"
aria-label={`Open actions for ${project.name}`}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={() => {
if (pending) return;
unstar(project);
}}
disabled={pending}
>
{unstarPending ? (
<Loader2 className="size-4 motion-safe:animate-spin" />
) : (
<Star className="size-4 fill-amber-500 text-amber-500" />
)}
<span>Remove from starred</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
if (pending) return;
leave(project);
}}
disabled={pending}
>
{leavePending ? (
<Loader2 className="size-4 motion-safe:animate-spin" />
) : (
<LogOut className="size-4" />
)}
<span>Leave project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
);
})}
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StarToggle } from "./StarToggle";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
describe("StarToggle", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
});
afterEach(async () => {
if (root) {
await act(async () => { root?.unmount(); });
}
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
async function render(node: React.ReactElement) {
root = createRoot(container);
await act(async () => {
root?.render(node);
});
}
function button() {
return container.querySelector("button");
}
it("labels and announces the unstarred state and toggles toward starred", async () => {
const onToggle = vi.fn();
await render(<StarToggle starred={false} resourceName="Alpha" onToggle={onToggle} />);
const btn = button();
expect(btn?.getAttribute("aria-label")).toBe("Star Alpha");
expect(btn?.getAttribute("aria-pressed")).toBe("false");
await act(async () => { btn?.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
expect(onToggle).toHaveBeenCalledWith(true);
});
it("labels and announces the starred state and toggles toward unstarred", async () => {
const onToggle = vi.fn();
await render(<StarToggle starred resourceName="Alpha" onToggle={onToggle} />);
const btn = button();
expect(btn?.getAttribute("aria-label")).toBe("Unstar Alpha");
expect(btn?.getAttribute("aria-pressed")).toBe("true");
// A starred (non-quiet) row control is visible at rest.
expect(btn?.className).toContain("opacity-100");
await act(async () => { btn?.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
expect(onToggle).toHaveBeenCalledWith(false);
});
it("stays quiet (hidden at rest) for a starred sidebar row", async () => {
await render(
<StarToggle starred quiet resourceName="Alpha" onToggle={() => {}} revealClassName="reveal-me" />,
);
const btn = button();
// Quiet: even starred, hidden at rest and revealed via the passed class.
expect(btn?.className).toContain("reveal-me");
expect(btn?.className).not.toContain("opacity-100");
});
it("blocks input and spins while pending", async () => {
const onToggle = vi.fn();
await render(<StarToggle starred={false} pending resourceName="Alpha" onToggle={onToggle} />);
const btn = button();
expect(btn?.hasAttribute("disabled")).toBe(true);
expect(btn?.getAttribute("aria-busy")).toBe("true");
await act(async () => { btn?.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
expect(onToggle).not.toHaveBeenCalled();
});
it("surfaces a retry affordance on error for the button variant", async () => {
await render(<StarToggle starred size="button" error resourceName="Alpha" onToggle={() => {}} />);
const btn = button();
expect(btn?.textContent).toContain("Retry star");
expect(btn?.getAttribute("title")).toBe("Couldn't save — retry");
});
it("renders the labelled Star/Starred button variant", async () => {
await render(<StarToggle starred size="button" resourceName="Alpha" onToggle={() => {}} />);
expect(button()?.textContent).toContain("Starred");
});
});
+132
View File
@@ -0,0 +1,132 @@
import type { MouseEvent } from "react";
import { Loader2, Star } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "../lib/utils";
export interface StarToggleProps {
/** Whether the resource is currently starred (post-optimistic value). */
starred: boolean;
/** Human-readable resource name, used for the accessible label. */
resourceName: string;
/** Optimistic mutation in flight — shows a spinner and blocks input. */
pending?: boolean;
/** Last mutation failed — surface a retry affordance (red star). */
error?: boolean;
/**
* "row" — quiet icon-only control for sidebar and browse-list rows.
* "button" — labelled Star/Starred button for detail headers.
*/
size?: "row" | "button";
/** Called with the desired next starred value. */
onToggle: (nextStarred: boolean) => void;
/**
* Row variant only: keep the control hidden at rest even when starred, so it
* only appears on hover/focus. Sidebar rows are "intentionally quiet"; browse
* rows keep the starred control visible.
*/
quiet?: boolean;
/** Extra classes for the control itself. */
className?: string;
/**
* Row variant only: classes that control at-rest visibility when the resource
* is not starred (e.g. reveal on hover/focus). Ignored when starred (a starred
* control is always visible) or on the button variant. Defaults to the shared
* unnamed-`group` reveal used by browse rows; sidebar passes a named-group
* variant so it reveals with `group/project` / `group/agent`.
*/
revealClassName?: string;
}
const DEFAULT_ROW_REVEAL =
"opacity-100 sm:opacity-0 sm:transition-opacity sm:group-hover:opacity-100 sm:group-focus-within:opacity-100";
export function StarToggle({
starred,
resourceName,
pending = false,
error = false,
size = "row",
onToggle,
quiet = false,
className,
revealClassName,
}: StarToggleProps) {
const ariaLabel = starred ? `Unstar ${resourceName}` : `Star ${resourceName}`;
const Icon = pending ? Loader2 : Star;
function handleClick(event: MouseEvent<HTMLButtonElement>) {
event.preventDefault();
event.stopPropagation();
if (pending) return;
// On error, retry the last intent (toggle toward the desired end state).
onToggle(!starred);
}
if (size === "button") {
const label = pending ? "Saving..." : error ? "Retry star" : starred ? "Starred" : "Star";
return (
<Button
type="button"
size="sm"
variant="outline"
aria-label={ariaLabel}
aria-pressed={starred}
aria-busy={pending ? "true" : undefined}
disabled={pending}
onClick={handleClick}
title={error ? "Couldn't save — retry" : undefined}
className={cn(
error
? "text-red-500 hover:text-red-500"
: starred
? "text-amber-600 dark:text-amber-500"
: undefined,
className,
)}
>
<Icon
className={cn(
"h-4 w-4",
pending && "motion-safe:animate-spin",
!pending && starred && !error && "fill-amber-500 text-amber-500",
!pending && error && "text-red-500",
)}
/>
<span>{label}</span>
</Button>
);
}
// Row variant: a starred (or errored) control is always visible; an unstarred
// one is quiet and revealed on hover/focus so the nav stays calm. When `quiet`
// (sidebar), even a starred control hides at rest and reveals on hover/focus.
const visible = error || pending || (starred && !quiet);
return (
<Button
type="button"
size="icon-xs"
variant="ghost"
aria-label={ariaLabel}
aria-pressed={starred}
aria-busy={pending ? "true" : undefined}
disabled={pending}
onClick={handleClick}
title={error ? "Couldn't save — retry" : undefined}
className={cn(
"h-6 w-6 shrink-0",
visible ? "opacity-100" : revealClassName ?? DEFAULT_ROW_REVEAL,
className,
)}
>
<Icon
className={cn(
"h-3.5 w-3.5",
pending && "motion-safe:animate-spin",
!pending && error && "text-red-500",
!pending && !error && starred && "fill-amber-500 text-amber-500",
!pending && !error && !starred && "text-muted-foreground",
)}
/>
</Button>
);
}
@@ -0,0 +1,454 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import type { ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { WorkTimelineResult } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WorkTimelineChart } from "./WorkTimelineChart";
import { computeLayout } from "@/lib/timeline/layout";
vi.mock("@/lib/router", () => ({
useLocation: () => ({ pathname: "/PAP/timeline" }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
vi.restoreAllMocks();
});
function renderChart(
data: WorkTimelineResult,
props: Partial<ComponentProps<typeof WorkTimelineChart>> = {},
) {
flushSync(() => {
root.render(
<WorkTimelineChart
data={data}
zoom="hour"
nowMs={new Date("2026-07-02T12:00:00.000Z").getTime()}
{...props}
/>,
);
});
}
function timelineSample(): WorkTimelineResult {
return {
actors: [
{ id: "agent:codex", type: "agent", name: "CodexCoder" },
{ id: "agent:qa", type: "agent", name: "QA" },
],
spans: [
{
actorId: "agent:codex",
laneHint: null,
runId: "run-1",
issueId: "issue-1",
issueIdentifier: "PAP-12443",
issueTitle: "Work Timeline sticky gutter",
start: "2026-07-02T09:00:00.000Z",
end: "2026-07-02T10:00:00.000Z",
status: "completed",
retryOfRunId: null,
},
{
actorId: "agent:qa",
laneHint: null,
runId: "run-2",
issueId: "issue-2",
issueIdentifier: "PAP-12426",
issueTitle: "QA validation",
start: "2026-07-02T11:00:00.000Z",
end: "2026-07-02T11:30:00.000Z",
status: "completed",
retryOfRunId: null,
},
],
events: [],
edges: [],
pagination: { limit: 200, offset: 0, totalIssues: 2, hasMore: false },
window: {
from: "2026-07-02T00:00:00.000Z",
to: "2026-07-03T00:00:00.000Z",
capped: false,
},
};
}
describe("WorkTimelineChart", () => {
it("renders date-aware AM/PM labels on the header axis", () => {
renderChart(timelineSample());
const timeAxis = container.querySelector<HTMLElement>("[data-testid='work-timeline-time-axis']");
expect(timeAxis?.textContent).toContain("Jul 2");
expect(timeAxis?.textContent).toContain("AM");
expect(timeAxis?.textContent).not.toContain("09:00");
});
it("freezes the time axis over vertical scrolling while preserving horizontal alignment", async () => {
renderChart(timelineSample());
const scroller = container.querySelector<HTMLElement>("[data-testid='work-timeline-scroll']")!;
const timeAxis = container.querySelector<HTMLElement>("[data-testid='work-timeline-time-axis']")!;
const axisSvg = timeAxis.querySelector<SVGSVGElement>("svg")!;
expect(timeAxis.getAttribute("class")).toContain("absolute");
expect(timeAxis.getAttribute("class")).toContain("top-0");
expect(timeAxis.style.height).toBe("32px");
expect(axisSvg.style.transform).toBe("translateX(0px)");
flushSync(() => {
Object.defineProperty(scroller, "scrollTop", { configurable: true, value: 400 });
Object.defineProperty(scroller, "scrollLeft", { configurable: true, value: 240 });
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(timeAxis.textContent).toContain("Jul 2");
expect(axisSvg.style.transform).toBe("translateX(-240px)");
});
it("renders actor labels in a sticky gutter outside the horizontally scrolling SVG", () => {
renderChart(timelineSample());
const scroller = container.querySelector<HTMLElement>("[data-testid='work-timeline-scroll']");
const gutter = container.querySelector<SVGSVGElement>("[data-testid='work-timeline-actor-gutter']");
const chartSvg = container.querySelector<SVGSVGElement>("svg.absolute");
expect(scroller).not.toBeNull();
expect(gutter).not.toBeNull();
expect(chartSvg).not.toBeNull();
expect(gutter?.getAttribute("class")).toContain("sticky");
expect(gutter?.getAttribute("class")).toContain("left-0");
expect(gutter?.getAttribute("class")).not.toContain("top-0");
expect(gutter?.getAttribute("width")).toBe("176");
expect(chartSvg?.getAttribute("width")).not.toBe(gutter?.getAttribute("width"));
expect(gutter?.textContent).toContain("CodexCoder");
flushSync(() => {
scroller!.scrollLeft = 10_000;
scroller!.dispatchEvent(new Event("scroll", { bubbles: true }));
});
expect(container.querySelector("[data-testid='work-timeline-actor-gutter']")?.textContent).toContain("CodexCoder");
});
it("does not render created diamonds or comment bubbles from instant events", () => {
const data = timelineSample();
data.actors.push({ id: "user:dotta", type: "user", name: "Dotta" });
data.events = [
{ actorId: "user:dotta", kind: "created", issueId: "issue-1", at: "2026-07-02T08:30:00.000Z" },
{ actorId: "user:dotta", kind: "commented", issueId: "issue-2", at: "2026-07-02T09:15:00.000Z" },
{ actorId: "user:dotta", kind: "approved", issueId: "issue-1", at: "2026-07-02T10:05:00.000Z" },
];
renderChart(data);
const gutter = container.querySelector<SVGSVGElement>("[data-testid='work-timeline-actor-gutter']");
expect(gutter?.textContent).not.toContain("Dotta");
expect(container.querySelectorAll("[data-testid='timeline-event-marker']")).toHaveLength(0);
expect(container.querySelectorAll("[data-testid='timeline-comment-marker']")).toHaveLength(0);
});
it("keeps connectors hidden until hover, renders them orthogonally, and highlights the connected graph", async () => {
const data = timelineSample();
data.actors.push({ id: "agent:cto", type: "agent", name: "CTO" });
data.spans.push(
{
actorId: "agent:cto",
laneHint: null,
runId: "run-3",
issueId: "issue-3",
issueIdentifier: "PAP-12427",
issueTitle: "Follow-up validation",
start: "2026-07-02T11:45:00.000Z",
end: "2026-07-02T12:00:00.000Z",
status: "completed",
retryOfRunId: null,
},
{
actorId: "agent:codex",
laneHint: null,
runId: "run-4",
issueId: "issue-4",
issueIdentifier: "PAP-12428",
issueTitle: "Unrelated work",
start: "2026-07-02T13:00:00.000Z",
end: "2026-07-02T14:00:00.000Z",
status: "completed",
retryOfRunId: null,
},
);
data.edges = [
{
fromActorId: "agent:codex",
toActorId: "agent:qa",
issueId: "issue-2",
at: "2026-07-02T10:45:00.000Z",
kind: "delegation",
},
{
fromActorId: "agent:qa",
toActorId: "agent:cto",
issueId: "issue-3",
at: "2026-07-02T11:35:00.000Z",
kind: "delegation",
},
];
renderChart(data);
expect(container.querySelectorAll("[data-testid='timeline-connector']")).toHaveLength(0);
const hovered = container.querySelector<SVGGElement>("[data-run-id='run-2']")!;
flushSync(() => {
hovered.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, clientX: 100, clientY: 100 }));
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(container.querySelectorAll("[data-testid='timeline-connector']")).toHaveLength(2);
const connectorStrokePaths = Array.from(
container.querySelectorAll<SVGPathElement>("[data-testid='timeline-connector'] path[fill='none']"),
);
expect(connectorStrokePaths.map((path) => path.getAttribute("d"))).toEqual(
expect.arrayContaining([expect.stringMatching(/ V.+ H/)]),
);
expect(container.querySelector("[data-run-id='run-1']")?.getAttribute("data-connected-state")).toBe("connected");
expect(container.querySelector("[data-run-id='run-2']")?.getAttribute("data-connected-state")).toBe("connected");
expect(container.querySelector("[data-run-id='run-3']")?.getAttribute("data-connected-state")).toBe("connected");
expect(container.querySelector("[data-run-id='run-4']")?.getAttribute("data-connected-state")).toBe("faded");
const layout = computeLayout(data, {
gutter: 176,
rowH: 34,
barH: 15,
laneGap: 4,
pxPerMinute: 8,
nowMs: new Date("2026-07-02T12:00:00.000Z").getTime(),
});
expect(layout.connectors).toMatchObject([
{ sourceRunId: "run-1", targetRunId: "run-2", dashed: false },
{ sourceRunId: "run-2", targetRunId: "run-3", dashed: false },
]);
const bars = new Map(layout.rows.flatMap((row) => row.bars.map((bar) => [bar.span.runId, bar])));
expect(layout.connectors[0].x1).toBe(bars.get("run-1")?.x2);
expect(layout.connectors[0].x2).toBe(bars.get("run-2")?.x1);
});
it("renders kickoff chips for human users but not delegating agents", () => {
const data = timelineSample();
data.actors.push({ id: "user:dotta", type: "user", name: "Dotta" });
data.edges = [
{
fromActorId: "user:dotta",
toActorId: "agent:codex",
issueId: "issue-1",
at: "2026-07-02T08:45:00.000Z",
kind: "delegation",
},
{
fromActorId: "agent:codex",
toActorId: "agent:qa",
issueId: "issue-2",
at: "2026-07-02T10:45:00.000Z",
kind: "delegation",
},
];
renderChart(data);
const kickoffChips = container.querySelectorAll("[data-testid='timeline-kickoff-chip']");
expect(kickoffChips).toHaveLength(1);
expect(kickoffChips[0].textContent).toContain("DO");
});
it("reserves normal wheel input for panning and uses modifier-wheel for continuous zoom", () => {
const onZoomScaleChange = vi.fn();
renderChart(timelineSample(), { onZoomScaleChange });
const scroller = container.querySelector<HTMLElement>("[data-testid='work-timeline-scroll']")!;
flushSync(() => {
scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: 80, bubbles: true, cancelable: true }));
});
expect(onZoomScaleChange).not.toHaveBeenCalled();
flushSync(() => {
scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: 80, ctrlKey: true, bubbles: true, cancelable: true }));
});
expect(onZoomScaleChange).toHaveBeenCalledTimes(1);
});
it("opens task bars in a new company-prefixed window", () => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
renderChart(timelineSample());
const bar = container.querySelector<SVGGElement>("[data-run-id='run-1']")!;
flushSync(() => {
bar.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(open).toHaveBeenCalledWith("/PAP/issues/issue-1", "_blank", "noopener,noreferrer");
});
it("lets minimap edge handles resize the visible range and update zoom", () => {
const onZoomScaleChange = vi.fn();
renderChart(timelineSample(), { onZoomScaleChange });
const rightHandle = container.querySelector<SVGRectElement>("[data-testid='timeline-minimap-right-handle']")!;
const minimap = rightHandle.ownerSVGElement!;
vi.spyOn(minimap, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 900,
bottom: 54,
width: 900,
height: 54,
toJSON: () => ({}),
});
flushSync(() => {
rightHandle.dispatchEvent(new MouseEvent("mousedown", { clientX: 300, bubbles: true, cancelable: true }));
document.dispatchEvent(new MouseEvent("mousemove", { clientX: 520, bubbles: true, cancelable: true }));
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
});
expect(onZoomScaleChange).toHaveBeenCalled();
});
it("cleans up chart drag listeners when unmounted mid-drag", () => {
const add = vi.spyOn(document, "addEventListener");
const remove = vi.spyOn(document, "removeEventListener");
renderChart(timelineSample(), { onZoomScaleChange: vi.fn() });
const chartSvg = container.querySelector<SVGSVGElement>("svg.absolute")!;
vi.spyOn(chartSvg, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 1000,
bottom: 400,
width: 1000,
height: 400,
toJSON: () => ({}),
});
flushSync(() => {
chartSvg.dispatchEvent(new MouseEvent("mousedown", { clientX: 260, bubbles: true, cancelable: true }));
});
expect(add).toHaveBeenCalledWith("mousemove", expect.any(Function));
expect(add).toHaveBeenCalledWith("mouseup", expect.any(Function));
flushSync(() => root.unmount());
root = createRoot(container);
expect(remove).toHaveBeenCalledWith("mousemove", expect.any(Function));
expect(remove).toHaveBeenCalledWith("mouseup", expect.any(Function));
});
it("cleans up minimap drag listeners when unmounted mid-drag", () => {
const add = vi.spyOn(document, "addEventListener");
const remove = vi.spyOn(document, "removeEventListener");
renderChart(timelineSample(), { onZoomScaleChange: vi.fn() });
const rightHandle = container.querySelector<SVGRectElement>("[data-testid='timeline-minimap-right-handle']")!;
const minimap = rightHandle.ownerSVGElement!;
vi.spyOn(minimap, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 900,
bottom: 54,
width: 900,
height: 54,
toJSON: () => ({}),
});
flushSync(() => {
rightHandle.dispatchEvent(new MouseEvent("mousedown", { clientX: 300, bubbles: true, cancelable: true }));
});
expect(add).toHaveBeenCalledWith("mousemove", expect.any(Function));
expect(add).toHaveBeenCalledWith("mouseup", expect.any(Function));
flushSync(() => root.unmount());
root = createRoot(container);
expect(remove).toHaveBeenCalledWith("mousemove", expect.any(Function));
expect(remove).toHaveBeenCalledWith("mouseup", expect.any(Function));
});
it("keeps the default now timestamp stable across rerenders", () => {
const now = new Date("2026-07-02T12:00:00.000Z").getTime();
const later = new Date("2026-07-02T13:00:00.000Z").getTime();
let currentNow = now;
vi.spyOn(Date, "now").mockImplementation(() => currentNow);
const data = timelineSample();
data.spans[0] = {
...data.spans[0],
end: null,
status: "running",
};
renderChart(data, { nowMs: undefined });
const initialWidth = container
.querySelector<SVGRectElement>("[data-run-id='run-1'] rect")
?.getAttribute("width");
currentNow = later;
renderChart(data, { nowMs: undefined });
expect(container.querySelector<SVGRectElement>("[data-run-id='run-1'] rect")?.getAttribute("width")).toBe(initialWidth);
});
it("lets dragging the chart grid select a time range to zoom into", () => {
const onZoomScaleChange = vi.fn();
renderChart(timelineSample(), { onZoomScaleChange });
const chartSvg = container.querySelector<SVGSVGElement>("svg.absolute")!;
const width = Number(chartSvg.getAttribute("width") ?? "1000");
const height = Number(chartSvg.getAttribute("height") ?? "400");
vi.spyOn(chartSvg, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: width,
bottom: height,
width,
height,
toJSON: () => ({}),
});
flushSync(() => {
chartSvg.dispatchEvent(new MouseEvent("mousedown", { clientX: 260, bubbles: true, cancelable: true }));
document.dispatchEvent(new MouseEvent("mousemove", { clientX: 520, bubbles: true, cancelable: true }));
});
expect(container.querySelector("[data-testid='timeline-drag-selection']")).not.toBeNull();
flushSync(() => {
document.dispatchEvent(new MouseEvent("mouseup", { clientX: 520, bubbles: true, cancelable: true }));
});
expect(onZoomScaleChange).toHaveBeenCalled();
expect(container.querySelector("[data-testid='timeline-drag-selection']")).toBeNull();
});
});
@@ -0,0 +1,902 @@
/**
* Work Timeline — custom-SVG Gantt (board-locked Direction C, PAP-12422).
*
* Renders actor rows with concurrency sub-lanes, run bars (no issue IDs on the
* bar — identity is the thin left colour tab; truncated title shows on hover),
* human kickoff chips at each bar's leading edge, straight
* hover-revealed agent→agent delegation connectors (dashed for retries), an
* in-progress fade to "now", a hover tooltip, and a full-window mini-map with a
* draggable brush.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "@/lib/router";
import type { WorkTimelineActor, WorkTimelineResult } from "@paperclipai/shared";
import { applyCompanyPrefix, extractCompanyPrefixFromPath } from "@/lib/company-routes";
import {
AXIS_H,
actorType,
barColor,
chooseTickStepMs,
computeLayout,
formatDuration,
isCancelledStatus,
shortLabel,
TIMELINE_COLORS,
type LayoutOptions,
type PositionedBar,
} from "@/lib/timeline/layout";
export type ZoomLevel = "hour" | "day" | "week";
const ZOOM_DURATION_MIN: Record<ZoomLevel, number> = {
hour: 60,
day: 24 * 60,
week: 7 * 24 * 60,
};
const MIN_PX_PER_MIN = 0.08;
const MAX_PX_PER_MIN = 12;
const DEFAULT_VIEWPORT_W = 960;
const MIN_MINIMAP_SELECTION_MS = 15 * 60 * 1000;
function plotViewportWidth(viewportWidth: number): number {
return Math.max(240, viewportWidth - GEOM.gutter - 24);
}
export function zoomScaleForLevel(level: ZoomLevel, viewportWidth = DEFAULT_VIEWPORT_W): number {
return clampZoomScale(plotViewportWidth(viewportWidth) / ZOOM_DURATION_MIN[level]);
}
export function nearestZoomForScale(pxPerMinute: number, viewportWidth = DEFAULT_VIEWPORT_W): ZoomLevel {
return (Object.entries(ZOOM_DURATION_MIN) as [ZoomLevel, number][]).reduce<ZoomLevel>((best, [level]) => (
Math.abs(zoomScaleForLevel(level, viewportWidth) - pxPerMinute)
< Math.abs(zoomScaleForLevel(best, viewportWidth) - pxPerMinute)
? level
: best
), "day");
}
export function clampZoomScale(pxPerMinute: number): number {
return Math.min(MAX_PX_PER_MIN, Math.max(MIN_PX_PER_MIN, pxPerMinute));
}
/** Pick an initial zoom whose plotted width comfortably fills a typical viewport. */
export function defaultZoomForWindow(fromMs: number, toMs: number): ZoomLevel {
const hours = (toMs - fromMs) / 3_600_000;
if (hours <= 4) return "hour";
if (hours <= 48) return "day";
return "week";
}
const GEOM: Omit<LayoutOptions, "pxPerMinute" | "nowMs"> = {
gutter: 176,
rowH: 34,
barH: 15,
laneGap: 4,
};
const AVATAR_R = 11;
const CHIP_R = 9;
interface TooltipState {
x: number;
y: number;
bar: PositionedBar;
connectorHint: string | null;
}
interface DragSelectionState {
anchorX: number;
currentX: number;
}
function fmtClock(ms: number): string {
const d = new Date(ms);
const hasMinutes = d.getMinutes() !== 0;
return d.toLocaleTimeString("en-US", {
hour: "numeric",
minute: hasMinutes ? "2-digit" : undefined,
hour12: true,
});
}
function fmtTick(ms: number, stepMs: number): string {
const d = new Date(ms);
const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
if (stepMs >= 24 * 60 * 60 * 1000) {
return date;
}
return `${date}, ${fmtClock(ms)}`;
}
export function formatVisibleDurationMinutes(minutes: number): string {
const rounded = Math.max(1, Math.round(minutes));
if (rounded >= 7 * 24 * 60 && rounded % (7 * 24 * 60) === 0) {
const weeks = rounded / (7 * 24 * 60);
return `${weeks} week${weeks === 1 ? "" : "s"} visible`;
}
if (rounded >= 24 * 60 && rounded % (24 * 60) === 0) {
const days = rounded / (24 * 60);
return `${days} day${days === 1 ? "" : "s"} visible`;
}
if (rounded >= 24 * 60) {
const days = Math.floor(rounded / (24 * 60));
const hours = Math.round((rounded % (24 * 60)) / 60);
return `${days}d${hours > 0 ? ` ${hours}h` : ""} visible`;
}
if (rounded >= 60 && rounded % 60 === 0) {
const hours = rounded / 60;
return `${hours} hour${hours === 1 ? "" : "s"} visible`;
}
if (rounded >= 60) return `${Math.floor(rounded / 60)}h ${rounded % 60}m visible`;
return `${rounded} minutes visible`;
}
function truncate(text: string, n = 42): string {
return text.length > n ? `${text.slice(0, n - 1)}…` : text;
}
/** An SVG avatar glyph: square for humans, dashed circle for system, circle for agents. */
function AvatarGlyph({
cx,
cy,
r,
label,
type,
}: {
cx: number;
cy: number;
r: number;
label: string;
type: string;
}) {
const stroke = "var(--color-foreground)";
const fill = type === "system" ? "var(--color-muted)" : "var(--color-card)";
return (
<g>
{type === "user" ? (
<rect x={cx - r} y={cy - r} width={2 * r} height={2 * r} rx={3} fill={fill} stroke={stroke} strokeWidth={1.5} />
) : (
<circle
cx={cx}
cy={cy}
r={r}
fill={fill}
stroke={stroke}
strokeWidth={1.5}
strokeDasharray={type === "system" ? "3 2" : undefined}
/>
)}
<text x={cx} y={cy + 3.4} fontSize={r > 10 ? 9 : 8} textAnchor="middle" fill={stroke}>
{label}
</text>
</g>
);
}
export interface WorkTimelineChartProps {
data: WorkTimelineResult;
zoom: ZoomLevel;
zoomScale?: number;
onZoomScaleChange?: (nextScale: number, nextZoom: ZoomLevel) => void;
onVisibleRangeLabelChange?: (label: string) => void;
/** override "now" (tests / stories); defaults to Date.now(). */
nowMs?: number;
}
export function WorkTimelineChart({
data,
zoom,
zoomScale,
onZoomScaleChange,
onVisibleRangeLabelChange,
nowMs,
}: WorkTimelineChartProps) {
const location = useLocation();
const scrollRef = useRef<HTMLDivElement>(null);
const initialWindowKeyRef = useRef<string | null>(null);
const centerMsRef = useRef<number | null>(null);
const defaultNowRef = useRef<number | null>(null);
const documentDragCleanupRef = useRef<(() => void) | null>(null);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const [hoveredRunId, setHoveredRunId] = useState<string | null>(null);
const [scrollLeft, setScrollLeft] = useState(0);
const [viewportW, setViewportW] = useState(0);
const [dragSelection, setDragSelection] = useState<DragSelectionState | null>(null);
const clearDocumentDrag = () => {
documentDragCleanupRef.current?.();
documentDragCleanupRef.current = null;
};
const setDocumentDrag = (move: (event: MouseEvent) => void, up: (event: MouseEvent) => void) => {
clearDocumentDrag();
const handleUp = (event: MouseEvent) => {
clearDocumentDrag();
up(event);
};
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", handleUp);
documentDragCleanupRef.current = () => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", handleUp);
};
};
useEffect(() => () => clearDocumentDrag(), []);
if (defaultNowRef.current == null) defaultNowRef.current = Date.now();
const now = nowMs ?? defaultNowRef.current;
const pxPerMinute = zoomScale ?? zoomScaleForLevel(zoom, viewportW || DEFAULT_VIEWPORT_W);
const layout = useMemo(
() => computeLayout(data, { ...GEOM, pxPerMinute, nowMs: now }),
[data, pxPerMinute, now],
);
const connectedRunIds = useMemo(() => {
if (!hoveredRunId) return null;
const connected = new Set([hoveredRunId]);
let changed = true;
while (changed) {
changed = false;
for (const c of layout.connectors) {
if (connected.has(c.sourceRunId) && !connected.has(c.targetRunId)) {
connected.add(c.targetRunId);
changed = true;
}
if (connected.has(c.targetRunId) && !connected.has(c.sourceRunId)) {
connected.add(c.sourceRunId);
changed = true;
}
}
}
return connected;
}, [hoveredRunId, layout.connectors]);
const visibleConnectors = useMemo(
() =>
connectedRunIds
? layout.connectors.filter((c) => connectedRunIds.has(c.sourceRunId) && connectedRunIds.has(c.targetRunId))
: [],
[connectedRunIds, layout.connectors],
);
const companyPrefix = extractCompanyPrefixFromPath(location.pathname);
const timeToScrollLeft = (ms: number, viewportWidth: number) => {
const x = layout.gutter + ((ms - layout.fromMs) / 60000) * layout.pxPerMinute;
return Math.max(0, Math.min(layout.width - viewportWidth, x - viewportWidth / 2));
};
const scrollCenterMs = (el: HTMLDivElement) => {
const centerX = el.scrollLeft + el.clientWidth / 2;
return layout.fromMs + ((centerX - layout.gutter) / layout.pxPerMinute) * 60000;
};
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const nextViewportW = el.clientWidth;
if (nextViewportW > 0 && nextViewportW !== viewportW) setViewportW(nextViewportW);
const windowKey = `${data.window.from}:${data.window.to}`;
if (initialWindowKeyRef.current !== windowKey) {
initialWindowKeyRef.current = windowKey;
const latest = Math.max(0, layout.width - nextViewportW);
el.scrollLeft = latest;
setScrollLeft(latest);
centerMsRef.current = scrollCenterMs(el);
return;
}
if (centerMsRef.current != null) {
const next = timeToScrollLeft(centerMsRef.current, nextViewportW);
el.scrollLeft = next;
setScrollLeft(next);
}
}, [data.window.from, data.window.to, layout.fromMs, layout.gutter, layout.pxPerMinute, layout.toMs, layout.width, viewportW]);
useEffect(() => {
if (!onVisibleRangeLabelChange) return;
const effectiveViewportW = viewportW || DEFAULT_VIEWPORT_W;
const minutes = plotViewportWidth(effectiveViewportW) / layout.pxPerMinute;
onVisibleRangeLabelChange(formatVisibleDurationMinutes(minutes));
}, [layout.pxPerMinute, onVisibleRangeLabelChange, viewportW]);
const stepMs = chooseTickStepMs(layout.pxPerMinute);
const ticks: number[] = [];
const startTick = Math.ceil(layout.fromMs / stepMs) * stepMs;
for (let ms = startTick; ms <= layout.toMs; ms += stepMs) ticks.push(ms);
const openIssue = (issueId: string) => {
const href = applyCompanyPrefix(`/issues/${encodeURIComponent(issueId)}`, companyPrefix);
window.open(href, "_blank", "noopener,noreferrer");
};
const updateVisibleRange = (fromMs: number, toMs: number) => {
if (!onZoomScaleChange) return;
const el = scrollRef.current;
const boundedFrom = Math.max(layout.fromMs, Math.min(layout.toMs, fromMs));
const boundedTo = Math.max(layout.fromMs, Math.min(layout.toMs, toMs));
const startMs = Math.min(boundedFrom, boundedTo);
const endMs = Math.max(boundedFrom, boundedTo);
const durationMs = Math.max(MIN_MINIMAP_SELECTION_MS, endMs - startMs);
const centerMs = startMs + durationMs / 2;
const effectiveViewportW = el?.clientWidth || viewportW || DEFAULT_VIEWPORT_W;
const nextScale = clampZoomScale(plotViewportWidth(effectiveViewportW) / (durationMs / 60000));
centerMsRef.current = centerMs;
onZoomScaleChange(nextScale, nearestZoomForScale(nextScale, effectiveViewportW));
};
const svgXFromClientX = (clientX: number, el: SVGSVGElement) => {
const rect = el.getBoundingClientRect();
if (rect.width <= 0) return layout.gutter;
const x = ((clientX - rect.left) / rect.width) * layout.width;
return Math.max(layout.gutter, Math.min(layout.width - 40, x));
};
const msFromSvgX = (x: number) => (
layout.fromMs + ((x - layout.gutter) / layout.pxPerMinute) * 60000
);
const handlePlotMouseDown = (event: React.MouseEvent<SVGSVGElement>) => {
if (!onZoomScaleChange || event.button !== 0) return;
const el = event.currentTarget;
const startX = svgXFromClientX(event.clientX, el);
event.preventDefault();
setTooltip(null);
setHoveredRunId(null);
setDragSelection({ anchorX: startX, currentX: startX });
const move = (moveEvent: MouseEvent) => {
setDragSelection((prev) => prev && {
...prev,
currentX: svgXFromClientX(moveEvent.clientX, el),
});
};
const up = (upEvent: MouseEvent) => {
const endX = svgXFromClientX(upEvent.clientX, el);
setDragSelection(null);
if (Math.abs(endX - startX) < 8) return;
const fromMs = Math.min(msFromSvgX(startX), msFromSvgX(endX));
const toMs = Math.max(msFromSvgX(startX), msFromSvgX(endX));
updateVisibleRange(fromMs, toMs);
};
setDocumentDrag(move, up);
};
const connectorHintForBar = (bar: PositionedBar): string | null => {
const related = layout.connectors.filter((c) => c.sourceRunId === bar.span.runId || c.targetRunId === bar.span.runId);
if (related.length === 0) return null;
return related.some((c) => c.dashed)
? "dashed handoff: retry or changes requested"
: "solid handoff: delegation or assignment";
};
const showTooltip = (evt: React.MouseEvent, bar: PositionedBar) => {
setHoveredRunId(bar.span.runId);
setTooltip({ x: evt.clientX, y: evt.clientY, bar, connectorHint: connectorHintForBar(bar) });
};
const handleWheel = (evt: React.WheelEvent<HTMLDivElement>) => {
if (!onZoomScaleChange || !(evt.ctrlKey || evt.metaKey || evt.altKey)) return;
evt.preventDefault();
const el = scrollRef.current;
if (el) {
centerMsRef.current = scrollCenterMs(el);
}
const nextScale = clampZoomScale(layout.pxPerMinute * Math.exp(-evt.deltaY * 0.001));
onZoomScaleChange(nextScale, nearestZoomForScale(nextScale, el?.clientWidth ?? viewportW));
};
return (
<div className="relative">
<div
ref={scrollRef}
className="max-h-[70vh] overflow-auto"
data-testid="work-timeline-scroll"
onScroll={(e) => {
setScrollLeft(e.currentTarget.scrollLeft);
centerMsRef.current = scrollCenterMs(e.currentTarget);
}}
onWheel={handleWheel}
>
<div className="relative" style={{ width: layout.width, height: layout.height }}>
<ActorGutter rows={layout.rows} height={layout.height} />
<svg
width={layout.width}
height={layout.height}
viewBox={`0 0 ${layout.width} ${layout.height}`}
className="absolute inset-0 block select-none"
onMouseDown={handlePlotMouseDown}
ref={(el) => {
if (el && viewportW === 0 && scrollRef.current) setViewportW(scrollRef.current.clientWidth);
}}
>
<defs>
<linearGradient id="tl-fade" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="var(--color-foreground)" stopOpacity={0.28} />
<stop offset="100%" stopColor="var(--color-foreground)" stopOpacity={0} />
</linearGradient>
</defs>
{/* row backgrounds */}
{layout.rows.map((row, i) => (
<rect
key={`bg-${row.actor.id}`}
x={0}
y={row.y + AXIS_H}
width={layout.width}
height={row.h}
fill={i % 2 ? "var(--color-muted)" : "transparent"}
opacity={i % 2 ? 0.35 : 1}
/>
))}
{/* vertical gridlines */}
{ticks.map((ms) => {
const gx = layout.gutter + ((ms - layout.fromMs) / 60000) * layout.pxPerMinute;
return (
<g key={`tick-${ms}`}>
<line x1={gx} y1={AXIS_H} x2={gx} y2={layout.height} stroke="var(--color-border)" strokeWidth={1} />
</g>
);
})}
{/* now line — teal "Signal" present marker */}
{now >= layout.fromMs && now <= layout.toMs && (
<line
x1={layout.gutter + ((now - layout.fromMs) / 60000) * layout.pxPerMinute}
y1={AXIS_H}
x2={layout.gutter + ((now - layout.fromMs) / 60000) * layout.pxPerMinute}
y2={layout.height}
stroke={TIMELINE_COLORS.now}
strokeWidth={1.5}
strokeDasharray="2 3"
opacity={0.9}
/>
)}
{/* gutter divider + axis baseline */}
<line x1={layout.gutter} y1={0} x2={layout.gutter} y2={layout.height} stroke="var(--color-foreground)" strokeWidth={1.5} />
<line x1={0} y1={AXIS_H} x2={layout.width} y2={AXIS_H} stroke="var(--color-foreground)" strokeWidth={1.5} />
{/* connectors (behind bars): hover reveals the connected handoff graph. */}
{visibleConnectors.map((c, i) => {
const y1 = c.y1 + AXIS_H;
const y2 = c.y2 + AXIS_H;
const arrow =
c.x2 >= c.x1
? `M${c.x2},${y2} l-10,-5 l0,10 z`
: `M${c.x2},${y2} l10,-5 l0,10 z`;
return (
<g key={`edge-${c.sourceRunId}-${c.targetRunId}-${i}`} data-testid="timeline-connector" opacity={0.86}>
<path
d={`M${c.x1},${y1} V${y2} H${c.x2}`}
fill="none"
stroke="var(--color-foreground)"
strokeWidth={2.2}
strokeDasharray={c.dashed ? "5 4" : undefined}
/>
<circle cx={c.x1} cy={y1} r={3.2} fill="var(--color-foreground)" />
<path d={arrow} fill="var(--color-foreground)" />
</g>
);
})}
{/* rows: gutter avatar/label, lane baselines, bars, human kickoff chips */}
{layout.rows.map((row) => {
const cy = row.y + AXIS_H + row.h / 2;
return (
<g key={`row-${row.actor.id}`}>
<AvatarGlyph cx={26} cy={cy} r={AVATAR_R} label={shortLabel(row.actor.name)} type={row.actor.type} />
<text x={26 + AVATAR_R + 10} y={cy - 2} fontSize={13} fill="var(--color-foreground)">
{truncate(row.actor.name, 18)}
</text>
<text x={26 + AVATAR_R + 10} y={cy + 12} fontSize={11} fill="var(--color-muted-foreground)">
{row.actor.type}
</text>
{Array.from({ length: row.laneCount }).map((_, ln) => {
const ly = row.y + AXIS_H + 6 + ln * (GEOM.barH + GEOM.laneGap) + GEOM.barH / 2;
return (
<line
key={`lane-${row.actor.id}-${ln}`}
x1={layout.gutter}
y1={ly}
x2={layout.width - 8}
y2={ly}
stroke="var(--color-border)"
strokeWidth={1}
strokeDasharray="2 4"
opacity={0.6}
/>
);
})}
{row.bars.map((bar) => {
const yTop = bar.yTop + AXIS_H;
const w = bar.x2 - bar.x1;
const cancelled = isCancelledStatus(bar.span.status);
const color = barColor(bar);
const connectedState =
connectedRunIds == null ? "idle" : connectedRunIds.has(bar.span.runId) ? "connected" : "faded";
const barOpacity =
connectedState === "idle"
? 0.88
: connectedState === "connected"
? 1
: 0.22;
return (
<g key={bar.span.runId} opacity={connectedState === "faded" ? 0.42 : 1}>
<g
className="cursor-pointer"
data-run-id={bar.span.runId}
data-connected-state={connectedState}
onMouseEnter={(e) => showTooltip(e, bar)}
onMouseOver={(e) => showTooltip(e, bar)}
onMouseMove={(e) => showTooltip(e, bar)}
onMouseLeave={() => {
setTooltip(null);
setHoveredRunId(null);
}}
onMouseDown={(e) => e.stopPropagation()}
onClick={() => openIssue(bar.span.issueId)}
>
{/* "Signal" encoding: fill = how the run started (delegated /
automation); cancelled runs drop the fill and read as a
hollow dashed bar. */}
<rect
x={bar.x1}
y={yTop}
width={w}
height={bar.height}
rx={3}
fill={cancelled ? "transparent" : color}
stroke={cancelled ? TIMELINE_COLORS.cancelled : "var(--color-foreground)"}
strokeWidth={1.5}
strokeDasharray={cancelled ? "4 3" : undefined}
opacity={barOpacity}
/>
{/* in-progress fade to "now" */}
{bar.running && !cancelled && w > 8 && (
<rect x={bar.x2 - Math.min(w - 2, 26)} y={yTop + 1.5} width={Math.min(w - 2, 26)} height={bar.height - 3} fill="url(#tl-fade)" />
)}
</g>
{bar.kickoff && actorType(bar.kickoff) === "user" && (
<g className="pointer-events-none" data-testid="timeline-kickoff-chip">
<AvatarGlyph
cx={bar.x1}
cy={yTop + bar.height / 2}
r={CHIP_R}
label={shortLabel((bar.kickoff as WorkTimelineActor).name)}
type={actorType(bar.kickoff)}
/>
</g>
)}
</g>
);
})}
</g>
);
})}
{dragSelection && (
<rect
data-testid="timeline-drag-selection"
x={Math.min(dragSelection.anchorX, dragSelection.currentX)}
y={AXIS_H}
width={Math.abs(dragSelection.currentX - dragSelection.anchorX)}
height={layout.height - AXIS_H}
fill="var(--color-primary)"
opacity={0.16}
stroke="var(--color-primary)"
strokeWidth={1.5}
pointerEvents="none"
/>
)}
</svg>
</div>
</div>
<TimeAxisOverlay layout={layout} ticks={ticks} stepMs={stepMs} scrollLeft={scrollLeft} />
<MiniMap
layout={layout}
scrollRef={scrollRef}
viewportW={viewportW}
scrollLeft={scrollLeft}
onVisibleRangeChange={updateVisibleRange}
/>
{tooltip && <Tooltip tooltip={tooltip} now={now} />}
</div>
);
}
function ActorGutter({ rows, height }: { rows: ReturnType<typeof computeLayout>["rows"]; height: number }) {
return (
<svg
aria-hidden="true"
data-testid="work-timeline-actor-gutter"
width={GEOM.gutter}
height={height}
viewBox={`0 0 ${GEOM.gutter} ${height}`}
className="sticky left-0 z-20 block bg-card"
>
<rect x={0} y={0} width={GEOM.gutter} height={height} fill="var(--color-card)" />
{rows.map((row, i) => {
const cy = row.y + AXIS_H + row.h / 2;
return (
<g key={`gutter-${row.actor.id}`}>
<rect
x={0}
y={row.y + AXIS_H}
width={GEOM.gutter}
height={row.h}
fill={i % 2 ? "var(--color-muted)" : "var(--color-card)"}
opacity={i % 2 ? 0.35 : 1}
/>
<AvatarGlyph cx={26} cy={cy} r={AVATAR_R} label={shortLabel(row.actor.name)} type={row.actor.type} />
<text x={26 + AVATAR_R + 10} y={cy - 2} fontSize={13} fill="var(--color-foreground)">
{truncate(row.actor.name, 16)}
</text>
<text x={26 + AVATAR_R + 10} y={cy + 12} fontSize={11} fill="var(--color-muted-foreground)">
{row.actor.type}
</text>
{/* "Signal" rail: run count + active time, right-aligned in the gutter. */}
<text
x={GEOM.gutter - 10}
y={cy + 11}
fontSize={10.5}
textAnchor="end"
fill="var(--color-muted-foreground)"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{row.runCount}× · {formatDuration(0, row.activeMs)}
</text>
</g>
);
})}
<line x1={GEOM.gutter} y1={0} x2={GEOM.gutter} y2={height} stroke="var(--color-foreground)" strokeWidth={1.5} />
<line x1={0} y1={AXIS_H} x2={GEOM.gutter} y2={AXIS_H} stroke="var(--color-foreground)" strokeWidth={1.5} />
</svg>
);
}
function TimeAxisOverlay({
layout,
ticks,
stepMs,
scrollLeft,
}: {
layout: ReturnType<typeof computeLayout>;
ticks: number[];
stepMs: number;
scrollLeft: number;
}) {
return (
<div
aria-hidden="true"
data-testid="work-timeline-time-axis"
className="pointer-events-none absolute left-0 right-0 top-0 z-30 overflow-hidden bg-card"
style={{ height: AXIS_H }}
>
<svg
width={layout.width}
height={AXIS_H}
viewBox={`0 0 ${layout.width} ${AXIS_H}`}
className="block"
style={{ transform: `translateX(${-scrollLeft}px)` }}
>
<rect x={0} y={0} width={layout.width} height={AXIS_H} fill="var(--color-card)" />
{ticks.map((ms) => {
const gx = layout.gutter + ((ms - layout.fromMs) / 60000) * layout.pxPerMinute;
return (
<g key={`axis-tick-${ms}`}>
<line x1={gx} y1={AXIS_H - 7} x2={gx} y2={AXIS_H} stroke="var(--color-border)" strokeWidth={1} />
<text x={gx + 3} y={19} fontSize={11} fill="var(--color-muted-foreground)">
{fmtTick(ms, stepMs)}
</text>
</g>
);
})}
<line x1={0} y1={AXIS_H} x2={layout.width} y2={AXIS_H} stroke="var(--color-foreground)" strokeWidth={1.5} />
</svg>
<svg
width={layout.gutter}
height={AXIS_H}
viewBox={`0 0 ${layout.gutter} ${AXIS_H}`}
className="absolute left-0 top-0 block bg-card"
>
<rect x={0} y={0} width={layout.gutter} height={AXIS_H} fill="var(--color-card)" />
<line x1={layout.gutter} y1={0} x2={layout.gutter} y2={AXIS_H} stroke="var(--color-foreground)" strokeWidth={1.5} />
<line x1={0} y1={AXIS_H} x2={layout.gutter} y2={AXIS_H} stroke="var(--color-foreground)" strokeWidth={1.5} />
</svg>
</div>
);
}
function Tooltip({ tooltip, now }: { tooltip: TooltipState; now: number }) {
const { bar } = tooltip;
const startMs = new Date(bar.span.start).getTime();
const endMs = bar.span.end ? new Date(bar.span.end).getTime() : now;
const title = bar.span.issueTitle ?? bar.span.issueIdentifier ?? "run";
const left = Math.min(tooltip.x + 14, (typeof window !== "undefined" ? window.innerWidth : 1200) - 300);
return (
<div
className="pointer-events-none fixed z-50 max-w-[280px] rounded-md border border-foreground bg-card px-2.5 py-2 text-xs shadow-md"
style={{ left, top: tooltip.y + 14 }}
>
<div className="text-[13px] font-medium text-foreground">{truncate(title)}</div>
<div className="mt-0.5 text-muted-foreground">
{fmtClock(startMs)}–{bar.span.end ? fmtClock(endMs) : "now"} · {formatDuration(startMs, endMs)} ·{" "}
<span className="font-medium text-foreground">{bar.span.status}</span>
</div>
{bar.kickoff && (
<div className="text-muted-foreground">
kicked off by: {(bar.kickoff as WorkTimelineActor).name}
{bar.span.retryOfRunId ? " · retry" : ""}
</div>
)}
{tooltip.connectorHint && (
<div className="text-muted-foreground">{tooltip.connectorHint}</div>
)}
</div>
);
}
function MiniMap({
layout,
scrollRef,
viewportW,
scrollLeft,
onVisibleRangeChange,
}: {
layout: ReturnType<typeof computeLayout>;
scrollRef: React.RefObject<HTMLDivElement | null>;
viewportW: number;
scrollLeft: number;
onVisibleRangeChange: (fromMs: number, toMs: number) => void;
}) {
const documentDragCleanupRef = useRef<(() => void) | null>(null);
const W = Math.max(320, viewportW || 900);
const H = 54;
const pad = 8;
const spanMs = layout.toMs - layout.fromMs || 1;
const mx = (ms: number) => pad + ((ms - layout.fromMs) / spanMs) * (W - 2 * pad);
// one thin tick per run, stacked by row order
const rowIndex = new Map(layout.rows.map((r, i) => [r.actor.id, i]));
const laneH = (H - 2 * pad) / Math.max(1, layout.rows.length);
const timeAtX = (x: number) => {
const ms = layout.fromMs + ((x - layout.gutter) / layout.pxPerMinute) * 60000;
return Math.max(layout.fromMs, Math.min(layout.toMs, ms));
};
const visibleStartMs = timeAtX(scrollLeft + layout.gutter);
const visibleEndMs = timeAtX(scrollLeft + layout.gutter + (viewportW || W));
const brushX = mx(visibleStartMs);
const brushW = Math.max(24, mx(visibleEndMs) - brushX);
const clearDocumentDrag = () => {
documentDragCleanupRef.current?.();
documentDragCleanupRef.current = null;
};
const setDocumentDrag = (move: (event: MouseEvent) => void, up: (event: MouseEvent) => void) => {
clearDocumentDrag();
const handleUp = (event: MouseEvent) => {
clearDocumentDrag();
up(event);
};
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", handleUp);
documentDragCleanupRef.current = () => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", handleUp);
};
};
useEffect(() => () => clearDocumentDrag(), []);
const msAtClientX = (clientX: number, el: SVGSVGElement) => {
const rect = el.getBoundingClientRect();
const f = Math.min(1, Math.max(0, (clientX - rect.left - pad) / (W - 2 * pad)));
return layout.fromMs + f * spanMs;
};
const seek = (clientX: number, el: SVGSVGElement) => {
const centerMs = msAtClientX(clientX, el);
if (scrollRef.current) {
scrollRef.current.scrollLeft = layout.gutter + ((centerMs - layout.fromMs) / 60000) * layout.pxPerMinute - scrollRef.current.clientWidth / 2;
}
};
const startRangeDrag = (mode: "left" | "right" | "move", event: React.MouseEvent<SVGElement>) => {
event.preventDefault();
event.stopPropagation();
const el = event.currentTarget.ownerSVGElement;
if (!el) return;
const startLeftMs = visibleStartMs;
const startRightMs = visibleEndMs;
const durationMs = Math.max(MIN_MINIMAP_SELECTION_MS, startRightMs - startLeftMs);
const move = (ev: MouseEvent) => {
const hitMs = msAtClientX(ev.clientX, el);
if (mode === "left") {
onVisibleRangeChange(Math.min(hitMs, startRightMs - MIN_MINIMAP_SELECTION_MS), startRightMs);
} else if (mode === "right") {
onVisibleRangeChange(startLeftMs, Math.max(hitMs, startLeftMs + MIN_MINIMAP_SELECTION_MS));
} else {
const nextFrom = Math.max(layout.fromMs, Math.min(layout.toMs - durationMs, hitMs - durationMs / 2));
onVisibleRangeChange(nextFrom, nextFrom + durationMs);
}
};
setDocumentDrag(move, () => {});
};
return (
<div className="mt-2 border-t border-border bg-card px-3.5 py-2">
<svg
width={W}
height={H}
viewBox={`0 0 ${W} ${H}`}
className="block cursor-ew-resize"
onMouseDown={(e) => {
const el = e.currentTarget;
seek(e.clientX, el);
const move = (ev: MouseEvent) => seek(ev.clientX, el);
setDocumentDrag(move, () => {});
}}
>
<rect x={0} y={0} width={W} height={H} fill="var(--color-card)" stroke="var(--color-foreground)" strokeWidth={1.5} />
{layout.rows.flatMap((row) =>
row.bars.map((bar) => {
const startMs = new Date(bar.span.start).getTime();
const endMs = bar.span.end ? new Date(bar.span.end).getTime() : layout.toMs;
const yy = pad + (rowIndex.get(row.actor.id) ?? 0) * laneH;
return (
<rect
key={`mm-${bar.span.runId}`}
x={mx(startMs)}
y={yy + 1}
width={Math.max(2, mx(endMs) - mx(startMs))}
height={Math.max(2, laneH - 2)}
fill={isCancelledStatus(bar.span.status) ? TIMELINE_COLORS.cancelled : barColor(bar)}
opacity={isCancelledStatus(bar.span.status) ? 0.5 : 1}
/>
);
}),
)}
<rect
x={brushX}
y={1}
width={brushW}
height={H - 2}
fill="var(--color-foreground)"
opacity={0.12}
stroke="var(--color-foreground)"
strokeWidth={1.5}
onMouseDown={(e) => startRangeDrag("move", e)}
/>
<rect
data-testid="timeline-minimap-left-handle"
x={brushX - 3}
y={1}
width={6}
height={H - 2}
fill="var(--color-foreground)"
opacity={0.55}
onMouseDown={(e) => startRangeDrag("left", e)}
/>
<rect
data-testid="timeline-minimap-right-handle"
x={brushX + brushW - 3}
y={1}
width={6}
height={H - 2}
fill="var(--color-foreground)"
opacity={0.55}
onMouseDown={(e) => startRangeDrag("right", e)}
/>
</svg>
</div>
);
}
+97 -20
View File
@@ -12,40 +12,84 @@ type MutationVariables = {
resourceType: ResourceMembershipResourceType;
resourceId: string;
resourceName: string;
state: ResourceMembershipState;
/** Join / leave transition. Omit to only change the starred flag. */
state?: ResourceMembershipState;
/** Star / unstar transition. Omit to only change join/leave state. */
starred?: boolean;
};
function emptyMemberships(): ResourceMemberships {
return {
projectMemberships: {},
agentMemberships: {},
starredProjectIds: [],
starredAgentIds: [],
projectStarredAt: {},
agentStarredAt: {},
updatedAt: null,
};
}
function applyMembershipState(
function starKeys(resourceType: ResourceMembershipResourceType) {
return resourceType === "project"
? { ids: "starredProjectIds", at: "projectStarredAt", state: "projectMemberships" }
: { ids: "starredAgentIds", at: "agentStarredAt", state: "agentMemberships" };
}
/**
* Apply an optimistic membership change to the cached memberships snapshot.
* Mirrors the server rules (see server/src/services/resource-memberships.ts):
* - starred=true always implies joined and stamps starredAt,
* - starred=false clears the star but leaves join/leave state alone,
* - state="left" clears any star (you cannot star a left resource).
*/
function applyMembershipChange(
current: ResourceMemberships | undefined,
resourceType: ResourceMembershipResourceType,
resourceId: string,
state: ResourceMembershipState,
change: { state?: ResourceMembershipState; starred?: boolean },
): ResourceMemberships {
const base = current ?? emptyMemberships();
if (resourceType === "project") {
return {
...base,
projectMemberships: {
...base.projectMemberships,
[resourceId]: state,
},
updatedAt: new Date(),
};
const keys = starKeys(resourceType);
// Resolve next join/leave state (starring implies joined).
const currentStateMap = base[keys.state as "projectMemberships"] ?? {};
const previousState: ResourceMembershipState =
currentStateMap[resourceId] === "left" ? "left" : "joined";
const nextState: ResourceMembershipState =
change.starred === true ? "joined" : change.state ?? previousState;
// Resolve next starred set.
const currentStarredIds = base[keys.ids as "starredProjectIds"] ?? [];
const nextStarredAt = { ...(base[keys.at as "projectStarredAt"] ?? {}) };
const previouslyStarred = currentStarredIds.includes(resourceId);
const nextStarred =
nextState === "left"
? false
: change.starred === true
? true
: change.starred === false
? false
: previouslyStarred;
let starredIds = currentStarredIds;
if (nextStarred && !previouslyStarred) {
// Newest star sorts first, matching the server's starredAt DESC ordering.
starredIds = [resourceId, ...currentStarredIds];
nextStarredAt[resourceId] = new Date();
} else if (!nextStarred && previouslyStarred) {
starredIds = currentStarredIds.filter((id) => id !== resourceId);
delete nextStarredAt[resourceId];
}
return {
...base,
agentMemberships: {
...base.agentMemberships,
[resourceId]: state,
[keys.state]: {
...currentStateMap,
[resourceId]: nextState,
},
[keys.ids]: starredIds,
[keys.at]: nextStarredAt,
updatedAt: new Date(),
};
}
@@ -61,6 +105,29 @@ export function resourceMembershipState(
return state === "left" ? "left" : "joined";
}
/** Whether the current viewer has starred this resource (navigation preference). */
export function isStarred(
memberships: ResourceMemberships | undefined,
resourceType: ResourceMembershipResourceType,
resourceId: string,
): boolean {
const ids = resourceType === "project"
? memberships?.starredProjectIds
: memberships?.starredAgentIds;
return Array.isArray(ids) && ids.includes(resourceId);
}
/** Ordered starred ids (server returns starredAt DESC; falls back to empty). */
export function starredResourceIds(
memberships: ResourceMemberships | undefined,
resourceType: ResourceMembershipResourceType,
): string[] {
const ids = resourceType === "project"
? memberships?.starredProjectIds
: memberships?.starredAgentIds;
return Array.isArray(ids) ? ids : [];
}
export function useResourceMemberships(companyId: string | null | undefined) {
return useQuery({
queryKey: queryKeys.resourceMemberships.mine(companyId ?? "__none__"),
@@ -77,16 +144,20 @@ export function useResourceMembershipMutation(companyId: string | null | undefin
return useMutation({
mutationFn: (variables: MutationVariables) => {
if (!companyId) throw new Error("Select a company first.");
const body = { state: variables.state, starred: variables.starred };
return variables.resourceType === "project"
? resourceMembershipsApi.updateProject(companyId, variables.resourceId, { state: variables.state })
: resourceMembershipsApi.updateAgent(companyId, variables.resourceId, { state: variables.state });
? resourceMembershipsApi.updateProject(companyId, variables.resourceId, body)
: resourceMembershipsApi.updateAgent(companyId, variables.resourceId, body);
},
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<ResourceMemberships>(queryKey);
queryClient.setQueryData<ResourceMemberships>(
queryKey,
applyMembershipState(previous, variables.resourceType, variables.resourceId, variables.state),
applyMembershipChange(previous, variables.resourceType, variables.resourceId, {
state: variables.state,
starred: variables.starred,
}),
);
return { previous };
},
@@ -94,7 +165,9 @@ export function useResourceMembershipMutation(companyId: string | null | undefin
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous);
}
const verb = variables.state === "left" ? "leave" : "join";
const verb = variables.starred !== undefined
? variables.starred ? "star" : "unstar"
: variables.state === "left" ? "leave" : "join";
pushToast({
title: `Couldn't ${verb} ${variables.resourceName}.`,
body: error instanceof Error ? error.message : "Try again.",
@@ -104,7 +177,11 @@ export function useResourceMembershipMutation(companyId: string | null | undefin
onSuccess: (result, variables) => {
queryClient.setQueryData<ResourceMemberships>(
queryKey,
(current) => applyMembershipState(current, variables.resourceType, result.resourceId, result.state),
// Loose null-check: a missing or null starredAt both mean "not starred".
(current) => applyMembershipChange(current, variables.resourceType, result.resourceId, {
state: result.state,
starred: result.starredAt != null,
}),
);
},
onSettled: () => {
+21 -2
View File
@@ -1,9 +1,27 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@font-face {
font-family: "InterVariable";
src: url("../fonts/InterVariable.woff2") format("woff2");
font-display: swap;
font-style: normal;
font-weight: 100 900;
}
@font-face {
font-family: "InterVariable";
src: url("../fonts/InterVariable-Italic.woff2") format("woff2");
font-display: swap;
font-style: italic;
font-weight: 100 900;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans: "InterVariable", "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@@ -247,6 +265,7 @@
}
body {
@apply bg-background text-foreground antialiased;
font-family: var(--font-sans);
height: 100%;
overflow: hidden;
}
@@ -851,7 +870,7 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
}
.paperclip-mdxeditor-content code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-family: var(--font-mono);
font-size: 1em;
}
@@ -956,7 +975,7 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
}
.paperclip-markdown code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-family: var(--font-mono);
font-size: 1em;
}
+1 -15
View File
@@ -1,4 +1,4 @@
import type { Agent } from "@paperclipai/shared";
import { ADAPTER_AGNOSTIC_KEYS, type Agent } from "@paperclipai/shared";
export interface AgentModelProfileOverlay {
enabled?: boolean;
@@ -19,20 +19,6 @@ export interface AgentConfigOverlay {
modelProfiles?: { cheap?: AgentModelProfileOverlay };
}
const ADAPTER_AGNOSTIC_KEYS = [
"env",
"promptTemplate",
"instructionsFilePath",
"cwd",
"timeoutSec",
"graceSec",
"bootstrapPromptTemplate",
// Desired-skill selection is a company-level, adapter-agnostic choice even
// though it is persisted inside the per-adapter config; keep it when the
// adapter type changes so switching adapters does not wipe the agent's skills.
"paperclipSkillSync",
] as const;
function omitUndefinedEntries(value: Record<string, unknown>) {
return Object.fromEntries(
Object.entries(value).filter(([, entryValue]) => entryValue !== undefined),
+7
View File
@@ -72,6 +72,13 @@ describe("company routes", () => {
expect(toCompanyRelativePath("/PAP/artifacts")).toBe("/artifacts");
});
it("treats /timeline as a board route that needs a company prefix", () => {
expect(isBoardPathWithoutPrefix("/timeline")).toBe(true);
expect(extractCompanyPrefixFromPath("/timeline")).toBeNull();
expect(applyCompanyPrefix("/timeline", "PAP")).toBe("/PAP/timeline");
expect(toCompanyRelativePath("/PAP/timeline")).toBe("/timeline");
});
it("preserves artifact deep-link anchors when applying the company prefix", () => {
expect(applyCompanyPrefix("/issues/PAP-10205#work-product-wp-1", "PAP")).toBe(
"/PAP/issues/PAP-10205#work-product-wp-1",
+1
View File
@@ -24,6 +24,7 @@ const BOARD_ROUTE_ROOTS = new Set([
"design-guide",
"search",
"settings",
"timeline",
]);
const GLOBAL_ROUTE_ROOTS = new Set(["auth", "invite", "board-claim", "cli-auth", "docs", "instance"]);
+1
View File
@@ -258,6 +258,7 @@ export const queryKeys = {
["company-search", companyId, q, scope, limit, offset] as const,
},
dashboard: (companyId: string) => ["dashboard", companyId] as const,
workTimeline: (companyId: string, lens?: string) => ["work-timeline", companyId, lens ?? "all"] as const,
userProfile: (companyId: string, userSlug: string) =>
["user-profile", companyId, userSlug] as const,
sidebarBadges: (companyId: string) => ["sidebar-badges", companyId] as const,
+216
View File
@@ -0,0 +1,216 @@
import { describe, expect, it } from "vitest";
import type { WorkTimelineResult } from "@paperclipai/shared";
import { chooseTickStepMs, computeLayout, issueColor, shortLabel, type LayoutOptions } from "./layout";
const DAY = "2026-07-02";
const t = (hhmm: string) => `${DAY}T${hhmm}:00.000Z`;
// A trimmed version of the board's locked-design sample (PAP-12422), expressed
// in the real endpoint contract: actor→actor edges rather than run→run.
function sample(): WorkTimelineResult {
const actors = [
{ id: "user:dotta", type: "user" as const, name: "dotta" },
{ id: "agent:ceo", type: "agent" as const, name: "CEO" },
{ id: "agent:cto", type: "agent" as const, name: "CTO Architect" },
{ id: "agent:ux", type: "agent" as const, name: "UXDesigner" },
{ id: "agent:senior", type: "agent" as const, name: "SeniorEngineer" },
{ id: "agent:codex", type: "agent" as const, name: "CodexCoder" },
{ id: "agent:qa", type: "agent" as const, name: "QA" },
{ id: "system:routine", type: "system" as const, name: "Circleback" },
];
const span = (
runId: string,
actorId: string,
issueId: string,
identifier: string,
start: string,
end: string | null,
status: string,
retryOfRunId: string | null = null,
): WorkTimelineResult["spans"][number] => ({
actorId,
laneHint: null,
runId,
issueId,
issueIdentifier: identifier,
issueTitle: `${identifier} title`,
start: t(start),
end: end ? t(end) : null,
status,
retryOfRunId,
});
const spans = [
span("r1", "agent:ceo", "i-405", "PAP-12405", "09:02", "09:10", "completed"),
span("r2", "agent:cto", "i-405", "PAP-12405", "09:14", "09:52", "completed"),
span("r3", "agent:senior", "i-075", "PAP-12075", "09:58", "10:20", "completed"),
span("r4", "agent:ux", "i-422", "PAP-12422", "10:10", "11:34", "running"),
span("r5", "agent:codex", "i-423", "PAP-12423", "10:10", "10:56", "completed"),
span("r6", "agent:codex", "i-075", "PAP-12075", "10:22", "11:08", "completed"), // overlaps r5 → sub-lane
span("r7", "agent:qa", "i-423", "PAP-12423", "11:40", "12:12", "completed"),
span("r8", "agent:codex", "i-423", "PAP-12423", "12:20", "12:38", "completed", "r5"), // retry
span("r9", "system:routine", "i-286", "PAP-12286", "20:00", "20:04", "completed"),
];
const edge = (from: string, to: string, issueId: string, at: string): WorkTimelineResult["edges"][number] => ({
fromActorId: from,
toActorId: to,
issueId,
at: t(at),
kind: "delegation",
});
const edges = [
edge("user:dotta", "agent:ceo", "i-405", "09:01"), // human kickoff → chip only, no line
edge("agent:ceo", "agent:cto", "i-405", "09:13"),
edge("agent:cto", "agent:ux", "i-422", "10:09"),
edge("agent:cto", "agent:codex", "i-423", "10:09"),
edge("agent:senior", "agent:codex", "i-075", "10:21"),
edge("agent:codex", "agent:qa", "i-423", "11:39"),
edge("agent:qa", "agent:codex", "i-423", "12:19"), // → retry r8 (dashed)
];
return {
actors,
spans,
events: [],
edges,
pagination: { limit: 200, offset: 0, totalIssues: 6, hasMore: false },
window: { from: t("09:00"), to: t("20:15"), capped: false },
};
}
const OPTS: LayoutOptions = {
pxPerMinute: 1.7,
gutter: 172,
rowH: 34,
barH: 15,
laneGap: 4,
nowMs: new Date(t("13:00")).getTime(),
};
describe("computeLayout", () => {
it("excludes humans from rows but keeps agents and system actors", () => {
const layout = computeLayout(sample(), OPTS);
const rowActorTypes = layout.rows.map((r) => r.actor.type);
expect(rowActorTypes).not.toContain("user");
expect(layout.rows.map((r) => r.actor.id)).toContain("system:routine");
expect(layout.rows).toHaveLength(7); // 6 agents + 1 system
});
it("orders rows by first activity", () => {
const layout = computeLayout(sample(), OPTS);
expect(layout.rows[0].actor.id).toBe("agent:ceo");
expect(layout.rows[layout.rows.length - 1].actor.id).toBe("system:routine");
});
it("packs overlapping runs into concurrency sub-lanes", () => {
const layout = computeLayout(sample(), OPTS);
const codex = layout.rows.find((r) => r.actor.id === "agent:codex")!;
expect(codex.laneCount).toBe(2); // r5 and r6 overlap
const lanesTop = new Set(codex.bars.map((b) => b.yTop));
expect(lanesTop.size).toBeGreaterThan(1);
});
it("derives the kickoff actor for a run (incl. human) from edges", () => {
const layout = computeLayout(sample(), OPTS);
const codexApi = layout.rows
.find((r) => r.actor.id === "agent:codex")!
.bars.find((b) => b.span.runId === "r5")!;
expect(codexApi.kickoff?.id).toBe("agent:cto");
const ceoRun = layout.rows.find((r) => r.actor.id === "agent:ceo")!.bars[0];
expect(ceoRun.kickoff?.id).toBe("user:dotta"); // human kickoff shown as chip
});
it("prefers the nearest post-start kickoff edge when no prior edge exists", () => {
const data = sample();
data.spans.push({
actorId: "agent:ux",
laneHint: null,
runId: "late-edge-run",
issueId: "i-late",
issueIdentifier: "PAP-99999",
issueTitle: "Late kickoff fallback",
start: t("14:00"),
end: t("14:10"),
status: "completed",
retryOfRunId: null,
});
data.edges.push(
{ fromActorId: "agent:qa", toActorId: "agent:ux", issueId: "i-late", at: t("14:30"), kind: "delegation" },
{ fromActorId: "agent:cto", toActorId: "agent:ux", issueId: "i-late", at: t("14:02"), kind: "delegation" },
);
const layout = computeLayout(data, OPTS);
const lateRun = layout.rows
.find((r) => r.actor.id === "agent:ux")!
.bars.find((b) => b.span.runId === "late-edge-run")!;
expect(lateRun.kickoff?.id).toBe("agent:cto");
});
it("draws agent→agent connectors only, dashing retries, never from a human", () => {
const layout = computeLayout(sample(), OPTS);
// 6 agent→agent edges resolve to bars; the dotta→ceo human edge draws no line.
expect(layout.connectors.length).toBe(6);
const dashed = layout.connectors.filter((c) => c.dashed);
expect(dashed).toHaveLength(1); // only the QA→codex retry hop
// every connector is left-to-right (source trailing edge → target leading edge)
for (const c of layout.connectors) expect(c.x2).toBeGreaterThanOrEqual(c.x1 - 1);
});
it("extends in-progress runs to now and clamps sub-minute bars", () => {
const layout = computeLayout(sample(), OPTS);
const running = layout.rows
.find((r) => r.actor.id === "agent:ux")!
.bars.find((b) => b.span.runId === "r4")!;
expect(running.running).toBe(true);
expect(running.x2).toBeGreaterThan(running.x1 + 3);
});
it("produces a deterministic issue hue + legend", () => {
const layout = computeLayout(sample(), OPTS);
expect(layout.issues.length).toBe(5); // i-405, i-075, i-422, i-423, i-286
expect(issueColor("i-405")).toBe(issueColor("i-405"));
});
});
describe("human activity markers", () => {
const withUserEvents = (): WorkTimelineResult => ({
...sample(),
events: [
{ actorId: "user:dotta", kind: "created", issueId: "i-405", at: t("09:00") },
{ actorId: "user:dotta", kind: "commented", issueId: "i-422", at: t("11:00") },
{ actorId: "user:dotta", kind: "approved", issueId: "i-423", at: t("12:15") },
],
});
it("does not create a marker-only human row from instant events", () => {
const layout = computeLayout(withUserEvents(), OPTS);
const dotta = layout.rows.find((r) => r.actor.id === "user:dotta");
expect(dotta).toBeUndefined();
});
it("does not plot instant markers on run rows", () => {
const layout = computeLayout(withUserEvents(), OPTS);
expect(layout.rows.flatMap((r) => r.markers)).toHaveLength(0);
});
it("keeps human events visual-only as kickoff chips, not connector targets", () => {
const layout = computeLayout(withUserEvents(), OPTS);
// dotta→ceo human kickoff still draws no line; agent→agent count unchanged.
expect(layout.connectors.length).toBe(6);
});
it("still excludes a human with no in-window events", () => {
const layout = computeLayout(sample(), OPTS); // events: []
expect(layout.rows.map((r) => r.actor.type)).not.toContain("user");
});
});
describe("helpers", () => {
it("shortLabel builds 2-char initials", () => {
expect(shortLabel("CodexCoder")).toBe("CO");
expect(shortLabel("CTO Architect")).toBe("CA");
});
it("chooseTickStepMs grows the step as the view zooms out", () => {
expect(chooseTickStepMs(6)).toBeLessThan(chooseTickStepMs(0.4));
});
});
+365
View File
@@ -0,0 +1,365 @@
/**
* Work Timeline layout — pure transform from the Phase B endpoint contract
* (`WorkTimelineResult`) into a renderable view model for the custom-SVG Gantt.
*
* Ports the board-locked "Direction C" logic (PAP-12422): agent/system rows only
* (humans never get a row), overlapping runs packed into concurrency sub-lanes,
* a kickoff actor derived per run (shown as an avatar chip — may be a human),
* and straight agent→agent delegation connectors from a source bar's trailing
* edge to a target bar's leading edge (dashed for retries / changes-requested).
*
* Everything here is deterministic given (result, options) so it can be unit
* tested without a DOM.
*/
import type {
WorkTimelineActor,
WorkTimelineEdge,
WorkTimelineEvent,
WorkTimelineResult,
WorkTimelineSpan,
} from "@paperclipai/shared";
export interface LayoutOptions {
/** px per minute along the x axis (set by the zoom level). */
pxPerMinute: number;
/** width of the left actor gutter in px. */
gutter: number;
/** row height in px. */
rowH: number;
/** bar height in px. */
barH: number;
/** vertical gap between concurrency sub-lanes in px. */
laneGap: number;
/** wall-clock "now" in ms, used to close in-progress runs. */
nowMs: number;
}
export interface PositionedBar {
span: WorkTimelineSpan;
/** leading (start) x in px. */
x1: number;
/** trailing (end) x in px. */
x2: number;
/** vertical center of the bar in px. */
yc: number;
/** top of the bar in px. */
yTop: number;
height: number;
running: boolean;
/** the actor who kicked this run off, if resolvable (may be a human/user). */
kickoff: WorkTimelineActor | null;
}
/**
* Instant human/actor actions are deliberately not plotted in the main chart.
* The shape remains in the layout model so call sites do not need special cases,
* but rows now stay focused on actors with actual run participation.
*/
export interface PositionedMarker {
event: WorkTimelineEvent;
/** x position (px) of the marker centre = x(event.at). */
x: number;
/** vertical centre of the marker in px (row-relative, excludes axis offset). */
yc: number;
}
export interface ActorRow {
actor: WorkTimelineActor;
/** top of the row (excluding axis offset) in px. */
y: number;
/** row height in px. */
h: number;
laneCount: number;
bars: PositionedBar[];
/** reserved for instant event markers; currently empty by design. */
markers: PositionedMarker[];
/** number of runs plotted on this row (the "Signal" rail count). */
runCount: number;
/** total active run time on this row in ms (the "Signal" rail active-time). */
activeMs: number;
}
export interface Connector {
x1: number;
y1: number;
x2: number;
y2: number;
sourceRunId: string;
targetRunId: string;
/** dashed = a return / retry (changes-requested) hop. */
dashed: boolean;
}
export interface TimelineLayout {
rows: ActorRow[];
connectors: Connector[];
/** full inner width of the chart (gutter + plotted time + pad). */
width: number;
/** full height of the chart including the axis strip. */
height: number;
/** domain start (ms). */
fromMs: number;
/** domain end (ms). */
toMs: number;
gutter: number;
pxPerMinute: number;
/** ordered list of distinct issue keys present for the task hue map. */
issues: { key: string; label: string; color: string }[];
}
export const AXIS_H = 32;
const RUNNING_STATUSES = new Set(["running", "in_progress", "queued", "pending"]);
const CANCELLED_STATUSES = new Set(["cancelled", "canceled", "aborted", "skipped"]);
export function isRunningStatus(status: string): boolean {
return RUNNING_STATUSES.has(status);
}
export function isCancelledStatus(status: string): boolean {
return CANCELLED_STATUSES.has(status);
}
/**
* "Signal" encoding (PAP-12694, board-picked): colour spends on ONE meaning —
* how the run started — instead of a per-issue hash rainbow. Delegated runs
* (kicked off by another actor) read blue; automation/self-started runs read
* amber. The blue/amber pair is colour-blind-safe and holds contrast on both
* light and dark backgrounds so the chart screenshots cleanly. Cancelled runs
* drop their fill entirely (rendered as a hollow dashed bar) and a teal "now"
* line marks the present.
*/
export const TIMELINE_COLORS = {
delegated: "#5b9bf6",
automation: "#f4b740",
/** stroke/ink for a hollow, cancelled bar. */
cancelled: "#9aa3ad",
now: "#2dd4bf",
} as const;
export type RunSourceKind = "delegated" | "automation";
/** How a run was started: delegated (has a kickoff actor) vs. automation/self. */
export function barSourceKind(bar: PositionedBar): RunSourceKind {
return bar.kickoff ? "delegated" : "automation";
}
/** Source colour for a bar under the "Signal" encoding. */
export function barColor(bar: PositionedBar): string {
return TIMELINE_COLORS[barSourceKind(bar)];
}
export function actorType(actor: WorkTimelineActor | undefined): string {
return actor?.type ?? "system";
}
/** Deterministic, stable hue per issue that reads on both light and dark. */
export function issueColor(key: string): string {
let hash = 0;
for (let i = 0; i < key.length; i++) {
hash = (hash * 31 + key.charCodeAt(i)) & 0xffffffff;
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue} 62% 52%)`;
}
/** 2-char initials for an SVG avatar chip. */
export function shortLabel(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return (name.slice(0, 2) || "?").toUpperCase();
}
function spanStartMs(s: WorkTimelineSpan): number {
return new Date(s.start).getTime();
}
function spanEndMs(s: WorkTimelineSpan, nowMs: number): number {
const raw = s.end ? new Date(s.end).getTime() : nowMs;
return raw;
}
/**
* Resolve the kickoff actor for a run: the source of the delegation/assignment
* edge that points at this run's actor on this run's issue, closest at-or-before
* the run start (falling back to the nearest such edge). Mirrors the design's
* "avatar chip at the leading edge = who kicked it off".
*/
function resolveKickoff(
span: WorkTimelineSpan,
edges: WorkTimelineEdge[],
actorById: Map<string, WorkTimelineActor>,
): WorkTimelineActor | null {
const start = spanStartMs(span);
let best: { edge: WorkTimelineEdge; delta: number } | null = null;
for (const e of edges) {
if (e.toActorId !== span.actorId || e.issueId !== span.issueId) continue;
if (e.fromActorId === span.actorId) continue; // self-kickoff is not a delegation
const at = new Date(e.at).getTime();
// Prefer edges at-or-before the run start; otherwise smallest absolute gap.
const delta = at <= start ? start - at : (at - start) + 1e12;
if (!best || delta < best.delta) best = { edge: e, delta };
}
if (!best) return null;
return actorById.get(best.edge.fromActorId) ?? null;
}
export function computeLayout(result: WorkTimelineResult, opts: LayoutOptions): TimelineLayout {
const { pxPerMinute, gutter, rowH, barH, laneGap, nowMs } = opts;
const fromMs = new Date(result.window.from).getTime();
const toMs = new Date(result.window.to).getTime();
const actorById = new Map(result.actors.map((a) => [a.id, a]));
const x = (ms: number) => gutter + ((ms - fromMs) / 60000) * pxPerMinute;
// Rows: any actor with in-window runs gets a row. Event-only comments/creates
// do not create marker-only rows because they make the chart noisy.
const firstActivity = new Map<string, number>();
const noteActivity = (actorId: string, t: number) => {
const cur = firstActivity.get(actorId);
if (cur === undefined || t < cur) firstActivity.set(actorId, t);
};
for (const s of result.spans) noteActivity(s.actorId, spanStartMs(s));
const rowActors = result.actors
.filter((a) => firstActivity.has(a.id)) // drop actors with no run in-window
.sort((a, b) => (firstActivity.get(a.id)! - firstActivity.get(b.id)!));
// Issue hue map (ordered by first appearance) for the task color map.
const issueOrder: string[] = [];
const issueLabel = new Map<string, string>();
for (const s of result.spans) {
const key = s.issueId;
if (!issueLabel.has(key)) {
issueOrder.push(key);
issueLabel.set(key, s.issueIdentifier ?? s.issueTitle ?? "issue");
}
}
const issues = issueOrder.map((key) => ({ key, label: issueLabel.get(key)!, color: issueColor(key) }));
const barIndex = new Map<string, PositionedBar>(); // runId -> bar
const rows: ActorRow[] = [];
let y = 0;
for (const actor of rowActors) {
const runs = result.spans
.filter((s) => s.actorId === actor.id)
.sort((p, q) => spanStartMs(p) - spanStartMs(q));
// Greedy pack overlapping runs into sub-lanes.
const laneEnds: number[] = [];
const laneOf = new Map<string, number>();
for (const r of runs) {
const rs = spanStartMs(r);
const re = spanEndMs(r, nowMs);
let placed = -1;
for (let ln = 0; ln < laneEnds.length; ln++) {
if (laneEnds[ln] <= rs) {
placed = ln;
break;
}
}
if (placed === -1) {
placed = laneEnds.length;
laneEnds.push(re);
} else {
laneEnds[placed] = re;
}
laneOf.set(r.runId, placed);
}
const laneCount = Math.max(1, laneEnds.length);
const h = Math.max(rowH, laneCount * (barH + laneGap) + 8);
const bars: PositionedBar[] = runs.map((r) => {
const lane = laneOf.get(r.runId) ?? 0;
const laneTop = y + 6 + lane * (barH + laneGap);
const x1 = x(spanStartMs(r));
const x2raw = x(spanEndMs(r, nowMs));
const x2 = Math.max(x1 + 3, x2raw); // clamp sub-minute runs to a visible min width
const bar: PositionedBar = {
span: r,
x1,
x2,
yTop: laneTop,
yc: laneTop + barH / 2,
height: barH,
running: isRunningStatus(r.status),
kickoff: resolveKickoff(r, result.edges, actorById),
};
barIndex.set(r.runId, bar);
return bar;
});
const markers: PositionedMarker[] = [];
const activeMs = runs.reduce((sum, r) => sum + Math.max(0, spanEndMs(r, nowMs) - spanStartMs(r)), 0);
rows.push({ actor, y, h, laneCount, bars, markers, runCount: runs.length, activeMs });
y += h;
}
// Connectors: straight agent→agent lines, connected at both ends. For each bar
// with an agent/system kickoff, connect from the kickoff actor's nearest
// preceding bar (same issue preferred) to this bar's leading edge.
const connectors: Connector[] = [];
for (const row of rows) {
for (const bar of row.bars) {
const k = bar.kickoff;
if (!k || k.type === "user") continue; // agent→agent only; humans stay as chips
const source = nearestSourceBar(k.id, bar, barIndex, nowMs);
if (!source) continue;
connectors.push({
x1: source.x2,
y1: source.yc,
x2: bar.x1,
y2: bar.yc,
sourceRunId: source.span.runId,
targetRunId: bar.span.runId,
dashed: Boolean(bar.span.retryOfRunId),
});
}
}
const width = gutter + ((toMs - fromMs) / 60000) * pxPerMinute + 40;
const height = y + AXIS_H;
return { rows, connectors, width, height, fromMs, toMs, gutter, pxPerMinute, issues };
}
/** The kickoff actor's bar that best precedes `target` (same issue preferred). */
function nearestSourceBar(
kickoffActorId: string,
target: PositionedBar,
barIndex: Map<string, PositionedBar>,
nowMs: number,
): PositionedBar | null {
const targetStart = spanStartMs(target.span);
let sameIssue: PositionedBar | null = null;
let anyIssue: PositionedBar | null = null;
for (const bar of barIndex.values()) {
if (bar.span.actorId !== kickoffActorId) continue;
if (bar === target) continue;
const end = spanEndMs(bar.span, nowMs);
if (end > targetStart + 1) continue; // must precede (small tolerance)
if (bar.span.issueId === target.span.issueId) {
if (!sameIssue || spanEndMs(sameIssue.span, nowMs) < end) sameIssue = bar;
}
if (!anyIssue || spanEndMs(anyIssue.span, nowMs) < end) anyIssue = bar;
}
return sameIssue ?? anyIssue;
}
/** Choose a "nice" gridline step (ms) targeting ~120px between labels. */
export function chooseTickStepMs(pxPerMinute: number): number {
const targetPx = 120;
const minutesPerTarget = targetPx / pxPerMinute;
const steps = [15, 30, 60, 120, 180, 360, 720, 1440, 2880, 10080]; // minutes
for (const m of steps) {
if (m >= minutesPerTarget) return m * 60000;
}
return steps[steps.length - 1] * 60000;
}
export function formatDuration(startMs: number, endMs: number): string {
const mins = Math.max(0, Math.round((endMs - startMs) / 60000));
if (mins >= 1440) return `${Math.floor(mins / 1440)}d ${Math.floor((mins % 1440) / 60)}h`;
if (mins >= 60) return `${Math.floor(mins / 60)}h ${mins % 60}m`;
return `${mins}m`;
}
+36
View File
@@ -0,0 +1,36 @@
import { existsSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const uiRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const fontDir = path.join(uiRoot, "public", "fonts");
const cssPath = path.join(uiRoot, "src", "index.css");
const fontFiles = ["InterVariable.woff2", "InterVariable-Italic.woff2"];
describe("bundled UI font assets", () => {
it("ships the Inter variable font files used by the CSS", () => {
const css = readFileSync(cssPath, "utf8");
for (const fileName of fontFiles) {
const fontPath = path.join(fontDir, fileName);
expect(existsSync(fontPath), `${fileName} should exist in ui/public/fonts`).toBe(true);
expect(statSync(fontPath).isFile(), `${fileName} should be a file`).toBe(true);
expect(readFileSync(fontPath).subarray(0, 4).toString("ascii")).toBe("wOF2");
expect(css).toContain(`url("../fonts/${fileName}")`);
}
expect(css).toContain('--font-sans: "InterVariable"');
});
it("includes redistribution notice text for the bundled Inter files", () => {
const notice = readFileSync(path.join(fontDir, "NOTICE.md"), "utf8");
expect(notice).toContain("Inter");
expect(notice).toContain("v4.1");
expect(notice).toContain("SIL Open Font License 1.1");
expect(notice).toContain("InterVariable.woff2");
expect(notice).toContain("InterVariable-Italic.woff2");
});
});
+44 -25
View File
@@ -38,6 +38,7 @@ import { MarkdownBody } from "../components/MarkdownBody";
import { CopyText } from "../components/CopyText";
import { EntityRow } from "../components/EntityRow";
import { MembershipAction } from "../components/MembershipAction";
import { StarToggle } from "../components/StarToggle";
import { Identity } from "../components/Identity";
import { PageSkeleton } from "../components/PageSkeleton";
import { AgentActionButtons } from "../components/AgentActionButtons";
@@ -101,6 +102,7 @@ import { buildPermissionsForTrustPreset, getTrustPreset } from "../lib/trust-pol
import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils";
import { agentRouteRef } from "../lib/utils";
import {
isStarred,
resourceMembershipState,
useResourceMembershipMutation,
useResourceMemberships,
@@ -954,6 +956,9 @@ export function AgentDetail() {
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id;
const agentStarred = isStarred(membershipsQuery.data, "agent", agent.id);
const agentStarPending = agentMembershipPending && membershipMutation.variables?.starred !== undefined;
const agentJoinLeavePending = agentMembershipPending && membershipMutation.variables?.starred === undefined;
return (
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
@@ -965,8 +970,8 @@ export function AgentDetail() {
<MembershipAction
compact
state="left"
pending={agentMembershipPending}
pendingState={agentMembershipPending ? membershipMutation.variables?.state : null}
pending={agentJoinLeavePending}
pendingState={agentJoinLeavePending ? membershipMutation.variables?.state : null}
resourceName={agent.name}
onJoin={() => membershipMutation.mutate({
resourceType: "agent",
@@ -1031,29 +1036,43 @@ export function AgentDetail() {
</p>
</div>
</div>
<AgentActionButtons
agent={agent}
companyId={resolvedCompanyId}
assignLabel="Assign Task"
runLabel="Run Heartbeat"
actionsDisabled={agentAction.isPending}
workActionsDisabled={hasInvalidOrgChain}
workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs"
onActionError={setActionError}
>
{mobileLiveRun && (
<Link
to={`/agents/${canonicalAgentRef}/runs/${mobileLiveRun.id}`}
className="sm:hidden flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline"
>
<span className="relative flex h-2 w-2">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
</Link>
)}
</AgentActionButtons>
<div className="flex items-center gap-2 shrink-0">
<StarToggle
size="button"
starred={agentStarred}
pending={agentStarPending}
resourceName={agent.name}
onToggle={(next) => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
starred: next,
})}
/>
<AgentActionButtons
agent={agent}
companyId={resolvedCompanyId}
assignLabel="Assign Task"
runLabel="Run Heartbeat"
actionsDisabled={agentAction.isPending}
workActionsDisabled={hasInvalidOrgChain}
workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs"
onActionError={setActionError}
>
{mobileLiveRun && (
<Link
to={`/agents/${canonicalAgentRef}/runs/${mobileLiveRun.id}`}
className="sm:hidden flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline"
>
<span className="relative flex h-2 w-2">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
</Link>
)}
</AgentActionButtons>
</div>
</div>
{!urlRunId && (
+40 -14
View File
@@ -13,6 +13,7 @@ import { queryKeys } from "../lib/queryKeys";
import { AgentStatusBadge, AgentStatusCapsule } from "../components/StatusBadge";
import { AgentActionButtons } from "../components/AgentActionButtons";
import { MembershipAction } from "../components/MembershipAction";
import { StarToggle } from "../components/StarToggle";
import { EntityRow } from "../components/EntityRow";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
@@ -23,6 +24,7 @@ import { Button } from "@/components/ui/button";
import { AlertTriangle, Bot, Plus, List, GitBranch } from "lucide-react";
import { AGENT_ROLE_LABELS, type Agent, type Environment, type EnvironmentCapabilities } from "@paperclipai/shared";
import {
isStarred,
resourceMembershipState,
useResourceMembershipMutation,
useResourceMemberships,
@@ -271,6 +273,13 @@ export function Agents() {
const renderAgentRow = (agent: Agent) => {
const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain";
const agentPending =
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id;
const agentStarPending = agentPending && membershipMutation.variables?.starred !== undefined;
const agentJoinLeavePending = agentPending && membershipMutation.variables?.starred === undefined;
const agentStarred = isStarred(membershipsQuery.data, "agent", agent.id);
return (
<EntityRow
key={agent.id}
@@ -343,18 +352,8 @@ export function Agents() {
</div>
<MembershipAction
state={resourceMembershipState(membershipsQuery.data, "agent", agent.id)}
pending={
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id
}
pendingState={
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id
? membershipMutation.variables.state
: null
}
pending={agentJoinLeavePending}
pendingState={agentJoinLeavePending ? membershipMutation.variables?.state ?? null : null}
resourceName={agent.name}
onJoin={() => membershipMutation.mutate({
resourceType: "agent",
@@ -369,6 +368,18 @@ export function Agents() {
state: "left",
})}
/>
<StarToggle
size="row"
starred={agentStarred}
pending={agentStarPending}
resourceName={agent.name}
onToggle={(next) => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
starred: next,
})}
/>
</div>
}
/>
@@ -514,6 +525,9 @@ function OrgTreeNode({
const pending = membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === node.id;
const starPending = pending && membershipMutation.variables?.starred !== undefined;
const joinLeavePending = pending && membershipMutation.variables?.starred === undefined;
const starred = isStarred(memberships, "agent", node.id);
return (
<div style={{ paddingLeft: depth * 24 }}>
@@ -576,8 +590,8 @@ function OrgTreeNode({
</div>
<MembershipAction
state={membershipState}
pending={pending}
pendingState={pending ? membershipMutation.variables?.state : null}
pending={joinLeavePending}
pendingState={joinLeavePending ? membershipMutation.variables?.state : null}
resourceName={node.name}
onJoin={() => membershipMutation.mutate({
resourceType: "agent",
@@ -592,6 +606,18 @@ function OrgTreeNode({
state: "left",
})}
/>
<StarToggle
size="row"
starred={starred}
pending={starPending}
resourceName={node.name}
onToggle={(next) => membershipMutation.mutate({
resourceType: "agent",
resourceId: node.id,
resourceName: node.name,
starred: next,
})}
/>
</div>
</Link>
{node.reports && node.reports.length > 0 && (
@@ -122,7 +122,6 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
await renderPage();
const headings = [...container.querySelectorAll("section h2")].map((h) => h.textContent);
expect(headings).toContain("Streamlined Left Navigation Bar");
expect(headings).not.toContain("Conference Room Chat");
expect(container.querySelector(CONFERENCE_TOGGLE_SELECTOR)).toBeNull();
});
@@ -147,21 +146,13 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled();
});
it("renders the Streamlined Left Navigation toggle on by default and patches opt-out", async () => {
it("no longer renders the Streamlined Left Navigation toggle (opt-out retired, PAP-12472)", async () => {
await renderPage();
const toggle = container.querySelector<HTMLButtonElement>(STREAMLINED_TOGGLE_SELECTOR);
expect(toggle?.getAttribute("aria-checked")).toBe("true");
await act(async () => {
toggle?.click();
});
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
enableStreamlinedLeftNavigation: false,
});
expect(toggle?.getAttribute("aria-checked")).toBe("false");
const headings = [...container.querySelectorAll("section h2")].map((h) => h.textContent);
expect(headings).not.toContain("Streamlined Left Navigation Bar");
expect(container.querySelector(STREAMLINED_TOGGLE_SELECTOR)).toBeNull();
expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled();
});
it("renders and patches the Task Watchdogs experimental toggle on and off", async () => {
+2 -26
View File
@@ -232,10 +232,8 @@ export function InstanceExperimentalSettings() {
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
// Default ON: treat anything but an explicit `false` as enabled so
// the toggle reflects the streamlined sidebar being the default experience.
const enableStreamlinedLeftNavigation =
experimentalQuery.data?.enableStreamlinedLeftNavigation !== false;
// Streamlined left navigation is now the standard sidebar (PAP-12472); the
// experimental opt-out was retired, so it no longer surfaces a toggle here.
const enableConferenceRoomChat = experimentalQuery.data?.enableConferenceRoomChat === true;
const enableIssuePlanDecompositions =
experimentalQuery.data?.enableIssuePlanDecompositions === true;
@@ -393,28 +391,6 @@ export function InstanceExperimentalSettings() {
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Streamlined Left Navigation Bar</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Reduces the maximum number of items in the left navigation bar — nests Projects under Work with a
dedicated Projects page, and shows only active agents (max 5 recently-active) in the sidebar.
</p>
</div>
<ToggleSwitch
checked={enableStreamlinedLeftNavigation}
onCheckedChange={() =>
toggleMutation.mutate({
enableStreamlinedLeftNavigation: !enableStreamlinedLeftNavigation,
})
}
disabled={toggleMutation.isPending}
aria-label="Toggle streamlined left navigation experimental setting"
/>
</div>
</section>
{SHOW_CONFERENCE_ROOM_EXPERIMENTAL_SETTING ? (
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
+21 -2
View File
@@ -25,6 +25,7 @@ import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
import { ProjectWorkspacesContent } from "../components/ProjectWorkspacesContent";
import { MembershipAction } from "../components/MembershipAction";
import { StarToggle } from "../components/StarToggle";
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
import { projectRouteRef } from "../lib/utils";
@@ -37,6 +38,7 @@ import { Tabs } from "@/components/ui/tabs";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import {
isStarred,
resourceMembershipState,
useResourceMembershipMutation,
useResourceMemberships,
@@ -696,6 +698,9 @@ export function ProjectDetail() {
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "project" &&
membershipMutation.variables.resourceId === project.id;
const projectStarred = isStarred(membershipsQuery.data, "project", project.id);
const projectStarPending = projectMembershipPending && membershipMutation.variables?.starred !== undefined;
const projectJoinLeavePending = projectMembershipPending && membershipMutation.variables?.starred === undefined;
const handleTabChange = (tab: ProjectTab) => {
// Cache the active tab per project
@@ -731,8 +736,8 @@ export function ProjectDetail() {
<MembershipAction
compact
state="left"
pending={projectMembershipPending}
pendingState={projectMembershipPending ? membershipMutation.variables?.state : null}
pending={projectJoinLeavePending}
pendingState={projectJoinLeavePending ? membershipMutation.variables?.state : null}
resourceName={project.name}
onJoin={() => membershipMutation.mutate({
resourceType: "project",
@@ -786,6 +791,20 @@ export function ProjectDetail() {
</div>
) : null}
</div>
<div className="ml-auto flex items-center gap-2">
<StarToggle
size="button"
starred={projectStarred}
pending={projectStarPending}
resourceName={project.name}
onToggle={(next) => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
starred: next,
})}
/>
</div>
</div>
<PluginSlotOutlet
+19 -2
View File
@@ -10,10 +10,12 @@ import { EntityRow } from "../components/EntityRow";
import { ProjectTile } from "../components/ProjectTile";
import { StatusBadge } from "../components/StatusBadge";
import { MembershipAction } from "../components/MembershipAction";
import { StarToggle } from "../components/StarToggle";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { formatDate, formatNumber, formatProjectBudget, projectUrl } from "../lib/utils";
import {
isStarred,
resourceMembershipState,
useResourceMembershipMutation,
useResourceMemberships,
@@ -204,6 +206,9 @@ export function Projects() {
const pending = membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "project" &&
membershipMutation.variables.resourceId === project.id;
const starPending = pending && membershipMutation.variables?.starred !== undefined;
const joinLeavePending = pending && membershipMutation.variables?.starred === undefined;
const starred = isStarred(membershipsQuery.data, "project", project.id);
return (
<EntityRow
key={project.id}
@@ -234,8 +239,8 @@ export function Projects() {
<StatusBadge status={project.status} />
<MembershipAction
state={state}
pending={pending}
pendingState={pending ? membershipMutation.variables?.state : null}
pending={joinLeavePending}
pendingState={joinLeavePending ? membershipMutation.variables?.state : null}
resourceName={project.name}
onJoin={() => membershipMutation.mutate({
resourceType: "project",
@@ -250,6 +255,18 @@ export function Projects() {
state: "left",
})}
/>
<StarToggle
size="row"
starred={starred}
pending={starPending}
resourceName={project.name}
onToggle={(next) => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
starred: next,
})}
/>
</div>
}
/>
+138
View File
@@ -0,0 +1,138 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { WorkTimelineResult } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Timeline } from "./Timeline";
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockWorkTimelineApi = vi.hoisted(() => ({
get: vi.fn(),
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }),
}));
vi.mock("@/api/workTimeline", () => ({
workTimelineApi: mockWorkTimelineApi,
}));
vi.mock("@/components/RequestCollapsedSidebar", () => ({
RequestCollapsedSidebar: () => <div data-testid="request-collapsed-sidebar" />,
}));
const emptyTimeline: WorkTimelineResult = {
actors: [],
spans: [],
events: [],
edges: [],
pagination: {
limit: 100,
offset: 0,
totalIssues: 0,
hasMore: false,
},
window: {
from: "2026-07-01T00:00:00.000Z",
to: "2026-07-07T23:59:59.999Z",
capped: false,
},
};
async function flushReact() {
for (let index = 0; index < 3; index += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
flushSync(() => {});
}
describe("Timeline", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;
let queryClient: QueryClient;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
mockWorkTimelineApi.get.mockResolvedValue(emptyTimeline);
});
afterEach(() => {
if (root) {
flushSync(() => root?.unmount());
}
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("requests the collapsed app sidebar by default", async () => {
root = createRoot(container);
flushSync(() => {
root?.render(
<QueryClientProvider client={queryClient}>
<Timeline />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.querySelector('[data-testid="request-collapsed-sidebar"]')).not.toBeNull();
});
it("renders range controls plus icon zoom controls without the user lens selector or visible-duration readout", async () => {
root = createRoot(container);
flushSync(() => {
root?.render(
<QueryClientProvider client={queryClient}>
<Timeline />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("Range");
expect(container.querySelector('[aria-label="Zoom out"]')).not.toBeNull();
expect(container.querySelector('[aria-label="Zoom in"]')).not.toBeNull();
expect(container.querySelector('[aria-label="Reset zoom"]')).not.toBeNull();
expect(container.textContent).not.toContain("Everyone");
expect(container.textContent).not.toContain("work kicked off");
expect(container.textContent).not.toContain("visible");
});
it("requests the company timeline without a user lens parameter", async () => {
root = createRoot(container);
flushSync(() => {
root?.render(
<QueryClientProvider client={queryClient}>
<Timeline />
</QueryClientProvider>,
);
});
await flushReact();
expect(mockWorkTimelineApi.get).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
from: expect.any(String),
to: expect.any(String),
}),
);
expect(mockWorkTimelineApi.get.mock.calls[0]?.[1]).not.toHaveProperty("userId");
});
});
+318
View File
@@ -0,0 +1,318 @@
/**
* Work Timeline page (PAP-12424 / Phase C of PAP-12405).
*
* A Gantt-style view of company actor activity built on the Phase B endpoint
* (`GET /companies/:companyId/timeline`). Rendering is the board-locked
* Direction C (PAP-12422): dense rows, mini-map brush, custom inline SVG.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { GanttChartSquare, Minus, Plus, RotateCcw } from "lucide-react";
import { workTimelineApi, type WorkTimelineParams } from "@/api/workTimeline";
import { queryKeys } from "@/lib/queryKeys";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { EmptyState } from "@/components/EmptyState";
import { PageSkeleton } from "@/components/PageSkeleton";
import { RequestCollapsedSidebar } from "@/components/RequestCollapsedSidebar";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
WorkTimelineChart,
clampZoomScale,
defaultZoomForWindow,
nearestZoomForScale,
type ZoomLevel,
zoomScaleForLevel,
} from "@/components/timeline/WorkTimelineChart";
import { TIMELINE_COLORS } from "@/lib/timeline/layout";
import { cn } from "@/lib/utils";
type RangePreset = "today" | "7d" | "30d" | "custom";
interface DateRangeState {
fromDate: string;
toDate: string;
}
function dateInputValue(date: Date): string {
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
function presetRange(preset: Exclude<RangePreset, "custom">, now = new Date()): DateRangeState {
const from = new Date(now);
const to = new Date(now);
if (preset === "today") {
return { fromDate: dateInputValue(from), toDate: dateInputValue(to) };
} else {
from.setDate(from.getDate() - (preset === "7d" ? 6 : 29));
}
return { fromDate: dateInputValue(from), toDate: dateInputValue(to) };
}
function rangeWindow(range: DateRangeState): Pick<WorkTimelineParams, "from" | "to"> | null {
if (!range.fromDate || !range.toDate) return null;
const from = new Date(`${range.fromDate}T00:00:00`);
const to = new Date(`${range.toDate}T23:59:59.999`);
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from > to) return null;
return { from: from.toISOString(), to: to.toISOString() };
}
function rangeError(range: DateRangeState): string | null {
if (!range.fromDate || !range.toDate) return "Choose a start and end date.";
if (!rangeWindow(range)) return "Start date must be before end date.";
return null;
}
function Segmented<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: { value: T; label: string }[];
onChange: (v: T) => void;
}) {
return (
<div className="inline-flex overflow-hidden rounded-md border border-border">
{options.map((opt, i) => (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
aria-pressed={value === opt.value}
className={cn(
"px-3 py-1.5 text-xs transition-colors",
i > 0 && "border-l border-border",
value === opt.value
? "bg-primary text-primary-foreground"
: "bg-card text-foreground hover:bg-muted",
)}
>
{opt.label}
</button>
))}
</div>
);
}
/** Encoding key for the "Signal" timeline: colour = how each run started. */
function TimelineLegend() {
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border px-3.5 py-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-4 rounded-sm" style={{ backgroundColor: TIMELINE_COLORS.delegated }} />
Delegated
</span>
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-4 rounded-sm" style={{ backgroundColor: TIMELINE_COLORS.automation }} />
Automation
</span>
<span className="flex items-center gap-1.5">
<span
className="h-2.5 w-4 rounded-sm border border-dashed bg-transparent"
style={{ borderColor: TIMELINE_COLORS.cancelled }}
/>
Cancelled
</span>
<span className="flex items-center gap-1.5">
<span className="h-3.5 w-0.5" style={{ backgroundColor: TIMELINE_COLORS.now }} />
Now
</span>
</div>
);
}
export function Timeline() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [zoom, setZoom] = useState<ZoomLevel>("day");
const [zoomScale, setZoomScale] = useState<number | undefined>(undefined);
const zoomTouched = useRef(false);
const [rangePreset, setRangePreset] = useState<RangePreset>("7d");
const [dateRange, setDateRange] = useState<DateRangeState>(() => presetRange("7d"));
useEffect(() => {
setBreadcrumbs([{ label: "Timeline" }]);
}, [setBreadcrumbs]);
const dateRangeError = rangeError(dateRange);
const params: WorkTimelineParams | null = useMemo(() => {
const window = rangeWindow(dateRange);
if (!window) return null;
return window;
}, [dateRange]);
const { data, isLoading, error } = useQuery({
queryKey: [...queryKeys.workTimeline(selectedCompanyId ?? ""), dateRange.fromDate, dateRange.toDate],
queryFn: () => workTimelineApi.get(selectedCompanyId!, params!),
enabled: !!selectedCompanyId && !!params,
});
useEffect(() => {
if (!data || zoomTouched.current) return;
const defaultZoom = defaultZoomForWindow(new Date(data.window.from).getTime(), new Date(data.window.to).getTime());
setZoom(defaultZoom);
setZoomScale(undefined);
}, [data]);
if (!selectedCompanyId) {
return (
<>
<RequestCollapsedSidebar />
<EmptyState icon={GanttChartSquare} message="Select a company to view its work timeline." />
</>
);
}
const header = (
<div className="flex items-center gap-2">
<GanttChartSquare className="h-6 w-6 text-muted-foreground" />
<h1 className="text-3xl font-semibold tracking-tight">Work Timeline</h1>
</div>
);
const adjustZoom = (factor: number) => {
zoomTouched.current = true;
const nextScale = clampZoomScale((zoomScale ?? zoomScaleForLevel(zoom)) * factor);
setZoomScale(nextScale);
setZoom(nearestZoomForScale(nextScale));
};
const resetZoom = () => {
zoomTouched.current = true;
if (data) {
setZoom(defaultZoomForWindow(new Date(data.window.from).getTime(), new Date(data.window.to).getTime()));
} else {
setZoom("day");
}
setZoomScale(undefined);
};
const toolbar = (
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
<label className="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
Range
<Segmented
value={rangePreset}
onChange={(preset) => {
if (preset === "custom") return;
setRangePreset(preset);
setDateRange(presetRange(preset));
}}
options={[
{ value: "today", label: "Today" },
{ value: "7d", label: "7 days" },
{ value: "30d", label: "30 days" },
]}
/>
<Input
type="date"
value={dateRange.fromDate}
onChange={(event) => {
setRangePreset("custom");
setDateRange((prev) => ({ ...prev, fromDate: event.target.value }));
}}
className="h-8 w-[150px] text-xs"
aria-label="Timeline start date"
/>
<span>to</span>
<Input
type="date"
value={dateRange.toDate}
onChange={(event) => {
setRangePreset("custom");
setDateRange((prev) => ({ ...prev, toDate: event.target.value }));
}}
className="h-8 w-[150px] text-xs"
aria-label="Timeline end date"
/>
</label>
<div className="ml-auto flex items-center gap-1" aria-label="Timeline zoom controls">
<Button
type="button"
variant="outline"
size="icon-xs"
onClick={() => adjustZoom(0.8)}
aria-label="Zoom out"
title="Zoom out"
>
<Minus className="h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="icon-xs"
onClick={() => adjustZoom(1.25)}
aria-label="Zoom in"
title="Zoom in"
>
<Plus className="h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="icon-xs"
onClick={resetZoom}
aria-label="Reset zoom"
title="Reset zoom"
>
<RotateCcw className="h-3 w-3" />
</Button>
</div>
</div>
);
return (
<div className="space-y-6">
<RequestCollapsedSidebar />
{header}
{toolbar}
{isLoading && <PageSkeleton />}
{dateRangeError && (
<EmptyState
icon={GanttChartSquare}
message={dateRangeError}
/>
)}
{error && (
<EmptyState
icon={GanttChartSquare}
message="Couldn't load the timeline. The aggregation endpoint may be unavailable."
/>
)}
{data && !isLoading && !dateRangeError && (
data.spans.length === 0 ? (
<EmptyState icon={GanttChartSquare} message="No activity in this window." />
) : (
<div className="space-y-3">
<div className="rounded-lg border border-border bg-card">
<TimelineLegend />
<WorkTimelineChart
data={data}
zoom={zoom}
zoomScale={zoomScale}
onZoomScaleChange={(nextScale, nextZoom = nearestZoomForScale(nextScale)) => {
zoomTouched.current = true;
setZoomScale(nextScale);
setZoom(nextZoom);
}}
/>
</div>
<p className="text-xs text-muted-foreground">
{data.spans.length} run{data.spans.length === 1 ? "" : "s"} ·{" "}
{new Date(data.window.from).toLocaleString()} → {new Date(data.window.to).toLocaleString()}
{data.window.capped ? " · window capped" : ""}
</p>
</div>
)
)}
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
{"actors": [{"id": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "type": "agent", "name": "CEO", "avatar": "crown"}, {"id": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "type": "agent", "name": "ClaudeCoder", "avatar": "code"}, {"id": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "type": "agent", "name": "CloudOpsEngineer", "avatar": "cog"}, {"id": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "type": "agent", "name": "UXDesigner", "avatar": "gem"}, {"id": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", "type": "agent", "name": "CTO", "avatar": "file-code"}, {"id": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "type": "agent", "name": "Senior Engineer Fable", "avatar": "code"}, {"id": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", "type": "agent", "name": "Senior Planning Engineer Pro", "avatar": "code"}, {"id": "agent:c9dcefd1-b052-489a-a1f3-4cd1783bcb5f", "type": "agent", "name": "DevRel", "avatar": "globe"}, {"id": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "type": "agent", "name": "CodexCoder", "avatar": "code"}, {"id": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "type": "agent", "name": "QA", "avatar": "bug"}, {"id": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", "type": "agent", "name": "SecurityEngineer", "avatar": "shield"}, {"id": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", "type": "agent", "name": "Strategic Ops", "avatar": "target"}], "spans": [{"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "063a9727-b191-49e2-9802-b6b63ec65b97", "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", "issueIdentifier": "PAP-12424", "issueTitle": "Work Timeline \u2014 Phase C: frontend Gantt component + Timeline page", "start": "2026-07-02T15:41:30.586Z", "end": null, "status": "running", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "20f1e461-b4b6-4d5f-b897-6f60656429fb", "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", "issueIdentifier": "PAP-12298", "issueTitle": "blog page redesign", "start": "2026-07-02T15:35:56.578Z", "end": "2026-07-02T15:38:40.543Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "laneHint": "automation", "runId": "040ce2ef-acd4-47f2-bcf1-5a1194f5a450", "issueId": "e0189c44-1245-4526-9423-1128862d7c95", "issueIdentifier": "PAP-12438", "issueTitle": "environment variables editor re-design", "start": "2026-07-02T15:34:59.718Z", "end": "2026-07-02T15:38:04.415Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "laneHint": "automation", "runId": "339694a1-6a7d-4de0-8466-43574a7c6b4d", "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", "issueIdentifier": "PAP-12422", "issueTitle": "Work Timeline \u2014 Phase A: wireframe exploration (2\u20133 directions, board picks one)", "start": "2026-07-02T15:28:13.886Z", "end": "2026-07-02T15:42:27.258Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", "laneHint": "assignment", "runId": "9eb8e7ba-53b5-4d22-8f8d-cbba6a5c5bb5", "issueId": "9ae0377a-8040-4221-9376-4ae64270e3b4", "issueIdentifier": "PAP-12440", "issueTitle": "Defect (Minor): teams-catalog install is not atomic \u2014 mid-import failure can orphan skills/agents", "start": "2026-07-02T15:27:39.339Z", "end": "2026-07-02T15:37:04.236Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "laneHint": "automation", "runId": "2bd1ec14-ecbb-42d8-8412-106a251780b8", "issueId": "e0189c44-1245-4526-9423-1128862d7c95", "issueIdentifier": "PAP-12438", "issueTitle": "environment variables editor re-design", "start": "2026-07-02T15:26:14.310Z", "end": "2026-07-02T15:34:59.661Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "laneHint": "assignment", "runId": "1b734867-943b-4ef3-a10e-3f2c1edcad93", "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", "issueIdentifier": "PAP-12439", "issueTitle": "Wireframes: Environment Variables Editor redesign", "start": "2026-07-02T15:25:07.982Z", "end": "2026-07-02T15:32:24.457Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "88e7b7a9-4ea6-40bc-b402-ffd731a3dfe2", "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", "issueIdentifier": "PAP-12298", "issueTitle": "blog page redesign", "start": "2026-07-02T15:23:28.308Z", "end": "2026-07-02T15:29:09.108Z", "status": "succeeded", "retrLine truncated
@@ -0,0 +1,129 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { WorkTimelineResult } from "@paperclipai/shared";
import { Minus, Plus, RotateCcw } from "lucide-react";
import {
WorkTimelineChart,
clampZoomScale,
nearestZoomForScale,
type ZoomLevel,
zoomScaleForLevel,
} from "@/components/timeline/WorkTimelineChart";
import { Button } from "@/components/ui/button";
import sampleJson from "../fixtures/workTimeline.sample.json";
import humanSampleJson from "../fixtures/workTimeline.human.sample.json";
const sample = sampleJson as unknown as WorkTimelineResult;
// A second real slice (2026-07-02 14:00–16:00Z) captured straight from the live
// `/timeline` endpoint that DOES carry human events — Dotta's created / commented /
// approved / delegated actions provide human participation and kickoff context.
const humanSample = humanSampleJson as unknown as WorkTimelineResult;
// The fixture is a real slice of PAP company activity (2026-07-02 14:00–15:50Z);
// pin "now" to the window end so in-progress runs fade correctly.
const NOW = new Date("2026-07-02T15:45:00.000Z").getTime();
function TimelineHarness({
initialZoom = "day" as ZoomLevel,
data = sample,
now = NOW,
}: {
initialZoom?: ZoomLevel;
data?: WorkTimelineResult;
now?: number;
}) {
const [zoom, setZoom] = useState<ZoomLevel>(initialZoom);
const [zoomScale, setZoomScale] = useState<number | undefined>(undefined);
const adjustZoom = (factor: number) => {
const nextScale = clampZoomScale((zoomScale ?? zoomScaleForLevel(zoom)) * factor);
setZoomScale(nextScale);
setZoom(nearestZoomForScale(nextScale));
};
const resetZoom = () => {
setZoom(initialZoom);
setZoomScale(undefined);
};
return (
<div className="min-h-screen bg-background p-6 text-foreground">
<div className="space-y-6">
<div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight">Work Timeline</h1>
</div>
<div className="flex flex-wrap items-center justify-end gap-1" aria-label="Timeline zoom controls">
<Button
type="button"
variant="outline"
size="icon-xs"
onClick={() => adjustZoom(0.8)}
aria-label="Zoom out"
title="Zoom out"
>
<Minus className="h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="icon-xs"
onClick={() => adjustZoom(1.25)}
aria-label="Zoom in"
title="Zoom in"
>
<Plus className="h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="icon-xs"
onClick={resetZoom}
aria-label="Reset zoom"
title="Reset zoom"
>
<RotateCcw className="h-3 w-3" />
</Button>
</div>
<div className="space-y-3">
<div className="rounded-lg border border-border bg-card">
<WorkTimelineChart
data={data}
zoom={zoom}
zoomScale={zoomScale}
nowMs={now}
onZoomScaleChange={(nextScale, nextZoom = nearestZoomForScale(nextScale)) => {
setZoomScale(nextScale);
setZoom(nextZoom);
}}
/>
</div>
<p className="text-xs text-muted-foreground">
{data.spans.length} runs · {data.actors.length} actors · {data.events.length} human/instant events · real
company data
</p>
</div>
</div>
</div>
);
}
const meta: Meta<typeof TimelineHarness> = {
title: "Pages/Work Timeline",
component: TimelineHarness,
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof TimelineHarness>;
export const HourZoom: Story = { args: { initialZoom: "hour" } };
export const DayZoom: Story = { args: { initialZoom: "day" } };
// Live slice that carries human-originated activity and delegation context.
export const WithHumanActivity: Story = {
args: {
initialZoom: "hour",
data: humanSample,
now: new Date("2026-07-02T16:00:00.000Z").getTime(),
},
};