Merge origin/master into work-product workspace PR

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-06 19:55:04 +00:00
commit fbad0d1ed6
50 files changed
+2858 -289

No files matched your search

+12
View File
@@ -56,6 +56,18 @@ pnpm build-storybook
These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`.
## UI Fonts And Screenshots
The board UI ships its own sans-serif webfont assets in `ui/public/fonts/`.
`ui/src/index.css` declares Inter v4.1 variable regular and italic faces and wires
the Tailwind `font-sans` token to those bundled files before system fallbacks.
Linux screenshot or Storybook capture jobs should not install host Inter packages
or inject external font CSS to make Paperclip text render correctly.
Font assets live in Vite's public directory so `pnpm --filter @paperclipai/ui build`
emits them under `ui/dist/fonts/`. The server package copies the same output into
`server/ui-dist/fonts/` through `scripts/prepare-server-ui-dist.sh`.
Inspect or stop the current repo's managed dev runner:
```sh
+2 -1
View File
@@ -40,7 +40,8 @@ Priority indicators:
### Typography
- **Font:** System font stack (Inter if loaded, else `-apple-system, BlinkMacSystemFont, 'Segoe UI'`)
- **Font:** Bundled Inter v4.1 variable WOFF2 for sans text, loaded from `/fonts/InterVariable.woff2` and `/fonts/InterVariable-Italic.woff2`, with `Inter`, `ui-sans-serif`, `system-ui`, `-apple-system`, `BlinkMacSystemFont`, and `'Segoe UI'` fallbacks.
- **Mono:** System monospace stack via the `font-mono` token (`ui-monospace`, `SFMono-Regular`, `Menlo`, `Monaco`, `Consolas`, `Liberation Mono`, `Courier New`, `monospace`).
- **Body:** 13px / 1.5 line-height
- **Labels/metadata:** 11px / uppercase tracking
- **Headings:** 14-18px / semi-bold, never all-caps
+10 -3
View File
@@ -1,5 +1,11 @@
"use strict";
function stripAnsi(text) {
return text
.replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "")
.replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
}
function safeJsonParse(text) {
try {
return JSON.parse(text);
@@ -18,7 +24,8 @@ function asString(value) {
}
function parseStdoutLine(line, ts) {
const trimmed = line.trim();
const cleaned = stripAnsi(line);
const trimmed = cleaned.trim();
if (!trimmed) return [];
const eventMatch = trimmed.match(/^\[hermes-gateway:event\]\s+run=([^\s]+)\s+event=([^\s]+)\s+data=(.*)$/s);
@@ -27,7 +34,7 @@ function parseStdoutLine(line, ts) {
const data = asRecord(safeJsonParse(eventMatch[3]));
if (eventName === "message.delta") {
const delta = asString(data && data.delta) || asString(data && data.text_delta);
return delta ? [{ kind: "assistant", ts, text: delta, delta: true }] : [];
return delta ? [{ kind: "assistant", ts, text: stripAnsi(delta), delta: true }] : [];
}
if (eventName === "run.failed" || eventName === "run.error") {
const message = asString(data && data.error) || asString(data && data.message) || "Hermes run failed";
@@ -43,7 +50,7 @@ function parseStdoutLine(line, ts) {
return [{ kind: "system", ts, text: trimmed.replace(/^\[hermes-gateway\]\s*/, "") }];
}
return [{ kind: "stdout", ts, text: line }];
return [{ kind: "stdout", ts, text: cleaned }];
}
module.exports = { parseStdoutLine };
@@ -1,5 +1,15 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
/**
* Strip ANSI escape sequences (CSI, OSC) from terminal text.
* Same pattern used in claude-local adapter quota.ts.
*/
function stripAnsi(text: string): string {
return text
.replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "")
.replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
}
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
@@ -18,7 +28,8 @@ function asString(value: unknown): string {
}
export function parseHermesGatewayStdoutLine(line: string, ts: string): TranscriptEntry[] {
const trimmed = line.trim();
const cleaned = stripAnsi(line);
const trimmed = cleaned.trim();
if (!trimmed) return [];
const eventMatch = trimmed.match(/^\[hermes-gateway:event\]\s+run=([^\s]+)\s+event=([^\s]+)\s+data=(.*)$/s);
@@ -27,7 +38,7 @@ export function parseHermesGatewayStdoutLine(line: string, ts: string): Transcri
const data = asRecord(safeJsonParse(eventMatch[3]));
if (eventName === "message.delta") {
const delta = asString(data?.delta) || asString(data?.text_delta);
return delta ? [{ kind: "assistant", ts, text: delta, delta: true }] : [];
return delta ? [{ kind: "assistant", ts, text: stripAnsi(delta), delta: true }] : [];
}
if (eventName === "run.failed" || eventName === "run.error") {
const message = asString(data?.error) || asString(data?.message) || "Hermes run failed";
@@ -43,5 +54,5 @@ export function parseHermesGatewayStdoutLine(line: string, ts: string): Transcri
return [{ kind: "system", ts, text: trimmed.replace(/^\[hermes-gateway\]\s*/, "") }];
}
return [{ kind: "stdout", ts, text: line }];
return [{ kind: "stdout", ts, text: cleaned }];
}
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { parseHermesStdoutLine } from "./parse-stdout.js";
const TS = "2026-06-29T12:00:00.000Z";
describe("parseHermesStdoutLine — ANSI stripping", () => {
it("strips 24-bit foreground + background color CSI sequences", () => {
const result = parseHermesStdoutLine(
"\x1b[38;2;255;255;255;48;2;19;87;20m+r = curl(\"POST\", \"/api/issues/d7b08cc5/comments\",\x1b[0m",
TS,
);
expect(result.length).toBeGreaterThan(0);
for (const entry of result) {
for (const v of Object.values(entry)) {
if (typeof v === "string") {
expect(v).not.toMatch(/\x1b\[/);
}
}
}
});
it("strips bold yellow CSI sequence from Hermes header", () => {
const result = parseHermesStdoutLine("\x1b[1;38;2;255;215;0m- Hermes\x1b[0m", TS);
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("text", "- Hermes");
});
it("strips light text CSI sequence", () => {
const result = parseHermesStdoutLine(
"\x1b[38;2;255;248;220mAll done. Now let me verify.\x1b[0m",
TS,
);
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("text", "All done. Now let me verify.");
});
it("passes through clean text unchanged", () => {
const result = parseHermesStdoutLine("Normal text without ANSI", TS);
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("text", "Normal text without ANSI");
});
it("strips multiple CSI sequences on a single line", () => {
const result = parseHermesStdoutLine(
"\x1b[38;2;255;255;255;48;2;19;87;20m+ \"priority\": \"highest\",\x1b[0m \x1b[38;2;255;255;255;48;2;19;87;20m+r = curl(\"PATCH\", ...\x1b[0m",
TS,
);
expect(result.length).toBeGreaterThan(0);
for (const entry of result) {
for (const v of Object.values(entry)) {
if (typeof v === "string") {
expect(v).not.toMatch(/\x1b\[/);
}
}
}
});
it("still parses tool completion lines correctly after stripping", () => {
const result = parseHermesStdoutLine("\u250a \u{1f50d} search \"pattern\" 0.5s", TS);
expect(result.length).toBeGreaterThanOrEqual(2);
const toolCall = result.find((e) => e.kind === "tool_call");
expect(toolCall?.name).toBe("search");
});
it("still parses shell tool lines correctly after stripping", () => {
const result = parseHermesStdoutLine("\u250a $ ls -la 0.3s", TS);
const toolCall = result.find((e) => e.kind === "tool_call");
expect(toolCall?.name).toBe("shell");
});
it("strips OSC title sequences", () => {
const result = parseHermesStdoutLine("\x1b]0;Terminal Title\x07Actual content", TS);
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("text", "Actual content");
});
it("handles empty lines after ANSI stripping", () => {
const result = parseHermesStdoutLine("\x1b[0m", TS);
expect(result).toHaveLength(0);
});
});
@@ -1,3 +1,13 @@
/**
* Strip ANSI escape sequences (CSI, OSC) from terminal text.
* Same pattern used in claude-local adapter quota.ts.
*/
function stripAnsi(text: string): string {
return text
.replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "")
.replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
}
/**
* Parse Hermes Agent stdout into TranscriptEntry objects for the Paperclip UI.
*
@@ -184,7 +194,7 @@ export function parseHermesStdoutLine(
line: string,
ts: string,
): TranscriptEntry[] {
const trimmed = line.trim();
const trimmed = stripAnsi(line).trim();
if (!trimmed) return [];
// ── System/adapter messages ────────────────────────────────────────────
+7 -1
View File
@@ -1,5 +1,11 @@
"use strict";
function stripAnsi(text) {
return text
.replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "")
.replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
}
const TOOL_OUTPUT_PREFIX = "\u250a";
function stripKaomoji(text) {
@@ -114,7 +120,7 @@ function isThinkingLine(line) {
}
function parseStdoutLine(line, ts) {
const trimmed = line.trim();
const trimmed = stripAnsi(line).trim();
if (!trimmed) return [];
if (trimmed.startsWith("[hermes]") || trimmed.startsWith("[paperclip]")) {
+127
View File
@@ -541,4 +541,131 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
},
20_000,
);
it(
"replays the run responsible user repair migration when heartbeat run issue refs are identifiers",
async () => {
const connectionString = await createTempDatabase();
await applyPendingMigrations(connectionString);
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
const runResponsibleUserRepairHash = await migrationHash(
"0131_repair_run_responsible_user_context_refs.sql",
);
await sql.unsafe(`
INSERT INTO "companies" ("id", "name", "issue_prefix", "created_at", "updated_at")
VALUES ('00000000-0000-0000-0000-000000000130', 'Migration Test Co', 'TST130', now(), now())
`);
await sql.unsafe(`
INSERT INTO "company_memberships" (
"id",
"company_id",
"principal_type",
"principal_id",
"status",
"membership_role",
"created_at",
"updated_at"
)
VALUES (
'00000000-0000-0000-0000-000000000131',
'00000000-0000-0000-0000-000000000130',
'user',
'owner-user',
'active',
'owner',
now(),
now()
)
`);
await sql.unsafe(`
INSERT INTO "agents" ("id", "company_id", "name", "role", "adapter_type", "created_at", "updated_at")
VALUES (
'00000000-0000-0000-0000-000000000132',
'00000000-0000-0000-0000-000000000130',
'Migration Agent',
'general',
'process',
now(),
now()
)
`);
await sql.unsafe(`
INSERT INTO "issues" (
"id",
"company_id",
"title",
"status",
"responsible_user_id",
"identifier",
"created_at",
"updated_at"
)
VALUES (
'00000000-0000-0000-0000-000000000133',
'00000000-0000-0000-0000-000000000130',
'Identifier referenced issue',
'todo',
'issue-user',
'TST130-1',
now(),
now()
)
`);
await sql.unsafe(`
INSERT INTO "heartbeat_runs" (
"id",
"company_id",
"agent_id",
"status",
"responsible_user_id",
"context_snapshot",
"created_at",
"updated_at"
)
VALUES (
'00000000-0000-0000-0000-000000000134',
'00000000-0000-0000-0000-000000000130',
'00000000-0000-0000-0000-000000000132',
'completed',
NULL,
'{"issueId":"TST130-1"}'::jsonb,
now(),
now()
)
`);
await sql.unsafe(
`DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${runResponsibleUserRepairHash}'`,
);
} finally {
await sql.end();
}
const pendingState = await inspectMigrations(connectionString);
expect(pendingState).toMatchObject({
status: "needsMigrations",
pendingMigrations: ["0131_repair_run_responsible_user_context_refs.sql"],
reason: "pending-migrations",
});
await applyPendingMigrations(connectionString);
const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
const runs = await verifySql.unsafe<{ responsible_user_id: string | null }[]>(`
SELECT "responsible_user_id"
FROM "heartbeat_runs"
WHERE "id" = '00000000-0000-0000-0000-000000000134'
`);
expect(runs).toEqual([{ responsible_user_id: "issue-user" }]);
} finally {
await verifySql.end();
}
},
20_000,
);
});
@@ -0,0 +1,345 @@
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import postgres from "postgres";
import {
applyPendingMigrations,
inspectMigrations,
} from "./client.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./test-embedded-postgres.js";
const DERIVED_ATTRIBUTION_MIGRATION = "0132_issue_comment_derived_attribution_fast.sql";
const cleanups: Array<() => Promise<void>> = [];
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
async function createTempDatabase(): Promise<string> {
const db = await startEmbeddedPostgresTestDatabase("paperclip-derived-attribution-");
cleanups.push(db.cleanup);
return db.connectionString;
}
async function migrationHash(migrationFile: string): Promise<string> {
const content = await fs.promises.readFile(
new URL(`./migrations/${migrationFile}`, import.meta.url),
"utf8",
);
return createHash("sha256").update(content).digest("hex");
}
async function makeDerivedAttributionMigrationPending(
sql: ReturnType<typeof postgres>,
): Promise<void> {
const hash = await migrationHash(DERIVED_ATTRIBUTION_MIGRATION);
await sql`
DELETE FROM "drizzle"."__drizzle_migrations"
WHERE "hash" = ${hash}
`;
}
async function dropDerivedAttributionSchema(sql: ReturnType<typeof postgres>): Promise<void> {
await sql`ALTER TABLE "issue_comments" DROP CONSTRAINT IF EXISTS "issue_comments_derived_author_agent_id_agents_id_fk"`;
await sql`ALTER TABLE "issue_comments" DROP CONSTRAINT IF EXISTS "issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk"`;
await sql`ALTER TABLE "issue_comments" DROP COLUMN IF EXISTS "derived_author_agent_id"`;
await sql`ALTER TABLE "issue_comments" DROP COLUMN IF EXISTS "derived_created_by_run_id"`;
await sql`ALTER TABLE "issue_comments" DROP COLUMN IF EXISTS "derived_author_source"`;
}
async function createSeedGraph(sql: ReturnType<typeof postgres>, label: string) {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const runId = randomUUID();
await sql`
INSERT INTO "companies" ("id", "name", "issue_prefix")
VALUES (${companyId}, ${`Company ${label}`}, ${`T${label}`})
`;
await sql`
INSERT INTO "agents" ("id", "company_id", "name", "role", "adapter_type", "adapter_config")
VALUES (${agentId}, ${companyId}, ${`Agent ${label}`}, 'engineer', 'process', '{}'::jsonb)
`;
await sql`
INSERT INTO "issues" ("id", "company_id", "title", "identifier")
VALUES (${issueId}, ${companyId}, ${`Issue ${label}`}, ${`T${label}-1`})
`;
await sql`
INSERT INTO "heartbeat_runs" ("id", "company_id", "agent_id", "status")
VALUES (${runId}, ${companyId}, ${agentId}, 'succeeded')
`;
return { companyId, agentId, issueId, runId };
}
async function expectDerivedAttributionSchema(sql: ReturnType<typeof postgres>): Promise<void> {
const columns = await sql<{ column_name: string; data_type: string; is_nullable: string }[]>`
SELECT "column_name", "data_type", "is_nullable"
FROM "information_schema"."columns"
WHERE "table_schema" = 'public'
AND "table_name" = 'issue_comments'
AND "column_name" IN (
'derived_author_agent_id',
'derived_created_by_run_id',
'derived_author_source'
)
ORDER BY "column_name"
`;
expect(columns).toEqual([
{ column_name: "derived_author_agent_id", data_type: "uuid", is_nullable: "YES" },
{ column_name: "derived_author_source", data_type: "text", is_nullable: "YES" },
{ column_name: "derived_created_by_run_id", data_type: "uuid", is_nullable: "YES" },
]);
const constraints = await sql<{ conname: string; delete_rule: string }[]>`
SELECT tc."constraint_name" AS "conname", rc."delete_rule"
FROM "information_schema"."table_constraints" tc
JOIN "information_schema"."referential_constraints" rc
ON rc."constraint_schema" = tc."constraint_schema"
AND rc."constraint_name" = tc."constraint_name"
WHERE tc."table_schema" = 'public'
AND tc."table_name" = 'issue_comments'
AND tc."constraint_name" IN (
'issue_comments_derived_author_agent_id_agents_id_fk',
'issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk'
)
ORDER BY tc."constraint_name"
`;
expect(constraints).toEqual([
{
conname: "issue_comments_derived_author_agent_id_agents_id_fk",
delete_rule: "SET NULL",
},
{
conname: "issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk",
delete_rule: "SET NULL",
},
]);
}
afterEach(async () => {
while (cleanups.length > 0) {
const cleanup = cleanups.pop();
await cleanup?.();
}
});
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres derived attribution migration tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("issue comment derived attribution migration", () => {
it(
"fresh installs include the relocated schema and no deleted 0126 migration",
async () => {
const connectionString = await createTempDatabase();
const state = await inspectMigrations(connectionString);
expect(state.status).toBe("upToDate");
expect(state.availableMigrations).not.toContain("0126_issue_comment_derived_attribution.sql");
expect(state.availableMigrations).toContain(DERIVED_ATTRIBUTION_MIGRATION);
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
await expectDerivedAttributionSchema(sql);
const supportIndexes = await sql<{ indexname: string }[]>`
SELECT "indexname"
FROM "pg_indexes"
WHERE "schemaname" = 'public'
AND "indexname" = 'issue_comments_derived_attribution_backfill_idx'
`;
expect(supportIndexes).toEqual([]);
} finally {
await sql.end();
}
},
20_000,
);
it(
"is idempotent for a database that already has 0126 schema and data",
async () => {
const connectionString = await createTempDatabase();
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
const alreadyBackfilledCommentId = randomUUID();
const timingTierCommentId = randomUUID();
const realUserCommentId = randomUUID();
try {
await makeDerivedAttributionMigrationPending(sql);
const { companyId, agentId, issueId, runId } = await createSeedGraph(sql, "OLD126");
await sql`
INSERT INTO "user" ("id", "name", "email", "email_verified", "created_at", "updated_at")
VALUES ('real-user', 'Real User', 'real-user@example.test', true, now(), now())
ON CONFLICT ("id") DO NOTHING
`;
await sql`
INSERT INTO "issue_comments" (
"id",
"company_id",
"issue_id",
"author_user_id",
"created_by_run_id",
"derived_author_agent_id",
"derived_created_by_run_id",
"derived_author_source",
"body"
)
VALUES
(${alreadyBackfilledCommentId}, ${companyId}, ${issueId}, 'local-board', ${runId}, ${agentId}, ${runId}, 'run_id', 'already attributed'),
(${timingTierCommentId}, ${companyId}, ${issueId}, 'local-board', NULL, ${agentId}, ${runId}, 'run_window_unique', 'timing tier'),
(${realUserCommentId}, ${companyId}, ${issueId}, 'real-user', ${runId}, NULL, NULL, NULL, 'human comment')
`;
} finally {
await sql.end();
}
const pendingState = await inspectMigrations(connectionString);
expect(pendingState).toMatchObject({
status: "needsMigrations",
pendingMigrations: [DERIVED_ATTRIBUTION_MIGRATION],
reason: "pending-migrations",
});
await applyPendingMigrations(connectionString);
const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
const rows = await verifySql<{
id: string;
derived_author_agent_id: string | null;
derived_created_by_run_id: string | null;
derived_author_source: string | null;
}[]>`
SELECT
"id",
"derived_author_agent_id",
"derived_created_by_run_id",
"derived_author_source"
FROM "issue_comments"
WHERE "id" IN (${alreadyBackfilledCommentId}, ${timingTierCommentId}, ${realUserCommentId})
ORDER BY "body"
`;
expect(rows).toEqual([
expect.objectContaining({
id: alreadyBackfilledCommentId,
derived_author_source: "run_id",
}),
{
id: realUserCommentId,
derived_author_agent_id: null,
derived_created_by_run_id: null,
derived_author_source: null,
},
{
id: timingTierCommentId,
derived_author_agent_id: null,
derived_created_by_run_id: null,
derived_author_source: null,
},
]);
} finally {
await verifySql.end();
}
const finalState = await inspectMigrations(connectionString);
expect(finalState.status).toBe("upToDate");
},
20_000,
);
it(
"completes a partially backfilled pre-0131 database",
async () => {
const connectionString = await createTempDatabase();
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
const eligibleCommentId = randomUUID();
const deletedUserCommentId = randomUUID();
const agentAuthoredCommentId = randomUUID();
try {
await dropDerivedAttributionSchema(sql);
await makeDerivedAttributionMigrationPending(sql);
const { companyId, agentId, issueId, runId } = await createSeedGraph(sql, "PARTIAL");
await sql`
INSERT INTO "issue_comments" (
"id",
"company_id",
"issue_id",
"author_user_id",
"created_by_run_id",
"body"
)
VALUES
(${eligibleCommentId}, ${companyId}, ${issueId}, 'local-board', ${runId}, 'eligible local-board'),
(${deletedUserCommentId}, ${companyId}, ${issueId}, 'deleted-user', ${runId}, 'eligible deleted user')
`;
await sql`
INSERT INTO "issue_comments" (
"id",
"company_id",
"issue_id",
"author_agent_id",
"author_user_id",
"created_by_run_id",
"body"
)
VALUES (${agentAuthoredCommentId}, ${companyId}, ${issueId}, ${agentId}, 'local-board', ${runId}, 'already agent-authored')
`;
} finally {
await sql.end();
}
await applyPendingMigrations(connectionString);
const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
await expectDerivedAttributionSchema(verifySql);
const rows = await verifySql<{
id: string;
derived_author_agent_id: string | null;
derived_created_by_run_id: string | null;
derived_author_source: string | null;
}[]>`
SELECT
"id",
"derived_author_agent_id",
"derived_created_by_run_id",
"derived_author_source"
FROM "issue_comments"
WHERE "id" IN (${eligibleCommentId}, ${deletedUserCommentId}, ${agentAuthoredCommentId})
ORDER BY "body"
`;
expect(rows).toEqual([
{
id: agentAuthoredCommentId,
derived_author_agent_id: null,
derived_created_by_run_id: null,
derived_author_source: null,
},
expect.objectContaining({
id: deletedUserCommentId,
derived_author_source: "run_id",
}),
expect.objectContaining({
id: eligibleCommentId,
derived_author_source: "run_id",
}),
]);
} finally {
await verifySql.end();
}
const finalState = await inspectMigrations(connectionString);
expect(finalState.status).toBe("upToDate");
},
20_000,
);
});
@@ -1,69 +0,0 @@
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_author_agent_id" uuid;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_created_by_run_id" uuid;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_author_source" text;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'issue_comments_derived_author_agent_id_agents_id_fk'
) THEN
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_derived_author_agent_id_agents_id_fk" FOREIGN KEY ("derived_author_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk'
) THEN
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("derived_created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
-- Backfill agent attribution for historical non-human ("Board") comments so old
-- threads stop rendering as blue board bubbles and the read path stops
-- re-scanning run logs. Two SQL-computable tiers, both guarded to NEVER touch a
-- comment whose author maps to a genuine user profile. The log-marker tier is
-- handled lazily on read (it needs object-storage log bodies).
--
-- Tier `run_id`: the comment's own authoring run resolves to an agent (lossless).
-- Batched to keep lock/WAL footprint bounded on large histories.
DO $$
DECLARE
affected integer;
BEGIN
LOOP
WITH batch AS (
SELECT c.id AS comment_id, hr.agent_id, hr.id AS run_id
FROM issue_comments c
JOIN heartbeat_runs hr ON hr.id = c.created_by_run_id
WHERE c.author_agent_id IS NULL
AND c.derived_author_agent_id IS NULL
AND c.author_user_id IS NOT NULL
-- Only the non-human board sentinel or non-`user` authors are
-- eligible. `local-board` IS a row in "user" (the implicit board
-- admin), so it must be allowed explicitly; genuine signups are not.
AND (c.author_user_id = 'local-board'
OR NOT EXISTS (SELECT 1 FROM "user" u WHERE u.id = c.author_user_id))
LIMIT 5000
)
UPDATE issue_comments c
SET derived_author_agent_id = b.agent_id,
derived_created_by_run_id = b.run_id,
derived_author_source = 'run_id'
FROM batch b
WHERE c.id = b.comment_id;
GET DIAGNOSTICS affected = ROW_COUNT;
EXIT WHEN affected = 0;
END LOOP;
END $$;--> statement-breakpoint
-- Option A: the pure run-window TIMING tiers are intentionally NOT applied.
-- Because agents post through the `local-board` subprocess, an agent comment and
-- a genuine human board comment are indistinguishable rows, so any timing-overlap
-- guess mis-attributes human board comments that merely coincided with an agent
-- run (e.g. a human board reply typed while an agent run was in flight).
-- Only the lossless `run_id` backfill above (and the read-path `run_log_comment_post`
-- tier) attribute history; everything else stays "Board".
--
-- This statement also reverts any attribution a PRIOR revision of this migration
-- persisted via the timing tiers, so re-applying / upgrading is idempotent.
UPDATE issue_comments
SET derived_author_agent_id = NULL,
derived_created_by_run_id = NULL,
derived_author_source = NULL
WHERE derived_author_source IN ('run_window_unique', 'run_window_agent_unique');
@@ -129,19 +129,74 @@ WHERE h."retry_of_run_id" = original."id"
AND h."responsible_user_id" IS NULL
AND original."responsible_user_id" IS NOT NULL;
--> statement-breakpoint
WITH extracted_run_refs AS (
SELECT
h."id" AS "run_id",
h."company_id",
NULLIF(h."context_snapshot" ->> 'issueId', '') AS "issue_ref",
1 AS "ref_priority"
FROM "heartbeat_runs" AS h
WHERE h."responsible_user_id" IS NULL
AND NULLIF(h."context_snapshot" ->> 'issueId', '') IS NOT NULL
UNION ALL
SELECT
h."id" AS "run_id",
h."company_id",
NULLIF(h."context_snapshot" ->> 'taskId', '') AS "issue_ref",
2 AS "ref_priority"
FROM "heartbeat_runs" AS h
WHERE h."responsible_user_id" IS NULL
AND NULLIF(h."context_snapshot" ->> 'taskId', '') IS NOT NULL
),
uuid_run_refs AS (
SELECT
"run_id",
"company_id",
"issue_ref"::uuid AS "issue_id",
"ref_priority"
FROM extracted_run_refs
WHERE "issue_ref" ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
),
resolved_run_users AS (
SELECT DISTINCT ON ("run_id")
"run_id",
"responsible_user_id"
FROM (
SELECT
refs."run_id",
i."responsible_user_id",
refs."ref_priority",
1 AS "match_priority"
FROM uuid_run_refs AS refs
JOIN "issues" AS i
ON i."id" = refs."issue_id"
AND i."company_id" = refs."company_id"
WHERE i."responsible_user_id" IS NOT NULL
UNION ALL
SELECT
refs."run_id",
i."responsible_user_id",
refs."ref_priority",
2 AS "match_priority"
FROM extracted_run_refs AS refs
JOIN "issues" AS i
ON i."identifier" = refs."issue_ref"
AND i."company_id" = refs."company_id"
WHERE refs."issue_ref" IS NOT NULL
AND i."responsible_user_id" IS NOT NULL
) AS candidates
ORDER BY "run_id", "ref_priority" ASC, "match_priority" ASC
)
UPDATE "heartbeat_runs" AS h
SET "responsible_user_id" = i."responsible_user_id",
SET "responsible_user_id" = resolved_run_users."responsible_user_id",
"updated_at" = now()
FROM "issues" AS i
WHERE h."company_id" = i."company_id"
AND h."responsible_user_id" IS NULL
AND i."responsible_user_id" IS NOT NULL
AND (
h."context_snapshot" ->> 'issueId' = i."id"::text
OR h."context_snapshot" ->> 'taskId' = i."id"::text
OR h."context_snapshot" ->> 'issueId' = i."identifier"
OR h."context_snapshot" ->> 'taskId' = i."identifier"
);
FROM resolved_run_users
WHERE h."id" = resolved_run_users."run_id"
AND h."responsible_user_id" IS NULL;
--> statement-breakpoint
UPDATE "heartbeat_runs" AS h
SET "responsible_user_id" = awr."requested_by_actor_id",
@@ -0,0 +1,67 @@
WITH extracted_run_refs AS (
SELECT
h."id" AS "run_id",
h."company_id",
NULLIF(h."context_snapshot" ->> 'issueId', '') AS "issue_ref",
1 AS "ref_priority"
FROM "heartbeat_runs" AS h
WHERE h."responsible_user_id" IS NULL
AND NULLIF(h."context_snapshot" ->> 'issueId', '') IS NOT NULL
UNION ALL
SELECT
h."id" AS "run_id",
h."company_id",
NULLIF(h."context_snapshot" ->> 'taskId', '') AS "issue_ref",
2 AS "ref_priority"
FROM "heartbeat_runs" AS h
WHERE h."responsible_user_id" IS NULL
AND NULLIF(h."context_snapshot" ->> 'taskId', '') IS NOT NULL
),
uuid_run_refs AS (
SELECT
"run_id",
"company_id",
"issue_ref"::uuid AS "issue_id",
"ref_priority"
FROM extracted_run_refs
WHERE "issue_ref" ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
),
resolved_run_users AS (
SELECT DISTINCT ON ("run_id")
"run_id",
"responsible_user_id"
FROM (
SELECT
refs."run_id",
i."responsible_user_id",
refs."ref_priority",
1 AS "match_priority"
FROM uuid_run_refs AS refs
JOIN "issues" AS i
ON i."id" = refs."issue_id"
AND i."company_id" = refs."company_id"
WHERE i."responsible_user_id" IS NOT NULL
UNION ALL
SELECT
refs."run_id",
i."responsible_user_id",
refs."ref_priority",
2 AS "match_priority"
FROM extracted_run_refs AS refs
JOIN "issues" AS i
ON i."identifier" = refs."issue_ref"
AND i."company_id" = refs."company_id"
WHERE i."responsible_user_id" IS NOT NULL
) AS candidates
ORDER BY "run_id", "ref_priority" ASC, "match_priority" ASC
)
UPDATE "heartbeat_runs" AS h
SET "responsible_user_id" = resolved_run_users."responsible_user_id",
"updated_at" = now()
FROM resolved_run_users
WHERE h."id" = resolved_run_users."run_id"
AND h."responsible_user_id" IS NULL;
@@ -0,0 +1,82 @@
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_author_agent_id" uuid;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_created_by_run_id" uuid;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_author_source" text;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'issue_comments_derived_author_agent_id_agents_id_fk'
) THEN
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_derived_author_agent_id_agents_id_fk" FOREIGN KEY ("derived_author_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk'
) THEN
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("derived_created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
-- Temporary support for the forward-only backfill. The keyset loop below
-- advances over this partial index by comment id, so each eligible slice is
-- visited once instead of re-scanning issue_comments from the beginning on
-- every batch.
CREATE INDEX IF NOT EXISTS "issue_comments_derived_attribution_backfill_idx"
ON "issue_comments" USING btree ("id")
WHERE "author_agent_id" IS NULL
AND "derived_author_agent_id" IS NULL
AND "author_user_id" IS NOT NULL
AND "created_by_run_id" IS NOT NULL;--> statement-breakpoint
ANALYZE "issue_comments";--> statement-breakpoint
DO $$
DECLARE
last_comment_id uuid := '00000000-0000-0000-0000-000000000000'::uuid;
next_last_comment_id uuid;
BEGIN
LOOP
next_last_comment_id := NULL;
WITH batch AS MATERIALIZED (
SELECT c."id" AS comment_id, hr."agent_id", hr."id" AS run_id
FROM "issue_comments" c
JOIN "heartbeat_runs" hr ON hr."id" = c."created_by_run_id"
LEFT JOIN "user" u ON u."id" = c."author_user_id"
WHERE c."id" > last_comment_id
AND c."author_agent_id" IS NULL
AND c."derived_author_agent_id" IS NULL
AND c."author_user_id" IS NOT NULL
AND c."created_by_run_id" IS NOT NULL
AND (
c."author_user_id" = 'local-board'
OR u."id" IS NULL
)
ORDER BY c."id"
LIMIT 5000
),
updated AS (
UPDATE "issue_comments" c
SET "derived_author_agent_id" = b."agent_id",
"derived_created_by_run_id" = b."run_id",
"derived_author_source" = 'run_id'
FROM batch b
WHERE c."id" = b."comment_id"
RETURNING c."id"
)
SELECT b."comment_id"
INTO next_last_comment_id
FROM batch b
LEFT JOIN updated u ON u."id" = b."comment_id"
ORDER BY b."comment_id" DESC
LIMIT 1;
EXIT WHEN next_last_comment_id IS NULL;
last_comment_id := next_last_comment_id;
END LOOP;
END $$;--> statement-breakpoint
DROP INDEX IF EXISTS "issue_comments_derived_attribution_backfill_idx";--> statement-breakpoint
-- Keep the Option-A cleanup at the end as well, matching the original 0126
-- terminal state if any timing-tier rows are introduced before this migration
-- is retried.
UPDATE "issue_comments"
SET "derived_author_agent_id" = NULL,
"derived_created_by_run_id" = NULL,
"derived_author_source" = NULL
WHERE "derived_author_source" IN ('run_window_unique', 'run_window_agent_unique');
@@ -0,0 +1,7 @@
ALTER TABLE "agent_memberships" ADD COLUMN IF NOT EXISTS "starred_at" timestamp with time zone;
--> statement-breakpoint
ALTER TABLE "project_memberships" ADD COLUMN IF NOT EXISTS "starred_at" timestamp with time zone;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "agent_memberships_company_user_starred_idx" ON "agent_memberships" USING btree ("company_id","user_id","starred_at");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "project_memberships_company_user_starred_idx" ON "project_memberships" USING btree ("company_id","user_id","starred_at");
+22 -8
View File
@@ -884,13 +884,6 @@
"tag": "0125_environment_custom_image_templates",
"breakpoints": true
},
{
"idx": 126,
"version": "7",
"when": 1782526400000,
"tag": "0126_issue_comment_derived_attribution",
"breakpoints": true
},
{
"idx": 127,
"version": "7",
@@ -922,8 +915,29 @@
{
"idx": 131,
"version": "7",
"when": 1783025324120,
"tag": "0131_repair_run_responsible_user_context_refs",
"breakpoints": true
},
{
"idx": 132,
"version": "7",
"when": 1783025424120,
"tag": "0132_issue_comment_derived_attribution_fast",
"breakpoints": true
},
{
"idx": 133,
"version": "7",
"when": 1783034521000,
"tag": "0133_resource_membership_stars",
"breakpoints": true
},
{
"idx": 134,
"version": "7",
"when": 1783343000000,
"tag": "0131_issue_work_product_identity_unique",
"tag": "0134_issue_work_product_identity_unique",
"breakpoints": true
}
]
@@ -10,11 +10,17 @@ export const agentMemberships = pgTable(
agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
userId: text("user_id").notNull(),
state: text("state").notNull().default("joined"),
starredAt: timestamp("starred_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyUserIdx: index("agent_memberships_company_user_idx").on(table.companyId, table.userId),
companyUserStarredIdx: index("agent_memberships_company_user_starred_idx").on(
table.companyId,
table.userId,
table.starredAt,
),
agentIdx: index("agent_memberships_agent_idx").on(table.agentId),
companyUserAgentUq: uniqueIndex("agent_memberships_company_user_agent_uq").on(
table.companyId,
@@ -10,11 +10,17 @@ export const projectMemberships = pgTable(
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
userId: text("user_id").notNull(),
state: text("state").notNull().default("joined"),
starredAt: timestamp("starred_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyUserIdx: index("project_memberships_company_user_idx").on(table.companyId, table.userId),
companyUserStarredIdx: index("project_memberships_company_user_starred_idx").on(
table.companyId,
table.userId,
table.starredAt,
),
projectIdx: index("project_memberships_project_idx").on(table.projectId),
companyUserProjectUq: uniqueIndex("project_memberships_company_user_project_uq").on(
table.companyId,
+163
View File
@@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import {
computePipelineHealth,
type PipelineHealthFailedAutomationInput,
type PipelineHealthInput,
} from "./pipeline-health.js";
describe("computePipelineHealth", () => {
const baseInput: PipelineHealthInput = {
pipelineId: "pipeline-1",
stages: [],
agentsById: {},
pipelinesById: {},
};
it("emits one warning per failed automation item and stage", () => {
const failure: PipelineHealthFailedAutomationInput = {
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
caseId: "case-1",
caseTitle: "Case 1",
error: "Routine timed out",
};
const report = computePipelineHealth({
...baseInput,
failedAutomations: [failure],
});
expect(report.warnings).toHaveLength(1);
expect(report.warnings[0]).toMatchObject({
code: "automation_failed",
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
href: "/pipelines/pipeline-1/items/case-1",
hrefLabel: "Open item",
message: `Automation failed on "Case 1". Open the item to inspect the log and retry it.`,
});
});
it("deduplicates duplicate failed automation rows for the same stage and case", () => {
const failure: PipelineHealthFailedAutomationInput = {
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
caseId: "case-1",
caseTitle: "Case 1",
error: "Routine timed out",
};
const duplicateFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
caseId: "case-1",
caseTitle: "Case 1",
error: "Routine timed out",
};
const report = computePipelineHealth({
...baseInput,
failedAutomations: [failure, duplicateFailure],
});
const automationWarnings = report.warnings.filter((warning) => warning.code === "automation_failed");
expect(automationWarnings).toHaveLength(1);
});
it("keeps separate warnings for different case IDs in the same stage", () => {
const firstFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
caseId: "case-1",
caseTitle: "Case 1",
error: "Routine timed out",
};
const secondFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
caseId: "case-2",
caseTitle: "Case 2",
error: "Routine timed out",
};
const report = computePipelineHealth({
...baseInput,
failedAutomations: [firstFailure, secondFailure],
});
const automationWarnings = report.warnings.filter((warning) => warning.code === "automation_failed");
expect(automationWarnings).toHaveLength(2);
expect(automationWarnings.map((warning) => warning.href)).toEqual([
"/pipelines/pipeline-1/items/case-1",
"/pipelines/pipeline-1/items/case-2",
]);
});
it("keeps separate warnings for the same case ID in different stages", () => {
const firstFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
caseId: "case-1",
caseTitle: "Case 1",
error: "Routine timed out",
};
const secondFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage-2",
stageKey: "verify",
stageName: "Verify",
caseId: "case-1",
caseTitle: "Case 1",
error: "Routine timed out",
};
const report = computePipelineHealth({
...baseInput,
failedAutomations: [firstFailure, secondFailure],
});
const automationWarnings = report.warnings.filter((warning) => warning.code === "automation_failed");
expect(automationWarnings).toHaveLength(2);
expect(automationWarnings.map((warning) => warning.stageId)).toEqual(["stage-1", "stage-2"]);
});
it("keeps separate warnings when stage and case IDs would collide with colon-delimited keys", () => {
const firstFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage:one",
stageKey: "build",
stageName: "Build",
caseId: "case",
caseTitle: "Case 1",
error: "Routine timed out",
};
const secondFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage",
stageKey: "verify",
stageName: "Verify",
caseId: "one:case",
caseTitle: "Case 2",
error: "Routine timed out",
};
const report = computePipelineHealth({
...baseInput,
failedAutomations: [firstFailure, secondFailure],
});
const automationWarnings = report.warnings.filter((warning) => warning.code === "automation_failed");
expect(automationWarnings).toHaveLength(2);
expect(automationWarnings.map((warning) => warning.href)).toEqual([
"/pipelines/pipeline-1/items/case",
"/pipelines/pipeline-1/items/one:case",
]);
});
});
+8
View File
@@ -339,7 +339,15 @@ export function computePipelineHealth(input: PipelineHealthInput): PipelineHealt
// the pipeline; a blank default in settings is a normal configuration.
}
const seenFailedAutomationCaseIdsByStage = new Map<string, Set<string>>();
for (const failure of input.failedAutomations ?? []) {
let seenCaseIds = seenFailedAutomationCaseIdsByStage.get(failure.stageId);
if (!seenCaseIds) {
seenCaseIds = new Set<string>();
seenFailedAutomationCaseIdsByStage.set(failure.stageId, seenCaseIds);
}
if (seenCaseIds.has(failure.caseId)) continue;
seenCaseIds.add(failure.caseId);
warnings.push({
code: "automation_failed",
stageId: failure.stageId,
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { updateResourceMembershipSchema } from "./validators/resource-memberships.js";
describe("resource membership contract", () => {
it("accepts legacy state-only membership updates", () => {
expect(updateResourceMembershipSchema.parse({ state: "left" })).toEqual({ state: "left" });
expect(updateResourceMembershipSchema.parse({ state: "joined" })).toEqual({ state: "joined" });
});
it("accepts star-only updates without requiring a state mutation", () => {
expect(updateResourceMembershipSchema.parse({ starred: true })).toEqual({ starred: true });
expect(updateResourceMembershipSchema.parse({ starred: false })).toEqual({ starred: false });
});
it("rejects empty or contradictory star/state updates", () => {
expect(() => updateResourceMembershipSchema.parse({})).toThrow("state or starred is required");
expect(() => updateResourceMembershipSchema.parse({ state: "left", starred: true })).toThrow(
"starred resources must be joined",
);
});
});
@@ -6,16 +6,22 @@ export type ResourceMembershipResourceType = "project" | "agent";
export interface ResourceMemberships {
projectMemberships: Record<string, ResourceMembershipState>;
agentMemberships: Record<string, ResourceMembershipState>;
starredProjectIds?: string[];
starredAgentIds?: string[];
projectStarredAt?: Record<string, Date>;
agentStarredAt?: Record<string, Date>;
updatedAt: Date | null;
}
export interface UpdateResourceMembership {
state: ResourceMembershipState;
state?: ResourceMembershipState;
starred?: boolean;
}
export interface ResourceMembershipUpdateResult {
resourceType: ResourceMembershipResourceType;
resourceId: string;
state: ResourceMembershipState;
starredAt: Date | null;
updatedAt: Date;
}
@@ -4,7 +4,13 @@ import { RESOURCE_MEMBERSHIP_STATES } from "../types/resource-memberships.js";
export const resourceMembershipStateSchema = z.enum(RESOURCE_MEMBERSHIP_STATES);
export const updateResourceMembershipSchema = z.object({
state: resourceMembershipStateSchema,
state: resourceMembershipStateSchema.optional(),
starred: z.boolean().optional(),
}).refine((value) => value.state !== undefined || value.starred !== undefined, {
message: "state or starred is required",
}).refine((value) => !(value.state === "left" && value.starred === true), {
message: "starred resources must be joined",
path: ["starred"],
});
export type UpdateResourceMembership = z.infer<typeof updateResourceMembershipSchema>;
+59 -1
View File
@@ -435,8 +435,60 @@ list_base_node_modules_paths() {
! -path './.paperclip/*' \
| sed 's#^\./##'
}
compute_pnpm_install_fingerprint() {
WORKTREE_CWD="$worktree_cwd" node <<'EOF'
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const root = process.env.WORKTREE_CWD;
const ignoredDirs = new Set([".git", ".paperclip", "node_modules", "dist", "storybook-static"]);
const files = [];
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (ignoredDirs.has(entry.name)) continue;
const absolutePath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(absolutePath);
continue;
}
if (
entry.isFile()
&& (entry.name === "package.json" || entry.name === "pnpm-lock.yaml" || entry.name === "pnpm-workspace.yaml")
) {
files.push(absolutePath);
}
}
}
walk(root);
files.sort((left, right) => path.relative(root, left).localeCompare(path.relative(root, right)));
const hash = crypto.createHash("sha256");
for (const file of files) {
const relativePath = path.relative(root, file).replaceAll(path.sep, "/");
hash.update(relativePath);
hash.update("\0");
hash.update(fs.readFileSync(file));
hash.update("\0");
}
process.stdout.write(hash.digest("hex"));
EOF
}
if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; then
needs_install=0
install_fingerprint_path="$paperclip_dir/pnpm-install-fingerprint"
current_install_fingerprint="$(compute_pnpm_install_fingerprint)"
previous_install_fingerprint=""
if [[ -f "$install_fingerprint_path" ]]; then
previous_install_fingerprint="$(cat "$install_fingerprint_path")"
fi
while IFS= read -r relative_path; do
[[ -n "$relative_path" ]] || continue
@@ -448,6 +500,10 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t
fi
done < <(list_base_node_modules_paths)
if [[ "$needs_install" -eq 0 && "$current_install_fingerprint" != "$previous_install_fingerprint" ]]; then
needs_install=1
fi
if [[ "$needs_install" -eq 1 ]]; then
backup_suffix=".paperclip-backup-${BASHPID:-$$}"
moved_symlink_paths=()
@@ -492,7 +548,7 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t
if (
cd "$worktree_cwd"
pnpm install "$@"
pnpm install --prod=false "$@"
) >"$stdout_path" 2>"$stderr_path"; then
cat "$stdout_path"
cat "$stderr_path" >&2
@@ -529,6 +585,8 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t
fi
cleanup_moved_symlinks
current_install_fingerprint="$(compute_pnpm_install_fingerprint)"
printf '%s\n' "$current_install_fingerprint" >"$install_fingerprint_path"
fi
exit 0
@@ -78,8 +78,10 @@ describeEmbeddedPostgres("resource membership routes", () => {
const otherCompanyId = randomUUID();
const projectId = randomUUID();
const otherProjectId = randomUUID();
const archivedProjectId = randomUUID();
const agentId = randomUUID();
const otherAgentId = randomUUID();
const terminatedAgentId = randomUUID();
await db.insert(companies).values([
{
id: companyId,
@@ -96,6 +98,7 @@ describeEmbeddedPostgres("resource membership routes", () => {
]);
await db.insert(projects).values([
{ id: projectId, companyId, name: "Growth", status: "in_progress" },
{ id: archivedProjectId, companyId, name: "Archived", status: "completed", archivedAt: new Date() },
{ id: otherProjectId, companyId: otherCompanyId, name: "Other", status: "in_progress" },
]);
await db.insert(agents).values([
@@ -121,8 +124,19 @@ describeEmbeddedPostgres("resource membership routes", () => {
runtimeConfig: {},
permissions: {},
},
{
id: terminatedAgentId,
companyId,
name: "Terminated",
role: "engineer",
status: "terminated",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
return { companyId, otherAgentId, otherProjectId, projectId, agentId };
return { archivedProjectId, companyId, otherAgentId, otherProjectId, projectId, agentId, terminatedAgentId };
}
it("defaults missing membership rows to joined", async () => {
@@ -135,6 +149,10 @@ describeEmbeddedPostgres("resource membership routes", () => {
expect(res.body).toEqual({
projectMemberships: {},
agentMemberships: {},
starredProjectIds: [],
starredAgentIds: [],
projectStarredAt: {},
agentStarredAt: {},
updatedAt: null,
});
});
@@ -151,7 +169,7 @@ describeEmbeddedPostgres("resource membership routes", () => {
.send({ state: "left" });
expect(first.status).toBe(200);
expect(first.body).toMatchObject({ resourceType: "project", resourceId: projectId, state: "left" });
expect(first.body).toMatchObject({ resourceType: "project", resourceId: projectId, state: "left", starredAt: null });
expect(second.status).toBe(200);
const rows = await db.select().from(projectMemberships);
@@ -170,6 +188,174 @@ describeEmbeddedPostgres("resource membership routes", () => {
});
});
it("stars projects idempotently and exposes starred project contract data", async () => {
const { companyId, projectId } = await seed();
const app = createApp(db, boardActor(companyId, "viewer"));
const first = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/projects/${projectId}`)
.send({ starred: true });
const second = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/projects/${projectId}`)
.send({ starred: true });
const list = await request(app).get(`/api/companies/${companyId}/resource-memberships/me`);
expect(first.status).toBe(200);
expect(first.body).toMatchObject({ resourceType: "project", resourceId: projectId, state: "joined" });
expect(first.body.starredAt).toEqual(expect.any(String));
expect(second.status).toBe(200);
expect(second.body.starredAt).toBe(first.body.starredAt);
expect(list.body.starredProjectIds).toEqual([projectId]);
expect(list.body.projectStarredAt[projectId]).toEqual(first.body.starredAt);
const rows = await db.select().from(projectMemberships);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ companyId, projectId, userId: "user-1", state: "joined" });
expect(rows[0]?.starredAt).toBeInstanceOf(Date);
const activity = await db.select().from(activityLog);
expect(activity).toHaveLength(1);
expect(activity[0]).toMatchObject({
action: "resource_membership.starred",
entityType: "project",
entityId: projectId,
});
expect(activity[0]?.details).toMatchObject({
userId: "user-1",
resourceType: "project",
resourceId: projectId,
state: "joined",
starred: true,
});
});
it("clears starred_at when leaving a starred resource", async () => {
const { companyId, projectId } = await seed();
const app = createApp(db, boardActor(companyId));
await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/projects/${projectId}`)
.send({ starred: true })
.expect(200);
const leave = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/projects/${projectId}`)
.send({ state: "left" });
expect(leave.status).toBe(200);
expect(leave.body).toMatchObject({ state: "left", starredAt: null });
const [row] = await db.select().from(projectMemberships);
expect(row).toMatchObject({ state: "left", starredAt: null });
const activity = await db.select().from(activityLog);
expect(activity.map((entry) => entry.action)).toEqual([
"resource_membership.starred",
"resource_membership.left",
]);
expect(activity[1]?.details).toMatchObject({ state: "left", starred: false, starredAt: null });
});
it("starring a left resource rejoins it", async () => {
const { companyId, agentId } = await seed();
const app = createApp(db, boardActor(companyId));
await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/agents/${agentId}`)
.send({ state: "left" })
.expect(200);
const star = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/agents/${agentId}`)
.send({ starred: true });
expect(star.status).toBe(200);
expect(star.body).toMatchObject({ resourceType: "agent", resourceId: agentId, state: "joined" });
expect(star.body.starredAt).toEqual(expect.any(String));
const [row] = await db.select().from(agentMemberships);
expect(row).toMatchObject({ state: "joined" });
expect(row?.starredAt).toBeInstanceOf(Date);
const activity = await db.select().from(activityLog);
expect(activity.map((entry) => entry.action)).toEqual([
"resource_membership.left",
"resource_membership.starred",
]);
});
it("unstars agents idempotently without requiring a state change", async () => {
const { companyId, agentId } = await seed();
const app = createApp(db, boardActor(companyId));
await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/agents/${agentId}`)
.send({ starred: true })
.expect(200);
const first = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/agents/${agentId}`)
.send({ starred: false });
const second = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/agents/${agentId}`)
.send({ starred: false });
expect(first.status).toBe(200);
expect(first.body).toMatchObject({ state: "joined", starredAt: null });
expect(second.status).toBe(200);
expect(second.body).toMatchObject({ state: "joined", starredAt: null });
const [row] = await db.select().from(agentMemberships);
expect(row).toMatchObject({ state: "joined", starredAt: null });
const activity = await db.select().from(activityLog);
expect(activity.map((entry) => entry.action)).toEqual([
"resource_membership.starred",
"resource_membership.unstarred",
]);
});
it("omits archived projects and terminated agents from starred sidebar data", async () => {
const { archivedProjectId, companyId, terminatedAgentId } = await seed();
const starredAt = new Date();
await db.insert(projectMemberships).values({
companyId,
projectId: archivedProjectId,
userId: "user-1",
state: "joined",
starredAt,
});
await db.insert(agentMemberships).values({
companyId,
agentId: terminatedAgentId,
userId: "user-1",
state: "joined",
starredAt,
});
const app = createApp(db, boardActor(companyId));
const res = await request(app).get(`/api/companies/${companyId}/resource-memberships/me`);
expect(res.status).toBe(200);
expect(res.body.projectMemberships[archivedProjectId]).toBe("joined");
expect(res.body.agentMemberships[terminatedAgentId]).toBe("joined");
expect(res.body.starredProjectIds).toEqual([]);
expect(res.body.starredAgentIds).toEqual([]);
expect(res.body.projectStarredAt).toEqual({});
expect(res.body.agentStarredAt).toEqual({});
});
it("rejects starring archived projects and terminated agents", async () => {
const { archivedProjectId, companyId, terminatedAgentId } = await seed();
const app = createApp(db, boardActor(companyId));
const projectRes = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/projects/${archivedProjectId}`)
.send({ starred: true });
const agentRes = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/agents/${terminatedAgentId}`)
.send({ starred: true });
expect(projectRes.status).toBe(404);
expect(agentRes.status).toBe(404);
});
it("rejects agent API key actors", async () => {
const { companyId, agentId } = await seed();
const app = createApp(db, {
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
import { parseGitDescribeVersion, resolveServerVersion } from "../version.js";
describe("parseGitDescribeVersion", () => {
it("reports drift from the nearest release tag as a PEP 440 local version", () => {
expect(parseGitDescribeVersion("v2026.626.0-58-g518fc71ce\n")).toBe(
"2026.626.0+58.git.518fc71ce",
);
});
it("collapses clean on-tag checkouts to the release version", () => {
expect(parseGitDescribeVersion("v2026.626.0-0-g012345678\n")).toBe("2026.626.0");
});
it("adds dirty state to the local version segment", () => {
expect(parseGitDescribeVersion("v2026.626.0-58-g518fc71ce-dirty\n")).toBe(
"2026.626.0+58.git.518fc71ce.dirty",
);
});
it("returns null for unparseable describe output so callers can fall back", () => {
expect(parseGitDescribeVersion("canary/v2026.706.0-canary.1")).toBeNull();
});
it("appends dirty suffix even when on-tag (zero commits since tag)", () => {
expect(parseGitDescribeVersion("v2026.626.0-0-g012345678-dirty\n")).toBe(
"2026.626.0+0.git.012345678.dirty",
);
});
});
describe("resolveServerVersion", () => {
it("returns the parsed git-derived version when git describe succeeds", () => {
expect(
resolveServerVersion({
packageVersion: "2026.706.0",
gitDescribeCommand: () => "v2026.626.0-58-g518fc71ce\n",
debugLog: vi.fn(),
}),
).toBe("2026.626.0+58.git.518fc71ce");
});
it("falls back to package version without throwing when git is unavailable", () => {
const debugLog = vi.fn();
expect(
resolveServerVersion({
packageVersion: "2026.706.0",
gitDescribeCommand: () => {
throw new Error("fatal: not a git repository");
},
debugLog,
}),
).toBe("2026.706.0");
expect(debugLog).toHaveBeenCalledWith(
expect.objectContaining({ reason: "git_describe_unavailable" }),
"falling back to package version for server version",
);
});
});
+95 -2
View File
@@ -1500,6 +1500,99 @@ describe("realizeExecutionWorkspace", () => {
);
}, 30_000);
it("reinstalls worktree-local pnpm dependencies when package metadata changes", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-stale-deps-"));
const baseRoot = path.join(tempRoot, "base");
const worktreeRoot = path.join(tempRoot, "worktree");
const fakeBin = path.join(tempRoot, "bin");
const fakePnpmPath = path.join(fakeBin, "pnpm");
const scriptPath = path.join(worktreeRoot, "provision-worktree.sh");
const installLogPath = path.join(tempRoot, "install.log");
try {
await fs.mkdir(path.join(baseRoot, "node_modules"), { recursive: true });
await fs.mkdir(path.join(worktreeRoot, "node_modules"), { recursive: true });
await fs.mkdir(path.join(worktreeRoot, "ui"), { recursive: true });
await fs.mkdir(fakeBin, { recursive: true });
await fs.copyFile(provisionWorktreeScriptPath, scriptPath);
await fs.chmod(scriptPath, 0o755);
await fs.writeFile(
path.join(worktreeRoot, "package.json"),
JSON.stringify(
{
name: "workspace-root",
private: true,
packageManager: "pnpm@9.15.4",
},
null,
2,
),
"utf8",
);
await fs.writeFile(
path.join(worktreeRoot, "pnpm-lock.yaml"),
["lockfileVersion: '9.0'", "", "importers:", " .: {}", ""].join("\n"),
"utf8",
);
await fs.writeFile(
path.join(worktreeRoot, "ui", "package.json"),
JSON.stringify({ name: "ui", private: true, dependencies: {} }, null, 2),
"utf8",
);
await fs.writeFile(
fakePnpmPath,
[
"#!/bin/sh",
"if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then",
" exit 1",
"fi",
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--prod=false\" ] && [ \"$3\" = \"--frozen-lockfile\" ]; then",
" mkdir -p \"$PWD/node_modules\"",
` echo "install:$*" >> ${JSON.stringify(installLogPath)}`,
" exit 0",
"fi",
"exit 1",
"",
].join("\n"),
"utf8",
);
await fs.chmod(fakePnpmPath, 0o755);
const runScript = () => execFileAsync(scriptPath, [], {
cwd: worktreeRoot,
env: {
...process.env,
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
PAPERCLIP_WORKSPACE_BASE_CWD: baseRoot,
PAPERCLIP_WORKSPACE_CWD: worktreeRoot,
},
});
await runScript();
await runScript();
await expect(fs.readFile(installLogPath, "utf8")).resolves.toBe(
"install:install --prod=false --frozen-lockfile\n",
);
await fs.writeFile(
path.join(worktreeRoot, "ui", "package.json"),
JSON.stringify(
{ name: "ui", private: true, dependencies: { "@xterm/addon-fit": "^0.11.0" } },
null,
2,
),
"utf8",
);
await runScript();
await expect(fs.readFile(installLogPath, "utf8")).resolves.toBe(
"install:install --prod=false --frozen-lockfile\ninstall:install --prod=false --frozen-lockfile\n",
);
} finally {
await fs.rm(tempRoot, { recursive: true, force: true });
}
}, 30_000);
it("fails instead of writing an unseeded fallback config when worktree init errors after CLI detection succeeds", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-provision-fail-"));
const baseRoot = path.join(tempRoot, "base");
@@ -1686,11 +1779,11 @@ describe("realizeExecutionWorkspace", () => {
"if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then",
" exit 1",
"fi",
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--frozen-lockfile\" ]; then",
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--prod=false\" ] && [ \"$3\" = \"--frozen-lockfile\" ]; then",
" echo \"ERR_PNPM_OUTDATED_LOCKFILE\" >&2",
" exit 1",
"fi",
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--no-frozen-lockfile\" ]; then",
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--prod=false\" ] && [ \"$3\" = \"--no-frozen-lockfile\" ]; then",
" mkdir -p \"$PWD/node_modules\"",
" : > \"$PWD/node_modules/.retry-success\"",
" exit 0",
+15 -5
View File
@@ -22,6 +22,8 @@ async function logMembershipChange(
resourceType: "project" | "agent";
resourceId: string;
state: "joined" | "left";
starredAt: Date | null;
changeKind: "joined" | "left" | "starred" | "unstarred";
policySource: string;
},
) {
@@ -32,7 +34,7 @@ async function logMembershipChange(
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: `resource_membership.${input.state}`,
action: `resource_membership.${input.changeKind}`,
entityType: input.resourceType,
entityId: input.resourceId,
details: {
@@ -40,6 +42,8 @@ async function logMembershipChange(
resourceType: input.resourceType,
resourceId: input.resourceId,
state: input.state,
starredAt: input.starredAt,
starred: input.starredAt !== null,
policySource: input.policySource,
},
});
@@ -69,19 +73,22 @@ export function resourceMembershipRoutes(db: Db) {
projectId,
userId,
state: req.body.state,
starred: req.body.starred,
actor: req.actor,
});
if (result.changed) {
if (result.changed && result.changeKind) {
await logMembershipChange(db, req, {
companyId,
userId,
resourceType: "project",
resourceId: projectId,
state: result.state,
starredAt: result.starredAt,
changeKind: result.changeKind,
policySource: result.policySource,
});
}
const { changed: _changed, policySource: _policySource, ...response } = result;
const { changed: _changed, changeKind: _changeKind, policySource: _policySource, ...response } = result;
res.json(response);
},
);
@@ -99,19 +106,22 @@ export function resourceMembershipRoutes(db: Db) {
agentId,
userId,
state: req.body.state,
starred: req.body.starred,
actor: req.actor,
});
if (result.changed) {
if (result.changed && result.changeKind) {
await logMembershipChange(db, req, {
companyId,
userId,
resourceType: "agent",
resourceId: agentId,
state: result.state,
starredAt: result.starredAt,
changeKind: result.changeKind,
policySource: result.policySource,
});
}
const { changed: _changed, policySource: _policySource, ...response } = result;
const { changed: _changed, changeKind: _changeKind, policySource: _policySource, ...response } = result;
res.json(response);
},
);
+126 -30
View File
@@ -41,12 +41,21 @@ export type ResourceMembershipPolicyHook = (input: {
resourceType: ResourceMembershipResourceType;
resourceId: string;
state: ResourceMembershipState;
starred?: boolean;
}) => Promise<PolicyDecision> | PolicyDecision;
type ResourceMembershipServiceOptions = {
policyHook?: ResourceMembershipPolicyHook | null;
};
type MembershipChangeKind = ResourceMembershipState | "starred" | "unstarred";
type MembershipUpdateResult = ResourceMembershipUpdateResult & {
changed: boolean;
changeKind: MembershipChangeKind | null;
policySource: string;
};
function defaultJoinedMap<T extends { projectId?: string; agentId?: string; state: string }>(
rows: T[],
key: "projectId" | "agentId",
@@ -60,6 +69,30 @@ function defaultJoinedMap<T extends { projectId?: string; agentId?: string; stat
return result;
}
function starredAtMap<T extends { projectId?: string; agentId?: string; starredAt: Date | null }>(
rows: T[],
key: "projectId" | "agentId",
): Record<string, Date> {
const result: Record<string, Date> = {};
for (const row of rows) {
const id = row[key];
if (typeof id !== "string" || !row.starredAt) continue;
result[id] = row.starredAt;
}
return result;
}
function starredIds<T extends { projectId?: string; agentId?: string; starredAt: Date | null }>(
rows: T[],
key: "projectId" | "agentId",
): string[] {
return rows
.filter((row) => row.starredAt)
.sort((a, b) => b.starredAt!.getTime() - a.starredAt!.getTime())
.map((row) => row[key])
.filter((id): id is string => typeof id === "string");
}
function latestDate(...dates: Array<Date | null | undefined>): Date | null {
let latest: Date | null = null;
for (const date of dates) {
@@ -116,6 +149,7 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
resourceType: ResourceMembershipResourceType;
resourceId: string;
state: ResourceMembershipState;
starred?: boolean;
}): Promise<PolicyDecision> {
assertBoardSelfMembershipAccess(input.actor, input.companyId, input.userId);
const decision = await evaluatePolicy(policyHook, input);
@@ -144,9 +178,15 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
.select({
projectId: projectMemberships.projectId,
state: projectMemberships.state,
starredAt: projectMemberships.starredAt,
updatedAt: projectMemberships.updatedAt,
projectArchivedAt: projects.archivedAt,
})
.from(projectMemberships)
.innerJoin(projects, and(
eq(projects.id, projectMemberships.projectId),
eq(projects.companyId, projectMemberships.companyId),
))
.where(and(
eq(projectMemberships.companyId, companyId),
eq(projectMemberships.userId, userId),
@@ -155,17 +195,29 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
.select({
agentId: agentMemberships.agentId,
state: agentMemberships.state,
starredAt: agentMemberships.starredAt,
updatedAt: agentMemberships.updatedAt,
agentStatus: agents.status,
})
.from(agentMemberships)
.innerJoin(agents, and(
eq(agents.id, agentMemberships.agentId),
eq(agents.companyId, agentMemberships.companyId),
))
.where(and(
eq(agentMemberships.companyId, companyId),
eq(agentMemberships.userId, userId),
)),
]);
const starEligibleProjectRows = projectRows.filter((row) => row.starredAt && !row.projectArchivedAt);
const starEligibleAgentRows = agentRows.filter((row) => row.starredAt && row.agentStatus !== "terminated");
return {
projectMemberships: defaultJoinedMap(projectRows, "projectId"),
agentMemberships: defaultJoinedMap(agentRows, "agentId"),
starredProjectIds: starredIds(starEligibleProjectRows, "projectId"),
starredAgentIds: starredIds(starEligibleAgentRows, "agentId"),
projectStarredAt: starredAtMap(starEligibleProjectRows, "projectId"),
agentStarredAt: starredAtMap(starEligibleAgentRows, "agentId"),
updatedAt: latestDate(
...projectRows.map((row) => row.updatedAt),
...agentRows.map((row) => row.updatedAt),
@@ -177,24 +229,17 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
companyId: string;
userId: string;
projectId: string;
state: ResourceMembershipState;
state?: ResourceMembershipState;
starred?: boolean;
actor: BoardActor;
}): Promise<ResourceMembershipUpdateResult & { changed: boolean; policySource: string }> {
}): Promise<MembershipUpdateResult> {
const project = await db.query.projects.findFirst({
where: and(
eq(projects.id, input.projectId),
eq(projects.companyId, input.companyId),
),
});
if (!project) throw notFound("Project not found");
const decision = await assertMutationAllowed({
actor: input.actor,
companyId: input.companyId,
userId: input.userId,
resourceType: "project",
resourceId: input.projectId,
state: input.state,
});
if (!project || project.archivedAt) throw notFound("Project not found");
const existing = await db.query.projectMemberships.findFirst({
where: and(
@@ -204,13 +249,36 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
),
});
const previousState: ResourceMembershipState = existing?.state === "left" ? "left" : "joined";
if (previousState === input.state) {
const previousStarredAt = existing?.starredAt ?? null;
const nextState: ResourceMembershipState = input.starred === true ? "joined" : input.state ?? previousState;
const nextStarredAt = nextState === "left"
? null
: input.starred === true
? previousStarredAt ?? new Date()
: input.starred === false
? null
: previousStarredAt;
const stateChanged = previousState !== nextState;
const starredChanged = (previousStarredAt?.getTime() ?? null) !== (nextStarredAt?.getTime() ?? null);
const decision = await assertMutationAllowed({
actor: input.actor,
companyId: input.companyId,
userId: input.userId,
resourceType: "project",
resourceId: input.projectId,
state: nextState,
starred: input.starred,
});
if (!stateChanged && !starredChanged) {
return {
resourceType: "project",
resourceId: input.projectId,
state: input.state,
state: nextState,
starredAt: previousStarredAt,
updatedAt: existing?.updatedAt ?? new Date(),
changed: false,
changeKind: null,
policySource: decision.source ?? "oss_default",
};
}
@@ -222,13 +290,15 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
companyId: input.companyId,
projectId: input.projectId,
userId: input.userId,
state: input.state,
state: nextState,
starredAt: nextStarredAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [projectMemberships.companyId, projectMemberships.userId, projectMemberships.projectId],
set: {
state: input.state,
state: nextState,
starredAt: nextStarredAt,
updatedAt: now,
},
})
@@ -238,8 +308,12 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
resourceType: "project",
resourceId: input.projectId,
state: row?.state === "left" ? "left" : "joined",
starredAt: row?.starredAt ?? null,
updatedAt: row?.updatedAt ?? now,
changed: true,
changeKind: input.starred !== undefined && starredChanged
? input.starred ? "starred" : "unstarred"
: stateChanged ? nextState : nextStarredAt ? "starred" : "unstarred",
policySource: decision.source ?? "oss_default",
};
},
@@ -248,24 +322,17 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
companyId: string;
userId: string;
agentId: string;
state: ResourceMembershipState;
state?: ResourceMembershipState;
starred?: boolean;
actor: BoardActor;
}): Promise<ResourceMembershipUpdateResult & { changed: boolean; policySource: string }> {
}): Promise<MembershipUpdateResult> {
const agent = await db.query.agents.findFirst({
where: and(
eq(agents.id, input.agentId),
eq(agents.companyId, input.companyId),
),
});
if (!agent) throw notFound("Agent not found");
const decision = await assertMutationAllowed({
actor: input.actor,
companyId: input.companyId,
userId: input.userId,
resourceType: "agent",
resourceId: input.agentId,
state: input.state,
});
if (!agent || agent.status === "terminated") throw notFound("Agent not found");
const existing = await db.query.agentMemberships.findFirst({
where: and(
@@ -275,13 +342,36 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
),
});
const previousState: ResourceMembershipState = existing?.state === "left" ? "left" : "joined";
if (previousState === input.state) {
const previousStarredAt = existing?.starredAt ?? null;
const nextState: ResourceMembershipState = input.starred === true ? "joined" : input.state ?? previousState;
const nextStarredAt = nextState === "left"
? null
: input.starred === true
? previousStarredAt ?? new Date()
: input.starred === false
? null
: previousStarredAt;
const stateChanged = previousState !== nextState;
const starredChanged = (previousStarredAt?.getTime() ?? null) !== (nextStarredAt?.getTime() ?? null);
const decision = await assertMutationAllowed({
actor: input.actor,
companyId: input.companyId,
userId: input.userId,
resourceType: "agent",
resourceId: input.agentId,
state: nextState,
starred: input.starred,
});
if (!stateChanged && !starredChanged) {
return {
resourceType: "agent",
resourceId: input.agentId,
state: input.state,
state: nextState,
starredAt: previousStarredAt,
updatedAt: existing?.updatedAt ?? new Date(),
changed: false,
changeKind: null,
policySource: decision.source ?? "oss_default",
};
}
@@ -293,13 +383,15 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
companyId: input.companyId,
agentId: input.agentId,
userId: input.userId,
state: input.state,
state: nextState,
starredAt: nextStarredAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [agentMemberships.companyId, agentMemberships.userId, agentMemberships.agentId],
set: {
state: input.state,
state: nextState,
starredAt: nextStarredAt,
updatedAt: now,
},
})
@@ -309,8 +401,12 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
resourceType: "agent",
resourceId: input.agentId,
state: row?.state === "left" ? "left" : "joined",
starredAt: row?.starredAt ?? null,
updatedAt: row?.updatedAt ?? now,
changed: true,
changeKind: input.starred !== undefined && starredChanged
? input.starred ? "starred" : "unstarred"
: stateChanged ? nextState : nextStarredAt ? "starred" : "unstarred",
policySource: decision.source ?? "oss_default",
};
},
+71 -3
View File
@@ -1,10 +1,78 @@
import { createRequire } from "node:module";
import { execFileSync } from "node:child_process";
type PackageJson = {
version?: string;
};
const require = createRequire(import.meta.url);
const pkg = require("../package.json") as PackageJson;
type GitDescribeCommand = () => string;
type DebugLog = (fields: Record<string, unknown>, message: string) => void;
export const serverVersion = pkg.version ?? "0.0.0";
const requirePackage = createRequire(import.meta.url);
const pkg = requirePackage("../package.json") as PackageJson;
const GIT_DESCRIBE_RE =
/^v(?<publicVersion>\d+\.\d+\.\d+)-(?<commitsSinceTag>\d+)-g(?<sha>[0-9a-f]{7,40})(?<dirty>-dirty)?$/i;
function defaultDebugLog(fields: Record<string, unknown>, message: string): void {
console.debug(message, fields);
}
function defaultGitDescribeCommand(): string {
return execFileSync(
"git",
["describe", "--tags", "--match", "v*", "--long", "--dirty"],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 1500,
},
);
}
export function parseGitDescribeVersion(output: string): string | null {
const match = output.trim().match(GIT_DESCRIBE_RE);
if (!match?.groups) return null;
const publicVersion = match.groups.publicVersion;
const commitsSinceTag = match.groups.commitsSinceTag;
const sha = match.groups.sha;
const isDirty = Boolean(match.groups.dirty);
if (commitsSinceTag === "0" && !isDirty) {
return publicVersion;
}
return `${publicVersion}+${commitsSinceTag}.git.${sha}${isDirty ? ".dirty" : ""}`;
}
export function resolveServerVersion(
opts: {
gitDescribeCommand?: GitDescribeCommand;
packageVersion?: string;
debugLog?: DebugLog;
} = {},
): string {
const packageVersion = opts.packageVersion ?? pkg.version ?? "0.0.0";
const gitDescribeCommand = opts.gitDescribeCommand ?? defaultGitDescribeCommand;
const debugLog = opts.debugLog ?? defaultDebugLog;
try {
const parsedVersion = parseGitDescribeVersion(gitDescribeCommand());
if (parsedVersion) return parsedVersion;
debugLog(
{ reason: "invalid_git_describe" },
"falling back to package version for server version",
);
} catch (err) {
debugLog(
{ err, reason: "git_describe_unavailable" },
"falling back to package version for server version",
);
}
return packageVersion;
}
export const serverVersion = resolveServerVersion();
Binary file not shown.
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
# Bundled UI Fonts
Paperclip bundles Inter for the board UI so screenshots and packaged installs use
the same sans-serif text stack without relying on host font packages.
## Inter
- Upstream project: https://github.com/rsms/inter
- Version: v4.1
- Source files:
- https://raw.githubusercontent.com/rsms/inter/v4.1/docs/font-files/InterVariable.woff2
- https://raw.githubusercontent.com/rsms/inter/v4.1/docs/font-files/InterVariable-Italic.woff2
- License: SIL Open Font License 1.1
- License text: https://github.com/rsms/inter/blob/v4.1/LICENSE.txt
Redistribution note: Inter is redistributed under the SIL Open Font License 1.1.
The bundled WOFF2 files are included unmodified from the upstream v4.1 release.
+12 -11
View File
@@ -100,6 +100,10 @@ vi.mock("./SidebarProjects", () => ({
SidebarProjects: () => <div data-testid="sidebar-projects">Projects collapsible</div>,
}));
vi.mock("./SidebarStarredProjects", () => ({
SidebarStarredProjects: () => <div data-testid="sidebar-starred-projects" />,
}));
async function flushReact() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
@@ -223,27 +227,24 @@ describe("Sidebar", () => {
});
});
it("classic (flag OFF): New Task button, Tasks label, per-project collapsible, no top-level Projects link", async () => {
it("streamlined is now standard: a stale enableStreamlinedLeftNavigation=false opt-out is ignored", async () => {
// PAP-12472 retired the experimental opt-out; the streamlined sidebar is the
// only path, so an old `false` setting no longer restores classic mode.
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableStreamlinedLeftNavigation: false,
});
const root = await renderSidebar();
expect(container.textContent).toContain("New Task");
expect(container.textContent).not.toContain("New Issue");
const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim());
expect(navLabels).toContain("Tasks");
expect(navLabels).not.toContain("Issues");
// No top-level Projects nav link in classic mode (D5 option A).
expect(navLabels).not.toContain("Projects");
// Per-project collapsible restored below Work.
expect(container.querySelector('[data-testid="sidebar-projects"]')).not.toBeNull();
// Top-level Projects link + starred children stay, per-project collapsible gone.
expect(navLabels).toContain("Projects");
expect(container.querySelector('[data-testid="sidebar-projects"]')).toBeNull();
expect(container.querySelector('[data-testid="sidebar-starred-projects"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
).toBe("false");
).toBe("true");
flushSync(() => {
root.unmount();
+11 -7
View File
@@ -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() {
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
) : null}
{streamlined ? (
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
<>
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
<SidebarStarredProjects />
</>
) : null}
<PluginSlotOutlet
slotTypes={["sidebar"]}
+94 -2
View File
@@ -233,18 +233,28 @@ describe("SidebarAgents", () => {
};
mockResourceMembershipsApi.listMine.mockImplementation(() => Promise.resolve(memberships));
mockResourceMembershipsApi.updateAgent.mockImplementation((_companyId, agentId, data) => {
const previousState = memberships.agentMemberships[agentId] ?? "joined";
const nextState = data.starred === true ? "joined" : data.state ?? previousState;
const starredAgentIds = memberships.starredAgentIds ?? [];
const nextStarredAgentIds = data.starred === true
? starredAgentIds.includes(agentId) ? starredAgentIds : [agentId, ...starredAgentIds]
: data.starred === false || nextState === "left"
? starredAgentIds.filter((id) => id !== agentId)
: starredAgentIds;
memberships = {
...memberships,
agentMemberships: {
...memberships.agentMemberships,
[agentId]: data.state,
[agentId]: nextState,
},
starredAgentIds: nextStarredAgentIds,
updatedAt: new Date(),
};
return Promise.resolve({
resourceType: "agent",
resourceId: agentId,
state: data.state,
state: nextState,
starredAt: data.starred === true ? new Date() : null,
});
});
localStorage.clear();
@@ -329,6 +339,88 @@ describe("SidebarAgents", () => {
expect(container.querySelector('button[aria-label="Agents section actions"]')).toBeNull();
});
it("pins starred agents at the top without subheadings and dedupes them from the recent list", async () => {
mockAgentsApi.list.mockResolvedValue([
makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }),
makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }),
]);
memberships = {
projectMemberships: {},
agentMemberships: {},
starredProjectIds: [],
starredAgentIds: ["agent-b"],
projectStarredAt: {},
agentStarredAt: {},
updatedAt: new Date(),
};
await renderSidebarAgents();
expect(container.textContent).not.toContain("Starred");
expect(container.textContent).not.toContain("Recently active");
// Bravo is starred -> shown once at the top, deduped from recent.
const labels = agentLinkLabels(container);
expect(labels.filter((label) => label === "Bravo")).toHaveLength(1);
expect(labels).toContain("Alpha");
// Starred order lands the starred agent first.
expect(labels[0]).toBe("Bravo");
// The starred row offers an explicit "Remove from starred" menu action.
await openAgentMenu("Open actions for Bravo");
expect(document.body.textContent).toContain("Remove from starred");
});
it("offers star agent from an unstarred sidebar agent menu", async () => {
await renderSidebarAgents();
await openAgentMenu();
const starItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("Star agent"));
expect(starItem).toBeTruthy();
await act(async () => {
starItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockResourceMembershipsApi.updateAgent).toHaveBeenCalledWith(
"company-1",
"agent-1",
{ state: undefined, starred: true },
);
expect(document.body.querySelector('button[aria-label="Unstar Alpha"]')).not.toBeNull();
});
it("keeps the agent starred and toasts when an unstar request fails", async () => {
mockAgentsApi.list.mockResolvedValue([makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" })]);
memberships = {
projectMemberships: {},
agentMemberships: { "agent-b": "joined" },
starredProjectIds: [],
starredAgentIds: ["agent-b"],
projectStarredAt: {},
agentStarredAt: {},
updatedAt: new Date(),
};
mockResourceMembershipsApi.updateAgent.mockRejectedValue(new Error("nope"));
await renderSidebarAgents();
const unstar = document.body.querySelector('button[aria-label="Unstar Bravo"]');
expect(unstar).not.toBeNull();
await act(async () => {
unstar?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
// Optimistic unstar is rolled back → the row stays in the starred group.
expect(document.body.querySelector('button[aria-label="Unstar Bravo"]')).not.toBeNull();
expect(mockPushToast).toHaveBeenCalledWith(
expect.objectContaining({ tone: "error" }),
);
});
it("keeps top mode in stored org-aware order", async () => {
localStorage.setItem("paperclip.agentOrder:company-1:user-1", JSON.stringify(["agent-b", "agent-a", "agent-c"]));
mockAgentsApi.list.mockResolvedValue([
+112 -22
View File
@@ -9,6 +9,7 @@ import {
Pencil,
PlayCircle,
Plus,
Star,
Users,
AlertTriangle,
} from "lucide-react";
@@ -23,7 +24,13 @@ import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { queryKeys } from "../lib/queryKeys";
import { cn, agentRouteRef, agentUrl, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import { useAgentOrder } from "../hooks/useAgentOrder";
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
import {
isStarred,
resourceMembershipState,
starredResourceIds,
useResourceMembershipMutation,
useResourceMemberships,
} from "../hooks/useResourceMemberships";
import {
AGENT_SORT_MODE_UPDATED_EVENT,
getAgentSortModeStorageKey,
@@ -35,6 +42,7 @@ import {
import { AgentIcon } from "./AgentIconPicker";
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection";
import { StarToggle } from "./StarToggle";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -87,6 +95,10 @@ function sortAgents(agents: Agent[], sortMode: AgentSidebarSortMode): Agent[] {
return sorted;
}
// Sidebar star reveals with the agent row's own group, not the shared group.
const AGENT_STAR_ROW_REVEAL =
"opacity-0 transition-opacity group-hover/agent:opacity-100 group-focus-within/agent:opacity-100";
function SidebarAgentItem({
activeAgentId,
activeTab,
@@ -99,6 +111,9 @@ function SidebarAgentItem({
rail,
runCount,
setSidebarOpen,
starred = false,
onToggleStar,
starPending = false,
}: {
activeAgentId: string | null;
activeTab: string | null;
@@ -111,6 +126,9 @@ function SidebarAgentItem({
rail: boolean;
runCount: number;
setSidebarOpen: (open: boolean) => void;
starred?: boolean;
onToggleStar?: (agent: Agent, starred: boolean) => void;
starPending?: boolean;
}) {
const routeRef = agentRouteRef(agent);
const href = activeTab ? `${agentUrl(agent)}/${activeTab}` : agentUrl(agent);
@@ -137,7 +155,9 @@ function SidebarAgentItem({
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 pr-8 text-[13px] font-medium transition-colors",
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 text-[13px] font-medium transition-colors",
// Reserve room for the ⋯ menu, plus the inline unstar star on starred rows.
starred && !isMobile ? "pr-14" : "pr-8",
isActive
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
@@ -187,6 +207,21 @@ function SidebarAgentItem({
link
)}
{!rail && starred && !isMobile && onToggleStar ? (
// Desktop: quiet inline unstar, left of the ⋯ menu, revealed on hover/focus.
<span className="absolute right-8 top-1/2 -translate-y-1/2">
<StarToggle
size="row"
quiet
starred
pending={starPending}
resourceName={agent.name}
onToggle={() => onToggleStar(agent, false)}
revealClassName={AGENT_STAR_ROW_REVEAL}
/>
</span>
) : null}
{!rail && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -204,7 +239,26 @@ function SidebarAgentItem({
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuContent align="end" className="w-48">
{onToggleStar ? (
<>
<DropdownMenuItem
onClick={() => {
if (starPending) return;
onToggleStar(agent, !starred);
}}
disabled={starPending}
>
{starPending ? (
<Loader2 className="size-4 motion-safe:animate-spin" />
) : (
<Star className={cn("size-4", starred && "fill-amber-500 text-amber-500")} />
)}
<span>{starred ? "Remove from starred" : "Star agent"}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuItem asChild>
<Link
to={editHref}
@@ -441,6 +495,59 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
[membershipMutation.isPending, membershipMutation.variables],
);
const toggleStarAgent = useCallback(
(agent: Agent, starred: boolean) => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
starred,
}),
[membershipMutation],
);
const agentStarPending = useCallback(
(agent: Agent) =>
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id &&
membershipMutation.variables.starred !== undefined,
[membershipMutation.isPending, membershipMutation.variables],
);
// Starred agents pin to the top of the section (name order), and are deduped
// out of the active/recent subset so no agent appears twice.
const starredAgentIdSet = useMemo(
() => new Set(starredResourceIds(membershipsQuery.data, "agent")),
[membershipsQuery.data],
);
const starredAgents = useMemo(
() => sortAgents(visibleAgents.filter((agent: Agent) => starredAgentIdSet.has(agent.id)), "alphabetical"),
[visibleAgents, starredAgentIdSet],
);
const dedupedDisplayedAgents = useMemo(
() => displayedAgents.filter((agent: Agent) => !starredAgentIdSet.has(agent.id)),
[displayedAgents, starredAgentIdSet],
);
const renderAgentRow = (agent: Agent, isStarredRow: boolean) => (
<SidebarAgentItem
key={agent.id}
activeAgentId={activeAgentId}
activeTab={activeTab}
agent={agent}
disabled={pendingAgentIds.has(agent.id)}
isMobile={isMobile}
leaving={agentLeaving(agent)}
onLeaveAgent={leaveAgent}
onPauseResume={(targetAgent, action) => pauseResumeAgent.mutate({ agent: targetAgent, action })}
rail={rail}
runCount={liveCountByAgent.get(agent.id) ?? 0}
setSidebarOpen={setSidebarOpen}
starred={isStarredRow || isStarred(membershipsQuery.data, "agent", agent.id)}
onToggleStar={toggleStarAgent}
starPending={agentStarPending(agent)}
/>
);
return (
<SidebarSection
label="Agents"
@@ -462,25 +569,8 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
onRadioValueChange: persistSortMode,
}}
>
{displayedAgents.map((agent: Agent) => {
const runCount = liveCountByAgent.get(agent.id) ?? 0;
return (
<SidebarAgentItem
key={agent.id}
activeAgentId={activeAgentId}
activeTab={activeTab}
agent={agent}
disabled={pendingAgentIds.has(agent.id)}
isMobile={isMobile}
leaving={agentLeaving(agent)}
onLeaveAgent={leaveAgent}
onPauseResume={(targetAgent, action) => pauseResumeAgent.mutate({ agent: targetAgent, action })}
rail={rail}
runCount={runCount}
setSidebarOpen={setSidebarOpen}
/>
);
})}
{starredAgents.map((agent: Agent) => renderAgentRow(agent, true))}
{dedupedDisplayedAgents.map((agent: Agent) => renderAgentRow(agent, false))}
{showSeeAllLink && (() => {
const seeAllLink = (
<Link
@@ -0,0 +1,187 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Project, ResourceMemberships } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarStarredProjects } from "./SidebarStarredProjects";
import { TooltipProvider } from "@/components/ui/tooltip";
const mockProjectsApi = vi.hoisted(() => ({ list: vi.fn() }));
const mockResourceMembershipsApi = vi.hoisted(() => ({ listMine: vi.fn(), updateProject: vi.fn() }));
const mockPushToast = vi.hoisted(() => vi.fn());
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
const mockSidebarState = vi.hoisted(() => ({ isMobile: false, collapsed: false, peeking: false }));
vi.mock("@/lib/router", () => ({
NavLink: ({ children, className, to, ...props }: {
children: ReactNode;
className?: string | ((state: { isActive: boolean }) => string);
to: string;
}) => (
<a href={to} className={typeof className === "function" ? className({ isActive: false }) : className} {...props}>
{children}
</a>
),
useLocation: () => ({ pathname: "/PAP/dashboard", search: "", hash: "", state: null }),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { id: "company-1", issuePrefix: "PAP" } }),
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({
isMobile: mockSidebarState.isMobile,
setSidebarOpen: mockSetSidebarOpen,
collapsed: mockSidebarState.collapsed,
peeking: mockSidebarState.peeking,
}),
}));
vi.mock("../context/ToastContext", () => ({
useToastActions: () => ({ pushToast: mockPushToast }),
}));
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
vi.mock("../api/resourceMemberships", () => ({ resourceMembershipsApi: mockResourceMembershipsApi }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
function makeProject(overrides: Partial<Project>): Project {
return {
id: "project-a",
companyId: "company-1",
urlKey: "alpha",
goalId: null,
goalIds: [],
goals: [],
name: "Alpha",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#ef4444",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/project-a",
effectiveLocalFolder: "/tmp/project-a",
origin: "local_folder",
},
workspaces: [],
primaryWorkspace: null,
managedByPlugin: null,
archivedAt: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
};
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
function projectLinkLabels(container: HTMLElement) {
return Array.from(container.querySelectorAll('a[href$="/issues"]'))
.map((anchor) => anchor.textContent?.trim())
.filter(Boolean);
}
describe("SidebarStarredProjects", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;
let queryClient: QueryClient;
let memberships: ResourceMemberships;
beforeEach(() => {
mockSidebarState.isMobile = false;
mockSidebarState.collapsed = false;
mockSidebarState.peeking = false;
container = document.createElement("div");
document.body.appendChild(container);
root = null;
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
memberships = {
projectMemberships: {},
agentMemberships: {},
starredProjectIds: [],
starredAgentIds: [],
projectStarredAt: {},
agentStarredAt: {},
updatedAt: null,
};
mockResourceMembershipsApi.listMine.mockImplementation(() => Promise.resolve(memberships));
mockProjectsApi.list.mockResolvedValue([]);
});
afterEach(async () => {
if (root) await act(async () => root?.unmount());
queryClient.clear();
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
async function render() {
root = createRoot(container);
await act(async () => {
root?.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<SidebarStarredProjects />
</TooltipProvider>
</QueryClientProvider>,
);
});
await flushReact();
}
it("renders only starred, non-archived projects with a quiet unstar control", async () => {
mockProjectsApi.list.mockResolvedValue([
makeProject({ id: "project-a", name: "Alpha", urlKey: "alpha" }),
makeProject({ id: "project-b", name: "Bravo", urlKey: "bravo" }),
makeProject({ id: "project-c", name: "Ghost", urlKey: "ghost", archivedAt: new Date() }),
]);
memberships = { ...memberships, starredProjectIds: ["project-b", "project-c"] };
await render();
// Only the starred, non-archived project renders (archived "Ghost" is filtered out).
expect(projectLinkLabels(container)).toEqual(["Bravo"]);
expect(document.body.querySelector('button[aria-label="Unstar Bravo"]')).not.toBeNull();
});
it("renders nothing when no projects are starred", async () => {
mockProjectsApi.list.mockResolvedValue([makeProject({ id: "project-a", name: "Alpha" })]);
await render();
expect(container.textContent).not.toContain("No starred projects yet");
expect(projectLinkLabels(container)).toEqual([]);
});
});
@@ -0,0 +1,217 @@
import { useCallback, useMemo } from "react";
import { NavLink, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { Loader2, LogOut, MoreHorizontal, Star } from "lucide-react";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
import { projectsApi } from "../api/projects";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { queryKeys } from "../lib/queryKeys";
import { cn, projectRouteRef, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import {
isStarred,
starredResourceIds,
useResourceMembershipMutation,
useResourceMemberships,
} from "../hooks/useResourceMemberships";
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
import { ProjectTile } from "./ProjectTile";
import { StarToggle } from "./StarToggle";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { Project } from "@paperclipai/shared";
// Sidebar star reveals with the row's own group, not the shared unnamed group.
const STAR_ROW_REVEAL =
"opacity-0 transition-opacity group-hover/starred-project:opacity-100 group-focus-within/starred-project:opacity-100";
/**
* Compact starred-project children rendered directly below the top-level
* `Projects` nav row in the streamlined sidebar. Starring/unstarring itself
* happens from browse/detail surfaces; here we only ever *remove* a star
* (plus the existing leave affordance). Archived projects are filtered out
* server-side, so a stale star never resurrects a hidden project.
*/
export function SidebarStarredProjects() {
const { selectedCompanyId } = useCompany();
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
const rail = collapsed && !peeking;
const location = useLocation();
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const membershipsQuery = useResourceMemberships(selectedCompanyId);
const membershipMutation = useResourceMembershipMutation(selectedCompanyId);
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
const activeProjectRef = projectMatch?.[1] ?? null;
const starredProjects = useMemo(() => {
if (!membershipsQuery.isSuccess) return [];
const starredIds = new Set(starredResourceIds(membershipsQuery.data, "project"));
if (starredIds.size === 0) return [];
const byId = new Map((projects ?? []).map((project: Project) => [project.id, project]));
return Array.from(starredIds)
.map((id) => byId.get(id))
.filter((project): project is Project => !!project && !project.archivedAt)
.sort((left, right) =>
left.name.localeCompare(right.name, undefined, { sensitivity: "base" }),
);
}, [membershipsQuery.data, membershipsQuery.isSuccess, projects]);
const unstar = useCallback(
(project: Project) => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
starred: false,
}),
[membershipMutation],
);
const leave = useCallback(
(project: Project) => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
state: "left",
}),
[membershipMutation],
);
const pendingFor = useCallback(
(project: Project) =>
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "project" &&
membershipMutation.variables.resourceId === project.id,
[membershipMutation.isPending, membershipMutation.variables],
);
// Don't render anything until memberships load — no skeleton flash in the nav.
if (!membershipsQuery.isSuccess) return null;
// Empty starred groups should not add a placeholder row or extra sidebar spacing.
if (starredProjects.length === 0) {
return null;
}
return (
<div className="flex flex-col gap-0.5" aria-label="Starred projects">
{starredProjects.map((project) => {
const routeRef = projectRouteRef(project);
const isActive = activeProjectRef === routeRef || activeProjectRef === project.id;
const pending = pendingFor(project);
const unstarPending = pending && membershipMutation.variables?.starred === false;
const leavePending = pending && membershipMutation.variables?.state === "left";
const starred = isStarred(membershipsQuery.data, "project", project.id);
const link = (
<NavLink
to={`/projects/${routeRef}/issues`}
state={SIDEBAR_SCROLL_RESET_STATE}
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pl-8 pointer-coarse:py-1 pr-8 text-[13px] font-medium transition-colors",
isActive
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
)}
>
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "flex-1 truncate"}>{project.name}</span>
{!rail && project.pauseReason === "budget" ? (
<BudgetSidebarMarker title="Project paused by budget" />
) : null}
</NavLink>
);
return (
<div key={project.id} className="group/starred-project relative flex items-center">
{rail ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="min-w-0 flex-1">{link}</div>
</TooltipTrigger>
<TooltipContent side="right">{project.name}</TooltipContent>
</Tooltip>
) : (
link
)}
{!rail && !isMobile ? (
// Desktop: quiet inline unstar revealed on hover/focus.
<span className="absolute right-1 top-1/2 -translate-y-1/2">
<StarToggle
size="row"
quiet
starred={starred}
pending={unstarPending}
resourceName={project.name}
onToggle={() => unstar(project)}
revealClassName={STAR_ROW_REVEAL}
/>
</span>
) : null}
{!rail && isMobile ? (
// Touch: explicit ⋯ menu (no hover). Star action + separated Leave.
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 opacity-100"
aria-label={`Open actions for ${project.name}`}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={() => {
if (pending) return;
unstar(project);
}}
disabled={pending}
>
{unstarPending ? (
<Loader2 className="size-4 motion-safe:animate-spin" />
) : (
<Star className="size-4 fill-amber-500 text-amber-500" />
)}
<span>Remove from starred</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
if (pending) return;
leave(project);
}}
disabled={pending}
>
{leavePending ? (
<Loader2 className="size-4 motion-safe:animate-spin" />
) : (
<LogOut className="size-4" />
)}
<span>Leave project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
);
})}
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StarToggle } from "./StarToggle";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
describe("StarToggle", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
});
afterEach(async () => {
if (root) {
await act(async () => { root?.unmount(); });
}
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
async function render(node: React.ReactElement) {
root = createRoot(container);
await act(async () => {
root?.render(node);
});
}
function button() {
return container.querySelector("button");
}
it("labels and announces the unstarred state and toggles toward starred", async () => {
const onToggle = vi.fn();
await render(<StarToggle starred={false} resourceName="Alpha" onToggle={onToggle} />);
const btn = button();
expect(btn?.getAttribute("aria-label")).toBe("Star Alpha");
expect(btn?.getAttribute("aria-pressed")).toBe("false");
await act(async () => { btn?.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
expect(onToggle).toHaveBeenCalledWith(true);
});
it("labels and announces the starred state and toggles toward unstarred", async () => {
const onToggle = vi.fn();
await render(<StarToggle starred resourceName="Alpha" onToggle={onToggle} />);
const btn = button();
expect(btn?.getAttribute("aria-label")).toBe("Unstar Alpha");
expect(btn?.getAttribute("aria-pressed")).toBe("true");
// A starred (non-quiet) row control is visible at rest.
expect(btn?.className).toContain("opacity-100");
await act(async () => { btn?.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
expect(onToggle).toHaveBeenCalledWith(false);
});
it("stays quiet (hidden at rest) for a starred sidebar row", async () => {
await render(
<StarToggle starred quiet resourceName="Alpha" onToggle={() => {}} revealClassName="reveal-me" />,
);
const btn = button();
// Quiet: even starred, hidden at rest and revealed via the passed class.
expect(btn?.className).toContain("reveal-me");
expect(btn?.className).not.toContain("opacity-100");
});
it("blocks input and spins while pending", async () => {
const onToggle = vi.fn();
await render(<StarToggle starred={false} pending resourceName="Alpha" onToggle={onToggle} />);
const btn = button();
expect(btn?.hasAttribute("disabled")).toBe(true);
expect(btn?.getAttribute("aria-busy")).toBe("true");
await act(async () => { btn?.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
expect(onToggle).not.toHaveBeenCalled();
});
it("surfaces a retry affordance on error for the button variant", async () => {
await render(<StarToggle starred size="button" error resourceName="Alpha" onToggle={() => {}} />);
const btn = button();
expect(btn?.textContent).toContain("Retry star");
expect(btn?.getAttribute("title")).toBe("Couldn't save — retry");
});
it("renders the labelled Star/Starred button variant", async () => {
await render(<StarToggle starred size="button" resourceName="Alpha" onToggle={() => {}} />);
expect(button()?.textContent).toContain("Starred");
});
});
+132
View File
@@ -0,0 +1,132 @@
import type { MouseEvent } from "react";
import { Loader2, Star } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "../lib/utils";
export interface StarToggleProps {
/** Whether the resource is currently starred (post-optimistic value). */
starred: boolean;
/** Human-readable resource name, used for the accessible label. */
resourceName: string;
/** Optimistic mutation in flight — shows a spinner and blocks input. */
pending?: boolean;
/** Last mutation failed — surface a retry affordance (red star). */
error?: boolean;
/**
* "row" — quiet icon-only control for sidebar and browse-list rows.
* "button" — labelled Star/Starred button for detail headers.
*/
size?: "row" | "button";
/** Called with the desired next starred value. */
onToggle: (nextStarred: boolean) => void;
/**
* Row variant only: keep the control hidden at rest even when starred, so it
* only appears on hover/focus. Sidebar rows are "intentionally quiet"; browse
* rows keep the starred control visible.
*/
quiet?: boolean;
/** Extra classes for the control itself. */
className?: string;
/**
* Row variant only: classes that control at-rest visibility when the resource
* is not starred (e.g. reveal on hover/focus). Ignored when starred (a starred
* control is always visible) or on the button variant. Defaults to the shared
* unnamed-`group` reveal used by browse rows; sidebar passes a named-group
* variant so it reveals with `group/project` / `group/agent`.
*/
revealClassName?: string;
}
const DEFAULT_ROW_REVEAL =
"opacity-100 sm:opacity-0 sm:transition-opacity sm:group-hover:opacity-100 sm:group-focus-within:opacity-100";
export function StarToggle({
starred,
resourceName,
pending = false,
error = false,
size = "row",
onToggle,
quiet = false,
className,
revealClassName,
}: StarToggleProps) {
const ariaLabel = starred ? `Unstar ${resourceName}` : `Star ${resourceName}`;
const Icon = pending ? Loader2 : Star;
function handleClick(event: MouseEvent<HTMLButtonElement>) {
event.preventDefault();
event.stopPropagation();
if (pending) return;
// On error, retry the last intent (toggle toward the desired end state).
onToggle(!starred);
}
if (size === "button") {
const label = pending ? "Saving..." : error ? "Retry star" : starred ? "Starred" : "Star";
return (
<Button
type="button"
size="sm"
variant="outline"
aria-label={ariaLabel}
aria-pressed={starred}
aria-busy={pending ? "true" : undefined}
disabled={pending}
onClick={handleClick}
title={error ? "Couldn't save — retry" : undefined}
className={cn(
error
? "text-red-500 hover:text-red-500"
: starred
? "text-amber-600 dark:text-amber-500"
: undefined,
className,
)}
>
<Icon
className={cn(
"h-4 w-4",
pending && "motion-safe:animate-spin",
!pending && starred && !error && "fill-amber-500 text-amber-500",
!pending && error && "text-red-500",
)}
/>
<span>{label}</span>
</Button>
);
}
// Row variant: a starred (or errored) control is always visible; an unstarred
// one is quiet and revealed on hover/focus so the nav stays calm. When `quiet`
// (sidebar), even a starred control hides at rest and reveals on hover/focus.
const visible = error || pending || (starred && !quiet);
return (
<Button
type="button"
size="icon-xs"
variant="ghost"
aria-label={ariaLabel}
aria-pressed={starred}
aria-busy={pending ? "true" : undefined}
disabled={pending}
onClick={handleClick}
title={error ? "Couldn't save — retry" : undefined}
className={cn(
"h-6 w-6 shrink-0",
visible ? "opacity-100" : revealClassName ?? DEFAULT_ROW_REVEAL,
className,
)}
>
<Icon
className={cn(
"h-3.5 w-3.5",
pending && "motion-safe:animate-spin",
!pending && error && "text-red-500",
!pending && !error && starred && "fill-amber-500 text-amber-500",
!pending && !error && !starred && "text-muted-foreground",
)}
/>
</Button>
);
}
+97 -20
View File
@@ -12,40 +12,84 @@ type MutationVariables = {
resourceType: ResourceMembershipResourceType;
resourceId: string;
resourceName: string;
state: ResourceMembershipState;
/** Join / leave transition. Omit to only change the starred flag. */
state?: ResourceMembershipState;
/** Star / unstar transition. Omit to only change join/leave state. */
starred?: boolean;
};
function emptyMemberships(): ResourceMemberships {
return {
projectMemberships: {},
agentMemberships: {},
starredProjectIds: [],
starredAgentIds: [],
projectStarredAt: {},
agentStarredAt: {},
updatedAt: null,
};
}
function applyMembershipState(
function starKeys(resourceType: ResourceMembershipResourceType) {
return resourceType === "project"
? { ids: "starredProjectIds", at: "projectStarredAt", state: "projectMemberships" }
: { ids: "starredAgentIds", at: "agentStarredAt", state: "agentMemberships" };
}
/**
* Apply an optimistic membership change to the cached memberships snapshot.
* Mirrors the server rules (see server/src/services/resource-memberships.ts):
* - starred=true always implies joined and stamps starredAt,
* - starred=false clears the star but leaves join/leave state alone,
* - state="left" clears any star (you cannot star a left resource).
*/
function applyMembershipChange(
current: ResourceMemberships | undefined,
resourceType: ResourceMembershipResourceType,
resourceId: string,
state: ResourceMembershipState,
change: { state?: ResourceMembershipState; starred?: boolean },
): ResourceMemberships {
const base = current ?? emptyMemberships();
if (resourceType === "project") {
return {
...base,
projectMemberships: {
...base.projectMemberships,
[resourceId]: state,
},
updatedAt: new Date(),
};
const keys = starKeys(resourceType);
// Resolve next join/leave state (starring implies joined).
const currentStateMap = base[keys.state as "projectMemberships"] ?? {};
const previousState: ResourceMembershipState =
currentStateMap[resourceId] === "left" ? "left" : "joined";
const nextState: ResourceMembershipState =
change.starred === true ? "joined" : change.state ?? previousState;
// Resolve next starred set.
const currentStarredIds = base[keys.ids as "starredProjectIds"] ?? [];
const nextStarredAt = { ...(base[keys.at as "projectStarredAt"] ?? {}) };
const previouslyStarred = currentStarredIds.includes(resourceId);
const nextStarred =
nextState === "left"
? false
: change.starred === true
? true
: change.starred === false
? false
: previouslyStarred;
let starredIds = currentStarredIds;
if (nextStarred && !previouslyStarred) {
// Newest star sorts first, matching the server's starredAt DESC ordering.
starredIds = [resourceId, ...currentStarredIds];
nextStarredAt[resourceId] = new Date();
} else if (!nextStarred && previouslyStarred) {
starredIds = currentStarredIds.filter((id) => id !== resourceId);
delete nextStarredAt[resourceId];
}
return {
...base,
agentMemberships: {
...base.agentMemberships,
[resourceId]: state,
[keys.state]: {
...currentStateMap,
[resourceId]: nextState,
},
[keys.ids]: starredIds,
[keys.at]: nextStarredAt,
updatedAt: new Date(),
};
}
@@ -61,6 +105,29 @@ export function resourceMembershipState(
return state === "left" ? "left" : "joined";
}
/** Whether the current viewer has starred this resource (navigation preference). */
export function isStarred(
memberships: ResourceMemberships | undefined,
resourceType: ResourceMembershipResourceType,
resourceId: string,
): boolean {
const ids = resourceType === "project"
? memberships?.starredProjectIds
: memberships?.starredAgentIds;
return Array.isArray(ids) && ids.includes(resourceId);
}
/** Ordered starred ids (server returns starredAt DESC; falls back to empty). */
export function starredResourceIds(
memberships: ResourceMemberships | undefined,
resourceType: ResourceMembershipResourceType,
): string[] {
const ids = resourceType === "project"
? memberships?.starredProjectIds
: memberships?.starredAgentIds;
return Array.isArray(ids) ? ids : [];
}
export function useResourceMemberships(companyId: string | null | undefined) {
return useQuery({
queryKey: queryKeys.resourceMemberships.mine(companyId ?? "__none__"),
@@ -77,16 +144,20 @@ export function useResourceMembershipMutation(companyId: string | null | undefin
return useMutation({
mutationFn: (variables: MutationVariables) => {
if (!companyId) throw new Error("Select a company first.");
const body = { state: variables.state, starred: variables.starred };
return variables.resourceType === "project"
? resourceMembershipsApi.updateProject(companyId, variables.resourceId, { state: variables.state })
: resourceMembershipsApi.updateAgent(companyId, variables.resourceId, { state: variables.state });
? resourceMembershipsApi.updateProject(companyId, variables.resourceId, body)
: resourceMembershipsApi.updateAgent(companyId, variables.resourceId, body);
},
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<ResourceMemberships>(queryKey);
queryClient.setQueryData<ResourceMemberships>(
queryKey,
applyMembershipState(previous, variables.resourceType, variables.resourceId, variables.state),
applyMembershipChange(previous, variables.resourceType, variables.resourceId, {
state: variables.state,
starred: variables.starred,
}),
);
return { previous };
},
@@ -94,7 +165,9 @@ export function useResourceMembershipMutation(companyId: string | null | undefin
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous);
}
const verb = variables.state === "left" ? "leave" : "join";
const verb = variables.starred !== undefined
? variables.starred ? "star" : "unstar"
: variables.state === "left" ? "leave" : "join";
pushToast({
title: `Couldn't ${verb} ${variables.resourceName}.`,
body: error instanceof Error ? error.message : "Try again.",
@@ -104,7 +177,11 @@ export function useResourceMembershipMutation(companyId: string | null | undefin
onSuccess: (result, variables) => {
queryClient.setQueryData<ResourceMemberships>(
queryKey,
(current) => applyMembershipState(current, variables.resourceType, result.resourceId, result.state),
// Loose null-check: a missing or null starredAt both mean "not starred".
(current) => applyMembershipChange(current, variables.resourceType, result.resourceId, {
state: result.state,
starred: result.starredAt != null,
}),
);
},
onSettled: () => {
+21 -2
View File
@@ -1,9 +1,27 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@font-face {
font-family: "InterVariable";
src: url("../fonts/InterVariable.woff2") format("woff2");
font-display: swap;
font-style: normal;
font-weight: 100 900;
}
@font-face {
font-family: "InterVariable";
src: url("../fonts/InterVariable-Italic.woff2") format("woff2");
font-display: swap;
font-style: italic;
font-weight: 100 900;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans: "InterVariable", "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@@ -247,6 +265,7 @@
}
body {
@apply bg-background text-foreground antialiased;
font-family: var(--font-sans);
height: 100%;
overflow: hidden;
}
@@ -851,7 +870,7 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
}
.paperclip-mdxeditor-content code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-family: var(--font-mono);
font-size: 1em;
}
@@ -956,7 +975,7 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
}
.paperclip-markdown code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-family: var(--font-mono);
font-size: 1em;
}
+36
View File
@@ -0,0 +1,36 @@
import { existsSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const uiRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const fontDir = path.join(uiRoot, "public", "fonts");
const cssPath = path.join(uiRoot, "src", "index.css");
const fontFiles = ["InterVariable.woff2", "InterVariable-Italic.woff2"];
describe("bundled UI font assets", () => {
it("ships the Inter variable font files used by the CSS", () => {
const css = readFileSync(cssPath, "utf8");
for (const fileName of fontFiles) {
const fontPath = path.join(fontDir, fileName);
expect(existsSync(fontPath), `${fileName} should exist in ui/public/fonts`).toBe(true);
expect(statSync(fontPath).isFile(), `${fileName} should be a file`).toBe(true);
expect(readFileSync(fontPath).subarray(0, 4).toString("ascii")).toBe("wOF2");
expect(css).toContain(`url("../fonts/${fileName}")`);
}
expect(css).toContain('--font-sans: "InterVariable"');
});
it("includes redistribution notice text for the bundled Inter files", () => {
const notice = readFileSync(path.join(fontDir, "NOTICE.md"), "utf8");
expect(notice).toContain("Inter");
expect(notice).toContain("v4.1");
expect(notice).toContain("SIL Open Font License 1.1");
expect(notice).toContain("InterVariable.woff2");
expect(notice).toContain("InterVariable-Italic.woff2");
});
});
+44 -25
View File
@@ -38,6 +38,7 @@ import { MarkdownBody } from "../components/MarkdownBody";
import { CopyText } from "../components/CopyText";
import { EntityRow } from "../components/EntityRow";
import { MembershipAction } from "../components/MembershipAction";
import { StarToggle } from "../components/StarToggle";
import { Identity } from "../components/Identity";
import { PageSkeleton } from "../components/PageSkeleton";
import { AgentActionButtons } from "../components/AgentActionButtons";
@@ -101,6 +102,7 @@ import { buildPermissionsForTrustPreset, getTrustPreset } from "../lib/trust-pol
import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils";
import { agentRouteRef } from "../lib/utils";
import {
isStarred,
resourceMembershipState,
useResourceMembershipMutation,
useResourceMemberships,
@@ -954,6 +956,9 @@ export function AgentDetail() {
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id;
const agentStarred = isStarred(membershipsQuery.data, "agent", agent.id);
const agentStarPending = agentMembershipPending && membershipMutation.variables?.starred !== undefined;
const agentJoinLeavePending = agentMembershipPending && membershipMutation.variables?.starred === undefined;
return (
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
@@ -965,8 +970,8 @@ export function AgentDetail() {
<MembershipAction
compact
state="left"
pending={agentMembershipPending}
pendingState={agentMembershipPending ? membershipMutation.variables?.state : null}
pending={agentJoinLeavePending}
pendingState={agentJoinLeavePending ? membershipMutation.variables?.state : null}
resourceName={agent.name}
onJoin={() => membershipMutation.mutate({
resourceType: "agent",
@@ -1031,29 +1036,43 @@ export function AgentDetail() {
</p>
</div>
</div>
<AgentActionButtons
agent={agent}
companyId={resolvedCompanyId}
assignLabel="Assign Task"
runLabel="Run Heartbeat"
actionsDisabled={agentAction.isPending}
workActionsDisabled={hasInvalidOrgChain}
workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs"
onActionError={setActionError}
>
{mobileLiveRun && (
<Link
to={`/agents/${canonicalAgentRef}/runs/${mobileLiveRun.id}`}
className="sm:hidden flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline"
>
<span className="relative flex h-2 w-2">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
</Link>
)}
</AgentActionButtons>
<div className="flex items-center gap-2 shrink-0">
<StarToggle
size="button"
starred={agentStarred}
pending={agentStarPending}
resourceName={agent.name}
onToggle={(next) => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
starred: next,
})}
/>
<AgentActionButtons
agent={agent}
companyId={resolvedCompanyId}
assignLabel="Assign Task"
runLabel="Run Heartbeat"
actionsDisabled={agentAction.isPending}
workActionsDisabled={hasInvalidOrgChain}
workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs"
onActionError={setActionError}
>
{mobileLiveRun && (
<Link
to={`/agents/${canonicalAgentRef}/runs/${mobileLiveRun.id}`}
className="sm:hidden flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline"
>
<span className="relative flex h-2 w-2">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
</Link>
)}
</AgentActionButtons>
</div>
</div>
{!urlRunId && (
+40 -14
View File
@@ -13,6 +13,7 @@ import { queryKeys } from "../lib/queryKeys";
import { AgentStatusBadge, AgentStatusCapsule } from "../components/StatusBadge";
import { AgentActionButtons } from "../components/AgentActionButtons";
import { MembershipAction } from "../components/MembershipAction";
import { StarToggle } from "../components/StarToggle";
import { EntityRow } from "../components/EntityRow";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
@@ -23,6 +24,7 @@ import { Button } from "@/components/ui/button";
import { AlertTriangle, Bot, Plus, List, GitBranch } from "lucide-react";
import { AGENT_ROLE_LABELS, type Agent, type Environment, type EnvironmentCapabilities } from "@paperclipai/shared";
import {
isStarred,
resourceMembershipState,
useResourceMembershipMutation,
useResourceMemberships,
@@ -271,6 +273,13 @@ export function Agents() {
const renderAgentRow = (agent: Agent) => {
const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain";
const agentPending =
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id;
const agentStarPending = agentPending && membershipMutation.variables?.starred !== undefined;
const agentJoinLeavePending = agentPending && membershipMutation.variables?.starred === undefined;
const agentStarred = isStarred(membershipsQuery.data, "agent", agent.id);
return (
<EntityRow
key={agent.id}
@@ -343,18 +352,8 @@ export function Agents() {
</div>
<MembershipAction
state={resourceMembershipState(membershipsQuery.data, "agent", agent.id)}
pending={
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id
}
pendingState={
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id
? membershipMutation.variables.state
: null
}
pending={agentJoinLeavePending}
pendingState={agentJoinLeavePending ? membershipMutation.variables?.state ?? null : null}
resourceName={agent.name}
onJoin={() => membershipMutation.mutate({
resourceType: "agent",
@@ -369,6 +368,18 @@ export function Agents() {
state: "left",
})}
/>
<StarToggle
size="row"
starred={agentStarred}
pending={agentStarPending}
resourceName={agent.name}
onToggle={(next) => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
starred: next,
})}
/>
</div>
}
/>
@@ -514,6 +525,9 @@ function OrgTreeNode({
const pending = membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === node.id;
const starPending = pending && membershipMutation.variables?.starred !== undefined;
const joinLeavePending = pending && membershipMutation.variables?.starred === undefined;
const starred = isStarred(memberships, "agent", node.id);
return (
<div style={{ paddingLeft: depth * 24 }}>
@@ -576,8 +590,8 @@ function OrgTreeNode({
</div>
<MembershipAction
state={membershipState}
pending={pending}
pendingState={pending ? membershipMutation.variables?.state : null}
pending={joinLeavePending}
pendingState={joinLeavePending ? membershipMutation.variables?.state : null}
resourceName={node.name}
onJoin={() => membershipMutation.mutate({
resourceType: "agent",
@@ -592,6 +606,18 @@ function OrgTreeNode({
state: "left",
})}
/>
<StarToggle
size="row"
starred={starred}
pending={starPending}
resourceName={node.name}
onToggle={(next) => membershipMutation.mutate({
resourceType: "agent",
resourceId: node.id,
resourceName: node.name,
starred: next,
})}
/>
</div>
</Link>
{node.reports && node.reports.length > 0 && (
@@ -122,7 +122,6 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
await renderPage();
const headings = [...container.querySelectorAll("section h2")].map((h) => h.textContent);
expect(headings).toContain("Streamlined Left Navigation Bar");
expect(headings).not.toContain("Conference Room Chat");
expect(container.querySelector(CONFERENCE_TOGGLE_SELECTOR)).toBeNull();
});
@@ -147,21 +146,13 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled();
});
it("renders the Streamlined Left Navigation toggle on by default and patches opt-out", async () => {
it("no longer renders the Streamlined Left Navigation toggle (opt-out retired, PAP-12472)", async () => {
await renderPage();
const toggle = container.querySelector<HTMLButtonElement>(STREAMLINED_TOGGLE_SELECTOR);
expect(toggle?.getAttribute("aria-checked")).toBe("true");
await act(async () => {
toggle?.click();
});
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
enableStreamlinedLeftNavigation: false,
});
expect(toggle?.getAttribute("aria-checked")).toBe("false");
const headings = [...container.querySelectorAll("section h2")].map((h) => h.textContent);
expect(headings).not.toContain("Streamlined Left Navigation Bar");
expect(container.querySelector(STREAMLINED_TOGGLE_SELECTOR)).toBeNull();
expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled();
});
it("renders and patches the Task Watchdogs experimental toggle on and off", async () => {
+2 -26
View File
@@ -232,10 +232,8 @@ export function InstanceExperimentalSettings() {
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
// Default ON: treat anything but an explicit `false` as enabled so
// the toggle reflects the streamlined sidebar being the default experience.
const enableStreamlinedLeftNavigation =
experimentalQuery.data?.enableStreamlinedLeftNavigation !== false;
// Streamlined left navigation is now the standard sidebar (PAP-12472); the
// experimental opt-out was retired, so it no longer surfaces a toggle here.
const enableConferenceRoomChat = experimentalQuery.data?.enableConferenceRoomChat === true;
const enableIssuePlanDecompositions =
experimentalQuery.data?.enableIssuePlanDecompositions === true;
@@ -393,28 +391,6 @@ export function InstanceExperimentalSettings() {
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Streamlined Left Navigation Bar</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Reduces the maximum number of items in the left navigation bar — nests Projects under Work with a
dedicated Projects page, and shows only active agents (max 5 recently-active) in the sidebar.
</p>
</div>
<ToggleSwitch
checked={enableStreamlinedLeftNavigation}
onCheckedChange={() =>
toggleMutation.mutate({
enableStreamlinedLeftNavigation: !enableStreamlinedLeftNavigation,
})
}
disabled={toggleMutation.isPending}
aria-label="Toggle streamlined left navigation experimental setting"
/>
</div>
</section>
{SHOW_CONFERENCE_ROOM_EXPERIMENTAL_SETTING ? (
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
+21 -2
View File
@@ -25,6 +25,7 @@ import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
import { ProjectWorkspacesContent } from "../components/ProjectWorkspacesContent";
import { MembershipAction } from "../components/MembershipAction";
import { StarToggle } from "../components/StarToggle";
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
import { projectRouteRef } from "../lib/utils";
@@ -37,6 +38,7 @@ import { Tabs } from "@/components/ui/tabs";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import {
isStarred,
resourceMembershipState,
useResourceMembershipMutation,
useResourceMemberships,
@@ -696,6 +698,9 @@ export function ProjectDetail() {
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "project" &&
membershipMutation.variables.resourceId === project.id;
const projectStarred = isStarred(membershipsQuery.data, "project", project.id);
const projectStarPending = projectMembershipPending && membershipMutation.variables?.starred !== undefined;
const projectJoinLeavePending = projectMembershipPending && membershipMutation.variables?.starred === undefined;
const handleTabChange = (tab: ProjectTab) => {
// Cache the active tab per project
@@ -731,8 +736,8 @@ export function ProjectDetail() {
<MembershipAction
compact
state="left"
pending={projectMembershipPending}
pendingState={projectMembershipPending ? membershipMutation.variables?.state : null}
pending={projectJoinLeavePending}
pendingState={projectJoinLeavePending ? membershipMutation.variables?.state : null}
resourceName={project.name}
onJoin={() => membershipMutation.mutate({
resourceType: "project",
@@ -786,6 +791,20 @@ export function ProjectDetail() {
</div>
) : null}
</div>
<div className="ml-auto flex items-center gap-2">
<StarToggle
size="button"
starred={projectStarred}
pending={projectStarPending}
resourceName={project.name}
onToggle={(next) => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
starred: next,
})}
/>
</div>
</div>
<PluginSlotOutlet
+19 -2
View File
@@ -10,10 +10,12 @@ import { EntityRow } from "../components/EntityRow";
import { ProjectTile } from "../components/ProjectTile";
import { StatusBadge } from "../components/StatusBadge";
import { MembershipAction } from "../components/MembershipAction";
import { StarToggle } from "../components/StarToggle";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { formatDate, formatNumber, formatProjectBudget, projectUrl } from "../lib/utils";
import {
isStarred,
resourceMembershipState,
useResourceMembershipMutation,
useResourceMemberships,
@@ -204,6 +206,9 @@ export function Projects() {
const pending = membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "project" &&
membershipMutation.variables.resourceId === project.id;
const starPending = pending && membershipMutation.variables?.starred !== undefined;
const joinLeavePending = pending && membershipMutation.variables?.starred === undefined;
const starred = isStarred(membershipsQuery.data, "project", project.id);
return (
<EntityRow
key={project.id}
@@ -234,8 +239,8 @@ export function Projects() {
<StatusBadge status={project.status} />
<MembershipAction
state={state}
pending={pending}
pendingState={pending ? membershipMutation.variables?.state : null}
pending={joinLeavePending}
pendingState={joinLeavePending ? membershipMutation.variables?.state : null}
resourceName={project.name}
onJoin={() => membershipMutation.mutate({
resourceType: "project",
@@ -250,6 +255,18 @@ export function Projects() {
state: "left",
})}
/>
<StarToggle
size="row"
starred={starred}
pending={starPending}
resourceName={project.name}
onToggle={(next) => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
starred: next,
})}
/>
</div>
}
/>