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_issue_work_product_identity_unique.sql b/packages/db/src/migrations/0134_issue_work_product_identity_unique.sql similarity index 100% rename from packages/db/src/migrations/0131_issue_work_product_identity_unique.sql rename to packages/db/src/migrations/0134_issue_work_product_identity_unique.sql diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 19569f5c472..5e6cda3a557 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": 1783343000000, - "tag": "0131_issue_work_product_identity_unique", + "tag": "0134_issue_work_product_identity_unique", "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/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/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/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/components/Sidebar.test.tsx b/ui/src/components/Sidebar.test.tsx index dfbdf9677b4..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(); diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index fc5bac9d765..1cb871b51d8 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -26,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"; @@ -59,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). @@ -187,7 +188,10 @@ export function Sidebar() { ) : null} {streamlined ? ( - + <> + + + ) : null} { }; 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/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/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, + })} + /> } />