mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
Merge remote-tracking branch 'origin/master' into PAP-12383-what-are-the-organizational-patterns-for-things-like-zapier-and-other-similar-tools
# Conflicts: # packages/db/src/migrations/meta/_journal.json
This commit is contained in:
commit
ae04bca734
71 files changed
+7354
-329
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
@@ -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]")) {
|
||||
|
||||
@@ -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");
|
||||
File renamed without changes.
@@ -884,13 +884,6 @@
|
||||
"tag": "0125_environment_custom_image_templates",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 126,
|
||||
"version": "7",
|
||||
"when": 1782526400000,
|
||||
"tag": "0126_issue_comment_derived_attribution",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 127,
|
||||
"version": "7",
|
||||
@@ -922,8 +915,29 @@
|
||||
{
|
||||
"idx": 131,
|
||||
"version": "7",
|
||||
"when": 1783025324120,
|
||||
"tag": "0131_repair_run_responsible_user_context_refs",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 132,
|
||||
"version": "7",
|
||||
"when": 1783025424120,
|
||||
"tag": "0132_issue_comment_derived_attribution_fast",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 133,
|
||||
"version": "7",
|
||||
"when": 1783034521000,
|
||||
"tag": "0133_resource_membership_stars",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 134,
|
||||
"version": "7",
|
||||
"when": 1783250344742,
|
||||
"tag": "0131_folders",
|
||||
"tag": "0134_folders",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -10,11 +10,17 @@ export const agentMemberships = pgTable(
|
||||
agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id").notNull(),
|
||||
state: text("state").notNull().default("joined"),
|
||||
starredAt: timestamp("starred_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyUserIdx: index("agent_memberships_company_user_idx").on(table.companyId, table.userId),
|
||||
companyUserStarredIdx: index("agent_memberships_company_user_starred_idx").on(
|
||||
table.companyId,
|
||||
table.userId,
|
||||
table.starredAt,
|
||||
),
|
||||
agentIdx: index("agent_memberships_agent_idx").on(table.agentId),
|
||||
companyUserAgentUq: uniqueIndex("agent_memberships_company_user_agent_uq").on(
|
||||
table.companyId,
|
||||
|
||||
@@ -10,11 +10,17 @@ export const projectMemberships = pgTable(
|
||||
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id").notNull(),
|
||||
state: text("state").notNull().default("joined"),
|
||||
starredAt: timestamp("starred_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyUserIdx: index("project_memberships_company_user_idx").on(table.companyId, table.userId),
|
||||
companyUserStarredIdx: index("project_memberships_company_user_starred_idx").on(
|
||||
table.companyId,
|
||||
table.userId,
|
||||
table.starredAt,
|
||||
),
|
||||
projectIdx: index("project_memberships_project_idx").on(table.projectId),
|
||||
companyUserProjectUq: uniqueIndex("project_memberships_company_user_project_uq").on(
|
||||
table.companyId,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ADAPTER_AGNOSTIC_KEYS } from "./constants.js";
|
||||
|
||||
const EXPECTED_ADAPTER_AGNOSTIC_KEYS = [
|
||||
"env",
|
||||
"promptTemplate",
|
||||
"instructionsFilePath",
|
||||
"cwd",
|
||||
"timeoutSec",
|
||||
"graceSec",
|
||||
"bootstrapPromptTemplate",
|
||||
"paperclipSkillSync",
|
||||
] as const;
|
||||
|
||||
function readRepoFile(pathFromRoot: string) {
|
||||
return readFileSync(
|
||||
fileURLToPath(new URL(`../../../${pathFromRoot}`, import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
describe("adapter-agnostic config keys", () => {
|
||||
it("keeps the preserved adapter config keys explicit", () => {
|
||||
expect(ADAPTER_AGNOSTIC_KEYS).toEqual(EXPECTED_ADAPTER_AGNOSTIC_KEYS);
|
||||
});
|
||||
|
||||
it("is imported by the server and UI instead of being re-declared", () => {
|
||||
const serverSource = readRepoFile("server/src/routes/agents.ts");
|
||||
const uiSource = readRepoFile("ui/src/lib/agent-config-patch.ts");
|
||||
|
||||
expect(serverSource).toContain("ADAPTER_AGNOSTIC_KEYS");
|
||||
expect(serverSource).toContain("from \"@paperclipai/shared\"");
|
||||
expect(serverSource).not.toMatch(/const\s+ADAPTER_AGNOSTIC_KEYS\s*=/);
|
||||
|
||||
expect(uiSource).toContain("ADAPTER_AGNOSTIC_KEYS");
|
||||
expect(uiSource).toContain("from \"@paperclipai/shared\"");
|
||||
expect(uiSource).not.toMatch(/const\s+ADAPTER_AGNOSTIC_KEYS\s*=/);
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,20 @@ export const AGENT_ROLE_LABELS: Record<AgentRole, string> = {
|
||||
export const AGENT_DEFAULT_MAX_CONCURRENT_RUNS = 20;
|
||||
export const WORKSPACE_BRANCH_ROUTINE_VARIABLE = "workspaceBranch";
|
||||
|
||||
// Config keys owned by Paperclip/company state rather than one concrete adapter.
|
||||
// `paperclipSkillSync` is persisted in adapterConfig but must survive adapter swaps.
|
||||
export const ADAPTER_AGNOSTIC_KEYS = [
|
||||
"env",
|
||||
"promptTemplate",
|
||||
"instructionsFilePath",
|
||||
"cwd",
|
||||
"timeoutSec",
|
||||
"graceSec",
|
||||
"bootstrapPromptTemplate",
|
||||
"paperclipSkillSync",
|
||||
] as const;
|
||||
export type AdapterAgnosticKey = (typeof ADAPTER_AGNOSTIC_KEYS)[number];
|
||||
|
||||
export const MODEL_PROFILE_KEYS = ["cheap"] as const;
|
||||
export type ModelProfileKey = (typeof MODEL_PROFILE_KEYS)[number];
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ export {
|
||||
AGENT_ROLE_LABELS,
|
||||
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
|
||||
ADAPTER_AGNOSTIC_KEYS,
|
||||
MODEL_PROFILE_KEYS,
|
||||
AGENT_ICON_NAMES,
|
||||
PROJECT_ICON_NAMES,
|
||||
@@ -265,6 +266,7 @@ export {
|
||||
type AgentStatus,
|
||||
type AgentAdapterType,
|
||||
type AgentRole,
|
||||
type AdapterAgnosticKey,
|
||||
type ModelProfileKey,
|
||||
type AgentIconName,
|
||||
type ProjectIconName,
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -339,7 +339,15 @@ export function computePipelineHealth(input: PipelineHealthInput): PipelineHealt
|
||||
// the pipeline; a blank default in settings is a normal configuration.
|
||||
}
|
||||
|
||||
const seenFailedAutomationCaseIdsByStage = new Map<string, Set<string>>();
|
||||
for (const failure of input.failedAutomations ?? []) {
|
||||
let seenCaseIds = seenFailedAutomationCaseIdsByStage.get(failure.stageId);
|
||||
if (!seenCaseIds) {
|
||||
seenCaseIds = new Set<string>();
|
||||
seenFailedAutomationCaseIdsByStage.set(failure.stageId, seenCaseIds);
|
||||
}
|
||||
if (seenCaseIds.has(failure.caseId)) continue;
|
||||
seenCaseIds.add(failure.caseId);
|
||||
warnings.push({
|
||||
code: "automation_failed",
|
||||
stageId: failure.stageId,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { updateResourceMembershipSchema } from "./validators/resource-memberships.js";
|
||||
|
||||
describe("resource membership contract", () => {
|
||||
it("accepts legacy state-only membership updates", () => {
|
||||
expect(updateResourceMembershipSchema.parse({ state: "left" })).toEqual({ state: "left" });
|
||||
expect(updateResourceMembershipSchema.parse({ state: "joined" })).toEqual({ state: "joined" });
|
||||
});
|
||||
|
||||
it("accepts star-only updates without requiring a state mutation", () => {
|
||||
expect(updateResourceMembershipSchema.parse({ starred: true })).toEqual({ starred: true });
|
||||
expect(updateResourceMembershipSchema.parse({ starred: false })).toEqual({ starred: false });
|
||||
});
|
||||
|
||||
it("rejects empty or contradictory star/state updates", () => {
|
||||
expect(() => updateResourceMembershipSchema.parse({})).toThrow("state or starred is required");
|
||||
expect(() => updateResourceMembershipSchema.parse({ state: "left", starred: true })).toThrow(
|
||||
"starred resources must be joined",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,16 +6,22 @@ export type ResourceMembershipResourceType = "project" | "agent";
|
||||
export interface ResourceMemberships {
|
||||
projectMemberships: Record<string, ResourceMembershipState>;
|
||||
agentMemberships: Record<string, ResourceMembershipState>;
|
||||
starredProjectIds?: string[];
|
||||
starredAgentIds?: string[];
|
||||
projectStarredAt?: Record<string, Date>;
|
||||
agentStarredAt?: Record<string, Date>;
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface UpdateResourceMembership {
|
||||
state: ResourceMembershipState;
|
||||
state?: ResourceMembershipState;
|
||||
starred?: boolean;
|
||||
}
|
||||
|
||||
export interface ResourceMembershipUpdateResult {
|
||||
resourceType: ResourceMembershipResourceType;
|
||||
resourceId: string;
|
||||
state: ResourceMembershipState;
|
||||
starredAt: Date | null;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -4,7 +4,13 @@ import { RESOURCE_MEMBERSHIP_STATES } from "../types/resource-memberships.js";
|
||||
export const resourceMembershipStateSchema = z.enum(RESOURCE_MEMBERSHIP_STATES);
|
||||
|
||||
export const updateResourceMembershipSchema = z.object({
|
||||
state: resourceMembershipStateSchema,
|
||||
state: resourceMembershipStateSchema.optional(),
|
||||
starred: z.boolean().optional(),
|
||||
}).refine((value) => value.state !== undefined || value.starred !== undefined, {
|
||||
message: "state or starred is required",
|
||||
}).refine((value) => !(value.state === "left" && value.starred === true), {
|
||||
message: "starred resources must be joined",
|
||||
path: ["starred"],
|
||||
});
|
||||
|
||||
export type UpdateResourceMembership = z.infer<typeof updateResourceMembershipSchema>;
|
||||
@@ -52,5 +52,5 @@ describe("skills catalog package artifacts", () => {
|
||||
expect(paths).toContain("catalog/bundled/software-development/github-pr-workflow/SKILL.md");
|
||||
expect(paths).toContain("catalog/optional/browser/agent-browser/SKILL.md");
|
||||
expect(paths).toContain("package.json");
|
||||
}, 30_000);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
||||
import {
|
||||
agentSkillSyncSchema,
|
||||
agentMineInboxQuerySchema,
|
||||
ADAPTER_AGNOSTIC_KEYS,
|
||||
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
createAgentKeySchema,
|
||||
createAgentHireSchema,
|
||||
@@ -170,6 +171,7 @@ export function agentRoutes(
|
||||
"instructionsFilePath",
|
||||
"agentsMdPath",
|
||||
] as const;
|
||||
const KNOWN_INSTRUCTIONS_BUNDLE_KEY_SET: ReadonlySet<string> = new Set(KNOWN_INSTRUCTIONS_BUNDLE_KEYS);
|
||||
|
||||
const router = Router();
|
||||
const svc = agentService(db);
|
||||
@@ -2871,15 +2873,8 @@ export function agentRoutes(
|
||||
// Preserve adapter-agnostic keys (env, cwd, etc.) from the existing config
|
||||
// when the adapter type changes. Without this, a PATCH that includes
|
||||
// adapterConfig but omits these keys would silently drop them.
|
||||
// `paperclipSkillSync` holds the agent's desired-skill selection, which is
|
||||
// a company-level (adapter-agnostic) choice even though it is persisted
|
||||
// inside the per-adapter config; switching adapters must not wipe it.
|
||||
const ADAPTER_AGNOSTIC_KEYS = [
|
||||
"env", "cwd", "timeoutSec", "graceSec",
|
||||
"promptTemplate", "bootstrapPromptTemplate",
|
||||
"paperclipSkillSync",
|
||||
] as const;
|
||||
for (const key of ADAPTER_AGNOSTIC_KEYS) {
|
||||
if (KNOWN_INSTRUCTIONS_BUNDLE_KEY_SET.has(key)) continue;
|
||||
if (rawEffectiveAdapterConfig[key] === undefined && existingAdapterConfig[key] !== undefined) {
|
||||
rawEffectiveAdapterConfig = { ...rawEffectiveAdapterConfig, [key]: existingAdapterConfig[key] };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
@@ -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.
@@ -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.
|
||||
@@ -8,6 +8,7 @@ import { OnboardingWizardVariant } from "./components/OnboardingWizardVariant";
|
||||
import { CloudAccessGate } from "./components/CloudAccessGate";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { DashboardLive } from "./pages/DashboardLive";
|
||||
import { Timeline } from "./pages/Timeline";
|
||||
import { Companies } from "./pages/Companies";
|
||||
import { Agents } from "./pages/Agents";
|
||||
import { AgentDetail } from "./pages/AgentDetail";
|
||||
@@ -80,6 +81,7 @@ function boardRoutes() {
|
||||
<Route index element={<Navigate to="dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="dashboard/live" element={<DashboardLive />} />
|
||||
<Route path="timeline" element={<Timeline />} />
|
||||
<Route path="onboarding" element={<OnboardingRoutePage />} />
|
||||
<Route path="companies" element={<Companies />} />
|
||||
<Route path="company/settings" element={<CompanySettings />} />
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { WorkTimelineResult } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export interface WorkTimelineParams {
|
||||
from?: string;
|
||||
to?: string;
|
||||
/** lens: work kicked off / touched by this user. */
|
||||
userId?: string;
|
||||
goalId?: string;
|
||||
projectId?: string;
|
||||
issueId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
function query(params: WorkTimelineParams): string {
|
||||
const search = new URLSearchParams();
|
||||
if (params.from) search.set("from", params.from);
|
||||
if (params.to) search.set("to", params.to);
|
||||
if (params.userId) search.set("userId", params.userId);
|
||||
if (params.goalId) search.set("goalId", params.goalId);
|
||||
if (params.projectId) search.set("projectId", params.projectId);
|
||||
if (params.issueId) search.set("issueId", params.issueId);
|
||||
if (params.limit) search.set("limit", String(params.limit));
|
||||
const qs = search.toString();
|
||||
return qs ? `?${qs}` : "";
|
||||
}
|
||||
|
||||
export const workTimelineApi = {
|
||||
get: (companyId: string, params: WorkTimelineParams = {}) =>
|
||||
api.get<WorkTimelineResult>(`/companies/${companyId}/timeline${query(params)}`),
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SidebarProvider, useSidebar } from "../context/SidebarContext";
|
||||
@@ -30,11 +30,18 @@ function Harness({ onRoute }: { onRoute: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function render(onRoute: boolean): { root: Root; host: HTMLDivElement } {
|
||||
async function flushReact() {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
flushSync(() => {});
|
||||
}
|
||||
|
||||
async function render(onRoute: boolean): Promise<{ root: Root; host: HTMLDivElement }> {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
act(() => root.render(<Harness onRoute={onRoute} />));
|
||||
flushSync(() => root.render(<Harness onRoute={onRoute} />));
|
||||
await flushReact();
|
||||
return { root, host };
|
||||
}
|
||||
|
||||
@@ -68,45 +75,47 @@ describe("RequestCollapsedSidebar", () => {
|
||||
|
||||
afterEach(() => {
|
||||
if (active) {
|
||||
act(() => active!.root.unmount());
|
||||
flushSync(() => active!.root.unmount());
|
||||
active.host.remove();
|
||||
active = null;
|
||||
}
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("requests collapsed while mounted when there is no user pin", () => {
|
||||
active = render(true);
|
||||
it("requests collapsed while mounted when there is no user pin", async () => {
|
||||
active = await render(true);
|
||||
expect(capturedValue?.routeRequestsCollapsed).toBe(true);
|
||||
expect(capturedValue?.collapsed).toBe(true);
|
||||
});
|
||||
|
||||
it("lets an explicit user pin override the route request", () => {
|
||||
active = render(true);
|
||||
it("lets an explicit user pin override the route request", async () => {
|
||||
active = await render(true);
|
||||
expect(capturedValue?.collapsed).toBe(true);
|
||||
|
||||
// User explicitly pins expanded — must win over the route's request.
|
||||
act(() => capturedValue?.setCollapsed(false));
|
||||
flushSync(() => capturedValue?.setCollapsed(false));
|
||||
expect(capturedValue?.routeRequestsCollapsed).toBe(true);
|
||||
expect(capturedValue?.collapsed).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the request on unmount, restoring the global default", () => {
|
||||
active = render(true);
|
||||
it("clears the request on unmount, restoring the global default", async () => {
|
||||
active = await render(true);
|
||||
expect(capturedValue?.collapsed).toBe(true);
|
||||
|
||||
// Navigate away: the route (and its <RequestCollapsedSidebar/>) unmounts.
|
||||
act(() => active!.root.render(<Harness onRoute={false} />));
|
||||
flushSync(() => active!.root.render(<Harness onRoute={false} />));
|
||||
await flushReact();
|
||||
expect(capturedValue?.routeRequestsCollapsed).toBe(false);
|
||||
expect(capturedValue?.collapsed).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a user pin after navigating away (pin persists, request cleared)", () => {
|
||||
active = render(true);
|
||||
act(() => capturedValue?.setCollapsed(true));
|
||||
it("keeps a user pin after navigating away (pin persists, request cleared)", async () => {
|
||||
active = await render(true);
|
||||
flushSync(() => capturedValue?.setCollapsed(true));
|
||||
expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("1");
|
||||
|
||||
act(() => active!.root.render(<Harness onRoute={false} />));
|
||||
flushSync(() => active!.root.render(<Harness onRoute={false} />));
|
||||
await flushReact();
|
||||
// Route request gone, but the explicit collapsed pin still applies.
|
||||
expect(capturedValue?.routeRequestsCollapsed).toBe(false);
|
||||
expect(capturedValue?.collapsed).toBe(true);
|
||||
|
||||
@@ -100,6 +100,10 @@ vi.mock("./SidebarProjects", () => ({
|
||||
SidebarProjects: () => <div data-testid="sidebar-projects">Projects collapsible</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarStarredProjects", () => ({
|
||||
SidebarStarredProjects: () => <div data-testid="sidebar-starred-projects" />,
|
||||
}));
|
||||
|
||||
async function flushReact() {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await Promise.resolve();
|
||||
@@ -223,27 +227,24 @@ describe("Sidebar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("classic (flag OFF): New Task button, Tasks label, per-project collapsible, no top-level Projects link", async () => {
|
||||
it("streamlined is now standard: a stale enableStreamlinedLeftNavigation=false opt-out is ignored", async () => {
|
||||
// PAP-12472 retired the experimental opt-out; the streamlined sidebar is the
|
||||
// only path, so an old `false` setting no longer restores classic mode.
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: false,
|
||||
});
|
||||
const root = await renderSidebar();
|
||||
|
||||
expect(container.textContent).toContain("New Task");
|
||||
expect(container.textContent).not.toContain("New Issue");
|
||||
|
||||
const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim());
|
||||
expect(navLabels).toContain("Tasks");
|
||||
expect(navLabels).not.toContain("Issues");
|
||||
// No top-level Projects nav link in classic mode (D5 option A).
|
||||
expect(navLabels).not.toContain("Projects");
|
||||
|
||||
// Per-project collapsible restored below Work.
|
||||
expect(container.querySelector('[data-testid="sidebar-projects"]')).not.toBeNull();
|
||||
// Top-level Projects link + starred children stay, per-project collapsible gone.
|
||||
expect(navLabels).toContain("Projects");
|
||||
expect(container.querySelector('[data-testid="sidebar-projects"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="sidebar-starred-projects"]')).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
|
||||
).toBe("false");
|
||||
).toBe("true");
|
||||
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
@@ -310,6 +311,24 @@ describe("Sidebar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("places Timeline in the Company section", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
const root = await renderSidebar();
|
||||
|
||||
const sections = [...container.querySelectorAll("nav > div")];
|
||||
const workSection = sections.find((section) => section.textContent?.startsWith("Work"));
|
||||
const companySection = sections.find((section) => section.textContent?.startsWith("Company"));
|
||||
expect(workSection?.textContent).not.toContain("Timeline");
|
||||
expect(companySection?.textContent).toContain("Timeline");
|
||||
|
||||
const timelineLink = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Timeline");
|
||||
expect(timelineLink?.getAttribute("href")).toBe("/timeline");
|
||||
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the Conference Room nav item when conference room chat is enabled (PAP-137)", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
PanelLeftOpen,
|
||||
Pin,
|
||||
MessagesSquare,
|
||||
GanttChartSquare,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { NavLink } from "@/lib/router";
|
||||
@@ -25,6 +26,7 @@ import { SidebarSection } from "./SidebarSection";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { SidebarAgents } from "./SidebarAgents";
|
||||
import { SidebarProjects } from "./SidebarProjects";
|
||||
import { SidebarStarredProjects } from "./SidebarStarredProjects";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
@@ -58,12 +60,12 @@ export function Sidebar() {
|
||||
const liveRunCount = liveRuns?.length ?? 0;
|
||||
const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||
const showPipelines = experimentalSettings?.enablePipelines === true;
|
||||
// IA flag: branch the sidebar nav presentation. Default ON =
|
||||
// streamlined (top-level Projects link). Users can opt out in experiments to
|
||||
// get classic (per-project collapsible, no Projects nav link). Issue/Task
|
||||
// wording is split to PR #7651. Gating is navigation-only; all routes stay
|
||||
// registered in both modes.
|
||||
const streamlined = experimentalSettings?.enableStreamlinedLeftNavigation !== false;
|
||||
// Streamlined left navigation (top-level Projects link + starred children) is
|
||||
// now the standard product sidebar (PAP-12472). The former experimental
|
||||
// opt-out was retired; classic per-project collapsible mode is no longer
|
||||
// user-selectable. Kept as a constant so the classic branch below stays as a
|
||||
// documented reference until it is fully removed. Routes are unaffected.
|
||||
const streamlined = true;
|
||||
// Conference Room Chat flag (PAP-136/PAP-137): the Conference Room nav item
|
||||
// is a new surface, hidden entirely while the flag is off (same no-flash
|
||||
// pattern as showWorkspacesLink above).
|
||||
@@ -186,7 +188,10 @@ export function Sidebar() {
|
||||
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
||||
) : null}
|
||||
{streamlined ? (
|
||||
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
|
||||
<>
|
||||
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
|
||||
<SidebarStarredProjects />
|
||||
</>
|
||||
) : null}
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["sidebar"]}
|
||||
@@ -210,6 +215,7 @@ export function Sidebar() {
|
||||
|
||||
<SidebarSection label="Company">
|
||||
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
||||
<SidebarNavItem to="/timeline" label="Timeline" icon={GanttChartSquare} />
|
||||
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
||||
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
||||
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { MouseEvent } from "react";
|
||||
import { Loader2, Star } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export interface StarToggleProps {
|
||||
/** Whether the resource is currently starred (post-optimistic value). */
|
||||
starred: boolean;
|
||||
/** Human-readable resource name, used for the accessible label. */
|
||||
resourceName: string;
|
||||
/** Optimistic mutation in flight — shows a spinner and blocks input. */
|
||||
pending?: boolean;
|
||||
/** Last mutation failed — surface a retry affordance (red star). */
|
||||
error?: boolean;
|
||||
/**
|
||||
* "row" — quiet icon-only control for sidebar and browse-list rows.
|
||||
* "button" — labelled Star/Starred button for detail headers.
|
||||
*/
|
||||
size?: "row" | "button";
|
||||
/** Called with the desired next starred value. */
|
||||
onToggle: (nextStarred: boolean) => void;
|
||||
/**
|
||||
* Row variant only: keep the control hidden at rest even when starred, so it
|
||||
* only appears on hover/focus. Sidebar rows are "intentionally quiet"; browse
|
||||
* rows keep the starred control visible.
|
||||
*/
|
||||
quiet?: boolean;
|
||||
/** Extra classes for the control itself. */
|
||||
className?: string;
|
||||
/**
|
||||
* Row variant only: classes that control at-rest visibility when the resource
|
||||
* is not starred (e.g. reveal on hover/focus). Ignored when starred (a starred
|
||||
* control is always visible) or on the button variant. Defaults to the shared
|
||||
* unnamed-`group` reveal used by browse rows; sidebar passes a named-group
|
||||
* variant so it reveals with `group/project` / `group/agent`.
|
||||
*/
|
||||
revealClassName?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_ROW_REVEAL =
|
||||
"opacity-100 sm:opacity-0 sm:transition-opacity sm:group-hover:opacity-100 sm:group-focus-within:opacity-100";
|
||||
|
||||
export function StarToggle({
|
||||
starred,
|
||||
resourceName,
|
||||
pending = false,
|
||||
error = false,
|
||||
size = "row",
|
||||
onToggle,
|
||||
quiet = false,
|
||||
className,
|
||||
revealClassName,
|
||||
}: StarToggleProps) {
|
||||
const ariaLabel = starred ? `Unstar ${resourceName}` : `Star ${resourceName}`;
|
||||
const Icon = pending ? Loader2 : Star;
|
||||
|
||||
function handleClick(event: MouseEvent<HTMLButtonElement>) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (pending) return;
|
||||
// On error, retry the last intent (toggle toward the desired end state).
|
||||
onToggle(!starred);
|
||||
}
|
||||
|
||||
if (size === "button") {
|
||||
const label = pending ? "Saving..." : error ? "Retry star" : starred ? "Starred" : "Star";
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label={ariaLabel}
|
||||
aria-pressed={starred}
|
||||
aria-busy={pending ? "true" : undefined}
|
||||
disabled={pending}
|
||||
onClick={handleClick}
|
||||
title={error ? "Couldn't save — retry" : undefined}
|
||||
className={cn(
|
||||
error
|
||||
? "text-red-500 hover:text-red-500"
|
||||
: starred
|
||||
? "text-amber-600 dark:text-amber-500"
|
||||
: undefined,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
pending && "motion-safe:animate-spin",
|
||||
!pending && starred && !error && "fill-amber-500 text-amber-500",
|
||||
!pending && error && "text-red-500",
|
||||
)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Row variant: a starred (or errored) control is always visible; an unstarred
|
||||
// one is quiet and revealed on hover/focus so the nav stays calm. When `quiet`
|
||||
// (sidebar), even a starred control hides at rest and reveals on hover/focus.
|
||||
const visible = error || pending || (starred && !quiet);
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
aria-label={ariaLabel}
|
||||
aria-pressed={starred}
|
||||
aria-busy={pending ? "true" : undefined}
|
||||
disabled={pending}
|
||||
onClick={handleClick}
|
||||
title={error ? "Couldn't save — retry" : undefined}
|
||||
className={cn(
|
||||
"h-6 w-6 shrink-0",
|
||||
visible ? "opacity-100" : revealClassName ?? DEFAULT_ROW_REVEAL,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
pending && "motion-safe:animate-spin",
|
||||
!pending && error && "text-red-500",
|
||||
!pending && !error && starred && "fill-amber-500 text-amber-500",
|
||||
!pending && !error && !starred && "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { WorkTimelineResult } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WorkTimelineChart } from "./WorkTimelineChart";
|
||||
import { computeLayout } from "@/lib/timeline/layout";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useLocation: () => ({ pathname: "/PAP/timeline" }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderChart(
|
||||
data: WorkTimelineResult,
|
||||
props: Partial<ComponentProps<typeof WorkTimelineChart>> = {},
|
||||
) {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<WorkTimelineChart
|
||||
data={data}
|
||||
zoom="hour"
|
||||
nowMs={new Date("2026-07-02T12:00:00.000Z").getTime()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function timelineSample(): WorkTimelineResult {
|
||||
return {
|
||||
actors: [
|
||||
{ id: "agent:codex", type: "agent", name: "CodexCoder" },
|
||||
{ id: "agent:qa", type: "agent", name: "QA" },
|
||||
],
|
||||
spans: [
|
||||
{
|
||||
actorId: "agent:codex",
|
||||
laneHint: null,
|
||||
runId: "run-1",
|
||||
issueId: "issue-1",
|
||||
issueIdentifier: "PAP-12443",
|
||||
issueTitle: "Work Timeline sticky gutter",
|
||||
start: "2026-07-02T09:00:00.000Z",
|
||||
end: "2026-07-02T10:00:00.000Z",
|
||||
status: "completed",
|
||||
retryOfRunId: null,
|
||||
},
|
||||
{
|
||||
actorId: "agent:qa",
|
||||
laneHint: null,
|
||||
runId: "run-2",
|
||||
issueId: "issue-2",
|
||||
issueIdentifier: "PAP-12426",
|
||||
issueTitle: "QA validation",
|
||||
start: "2026-07-02T11:00:00.000Z",
|
||||
end: "2026-07-02T11:30:00.000Z",
|
||||
status: "completed",
|
||||
retryOfRunId: null,
|
||||
},
|
||||
],
|
||||
events: [],
|
||||
edges: [],
|
||||
pagination: { limit: 200, offset: 0, totalIssues: 2, hasMore: false },
|
||||
window: {
|
||||
from: "2026-07-02T00:00:00.000Z",
|
||||
to: "2026-07-03T00:00:00.000Z",
|
||||
capped: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkTimelineChart", () => {
|
||||
it("renders date-aware AM/PM labels on the header axis", () => {
|
||||
renderChart(timelineSample());
|
||||
|
||||
const timeAxis = container.querySelector<HTMLElement>("[data-testid='work-timeline-time-axis']");
|
||||
|
||||
expect(timeAxis?.textContent).toContain("Jul 2");
|
||||
expect(timeAxis?.textContent).toContain("AM");
|
||||
expect(timeAxis?.textContent).not.toContain("09:00");
|
||||
});
|
||||
|
||||
it("freezes the time axis over vertical scrolling while preserving horizontal alignment", async () => {
|
||||
renderChart(timelineSample());
|
||||
|
||||
const scroller = container.querySelector<HTMLElement>("[data-testid='work-timeline-scroll']")!;
|
||||
const timeAxis = container.querySelector<HTMLElement>("[data-testid='work-timeline-time-axis']")!;
|
||||
const axisSvg = timeAxis.querySelector<SVGSVGElement>("svg")!;
|
||||
|
||||
expect(timeAxis.getAttribute("class")).toContain("absolute");
|
||||
expect(timeAxis.getAttribute("class")).toContain("top-0");
|
||||
expect(timeAxis.style.height).toBe("32px");
|
||||
expect(axisSvg.style.transform).toBe("translateX(0px)");
|
||||
|
||||
flushSync(() => {
|
||||
Object.defineProperty(scroller, "scrollTop", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scroller, "scrollLeft", { configurable: true, value: 240 });
|
||||
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(timeAxis.textContent).toContain("Jul 2");
|
||||
expect(axisSvg.style.transform).toBe("translateX(-240px)");
|
||||
});
|
||||
|
||||
it("renders actor labels in a sticky gutter outside the horizontally scrolling SVG", () => {
|
||||
renderChart(timelineSample());
|
||||
|
||||
const scroller = container.querySelector<HTMLElement>("[data-testid='work-timeline-scroll']");
|
||||
const gutter = container.querySelector<SVGSVGElement>("[data-testid='work-timeline-actor-gutter']");
|
||||
const chartSvg = container.querySelector<SVGSVGElement>("svg.absolute");
|
||||
|
||||
expect(scroller).not.toBeNull();
|
||||
expect(gutter).not.toBeNull();
|
||||
expect(chartSvg).not.toBeNull();
|
||||
expect(gutter?.getAttribute("class")).toContain("sticky");
|
||||
expect(gutter?.getAttribute("class")).toContain("left-0");
|
||||
expect(gutter?.getAttribute("class")).not.toContain("top-0");
|
||||
expect(gutter?.getAttribute("width")).toBe("176");
|
||||
expect(chartSvg?.getAttribute("width")).not.toBe(gutter?.getAttribute("width"));
|
||||
expect(gutter?.textContent).toContain("CodexCoder");
|
||||
|
||||
flushSync(() => {
|
||||
scroller!.scrollLeft = 10_000;
|
||||
scroller!.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector("[data-testid='work-timeline-actor-gutter']")?.textContent).toContain("CodexCoder");
|
||||
});
|
||||
|
||||
it("does not render created diamonds or comment bubbles from instant events", () => {
|
||||
const data = timelineSample();
|
||||
data.actors.push({ id: "user:dotta", type: "user", name: "Dotta" });
|
||||
data.events = [
|
||||
{ actorId: "user:dotta", kind: "created", issueId: "issue-1", at: "2026-07-02T08:30:00.000Z" },
|
||||
{ actorId: "user:dotta", kind: "commented", issueId: "issue-2", at: "2026-07-02T09:15:00.000Z" },
|
||||
{ actorId: "user:dotta", kind: "approved", issueId: "issue-1", at: "2026-07-02T10:05:00.000Z" },
|
||||
];
|
||||
renderChart(data);
|
||||
|
||||
const gutter = container.querySelector<SVGSVGElement>("[data-testid='work-timeline-actor-gutter']");
|
||||
expect(gutter?.textContent).not.toContain("Dotta");
|
||||
expect(container.querySelectorAll("[data-testid='timeline-event-marker']")).toHaveLength(0);
|
||||
expect(container.querySelectorAll("[data-testid='timeline-comment-marker']")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps connectors hidden until hover, renders them orthogonally, and highlights the connected graph", async () => {
|
||||
const data = timelineSample();
|
||||
data.actors.push({ id: "agent:cto", type: "agent", name: "CTO" });
|
||||
data.spans.push(
|
||||
{
|
||||
actorId: "agent:cto",
|
||||
laneHint: null,
|
||||
runId: "run-3",
|
||||
issueId: "issue-3",
|
||||
issueIdentifier: "PAP-12427",
|
||||
issueTitle: "Follow-up validation",
|
||||
start: "2026-07-02T11:45:00.000Z",
|
||||
end: "2026-07-02T12:00:00.000Z",
|
||||
status: "completed",
|
||||
retryOfRunId: null,
|
||||
},
|
||||
{
|
||||
actorId: "agent:codex",
|
||||
laneHint: null,
|
||||
runId: "run-4",
|
||||
issueId: "issue-4",
|
||||
issueIdentifier: "PAP-12428",
|
||||
issueTitle: "Unrelated work",
|
||||
start: "2026-07-02T13:00:00.000Z",
|
||||
end: "2026-07-02T14:00:00.000Z",
|
||||
status: "completed",
|
||||
retryOfRunId: null,
|
||||
},
|
||||
);
|
||||
data.edges = [
|
||||
{
|
||||
fromActorId: "agent:codex",
|
||||
toActorId: "agent:qa",
|
||||
issueId: "issue-2",
|
||||
at: "2026-07-02T10:45:00.000Z",
|
||||
kind: "delegation",
|
||||
},
|
||||
{
|
||||
fromActorId: "agent:qa",
|
||||
toActorId: "agent:cto",
|
||||
issueId: "issue-3",
|
||||
at: "2026-07-02T11:35:00.000Z",
|
||||
kind: "delegation",
|
||||
},
|
||||
];
|
||||
renderChart(data);
|
||||
|
||||
expect(container.querySelectorAll("[data-testid='timeline-connector']")).toHaveLength(0);
|
||||
|
||||
const hovered = container.querySelector<SVGGElement>("[data-run-id='run-2']")!;
|
||||
flushSync(() => {
|
||||
hovered.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, clientX: 100, clientY: 100 }));
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(container.querySelectorAll("[data-testid='timeline-connector']")).toHaveLength(2);
|
||||
const connectorStrokePaths = Array.from(
|
||||
container.querySelectorAll<SVGPathElement>("[data-testid='timeline-connector'] path[fill='none']"),
|
||||
);
|
||||
expect(connectorStrokePaths.map((path) => path.getAttribute("d"))).toEqual(
|
||||
expect.arrayContaining([expect.stringMatching(/ V.+ H/)]),
|
||||
);
|
||||
expect(container.querySelector("[data-run-id='run-1']")?.getAttribute("data-connected-state")).toBe("connected");
|
||||
expect(container.querySelector("[data-run-id='run-2']")?.getAttribute("data-connected-state")).toBe("connected");
|
||||
expect(container.querySelector("[data-run-id='run-3']")?.getAttribute("data-connected-state")).toBe("connected");
|
||||
expect(container.querySelector("[data-run-id='run-4']")?.getAttribute("data-connected-state")).toBe("faded");
|
||||
|
||||
const layout = computeLayout(data, {
|
||||
gutter: 176,
|
||||
rowH: 34,
|
||||
barH: 15,
|
||||
laneGap: 4,
|
||||
pxPerMinute: 8,
|
||||
nowMs: new Date("2026-07-02T12:00:00.000Z").getTime(),
|
||||
});
|
||||
expect(layout.connectors).toMatchObject([
|
||||
{ sourceRunId: "run-1", targetRunId: "run-2", dashed: false },
|
||||
{ sourceRunId: "run-2", targetRunId: "run-3", dashed: false },
|
||||
]);
|
||||
const bars = new Map(layout.rows.flatMap((row) => row.bars.map((bar) => [bar.span.runId, bar])));
|
||||
expect(layout.connectors[0].x1).toBe(bars.get("run-1")?.x2);
|
||||
expect(layout.connectors[0].x2).toBe(bars.get("run-2")?.x1);
|
||||
});
|
||||
|
||||
it("renders kickoff chips for human users but not delegating agents", () => {
|
||||
const data = timelineSample();
|
||||
data.actors.push({ id: "user:dotta", type: "user", name: "Dotta" });
|
||||
data.edges = [
|
||||
{
|
||||
fromActorId: "user:dotta",
|
||||
toActorId: "agent:codex",
|
||||
issueId: "issue-1",
|
||||
at: "2026-07-02T08:45:00.000Z",
|
||||
kind: "delegation",
|
||||
},
|
||||
{
|
||||
fromActorId: "agent:codex",
|
||||
toActorId: "agent:qa",
|
||||
issueId: "issue-2",
|
||||
at: "2026-07-02T10:45:00.000Z",
|
||||
kind: "delegation",
|
||||
},
|
||||
];
|
||||
|
||||
renderChart(data);
|
||||
|
||||
const kickoffChips = container.querySelectorAll("[data-testid='timeline-kickoff-chip']");
|
||||
expect(kickoffChips).toHaveLength(1);
|
||||
expect(kickoffChips[0].textContent).toContain("DO");
|
||||
});
|
||||
|
||||
it("reserves normal wheel input for panning and uses modifier-wheel for continuous zoom", () => {
|
||||
const onZoomScaleChange = vi.fn();
|
||||
renderChart(timelineSample(), { onZoomScaleChange });
|
||||
|
||||
const scroller = container.querySelector<HTMLElement>("[data-testid='work-timeline-scroll']")!;
|
||||
flushSync(() => {
|
||||
scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: 80, bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(onZoomScaleChange).not.toHaveBeenCalled();
|
||||
|
||||
flushSync(() => {
|
||||
scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: 80, ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(onZoomScaleChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("opens task bars in a new company-prefixed window", () => {
|
||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
renderChart(timelineSample());
|
||||
|
||||
const bar = container.querySelector<SVGGElement>("[data-run-id='run-1']")!;
|
||||
flushSync(() => {
|
||||
bar.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(open).toHaveBeenCalledWith("/PAP/issues/issue-1", "_blank", "noopener,noreferrer");
|
||||
});
|
||||
|
||||
it("lets minimap edge handles resize the visible range and update zoom", () => {
|
||||
const onZoomScaleChange = vi.fn();
|
||||
renderChart(timelineSample(), { onZoomScaleChange });
|
||||
|
||||
const rightHandle = container.querySelector<SVGRectElement>("[data-testid='timeline-minimap-right-handle']")!;
|
||||
const minimap = rightHandle.ownerSVGElement!;
|
||||
vi.spyOn(minimap, "getBoundingClientRect").mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 900,
|
||||
bottom: 54,
|
||||
width: 900,
|
||||
height: 54,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
flushSync(() => {
|
||||
rightHandle.dispatchEvent(new MouseEvent("mousedown", { clientX: 300, bubbles: true, cancelable: true }));
|
||||
document.dispatchEvent(new MouseEvent("mousemove", { clientX: 520, bubbles: true, cancelable: true }));
|
||||
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onZoomScaleChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans up chart drag listeners when unmounted mid-drag", () => {
|
||||
const add = vi.spyOn(document, "addEventListener");
|
||||
const remove = vi.spyOn(document, "removeEventListener");
|
||||
renderChart(timelineSample(), { onZoomScaleChange: vi.fn() });
|
||||
|
||||
const chartSvg = container.querySelector<SVGSVGElement>("svg.absolute")!;
|
||||
vi.spyOn(chartSvg, "getBoundingClientRect").mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 1000,
|
||||
bottom: 400,
|
||||
width: 1000,
|
||||
height: 400,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
flushSync(() => {
|
||||
chartSvg.dispatchEvent(new MouseEvent("mousedown", { clientX: 260, bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(add).toHaveBeenCalledWith("mousemove", expect.any(Function));
|
||||
expect(add).toHaveBeenCalledWith("mouseup", expect.any(Function));
|
||||
|
||||
flushSync(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
|
||||
expect(remove).toHaveBeenCalledWith("mousemove", expect.any(Function));
|
||||
expect(remove).toHaveBeenCalledWith("mouseup", expect.any(Function));
|
||||
});
|
||||
|
||||
it("cleans up minimap drag listeners when unmounted mid-drag", () => {
|
||||
const add = vi.spyOn(document, "addEventListener");
|
||||
const remove = vi.spyOn(document, "removeEventListener");
|
||||
renderChart(timelineSample(), { onZoomScaleChange: vi.fn() });
|
||||
|
||||
const rightHandle = container.querySelector<SVGRectElement>("[data-testid='timeline-minimap-right-handle']")!;
|
||||
const minimap = rightHandle.ownerSVGElement!;
|
||||
vi.spyOn(minimap, "getBoundingClientRect").mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 900,
|
||||
bottom: 54,
|
||||
width: 900,
|
||||
height: 54,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
flushSync(() => {
|
||||
rightHandle.dispatchEvent(new MouseEvent("mousedown", { clientX: 300, bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(add).toHaveBeenCalledWith("mousemove", expect.any(Function));
|
||||
expect(add).toHaveBeenCalledWith("mouseup", expect.any(Function));
|
||||
|
||||
flushSync(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
|
||||
expect(remove).toHaveBeenCalledWith("mousemove", expect.any(Function));
|
||||
expect(remove).toHaveBeenCalledWith("mouseup", expect.any(Function));
|
||||
});
|
||||
|
||||
it("keeps the default now timestamp stable across rerenders", () => {
|
||||
const now = new Date("2026-07-02T12:00:00.000Z").getTime();
|
||||
const later = new Date("2026-07-02T13:00:00.000Z").getTime();
|
||||
let currentNow = now;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => currentNow);
|
||||
const data = timelineSample();
|
||||
data.spans[0] = {
|
||||
...data.spans[0],
|
||||
end: null,
|
||||
status: "running",
|
||||
};
|
||||
|
||||
renderChart(data, { nowMs: undefined });
|
||||
const initialWidth = container
|
||||
.querySelector<SVGRectElement>("[data-run-id='run-1'] rect")
|
||||
?.getAttribute("width");
|
||||
|
||||
currentNow = later;
|
||||
renderChart(data, { nowMs: undefined });
|
||||
|
||||
expect(container.querySelector<SVGRectElement>("[data-run-id='run-1'] rect")?.getAttribute("width")).toBe(initialWidth);
|
||||
});
|
||||
|
||||
it("lets dragging the chart grid select a time range to zoom into", () => {
|
||||
const onZoomScaleChange = vi.fn();
|
||||
renderChart(timelineSample(), { onZoomScaleChange });
|
||||
|
||||
const chartSvg = container.querySelector<SVGSVGElement>("svg.absolute")!;
|
||||
const width = Number(chartSvg.getAttribute("width") ?? "1000");
|
||||
const height = Number(chartSvg.getAttribute("height") ?? "400");
|
||||
vi.spyOn(chartSvg, "getBoundingClientRect").mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: width,
|
||||
bottom: height,
|
||||
width,
|
||||
height,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
flushSync(() => {
|
||||
chartSvg.dispatchEvent(new MouseEvent("mousedown", { clientX: 260, bubbles: true, cancelable: true }));
|
||||
document.dispatchEvent(new MouseEvent("mousemove", { clientX: 520, bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector("[data-testid='timeline-drag-selection']")).not.toBeNull();
|
||||
|
||||
flushSync(() => {
|
||||
document.dispatchEvent(new MouseEvent("mouseup", { clientX: 520, bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(onZoomScaleChange).toHaveBeenCalled();
|
||||
expect(container.querySelector("[data-testid='timeline-drag-selection']")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,902 @@
|
||||
/**
|
||||
* Work Timeline — custom-SVG Gantt (board-locked Direction C, PAP-12422).
|
||||
*
|
||||
* Renders actor rows with concurrency sub-lanes, run bars (no issue IDs on the
|
||||
* bar — identity is the thin left colour tab; truncated title shows on hover),
|
||||
* human kickoff chips at each bar's leading edge, straight
|
||||
* hover-revealed agent→agent delegation connectors (dashed for retries), an
|
||||
* in-progress fade to "now", a hover tooltip, and a full-window mini-map with a
|
||||
* draggable brush.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation } from "@/lib/router";
|
||||
import type { WorkTimelineActor, WorkTimelineResult } from "@paperclipai/shared";
|
||||
import { applyCompanyPrefix, extractCompanyPrefixFromPath } from "@/lib/company-routes";
|
||||
import {
|
||||
AXIS_H,
|
||||
actorType,
|
||||
barColor,
|
||||
chooseTickStepMs,
|
||||
computeLayout,
|
||||
formatDuration,
|
||||
isCancelledStatus,
|
||||
shortLabel,
|
||||
TIMELINE_COLORS,
|
||||
type LayoutOptions,
|
||||
type PositionedBar,
|
||||
} from "@/lib/timeline/layout";
|
||||
|
||||
export type ZoomLevel = "hour" | "day" | "week";
|
||||
|
||||
const ZOOM_DURATION_MIN: Record<ZoomLevel, number> = {
|
||||
hour: 60,
|
||||
day: 24 * 60,
|
||||
week: 7 * 24 * 60,
|
||||
};
|
||||
const MIN_PX_PER_MIN = 0.08;
|
||||
const MAX_PX_PER_MIN = 12;
|
||||
const DEFAULT_VIEWPORT_W = 960;
|
||||
const MIN_MINIMAP_SELECTION_MS = 15 * 60 * 1000;
|
||||
|
||||
function plotViewportWidth(viewportWidth: number): number {
|
||||
return Math.max(240, viewportWidth - GEOM.gutter - 24);
|
||||
}
|
||||
|
||||
export function zoomScaleForLevel(level: ZoomLevel, viewportWidth = DEFAULT_VIEWPORT_W): number {
|
||||
return clampZoomScale(plotViewportWidth(viewportWidth) / ZOOM_DURATION_MIN[level]);
|
||||
}
|
||||
|
||||
export function nearestZoomForScale(pxPerMinute: number, viewportWidth = DEFAULT_VIEWPORT_W): ZoomLevel {
|
||||
return (Object.entries(ZOOM_DURATION_MIN) as [ZoomLevel, number][]).reduce<ZoomLevel>((best, [level]) => (
|
||||
Math.abs(zoomScaleForLevel(level, viewportWidth) - pxPerMinute)
|
||||
< Math.abs(zoomScaleForLevel(best, viewportWidth) - pxPerMinute)
|
||||
? level
|
||||
: best
|
||||
), "day");
|
||||
}
|
||||
|
||||
export function clampZoomScale(pxPerMinute: number): number {
|
||||
return Math.min(MAX_PX_PER_MIN, Math.max(MIN_PX_PER_MIN, pxPerMinute));
|
||||
}
|
||||
|
||||
/** Pick an initial zoom whose plotted width comfortably fills a typical viewport. */
|
||||
export function defaultZoomForWindow(fromMs: number, toMs: number): ZoomLevel {
|
||||
const hours = (toMs - fromMs) / 3_600_000;
|
||||
if (hours <= 4) return "hour";
|
||||
if (hours <= 48) return "day";
|
||||
return "week";
|
||||
}
|
||||
|
||||
const GEOM: Omit<LayoutOptions, "pxPerMinute" | "nowMs"> = {
|
||||
gutter: 176,
|
||||
rowH: 34,
|
||||
barH: 15,
|
||||
laneGap: 4,
|
||||
};
|
||||
const AVATAR_R = 11;
|
||||
const CHIP_R = 9;
|
||||
|
||||
interface TooltipState {
|
||||
x: number;
|
||||
y: number;
|
||||
bar: PositionedBar;
|
||||
connectorHint: string | null;
|
||||
}
|
||||
|
||||
interface DragSelectionState {
|
||||
anchorX: number;
|
||||
currentX: number;
|
||||
}
|
||||
|
||||
function fmtClock(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const hasMinutes = d.getMinutes() !== 0;
|
||||
return d.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: hasMinutes ? "2-digit" : undefined,
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
|
||||
function fmtTick(ms: number, stepMs: number): string {
|
||||
const d = new Date(ms);
|
||||
const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
if (stepMs >= 24 * 60 * 60 * 1000) {
|
||||
return date;
|
||||
}
|
||||
return `${date}, ${fmtClock(ms)}`;
|
||||
}
|
||||
|
||||
export function formatVisibleDurationMinutes(minutes: number): string {
|
||||
const rounded = Math.max(1, Math.round(minutes));
|
||||
if (rounded >= 7 * 24 * 60 && rounded % (7 * 24 * 60) === 0) {
|
||||
const weeks = rounded / (7 * 24 * 60);
|
||||
return `${weeks} week${weeks === 1 ? "" : "s"} visible`;
|
||||
}
|
||||
if (rounded >= 24 * 60 && rounded % (24 * 60) === 0) {
|
||||
const days = rounded / (24 * 60);
|
||||
return `${days} day${days === 1 ? "" : "s"} visible`;
|
||||
}
|
||||
if (rounded >= 24 * 60) {
|
||||
const days = Math.floor(rounded / (24 * 60));
|
||||
const hours = Math.round((rounded % (24 * 60)) / 60);
|
||||
return `${days}d${hours > 0 ? ` ${hours}h` : ""} visible`;
|
||||
}
|
||||
if (rounded >= 60 && rounded % 60 === 0) {
|
||||
const hours = rounded / 60;
|
||||
return `${hours} hour${hours === 1 ? "" : "s"} visible`;
|
||||
}
|
||||
if (rounded >= 60) return `${Math.floor(rounded / 60)}h ${rounded % 60}m visible`;
|
||||
return `${rounded} minutes visible`;
|
||||
}
|
||||
|
||||
function truncate(text: string, n = 42): string {
|
||||
return text.length > n ? `${text.slice(0, n - 1)}…` : text;
|
||||
}
|
||||
|
||||
/** An SVG avatar glyph: square for humans, dashed circle for system, circle for agents. */
|
||||
function AvatarGlyph({
|
||||
cx,
|
||||
cy,
|
||||
r,
|
||||
label,
|
||||
type,
|
||||
}: {
|
||||
cx: number;
|
||||
cy: number;
|
||||
r: number;
|
||||
label: string;
|
||||
type: string;
|
||||
}) {
|
||||
const stroke = "var(--color-foreground)";
|
||||
const fill = type === "system" ? "var(--color-muted)" : "var(--color-card)";
|
||||
return (
|
||||
<g>
|
||||
{type === "user" ? (
|
||||
<rect x={cx - r} y={cy - r} width={2 * r} height={2 * r} rx={3} fill={fill} stroke={stroke} strokeWidth={1.5} />
|
||||
) : (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r}
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray={type === "system" ? "3 2" : undefined}
|
||||
/>
|
||||
)}
|
||||
<text x={cx} y={cy + 3.4} fontSize={r > 10 ? 9 : 8} textAnchor="middle" fill={stroke}>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WorkTimelineChartProps {
|
||||
data: WorkTimelineResult;
|
||||
zoom: ZoomLevel;
|
||||
zoomScale?: number;
|
||||
onZoomScaleChange?: (nextScale: number, nextZoom: ZoomLevel) => void;
|
||||
onVisibleRangeLabelChange?: (label: string) => void;
|
||||
/** override "now" (tests / stories); defaults to Date.now(). */
|
||||
nowMs?: number;
|
||||
}
|
||||
|
||||
export function WorkTimelineChart({
|
||||
data,
|
||||
zoom,
|
||||
zoomScale,
|
||||
onZoomScaleChange,
|
||||
onVisibleRangeLabelChange,
|
||||
nowMs,
|
||||
}: WorkTimelineChartProps) {
|
||||
const location = useLocation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const initialWindowKeyRef = useRef<string | null>(null);
|
||||
const centerMsRef = useRef<number | null>(null);
|
||||
const defaultNowRef = useRef<number | null>(null);
|
||||
const documentDragCleanupRef = useRef<(() => void) | null>(null);
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
const [hoveredRunId, setHoveredRunId] = useState<string | null>(null);
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
const [viewportW, setViewportW] = useState(0);
|
||||
const [dragSelection, setDragSelection] = useState<DragSelectionState | null>(null);
|
||||
|
||||
const clearDocumentDrag = () => {
|
||||
documentDragCleanupRef.current?.();
|
||||
documentDragCleanupRef.current = null;
|
||||
};
|
||||
|
||||
const setDocumentDrag = (move: (event: MouseEvent) => void, up: (event: MouseEvent) => void) => {
|
||||
clearDocumentDrag();
|
||||
const handleUp = (event: MouseEvent) => {
|
||||
clearDocumentDrag();
|
||||
up(event);
|
||||
};
|
||||
document.addEventListener("mousemove", move);
|
||||
document.addEventListener("mouseup", handleUp);
|
||||
documentDragCleanupRef.current = () => {
|
||||
document.removeEventListener("mousemove", move);
|
||||
document.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => () => clearDocumentDrag(), []);
|
||||
|
||||
if (defaultNowRef.current == null) defaultNowRef.current = Date.now();
|
||||
const now = nowMs ?? defaultNowRef.current;
|
||||
const pxPerMinute = zoomScale ?? zoomScaleForLevel(zoom, viewportW || DEFAULT_VIEWPORT_W);
|
||||
const layout = useMemo(
|
||||
() => computeLayout(data, { ...GEOM, pxPerMinute, nowMs: now }),
|
||||
[data, pxPerMinute, now],
|
||||
);
|
||||
const connectedRunIds = useMemo(() => {
|
||||
if (!hoveredRunId) return null;
|
||||
const connected = new Set([hoveredRunId]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const c of layout.connectors) {
|
||||
if (connected.has(c.sourceRunId) && !connected.has(c.targetRunId)) {
|
||||
connected.add(c.targetRunId);
|
||||
changed = true;
|
||||
}
|
||||
if (connected.has(c.targetRunId) && !connected.has(c.sourceRunId)) {
|
||||
connected.add(c.sourceRunId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return connected;
|
||||
}, [hoveredRunId, layout.connectors]);
|
||||
const visibleConnectors = useMemo(
|
||||
() =>
|
||||
connectedRunIds
|
||||
? layout.connectors.filter((c) => connectedRunIds.has(c.sourceRunId) && connectedRunIds.has(c.targetRunId))
|
||||
: [],
|
||||
[connectedRunIds, layout.connectors],
|
||||
);
|
||||
const companyPrefix = extractCompanyPrefixFromPath(location.pathname);
|
||||
|
||||
const timeToScrollLeft = (ms: number, viewportWidth: number) => {
|
||||
const x = layout.gutter + ((ms - layout.fromMs) / 60000) * layout.pxPerMinute;
|
||||
return Math.max(0, Math.min(layout.width - viewportWidth, x - viewportWidth / 2));
|
||||
};
|
||||
|
||||
const scrollCenterMs = (el: HTMLDivElement) => {
|
||||
const centerX = el.scrollLeft + el.clientWidth / 2;
|
||||
return layout.fromMs + ((centerX - layout.gutter) / layout.pxPerMinute) * 60000;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const nextViewportW = el.clientWidth;
|
||||
if (nextViewportW > 0 && nextViewportW !== viewportW) setViewportW(nextViewportW);
|
||||
|
||||
const windowKey = `${data.window.from}:${data.window.to}`;
|
||||
if (initialWindowKeyRef.current !== windowKey) {
|
||||
initialWindowKeyRef.current = windowKey;
|
||||
const latest = Math.max(0, layout.width - nextViewportW);
|
||||
el.scrollLeft = latest;
|
||||
setScrollLeft(latest);
|
||||
centerMsRef.current = scrollCenterMs(el);
|
||||
return;
|
||||
}
|
||||
|
||||
if (centerMsRef.current != null) {
|
||||
const next = timeToScrollLeft(centerMsRef.current, nextViewportW);
|
||||
el.scrollLeft = next;
|
||||
setScrollLeft(next);
|
||||
}
|
||||
}, [data.window.from, data.window.to, layout.fromMs, layout.gutter, layout.pxPerMinute, layout.toMs, layout.width, viewportW]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onVisibleRangeLabelChange) return;
|
||||
const effectiveViewportW = viewportW || DEFAULT_VIEWPORT_W;
|
||||
const minutes = plotViewportWidth(effectiveViewportW) / layout.pxPerMinute;
|
||||
onVisibleRangeLabelChange(formatVisibleDurationMinutes(minutes));
|
||||
}, [layout.pxPerMinute, onVisibleRangeLabelChange, viewportW]);
|
||||
|
||||
const stepMs = chooseTickStepMs(layout.pxPerMinute);
|
||||
const ticks: number[] = [];
|
||||
const startTick = Math.ceil(layout.fromMs / stepMs) * stepMs;
|
||||
for (let ms = startTick; ms <= layout.toMs; ms += stepMs) ticks.push(ms);
|
||||
|
||||
const openIssue = (issueId: string) => {
|
||||
const href = applyCompanyPrefix(`/issues/${encodeURIComponent(issueId)}`, companyPrefix);
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const updateVisibleRange = (fromMs: number, toMs: number) => {
|
||||
if (!onZoomScaleChange) return;
|
||||
const el = scrollRef.current;
|
||||
const boundedFrom = Math.max(layout.fromMs, Math.min(layout.toMs, fromMs));
|
||||
const boundedTo = Math.max(layout.fromMs, Math.min(layout.toMs, toMs));
|
||||
const startMs = Math.min(boundedFrom, boundedTo);
|
||||
const endMs = Math.max(boundedFrom, boundedTo);
|
||||
const durationMs = Math.max(MIN_MINIMAP_SELECTION_MS, endMs - startMs);
|
||||
const centerMs = startMs + durationMs / 2;
|
||||
const effectiveViewportW = el?.clientWidth || viewportW || DEFAULT_VIEWPORT_W;
|
||||
const nextScale = clampZoomScale(plotViewportWidth(effectiveViewportW) / (durationMs / 60000));
|
||||
centerMsRef.current = centerMs;
|
||||
onZoomScaleChange(nextScale, nearestZoomForScale(nextScale, effectiveViewportW));
|
||||
};
|
||||
|
||||
const svgXFromClientX = (clientX: number, el: SVGSVGElement) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width <= 0) return layout.gutter;
|
||||
const x = ((clientX - rect.left) / rect.width) * layout.width;
|
||||
return Math.max(layout.gutter, Math.min(layout.width - 40, x));
|
||||
};
|
||||
|
||||
const msFromSvgX = (x: number) => (
|
||||
layout.fromMs + ((x - layout.gutter) / layout.pxPerMinute) * 60000
|
||||
);
|
||||
|
||||
const handlePlotMouseDown = (event: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!onZoomScaleChange || event.button !== 0) return;
|
||||
const el = event.currentTarget;
|
||||
const startX = svgXFromClientX(event.clientX, el);
|
||||
event.preventDefault();
|
||||
setTooltip(null);
|
||||
setHoveredRunId(null);
|
||||
setDragSelection({ anchorX: startX, currentX: startX });
|
||||
|
||||
const move = (moveEvent: MouseEvent) => {
|
||||
setDragSelection((prev) => prev && {
|
||||
...prev,
|
||||
currentX: svgXFromClientX(moveEvent.clientX, el),
|
||||
});
|
||||
};
|
||||
const up = (upEvent: MouseEvent) => {
|
||||
const endX = svgXFromClientX(upEvent.clientX, el);
|
||||
setDragSelection(null);
|
||||
if (Math.abs(endX - startX) < 8) return;
|
||||
const fromMs = Math.min(msFromSvgX(startX), msFromSvgX(endX));
|
||||
const toMs = Math.max(msFromSvgX(startX), msFromSvgX(endX));
|
||||
updateVisibleRange(fromMs, toMs);
|
||||
};
|
||||
setDocumentDrag(move, up);
|
||||
};
|
||||
|
||||
const connectorHintForBar = (bar: PositionedBar): string | null => {
|
||||
const related = layout.connectors.filter((c) => c.sourceRunId === bar.span.runId || c.targetRunId === bar.span.runId);
|
||||
if (related.length === 0) return null;
|
||||
return related.some((c) => c.dashed)
|
||||
? "dashed handoff: retry or changes requested"
|
||||
: "solid handoff: delegation or assignment";
|
||||
};
|
||||
|
||||
const showTooltip = (evt: React.MouseEvent, bar: PositionedBar) => {
|
||||
setHoveredRunId(bar.span.runId);
|
||||
setTooltip({ x: evt.clientX, y: evt.clientY, bar, connectorHint: connectorHintForBar(bar) });
|
||||
};
|
||||
|
||||
const handleWheel = (evt: React.WheelEvent<HTMLDivElement>) => {
|
||||
if (!onZoomScaleChange || !(evt.ctrlKey || evt.metaKey || evt.altKey)) return;
|
||||
evt.preventDefault();
|
||||
const el = scrollRef.current;
|
||||
if (el) {
|
||||
centerMsRef.current = scrollCenterMs(el);
|
||||
}
|
||||
const nextScale = clampZoomScale(layout.pxPerMinute * Math.exp(-evt.deltaY * 0.001));
|
||||
onZoomScaleChange(nextScale, nearestZoomForScale(nextScale, el?.clientWidth ?? viewportW));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-h-[70vh] overflow-auto"
|
||||
data-testid="work-timeline-scroll"
|
||||
onScroll={(e) => {
|
||||
setScrollLeft(e.currentTarget.scrollLeft);
|
||||
centerMsRef.current = scrollCenterMs(e.currentTarget);
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<div className="relative" style={{ width: layout.width, height: layout.height }}>
|
||||
<ActorGutter rows={layout.rows} height={layout.height} />
|
||||
|
||||
<svg
|
||||
width={layout.width}
|
||||
height={layout.height}
|
||||
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
||||
className="absolute inset-0 block select-none"
|
||||
onMouseDown={handlePlotMouseDown}
|
||||
ref={(el) => {
|
||||
if (el && viewportW === 0 && scrollRef.current) setViewportW(scrollRef.current.clientWidth);
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="tl-fade" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="var(--color-foreground)" stopOpacity={0.28} />
|
||||
<stop offset="100%" stopColor="var(--color-foreground)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* row backgrounds */}
|
||||
{layout.rows.map((row, i) => (
|
||||
<rect
|
||||
key={`bg-${row.actor.id}`}
|
||||
x={0}
|
||||
y={row.y + AXIS_H}
|
||||
width={layout.width}
|
||||
height={row.h}
|
||||
fill={i % 2 ? "var(--color-muted)" : "transparent"}
|
||||
opacity={i % 2 ? 0.35 : 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* vertical gridlines */}
|
||||
{ticks.map((ms) => {
|
||||
const gx = layout.gutter + ((ms - layout.fromMs) / 60000) * layout.pxPerMinute;
|
||||
return (
|
||||
<g key={`tick-${ms}`}>
|
||||
<line x1={gx} y1={AXIS_H} x2={gx} y2={layout.height} stroke="var(--color-border)" strokeWidth={1} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* now line — teal "Signal" present marker */}
|
||||
{now >= layout.fromMs && now <= layout.toMs && (
|
||||
<line
|
||||
x1={layout.gutter + ((now - layout.fromMs) / 60000) * layout.pxPerMinute}
|
||||
y1={AXIS_H}
|
||||
x2={layout.gutter + ((now - layout.fromMs) / 60000) * layout.pxPerMinute}
|
||||
y2={layout.height}
|
||||
stroke={TIMELINE_COLORS.now}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="2 3"
|
||||
opacity={0.9}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* gutter divider + axis baseline */}
|
||||
<line x1={layout.gutter} y1={0} x2={layout.gutter} y2={layout.height} stroke="var(--color-foreground)" strokeWidth={1.5} />
|
||||
<line x1={0} y1={AXIS_H} x2={layout.width} y2={AXIS_H} stroke="var(--color-foreground)" strokeWidth={1.5} />
|
||||
|
||||
{/* connectors (behind bars): hover reveals the connected handoff graph. */}
|
||||
{visibleConnectors.map((c, i) => {
|
||||
const y1 = c.y1 + AXIS_H;
|
||||
const y2 = c.y2 + AXIS_H;
|
||||
const arrow =
|
||||
c.x2 >= c.x1
|
||||
? `M${c.x2},${y2} l-10,-5 l0,10 z`
|
||||
: `M${c.x2},${y2} l10,-5 l0,10 z`;
|
||||
return (
|
||||
<g key={`edge-${c.sourceRunId}-${c.targetRunId}-${i}`} data-testid="timeline-connector" opacity={0.86}>
|
||||
<path
|
||||
d={`M${c.x1},${y1} V${y2} H${c.x2}`}
|
||||
fill="none"
|
||||
stroke="var(--color-foreground)"
|
||||
strokeWidth={2.2}
|
||||
strokeDasharray={c.dashed ? "5 4" : undefined}
|
||||
/>
|
||||
<circle cx={c.x1} cy={y1} r={3.2} fill="var(--color-foreground)" />
|
||||
<path d={arrow} fill="var(--color-foreground)" />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* rows: gutter avatar/label, lane baselines, bars, human kickoff chips */}
|
||||
{layout.rows.map((row) => {
|
||||
const cy = row.y + AXIS_H + row.h / 2;
|
||||
return (
|
||||
<g key={`row-${row.actor.id}`}>
|
||||
<AvatarGlyph cx={26} cy={cy} r={AVATAR_R} label={shortLabel(row.actor.name)} type={row.actor.type} />
|
||||
<text x={26 + AVATAR_R + 10} y={cy - 2} fontSize={13} fill="var(--color-foreground)">
|
||||
{truncate(row.actor.name, 18)}
|
||||
</text>
|
||||
<text x={26 + AVATAR_R + 10} y={cy + 12} fontSize={11} fill="var(--color-muted-foreground)">
|
||||
{row.actor.type}
|
||||
</text>
|
||||
|
||||
{Array.from({ length: row.laneCount }).map((_, ln) => {
|
||||
const ly = row.y + AXIS_H + 6 + ln * (GEOM.barH + GEOM.laneGap) + GEOM.barH / 2;
|
||||
return (
|
||||
<line
|
||||
key={`lane-${row.actor.id}-${ln}`}
|
||||
x1={layout.gutter}
|
||||
y1={ly}
|
||||
x2={layout.width - 8}
|
||||
y2={ly}
|
||||
stroke="var(--color-border)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="2 4"
|
||||
opacity={0.6}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{row.bars.map((bar) => {
|
||||
const yTop = bar.yTop + AXIS_H;
|
||||
const w = bar.x2 - bar.x1;
|
||||
const cancelled = isCancelledStatus(bar.span.status);
|
||||
const color = barColor(bar);
|
||||
const connectedState =
|
||||
connectedRunIds == null ? "idle" : connectedRunIds.has(bar.span.runId) ? "connected" : "faded";
|
||||
const barOpacity =
|
||||
connectedState === "idle"
|
||||
? 0.88
|
||||
: connectedState === "connected"
|
||||
? 1
|
||||
: 0.22;
|
||||
return (
|
||||
<g key={bar.span.runId} opacity={connectedState === "faded" ? 0.42 : 1}>
|
||||
<g
|
||||
className="cursor-pointer"
|
||||
data-run-id={bar.span.runId}
|
||||
data-connected-state={connectedState}
|
||||
onMouseEnter={(e) => showTooltip(e, bar)}
|
||||
onMouseOver={(e) => showTooltip(e, bar)}
|
||||
onMouseMove={(e) => showTooltip(e, bar)}
|
||||
onMouseLeave={() => {
|
||||
setTooltip(null);
|
||||
setHoveredRunId(null);
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={() => openIssue(bar.span.issueId)}
|
||||
>
|
||||
{/* "Signal" encoding: fill = how the run started (delegated /
|
||||
automation); cancelled runs drop the fill and read as a
|
||||
hollow dashed bar. */}
|
||||
<rect
|
||||
x={bar.x1}
|
||||
y={yTop}
|
||||
width={w}
|
||||
height={bar.height}
|
||||
rx={3}
|
||||
fill={cancelled ? "transparent" : color}
|
||||
stroke={cancelled ? TIMELINE_COLORS.cancelled : "var(--color-foreground)"}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray={cancelled ? "4 3" : undefined}
|
||||
opacity={barOpacity}
|
||||
/>
|
||||
{/* in-progress fade to "now" */}
|
||||
{bar.running && !cancelled && w > 8 && (
|
||||
<rect x={bar.x2 - Math.min(w - 2, 26)} y={yTop + 1.5} width={Math.min(w - 2, 26)} height={bar.height - 3} fill="url(#tl-fade)" />
|
||||
)}
|
||||
</g>
|
||||
{bar.kickoff && actorType(bar.kickoff) === "user" && (
|
||||
<g className="pointer-events-none" data-testid="timeline-kickoff-chip">
|
||||
<AvatarGlyph
|
||||
cx={bar.x1}
|
||||
cy={yTop + bar.height / 2}
|
||||
r={CHIP_R}
|
||||
label={shortLabel((bar.kickoff as WorkTimelineActor).name)}
|
||||
type={actorType(bar.kickoff)}
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{dragSelection && (
|
||||
<rect
|
||||
data-testid="timeline-drag-selection"
|
||||
x={Math.min(dragSelection.anchorX, dragSelection.currentX)}
|
||||
y={AXIS_H}
|
||||
width={Math.abs(dragSelection.currentX - dragSelection.anchorX)}
|
||||
height={layout.height - AXIS_H}
|
||||
fill="var(--color-primary)"
|
||||
opacity={0.16}
|
||||
stroke="var(--color-primary)"
|
||||
strokeWidth={1.5}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TimeAxisOverlay layout={layout} ticks={ticks} stepMs={stepMs} scrollLeft={scrollLeft} />
|
||||
|
||||
<MiniMap
|
||||
layout={layout}
|
||||
scrollRef={scrollRef}
|
||||
viewportW={viewportW}
|
||||
scrollLeft={scrollLeft}
|
||||
onVisibleRangeChange={updateVisibleRange}
|
||||
/>
|
||||
|
||||
{tooltip && <Tooltip tooltip={tooltip} now={now} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActorGutter({ rows, height }: { rows: ReturnType<typeof computeLayout>["rows"]; height: number }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
data-testid="work-timeline-actor-gutter"
|
||||
width={GEOM.gutter}
|
||||
height={height}
|
||||
viewBox={`0 0 ${GEOM.gutter} ${height}`}
|
||||
className="sticky left-0 z-20 block bg-card"
|
||||
>
|
||||
<rect x={0} y={0} width={GEOM.gutter} height={height} fill="var(--color-card)" />
|
||||
{rows.map((row, i) => {
|
||||
const cy = row.y + AXIS_H + row.h / 2;
|
||||
return (
|
||||
<g key={`gutter-${row.actor.id}`}>
|
||||
<rect
|
||||
x={0}
|
||||
y={row.y + AXIS_H}
|
||||
width={GEOM.gutter}
|
||||
height={row.h}
|
||||
fill={i % 2 ? "var(--color-muted)" : "var(--color-card)"}
|
||||
opacity={i % 2 ? 0.35 : 1}
|
||||
/>
|
||||
<AvatarGlyph cx={26} cy={cy} r={AVATAR_R} label={shortLabel(row.actor.name)} type={row.actor.type} />
|
||||
<text x={26 + AVATAR_R + 10} y={cy - 2} fontSize={13} fill="var(--color-foreground)">
|
||||
{truncate(row.actor.name, 16)}
|
||||
</text>
|
||||
<text x={26 + AVATAR_R + 10} y={cy + 12} fontSize={11} fill="var(--color-muted-foreground)">
|
||||
{row.actor.type}
|
||||
</text>
|
||||
{/* "Signal" rail: run count + active time, right-aligned in the gutter. */}
|
||||
<text
|
||||
x={GEOM.gutter - 10}
|
||||
y={cy + 11}
|
||||
fontSize={10.5}
|
||||
textAnchor="end"
|
||||
fill="var(--color-muted-foreground)"
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{row.runCount}× · {formatDuration(0, row.activeMs)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<line x1={GEOM.gutter} y1={0} x2={GEOM.gutter} y2={height} stroke="var(--color-foreground)" strokeWidth={1.5} />
|
||||
<line x1={0} y1={AXIS_H} x2={GEOM.gutter} y2={AXIS_H} stroke="var(--color-foreground)" strokeWidth={1.5} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeAxisOverlay({
|
||||
layout,
|
||||
ticks,
|
||||
stepMs,
|
||||
scrollLeft,
|
||||
}: {
|
||||
layout: ReturnType<typeof computeLayout>;
|
||||
ticks: number[];
|
||||
stepMs: number;
|
||||
scrollLeft: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-testid="work-timeline-time-axis"
|
||||
className="pointer-events-none absolute left-0 right-0 top-0 z-30 overflow-hidden bg-card"
|
||||
style={{ height: AXIS_H }}
|
||||
>
|
||||
<svg
|
||||
width={layout.width}
|
||||
height={AXIS_H}
|
||||
viewBox={`0 0 ${layout.width} ${AXIS_H}`}
|
||||
className="block"
|
||||
style={{ transform: `translateX(${-scrollLeft}px)` }}
|
||||
>
|
||||
<rect x={0} y={0} width={layout.width} height={AXIS_H} fill="var(--color-card)" />
|
||||
{ticks.map((ms) => {
|
||||
const gx = layout.gutter + ((ms - layout.fromMs) / 60000) * layout.pxPerMinute;
|
||||
return (
|
||||
<g key={`axis-tick-${ms}`}>
|
||||
<line x1={gx} y1={AXIS_H - 7} x2={gx} y2={AXIS_H} stroke="var(--color-border)" strokeWidth={1} />
|
||||
<text x={gx + 3} y={19} fontSize={11} fill="var(--color-muted-foreground)">
|
||||
{fmtTick(ms, stepMs)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<line x1={0} y1={AXIS_H} x2={layout.width} y2={AXIS_H} stroke="var(--color-foreground)" strokeWidth={1.5} />
|
||||
</svg>
|
||||
<svg
|
||||
width={layout.gutter}
|
||||
height={AXIS_H}
|
||||
viewBox={`0 0 ${layout.gutter} ${AXIS_H}`}
|
||||
className="absolute left-0 top-0 block bg-card"
|
||||
>
|
||||
<rect x={0} y={0} width={layout.gutter} height={AXIS_H} fill="var(--color-card)" />
|
||||
<line x1={layout.gutter} y1={0} x2={layout.gutter} y2={AXIS_H} stroke="var(--color-foreground)" strokeWidth={1.5} />
|
||||
<line x1={0} y1={AXIS_H} x2={layout.gutter} y2={AXIS_H} stroke="var(--color-foreground)" strokeWidth={1.5} />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({ tooltip, now }: { tooltip: TooltipState; now: number }) {
|
||||
const { bar } = tooltip;
|
||||
const startMs = new Date(bar.span.start).getTime();
|
||||
const endMs = bar.span.end ? new Date(bar.span.end).getTime() : now;
|
||||
const title = bar.span.issueTitle ?? bar.span.issueIdentifier ?? "run";
|
||||
const left = Math.min(tooltip.x + 14, (typeof window !== "undefined" ? window.innerWidth : 1200) - 300);
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed z-50 max-w-[280px] rounded-md border border-foreground bg-card px-2.5 py-2 text-xs shadow-md"
|
||||
style={{ left, top: tooltip.y + 14 }}
|
||||
>
|
||||
<div className="text-[13px] font-medium text-foreground">{truncate(title)}</div>
|
||||
<div className="mt-0.5 text-muted-foreground">
|
||||
{fmtClock(startMs)}–{bar.span.end ? fmtClock(endMs) : "now"} · {formatDuration(startMs, endMs)} ·{" "}
|
||||
<span className="font-medium text-foreground">{bar.span.status}</span>
|
||||
</div>
|
||||
{bar.kickoff && (
|
||||
<div className="text-muted-foreground">
|
||||
kicked off by: {(bar.kickoff as WorkTimelineActor).name}
|
||||
{bar.span.retryOfRunId ? " · retry" : ""}
|
||||
</div>
|
||||
)}
|
||||
{tooltip.connectorHint && (
|
||||
<div className="text-muted-foreground">{tooltip.connectorHint}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniMap({
|
||||
layout,
|
||||
scrollRef,
|
||||
viewportW,
|
||||
scrollLeft,
|
||||
onVisibleRangeChange,
|
||||
}: {
|
||||
layout: ReturnType<typeof computeLayout>;
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
viewportW: number;
|
||||
scrollLeft: number;
|
||||
onVisibleRangeChange: (fromMs: number, toMs: number) => void;
|
||||
}) {
|
||||
const documentDragCleanupRef = useRef<(() => void) | null>(null);
|
||||
const W = Math.max(320, viewportW || 900);
|
||||
const H = 54;
|
||||
const pad = 8;
|
||||
const spanMs = layout.toMs - layout.fromMs || 1;
|
||||
const mx = (ms: number) => pad + ((ms - layout.fromMs) / spanMs) * (W - 2 * pad);
|
||||
|
||||
// one thin tick per run, stacked by row order
|
||||
const rowIndex = new Map(layout.rows.map((r, i) => [r.actor.id, i]));
|
||||
const laneH = (H - 2 * pad) / Math.max(1, layout.rows.length);
|
||||
|
||||
const timeAtX = (x: number) => {
|
||||
const ms = layout.fromMs + ((x - layout.gutter) / layout.pxPerMinute) * 60000;
|
||||
return Math.max(layout.fromMs, Math.min(layout.toMs, ms));
|
||||
};
|
||||
const visibleStartMs = timeAtX(scrollLeft + layout.gutter);
|
||||
const visibleEndMs = timeAtX(scrollLeft + layout.gutter + (viewportW || W));
|
||||
const brushX = mx(visibleStartMs);
|
||||
const brushW = Math.max(24, mx(visibleEndMs) - brushX);
|
||||
|
||||
const clearDocumentDrag = () => {
|
||||
documentDragCleanupRef.current?.();
|
||||
documentDragCleanupRef.current = null;
|
||||
};
|
||||
|
||||
const setDocumentDrag = (move: (event: MouseEvent) => void, up: (event: MouseEvent) => void) => {
|
||||
clearDocumentDrag();
|
||||
const handleUp = (event: MouseEvent) => {
|
||||
clearDocumentDrag();
|
||||
up(event);
|
||||
};
|
||||
document.addEventListener("mousemove", move);
|
||||
document.addEventListener("mouseup", handleUp);
|
||||
documentDragCleanupRef.current = () => {
|
||||
document.removeEventListener("mousemove", move);
|
||||
document.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => () => clearDocumentDrag(), []);
|
||||
|
||||
const msAtClientX = (clientX: number, el: SVGSVGElement) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const f = Math.min(1, Math.max(0, (clientX - rect.left - pad) / (W - 2 * pad)));
|
||||
return layout.fromMs + f * spanMs;
|
||||
};
|
||||
|
||||
const seek = (clientX: number, el: SVGSVGElement) => {
|
||||
const centerMs = msAtClientX(clientX, el);
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollLeft = layout.gutter + ((centerMs - layout.fromMs) / 60000) * layout.pxPerMinute - scrollRef.current.clientWidth / 2;
|
||||
}
|
||||
};
|
||||
|
||||
const startRangeDrag = (mode: "left" | "right" | "move", event: React.MouseEvent<SVGElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const el = event.currentTarget.ownerSVGElement;
|
||||
if (!el) return;
|
||||
const startLeftMs = visibleStartMs;
|
||||
const startRightMs = visibleEndMs;
|
||||
const durationMs = Math.max(MIN_MINIMAP_SELECTION_MS, startRightMs - startLeftMs);
|
||||
|
||||
const move = (ev: MouseEvent) => {
|
||||
const hitMs = msAtClientX(ev.clientX, el);
|
||||
if (mode === "left") {
|
||||
onVisibleRangeChange(Math.min(hitMs, startRightMs - MIN_MINIMAP_SELECTION_MS), startRightMs);
|
||||
} else if (mode === "right") {
|
||||
onVisibleRangeChange(startLeftMs, Math.max(hitMs, startLeftMs + MIN_MINIMAP_SELECTION_MS));
|
||||
} else {
|
||||
const nextFrom = Math.max(layout.fromMs, Math.min(layout.toMs - durationMs, hitMs - durationMs / 2));
|
||||
onVisibleRangeChange(nextFrom, nextFrom + durationMs);
|
||||
}
|
||||
};
|
||||
setDocumentDrag(move, () => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 border-t border-border bg-card px-3.5 py-2">
|
||||
<svg
|
||||
width={W}
|
||||
height={H}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="block cursor-ew-resize"
|
||||
onMouseDown={(e) => {
|
||||
const el = e.currentTarget;
|
||||
seek(e.clientX, el);
|
||||
const move = (ev: MouseEvent) => seek(ev.clientX, el);
|
||||
setDocumentDrag(move, () => {});
|
||||
}}
|
||||
>
|
||||
<rect x={0} y={0} width={W} height={H} fill="var(--color-card)" stroke="var(--color-foreground)" strokeWidth={1.5} />
|
||||
{layout.rows.flatMap((row) =>
|
||||
row.bars.map((bar) => {
|
||||
const startMs = new Date(bar.span.start).getTime();
|
||||
const endMs = bar.span.end ? new Date(bar.span.end).getTime() : layout.toMs;
|
||||
const yy = pad + (rowIndex.get(row.actor.id) ?? 0) * laneH;
|
||||
return (
|
||||
<rect
|
||||
key={`mm-${bar.span.runId}`}
|
||||
x={mx(startMs)}
|
||||
y={yy + 1}
|
||||
width={Math.max(2, mx(endMs) - mx(startMs))}
|
||||
height={Math.max(2, laneH - 2)}
|
||||
fill={isCancelledStatus(bar.span.status) ? TIMELINE_COLORS.cancelled : barColor(bar)}
|
||||
opacity={isCancelledStatus(bar.span.status) ? 0.5 : 1}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
<rect
|
||||
x={brushX}
|
||||
y={1}
|
||||
width={brushW}
|
||||
height={H - 2}
|
||||
fill="var(--color-foreground)"
|
||||
opacity={0.12}
|
||||
stroke="var(--color-foreground)"
|
||||
strokeWidth={1.5}
|
||||
onMouseDown={(e) => startRangeDrag("move", e)}
|
||||
/>
|
||||
<rect
|
||||
data-testid="timeline-minimap-left-handle"
|
||||
x={brushX - 3}
|
||||
y={1}
|
||||
width={6}
|
||||
height={H - 2}
|
||||
fill="var(--color-foreground)"
|
||||
opacity={0.55}
|
||||
onMouseDown={(e) => startRangeDrag("left", e)}
|
||||
/>
|
||||
<rect
|
||||
data-testid="timeline-minimap-right-handle"
|
||||
x={brushX + brushW - 3}
|
||||
y={1}
|
||||
width={6}
|
||||
height={H - 2}
|
||||
fill="var(--color-foreground)"
|
||||
opacity={0.55}
|
||||
onMouseDown={(e) => startRangeDrag("right", e)}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -1,9 +1,27 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@font-face {
|
||||
font-family: "InterVariable";
|
||||
src: url("../fonts/InterVariable.woff2") format("woff2");
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "InterVariable";
|
||||
src: url("../fonts/InterVariable-Italic.woff2") format("woff2");
|
||||
font-display: swap;
|
||||
font-style: italic;
|
||||
font-weight: 100 900;
|
||||
}
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "InterVariable", "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
@@ -247,6 +265,7 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-family: var(--font-sans);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -851,7 +870,7 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
@@ -956,7 +975,7 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
|
||||
}
|
||||
|
||||
.paperclip-markdown code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { ADAPTER_AGNOSTIC_KEYS, type Agent } from "@paperclipai/shared";
|
||||
|
||||
export interface AgentModelProfileOverlay {
|
||||
enabled?: boolean;
|
||||
@@ -19,20 +19,6 @@ export interface AgentConfigOverlay {
|
||||
modelProfiles?: { cheap?: AgentModelProfileOverlay };
|
||||
}
|
||||
|
||||
const ADAPTER_AGNOSTIC_KEYS = [
|
||||
"env",
|
||||
"promptTemplate",
|
||||
"instructionsFilePath",
|
||||
"cwd",
|
||||
"timeoutSec",
|
||||
"graceSec",
|
||||
"bootstrapPromptTemplate",
|
||||
// Desired-skill selection is a company-level, adapter-agnostic choice even
|
||||
// though it is persisted inside the per-adapter config; keep it when the
|
||||
// adapter type changes so switching adapters does not wipe the agent's skills.
|
||||
"paperclipSkillSync",
|
||||
] as const;
|
||||
|
||||
function omitUndefinedEntries(value: Record<string, unknown>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(([, entryValue]) => entryValue !== undefined),
|
||||
|
||||
@@ -72,6 +72,13 @@ describe("company routes", () => {
|
||||
expect(toCompanyRelativePath("/PAP/artifacts")).toBe("/artifacts");
|
||||
});
|
||||
|
||||
it("treats /timeline as a board route that needs a company prefix", () => {
|
||||
expect(isBoardPathWithoutPrefix("/timeline")).toBe(true);
|
||||
expect(extractCompanyPrefixFromPath("/timeline")).toBeNull();
|
||||
expect(applyCompanyPrefix("/timeline", "PAP")).toBe("/PAP/timeline");
|
||||
expect(toCompanyRelativePath("/PAP/timeline")).toBe("/timeline");
|
||||
});
|
||||
|
||||
it("preserves artifact deep-link anchors when applying the company prefix", () => {
|
||||
expect(applyCompanyPrefix("/issues/PAP-10205#work-product-wp-1", "PAP")).toBe(
|
||||
"/PAP/issues/PAP-10205#work-product-wp-1",
|
||||
|
||||
@@ -24,6 +24,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
||||
"design-guide",
|
||||
"search",
|
||||
"settings",
|
||||
"timeline",
|
||||
]);
|
||||
|
||||
const GLOBAL_ROUTE_ROOTS = new Set(["auth", "invite", "board-claim", "cli-auth", "docs", "instance"]);
|
||||
|
||||
@@ -258,6 +258,7 @@ export const queryKeys = {
|
||||
["company-search", companyId, q, scope, limit, offset] as const,
|
||||
},
|
||||
dashboard: (companyId: string) => ["dashboard", companyId] as const,
|
||||
workTimeline: (companyId: string, lens?: string) => ["work-timeline", companyId, lens ?? "all"] as const,
|
||||
userProfile: (companyId: string, userSlug: string) =>
|
||||
["user-profile", companyId, userSlug] as const,
|
||||
sidebarBadges: (companyId: string) => ["sidebar-badges", companyId] as const,
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkTimelineResult } from "@paperclipai/shared";
|
||||
import { chooseTickStepMs, computeLayout, issueColor, shortLabel, type LayoutOptions } from "./layout";
|
||||
|
||||
const DAY = "2026-07-02";
|
||||
const t = (hhmm: string) => `${DAY}T${hhmm}:00.000Z`;
|
||||
|
||||
// A trimmed version of the board's locked-design sample (PAP-12422), expressed
|
||||
// in the real endpoint contract: actor→actor edges rather than run→run.
|
||||
function sample(): WorkTimelineResult {
|
||||
const actors = [
|
||||
{ id: "user:dotta", type: "user" as const, name: "dotta" },
|
||||
{ id: "agent:ceo", type: "agent" as const, name: "CEO" },
|
||||
{ id: "agent:cto", type: "agent" as const, name: "CTO Architect" },
|
||||
{ id: "agent:ux", type: "agent" as const, name: "UXDesigner" },
|
||||
{ id: "agent:senior", type: "agent" as const, name: "SeniorEngineer" },
|
||||
{ id: "agent:codex", type: "agent" as const, name: "CodexCoder" },
|
||||
{ id: "agent:qa", type: "agent" as const, name: "QA" },
|
||||
{ id: "system:routine", type: "system" as const, name: "Circleback" },
|
||||
];
|
||||
const span = (
|
||||
runId: string,
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
identifier: string,
|
||||
start: string,
|
||||
end: string | null,
|
||||
status: string,
|
||||
retryOfRunId: string | null = null,
|
||||
): WorkTimelineResult["spans"][number] => ({
|
||||
actorId,
|
||||
laneHint: null,
|
||||
runId,
|
||||
issueId,
|
||||
issueIdentifier: identifier,
|
||||
issueTitle: `${identifier} title`,
|
||||
start: t(start),
|
||||
end: end ? t(end) : null,
|
||||
status,
|
||||
retryOfRunId,
|
||||
});
|
||||
const spans = [
|
||||
span("r1", "agent:ceo", "i-405", "PAP-12405", "09:02", "09:10", "completed"),
|
||||
span("r2", "agent:cto", "i-405", "PAP-12405", "09:14", "09:52", "completed"),
|
||||
span("r3", "agent:senior", "i-075", "PAP-12075", "09:58", "10:20", "completed"),
|
||||
span("r4", "agent:ux", "i-422", "PAP-12422", "10:10", "11:34", "running"),
|
||||
span("r5", "agent:codex", "i-423", "PAP-12423", "10:10", "10:56", "completed"),
|
||||
span("r6", "agent:codex", "i-075", "PAP-12075", "10:22", "11:08", "completed"), // overlaps r5 → sub-lane
|
||||
span("r7", "agent:qa", "i-423", "PAP-12423", "11:40", "12:12", "completed"),
|
||||
span("r8", "agent:codex", "i-423", "PAP-12423", "12:20", "12:38", "completed", "r5"), // retry
|
||||
span("r9", "system:routine", "i-286", "PAP-12286", "20:00", "20:04", "completed"),
|
||||
];
|
||||
const edge = (from: string, to: string, issueId: string, at: string): WorkTimelineResult["edges"][number] => ({
|
||||
fromActorId: from,
|
||||
toActorId: to,
|
||||
issueId,
|
||||
at: t(at),
|
||||
kind: "delegation",
|
||||
});
|
||||
const edges = [
|
||||
edge("user:dotta", "agent:ceo", "i-405", "09:01"), // human kickoff → chip only, no line
|
||||
edge("agent:ceo", "agent:cto", "i-405", "09:13"),
|
||||
edge("agent:cto", "agent:ux", "i-422", "10:09"),
|
||||
edge("agent:cto", "agent:codex", "i-423", "10:09"),
|
||||
edge("agent:senior", "agent:codex", "i-075", "10:21"),
|
||||
edge("agent:codex", "agent:qa", "i-423", "11:39"),
|
||||
edge("agent:qa", "agent:codex", "i-423", "12:19"), // → retry r8 (dashed)
|
||||
];
|
||||
return {
|
||||
actors,
|
||||
spans,
|
||||
events: [],
|
||||
edges,
|
||||
pagination: { limit: 200, offset: 0, totalIssues: 6, hasMore: false },
|
||||
window: { from: t("09:00"), to: t("20:15"), capped: false },
|
||||
};
|
||||
}
|
||||
|
||||
const OPTS: LayoutOptions = {
|
||||
pxPerMinute: 1.7,
|
||||
gutter: 172,
|
||||
rowH: 34,
|
||||
barH: 15,
|
||||
laneGap: 4,
|
||||
nowMs: new Date(t("13:00")).getTime(),
|
||||
};
|
||||
|
||||
describe("computeLayout", () => {
|
||||
it("excludes humans from rows but keeps agents and system actors", () => {
|
||||
const layout = computeLayout(sample(), OPTS);
|
||||
const rowActorTypes = layout.rows.map((r) => r.actor.type);
|
||||
expect(rowActorTypes).not.toContain("user");
|
||||
expect(layout.rows.map((r) => r.actor.id)).toContain("system:routine");
|
||||
expect(layout.rows).toHaveLength(7); // 6 agents + 1 system
|
||||
});
|
||||
|
||||
it("orders rows by first activity", () => {
|
||||
const layout = computeLayout(sample(), OPTS);
|
||||
expect(layout.rows[0].actor.id).toBe("agent:ceo");
|
||||
expect(layout.rows[layout.rows.length - 1].actor.id).toBe("system:routine");
|
||||
});
|
||||
|
||||
it("packs overlapping runs into concurrency sub-lanes", () => {
|
||||
const layout = computeLayout(sample(), OPTS);
|
||||
const codex = layout.rows.find((r) => r.actor.id === "agent:codex")!;
|
||||
expect(codex.laneCount).toBe(2); // r5 and r6 overlap
|
||||
const lanesTop = new Set(codex.bars.map((b) => b.yTop));
|
||||
expect(lanesTop.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("derives the kickoff actor for a run (incl. human) from edges", () => {
|
||||
const layout = computeLayout(sample(), OPTS);
|
||||
const codexApi = layout.rows
|
||||
.find((r) => r.actor.id === "agent:codex")!
|
||||
.bars.find((b) => b.span.runId === "r5")!;
|
||||
expect(codexApi.kickoff?.id).toBe("agent:cto");
|
||||
|
||||
const ceoRun = layout.rows.find((r) => r.actor.id === "agent:ceo")!.bars[0];
|
||||
expect(ceoRun.kickoff?.id).toBe("user:dotta"); // human kickoff shown as chip
|
||||
});
|
||||
|
||||
it("prefers the nearest post-start kickoff edge when no prior edge exists", () => {
|
||||
const data = sample();
|
||||
data.spans.push({
|
||||
actorId: "agent:ux",
|
||||
laneHint: null,
|
||||
runId: "late-edge-run",
|
||||
issueId: "i-late",
|
||||
issueIdentifier: "PAP-99999",
|
||||
issueTitle: "Late kickoff fallback",
|
||||
start: t("14:00"),
|
||||
end: t("14:10"),
|
||||
status: "completed",
|
||||
retryOfRunId: null,
|
||||
});
|
||||
data.edges.push(
|
||||
{ fromActorId: "agent:qa", toActorId: "agent:ux", issueId: "i-late", at: t("14:30"), kind: "delegation" },
|
||||
{ fromActorId: "agent:cto", toActorId: "agent:ux", issueId: "i-late", at: t("14:02"), kind: "delegation" },
|
||||
);
|
||||
|
||||
const layout = computeLayout(data, OPTS);
|
||||
const lateRun = layout.rows
|
||||
.find((r) => r.actor.id === "agent:ux")!
|
||||
.bars.find((b) => b.span.runId === "late-edge-run")!;
|
||||
expect(lateRun.kickoff?.id).toBe("agent:cto");
|
||||
});
|
||||
|
||||
it("draws agent→agent connectors only, dashing retries, never from a human", () => {
|
||||
const layout = computeLayout(sample(), OPTS);
|
||||
// 6 agent→agent edges resolve to bars; the dotta→ceo human edge draws no line.
|
||||
expect(layout.connectors.length).toBe(6);
|
||||
const dashed = layout.connectors.filter((c) => c.dashed);
|
||||
expect(dashed).toHaveLength(1); // only the QA→codex retry hop
|
||||
// every connector is left-to-right (source trailing edge → target leading edge)
|
||||
for (const c of layout.connectors) expect(c.x2).toBeGreaterThanOrEqual(c.x1 - 1);
|
||||
});
|
||||
|
||||
it("extends in-progress runs to now and clamps sub-minute bars", () => {
|
||||
const layout = computeLayout(sample(), OPTS);
|
||||
const running = layout.rows
|
||||
.find((r) => r.actor.id === "agent:ux")!
|
||||
.bars.find((b) => b.span.runId === "r4")!;
|
||||
expect(running.running).toBe(true);
|
||||
expect(running.x2).toBeGreaterThan(running.x1 + 3);
|
||||
});
|
||||
|
||||
it("produces a deterministic issue hue + legend", () => {
|
||||
const layout = computeLayout(sample(), OPTS);
|
||||
expect(layout.issues.length).toBe(5); // i-405, i-075, i-422, i-423, i-286
|
||||
expect(issueColor("i-405")).toBe(issueColor("i-405"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("human activity markers", () => {
|
||||
const withUserEvents = (): WorkTimelineResult => ({
|
||||
...sample(),
|
||||
events: [
|
||||
{ actorId: "user:dotta", kind: "created", issueId: "i-405", at: t("09:00") },
|
||||
{ actorId: "user:dotta", kind: "commented", issueId: "i-422", at: t("11:00") },
|
||||
{ actorId: "user:dotta", kind: "approved", issueId: "i-423", at: t("12:15") },
|
||||
],
|
||||
});
|
||||
|
||||
it("does not create a marker-only human row from instant events", () => {
|
||||
const layout = computeLayout(withUserEvents(), OPTS);
|
||||
const dotta = layout.rows.find((r) => r.actor.id === "user:dotta");
|
||||
expect(dotta).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not plot instant markers on run rows", () => {
|
||||
const layout = computeLayout(withUserEvents(), OPTS);
|
||||
expect(layout.rows.flatMap((r) => r.markers)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps human events visual-only as kickoff chips, not connector targets", () => {
|
||||
const layout = computeLayout(withUserEvents(), OPTS);
|
||||
// dotta→ceo human kickoff still draws no line; agent→agent count unchanged.
|
||||
expect(layout.connectors.length).toBe(6);
|
||||
});
|
||||
|
||||
it("still excludes a human with no in-window events", () => {
|
||||
const layout = computeLayout(sample(), OPTS); // events: []
|
||||
expect(layout.rows.map((r) => r.actor.type)).not.toContain("user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("helpers", () => {
|
||||
it("shortLabel builds 2-char initials", () => {
|
||||
expect(shortLabel("CodexCoder")).toBe("CO");
|
||||
expect(shortLabel("CTO Architect")).toBe("CA");
|
||||
});
|
||||
|
||||
it("chooseTickStepMs grows the step as the view zooms out", () => {
|
||||
expect(chooseTickStepMs(6)).toBeLessThan(chooseTickStepMs(0.4));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* Work Timeline layout — pure transform from the Phase B endpoint contract
|
||||
* (`WorkTimelineResult`) into a renderable view model for the custom-SVG Gantt.
|
||||
*
|
||||
* Ports the board-locked "Direction C" logic (PAP-12422): agent/system rows only
|
||||
* (humans never get a row), overlapping runs packed into concurrency sub-lanes,
|
||||
* a kickoff actor derived per run (shown as an avatar chip — may be a human),
|
||||
* and straight agent→agent delegation connectors from a source bar's trailing
|
||||
* edge to a target bar's leading edge (dashed for retries / changes-requested).
|
||||
*
|
||||
* Everything here is deterministic given (result, options) so it can be unit
|
||||
* tested without a DOM.
|
||||
*/
|
||||
import type {
|
||||
WorkTimelineActor,
|
||||
WorkTimelineEdge,
|
||||
WorkTimelineEvent,
|
||||
WorkTimelineResult,
|
||||
WorkTimelineSpan,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export interface LayoutOptions {
|
||||
/** px per minute along the x axis (set by the zoom level). */
|
||||
pxPerMinute: number;
|
||||
/** width of the left actor gutter in px. */
|
||||
gutter: number;
|
||||
/** row height in px. */
|
||||
rowH: number;
|
||||
/** bar height in px. */
|
||||
barH: number;
|
||||
/** vertical gap between concurrency sub-lanes in px. */
|
||||
laneGap: number;
|
||||
/** wall-clock "now" in ms, used to close in-progress runs. */
|
||||
nowMs: number;
|
||||
}
|
||||
|
||||
export interface PositionedBar {
|
||||
span: WorkTimelineSpan;
|
||||
/** leading (start) x in px. */
|
||||
x1: number;
|
||||
/** trailing (end) x in px. */
|
||||
x2: number;
|
||||
/** vertical center of the bar in px. */
|
||||
yc: number;
|
||||
/** top of the bar in px. */
|
||||
yTop: number;
|
||||
height: number;
|
||||
running: boolean;
|
||||
/** the actor who kicked this run off, if resolvable (may be a human/user). */
|
||||
kickoff: WorkTimelineActor | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instant human/actor actions are deliberately not plotted in the main chart.
|
||||
* The shape remains in the layout model so call sites do not need special cases,
|
||||
* but rows now stay focused on actors with actual run participation.
|
||||
*/
|
||||
export interface PositionedMarker {
|
||||
event: WorkTimelineEvent;
|
||||
/** x position (px) of the marker centre = x(event.at). */
|
||||
x: number;
|
||||
/** vertical centre of the marker in px (row-relative, excludes axis offset). */
|
||||
yc: number;
|
||||
}
|
||||
|
||||
export interface ActorRow {
|
||||
actor: WorkTimelineActor;
|
||||
/** top of the row (excluding axis offset) in px. */
|
||||
y: number;
|
||||
/** row height in px. */
|
||||
h: number;
|
||||
laneCount: number;
|
||||
bars: PositionedBar[];
|
||||
/** reserved for instant event markers; currently empty by design. */
|
||||
markers: PositionedMarker[];
|
||||
/** number of runs plotted on this row (the "Signal" rail count). */
|
||||
runCount: number;
|
||||
/** total active run time on this row in ms (the "Signal" rail active-time). */
|
||||
activeMs: number;
|
||||
}
|
||||
|
||||
export interface Connector {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
sourceRunId: string;
|
||||
targetRunId: string;
|
||||
/** dashed = a return / retry (changes-requested) hop. */
|
||||
dashed: boolean;
|
||||
}
|
||||
|
||||
export interface TimelineLayout {
|
||||
rows: ActorRow[];
|
||||
connectors: Connector[];
|
||||
/** full inner width of the chart (gutter + plotted time + pad). */
|
||||
width: number;
|
||||
/** full height of the chart including the axis strip. */
|
||||
height: number;
|
||||
/** domain start (ms). */
|
||||
fromMs: number;
|
||||
/** domain end (ms). */
|
||||
toMs: number;
|
||||
gutter: number;
|
||||
pxPerMinute: number;
|
||||
/** ordered list of distinct issue keys present for the task hue map. */
|
||||
issues: { key: string; label: string; color: string }[];
|
||||
}
|
||||
|
||||
export const AXIS_H = 32;
|
||||
const RUNNING_STATUSES = new Set(["running", "in_progress", "queued", "pending"]);
|
||||
const CANCELLED_STATUSES = new Set(["cancelled", "canceled", "aborted", "skipped"]);
|
||||
|
||||
export function isRunningStatus(status: string): boolean {
|
||||
return RUNNING_STATUSES.has(status);
|
||||
}
|
||||
|
||||
export function isCancelledStatus(status: string): boolean {
|
||||
return CANCELLED_STATUSES.has(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Signal" encoding (PAP-12694, board-picked): colour spends on ONE meaning —
|
||||
* how the run started — instead of a per-issue hash rainbow. Delegated runs
|
||||
* (kicked off by another actor) read blue; automation/self-started runs read
|
||||
* amber. The blue/amber pair is colour-blind-safe and holds contrast on both
|
||||
* light and dark backgrounds so the chart screenshots cleanly. Cancelled runs
|
||||
* drop their fill entirely (rendered as a hollow dashed bar) and a teal "now"
|
||||
* line marks the present.
|
||||
*/
|
||||
export const TIMELINE_COLORS = {
|
||||
delegated: "#5b9bf6",
|
||||
automation: "#f4b740",
|
||||
/** stroke/ink for a hollow, cancelled bar. */
|
||||
cancelled: "#9aa3ad",
|
||||
now: "#2dd4bf",
|
||||
} as const;
|
||||
|
||||
export type RunSourceKind = "delegated" | "automation";
|
||||
|
||||
/** How a run was started: delegated (has a kickoff actor) vs. automation/self. */
|
||||
export function barSourceKind(bar: PositionedBar): RunSourceKind {
|
||||
return bar.kickoff ? "delegated" : "automation";
|
||||
}
|
||||
|
||||
/** Source colour for a bar under the "Signal" encoding. */
|
||||
export function barColor(bar: PositionedBar): string {
|
||||
return TIMELINE_COLORS[barSourceKind(bar)];
|
||||
}
|
||||
|
||||
export function actorType(actor: WorkTimelineActor | undefined): string {
|
||||
return actor?.type ?? "system";
|
||||
}
|
||||
|
||||
/** Deterministic, stable hue per issue that reads on both light and dark. */
|
||||
export function issueColor(key: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
hash = (hash * 31 + key.charCodeAt(i)) & 0xffffffff;
|
||||
}
|
||||
const hue = Math.abs(hash) % 360;
|
||||
return `hsl(${hue} 62% 52%)`;
|
||||
}
|
||||
|
||||
/** 2-char initials for an SVG avatar chip. */
|
||||
export function shortLabel(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
return (name.slice(0, 2) || "?").toUpperCase();
|
||||
}
|
||||
|
||||
function spanStartMs(s: WorkTimelineSpan): number {
|
||||
return new Date(s.start).getTime();
|
||||
}
|
||||
|
||||
function spanEndMs(s: WorkTimelineSpan, nowMs: number): number {
|
||||
const raw = s.end ? new Date(s.end).getTime() : nowMs;
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the kickoff actor for a run: the source of the delegation/assignment
|
||||
* edge that points at this run's actor on this run's issue, closest at-or-before
|
||||
* the run start (falling back to the nearest such edge). Mirrors the design's
|
||||
* "avatar chip at the leading edge = who kicked it off".
|
||||
*/
|
||||
function resolveKickoff(
|
||||
span: WorkTimelineSpan,
|
||||
edges: WorkTimelineEdge[],
|
||||
actorById: Map<string, WorkTimelineActor>,
|
||||
): WorkTimelineActor | null {
|
||||
const start = spanStartMs(span);
|
||||
let best: { edge: WorkTimelineEdge; delta: number } | null = null;
|
||||
for (const e of edges) {
|
||||
if (e.toActorId !== span.actorId || e.issueId !== span.issueId) continue;
|
||||
if (e.fromActorId === span.actorId) continue; // self-kickoff is not a delegation
|
||||
const at = new Date(e.at).getTime();
|
||||
// Prefer edges at-or-before the run start; otherwise smallest absolute gap.
|
||||
const delta = at <= start ? start - at : (at - start) + 1e12;
|
||||
if (!best || delta < best.delta) best = { edge: e, delta };
|
||||
}
|
||||
if (!best) return null;
|
||||
return actorById.get(best.edge.fromActorId) ?? null;
|
||||
}
|
||||
|
||||
export function computeLayout(result: WorkTimelineResult, opts: LayoutOptions): TimelineLayout {
|
||||
const { pxPerMinute, gutter, rowH, barH, laneGap, nowMs } = opts;
|
||||
const fromMs = new Date(result.window.from).getTime();
|
||||
const toMs = new Date(result.window.to).getTime();
|
||||
const actorById = new Map(result.actors.map((a) => [a.id, a]));
|
||||
|
||||
const x = (ms: number) => gutter + ((ms - fromMs) / 60000) * pxPerMinute;
|
||||
|
||||
// Rows: any actor with in-window runs gets a row. Event-only comments/creates
|
||||
// do not create marker-only rows because they make the chart noisy.
|
||||
const firstActivity = new Map<string, number>();
|
||||
const noteActivity = (actorId: string, t: number) => {
|
||||
const cur = firstActivity.get(actorId);
|
||||
if (cur === undefined || t < cur) firstActivity.set(actorId, t);
|
||||
};
|
||||
for (const s of result.spans) noteActivity(s.actorId, spanStartMs(s));
|
||||
|
||||
const rowActors = result.actors
|
||||
.filter((a) => firstActivity.has(a.id)) // drop actors with no run in-window
|
||||
.sort((a, b) => (firstActivity.get(a.id)! - firstActivity.get(b.id)!));
|
||||
|
||||
// Issue hue map (ordered by first appearance) for the task color map.
|
||||
const issueOrder: string[] = [];
|
||||
const issueLabel = new Map<string, string>();
|
||||
for (const s of result.spans) {
|
||||
const key = s.issueId;
|
||||
if (!issueLabel.has(key)) {
|
||||
issueOrder.push(key);
|
||||
issueLabel.set(key, s.issueIdentifier ?? s.issueTitle ?? "issue");
|
||||
}
|
||||
}
|
||||
const issues = issueOrder.map((key) => ({ key, label: issueLabel.get(key)!, color: issueColor(key) }));
|
||||
|
||||
const barIndex = new Map<string, PositionedBar>(); // runId -> bar
|
||||
const rows: ActorRow[] = [];
|
||||
let y = 0;
|
||||
for (const actor of rowActors) {
|
||||
const runs = result.spans
|
||||
.filter((s) => s.actorId === actor.id)
|
||||
.sort((p, q) => spanStartMs(p) - spanStartMs(q));
|
||||
|
||||
// Greedy pack overlapping runs into sub-lanes.
|
||||
const laneEnds: number[] = [];
|
||||
const laneOf = new Map<string, number>();
|
||||
for (const r of runs) {
|
||||
const rs = spanStartMs(r);
|
||||
const re = spanEndMs(r, nowMs);
|
||||
let placed = -1;
|
||||
for (let ln = 0; ln < laneEnds.length; ln++) {
|
||||
if (laneEnds[ln] <= rs) {
|
||||
placed = ln;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (placed === -1) {
|
||||
placed = laneEnds.length;
|
||||
laneEnds.push(re);
|
||||
} else {
|
||||
laneEnds[placed] = re;
|
||||
}
|
||||
laneOf.set(r.runId, placed);
|
||||
}
|
||||
const laneCount = Math.max(1, laneEnds.length);
|
||||
const h = Math.max(rowH, laneCount * (barH + laneGap) + 8);
|
||||
|
||||
const bars: PositionedBar[] = runs.map((r) => {
|
||||
const lane = laneOf.get(r.runId) ?? 0;
|
||||
const laneTop = y + 6 + lane * (barH + laneGap);
|
||||
const x1 = x(spanStartMs(r));
|
||||
const x2raw = x(spanEndMs(r, nowMs));
|
||||
const x2 = Math.max(x1 + 3, x2raw); // clamp sub-minute runs to a visible min width
|
||||
const bar: PositionedBar = {
|
||||
span: r,
|
||||
x1,
|
||||
x2,
|
||||
yTop: laneTop,
|
||||
yc: laneTop + barH / 2,
|
||||
height: barH,
|
||||
running: isRunningStatus(r.status),
|
||||
kickoff: resolveKickoff(r, result.edges, actorById),
|
||||
};
|
||||
barIndex.set(r.runId, bar);
|
||||
return bar;
|
||||
});
|
||||
|
||||
const markers: PositionedMarker[] = [];
|
||||
|
||||
const activeMs = runs.reduce((sum, r) => sum + Math.max(0, spanEndMs(r, nowMs) - spanStartMs(r)), 0);
|
||||
|
||||
rows.push({ actor, y, h, laneCount, bars, markers, runCount: runs.length, activeMs });
|
||||
y += h;
|
||||
}
|
||||
|
||||
// Connectors: straight agent→agent lines, connected at both ends. For each bar
|
||||
// with an agent/system kickoff, connect from the kickoff actor's nearest
|
||||
// preceding bar (same issue preferred) to this bar's leading edge.
|
||||
const connectors: Connector[] = [];
|
||||
for (const row of rows) {
|
||||
for (const bar of row.bars) {
|
||||
const k = bar.kickoff;
|
||||
if (!k || k.type === "user") continue; // agent→agent only; humans stay as chips
|
||||
const source = nearestSourceBar(k.id, bar, barIndex, nowMs);
|
||||
if (!source) continue;
|
||||
connectors.push({
|
||||
x1: source.x2,
|
||||
y1: source.yc,
|
||||
x2: bar.x1,
|
||||
y2: bar.yc,
|
||||
sourceRunId: source.span.runId,
|
||||
targetRunId: bar.span.runId,
|
||||
dashed: Boolean(bar.span.retryOfRunId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const width = gutter + ((toMs - fromMs) / 60000) * pxPerMinute + 40;
|
||||
const height = y + AXIS_H;
|
||||
return { rows, connectors, width, height, fromMs, toMs, gutter, pxPerMinute, issues };
|
||||
}
|
||||
|
||||
/** The kickoff actor's bar that best precedes `target` (same issue preferred). */
|
||||
function nearestSourceBar(
|
||||
kickoffActorId: string,
|
||||
target: PositionedBar,
|
||||
barIndex: Map<string, PositionedBar>,
|
||||
nowMs: number,
|
||||
): PositionedBar | null {
|
||||
const targetStart = spanStartMs(target.span);
|
||||
let sameIssue: PositionedBar | null = null;
|
||||
let anyIssue: PositionedBar | null = null;
|
||||
for (const bar of barIndex.values()) {
|
||||
if (bar.span.actorId !== kickoffActorId) continue;
|
||||
if (bar === target) continue;
|
||||
const end = spanEndMs(bar.span, nowMs);
|
||||
if (end > targetStart + 1) continue; // must precede (small tolerance)
|
||||
if (bar.span.issueId === target.span.issueId) {
|
||||
if (!sameIssue || spanEndMs(sameIssue.span, nowMs) < end) sameIssue = bar;
|
||||
}
|
||||
if (!anyIssue || spanEndMs(anyIssue.span, nowMs) < end) anyIssue = bar;
|
||||
}
|
||||
return sameIssue ?? anyIssue;
|
||||
}
|
||||
|
||||
/** Choose a "nice" gridline step (ms) targeting ~120px between labels. */
|
||||
export function chooseTickStepMs(pxPerMinute: number): number {
|
||||
const targetPx = 120;
|
||||
const minutesPerTarget = targetPx / pxPerMinute;
|
||||
const steps = [15, 30, 60, 120, 180, 360, 720, 1440, 2880, 10080]; // minutes
|
||||
for (const m of steps) {
|
||||
if (m >= minutesPerTarget) return m * 60000;
|
||||
}
|
||||
return steps[steps.length - 1] * 60000;
|
||||
}
|
||||
|
||||
export function formatDuration(startMs: number, endMs: number): string {
|
||||
const mins = Math.max(0, Math.round((endMs - startMs) / 60000));
|
||||
if (mins >= 1440) return `${Math.floor(mins / 1440)}d ${Math.floor((mins % 1440) / 60)}h`;
|
||||
if (mins >= 60) return `${Math.floor(mins / 60)}h ${mins % 60}m`;
|
||||
return `${mins}m`;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { WorkTimelineResult } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Timeline } from "./Timeline";
|
||||
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
const mockWorkTimelineApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({ selectedCompanyId: "company-1" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/workTimeline", () => ({
|
||||
workTimelineApi: mockWorkTimelineApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/RequestCollapsedSidebar", () => ({
|
||||
RequestCollapsedSidebar: () => <div data-testid="request-collapsed-sidebar" />,
|
||||
}));
|
||||
|
||||
const emptyTimeline: WorkTimelineResult = {
|
||||
actors: [],
|
||||
spans: [],
|
||||
events: [],
|
||||
edges: [],
|
||||
pagination: {
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
totalIssues: 0,
|
||||
hasMore: false,
|
||||
},
|
||||
window: {
|
||||
from: "2026-07-01T00:00:00.000Z",
|
||||
to: "2026-07-07T23:59:59.999Z",
|
||||
capped: false,
|
||||
},
|
||||
};
|
||||
|
||||
async function flushReact() {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
flushSync(() => {});
|
||||
}
|
||||
|
||||
describe("Timeline", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot> | null;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
mockWorkTimelineApi.get.mockResolvedValue(emptyTimeline);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
flushSync(() => root?.unmount());
|
||||
}
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("requests the collapsed app sidebar by default", async () => {
|
||||
root = createRoot(container);
|
||||
|
||||
flushSync(() => {
|
||||
root?.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Timeline />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.querySelector('[data-testid="request-collapsed-sidebar"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders range controls plus icon zoom controls without the user lens selector or visible-duration readout", async () => {
|
||||
root = createRoot(container);
|
||||
|
||||
flushSync(() => {
|
||||
root?.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Timeline />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Range");
|
||||
expect(container.querySelector('[aria-label="Zoom out"]')).not.toBeNull();
|
||||
expect(container.querySelector('[aria-label="Zoom in"]')).not.toBeNull();
|
||||
expect(container.querySelector('[aria-label="Reset zoom"]')).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("Everyone");
|
||||
expect(container.textContent).not.toContain("work kicked off");
|
||||
expect(container.textContent).not.toContain("visible");
|
||||
});
|
||||
|
||||
it("requests the company timeline without a user lens parameter", async () => {
|
||||
root = createRoot(container);
|
||||
|
||||
flushSync(() => {
|
||||
root?.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Timeline />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockWorkTimelineApi.get).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
from: expect.any(String),
|
||||
to: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(mockWorkTimelineApi.get.mock.calls[0]?.[1]).not.toHaveProperty("userId");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Work Timeline page (PAP-12424 / Phase C of PAP-12405).
|
||||
*
|
||||
* A Gantt-style view of company actor activity built on the Phase B endpoint
|
||||
* (`GET /companies/:companyId/timeline`). Rendering is the board-locked
|
||||
* Direction C (PAP-12422): dense rows, mini-map brush, custom inline SVG.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { GanttChartSquare, Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import { workTimelineApi, type WorkTimelineParams } from "@/api/workTimeline";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { PageSkeleton } from "@/components/PageSkeleton";
|
||||
import { RequestCollapsedSidebar } from "@/components/RequestCollapsedSidebar";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
WorkTimelineChart,
|
||||
clampZoomScale,
|
||||
defaultZoomForWindow,
|
||||
nearestZoomForScale,
|
||||
type ZoomLevel,
|
||||
zoomScaleForLevel,
|
||||
} from "@/components/timeline/WorkTimelineChart";
|
||||
import { TIMELINE_COLORS } from "@/lib/timeline/layout";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type RangePreset = "today" | "7d" | "30d" | "custom";
|
||||
interface DateRangeState {
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
}
|
||||
|
||||
function dateInputValue(date: Date): string {
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getDate()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function presetRange(preset: Exclude<RangePreset, "custom">, now = new Date()): DateRangeState {
|
||||
const from = new Date(now);
|
||||
const to = new Date(now);
|
||||
if (preset === "today") {
|
||||
return { fromDate: dateInputValue(from), toDate: dateInputValue(to) };
|
||||
} else {
|
||||
from.setDate(from.getDate() - (preset === "7d" ? 6 : 29));
|
||||
}
|
||||
return { fromDate: dateInputValue(from), toDate: dateInputValue(to) };
|
||||
}
|
||||
|
||||
function rangeWindow(range: DateRangeState): Pick<WorkTimelineParams, "from" | "to"> | null {
|
||||
if (!range.fromDate || !range.toDate) return null;
|
||||
const from = new Date(`${range.fromDate}T00:00:00`);
|
||||
const to = new Date(`${range.toDate}T23:59:59.999`);
|
||||
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from > to) return null;
|
||||
return { from: from.toISOString(), to: to.toISOString() };
|
||||
}
|
||||
|
||||
function rangeError(range: DateRangeState): string | null {
|
||||
if (!range.fromDate || !range.toDate) return "Choose a start and end date.";
|
||||
if (!rangeWindow(range)) return "Start date must be before end date.";
|
||||
return null;
|
||||
}
|
||||
|
||||
function Segmented<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
value: T;
|
||||
options: { value: T; label: string }[];
|
||||
onChange: (v: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex overflow-hidden rounded-md border border-border">
|
||||
{options.map((opt, i) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.value)}
|
||||
aria-pressed={value === opt.value}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-xs transition-colors",
|
||||
i > 0 && "border-l border-border",
|
||||
value === opt.value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-card text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Encoding key for the "Signal" timeline: colour = how each run started. */
|
||||
function TimelineLegend() {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border px-3.5 py-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-2.5 w-4 rounded-sm" style={{ backgroundColor: TIMELINE_COLORS.delegated }} />
|
||||
Delegated
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-2.5 w-4 rounded-sm" style={{ backgroundColor: TIMELINE_COLORS.automation }} />
|
||||
Automation
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-2.5 w-4 rounded-sm border border-dashed bg-transparent"
|
||||
style={{ borderColor: TIMELINE_COLORS.cancelled }}
|
||||
/>
|
||||
Cancelled
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-3.5 w-0.5" style={{ backgroundColor: TIMELINE_COLORS.now }} />
|
||||
Now
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Timeline() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [zoom, setZoom] = useState<ZoomLevel>("day");
|
||||
const [zoomScale, setZoomScale] = useState<number | undefined>(undefined);
|
||||
const zoomTouched = useRef(false);
|
||||
const [rangePreset, setRangePreset] = useState<RangePreset>("7d");
|
||||
const [dateRange, setDateRange] = useState<DateRangeState>(() => presetRange("7d"));
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Timeline" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const dateRangeError = rangeError(dateRange);
|
||||
const params: WorkTimelineParams | null = useMemo(() => {
|
||||
const window = rangeWindow(dateRange);
|
||||
if (!window) return null;
|
||||
return window;
|
||||
}, [dateRange]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: [...queryKeys.workTimeline(selectedCompanyId ?? ""), dateRange.fromDate, dateRange.toDate],
|
||||
queryFn: () => workTimelineApi.get(selectedCompanyId!, params!),
|
||||
enabled: !!selectedCompanyId && !!params,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || zoomTouched.current) return;
|
||||
const defaultZoom = defaultZoomForWindow(new Date(data.window.from).getTime(), new Date(data.window.to).getTime());
|
||||
setZoom(defaultZoom);
|
||||
setZoomScale(undefined);
|
||||
}, [data]);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return (
|
||||
<>
|
||||
<RequestCollapsedSidebar />
|
||||
<EmptyState icon={GanttChartSquare} message="Select a company to view its work timeline." />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const header = (
|
||||
<div className="flex items-center gap-2">
|
||||
<GanttChartSquare className="h-6 w-6 text-muted-foreground" />
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Work Timeline</h1>
|
||||
</div>
|
||||
);
|
||||
|
||||
const adjustZoom = (factor: number) => {
|
||||
zoomTouched.current = true;
|
||||
const nextScale = clampZoomScale((zoomScale ?? zoomScaleForLevel(zoom)) * factor);
|
||||
setZoomScale(nextScale);
|
||||
setZoom(nearestZoomForScale(nextScale));
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
zoomTouched.current = true;
|
||||
if (data) {
|
||||
setZoom(defaultZoomForWindow(new Date(data.window.from).getTime(), new Date(data.window.to).getTime()));
|
||||
} else {
|
||||
setZoom("day");
|
||||
}
|
||||
setZoomScale(undefined);
|
||||
};
|
||||
|
||||
const toolbar = (
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||
<label className="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
Range
|
||||
<Segmented
|
||||
value={rangePreset}
|
||||
onChange={(preset) => {
|
||||
if (preset === "custom") return;
|
||||
setRangePreset(preset);
|
||||
setDateRange(presetRange(preset));
|
||||
}}
|
||||
options={[
|
||||
{ value: "today", label: "Today" },
|
||||
{ value: "7d", label: "7 days" },
|
||||
{ value: "30d", label: "30 days" },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateRange.fromDate}
|
||||
onChange={(event) => {
|
||||
setRangePreset("custom");
|
||||
setDateRange((prev) => ({ ...prev, fromDate: event.target.value }));
|
||||
}}
|
||||
className="h-8 w-[150px] text-xs"
|
||||
aria-label="Timeline start date"
|
||||
/>
|
||||
<span>to</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateRange.toDate}
|
||||
onChange={(event) => {
|
||||
setRangePreset("custom");
|
||||
setDateRange((prev) => ({ ...prev, toDate: event.target.value }));
|
||||
}}
|
||||
className="h-8 w-[150px] text-xs"
|
||||
aria-label="Timeline end date"
|
||||
/>
|
||||
</label>
|
||||
<div className="ml-auto flex items-center gap-1" aria-label="Timeline zoom controls">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
onClick={() => adjustZoom(0.8)}
|
||||
aria-label="Zoom out"
|
||||
title="Zoom out"
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
onClick={() => adjustZoom(1.25)}
|
||||
aria-label="Zoom in"
|
||||
title="Zoom in"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
onClick={resetZoom}
|
||||
aria-label="Reset zoom"
|
||||
title="Reset zoom"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<RequestCollapsedSidebar />
|
||||
{header}
|
||||
{toolbar}
|
||||
|
||||
{isLoading && <PageSkeleton />}
|
||||
|
||||
{dateRangeError && (
|
||||
<EmptyState
|
||||
icon={GanttChartSquare}
|
||||
message={dateRangeError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<EmptyState
|
||||
icon={GanttChartSquare}
|
||||
message="Couldn't load the timeline. The aggregation endpoint may be unavailable."
|
||||
/>
|
||||
)}
|
||||
|
||||
{data && !isLoading && !dateRangeError && (
|
||||
data.spans.length === 0 ? (
|
||||
<EmptyState icon={GanttChartSquare} message="No activity in this window." />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<TimelineLegend />
|
||||
<WorkTimelineChart
|
||||
data={data}
|
||||
zoom={zoom}
|
||||
zoomScale={zoomScale}
|
||||
onZoomScaleChange={(nextScale, nextZoom = nearestZoomForScale(nextScale)) => {
|
||||
zoomTouched.current = true;
|
||||
setZoomScale(nextScale);
|
||||
setZoom(nextZoom);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data.spans.length} run{data.spans.length === 1 ? "" : "s"} ·{" "}
|
||||
{new Date(data.window.from).toLocaleString()} → {new Date(data.window.to).toLocaleString()}
|
||||
{data.window.capped ? " · window capped" : ""}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
{"actors": [{"id": "agent:244c0c2c-8416-43b6-84c9-ec183c074cc1", "type": "agent", "name": "CEO", "avatar": "crown"}, {"id": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "type": "agent", "name": "ClaudeCoder", "avatar": "code"}, {"id": "agent:352aa64b-2a22-4d00-928f-71f5fb884f66", "type": "agent", "name": "CloudOpsEngineer", "avatar": "cog"}, {"id": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "type": "agent", "name": "UXDesigner", "avatar": "gem"}, {"id": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", "type": "agent", "name": "CTO", "avatar": "file-code"}, {"id": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "type": "agent", "name": "Senior Engineer Fable", "avatar": "code"}, {"id": "agent:b8bc4df6-599c-4228-9969-6b041e7c88f8", "type": "agent", "name": "Senior Planning Engineer Pro", "avatar": "code"}, {"id": "agent:c9dcefd1-b052-489a-a1f3-4cd1783bcb5f", "type": "agent", "name": "DevRel", "avatar": "globe"}, {"id": "agent:eab6f1c7-5950-410e-8eed-7d074426165d", "type": "agent", "name": "CodexCoder", "avatar": "code"}, {"id": "agent:eae27ade-c807-4723-97e8-23808ad4fa14", "type": "agent", "name": "QA", "avatar": "bug"}, {"id": "agent:ece64c0e-767e-4d37-a15e-193694daa1d8", "type": "agent", "name": "SecurityEngineer", "avatar": "shield"}, {"id": "agent:ef2d1ee5-5c42-42fc-8197-5e2386ac34ed", "type": "agent", "name": "Strategic Ops", "avatar": "target"}], "spans": [{"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "063a9727-b191-49e2-9802-b6b63ec65b97", "issueId": "3c1b19ae-9064-4d78-8bf4-c3983a7730d6", "issueIdentifier": "PAP-12424", "issueTitle": "Work Timeline \u2014 Phase C: frontend Gantt component + Timeline page", "start": "2026-07-02T15:41:30.586Z", "end": null, "status": "running", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "20f1e461-b4b6-4d5f-b897-6f60656429fb", "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", "issueIdentifier": "PAP-12298", "issueTitle": "blog page redesign", "start": "2026-07-02T15:35:56.578Z", "end": "2026-07-02T15:38:40.543Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:a9f9a532-690a-45c2-8da7-64789e7cbb06", "laneHint": "automation", "runId": "040ce2ef-acd4-47f2-bcf1-5a1194f5a450", "issueId": "e0189c44-1245-4526-9423-1128862d7c95", "issueIdentifier": "PAP-12438", "issueTitle": "environment variables editor re-design", "start": "2026-07-02T15:34:59.718Z", "end": "2026-07-02T15:38:04.415Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "laneHint": "automation", "runId": "339694a1-6a7d-4de0-8466-43574a7c6b4d", "issueId": "b868097a-4b8a-447d-a667-a96ebd24e385", "issueIdentifier": "PAP-12422", "issueTitle": "Work Timeline \u2014 Phase A: wireframe exploration (2\u20133 directions, board picks one)", "start": "2026-07-02T15:28:13.886Z", "end": "2026-07-02T15:42:27.258Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:66b3c071-6cb8-4424-b833-9d9b6318de0b", "laneHint": "assignment", "runId": "9eb8e7ba-53b5-4d22-8f8d-cbba6a5c5bb5", "issueId": "9ae0377a-8040-4221-9376-4ae64270e3b4", "issueIdentifier": "PAP-12440", "issueTitle": "Defect (Minor): teams-catalog install is not atomic \u2014 mid-import failure can orphan skills/agents", "start": "2026-07-02T15:27:39.339Z", "end": "2026-07-02T15:37:04.236Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "laneHint": "automation", "runId": "2bd1ec14-ecbb-42d8-8412-106a251780b8", "issueId": "e0189c44-1245-4526-9423-1128862d7c95", "issueIdentifier": "PAP-12438", "issueTitle": "environment variables editor re-design", "start": "2026-07-02T15:26:14.310Z", "end": "2026-07-02T15:34:59.661Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "automation"}, {"actorId": "agent:6670e11b-91d3-4429-82e0-436b88b51808", "laneHint": "assignment", "runId": "1b734867-943b-4ef3-a10e-3f2c1edcad93", "issueId": "f5b13e28-cbe4-40e5-bd29-d88eb8ea0ee3", "issueIdentifier": "PAP-12439", "issueTitle": "Wireframes: Environment Variables Editor redesign", "start": "2026-07-02T15:25:07.982Z", "end": "2026-07-02T15:32:24.457Z", "status": "succeeded", "retryOfRunId": null, "continuationAttempt": 0, "invocationSource": "assignment"}, {"actorId": "agent:3108ef8e-5ed0-41d9-b561-6b41c41b8545", "laneHint": "automation", "runId": "88e7b7a9-4ea6-40bc-b402-ffd731a3dfe2", "issueId": "1ae0d728-2f4e-4ec9-ac95-d006a7f19741", "issueIdentifier": "PAP-12298", "issueTitle": "blog page redesign", "start": "2026-07-02T15:23:28.308Z", "end": "2026-07-02T15:29:09.108Z", "status": "succeeded", "retrLine truncated
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { WorkTimelineResult } from "@paperclipai/shared";
|
||||
import { Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
WorkTimelineChart,
|
||||
clampZoomScale,
|
||||
nearestZoomForScale,
|
||||
type ZoomLevel,
|
||||
zoomScaleForLevel,
|
||||
} from "@/components/timeline/WorkTimelineChart";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import sampleJson from "../fixtures/workTimeline.sample.json";
|
||||
import humanSampleJson from "../fixtures/workTimeline.human.sample.json";
|
||||
|
||||
const sample = sampleJson as unknown as WorkTimelineResult;
|
||||
// A second real slice (2026-07-02 14:00–16:00Z) captured straight from the live
|
||||
// `/timeline` endpoint that DOES carry human events — Dotta's created / commented /
|
||||
// approved / delegated actions provide human participation and kickoff context.
|
||||
const humanSample = humanSampleJson as unknown as WorkTimelineResult;
|
||||
// The fixture is a real slice of PAP company activity (2026-07-02 14:00–15:50Z);
|
||||
// pin "now" to the window end so in-progress runs fade correctly.
|
||||
const NOW = new Date("2026-07-02T15:45:00.000Z").getTime();
|
||||
|
||||
function TimelineHarness({
|
||||
initialZoom = "day" as ZoomLevel,
|
||||
data = sample,
|
||||
now = NOW,
|
||||
}: {
|
||||
initialZoom?: ZoomLevel;
|
||||
data?: WorkTimelineResult;
|
||||
now?: number;
|
||||
}) {
|
||||
const [zoom, setZoom] = useState<ZoomLevel>(initialZoom);
|
||||
const [zoomScale, setZoomScale] = useState<number | undefined>(undefined);
|
||||
|
||||
const adjustZoom = (factor: number) => {
|
||||
const nextScale = clampZoomScale((zoomScale ?? zoomScaleForLevel(zoom)) * factor);
|
||||
setZoomScale(nextScale);
|
||||
setZoom(nearestZoomForScale(nextScale));
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
setZoom(initialZoom);
|
||||
setZoomScale(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6 text-foreground">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Work Timeline</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-1" aria-label="Timeline zoom controls">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
onClick={() => adjustZoom(0.8)}
|
||||
aria-label="Zoom out"
|
||||
title="Zoom out"
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
onClick={() => adjustZoom(1.25)}
|
||||
aria-label="Zoom in"
|
||||
title="Zoom in"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
onClick={resetZoom}
|
||||
aria-label="Reset zoom"
|
||||
title="Reset zoom"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<WorkTimelineChart
|
||||
data={data}
|
||||
zoom={zoom}
|
||||
zoomScale={zoomScale}
|
||||
nowMs={now}
|
||||
onZoomScaleChange={(nextScale, nextZoom = nearestZoomForScale(nextScale)) => {
|
||||
setZoomScale(nextScale);
|
||||
setZoom(nextZoom);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data.spans.length} runs · {data.actors.length} actors · {data.events.length} human/instant events · real
|
||||
company data
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof TimelineHarness> = {
|
||||
title: "Pages/Work Timeline",
|
||||
component: TimelineHarness,
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof TimelineHarness>;
|
||||
|
||||
export const HourZoom: Story = { args: { initialZoom: "hour" } };
|
||||
export const DayZoom: Story = { args: { initialZoom: "day" } };
|
||||
// Live slice that carries human-originated activity and delegation context.
|
||||
export const WithHumanActivity: Story = {
|
||||
args: {
|
||||
initialZoom: "hour",
|
||||
data: humanSample,
|
||||
now: new Date("2026-07-02T16:00:00.000Z").getTime(),
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user