diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 7b784a32136..47652d17b0a 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -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 diff --git a/doc/spec/ui.md b/doc/spec/ui.md index c2ffdb7c02c..2946b706c60 100644 --- a/doc/spec/ui.md +++ b/doc/spec/ui.md @@ -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 diff --git a/packages/adapters/hermes/gateway-ui-parser.cjs b/packages/adapters/hermes/gateway-ui-parser.cjs index 1cba319c8d6..4654dbdef87 100644 --- a/packages/adapters/hermes/gateway-ui-parser.cjs +++ b/packages/adapters/hermes/gateway-ui-parser.cjs @@ -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 }; diff --git a/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts b/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts index df9db752ddc..2837bea15ef 100644 --- a/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts +++ b/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts @@ -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 }]; } diff --git a/packages/adapters/hermes/src/ui/parse-stdout.test.ts b/packages/adapters/hermes/src/ui/parse-stdout.test.ts new file mode 100644 index 00000000000..8e25cf8cd76 --- /dev/null +++ b/packages/adapters/hermes/src/ui/parse-stdout.test.ts @@ -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); + }); +}); diff --git a/packages/adapters/hermes/src/ui/parse-stdout.ts b/packages/adapters/hermes/src/ui/parse-stdout.ts index 6e48b40ed47..1589843ed95 100644 --- a/packages/adapters/hermes/src/ui/parse-stdout.ts +++ b/packages/adapters/hermes/src/ui/parse-stdout.ts @@ -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 ──────────────────────────────────────────── diff --git a/packages/adapters/hermes/ui-parser.cjs b/packages/adapters/hermes/ui-parser.cjs index 618c37606c8..48d1ac9062a 100644 --- a/packages/adapters/hermes/ui-parser.cjs +++ b/packages/adapters/hermes/ui-parser.cjs @@ -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]")) { diff --git a/packages/db/src/client.test.ts b/packages/db/src/client.test.ts index 552db3b0d7d..75e7effcc54 100644 --- a/packages/db/src/client.test.ts +++ b/packages/db/src/client.test.ts @@ -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, + ); }); diff --git a/packages/db/src/issue-comment-derived-attribution-migration.test.ts b/packages/db/src/issue-comment-derived-attribution-migration.test.ts new file mode 100644 index 00000000000..f208dced2f0 --- /dev/null +++ b/packages/db/src/issue-comment-derived-attribution-migration.test.ts @@ -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> = []; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +async function createTempDatabase(): Promise { + const db = await startEmbeddedPostgresTestDatabase("paperclip-derived-attribution-"); + cleanups.push(db.cleanup); + return db.connectionString; +} + +async function migrationHash(migrationFile: string): Promise { + 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, +): Promise { + const hash = await migrationHash(DERIVED_ATTRIBUTION_MIGRATION); + await sql` + DELETE FROM "drizzle"."__drizzle_migrations" + WHERE "hash" = ${hash} + `; +} + +async function dropDerivedAttributionSchema(sql: ReturnType): Promise { + 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, 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): Promise { + 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, + ); +}); diff --git a/packages/db/src/migrations/0126_issue_comment_derived_attribution.sql b/packages/db/src/migrations/0126_issue_comment_derived_attribution.sql deleted file mode 100644 index 564c2ad7a67..00000000000 --- a/packages/db/src/migrations/0126_issue_comment_derived_attribution.sql +++ /dev/null @@ -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'); diff --git a/packages/db/src/migrations/0130_run_responsible_user_invariant.sql b/packages/db/src/migrations/0130_run_responsible_user_invariant.sql index 87bc74d7711..1ae59fa5a99 100644 --- a/packages/db/src/migrations/0130_run_responsible_user_invariant.sql +++ b/packages/db/src/migrations/0130_run_responsible_user_invariant.sql @@ -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", diff --git a/packages/db/src/migrations/0131_repair_run_responsible_user_context_refs.sql b/packages/db/src/migrations/0131_repair_run_responsible_user_context_refs.sql new file mode 100644 index 00000000000..6edaa302c60 --- /dev/null +++ b/packages/db/src/migrations/0131_repair_run_responsible_user_context_refs.sql @@ -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; diff --git a/packages/db/src/migrations/0132_issue_comment_derived_attribution_fast.sql b/packages/db/src/migrations/0132_issue_comment_derived_attribution_fast.sql new file mode 100644 index 00000000000..4882c766549 --- /dev/null +++ b/packages/db/src/migrations/0132_issue_comment_derived_attribution_fast.sql @@ -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'); diff --git a/packages/db/src/migrations/0133_resource_membership_stars.sql b/packages/db/src/migrations/0133_resource_membership_stars.sql new file mode 100644 index 00000000000..b2ef82d2395 --- /dev/null +++ b/packages/db/src/migrations/0133_resource_membership_stars.sql @@ -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"); diff --git a/packages/db/src/migrations/0131_folders.sql b/packages/db/src/migrations/0134_folders.sql similarity index 100% rename from packages/db/src/migrations/0131_folders.sql rename to packages/db/src/migrations/0134_folders.sql diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 8cd871f8fd6..d10ca317ac7 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -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 } ] diff --git a/packages/db/src/schema/agent_memberships.ts b/packages/db/src/schema/agent_memberships.ts index 8ff4a87a6fc..b609ac6acb8 100644 --- a/packages/db/src/schema/agent_memberships.ts +++ b/packages/db/src/schema/agent_memberships.ts @@ -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, diff --git a/packages/db/src/schema/project_memberships.ts b/packages/db/src/schema/project_memberships.ts index 0e61294cd6c..0f04bcdb1c1 100644 --- a/packages/db/src/schema/project_memberships.ts +++ b/packages/db/src/schema/project_memberships.ts @@ -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, diff --git a/packages/shared/src/adapter-agnostic-keys.test.ts b/packages/shared/src/adapter-agnostic-keys.test.ts new file mode 100644 index 00000000000..d4977904a18 --- /dev/null +++ b/packages/shared/src/adapter-agnostic-keys.test.ts @@ -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*=/); + }); +}); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 6d124a95311..978aa4a5edc 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -78,6 +78,20 @@ export const AGENT_ROLE_LABELS: Record = { 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]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e7949453709..54b078e5415 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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, diff --git a/packages/shared/src/pipeline-health.test.ts b/packages/shared/src/pipeline-health.test.ts new file mode 100644 index 00000000000..05ad3c310a9 --- /dev/null +++ b/packages/shared/src/pipeline-health.test.ts @@ -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", + ]); + }); +}); diff --git a/packages/shared/src/pipeline-health.ts b/packages/shared/src/pipeline-health.ts index c2b8a947536..f8b46d2da82 100644 --- a/packages/shared/src/pipeline-health.ts +++ b/packages/shared/src/pipeline-health.ts @@ -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>(); for (const failure of input.failedAutomations ?? []) { + let seenCaseIds = seenFailedAutomationCaseIdsByStage.get(failure.stageId); + if (!seenCaseIds) { + seenCaseIds = new Set(); + seenFailedAutomationCaseIdsByStage.set(failure.stageId, seenCaseIds); + } + if (seenCaseIds.has(failure.caseId)) continue; + seenCaseIds.add(failure.caseId); warnings.push({ code: "automation_failed", stageId: failure.stageId, diff --git a/packages/shared/src/resource-memberships.test.ts b/packages/shared/src/resource-memberships.test.ts new file mode 100644 index 00000000000..012c1db7f0b --- /dev/null +++ b/packages/shared/src/resource-memberships.test.ts @@ -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", + ); + }); +}); diff --git a/packages/shared/src/types/resource-memberships.ts b/packages/shared/src/types/resource-memberships.ts index 5830ed8f0ca..b965c964754 100644 --- a/packages/shared/src/types/resource-memberships.ts +++ b/packages/shared/src/types/resource-memberships.ts @@ -6,16 +6,22 @@ export type ResourceMembershipResourceType = "project" | "agent"; export interface ResourceMemberships { projectMemberships: Record; agentMemberships: Record; + starredProjectIds?: string[]; + starredAgentIds?: string[]; + projectStarredAt?: Record; + agentStarredAt?: Record; 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; } diff --git a/packages/shared/src/validators/resource-memberships.ts b/packages/shared/src/validators/resource-memberships.ts index 81cd40906fc..8f96b976837 100644 --- a/packages/shared/src/validators/resource-memberships.ts +++ b/packages/shared/src/validators/resource-memberships.ts @@ -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; diff --git a/packages/skills-catalog/src/packaged-artifacts.test.ts b/packages/skills-catalog/src/packaged-artifacts.test.ts index e8fb872dd09..e8331290ecc 100644 --- a/packages/skills-catalog/src/packaged-artifacts.test.ts +++ b/packages/skills-catalog/src/packaged-artifacts.test.ts @@ -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); }); diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index 0fe3a2625d3..5e67daacbe1 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -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 diff --git a/server/src/__tests__/resource-memberships-routes.test.ts b/server/src/__tests__/resource-memberships-routes.test.ts index 9b0278ef170..649b5d9ac84 100644 --- a/server/src/__tests__/resource-memberships-routes.test.ts +++ b/server/src/__tests__/resource-memberships-routes.test.ts @@ -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, { diff --git a/server/src/__tests__/version.test.ts b/server/src/__tests__/version.test.ts new file mode 100644 index 00000000000..9d8f9d8b1f4 --- /dev/null +++ b/server/src/__tests__/version.test.ts @@ -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", + ); + }); +}); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 4c8ac909806..c936b95f7b3 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -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", diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 7b0474685e6..659491cac5c 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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 = 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] }; } diff --git a/server/src/routes/resource-memberships.ts b/server/src/routes/resource-memberships.ts index 38f1e7f734d..5a64d69e409 100644 --- a/server/src/routes/resource-memberships.ts +++ b/server/src/routes/resource-memberships.ts @@ -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); }, ); diff --git a/server/src/services/resource-memberships.ts b/server/src/services/resource-memberships.ts index 3c7dffc4add..0e74123bb56 100644 --- a/server/src/services/resource-memberships.ts +++ b/server/src/services/resource-memberships.ts @@ -41,12 +41,21 @@ export type ResourceMembershipPolicyHook = (input: { resourceType: ResourceMembershipResourceType; resourceId: string; state: ResourceMembershipState; + starred?: boolean; }) => Promise | PolicyDecision; type ResourceMembershipServiceOptions = { policyHook?: ResourceMembershipPolicyHook | null; }; +type MembershipChangeKind = ResourceMembershipState | "starred" | "unstarred"; + +type MembershipUpdateResult = ResourceMembershipUpdateResult & { + changed: boolean; + changeKind: MembershipChangeKind | null; + policySource: string; +}; + function defaultJoinedMap( rows: T[], key: "projectId" | "agentId", @@ -60,6 +69,30 @@ function defaultJoinedMap( + rows: T[], + key: "projectId" | "agentId", +): Record { + const result: Record = {}; + for (const row of rows) { + const id = row[key]; + if (typeof id !== "string" || !row.starredAt) continue; + result[id] = row.starredAt; + } + return result; +} + +function starredIds( + 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 { 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 { 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 { + }): Promise { 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 { + }): Promise { 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", }; }, diff --git a/server/src/version.ts b/server/src/version.ts index 39a16a4cfe7..1dfff0aa1a7 100644 --- a/server/src/version.ts +++ b/server/src/version.ts @@ -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, 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(?\d+\.\d+\.\d+)-(?\d+)-g(?[0-9a-f]{7,40})(?-dirty)?$/i; + +function defaultDebugLog(fields: Record, 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(); diff --git a/ui/public/fonts/InterVariable-Italic.woff2 b/ui/public/fonts/InterVariable-Italic.woff2 new file mode 100644 index 00000000000..b3530f3f526 Binary files /dev/null and b/ui/public/fonts/InterVariable-Italic.woff2 differ diff --git a/ui/public/fonts/InterVariable.woff2 b/ui/public/fonts/InterVariable.woff2 new file mode 100644 index 00000000000..5a8d3e72ad7 Binary files /dev/null and b/ui/public/fonts/InterVariable.woff2 differ diff --git a/ui/public/fonts/NOTICE.md b/ui/public/fonts/NOTICE.md new file mode 100644 index 00000000000..699d3d0e358 --- /dev/null +++ b/ui/public/fonts/NOTICE.md @@ -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. diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f11df9b259c..943b2ecd542 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/workTimeline.ts b/ui/src/api/workTimeline.ts new file mode 100644 index 00000000000..42a22b8ebf4 --- /dev/null +++ b/ui/src/api/workTimeline.ts @@ -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(`/companies/${companyId}/timeline${query(params)}`), +}; diff --git a/ui/src/components/RequestCollapsedSidebar.test.tsx b/ui/src/components/RequestCollapsedSidebar.test.tsx index a7657e5f3df..0e1d0339e35 100644 --- a/ui/src/components/RequestCollapsedSidebar.test.tsx +++ b/ui/src/components/RequestCollapsedSidebar.test.tsx @@ -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()); + flushSync(() => root.render()); + 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 ) unmounts. - act(() => active!.root.render()); + flushSync(() => active!.root.render()); + 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()); + flushSync(() => active!.root.render()); + await flushReact(); // Route request gone, but the explicit collapsed pin still applies. expect(capturedValue?.routeRequestsCollapsed).toBe(false); expect(capturedValue?.collapsed).toBe(true); diff --git a/ui/src/components/Sidebar.test.tsx b/ui/src/components/Sidebar.test.tsx index 6f046963365..077141c53ac 100644 --- a/ui/src/components/Sidebar.test.tsx +++ b/ui/src/components/Sidebar.test.tsx @@ -100,6 +100,10 @@ vi.mock("./SidebarProjects", () => ({ SidebarProjects: () =>
Projects collapsible
, })); +vi.mock("./SidebarStarredProjects", () => ({ + SidebarStarredProjects: () =>
, +})); + 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, diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 78cd9b882f8..1cb871b51d8 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -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() { ) : null} {streamlined ? ( - + <> + + + ) : null} + diff --git a/ui/src/components/SidebarAgents.test.tsx b/ui/src/components/SidebarAgents.test.tsx index d2162e67c9b..e78a7b0563f 100644 --- a/ui/src/components/SidebarAgents.test.tsx +++ b/ui/src/components/SidebarAgents.test.tsx @@ -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([ diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index fc28a7c33e9..f0b0ac89d0a 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -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. + + onToggleStar(agent, false)} + revealClassName={AGENT_STAR_ROW_REVEAL} + /> + + ) : null} + {!rail && ( @@ -204,7 +239,26 @@ function SidebarAgentItem({ - + + {onToggleStar ? ( + <> + { + if (starPending) return; + onToggleStar(agent, !starred); + }} + disabled={starPending} + > + {starPending ? ( + + ) : ( + + )} + {starred ? "Remove from starred" : "Star agent"} + + + + ) : null} 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) => ( + 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 ( - {displayedAgents.map((agent: Agent) => { - const runCount = liveCountByAgent.get(agent.id) ?? 0; - return ( - 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 = ( ({ 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; + }) => ( + + {children} + + ), + 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) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +function makeProject(overrides: Partial): 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 | 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( + + + + + , + ); + }); + 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([]); + }); +}); diff --git a/ui/src/components/SidebarStarredProjects.tsx b/ui/src/components/SidebarStarredProjects.tsx new file mode 100644 index 00000000000..85e1603d74e --- /dev/null +++ b/ui/src/components/SidebarStarredProjects.tsx @@ -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 ( +
+ {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 = ( + { + 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", + )} + > + + {project.name} + {!rail && project.pauseReason === "budget" ? ( + + ) : null} + + ); + + return ( +
+ {rail ? ( + + +
{link}
+
+ {project.name} +
+ ) : ( + link + )} + + {!rail && !isMobile ? ( + // Desktop: quiet inline unstar revealed on hover/focus. + + unstar(project)} + revealClassName={STAR_ROW_REVEAL} + /> + + ) : null} + + {!rail && isMobile ? ( + // Touch: explicit ⋯ menu (no hover). Star action + separated Leave. + + + + + + { + if (pending) return; + unstar(project); + }} + disabled={pending} + > + {unstarPending ? ( + + ) : ( + + )} + Remove from starred + + + { + if (pending) return; + leave(project); + }} + disabled={pending} + > + {leavePending ? ( + + ) : ( + + )} + Leave project + + + + ) : null} +
+ ); + })} +
+ ); +} diff --git a/ui/src/components/StarToggle.test.tsx b/ui/src/components/StarToggle.test.tsx new file mode 100644 index 00000000000..bc3c0a863dd --- /dev/null +++ b/ui/src/components/StarToggle.test.tsx @@ -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) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +describe("StarToggle", () => { + let container: HTMLDivElement; + let root: ReturnType | 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(); + + 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(); + + 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( + {}} 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(); + 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( {}} />); + 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( {}} />); + expect(button()?.textContent).toContain("Starred"); + }); +}); diff --git a/ui/src/components/StarToggle.tsx b/ui/src/components/StarToggle.tsx new file mode 100644 index 00000000000..72c1d9b4283 --- /dev/null +++ b/ui/src/components/StarToggle.tsx @@ -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) { + 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 ( + + ); + } + + // 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 ( + + ); +} diff --git a/ui/src/components/timeline/WorkTimelineChart.test.tsx b/ui/src/components/timeline/WorkTimelineChart.test.tsx new file mode 100644 index 00000000000..73daea7f2d9 --- /dev/null +++ b/ui/src/components/timeline/WorkTimelineChart.test.tsx @@ -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> = {}, +) { + flushSync(() => { + root.render( + , + ); + }); +} + +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("[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("[data-testid='work-timeline-scroll']")!; + const timeAxis = container.querySelector("[data-testid='work-timeline-time-axis']")!; + const axisSvg = timeAxis.querySelector("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("[data-testid='work-timeline-scroll']"); + const gutter = container.querySelector("[data-testid='work-timeline-actor-gutter']"); + const chartSvg = container.querySelector("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("[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("[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("[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("[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("[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("[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("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("[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("[data-run-id='run-1'] rect") + ?.getAttribute("width"); + + currentNow = later; + renderChart(data, { nowMs: undefined }); + + expect(container.querySelector("[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("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(); + }); +}); diff --git a/ui/src/components/timeline/WorkTimelineChart.tsx b/ui/src/components/timeline/WorkTimelineChart.tsx new file mode 100644 index 00000000000..689f6ead786 --- /dev/null +++ b/ui/src/components/timeline/WorkTimelineChart.tsx @@ -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 = { + 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((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 = { + 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 ( + + {type === "user" ? ( + + ) : ( + + )} + 10 ? 9 : 8} textAnchor="middle" fill={stroke}> + {label} + + + ); +} + +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(null); + const initialWindowKeyRef = useRef(null); + const centerMsRef = useRef(null); + const defaultNowRef = useRef(null); + const documentDragCleanupRef = useRef<(() => void) | null>(null); + const [tooltip, setTooltip] = useState(null); + const [hoveredRunId, setHoveredRunId] = useState(null); + const [scrollLeft, setScrollLeft] = useState(0); + const [viewportW, setViewportW] = useState(0); + const [dragSelection, setDragSelection] = useState(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) => { + 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) => { + 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 ( +
+
{ + setScrollLeft(e.currentTarget.scrollLeft); + centerMsRef.current = scrollCenterMs(e.currentTarget); + }} + onWheel={handleWheel} + > +
+ + + { + if (el && viewportW === 0 && scrollRef.current) setViewportW(scrollRef.current.clientWidth); + }} + > + + + + + + + + {/* row backgrounds */} + {layout.rows.map((row, i) => ( + + ))} + + {/* vertical gridlines */} + {ticks.map((ms) => { + const gx = layout.gutter + ((ms - layout.fromMs) / 60000) * layout.pxPerMinute; + return ( + + + + ); + })} + + {/* now line — teal "Signal" present marker */} + {now >= layout.fromMs && now <= layout.toMs && ( + + )} + + {/* gutter divider + axis baseline */} + + + + {/* 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 ( + + + + + + ); + })} + + {/* rows: gutter avatar/label, lane baselines, bars, human kickoff chips */} + {layout.rows.map((row) => { + const cy = row.y + AXIS_H + row.h / 2; + return ( + + + + {truncate(row.actor.name, 18)} + + + {row.actor.type} + + + {Array.from({ length: row.laneCount }).map((_, ln) => { + const ly = row.y + AXIS_H + 6 + ln * (GEOM.barH + GEOM.laneGap) + GEOM.barH / 2; + return ( + + ); + })} + + {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 ( + + 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. */} + + {/* in-progress fade to "now" */} + {bar.running && !cancelled && w > 8 && ( + + )} + + {bar.kickoff && actorType(bar.kickoff) === "user" && ( + + + + )} + + ); + })} + + + ); + })} + {dragSelection && ( + + )} + +
+
+ + + + + + {tooltip && } +
+ ); +} + +function ActorGutter({ rows, height }: { rows: ReturnType["rows"]; height: number }) { + return ( + + ); +} + +function TimeAxisOverlay({ + layout, + ticks, + stepMs, + scrollLeft, +}: { + layout: ReturnType; + ticks: number[]; + stepMs: number; + scrollLeft: number; +}) { + return ( + + ); +} + +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 ( +
+
{truncate(title)}
+
+ {fmtClock(startMs)}–{bar.span.end ? fmtClock(endMs) : "now"} · {formatDuration(startMs, endMs)} ·{" "} + {bar.span.status} +
+ {bar.kickoff && ( +
+ kicked off by: {(bar.kickoff as WorkTimelineActor).name} + {bar.span.retryOfRunId ? " · retry" : ""} +
+ )} + {tooltip.connectorHint && ( +
{tooltip.connectorHint}
+ )} +
+ ); +} + +function MiniMap({ + layout, + scrollRef, + viewportW, + scrollLeft, + onVisibleRangeChange, +}: { + layout: ReturnType; + scrollRef: React.RefObject; + 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) => { + 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 ( +
+ { + const el = e.currentTarget; + seek(e.clientX, el); + const move = (ev: MouseEvent) => seek(ev.clientX, el); + setDocumentDrag(move, () => {}); + }} + > + + {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 ( + + ); + }), + )} + startRangeDrag("move", e)} + /> + startRangeDrag("left", e)} + /> + startRangeDrag("right", e)} + /> + +
+ ); +} diff --git a/ui/src/hooks/useResourceMemberships.ts b/ui/src/hooks/useResourceMemberships.ts index 84e85c2f6a5..c20d06cd55d 100644 --- a/ui/src/hooks/useResourceMemberships.ts +++ b/ui/src/hooks/useResourceMemberships.ts @@ -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(queryKey); queryClient.setQueryData( 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( 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: () => { diff --git a/ui/src/index.css b/ui/src/index.css index f86dd53ba97..0e5e2cf6ff3 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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; } diff --git a/ui/src/lib/agent-config-patch.ts b/ui/src/lib/agent-config-patch.ts index 325d8d8833e..98176507387 100644 --- a/ui/src/lib/agent-config-patch.ts +++ b/ui/src/lib/agent-config-patch.ts @@ -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) { return Object.fromEntries( Object.entries(value).filter(([, entryValue]) => entryValue !== undefined), diff --git a/ui/src/lib/company-routes.test.ts b/ui/src/lib/company-routes.test.ts index 6278224db81..9a86268ab66 100644 --- a/ui/src/lib/company-routes.test.ts +++ b/ui/src/lib/company-routes.test.ts @@ -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", diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts index aaea26ad733..05a2c6a0b7a 100644 --- a/ui/src/lib/company-routes.ts +++ b/ui/src/lib/company-routes.ts @@ -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"]); diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 9f60830099a..657b31f540a 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -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, diff --git a/ui/src/lib/timeline/layout.test.ts b/ui/src/lib/timeline/layout.test.ts new file mode 100644 index 00000000000..279de2696b2 --- /dev/null +++ b/ui/src/lib/timeline/layout.test.ts @@ -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)); + }); +}); diff --git a/ui/src/lib/timeline/layout.ts b/ui/src/lib/timeline/layout.ts new file mode 100644 index 00000000000..68b1d55398d --- /dev/null +++ b/ui/src/lib/timeline/layout.ts @@ -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, +): 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(); + 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(); + 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(); // 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(); + 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, + 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`; +} diff --git a/ui/src/lib/ui-font-assets.test.ts b/ui/src/lib/ui-font-assets.test.ts new file mode 100644 index 00000000000..f2ea367a87d --- /dev/null +++ b/ui/src/lib/ui-font-assets.test.ts @@ -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"); + }); +}); diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index a5a41588aa3..61b436c9a12 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -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 (
@@ -965,8 +970,8 @@ export function AgentDetail() { membershipMutation.mutate({ resourceType: "agent", @@ -1031,29 +1036,43 @@ export function AgentDetail() {

- - {mobileLiveRun && ( - - - - - - Live - - )} - +
+ membershipMutation.mutate({ + resourceType: "agent", + resourceId: agent.id, + resourceName: agent.name, + starred: next, + })} + /> + + {mobileLiveRun && ( + + + + + + Live + + )} + +
{!urlRunId && ( diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index b0940605b0b..519496368b5 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -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 ( membershipMutation.mutate({ resourceType: "agent", @@ -369,6 +368,18 @@ export function Agents() { state: "left", })} /> + membershipMutation.mutate({ + resourceType: "agent", + resourceId: agent.id, + resourceName: agent.name, + starred: next, + })} + /> } /> @@ -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 (
@@ -576,8 +590,8 @@ function OrgTreeNode({
membershipMutation.mutate({ resourceType: "agent", @@ -592,6 +606,18 @@ function OrgTreeNode({ state: "left", })} /> + membershipMutation.mutate({ + resourceType: "agent", + resourceId: node.id, + resourceName: node.name, + starred: next, + })} + /> {node.reports && node.reports.length > 0 && ( diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index ea05f529b23..8f5b5cf669f 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -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(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 () => { diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index e8daeae9b32..54e5938582a 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -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() { -
-
-
-

Streamlined Left Navigation Bar

-

- 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. -

-
- - toggleMutation.mutate({ - enableStreamlinedLeftNavigation: !enableStreamlinedLeftNavigation, - }) - } - disabled={toggleMutation.isPending} - aria-label="Toggle streamlined left navigation experimental setting" - /> -
-
- {SHOW_CONFERENCE_ROOM_EXPERIMENTAL_SETTING ? (
diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 5ed0139b456..f0e79902136 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -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() { membershipMutation.mutate({ resourceType: "project", @@ -786,6 +791,20 @@ export function ProjectDetail() {
) : null} +
+ membershipMutation.mutate({ + resourceType: "project", + resourceId: project.id, + resourceName: project.name, + starred: next, + })} + /> +
membershipMutation.mutate({ resourceType: "project", @@ -250,6 +255,18 @@ export function Projects() { state: "left", })} /> + membershipMutation.mutate({ + resourceType: "project", + resourceId: project.id, + resourceName: project.name, + starred: next, + })} + /> } /> diff --git a/ui/src/pages/Timeline.test.tsx b/ui/src/pages/Timeline.test.tsx new file mode 100644 index 00000000000..cfe8943338e --- /dev/null +++ b/ui/src/pages/Timeline.test.tsx @@ -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: () =>
, +})); + +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 | 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( + + + , + ); + }); + 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( + + + , + ); + }); + 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( + + + , + ); + }); + 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"); + }); +}); diff --git a/ui/src/pages/Timeline.tsx b/ui/src/pages/Timeline.tsx new file mode 100644 index 00000000000..f244bedc09c --- /dev/null +++ b/ui/src/pages/Timeline.tsx @@ -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, 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 | 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({ + value, + options, + onChange, +}: { + value: T; + options: { value: T; label: string }[]; + onChange: (v: T) => void; +}) { + return ( +
+ {options.map((opt, i) => ( + + ))} +
+ ); +} + +/** Encoding key for the "Signal" timeline: colour = how each run started. */ +function TimelineLegend() { + return ( +
+ + + Delegated + + + + Automation + + + + Cancelled + + + + Now + +
+ ); +} + +export function Timeline() { + const { selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const [zoom, setZoom] = useState("day"); + const [zoomScale, setZoomScale] = useState(undefined); + const zoomTouched = useRef(false); + const [rangePreset, setRangePreset] = useState("7d"); + const [dateRange, setDateRange] = useState(() => 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 ( + <> + + + + ); + } + + const header = ( +
+ +

Work Timeline

+
+ ); + + 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 = ( +
+ +
+ + + +
+
+ ); + + return ( +
+ + {header} + {toolbar} + + {isLoading && } + + {dateRangeError && ( + + )} + + {error && ( + + )} + + {data && !isLoading && !dateRangeError && ( + data.spans.length === 0 ? ( + + ) : ( +
+
+ + { + zoomTouched.current = true; + setZoomScale(nextScale); + setZoom(nextZoom); + }} + /> +
+

+ {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" : ""} +

+
+ ) + )} +
+ ); +} diff --git a/ui/storybook/fixtures/workTimeline.human.sample.json b/ui/storybook/fixtures/workTimeline.human.sample.json new file mode 100644 index 00000000000..abbedf42279 --- /dev/null +++ b/ui/storybook/fixtures/workTimeline.human.sample.json @@ -0,0 +1,1824 @@ +{ + "actors": [ + { + "id": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "type": "agent", + "name": "QA", + "avatar": "bug" + }, + { + "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:6670e11b-91d3-4429-82e0-436b88b51808", + "type": "agent", + "name": "UXDesigner", + "avatar": "gem" + }, + { + "id": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "type": "user", + "name": "Dotta", + "avatar": "/api/assets/3b1e4f9b-6136-4315-8484-fd9f441aae54/content" + }, + { + "id": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "type": "agent", + "name": "CodexCoder", + "avatar": "code" + }, + { + "id": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "type": "agent", + "name": "CloudOpsEngineer", + "avatar": "cog" + }, + { + "id": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "type": "agent", + "name": "CEO", + "avatar": "crown" + }, + { + "id": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", + "type": "agent", + "name": "Strategic Ops", + "avatar": "target" + }, + { + "id": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", + "type": "agent", + "name": "Senior Planning Engineer Pro", + "avatar": "code" + }, + { + "id": "system:system", + "type": "system", + "name": "System", + "avatar": null + }, + { + "id": "agent:c9dcefd1-b052-489a-a1f3-4cd1783bcb5f", + "type": "agent", + "name": "DevRel", + "avatar": "globe" + }, + { + "id": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", + "type": "agent", + "name": "SecurityEngineer", + "avatar": "shield" + }, + { + "id": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "type": "agent", + "name": "ClaudeCoder", + "avatar": "code" + }, + { + "id": "agent:db1c09f1-422c-48c8-9711-4054d3657aac", + "type": "agent", + "name": "ConnectionCoder", + "avatar": "code" + } + ], + "spans": [ + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "laneHint": "automation", + "runId": "571d1495-fe7e-4cf8-a79f-e4d8d779cecf", + "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", + "issueIdentifier": "PAP-12298", + "issueTitle": "blog page redesign", + "start": "2026-07-02T14:42:05.689Z", + "end": "2026-07-02T14:45:37.694Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "laneHint": "assignment", + "runId": "c7a242bb-adf1-46e2-a322-474ed791c7ff", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "issueIdentifier": "PAP-12420", + "issueTitle": "Attach S3 policy to paperclip-page-uploader IAM user for pages.paperclip.ing", + "start": "2026-07-02T14:44:59.649Z", + "end": "2026-07-02T14:48:23.151Z", + "status": "cancelled", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "laneHint": "automation", + "runId": "c214c9f0-b46b-47de-ba86-2520e1a8aa75", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "issueIdentifier": "PAP-12420", + "issueTitle": "Attach S3 policy to paperclip-page-uploader IAM user for pages.paperclip.ing", + "start": "2026-07-02T14:48:23.194Z", + "end": "2026-07-02T14:52:29.939Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "laneHint": "automation", + "runId": "6b0a55a9-7c1a-4d48-bdfb-159370e9c03d", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "issueIdentifier": "PAP-12405", + "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", + "start": "2026-07-02T14:50:35.277Z", + "end": "2026-07-02T14:55:43.520Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "laneHint": "assignment", + "runId": "e573c5dc-94b9-4b63-bdb3-c48327a85cea", + "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", + "issueIdentifier": "PAP-12421", + "issueTitle": "CTO/admin: attach corrected pages.paperclip.ing S3 policy to paperclip-page-uploader", + "start": "2026-07-02T14:52:04.426Z", + "end": "2026-07-02T14:56:08.638Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "laneHint": "assignment", + "runId": "8a716a87-61e2-4667-9cc4-3641fe42c6f0", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "issueIdentifier": "PAP-12422", + "issueTitle": "Work Timeline — Phase A: wireframe exploration (2–3 directions, board picks one)", + "start": "2026-07-02T14:52:21.788Z", + "end": "2026-07-02T15:07:44.596Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "laneHint": "assignment", + "runId": "4b085459-77b0-41e4-a62f-a76e1ba9b521", + "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", + "issueIdentifier": "PAP-12423", + "issueTitle": "Work Timeline — Phase B: timeline aggregation endpoint (GET /companies/:id/timeline)", + "start": "2026-07-02T14:52:21.929Z", + "end": "2026-07-02T15:03:54.376Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "laneHint": "assignment", + "runId": "972b6b5b-5fdd-419d-aea0-5641b4634448", + "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", + "issueIdentifier": "PAP-12427", + "issueTitle": "CEO/admin: unblock IAM mutation for paperclip-page-uploader pages policy", + "start": "2026-07-02T14:54:41.918Z", + "end": "2026-07-02T14:57:27.722Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "laneHint": "assignment", + "runId": "d14a8901-4569-4f05-b8c2-5e49d4216a6d", + "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", + "issueIdentifier": "PAP-12428", + "issueTitle": "just change the terminology to “Responsible”", + "start": "2026-07-02T14:55:00.330Z", + "end": "2026-07-02T15:01:10.197Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "laneHint": "automation", + "runId": "77ffd755-a755-41e6-b2cb-ee0817f11152", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "issueIdentifier": "PAP-12405", + "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", + "start": "2026-07-02T14:55:43.595Z", + "end": "2026-07-02T14:57:12.235Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "laneHint": "automation", + "runId": "960639c7-f6cc-45ad-b746-22ca507c237d", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "issueIdentifier": "PAP-12405", + "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", + "start": "2026-07-02T14:57:12.287Z", + "end": "2026-07-02T15:12:26.352Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "laneHint": "assignment", + "runId": "a61b657c-d297-43b7-948c-2be7bd161e3b", + "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", + "issueIdentifier": "PAP-12427", + "issueTitle": "CEO/admin: unblock IAM mutation for paperclip-page-uploader pages policy", + "start": "2026-07-02T14:57:27.831Z", + "end": "2026-07-02T15:01:08.081Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "laneHint": "assignment", + "runId": "67f65027-b802-4e88-91a6-bebb5c94f16b", + "issueId": "0dc1021f-2326-4977-a5b2-7eb4ab66e6df", + "issueIdentifier": "PAP-12429", + "issueTitle": "give the AWS_ keys that ClaudeCoder has to CodexCoder and CloudOpsEngineer and UXDesigner", + "start": "2026-07-02T14:57:50.711Z", + "end": "2026-07-02T15:01:18.973Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "laneHint": "assignment", + "runId": "e9ff0a09-6607-4793-90f3-1b3ab1741ef0", + "issueId": "16901797-89cc-4b85-8213-7a27d70f9d16", + "issueIdentifier": "PAP-12430", + "issueTitle": "merged Create a single PR from this branch PAP-12404-inbox-blocked-but-descendent-live-icon", + "start": "2026-07-02T14:59:42.783Z", + "end": "2026-07-02T15:17:12.936Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:c9dcefd1-b052-489a-a1f3-4cd1783bcb5f", + "laneHint": "assignment", + "runId": "91f67e3b-18ae-4230-8fae-18b414ffcd14", + "issueId": "1533f78d-d677-449f-afd9-4cf653168438", + "issueIdentifier": "PAP-12431", + "issueTitle": "2026-07-02 create a standup message i can copy to slack about everything i've been working on for the last 36 hours", + "start": "2026-07-02T15:00:19.704Z", + "end": "2026-07-02T15:05:16.672Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "laneHint": "assignment", + "runId": "e08e32ae-9106-46e0-9c2b-97ab74520fc4", + "issueId": "0facd02e-425f-4822-875c-2f97988de98a", + "issueIdentifier": "PAP-12432", + "issueTitle": "CEO/admin: apply paperclip-page-uploader pages.paperclip.ing inline policy", + "start": "2026-07-02T15:00:46.697Z", + "end": "2026-07-02T15:06:05.586Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", + "laneHint": "automation", + "runId": "12223c0b-0bad-4b95-be56-ceb1ce955b0f", + "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", + "issueIdentifier": "PAP-12425", + "issueTitle": "Work Timeline — security review: timeline endpoint ACLs and data exposure", + "start": "2026-07-02T15:01:28.563Z", + "end": "2026-07-02T15:05:43.985Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", + "laneHint": "assignment", + "runId": "33181a90-d2af-4081-9deb-253d996d81f1", + "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", + "issueIdentifier": "PAP-12433", + "issueTitle": "project star’ing", + "start": "2026-07-02T15:03:27.392Z", + "end": "2026-07-02T15:52:11.373Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", + "laneHint": "assignment", + "runId": "0961f6e8-a1cd-4493-9979-99775285c748", + "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", + "issueIdentifier": "PAP-12434", + "issueTitle": "Strategic Ops: get AWS admin to attach pages.paperclip.ing uploader policy", + "start": "2026-07-02T15:05:38.870Z", + "end": "2026-07-02T15:08:15.576Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "laneHint": "assignment", + "runId": "0ecdb797-e6b4-4f12-a5f0-b7bce348138f", + "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", + "issueIdentifier": "PAP-12435", + "issueTitle": "CloudOps: attach pages.paperclip.ing uploader IAM policy", + "start": "2026-07-02T15:07:45.699Z", + "end": "2026-07-02T15:11:13.138Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "laneHint": "assignment", + "runId": "bcfaef62-ca9a-4a96-b279-bf2314108dbf", + "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", + "issueIdentifier": "PAP-12403", + "issueTitle": "github and environment variables", + "start": "2026-07-02T15:09:15.958Z", + "end": "2026-07-02T15:20:22.053Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "laneHint": "automation", + "runId": "e4e854bc-9cce-4c5f-bfb9-a2f3c0094075", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "issueIdentifier": "PAP-12420", + "issueTitle": "Attach S3 policy to paperclip-page-uploader IAM user for pages.paperclip.ing", + "start": "2026-07-02T15:10:25.014Z", + "end": "2026-07-02T15:15:34.966Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "laneHint": "assignment", + "runId": "b83d9b78-f63c-465c-808c-c1e1175a18c5", + "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", + "issueIdentifier": "PAP-12436", + "issueTitle": "CTO/admin: grant IAM write path for paperclip-page-uploader pages policy", + "start": "2026-07-02T15:10:51.773Z", + "end": "2026-07-02T15:14:59.703Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", + "laneHint": "automation", + "runId": "90cd6835-be7c-4dd8-bdf7-017d40df9c38", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "issueIdentifier": "PAP-12405", + "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", + "start": "2026-07-02T15:12:26.440Z", + "end": "2026-07-02T15:14:56.976Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "laneHint": "assignment", + "runId": "f4d1d8a6-c74d-4d59-b831-b788eeaf93f2", + "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", + "issueIdentifier": "PAP-12437", + "issueTitle": "for parent task, cleanup the effecitvely merged ones", + "start": "2026-07-02T15:14:30.602Z", + "end": "2026-07-02T15:20:22.033Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "laneHint": "automation", + "runId": "cd8cf33f-5a1c-4b3a-905e-aabc0341edd1", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "issueIdentifier": "PAP-12405", + "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", + "start": "2026-07-02T15:14:57.094Z", + "end": "2026-07-02T15:16:53.870Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "laneHint": "assignment", + "runId": "a9ba6322-e0e3-43bb-bc0b-2af53bec9610", + "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", + "issueIdentifier": "PAP-12436", + "issueTitle": "CTO/admin: grant IAM write path for paperclip-page-uploader pages policy", + "start": "2026-07-02T15:14:59.782Z", + "end": "2026-07-02T15:20:10.000Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "laneHint": "automation", + "runId": "df0d6959-ef5f-4a75-b710-492cfd6de658", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "issueIdentifier": "PAP-12405", + "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", + "start": "2026-07-02T15:16:53.952Z", + "end": "2026-07-02T15:19:28.111Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "laneHint": "assignment", + "runId": "4cec085d-bf1f-48a2-aef1-62cce353a09f", + "issueId": "e0189c44-1245-4526-9423-1128862d7c95", + "issueIdentifier": "PAP-12438", + "issueTitle": "environment variables editor re-design", + "start": "2026-07-02T15:18:55.386Z", + "end": "2026-07-02T15:26:14.263Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "assignment" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "laneHint": "automation", + "runId": "17e168c3-c2f4-4cef-9492-c4e680bebcf5", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "issueIdentifier": "PAP-12405", + "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", + "start": "2026-07-02T15:19:28.163Z", + "end": "2026-07-02T15:21:38.254Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "laneHint": "automation", + "runId": "8e58708e-88df-4681-84c5-f6a676d1dd6a", + "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", + "issueIdentifier": "PAP-10273", + "issueTitle": "Defect (Minor): preview emits noisy \"skill not present in package\" warnings for catalog-skill references", + "start": "2026-07-02T15:20:20.734Z", + "end": "2026-07-02T15:25:39.352Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "laneHint": "automation", + "runId": "accd3b96-89a2-47f4-a3db-65f94ec6be3a", + "issueId": "6f7ac707-363a-4e77-b3d0-7479165352db", + "issueIdentifier": "PAP-10274", + "issueTitle": "Defect (Minor UX): \"Partial state is not rolled back\" message is incorrect — install transaction rolled back cleanly", + "start": "2026-07-02T15:20:20.784Z", + "end": "2026-07-02T15:28:39.720Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "laneHint": "automation", + "runId": "ebc8e142-c416-4b85-86ff-be0fed98f41f", + "issueId": "153322a9-301d-4868-b15e-a24063ceeca2", + "issueIdentifier": "PAP-10275", + "issueTitle": "Defect (Minor): CLI install lacks --adapter-override and --secret-value flags", + "start": "2026-07-02T15:20:20.854Z", + "end": "2026-07-02T15:22:56.590Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "laneHint": "automation", + "runId": "85339e14-6ac3-459c-99a2-811e0e6adbba", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "issueIdentifier": "PAP-12405", + "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", + "start": "2026-07-02T15:21:38.307Z", + "end": "2026-07-02T15:22:48.667Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "laneHint": "automation", + "runId": "671705a1-718d-4be6-86b4-390295a17a74", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "issueIdentifier": "PAP-12405", + "issueTitle": "design a ‘gantt chart’ representation of my paperclip work", + "start": "2026-07-02T15:22:48.720Z", + "end": "2026-07-02T15:24:16.925Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "laneHint": "automation", + "runId": "cbce714c-a056-477d-971b-3ceb02a4441b", + "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", + "issueIdentifier": "PAP-12435", + "issueTitle": "CloudOps: attach pages.paperclip.ing uploader IAM policy", + "start": "2026-07-02T15:23:13.966Z", + "end": "2026-07-02T15:23:32.715Z", + "status": "cancelled", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + }, + { + "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", + "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: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: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 — 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": "339694a1-6a7d-4de0-8466-43574a7c6b4d", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "issueIdentifier": "PAP-12422", + "issueTitle": "Work Timeline — Phase A: wireframe exploration (2–3 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: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: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: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 — Phase C: frontend Gantt component + Timeline page", + "start": "2026-07-02T15:41:30.586Z", + "end": "2026-07-02T16:06:58.629Z", + "status": "succeeded", + "retryOfRunId": null, + "continuationAttempt": 0, + "invocationSource": "automation" + } + ], + "events": [ + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "kind": "created", + "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", + "at": "2026-06-03T19:39:46.628Z" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "kind": "created", + "issueId": "6f7ac707-363a-4e77-b3d0-7479165352db", + "at": "2026-06-03T19:40:14.945Z" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "kind": "created", + "issueId": "153322a9-301d-4868-b15e-a24063ceeca2", + "at": "2026-06-03T19:40:14.974Z" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "kind": "created", + "issueId": "5e955520-f5ea-4ad4-aef6-860c95b1797e", + "at": "2026-06-03T20:23:11.278Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "bc7a42bb-91bb-40b3-b3a8-6f9ebf98bc94", + "at": "2026-06-30T22:22:13.060Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "6ff91230-ab9d-4b95-9590-1471ae37297e", + "at": "2026-07-01T16:31:55.423Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", + "at": "2026-07-01T18:23:43.255Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "48264b4d-0004-4015-9ca0-08c867eed064", + "at": "2026-07-02T00:13:22.977Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "2e375459-115c-4f0c-a1c8-59e359b5c3f6", + "at": "2026-07-02T02:21:56.069Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "created", + "issueId": "df84e69c-35e1-4323-a95f-e10d27b3227c", + "at": "2026-07-02T02:39:19.891Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "ad7f2c54-6e50-421f-a805-4c743948421a", + "at": "2026-07-02T03:29:27.493Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "44c2c3d2-7dc0-44df-beb6-ad2417b81503", + "at": "2026-07-02T04:45:14.251Z" + }, + { + "actorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", + "kind": "delegated", + "issueId": "44c2c3d2-7dc0-44df-beb6-ad2417b81503", + "at": "2026-07-02T04:45:14.251Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", + "at": "2026-07-02T04:52:34.737Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "db6e4af1-0669-42d0-a6ef-e12dcfd7112d", + "at": "2026-07-02T04:56:04.034Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T05:07:46.810Z" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "kind": "created", + "issueId": "abe00e5c-bbcd-4782-ba9e-13532a09184f", + "at": "2026-07-02T05:08:53.619Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "approved", + "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", + "at": "2026-07-02T14:42:05.663Z" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "kind": "created", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "at": "2026-07-02T14:44:59.609Z" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "kind": "delegated", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "at": "2026-07-02T14:44:59.609Z" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "kind": "commented", + "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", + "at": "2026-07-02T14:45:19.329Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "commented", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "at": "2026-07-02T14:48:06.921Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "commented", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "at": "2026-07-02T14:48:23.164Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "approved", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T14:50:35.254Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "created", + "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", + "at": "2026-07-02T14:52:04.388Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "delegated", + "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", + "at": "2026-07-02T14:52:04.388Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "created", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "at": "2026-07-02T14:52:21.683Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "delegated", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "at": "2026-07-02T14:52:21.683Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "commented", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "at": "2026-07-02T14:52:21.751Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "created", + "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", + "at": "2026-07-02T14:52:21.778Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "delegated", + "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", + "at": "2026-07-02T14:52:21.778Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "created", + "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", + "at": "2026-07-02T14:52:53.705Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "delegated", + "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", + "at": "2026-07-02T14:52:53.705Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "created", + "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", + "at": "2026-07-02T14:53:41.388Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "delegated", + "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", + "at": "2026-07-02T14:53:41.388Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "created", + "issueId": "a466f1d9-cfb3-4fa1-a354-57d8bee78ce1", + "at": "2026-07-02T14:53:55.809Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "delegated", + "issueId": "a466f1d9-cfb3-4fa1-a354-57d8bee78ce1", + "at": "2026-07-02T14:53:55.809Z" + }, + { + "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "kind": "created", + "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", + "at": "2026-07-02T14:54:41.875Z" + }, + { + "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "kind": "delegated", + "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", + "at": "2026-07-02T14:54:41.875Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "commented", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T14:54:44.297Z" + }, + { + "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "kind": "commented", + "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", + "at": "2026-07-02T14:54:55.966Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", + "at": "2026-07-02T14:55:00.287Z" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "kind": "delegated", + "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", + "at": "2026-07-02T14:55:00.287Z" + }, + { + "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "kind": "commented", + "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", + "at": "2026-07-02T14:55:40.913Z" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "kind": "commented", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T14:57:12.257Z" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "kind": "commented", + "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", + "at": "2026-07-02T14:57:14.910Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "0dc1021f-2326-4977-a5b2-7eb4ab66e6df", + "at": "2026-07-02T14:57:50.676Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "16901797-89cc-4b85-8213-7a27d70f9d16", + "at": "2026-07-02T14:59:42.755Z" + }, + { + "actorId": "system:system", + "kind": "created", + "issueId": "1533f78d-d677-449f-afd9-4cf653168438", + "at": "2026-07-02T15:00:19.659Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "created", + "issueId": "0facd02e-425f-4822-875c-2f97988de98a", + "at": "2026-07-02T15:00:46.643Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "delegated", + "issueId": "0facd02e-425f-4822-875c-2f97988de98a", + "at": "2026-07-02T15:00:46.643Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "commented", + "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", + "at": "2026-07-02T15:01:00.247Z" + }, + { + "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "kind": "commented", + "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", + "at": "2026-07-02T15:01:05.759Z" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "kind": "commented", + "issueId": "0dc1021f-2326-4977-a5b2-7eb4ab66e6df", + "at": "2026-07-02T15:01:14.833Z" + }, + { + "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "kind": "commented", + "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", + "at": "2026-07-02T15:01:28.524Z" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "kind": "commented", + "issueId": "0facd02e-425f-4822-875c-2f97988de98a", + "at": "2026-07-02T15:02:00.701Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", + "at": "2026-07-02T15:03:27.354Z" + }, + { + "actorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", + "kind": "commented", + "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", + "at": "2026-07-02T15:04:40.445Z" + }, + { + "actorId": "agent:c9dcefd1-b052-489a-a1f3-4cd1783bcb5f", + "kind": "commented", + "issueId": "1533f78d-d677-449f-afd9-4cf653168438", + "at": "2026-07-02T15:05:03.153Z" + }, + { + "actorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", + "kind": "commented", + "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", + "at": "2026-07-02T15:05:38.298Z" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "kind": "created", + "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", + "at": "2026-07-02T15:05:38.834Z" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "kind": "delegated", + "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", + "at": "2026-07-02T15:05:38.834Z" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "kind": "commented", + "issueId": "0facd02e-425f-4822-875c-2f97988de98a", + "at": "2026-07-02T15:05:52.785Z" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "kind": "approved", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "at": "2026-07-02T15:06:51.894Z" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "kind": "commented", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "at": "2026-07-02T15:07:21.753Z" + }, + { + "actorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", + "kind": "created", + "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", + "at": "2026-07-02T15:07:45.644Z" + }, + { + "actorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", + "kind": "delegated", + "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", + "at": "2026-07-02T15:07:45.644Z" + }, + { + "actorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", + "kind": "commented", + "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", + "at": "2026-07-02T15:07:56.186Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "commented", + "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", + "at": "2026-07-02T15:09:15.910Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "commented", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "at": "2026-07-02T15:10:24.986Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "created", + "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", + "at": "2026-07-02T15:10:51.721Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "delegated", + "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", + "at": "2026-07-02T15:10:51.721Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "commented", + "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", + "at": "2026-07-02T15:11:04.865Z" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "kind": "commented", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "at": "2026-07-02T15:11:14.738Z" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "kind": "commented", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T15:12:26.380Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", + "at": "2026-07-02T15:14:30.565Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "delegated", + "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", + "at": "2026-07-02T15:14:30.565Z" + }, + { + "actorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", + "kind": "commented", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T15:14:43.831Z" + }, + { + "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "kind": "commented", + "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", + "at": "2026-07-02T15:14:44.435Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "commented", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "at": "2026-07-02T15:15:02.505Z" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "kind": "commented", + "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", + "at": "2026-07-02T15:15:56.982Z" + }, + { + "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "kind": "commented", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T15:16:35.238Z" + }, + { + "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "kind": "commented", + "issueId": "16901797-89cc-4b85-8213-7a27d70f9d16", + "at": "2026-07-02T15:17:07.032Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "created", + "issueId": "e0189c44-1245-4526-9423-1128862d7c95", + "at": "2026-07-02T15:18:55.353Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "approved", + "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", + "at": "2026-07-02T15:19:05.154Z" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "kind": "commented", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T15:19:11.188Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "commented", + "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", + "at": "2026-07-02T15:19:25.678Z" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "kind": "approved", + "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", + "at": "2026-07-02T15:19:39.277Z" + }, + { + "actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "kind": "commented", + "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", + "at": "2026-07-02T15:19:55.477Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "commented", + "issueId": "e0189c44-1245-4526-9423-1128862d7c95", + "at": "2026-07-02T15:20:10.329Z" + }, + { + "actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "kind": "commented", + "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", + "at": "2026-07-02T15:20:17.139Z" + }, + { + "actorId": "system:system", + "kind": "commented", + "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", + "at": "2026-07-02T15:20:20.709Z" + }, + { + "actorId": "system:system", + "kind": "commented", + "issueId": "6f7ac707-363a-4e77-b3d0-7479165352db", + "at": "2026-07-02T15:20:20.751Z" + }, + { + "actorId": "system:system", + "kind": "commented", + "issueId": "153322a9-301d-4868-b15e-a24063ceeca2", + "at": "2026-07-02T15:20:20.803Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "commented", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T15:21:26.500Z" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "kind": "commented", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T15:22:40.012Z" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "kind": "commented", + "issueId": "153322a9-301d-4868-b15e-a24063ceeca2", + "at": "2026-07-02T15:22:40.511Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "commented", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T15:24:07.505Z" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "kind": "commented", + "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", + "at": "2026-07-02T15:24:36.094Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "created", + "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", + "at": "2026-07-02T15:25:07.938Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "delegated", + "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", + "at": "2026-07-02T15:25:07.938Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "commented", + "issueId": "e0189c44-1245-4526-9423-1128862d7c95", + "at": "2026-07-02T15:25:26.349Z" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "kind": "commented", + "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", + "at": "2026-07-02T15:25:28.318Z" + }, + { + "actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "kind": "commented", + "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", + "at": "2026-07-02T15:25:56.773Z" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "kind": "created", + "issueId": "9ae0377a-8040-4221-9376-4ae64270e3b4", + "at": "2026-07-02T15:27:39.300Z" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "kind": "commented", + "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", + "at": "2026-07-02T15:28:01.540Z" + }, + { + "actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "kind": "commented", + "issueId": "6f7ac707-363a-4e77-b3d0-7479165352db", + "at": "2026-07-02T15:28:02.346Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "commented", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "at": "2026-07-02T15:28:13.852Z" + }, + { + "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "kind": "commented", + "issueId": "9ae0377a-8040-4221-9376-4ae64270e3b4", + "at": "2026-07-02T15:28:52.824Z" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "kind": "commented", + "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", + "at": "2026-07-02T15:30:55.654Z" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "kind": "commented", + "issueId": "e0189c44-1245-4526-9423-1128862d7c95", + "at": "2026-07-02T15:34:59.686Z" + }, + { + "actorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "kind": "approved", + "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", + "at": "2026-07-02T15:35:56.554Z" + }, + { + "actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "kind": "commented", + "issueId": "9ae0377a-8040-4221-9376-4ae64270e3b4", + "at": "2026-07-02T15:36:48.753Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "approved", + "issueId": "e0189c44-1245-4526-9423-1128862d7c95", + "at": "2026-07-02T15:37:11.074Z" + }, + { + "actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "kind": "commented", + "issueId": "e0189c44-1245-4526-9423-1128862d7c95", + "at": "2026-07-02T15:37:24.105Z" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "kind": "approved", + "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", + "at": "2026-07-02T15:38:21.704Z" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "kind": "commented", + "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", + "at": "2026-07-02T15:38:31.695Z" + }, + { + "actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "kind": "commented", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "at": "2026-07-02T15:41:22.766Z" + }, + { + "actorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", + "kind": "approved", + "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", + "at": "2026-07-02T15:43:04.428Z" + }, + { + "actorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", + "kind": "commented", + "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", + "at": "2026-07-02T15:47:28.798Z" + }, + { + "actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "kind": "commented", + "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", + "at": "2026-07-02T15:48:25.833Z" + } + ], + "edges": [ + { + "fromActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "toActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", + "at": "2026-06-03T19:39:46.628Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "toActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "issueId": "6f7ac707-363a-4e77-b3d0-7479165352db", + "at": "2026-06-03T19:40:14.945Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "toActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "issueId": "153322a9-301d-4868-b15e-a24063ceeca2", + "at": "2026-06-03T19:40:14.974Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "issueId": "5e955520-f5ea-4ad4-aef6-860c95b1797e", + "at": "2026-06-03T20:23:11.278Z", + "kind": "assignment" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", + "issueId": "bc7a42bb-91bb-40b3-b3a8-6f9ebf98bc94", + "at": "2026-06-30T22:22:13.060Z", + "kind": "assignment" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:db1c09f1-422c-48c8-9711-4054d3657aac", + "issueId": "6ff91230-ab9d-4b95-9590-1471ae37297e", + "at": "2026-07-01T16:31:55.423Z", + "kind": "assignment" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", + "at": "2026-07-01T18:23:43.255Z", + "kind": "assignment" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "issueId": "48264b4d-0004-4015-9ca0-08c867eed064", + "at": "2026-07-02T00:13:22.977Z", + "kind": "assignment" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "issueId": "2e375459-115c-4f0c-a1c8-59e359b5c3f6", + "at": "2026-07-02T02:21:56.069Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "issueId": "df84e69c-35e1-4323-a95f-e10d27b3227c", + "at": "2026-07-02T02:39:19.891Z", + "kind": "assignment" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "issueId": "ad7f2c54-6e50-421f-a805-4c743948421a", + "at": "2026-07-02T03:29:27.493Z", + "kind": "assignment" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "issueId": "44c2c3d2-7dc0-44df-beb6-ad2417b81503", + "at": "2026-07-02T04:45:14.251Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", + "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "issueId": "44c2c3d2-7dc0-44df-beb6-ad2417b81503", + "at": "2026-07-02T04:45:14.251Z", + "kind": "delegation" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", + "at": "2026-07-02T04:52:34.737Z", + "kind": "assignment" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "issueId": "db6e4af1-0669-42d0-a6ef-e12dcfd7112d", + "at": "2026-07-02T04:56:04.034Z", + "kind": "assignment" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "issueId": "828497e6-087d-4336-b627-331e51380ad4", + "at": "2026-07-02T05:07:46.810Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "issueId": "abe00e5c-bbcd-4782-ba9e-13532a09184f", + "at": "2026-07-02T05:08:53.619Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "at": "2026-07-02T14:44:59.609Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", + "at": "2026-07-02T14:44:59.609Z", + "kind": "delegation" + }, + { + "fromActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "toActorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", + "at": "2026-07-02T14:52:04.388Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "toActorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", + "at": "2026-07-02T14:52:04.388Z", + "kind": "delegation" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "at": "2026-07-02T14:52:21.683Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", + "at": "2026-07-02T14:52:21.683Z", + "kind": "delegation" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", + "at": "2026-07-02T14:52:21.778Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", + "at": "2026-07-02T14:52:21.778Z", + "kind": "delegation" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", + "at": "2026-07-02T14:52:53.705Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", + "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", + "at": "2026-07-02T14:52:53.705Z", + "kind": "delegation" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", + "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", + "at": "2026-07-02T14:53:41.388Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", + "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", + "at": "2026-07-02T14:53:41.388Z", + "kind": "delegation" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "issueId": "a466f1d9-cfb3-4fa1-a354-57d8bee78ce1", + "at": "2026-07-02T14:53:55.809Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "issueId": "a466f1d9-cfb3-4fa1-a354-57d8bee78ce1", + "at": "2026-07-02T14:53:55.809Z", + "kind": "delegation" + }, + { + "fromActorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", + "at": "2026-07-02T14:54:41.875Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", + "at": "2026-07-02T14:54:41.875Z", + "kind": "delegation" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", + "at": "2026-07-02T14:55:00.287Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", + "at": "2026-07-02T14:55:00.287Z", + "kind": "delegation" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "issueId": "0dc1021f-2326-4977-a5b2-7eb4ab66e6df", + "at": "2026-07-02T14:57:50.676Z", + "kind": "assignment" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "issueId": "16901797-89cc-4b85-8213-7a27d70f9d16", + "at": "2026-07-02T14:59:42.755Z", + "kind": "assignment" + }, + { + "fromActorId": "system:system", + "toActorId": "agent:c9dcefd1-b052-489a-a1f3-4cd1783bcb5f", + "issueId": "1533f78d-d677-449f-afd9-4cf653168438", + "at": "2026-07-02T15:00:19.659Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "issueId": "0facd02e-425f-4822-875c-2f97988de98a", + "at": "2026-07-02T15:00:46.643Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "issueId": "0facd02e-425f-4822-875c-2f97988de98a", + "at": "2026-07-02T15:00:46.643Z", + "kind": "delegation" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", + "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", + "at": "2026-07-02T15:03:27.354Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "toActorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", + "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", + "at": "2026-07-02T15:05:38.834Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "toActorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", + "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", + "at": "2026-07-02T15:05:38.834Z", + "kind": "delegation" + }, + { + "fromActorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", + "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", + "at": "2026-07-02T15:07:45.644Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", + "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", + "at": "2026-07-02T15:07:45.644Z", + "kind": "delegation" + }, + { + "fromActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", + "at": "2026-07-02T15:10:51.721Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", + "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", + "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", + "at": "2026-07-02T15:10:51.721Z", + "kind": "delegation" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", + "at": "2026-07-02T15:14:30.565Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", + "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", + "at": "2026-07-02T15:14:30.565Z", + "kind": "delegation" + }, + { + "fromActorId": "user:1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om", + "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "issueId": "e0189c44-1245-4526-9423-1128862d7c95", + "at": "2026-07-02T15:18:55.353Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", + "at": "2026-07-02T15:25:07.938Z", + "kind": "assignment" + }, + { + "fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", + "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", + "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", + "at": "2026-07-02T15:25:07.938Z", + "kind": "delegation" + }, + { + "fromActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", + "toActorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", + "issueId": "9ae0377a-8040-4221-9376-4ae64270e3b4", + "at": "2026-07-02T15:27:39.300Z", + "kind": "assignment" + } + ], + "pagination": { + "limit": 500, + "offset": 0, + "totalIssues": 37, + "hasMore": false + }, + "window": { + "from": "2026-07-02T14:00:00.000Z", + "to": "2026-07-02T16:00:00.000Z", + "capped": false + } +} \ No newline at end of file diff --git a/ui/storybook/fixtures/workTimeline.sample.json b/ui/storybook/fixtures/workTimeline.sample.json new file mode 100644 index 00000000000..29a8ada09d6 --- /dev/null +++ b/ui/storybook/fixtures/workTimeline.sample.json @@ -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", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "laneHint": "automation", "runId": "cbce714c-a056-477d-971b-3ceb02a4441b", "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", "issueIdentifier": "PAP-12435", "issueTitle": "CloudOps: attach pages.paperclip.ing uploader IAM policy", "start": "2026-07-02T15:23:13.966Z", "end": "2026-07-02T15:23:32.715Z", "status": "cancelled", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "laneHint": "automation", "runId": "671705a1-718d-4be6-86b4-390295a17a74", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T15:22:48.720Z", "end": "2026-07-02T15:24:16.925Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "85339e14-6ac3-459c-99a2-811e0e6adbba", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T15:21:38.307Z", "end": "2026-07-02T15:22:48.667Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "laneHint": "automation", "runId": "ebc8e142-c416-4b85-86ff-be0fed98f41f", "issueId": "153322a9-301d-4868-b15e-a24063ceeca2", "issueIdentifier": "PAP-10275", "issueTitle": "Defect (Minor): CLI install lacks --adapter-override and --secret-value flags", "start": "2026-07-02T15:20:20.854Z", "end": "2026-07-02T15:22:56.590Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "laneHint": "automation", "runId": "accd3b96-89a2-47f4-a3db-65f94ec6be3a", "issueId": "6f7ac707-363a-4e77-b3d0-7479165352db", "issueIdentifier": "PAP-10274", "issueTitle": "Defect (Minor UX): \"Partial state is not rolled back\" message is incorrect \u2014 install transaction rolled back cleanly", "start": "2026-07-02T15:20:20.784Z", "end": "2026-07-02T15:28:39.720Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "laneHint": "automation", "runId": "8e58708e-88df-4681-84c5-f6a676d1dd6a", "issueId": "e3afbdcf-98b8-4971-a094-9a92d78736d1", "issueIdentifier": "PAP-10273", "issueTitle": "Defect (Minor): preview emits noisy \"skill not present in package\" warnings for catalog-skill references", "start": "2026-07-02T15:20:20.734Z", "end": "2026-07-02T15:25:39.352Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "laneHint": "automation", "runId": "17e168c3-c2f4-4cef-9492-c4e680bebcf5", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T15:19:28.163Z", "end": "2026-07-02T15:21:38.254Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "laneHint": "assignment", "runId": "4cec085d-bf1f-48a2-aef1-62cce353a09f", "issueId": "e0189c44-1245-4526-9423-1128862d7c95", "issueIdentifier": "PAP-12438", "issueTitle": "environment variables editor re-design", "start": "2026-07-02T15:18:55.386Z", "end": "2026-07-02T15:26:14.263Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "df0d6959-ef5f-4a75-b710-492cfd6de658", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T15:16:53.952Z", "end": "2026-07-02T15:19:28.111Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "laneHint": "assignment", "runId": "a9ba6322-e0e3-43bb-bc0b-2af53bec9610", "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", "issueIdentifier": "PAP-12436", "issueTitle": "CTO/admin: grant IAM write path for paperclip-page-uploader pages policy", "start": "2026-07-02T15:14:59.782Z", "end": "2026-07-02T15:20:10.000Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "laneHint": "automation", "runId": "cd8cf33f-5a1c-4b3a-905e-aabc0341edd1", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T15:14:57.094Z", "end": "2026-07-02T15:16:53.870Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "laneHint": "assignment", "runId": "f4d1d8a6-c74d-4d59-b831-b788eeaf93f2", "issueId": "57a438c1-876a-40ce-9b2b-5c8a8b47ec5b", "issueIdentifier": "PAP-12437", "issueTitle": "for parent task, cleanup the effecitvely merged ones", "start": "2026-07-02T15:14:30.602Z", "end": "2026-07-02T15:20:22.033Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", "laneHint": "automation", "runId": "90cd6835-be7c-4dd8-bdf7-017d40df9c38", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T15:12:26.440Z", "end": "2026-07-02T15:14:56.976Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", "laneHint": "assignment", "runId": "b83d9b78-f63c-465c-808c-c1e1175a18c5", "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", "issueIdentifier": "PAP-12436", "issueTitle": "CTO/admin: grant IAM write path for paperclip-page-uploader pages policy", "start": "2026-07-02T15:10:51.773Z", "end": "2026-07-02T15:14:59.703Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "laneHint": "automation", "runId": "e4e854bc-9cce-4c5f-bfb9-a2f3c0094075", "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", "issueIdentifier": "PAP-12420", "issueTitle": "Attach S3 policy to paperclip-page-uploader IAM user for pages.paperclip.ing", "start": "2026-07-02T15:10:25.014Z", "end": "2026-07-02T15:15:34.966Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "laneHint": "assignment", "runId": "bcfaef62-ca9a-4a96-b279-bf2314108dbf", "issueId": "40490e19-4e4a-4fa3-8300-719a82d3633a", "issueIdentifier": "PAP-12403", "issueTitle": "github and environment variables", "start": "2026-07-02T15:09:15.958Z", "end": "2026-07-02T15:20:22.053Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "laneHint": "assignment", "runId": "0ecdb797-e6b4-4f12-a5f0-b7bce348138f", "issueId": "804fff38-a46b-4391-9c44-cd76fd889753", "issueIdentifier": "PAP-12435", "issueTitle": "CloudOps: attach pages.paperclip.ing uploader IAM policy", "start": "2026-07-02T15:07:45.699Z", "end": "2026-07-02T15:11:13.138Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", "laneHint": "assignment", "runId": "0961f6e8-a1cd-4493-9979-99775285c748", "issueId": "7751d27f-c016-4be5-a332-b29b888e8b48", "issueIdentifier": "PAP-12434", "issueTitle": "Strategic Ops: get AWS admin to attach pages.paperclip.ing uploader policy", "start": "2026-07-02T15:05:38.870Z", "end": "2026-07-02T15:08:15.576Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", "laneHint": "assignment", "runId": "33181a90-d2af-4081-9deb-253d996d81f1", "issueId": "09b1e135-e25c-4fb5-a812-48dfd15edd55", "issueIdentifier": "PAP-12433", "issueTitle": "project star\u2019ing", "start": "2026-07-02T15:03:27.392Z", "end": "2026-07-02T15:52:11.373Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", "laneHint": "automation", "runId": "12223c0b-0bad-4b95-be56-ceb1ce955b0f", "issueId": "d6327ba4-5bed-4bad-b860-213256cf127a", "issueIdentifier": "PAP-12425", "issueTitle": "Work Timeline \u2014 security review: timeline endpoint ACLs and data exposure", "start": "2026-07-02T15:01:28.563Z", "end": "2026-07-02T15:05:43.985Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "laneHint": "assignment", "runId": "e08e32ae-9106-46e0-9c2b-97ab74520fc4", "issueId": "0facd02e-425f-4822-875c-2f97988de98a", "issueIdentifier": "PAP-12432", "issueTitle": "CEO/admin: apply paperclip-page-uploader pages.paperclip.ing inline policy", "start": "2026-07-02T15:00:46.697Z", "end": "2026-07-02T15:06:05.586Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:c9dcefd1-b052-489a-a1f3-4cd1783bcb5f", "laneHint": "assignment", "runId": "91f67e3b-18ae-4230-8fae-18b414ffcd14", "issueId": "1533f78d-d677-449f-afd9-4cf653168438", "issueIdentifier": "PAP-12431", "issueTitle": "2026-07-02 create a standup message i can copy to slack about everything i've been working on for the last 36 hours", "start": "2026-07-02T15:00:19.704Z", "end": "2026-07-02T15:05:16.672Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "laneHint": "assignment", "runId": "e9ff0a09-6607-4793-90f3-1b3ab1741ef0", "issueId": "16901797-89cc-4b85-8213-7a27d70f9d16", "issueIdentifier": "PAP-12430", "issueTitle": "merged Create a single PR from this branch PAP-12404-inbox-blocked-but-descendent-live-icon", "start": "2026-07-02T14:59:42.783Z", "end": "2026-07-02T15:17:12.936Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "laneHint": "assignment", "runId": "67f65027-b802-4e88-91a6-bebb5c94f16b", "issueId": "0dc1021f-2326-4977-a5b2-7eb4ab66e6df", "issueIdentifier": "PAP-12429", "issueTitle": "give the AWS_ keys that ClaudeCoder has to CodexCoder and CloudOpsEngineer and UXDesigner", "start": "2026-07-02T14:57:50.711Z", "end": "2026-07-02T15:01:18.973Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "laneHint": "assignment", "runId": "a61b657c-d297-43b7-948c-2be7bd161e3b", "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", "issueIdentifier": "PAP-12427", "issueTitle": "CEO/admin: unblock IAM mutation for paperclip-page-uploader pages policy", "start": "2026-07-02T14:57:27.831Z", "end": "2026-07-02T15:01:08.081Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "laneHint": "automation", "runId": "960639c7-f6cc-45ad-b746-22ca507c237d", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T14:57:12.287Z", "end": "2026-07-02T15:12:26.352Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "laneHint": "automation", "runId": "77ffd755-a755-41e6-b2cb-ee0817f11152", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T14:55:43.595Z", "end": "2026-07-02T14:57:12.235Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "laneHint": "assignment", "runId": "d14a8901-4569-4f05-b8c2-5e49d4216a6d", "issueId": "6d12933d-1393-484c-aeb8-17d98e61b8c0", "issueIdentifier": "PAP-12428", "issueTitle": "just change the terminology to \u201cResponsible\u201d", "start": "2026-07-02T14:55:00.330Z", "end": "2026-07-02T15:01:10.197Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "laneHint": "assignment", "runId": "972b6b5b-5fdd-419d-aea0-5641b4634448", "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", "issueIdentifier": "PAP-12427", "issueTitle": "CEO/admin: unblock IAM mutation for paperclip-page-uploader pages policy", "start": "2026-07-02T14:54:41.918Z", "end": "2026-07-02T14:57:27.722Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "laneHint": "assignment", "runId": "4b085459-77b0-41e4-a62f-a76e1ba9b521", "issueId": "e64a9b35-54e8-430c-9244-18f60c70f87a", "issueIdentifier": "PAP-12423", "issueTitle": "Work Timeline \u2014 Phase B: timeline aggregation endpoint (GET /companies/:id/timeline)", "start": "2026-07-02T14:52:21.929Z", "end": "2026-07-02T15:03:54.376Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "laneHint": "assignment", "runId": "8a716a87-61e2-4667-9cc4-3641fe42c6f0", "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-02T14:52:21.788Z", "end": "2026-07-02T15:07:44.596Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", "laneHint": "assignment", "runId": "e573c5dc-94b9-4b63-bdb3-c48327a85cea", "issueId": "9c5003e8-104d-46ae-9389-c62cb6fc8889", "issueIdentifier": "PAP-12421", "issueTitle": "CTO/admin: attach corrected pages.paperclip.ing S3 policy to paperclip-page-uploader", "start": "2026-07-02T14:52:04.426Z", "end": "2026-07-02T14:56:08.638Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "laneHint": "automation", "runId": "6b0a55a9-7c1a-4d48-bdfb-159370e9c03d", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "issueIdentifier": "PAP-12405", "issueTitle": "design a \u2018gantt chart\u2019 representation of my paperclip work", "start": "2026-07-02T14:50:35.277Z", "end": "2026-07-02T14:55:43.520Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "laneHint": "automation", "runId": "c214c9f0-b46b-47de-ba86-2520e1a8aa75", "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", "issueIdentifier": "PAP-12420", "issueTitle": "Attach S3 policy to paperclip-page-uploader IAM user for pages.paperclip.ing", "start": "2026-07-02T14:48:23.194Z", "end": "2026-07-02T14:52:29.939Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "laneHint": "assignment", "runId": "c7a242bb-adf1-46e2-a322-474ed791c7ff", "issueId": "b5a59535-38f5-4f0d-a0bf-2bf755a4be15", "issueIdentifier": "PAP-12420", "issueTitle": "Attach S3 policy to paperclip-page-uploader IAM user for pages.paperclip.ing", "start": "2026-07-02T14:44:59.649Z", "end": "2026-07-02T14:48:23.151Z", "status": "cancelled", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "571d1495-fe7e-4cf8-a79f-e4d8d779cecf", "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", "issueIdentifier": "PAP-12298", "issueTitle": "blog page redesign", "start": "2026-07-02T14:42:05.689Z", "end": "2026-07-02T14:45:37.694Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}], "events": [], "edges": [{"fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "issueId": "e0189c44-1245-4526-9423-1128862d7c95", "at": "2026-07-02T15:26:14.310Z", "kind": "delegation"}, {"fromActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "issueId": "e0189c44-1245-4526-9423-1128862d7c95", "at": "2026-07-02T15:34:59.718Z", "kind": "delegation"}, {"fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "toActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T14:55:43.595Z", "kind": "delegation"}, {"fromActorId": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "toActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T14:57:12.287Z", "kind": "delegation"}, {"fromActorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "toActorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T15:12:26.440Z", "kind": "delegation"}, {"fromActorId": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", "toActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T15:14:57.094Z", "kind": "delegation"}, {"fromActorId": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "toActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T15:16:53.952Z", "kind": "delegation"}, {"fromActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T15:19:28.163Z", "kind": "delegation"}, {"fromActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "toActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T15:21:38.307Z", "kind": "delegation"}, {"fromActorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "toActorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "issueId": "828497e6-087d-4336-b627-331e51380ad4", "at": "2026-07-02T15:22:48.720Z", "kind": "delegation"}, {"fromActorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", "toActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "issueId": "91292c04-bbfe-4a94-9779-baceb3941857", "at": "2026-07-02T15:14:59.782Z", "kind": "delegation"}, {"fromActorId": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "toActorId": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "issueId": "5a3cf258-ae30-44ba-9b57-494eaf6c0238", "at": "2026-07-02T14:57:27.831Z", "kind": "delegation"}], "pagination": {"limit": 200, "offset": 0, "totalIssues": 26, "hasMore": false}, "window": {"from": "2026-07-02T14:00:00.000Z", "to": "2026-07-02T15:50:00.000Z", "capped": false}} \ No newline at end of file diff --git a/ui/storybook/stories/work-timeline.stories.tsx b/ui/storybook/stories/work-timeline.stories.tsx new file mode 100644 index 00000000000..f717f39d6f5 --- /dev/null +++ b/ui/storybook/stories/work-timeline.stories.tsx @@ -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(initialZoom); + const [zoomScale, setZoomScale] = useState(undefined); + + const adjustZoom = (factor: number) => { + const nextScale = clampZoomScale((zoomScale ?? zoomScaleForLevel(zoom)) * factor); + setZoomScale(nextScale); + setZoom(nearestZoomForScale(nextScale)); + }; + + const resetZoom = () => { + setZoom(initialZoom); + setZoomScale(undefined); + }; + + return ( +
+
+
+

Work Timeline

+
+ +
+ + + +
+ +
+
+ { + setZoomScale(nextScale); + setZoom(nextZoom); + }} + /> +
+

+ {data.spans.length} runs · {data.actors.length} actors · {data.events.length} human/instant events · real + company data +

+
+
+
+ ); +} + +const meta: Meta = { + title: "Pages/Work Timeline", + component: TimelineHarness, + parameters: { layout: "fullscreen" }, +}; +export default meta; + +type Story = StoryObj; + +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(), + }, +};