diff --git a/packages/db/src/migrations/0133_resource_membership_stars.sql b/packages/db/src/migrations/0133_resource_membership_stars.sql new file mode 100644 index 0000000000..b2ef82d239 --- /dev/null +++ b/packages/db/src/migrations/0133_resource_membership_stars.sql @@ -0,0 +1,7 @@ +ALTER TABLE "agent_memberships" ADD COLUMN IF NOT EXISTS "starred_at" timestamp with time zone; +--> statement-breakpoint +ALTER TABLE "project_memberships" ADD COLUMN IF NOT EXISTS "starred_at" timestamp with time zone; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "agent_memberships_company_user_starred_idx" ON "agent_memberships" USING btree ("company_id","user_id","starred_at"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "project_memberships_company_user_starred_idx" ON "project_memberships" USING btree ("company_id","user_id","starred_at"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index f1094e4725..0152df8813 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -925,6 +925,13 @@ "when": 1783025424120, "tag": "0132_issue_comment_derived_attribution_fast", "breakpoints": true + }, + { + "idx": 133, + "version": "7", + "when": 1783034521000, + "tag": "0133_resource_membership_stars", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/agent_memberships.ts b/packages/db/src/schema/agent_memberships.ts index 8ff4a87a6f..b609ac6acb 100644 --- a/packages/db/src/schema/agent_memberships.ts +++ b/packages/db/src/schema/agent_memberships.ts @@ -10,11 +10,17 @@ export const agentMemberships = pgTable( agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }), userId: text("user_id").notNull(), state: text("state").notNull().default("joined"), + starredAt: timestamp("starred_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => ({ companyUserIdx: index("agent_memberships_company_user_idx").on(table.companyId, table.userId), + companyUserStarredIdx: index("agent_memberships_company_user_starred_idx").on( + table.companyId, + table.userId, + table.starredAt, + ), agentIdx: index("agent_memberships_agent_idx").on(table.agentId), companyUserAgentUq: uniqueIndex("agent_memberships_company_user_agent_uq").on( table.companyId, diff --git a/packages/db/src/schema/project_memberships.ts b/packages/db/src/schema/project_memberships.ts index 0e61294cd6..0f04bcdb1c 100644 --- a/packages/db/src/schema/project_memberships.ts +++ b/packages/db/src/schema/project_memberships.ts @@ -10,11 +10,17 @@ export const projectMemberships = pgTable( projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), userId: text("user_id").notNull(), state: text("state").notNull().default("joined"), + starredAt: timestamp("starred_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => ({ companyUserIdx: index("project_memberships_company_user_idx").on(table.companyId, table.userId), + companyUserStarredIdx: index("project_memberships_company_user_starred_idx").on( + table.companyId, + table.userId, + table.starredAt, + ), projectIdx: index("project_memberships_project_idx").on(table.projectId), companyUserProjectUq: uniqueIndex("project_memberships_company_user_project_uq").on( table.companyId, diff --git a/packages/shared/src/resource-memberships.test.ts b/packages/shared/src/resource-memberships.test.ts new file mode 100644 index 0000000000..012c1db7f0 --- /dev/null +++ b/packages/shared/src/resource-memberships.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { updateResourceMembershipSchema } from "./validators/resource-memberships.js"; + +describe("resource membership contract", () => { + it("accepts legacy state-only membership updates", () => { + expect(updateResourceMembershipSchema.parse({ state: "left" })).toEqual({ state: "left" }); + expect(updateResourceMembershipSchema.parse({ state: "joined" })).toEqual({ state: "joined" }); + }); + + it("accepts star-only updates without requiring a state mutation", () => { + expect(updateResourceMembershipSchema.parse({ starred: true })).toEqual({ starred: true }); + expect(updateResourceMembershipSchema.parse({ starred: false })).toEqual({ starred: false }); + }); + + it("rejects empty or contradictory star/state updates", () => { + expect(() => updateResourceMembershipSchema.parse({})).toThrow("state or starred is required"); + expect(() => updateResourceMembershipSchema.parse({ state: "left", starred: true })).toThrow( + "starred resources must be joined", + ); + }); +}); diff --git a/packages/shared/src/types/resource-memberships.ts b/packages/shared/src/types/resource-memberships.ts index 5830ed8f0c..b965c96475 100644 --- a/packages/shared/src/types/resource-memberships.ts +++ b/packages/shared/src/types/resource-memberships.ts @@ -6,16 +6,22 @@ export type ResourceMembershipResourceType = "project" | "agent"; export interface ResourceMemberships { projectMemberships: Record; agentMemberships: Record; + starredProjectIds?: string[]; + starredAgentIds?: string[]; + projectStarredAt?: Record; + agentStarredAt?: Record; updatedAt: Date | null; } export interface UpdateResourceMembership { - state: ResourceMembershipState; + state?: ResourceMembershipState; + starred?: boolean; } export interface ResourceMembershipUpdateResult { resourceType: ResourceMembershipResourceType; resourceId: string; state: ResourceMembershipState; + starredAt: Date | null; updatedAt: Date; } diff --git a/packages/shared/src/validators/resource-memberships.ts b/packages/shared/src/validators/resource-memberships.ts index 81cd40906f..8f96b97683 100644 --- a/packages/shared/src/validators/resource-memberships.ts +++ b/packages/shared/src/validators/resource-memberships.ts @@ -4,7 +4,13 @@ import { RESOURCE_MEMBERSHIP_STATES } from "../types/resource-memberships.js"; export const resourceMembershipStateSchema = z.enum(RESOURCE_MEMBERSHIP_STATES); export const updateResourceMembershipSchema = z.object({ - state: resourceMembershipStateSchema, + state: resourceMembershipStateSchema.optional(), + starred: z.boolean().optional(), +}).refine((value) => value.state !== undefined || value.starred !== undefined, { + message: "state or starred is required", +}).refine((value) => !(value.state === "left" && value.starred === true), { + message: "starred resources must be joined", + path: ["starred"], }); export type UpdateResourceMembership = z.infer; diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index 0fe3a2625d..5e67daacbe 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -435,8 +435,60 @@ list_base_node_modules_paths() { ! -path './.paperclip/*' \ | sed 's#^\./##' } + +compute_pnpm_install_fingerprint() { + WORKTREE_CWD="$worktree_cwd" node <<'EOF' +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); + +const root = process.env.WORKTREE_CWD; +const ignoredDirs = new Set([".git", ".paperclip", "node_modules", "dist", "storybook-static"]); +const files = []; + +function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (ignoredDirs.has(entry.name)) continue; + + const absolutePath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(absolutePath); + continue; + } + + if ( + entry.isFile() + && (entry.name === "package.json" || entry.name === "pnpm-lock.yaml" || entry.name === "pnpm-workspace.yaml") + ) { + files.push(absolutePath); + } + } +} + +walk(root); +files.sort((left, right) => path.relative(root, left).localeCompare(path.relative(root, right))); + +const hash = crypto.createHash("sha256"); +for (const file of files) { + const relativePath = path.relative(root, file).replaceAll(path.sep, "/"); + hash.update(relativePath); + hash.update("\0"); + hash.update(fs.readFileSync(file)); + hash.update("\0"); +} + +process.stdout.write(hash.digest("hex")); +EOF +} + if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; then needs_install=0 + install_fingerprint_path="$paperclip_dir/pnpm-install-fingerprint" + current_install_fingerprint="$(compute_pnpm_install_fingerprint)" + previous_install_fingerprint="" + if [[ -f "$install_fingerprint_path" ]]; then + previous_install_fingerprint="$(cat "$install_fingerprint_path")" + fi while IFS= read -r relative_path; do [[ -n "$relative_path" ]] || continue @@ -448,6 +500,10 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t fi done < <(list_base_node_modules_paths) + if [[ "$needs_install" -eq 0 && "$current_install_fingerprint" != "$previous_install_fingerprint" ]]; then + needs_install=1 + fi + if [[ "$needs_install" -eq 1 ]]; then backup_suffix=".paperclip-backup-${BASHPID:-$$}" moved_symlink_paths=() @@ -492,7 +548,7 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t if ( cd "$worktree_cwd" - pnpm install "$@" + pnpm install --prod=false "$@" ) >"$stdout_path" 2>"$stderr_path"; then cat "$stdout_path" cat "$stderr_path" >&2 @@ -529,6 +585,8 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t fi cleanup_moved_symlinks + current_install_fingerprint="$(compute_pnpm_install_fingerprint)" + printf '%s\n' "$current_install_fingerprint" >"$install_fingerprint_path" fi exit 0 diff --git a/server/src/__tests__/resource-memberships-routes.test.ts b/server/src/__tests__/resource-memberships-routes.test.ts index 9b0278ef17..649b5d9ac8 100644 --- a/server/src/__tests__/resource-memberships-routes.test.ts +++ b/server/src/__tests__/resource-memberships-routes.test.ts @@ -78,8 +78,10 @@ describeEmbeddedPostgres("resource membership routes", () => { const otherCompanyId = randomUUID(); const projectId = randomUUID(); const otherProjectId = randomUUID(); + const archivedProjectId = randomUUID(); const agentId = randomUUID(); const otherAgentId = randomUUID(); + const terminatedAgentId = randomUUID(); await db.insert(companies).values([ { id: companyId, @@ -96,6 +98,7 @@ describeEmbeddedPostgres("resource membership routes", () => { ]); await db.insert(projects).values([ { id: projectId, companyId, name: "Growth", status: "in_progress" }, + { id: archivedProjectId, companyId, name: "Archived", status: "completed", archivedAt: new Date() }, { id: otherProjectId, companyId: otherCompanyId, name: "Other", status: "in_progress" }, ]); await db.insert(agents).values([ @@ -121,8 +124,19 @@ describeEmbeddedPostgres("resource membership routes", () => { runtimeConfig: {}, permissions: {}, }, + { + id: terminatedAgentId, + companyId, + name: "Terminated", + role: "engineer", + status: "terminated", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, ]); - return { companyId, otherAgentId, otherProjectId, projectId, agentId }; + return { archivedProjectId, companyId, otherAgentId, otherProjectId, projectId, agentId, terminatedAgentId }; } it("defaults missing membership rows to joined", async () => { @@ -135,6 +149,10 @@ describeEmbeddedPostgres("resource membership routes", () => { expect(res.body).toEqual({ projectMemberships: {}, agentMemberships: {}, + starredProjectIds: [], + starredAgentIds: [], + projectStarredAt: {}, + agentStarredAt: {}, updatedAt: null, }); }); @@ -151,7 +169,7 @@ describeEmbeddedPostgres("resource membership routes", () => { .send({ state: "left" }); expect(first.status).toBe(200); - expect(first.body).toMatchObject({ resourceType: "project", resourceId: projectId, state: "left" }); + expect(first.body).toMatchObject({ resourceType: "project", resourceId: projectId, state: "left", starredAt: null }); expect(second.status).toBe(200); const rows = await db.select().from(projectMemberships); @@ -170,6 +188,174 @@ describeEmbeddedPostgres("resource membership routes", () => { }); }); + it("stars projects idempotently and exposes starred project contract data", async () => { + const { companyId, projectId } = await seed(); + const app = createApp(db, boardActor(companyId, "viewer")); + + const first = await request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/projects/${projectId}`) + .send({ starred: true }); + const second = await request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/projects/${projectId}`) + .send({ starred: true }); + const list = await request(app).get(`/api/companies/${companyId}/resource-memberships/me`); + + expect(first.status).toBe(200); + expect(first.body).toMatchObject({ resourceType: "project", resourceId: projectId, state: "joined" }); + expect(first.body.starredAt).toEqual(expect.any(String)); + expect(second.status).toBe(200); + expect(second.body.starredAt).toBe(first.body.starredAt); + expect(list.body.starredProjectIds).toEqual([projectId]); + expect(list.body.projectStarredAt[projectId]).toEqual(first.body.starredAt); + + const rows = await db.select().from(projectMemberships); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ companyId, projectId, userId: "user-1", state: "joined" }); + expect(rows[0]?.starredAt).toBeInstanceOf(Date); + + const activity = await db.select().from(activityLog); + expect(activity).toHaveLength(1); + expect(activity[0]).toMatchObject({ + action: "resource_membership.starred", + entityType: "project", + entityId: projectId, + }); + expect(activity[0]?.details).toMatchObject({ + userId: "user-1", + resourceType: "project", + resourceId: projectId, + state: "joined", + starred: true, + }); + }); + + it("clears starred_at when leaving a starred resource", async () => { + const { companyId, projectId } = await seed(); + const app = createApp(db, boardActor(companyId)); + + await request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/projects/${projectId}`) + .send({ starred: true }) + .expect(200); + const leave = await request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/projects/${projectId}`) + .send({ state: "left" }); + + expect(leave.status).toBe(200); + expect(leave.body).toMatchObject({ state: "left", starredAt: null }); + const [row] = await db.select().from(projectMemberships); + expect(row).toMatchObject({ state: "left", starredAt: null }); + + const activity = await db.select().from(activityLog); + expect(activity.map((entry) => entry.action)).toEqual([ + "resource_membership.starred", + "resource_membership.left", + ]); + expect(activity[1]?.details).toMatchObject({ state: "left", starred: false, starredAt: null }); + }); + + it("starring a left resource rejoins it", async () => { + const { companyId, agentId } = await seed(); + const app = createApp(db, boardActor(companyId)); + + await request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/agents/${agentId}`) + .send({ state: "left" }) + .expect(200); + const star = await request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/agents/${agentId}`) + .send({ starred: true }); + + expect(star.status).toBe(200); + expect(star.body).toMatchObject({ resourceType: "agent", resourceId: agentId, state: "joined" }); + expect(star.body.starredAt).toEqual(expect.any(String)); + + const [row] = await db.select().from(agentMemberships); + expect(row).toMatchObject({ state: "joined" }); + expect(row?.starredAt).toBeInstanceOf(Date); + + const activity = await db.select().from(activityLog); + expect(activity.map((entry) => entry.action)).toEqual([ + "resource_membership.left", + "resource_membership.starred", + ]); + }); + + it("unstars agents idempotently without requiring a state change", async () => { + const { companyId, agentId } = await seed(); + const app = createApp(db, boardActor(companyId)); + + await request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/agents/${agentId}`) + .send({ starred: true }) + .expect(200); + const first = await request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/agents/${agentId}`) + .send({ starred: false }); + const second = await request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/agents/${agentId}`) + .send({ starred: false }); + + expect(first.status).toBe(200); + expect(first.body).toMatchObject({ state: "joined", starredAt: null }); + expect(second.status).toBe(200); + expect(second.body).toMatchObject({ state: "joined", starredAt: null }); + + const [row] = await db.select().from(agentMemberships); + expect(row).toMatchObject({ state: "joined", starredAt: null }); + + const activity = await db.select().from(activityLog); + expect(activity.map((entry) => entry.action)).toEqual([ + "resource_membership.starred", + "resource_membership.unstarred", + ]); + }); + + it("omits archived projects and terminated agents from starred sidebar data", async () => { + const { archivedProjectId, companyId, terminatedAgentId } = await seed(); + const starredAt = new Date(); + await db.insert(projectMemberships).values({ + companyId, + projectId: archivedProjectId, + userId: "user-1", + state: "joined", + starredAt, + }); + await db.insert(agentMemberships).values({ + companyId, + agentId: terminatedAgentId, + userId: "user-1", + state: "joined", + starredAt, + }); + const app = createApp(db, boardActor(companyId)); + + const res = await request(app).get(`/api/companies/${companyId}/resource-memberships/me`); + + expect(res.status).toBe(200); + expect(res.body.projectMemberships[archivedProjectId]).toBe("joined"); + expect(res.body.agentMemberships[terminatedAgentId]).toBe("joined"); + expect(res.body.starredProjectIds).toEqual([]); + expect(res.body.starredAgentIds).toEqual([]); + expect(res.body.projectStarredAt).toEqual({}); + expect(res.body.agentStarredAt).toEqual({}); + }); + + it("rejects starring archived projects and terminated agents", async () => { + const { archivedProjectId, companyId, terminatedAgentId } = await seed(); + const app = createApp(db, boardActor(companyId)); + + const projectRes = await request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/projects/${archivedProjectId}`) + .send({ starred: true }); + const agentRes = await request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/agents/${terminatedAgentId}`) + .send({ starred: true }); + + expect(projectRes.status).toBe(404); + expect(agentRes.status).toBe(404); + }); + it("rejects agent API key actors", async () => { const { companyId, agentId } = await seed(); const app = createApp(db, { diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 4c8ac90980..c936b95f7b 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -1500,6 +1500,99 @@ describe("realizeExecutionWorkspace", () => { ); }, 30_000); + it("reinstalls worktree-local pnpm dependencies when package metadata changes", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-stale-deps-")); + const baseRoot = path.join(tempRoot, "base"); + const worktreeRoot = path.join(tempRoot, "worktree"); + const fakeBin = path.join(tempRoot, "bin"); + const fakePnpmPath = path.join(fakeBin, "pnpm"); + const scriptPath = path.join(worktreeRoot, "provision-worktree.sh"); + const installLogPath = path.join(tempRoot, "install.log"); + + try { + await fs.mkdir(path.join(baseRoot, "node_modules"), { recursive: true }); + await fs.mkdir(path.join(worktreeRoot, "node_modules"), { recursive: true }); + await fs.mkdir(path.join(worktreeRoot, "ui"), { recursive: true }); + await fs.mkdir(fakeBin, { recursive: true }); + await fs.copyFile(provisionWorktreeScriptPath, scriptPath); + await fs.chmod(scriptPath, 0o755); + await fs.writeFile( + path.join(worktreeRoot, "package.json"), + JSON.stringify( + { + name: "workspace-root", + private: true, + packageManager: "pnpm@9.15.4", + }, + null, + 2, + ), + "utf8", + ); + await fs.writeFile( + path.join(worktreeRoot, "pnpm-lock.yaml"), + ["lockfileVersion: '9.0'", "", "importers:", " .: {}", ""].join("\n"), + "utf8", + ); + await fs.writeFile( + path.join(worktreeRoot, "ui", "package.json"), + JSON.stringify({ name: "ui", private: true, dependencies: {} }, null, 2), + "utf8", + ); + await fs.writeFile( + fakePnpmPath, + [ + "#!/bin/sh", + "if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then", + " exit 1", + "fi", + "if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--prod=false\" ] && [ \"$3\" = \"--frozen-lockfile\" ]; then", + " mkdir -p \"$PWD/node_modules\"", + ` echo "install:$*" >> ${JSON.stringify(installLogPath)}`, + " exit 0", + "fi", + "exit 1", + "", + ].join("\n"), + "utf8", + ); + await fs.chmod(fakePnpmPath, 0o755); + + const runScript = () => execFileAsync(scriptPath, [], { + cwd: worktreeRoot, + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + PAPERCLIP_WORKSPACE_BASE_CWD: baseRoot, + PAPERCLIP_WORKSPACE_CWD: worktreeRoot, + }, + }); + + await runScript(); + await runScript(); + await expect(fs.readFile(installLogPath, "utf8")).resolves.toBe( + "install:install --prod=false --frozen-lockfile\n", + ); + + await fs.writeFile( + path.join(worktreeRoot, "ui", "package.json"), + JSON.stringify( + { name: "ui", private: true, dependencies: { "@xterm/addon-fit": "^0.11.0" } }, + null, + 2, + ), + "utf8", + ); + + await runScript(); + await expect(fs.readFile(installLogPath, "utf8")).resolves.toBe( + "install:install --prod=false --frozen-lockfile\ninstall:install --prod=false --frozen-lockfile\n", + ); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }, 30_000); + it("fails instead of writing an unseeded fallback config when worktree init errors after CLI detection succeeds", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-provision-fail-")); const baseRoot = path.join(tempRoot, "base"); @@ -1686,11 +1779,11 @@ describe("realizeExecutionWorkspace", () => { "if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then", " exit 1", "fi", - "if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--frozen-lockfile\" ]; then", + "if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--prod=false\" ] && [ \"$3\" = \"--frozen-lockfile\" ]; then", " echo \"ERR_PNPM_OUTDATED_LOCKFILE\" >&2", " exit 1", "fi", - "if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--no-frozen-lockfile\" ]; then", + "if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--prod=false\" ] && [ \"$3\" = \"--no-frozen-lockfile\" ]; then", " mkdir -p \"$PWD/node_modules\"", " : > \"$PWD/node_modules/.retry-success\"", " exit 0", diff --git a/server/src/routes/resource-memberships.ts b/server/src/routes/resource-memberships.ts index 38f1e7f734..5a64d69e40 100644 --- a/server/src/routes/resource-memberships.ts +++ b/server/src/routes/resource-memberships.ts @@ -22,6 +22,8 @@ async function logMembershipChange( resourceType: "project" | "agent"; resourceId: string; state: "joined" | "left"; + starredAt: Date | null; + changeKind: "joined" | "left" | "starred" | "unstarred"; policySource: string; }, ) { @@ -32,7 +34,7 @@ async function logMembershipChange( actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, - action: `resource_membership.${input.state}`, + action: `resource_membership.${input.changeKind}`, entityType: input.resourceType, entityId: input.resourceId, details: { @@ -40,6 +42,8 @@ async function logMembershipChange( resourceType: input.resourceType, resourceId: input.resourceId, state: input.state, + starredAt: input.starredAt, + starred: input.starredAt !== null, policySource: input.policySource, }, }); @@ -69,19 +73,22 @@ export function resourceMembershipRoutes(db: Db) { projectId, userId, state: req.body.state, + starred: req.body.starred, actor: req.actor, }); - if (result.changed) { + if (result.changed && result.changeKind) { await logMembershipChange(db, req, { companyId, userId, resourceType: "project", resourceId: projectId, state: result.state, + starredAt: result.starredAt, + changeKind: result.changeKind, policySource: result.policySource, }); } - const { changed: _changed, policySource: _policySource, ...response } = result; + const { changed: _changed, changeKind: _changeKind, policySource: _policySource, ...response } = result; res.json(response); }, ); @@ -99,19 +106,22 @@ export function resourceMembershipRoutes(db: Db) { agentId, userId, state: req.body.state, + starred: req.body.starred, actor: req.actor, }); - if (result.changed) { + if (result.changed && result.changeKind) { await logMembershipChange(db, req, { companyId, userId, resourceType: "agent", resourceId: agentId, state: result.state, + starredAt: result.starredAt, + changeKind: result.changeKind, policySource: result.policySource, }); } - const { changed: _changed, policySource: _policySource, ...response } = result; + const { changed: _changed, changeKind: _changeKind, policySource: _policySource, ...response } = result; res.json(response); }, ); diff --git a/server/src/services/resource-memberships.ts b/server/src/services/resource-memberships.ts index 3c7dffc4ad..0e74123bb5 100644 --- a/server/src/services/resource-memberships.ts +++ b/server/src/services/resource-memberships.ts @@ -41,12 +41,21 @@ export type ResourceMembershipPolicyHook = (input: { resourceType: ResourceMembershipResourceType; resourceId: string; state: ResourceMembershipState; + starred?: boolean; }) => Promise | PolicyDecision; type ResourceMembershipServiceOptions = { policyHook?: ResourceMembershipPolicyHook | null; }; +type MembershipChangeKind = ResourceMembershipState | "starred" | "unstarred"; + +type MembershipUpdateResult = ResourceMembershipUpdateResult & { + changed: boolean; + changeKind: MembershipChangeKind | null; + policySource: string; +}; + function defaultJoinedMap( rows: T[], key: "projectId" | "agentId", @@ -60,6 +69,30 @@ function defaultJoinedMap( + rows: T[], + key: "projectId" | "agentId", +): Record { + const result: Record = {}; + for (const row of rows) { + const id = row[key]; + if (typeof id !== "string" || !row.starredAt) continue; + result[id] = row.starredAt; + } + return result; +} + +function starredIds( + rows: T[], + key: "projectId" | "agentId", +): string[] { + return rows + .filter((row) => row.starredAt) + .sort((a, b) => b.starredAt!.getTime() - a.starredAt!.getTime()) + .map((row) => row[key]) + .filter((id): id is string => typeof id === "string"); +} + function latestDate(...dates: Array): Date | null { let latest: Date | null = null; for (const date of dates) { @@ -116,6 +149,7 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer resourceType: ResourceMembershipResourceType; resourceId: string; state: ResourceMembershipState; + starred?: boolean; }): Promise { assertBoardSelfMembershipAccess(input.actor, input.companyId, input.userId); const decision = await evaluatePolicy(policyHook, input); @@ -144,9 +178,15 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer .select({ projectId: projectMemberships.projectId, state: projectMemberships.state, + starredAt: projectMemberships.starredAt, updatedAt: projectMemberships.updatedAt, + projectArchivedAt: projects.archivedAt, }) .from(projectMemberships) + .innerJoin(projects, and( + eq(projects.id, projectMemberships.projectId), + eq(projects.companyId, projectMemberships.companyId), + )) .where(and( eq(projectMemberships.companyId, companyId), eq(projectMemberships.userId, userId), @@ -155,17 +195,29 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer .select({ agentId: agentMemberships.agentId, state: agentMemberships.state, + starredAt: agentMemberships.starredAt, updatedAt: agentMemberships.updatedAt, + agentStatus: agents.status, }) .from(agentMemberships) + .innerJoin(agents, and( + eq(agents.id, agentMemberships.agentId), + eq(agents.companyId, agentMemberships.companyId), + )) .where(and( eq(agentMemberships.companyId, companyId), eq(agentMemberships.userId, userId), )), ]); + const starEligibleProjectRows = projectRows.filter((row) => row.starredAt && !row.projectArchivedAt); + const starEligibleAgentRows = agentRows.filter((row) => row.starredAt && row.agentStatus !== "terminated"); return { projectMemberships: defaultJoinedMap(projectRows, "projectId"), agentMemberships: defaultJoinedMap(agentRows, "agentId"), + starredProjectIds: starredIds(starEligibleProjectRows, "projectId"), + starredAgentIds: starredIds(starEligibleAgentRows, "agentId"), + projectStarredAt: starredAtMap(starEligibleProjectRows, "projectId"), + agentStarredAt: starredAtMap(starEligibleAgentRows, "agentId"), updatedAt: latestDate( ...projectRows.map((row) => row.updatedAt), ...agentRows.map((row) => row.updatedAt), @@ -177,24 +229,17 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer companyId: string; userId: string; projectId: string; - state: ResourceMembershipState; + state?: ResourceMembershipState; + starred?: boolean; actor: BoardActor; - }): Promise { + }): Promise { const project = await db.query.projects.findFirst({ where: and( eq(projects.id, input.projectId), eq(projects.companyId, input.companyId), ), }); - if (!project) throw notFound("Project not found"); - const decision = await assertMutationAllowed({ - actor: input.actor, - companyId: input.companyId, - userId: input.userId, - resourceType: "project", - resourceId: input.projectId, - state: input.state, - }); + if (!project || project.archivedAt) throw notFound("Project not found"); const existing = await db.query.projectMemberships.findFirst({ where: and( @@ -204,13 +249,36 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer ), }); const previousState: ResourceMembershipState = existing?.state === "left" ? "left" : "joined"; - if (previousState === input.state) { + const previousStarredAt = existing?.starredAt ?? null; + const nextState: ResourceMembershipState = input.starred === true ? "joined" : input.state ?? previousState; + const nextStarredAt = nextState === "left" + ? null + : input.starred === true + ? previousStarredAt ?? new Date() + : input.starred === false + ? null + : previousStarredAt; + const stateChanged = previousState !== nextState; + const starredChanged = (previousStarredAt?.getTime() ?? null) !== (nextStarredAt?.getTime() ?? null); + const decision = await assertMutationAllowed({ + actor: input.actor, + companyId: input.companyId, + userId: input.userId, + resourceType: "project", + resourceId: input.projectId, + state: nextState, + starred: input.starred, + }); + + if (!stateChanged && !starredChanged) { return { resourceType: "project", resourceId: input.projectId, - state: input.state, + state: nextState, + starredAt: previousStarredAt, updatedAt: existing?.updatedAt ?? new Date(), changed: false, + changeKind: null, policySource: decision.source ?? "oss_default", }; } @@ -222,13 +290,15 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer companyId: input.companyId, projectId: input.projectId, userId: input.userId, - state: input.state, + state: nextState, + starredAt: nextStarredAt, updatedAt: now, }) .onConflictDoUpdate({ target: [projectMemberships.companyId, projectMemberships.userId, projectMemberships.projectId], set: { - state: input.state, + state: nextState, + starredAt: nextStarredAt, updatedAt: now, }, }) @@ -238,8 +308,12 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer resourceType: "project", resourceId: input.projectId, state: row?.state === "left" ? "left" : "joined", + starredAt: row?.starredAt ?? null, updatedAt: row?.updatedAt ?? now, changed: true, + changeKind: input.starred !== undefined && starredChanged + ? input.starred ? "starred" : "unstarred" + : stateChanged ? nextState : nextStarredAt ? "starred" : "unstarred", policySource: decision.source ?? "oss_default", }; }, @@ -248,24 +322,17 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer companyId: string; userId: string; agentId: string; - state: ResourceMembershipState; + state?: ResourceMembershipState; + starred?: boolean; actor: BoardActor; - }): Promise { + }): Promise { const agent = await db.query.agents.findFirst({ where: and( eq(agents.id, input.agentId), eq(agents.companyId, input.companyId), ), }); - if (!agent) throw notFound("Agent not found"); - const decision = await assertMutationAllowed({ - actor: input.actor, - companyId: input.companyId, - userId: input.userId, - resourceType: "agent", - resourceId: input.agentId, - state: input.state, - }); + if (!agent || agent.status === "terminated") throw notFound("Agent not found"); const existing = await db.query.agentMemberships.findFirst({ where: and( @@ -275,13 +342,36 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer ), }); const previousState: ResourceMembershipState = existing?.state === "left" ? "left" : "joined"; - if (previousState === input.state) { + const previousStarredAt = existing?.starredAt ?? null; + const nextState: ResourceMembershipState = input.starred === true ? "joined" : input.state ?? previousState; + const nextStarredAt = nextState === "left" + ? null + : input.starred === true + ? previousStarredAt ?? new Date() + : input.starred === false + ? null + : previousStarredAt; + const stateChanged = previousState !== nextState; + const starredChanged = (previousStarredAt?.getTime() ?? null) !== (nextStarredAt?.getTime() ?? null); + const decision = await assertMutationAllowed({ + actor: input.actor, + companyId: input.companyId, + userId: input.userId, + resourceType: "agent", + resourceId: input.agentId, + state: nextState, + starred: input.starred, + }); + + if (!stateChanged && !starredChanged) { return { resourceType: "agent", resourceId: input.agentId, - state: input.state, + state: nextState, + starredAt: previousStarredAt, updatedAt: existing?.updatedAt ?? new Date(), changed: false, + changeKind: null, policySource: decision.source ?? "oss_default", }; } @@ -293,13 +383,15 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer companyId: input.companyId, agentId: input.agentId, userId: input.userId, - state: input.state, + state: nextState, + starredAt: nextStarredAt, updatedAt: now, }) .onConflictDoUpdate({ target: [agentMemberships.companyId, agentMemberships.userId, agentMemberships.agentId], set: { - state: input.state, + state: nextState, + starredAt: nextStarredAt, updatedAt: now, }, }) @@ -309,8 +401,12 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer resourceType: "agent", resourceId: input.agentId, state: row?.state === "left" ? "left" : "joined", + starredAt: row?.starredAt ?? null, updatedAt: row?.updatedAt ?? now, changed: true, + changeKind: input.starred !== undefined && starredChanged + ? input.starred ? "starred" : "unstarred" + : stateChanged ? nextState : nextStarredAt ? "starred" : "unstarred", policySource: decision.source ?? "oss_default", }; }, diff --git a/ui/src/components/Sidebar.test.tsx b/ui/src/components/Sidebar.test.tsx index dfbdf9677b..077141c53a 100644 --- a/ui/src/components/Sidebar.test.tsx +++ b/ui/src/components/Sidebar.test.tsx @@ -100,6 +100,10 @@ vi.mock("./SidebarProjects", () => ({ SidebarProjects: () =>
Projects collapsible
, })); +vi.mock("./SidebarStarredProjects", () => ({ + SidebarStarredProjects: () =>
, +})); + async function flushReact() { for (let index = 0; index < 5; index += 1) { await Promise.resolve(); @@ -223,27 +227,24 @@ describe("Sidebar", () => { }); }); - it("classic (flag OFF): New Task button, Tasks label, per-project collapsible, no top-level Projects link", async () => { + it("streamlined is now standard: a stale enableStreamlinedLeftNavigation=false opt-out is ignored", async () => { + // PAP-12472 retired the experimental opt-out; the streamlined sidebar is the + // only path, so an old `false` setting no longer restores classic mode. mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false, enableStreamlinedLeftNavigation: false, }); const root = await renderSidebar(); - expect(container.textContent).toContain("New Task"); - expect(container.textContent).not.toContain("New Issue"); - const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim()); expect(navLabels).toContain("Tasks"); - expect(navLabels).not.toContain("Issues"); - // No top-level Projects nav link in classic mode (D5 option A). - expect(navLabels).not.toContain("Projects"); - - // Per-project collapsible restored below Work. - expect(container.querySelector('[data-testid="sidebar-projects"]')).not.toBeNull(); + // Top-level Projects link + starred children stay, per-project collapsible gone. + expect(navLabels).toContain("Projects"); + expect(container.querySelector('[data-testid="sidebar-projects"]')).toBeNull(); + expect(container.querySelector('[data-testid="sidebar-starred-projects"]')).not.toBeNull(); expect( container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"), - ).toBe("false"); + ).toBe("true"); flushSync(() => { root.unmount(); diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index fc5bac9d76..1cb871b51d 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -26,6 +26,7 @@ import { SidebarSection } from "./SidebarSection"; import { SidebarNavItem } from "./SidebarNavItem"; import { SidebarAgents } from "./SidebarAgents"; import { SidebarProjects } from "./SidebarProjects"; +import { SidebarStarredProjects } from "./SidebarStarredProjects"; import { useDialogActions } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { useSidebar } from "../context/SidebarContext"; @@ -59,12 +60,12 @@ export function Sidebar() { const liveRunCount = liveRuns?.length ?? 0; const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true; const showPipelines = experimentalSettings?.enablePipelines === true; - // IA flag: branch the sidebar nav presentation. Default ON = - // streamlined (top-level Projects link). Users can opt out in experiments to - // get classic (per-project collapsible, no Projects nav link). Issue/Task - // wording is split to PR #7651. Gating is navigation-only; all routes stay - // registered in both modes. - const streamlined = experimentalSettings?.enableStreamlinedLeftNavigation !== false; + // Streamlined left navigation (top-level Projects link + starred children) is + // now the standard product sidebar (PAP-12472). The former experimental + // opt-out was retired; classic per-project collapsible mode is no longer + // user-selectable. Kept as a constant so the classic branch below stays as a + // documented reference until it is fully removed. Routes are unaffected. + const streamlined = true; // Conference Room Chat flag (PAP-136/PAP-137): the Conference Room nav item // is a new surface, hidden entirely while the flag is off (same no-flash // pattern as showWorkspacesLink above). @@ -187,7 +188,10 @@ export function Sidebar() { ) : null} {streamlined ? ( - + <> + + + ) : null} { }; mockResourceMembershipsApi.listMine.mockImplementation(() => Promise.resolve(memberships)); mockResourceMembershipsApi.updateAgent.mockImplementation((_companyId, agentId, data) => { + const previousState = memberships.agentMemberships[agentId] ?? "joined"; + const nextState = data.starred === true ? "joined" : data.state ?? previousState; + const starredAgentIds = memberships.starredAgentIds ?? []; + const nextStarredAgentIds = data.starred === true + ? starredAgentIds.includes(agentId) ? starredAgentIds : [agentId, ...starredAgentIds] + : data.starred === false || nextState === "left" + ? starredAgentIds.filter((id) => id !== agentId) + : starredAgentIds; memberships = { ...memberships, agentMemberships: { ...memberships.agentMemberships, - [agentId]: data.state, + [agentId]: nextState, }, + starredAgentIds: nextStarredAgentIds, updatedAt: new Date(), }; return Promise.resolve({ resourceType: "agent", resourceId: agentId, - state: data.state, + state: nextState, + starredAt: data.starred === true ? new Date() : null, }); }); localStorage.clear(); @@ -329,6 +339,88 @@ describe("SidebarAgents", () => { expect(container.querySelector('button[aria-label="Agents section actions"]')).toBeNull(); }); + it("pins starred agents at the top without subheadings and dedupes them from the recent list", async () => { + mockAgentsApi.list.mockResolvedValue([ + makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }), + makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }), + ]); + memberships = { + projectMemberships: {}, + agentMemberships: {}, + starredProjectIds: [], + starredAgentIds: ["agent-b"], + projectStarredAt: {}, + agentStarredAt: {}, + updatedAt: new Date(), + }; + + await renderSidebarAgents(); + + expect(container.textContent).not.toContain("Starred"); + expect(container.textContent).not.toContain("Recently active"); + // Bravo is starred -> shown once at the top, deduped from recent. + const labels = agentLinkLabels(container); + expect(labels.filter((label) => label === "Bravo")).toHaveLength(1); + expect(labels).toContain("Alpha"); + // Starred order lands the starred agent first. + expect(labels[0]).toBe("Bravo"); + + // The starred row offers an explicit "Remove from starred" menu action. + await openAgentMenu("Open actions for Bravo"); + expect(document.body.textContent).toContain("Remove from starred"); + }); + + it("offers star agent from an unstarred sidebar agent menu", async () => { + await renderSidebarAgents(); + await openAgentMenu(); + + const starItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]')) + .find((element) => element.textContent?.includes("Star agent")); + expect(starItem).toBeTruthy(); + + await act(async () => { + starItem?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(mockResourceMembershipsApi.updateAgent).toHaveBeenCalledWith( + "company-1", + "agent-1", + { state: undefined, starred: true }, + ); + expect(document.body.querySelector('button[aria-label="Unstar Alpha"]')).not.toBeNull(); + }); + + it("keeps the agent starred and toasts when an unstar request fails", async () => { + mockAgentsApi.list.mockResolvedValue([makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" })]); + memberships = { + projectMemberships: {}, + agentMemberships: { "agent-b": "joined" }, + starredProjectIds: [], + starredAgentIds: ["agent-b"], + projectStarredAt: {}, + agentStarredAt: {}, + updatedAt: new Date(), + }; + mockResourceMembershipsApi.updateAgent.mockRejectedValue(new Error("nope")); + + await renderSidebarAgents(); + + const unstar = document.body.querySelector('button[aria-label="Unstar Bravo"]'); + expect(unstar).not.toBeNull(); + + await act(async () => { + unstar?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + // Optimistic unstar is rolled back → the row stays in the starred group. + expect(document.body.querySelector('button[aria-label="Unstar Bravo"]')).not.toBeNull(); + expect(mockPushToast).toHaveBeenCalledWith( + expect.objectContaining({ tone: "error" }), + ); + }); + it("keeps top mode in stored org-aware order", async () => { localStorage.setItem("paperclip.agentOrder:company-1:user-1", JSON.stringify(["agent-b", "agent-a", "agent-c"])); mockAgentsApi.list.mockResolvedValue([ diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index fc28a7c33e..f0b0ac89d0 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -9,6 +9,7 @@ import { Pencil, PlayCircle, Plus, + Star, Users, AlertTriangle, } from "lucide-react"; @@ -23,7 +24,13 @@ import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll"; import { queryKeys } from "../lib/queryKeys"; import { cn, agentRouteRef, agentUrl, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils"; import { useAgentOrder } from "../hooks/useAgentOrder"; -import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships"; +import { + isStarred, + resourceMembershipState, + starredResourceIds, + useResourceMembershipMutation, + useResourceMemberships, +} from "../hooks/useResourceMemberships"; import { AGENT_SORT_MODE_UPDATED_EVENT, getAgentSortModeStorageKey, @@ -35,6 +42,7 @@ import { import { AgentIcon } from "./AgentIconPicker"; import { BudgetSidebarMarker } from "./BudgetSidebarMarker"; import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection"; +import { StarToggle } from "./StarToggle"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -87,6 +95,10 @@ function sortAgents(agents: Agent[], sortMode: AgentSidebarSortMode): Agent[] { return sorted; } +// Sidebar star reveals with the agent row's own group, not the shared group. +const AGENT_STAR_ROW_REVEAL = + "opacity-0 transition-opacity group-hover/agent:opacity-100 group-focus-within/agent:opacity-100"; + function SidebarAgentItem({ activeAgentId, activeTab, @@ -99,6 +111,9 @@ function SidebarAgentItem({ rail, runCount, setSidebarOpen, + starred = false, + onToggleStar, + starPending = false, }: { activeAgentId: string | null; activeTab: string | null; @@ -111,6 +126,9 @@ function SidebarAgentItem({ rail: boolean; runCount: number; setSidebarOpen: (open: boolean) => void; + starred?: boolean; + onToggleStar?: (agent: Agent, starred: boolean) => void; + starPending?: boolean; }) { const routeRef = agentRouteRef(agent); const href = activeTab ? `${agentUrl(agent)}/${activeTab}` : agentUrl(agent); @@ -137,7 +155,9 @@ function SidebarAgentItem({ if (isMobile) setSidebarOpen(false); }} className={cn( - "flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 pr-8 text-[13px] font-medium transition-colors", + "flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 text-[13px] font-medium transition-colors", + // Reserve room for the ⋯ menu, plus the inline unstar star on starred rows. + starred && !isMobile ? "pr-14" : "pr-8", isActive ? "bg-accent text-foreground" : "text-foreground/80 hover:bg-accent/50 hover:text-foreground" @@ -187,6 +207,21 @@ function SidebarAgentItem({ link )} + {!rail && starred && !isMobile && onToggleStar ? ( + // Desktop: quiet inline unstar, left of the ⋯ menu, revealed on hover/focus. + + onToggleStar(agent, false)} + revealClassName={AGENT_STAR_ROW_REVEAL} + /> + + ) : null} + {!rail && ( @@ -204,7 +239,26 @@ function SidebarAgentItem({ - + + {onToggleStar ? ( + <> + { + if (starPending) return; + onToggleStar(agent, !starred); + }} + disabled={starPending} + > + {starPending ? ( + + ) : ( + + )} + {starred ? "Remove from starred" : "Star agent"} + + + + ) : null} membershipMutation.mutate({ + resourceType: "agent", + resourceId: agent.id, + resourceName: agent.name, + starred, + }), + [membershipMutation], + ); + const agentStarPending = useCallback( + (agent: Agent) => + membershipMutation.isPending && + membershipMutation.variables?.resourceType === "agent" && + membershipMutation.variables.resourceId === agent.id && + membershipMutation.variables.starred !== undefined, + [membershipMutation.isPending, membershipMutation.variables], + ); + + // Starred agents pin to the top of the section (name order), and are deduped + // out of the active/recent subset so no agent appears twice. + const starredAgentIdSet = useMemo( + () => new Set(starredResourceIds(membershipsQuery.data, "agent")), + [membershipsQuery.data], + ); + const starredAgents = useMemo( + () => sortAgents(visibleAgents.filter((agent: Agent) => starredAgentIdSet.has(agent.id)), "alphabetical"), + [visibleAgents, starredAgentIdSet], + ); + const dedupedDisplayedAgents = useMemo( + () => displayedAgents.filter((agent: Agent) => !starredAgentIdSet.has(agent.id)), + [displayedAgents, starredAgentIdSet], + ); + + const renderAgentRow = (agent: Agent, isStarredRow: boolean) => ( + pauseResumeAgent.mutate({ agent: targetAgent, action })} + rail={rail} + runCount={liveCountByAgent.get(agent.id) ?? 0} + setSidebarOpen={setSidebarOpen} + starred={isStarredRow || isStarred(membershipsQuery.data, "agent", agent.id)} + onToggleStar={toggleStarAgent} + starPending={agentStarPending(agent)} + /> + ); + return ( - {displayedAgents.map((agent: Agent) => { - const runCount = liveCountByAgent.get(agent.id) ?? 0; - return ( - pauseResumeAgent.mutate({ agent: targetAgent, action })} - rail={rail} - runCount={runCount} - setSidebarOpen={setSidebarOpen} - /> - ); - })} + {starredAgents.map((agent: Agent) => renderAgentRow(agent, true))} + {dedupedDisplayedAgents.map((agent: Agent) => renderAgentRow(agent, false))} {showSeeAllLink && (() => { const seeAllLink = ( ({ list: vi.fn() })); +const mockResourceMembershipsApi = vi.hoisted(() => ({ listMine: vi.fn(), updateProject: vi.fn() })); +const mockPushToast = vi.hoisted(() => vi.fn()); +const mockSetSidebarOpen = vi.hoisted(() => vi.fn()); +const mockSidebarState = vi.hoisted(() => ({ isMobile: false, collapsed: false, peeking: false })); + +vi.mock("@/lib/router", () => ({ + NavLink: ({ children, className, to, ...props }: { + children: ReactNode; + className?: string | ((state: { isActive: boolean }) => string); + to: string; + }) => ( + + {children} + + ), + useLocation: () => ({ pathname: "/PAP/dashboard", search: "", hash: "", state: null }), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { id: "company-1", issuePrefix: "PAP" } }), +})); + +vi.mock("../context/SidebarContext", () => ({ + useSidebar: () => ({ + isMobile: mockSidebarState.isMobile, + setSidebarOpen: mockSetSidebarOpen, + collapsed: mockSidebarState.collapsed, + peeking: mockSidebarState.peeking, + }), +})); + +vi.mock("../context/ToastContext", () => ({ + useToastActions: () => ({ pushToast: mockPushToast }), +})); + +vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi })); +vi.mock("../api/resourceMemberships", () => ({ resourceMembershipsApi: mockResourceMembershipsApi })); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +function makeProject(overrides: Partial): Project { + return { + id: "project-a", + companyId: "company-1", + urlKey: "alpha", + goalId: null, + goalIds: [], + goals: [], + name: "Alpha", + description: null, + status: "in_progress", + leadAgentId: null, + targetDate: null, + color: "#ef4444", + icon: null, + env: null, + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + codebase: { + workspaceId: null, + repoUrl: null, + repoRef: null, + defaultRef: null, + repoName: null, + localFolder: null, + managedFolder: "/tmp/project-a", + effectiveLocalFolder: "/tmp/project-a", + origin: "local_folder", + }, + workspaces: [], + primaryWorkspace: null, + managedByPlugin: null, + archivedAt: null, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + ...overrides, + }; +} + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +function projectLinkLabels(container: HTMLElement) { + return Array.from(container.querySelectorAll('a[href$="/issues"]')) + .map((anchor) => anchor.textContent?.trim()) + .filter(Boolean); +} + +describe("SidebarStarredProjects", () => { + let container: HTMLDivElement; + let root: ReturnType | null; + let queryClient: QueryClient; + let memberships: ResourceMemberships; + + beforeEach(() => { + mockSidebarState.isMobile = false; + mockSidebarState.collapsed = false; + mockSidebarState.peeking = false; + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + memberships = { + projectMemberships: {}, + agentMemberships: {}, + starredProjectIds: [], + starredAgentIds: [], + projectStarredAt: {}, + agentStarredAt: {}, + updatedAt: null, + }; + mockResourceMembershipsApi.listMine.mockImplementation(() => Promise.resolve(memberships)); + mockProjectsApi.list.mockResolvedValue([]); + }); + + afterEach(async () => { + if (root) await act(async () => root?.unmount()); + queryClient.clear(); + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + async function render() { + root = createRoot(container); + await act(async () => { + root?.render( + + + + + , + ); + }); + await flushReact(); + } + + it("renders only starred, non-archived projects with a quiet unstar control", async () => { + mockProjectsApi.list.mockResolvedValue([ + makeProject({ id: "project-a", name: "Alpha", urlKey: "alpha" }), + makeProject({ id: "project-b", name: "Bravo", urlKey: "bravo" }), + makeProject({ id: "project-c", name: "Ghost", urlKey: "ghost", archivedAt: new Date() }), + ]); + memberships = { ...memberships, starredProjectIds: ["project-b", "project-c"] }; + + await render(); + + // Only the starred, non-archived project renders (archived "Ghost" is filtered out). + expect(projectLinkLabels(container)).toEqual(["Bravo"]); + expect(document.body.querySelector('button[aria-label="Unstar Bravo"]')).not.toBeNull(); + }); + + it("renders nothing when no projects are starred", async () => { + mockProjectsApi.list.mockResolvedValue([makeProject({ id: "project-a", name: "Alpha" })]); + + await render(); + + expect(container.textContent).not.toContain("No starred projects yet"); + expect(projectLinkLabels(container)).toEqual([]); + }); +}); diff --git a/ui/src/components/SidebarStarredProjects.tsx b/ui/src/components/SidebarStarredProjects.tsx new file mode 100644 index 0000000000..85e1603d74 --- /dev/null +++ b/ui/src/components/SidebarStarredProjects.tsx @@ -0,0 +1,217 @@ +import { useCallback, useMemo } from "react"; +import { NavLink, useLocation } from "@/lib/router"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2, LogOut, MoreHorizontal, Star } from "lucide-react"; +import { useCompany } from "../context/CompanyContext"; +import { useSidebar } from "../context/SidebarContext"; +import { projectsApi } from "../api/projects"; +import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll"; +import { queryKeys } from "../lib/queryKeys"; +import { cn, projectRouteRef, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils"; +import { + isStarred, + starredResourceIds, + useResourceMembershipMutation, + useResourceMemberships, +} from "../hooks/useResourceMemberships"; +import { BudgetSidebarMarker } from "./BudgetSidebarMarker"; +import { ProjectTile } from "./ProjectTile"; +import { StarToggle } from "./StarToggle"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import type { Project } from "@paperclipai/shared"; + +// Sidebar star reveals with the row's own group, not the shared unnamed group. +const STAR_ROW_REVEAL = + "opacity-0 transition-opacity group-hover/starred-project:opacity-100 group-focus-within/starred-project:opacity-100"; + +/** + * Compact starred-project children rendered directly below the top-level + * `Projects` nav row in the streamlined sidebar. Starring/unstarring itself + * happens from browse/detail surfaces; here we only ever *remove* a star + * (plus the existing leave affordance). Archived projects are filtered out + * server-side, so a stale star never resurrects a hidden project. + */ +export function SidebarStarredProjects() { + const { selectedCompanyId } = useCompany(); + const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar(); + const rail = collapsed && !peeking; + const location = useLocation(); + + const { data: projects } = useQuery({ + queryKey: queryKeys.projects.list(selectedCompanyId!), + queryFn: () => projectsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + const membershipsQuery = useResourceMemberships(selectedCompanyId); + const membershipMutation = useResourceMembershipMutation(selectedCompanyId); + + const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/); + const activeProjectRef = projectMatch?.[1] ?? null; + + const starredProjects = useMemo(() => { + if (!membershipsQuery.isSuccess) return []; + const starredIds = new Set(starredResourceIds(membershipsQuery.data, "project")); + if (starredIds.size === 0) return []; + const byId = new Map((projects ?? []).map((project: Project) => [project.id, project])); + return Array.from(starredIds) + .map((id) => byId.get(id)) + .filter((project): project is Project => !!project && !project.archivedAt) + .sort((left, right) => + left.name.localeCompare(right.name, undefined, { sensitivity: "base" }), + ); + }, [membershipsQuery.data, membershipsQuery.isSuccess, projects]); + + const unstar = useCallback( + (project: Project) => membershipMutation.mutate({ + resourceType: "project", + resourceId: project.id, + resourceName: project.name, + starred: false, + }), + [membershipMutation], + ); + const leave = useCallback( + (project: Project) => membershipMutation.mutate({ + resourceType: "project", + resourceId: project.id, + resourceName: project.name, + state: "left", + }), + [membershipMutation], + ); + const pendingFor = useCallback( + (project: Project) => + membershipMutation.isPending && + membershipMutation.variables?.resourceType === "project" && + membershipMutation.variables.resourceId === project.id, + [membershipMutation.isPending, membershipMutation.variables], + ); + + // Don't render anything until memberships load — no skeleton flash in the nav. + if (!membershipsQuery.isSuccess) return null; + + // Empty starred groups should not add a placeholder row or extra sidebar spacing. + if (starredProjects.length === 0) { + return null; + } + + return ( +
+ {starredProjects.map((project) => { + const routeRef = projectRouteRef(project); + const isActive = activeProjectRef === routeRef || activeProjectRef === project.id; + const pending = pendingFor(project); + const unstarPending = pending && membershipMutation.variables?.starred === false; + const leavePending = pending && membershipMutation.variables?.state === "left"; + const starred = isStarred(membershipsQuery.data, "project", project.id); + + const link = ( + { + if (isMobile) setSidebarOpen(false); + }} + className={cn( + "flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pl-8 pointer-coarse:py-1 pr-8 text-[13px] font-medium transition-colors", + isActive + ? "bg-accent text-foreground" + : "text-foreground/80 hover:bg-accent/50 hover:text-foreground", + )} + > + + {project.name} + {!rail && project.pauseReason === "budget" ? ( + + ) : null} + + ); + + return ( +
+ {rail ? ( + + +
{link}
+
+ {project.name} +
+ ) : ( + link + )} + + {!rail && !isMobile ? ( + // Desktop: quiet inline unstar revealed on hover/focus. + + unstar(project)} + revealClassName={STAR_ROW_REVEAL} + /> + + ) : null} + + {!rail && isMobile ? ( + // Touch: explicit ⋯ menu (no hover). Star action + separated Leave. + + + + + + { + if (pending) return; + unstar(project); + }} + disabled={pending} + > + {unstarPending ? ( + + ) : ( + + )} + Remove from starred + + + { + if (pending) return; + leave(project); + }} + disabled={pending} + > + {leavePending ? ( + + ) : ( + + )} + Leave project + + + + ) : null} +
+ ); + })} +
+ ); +} diff --git a/ui/src/components/StarToggle.test.tsx b/ui/src/components/StarToggle.test.tsx new file mode 100644 index 0000000000..bc3c0a863d --- /dev/null +++ b/ui/src/components/StarToggle.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { StarToggle } from "./StarToggle"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +describe("StarToggle", () => { + let container: HTMLDivElement; + let root: ReturnType | null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + }); + + afterEach(async () => { + if (root) { + await act(async () => { root?.unmount(); }); + } + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + async function render(node: React.ReactElement) { + root = createRoot(container); + await act(async () => { + root?.render(node); + }); + } + + function button() { + return container.querySelector("button"); + } + + it("labels and announces the unstarred state and toggles toward starred", async () => { + const onToggle = vi.fn(); + await render(); + + const btn = button(); + expect(btn?.getAttribute("aria-label")).toBe("Star Alpha"); + expect(btn?.getAttribute("aria-pressed")).toBe("false"); + + await act(async () => { btn?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + expect(onToggle).toHaveBeenCalledWith(true); + }); + + it("labels and announces the starred state and toggles toward unstarred", async () => { + const onToggle = vi.fn(); + await render(); + + const btn = button(); + expect(btn?.getAttribute("aria-label")).toBe("Unstar Alpha"); + expect(btn?.getAttribute("aria-pressed")).toBe("true"); + // A starred (non-quiet) row control is visible at rest. + expect(btn?.className).toContain("opacity-100"); + + await act(async () => { btn?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + expect(onToggle).toHaveBeenCalledWith(false); + }); + + it("stays quiet (hidden at rest) for a starred sidebar row", async () => { + await render( + {}} revealClassName="reveal-me" />, + ); + const btn = button(); + // Quiet: even starred, hidden at rest and revealed via the passed class. + expect(btn?.className).toContain("reveal-me"); + expect(btn?.className).not.toContain("opacity-100"); + }); + + it("blocks input and spins while pending", async () => { + const onToggle = vi.fn(); + await render(); + const btn = button(); + expect(btn?.hasAttribute("disabled")).toBe(true); + expect(btn?.getAttribute("aria-busy")).toBe("true"); + await act(async () => { btn?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + expect(onToggle).not.toHaveBeenCalled(); + }); + + it("surfaces a retry affordance on error for the button variant", async () => { + await render( {}} />); + const btn = button(); + expect(btn?.textContent).toContain("Retry star"); + expect(btn?.getAttribute("title")).toBe("Couldn't save — retry"); + }); + + it("renders the labelled Star/Starred button variant", async () => { + await render( {}} />); + expect(button()?.textContent).toContain("Starred"); + }); +}); diff --git a/ui/src/components/StarToggle.tsx b/ui/src/components/StarToggle.tsx new file mode 100644 index 0000000000..72c1d9b428 --- /dev/null +++ b/ui/src/components/StarToggle.tsx @@ -0,0 +1,132 @@ +import type { MouseEvent } from "react"; +import { Loader2, Star } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "../lib/utils"; + +export interface StarToggleProps { + /** Whether the resource is currently starred (post-optimistic value). */ + starred: boolean; + /** Human-readable resource name, used for the accessible label. */ + resourceName: string; + /** Optimistic mutation in flight — shows a spinner and blocks input. */ + pending?: boolean; + /** Last mutation failed — surface a retry affordance (red star). */ + error?: boolean; + /** + * "row" — quiet icon-only control for sidebar and browse-list rows. + * "button" — labelled Star/Starred button for detail headers. + */ + size?: "row" | "button"; + /** Called with the desired next starred value. */ + onToggle: (nextStarred: boolean) => void; + /** + * Row variant only: keep the control hidden at rest even when starred, so it + * only appears on hover/focus. Sidebar rows are "intentionally quiet"; browse + * rows keep the starred control visible. + */ + quiet?: boolean; + /** Extra classes for the control itself. */ + className?: string; + /** + * Row variant only: classes that control at-rest visibility when the resource + * is not starred (e.g. reveal on hover/focus). Ignored when starred (a starred + * control is always visible) or on the button variant. Defaults to the shared + * unnamed-`group` reveal used by browse rows; sidebar passes a named-group + * variant so it reveals with `group/project` / `group/agent`. + */ + revealClassName?: string; +} + +const DEFAULT_ROW_REVEAL = + "opacity-100 sm:opacity-0 sm:transition-opacity sm:group-hover:opacity-100 sm:group-focus-within:opacity-100"; + +export function StarToggle({ + starred, + resourceName, + pending = false, + error = false, + size = "row", + onToggle, + quiet = false, + className, + revealClassName, +}: StarToggleProps) { + const ariaLabel = starred ? `Unstar ${resourceName}` : `Star ${resourceName}`; + const Icon = pending ? Loader2 : Star; + + function handleClick(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + if (pending) return; + // On error, retry the last intent (toggle toward the desired end state). + onToggle(!starred); + } + + if (size === "button") { + const label = pending ? "Saving..." : error ? "Retry star" : starred ? "Starred" : "Star"; + return ( + + ); + } + + // Row variant: a starred (or errored) control is always visible; an unstarred + // one is quiet and revealed on hover/focus so the nav stays calm. When `quiet` + // (sidebar), even a starred control hides at rest and reveals on hover/focus. + const visible = error || pending || (starred && !quiet); + return ( + + ); +} diff --git a/ui/src/hooks/useResourceMemberships.ts b/ui/src/hooks/useResourceMemberships.ts index 84e85c2f6a..c20d06cd55 100644 --- a/ui/src/hooks/useResourceMemberships.ts +++ b/ui/src/hooks/useResourceMemberships.ts @@ -12,40 +12,84 @@ type MutationVariables = { resourceType: ResourceMembershipResourceType; resourceId: string; resourceName: string; - state: ResourceMembershipState; + /** Join / leave transition. Omit to only change the starred flag. */ + state?: ResourceMembershipState; + /** Star / unstar transition. Omit to only change join/leave state. */ + starred?: boolean; }; function emptyMemberships(): ResourceMemberships { return { projectMemberships: {}, agentMemberships: {}, + starredProjectIds: [], + starredAgentIds: [], + projectStarredAt: {}, + agentStarredAt: {}, updatedAt: null, }; } -function applyMembershipState( +function starKeys(resourceType: ResourceMembershipResourceType) { + return resourceType === "project" + ? { ids: "starredProjectIds", at: "projectStarredAt", state: "projectMemberships" } + : { ids: "starredAgentIds", at: "agentStarredAt", state: "agentMemberships" }; +} + +/** + * Apply an optimistic membership change to the cached memberships snapshot. + * Mirrors the server rules (see server/src/services/resource-memberships.ts): + * - starred=true always implies joined and stamps starredAt, + * - starred=false clears the star but leaves join/leave state alone, + * - state="left" clears any star (you cannot star a left resource). + */ +function applyMembershipChange( current: ResourceMemberships | undefined, resourceType: ResourceMembershipResourceType, resourceId: string, - state: ResourceMembershipState, + change: { state?: ResourceMembershipState; starred?: boolean }, ): ResourceMemberships { const base = current ?? emptyMemberships(); - if (resourceType === "project") { - return { - ...base, - projectMemberships: { - ...base.projectMemberships, - [resourceId]: state, - }, - updatedAt: new Date(), - }; + const keys = starKeys(resourceType); + + // Resolve next join/leave state (starring implies joined). + const currentStateMap = base[keys.state as "projectMemberships"] ?? {}; + const previousState: ResourceMembershipState = + currentStateMap[resourceId] === "left" ? "left" : "joined"; + const nextState: ResourceMembershipState = + change.starred === true ? "joined" : change.state ?? previousState; + + // Resolve next starred set. + const currentStarredIds = base[keys.ids as "starredProjectIds"] ?? []; + const nextStarredAt = { ...(base[keys.at as "projectStarredAt"] ?? {}) }; + const previouslyStarred = currentStarredIds.includes(resourceId); + const nextStarred = + nextState === "left" + ? false + : change.starred === true + ? true + : change.starred === false + ? false + : previouslyStarred; + + let starredIds = currentStarredIds; + if (nextStarred && !previouslyStarred) { + // Newest star sorts first, matching the server's starredAt DESC ordering. + starredIds = [resourceId, ...currentStarredIds]; + nextStarredAt[resourceId] = new Date(); + } else if (!nextStarred && previouslyStarred) { + starredIds = currentStarredIds.filter((id) => id !== resourceId); + delete nextStarredAt[resourceId]; } + return { ...base, - agentMemberships: { - ...base.agentMemberships, - [resourceId]: state, + [keys.state]: { + ...currentStateMap, + [resourceId]: nextState, }, + [keys.ids]: starredIds, + [keys.at]: nextStarredAt, updatedAt: new Date(), }; } @@ -61,6 +105,29 @@ export function resourceMembershipState( return state === "left" ? "left" : "joined"; } +/** Whether the current viewer has starred this resource (navigation preference). */ +export function isStarred( + memberships: ResourceMemberships | undefined, + resourceType: ResourceMembershipResourceType, + resourceId: string, +): boolean { + const ids = resourceType === "project" + ? memberships?.starredProjectIds + : memberships?.starredAgentIds; + return Array.isArray(ids) && ids.includes(resourceId); +} + +/** Ordered starred ids (server returns starredAt DESC; falls back to empty). */ +export function starredResourceIds( + memberships: ResourceMemberships | undefined, + resourceType: ResourceMembershipResourceType, +): string[] { + const ids = resourceType === "project" + ? memberships?.starredProjectIds + : memberships?.starredAgentIds; + return Array.isArray(ids) ? ids : []; +} + export function useResourceMemberships(companyId: string | null | undefined) { return useQuery({ queryKey: queryKeys.resourceMemberships.mine(companyId ?? "__none__"), @@ -77,16 +144,20 @@ export function useResourceMembershipMutation(companyId: string | null | undefin return useMutation({ mutationFn: (variables: MutationVariables) => { if (!companyId) throw new Error("Select a company first."); + const body = { state: variables.state, starred: variables.starred }; return variables.resourceType === "project" - ? resourceMembershipsApi.updateProject(companyId, variables.resourceId, { state: variables.state }) - : resourceMembershipsApi.updateAgent(companyId, variables.resourceId, { state: variables.state }); + ? resourceMembershipsApi.updateProject(companyId, variables.resourceId, body) + : resourceMembershipsApi.updateAgent(companyId, variables.resourceId, body); }, onMutate: async (variables) => { await queryClient.cancelQueries({ queryKey }); const previous = queryClient.getQueryData(queryKey); queryClient.setQueryData( queryKey, - applyMembershipState(previous, variables.resourceType, variables.resourceId, variables.state), + applyMembershipChange(previous, variables.resourceType, variables.resourceId, { + state: variables.state, + starred: variables.starred, + }), ); return { previous }; }, @@ -94,7 +165,9 @@ export function useResourceMembershipMutation(companyId: string | null | undefin if (context?.previous) { queryClient.setQueryData(queryKey, context.previous); } - const verb = variables.state === "left" ? "leave" : "join"; + const verb = variables.starred !== undefined + ? variables.starred ? "star" : "unstar" + : variables.state === "left" ? "leave" : "join"; pushToast({ title: `Couldn't ${verb} ${variables.resourceName}.`, body: error instanceof Error ? error.message : "Try again.", @@ -104,7 +177,11 @@ export function useResourceMembershipMutation(companyId: string | null | undefin onSuccess: (result, variables) => { queryClient.setQueryData( queryKey, - (current) => applyMembershipState(current, variables.resourceType, result.resourceId, result.state), + // Loose null-check: a missing or null starredAt both mean "not starred". + (current) => applyMembershipChange(current, variables.resourceType, result.resourceId, { + state: result.state, + starred: result.starredAt != null, + }), ); }, onSettled: () => { diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index a5a41588aa..61b436c9a1 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -38,6 +38,7 @@ import { MarkdownBody } from "../components/MarkdownBody"; import { CopyText } from "../components/CopyText"; import { EntityRow } from "../components/EntityRow"; import { MembershipAction } from "../components/MembershipAction"; +import { StarToggle } from "../components/StarToggle"; import { Identity } from "../components/Identity"; import { PageSkeleton } from "../components/PageSkeleton"; import { AgentActionButtons } from "../components/AgentActionButtons"; @@ -101,6 +102,7 @@ import { buildPermissionsForTrustPreset, getTrustPreset } from "../lib/trust-pol import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils"; import { agentRouteRef } from "../lib/utils"; import { + isStarred, resourceMembershipState, useResourceMembershipMutation, useResourceMemberships, @@ -954,6 +956,9 @@ export function AgentDetail() { membershipMutation.isPending && membershipMutation.variables?.resourceType === "agent" && membershipMutation.variables.resourceId === agent.id; + const agentStarred = isStarred(membershipsQuery.data, "agent", agent.id); + const agentStarPending = agentMembershipPending && membershipMutation.variables?.starred !== undefined; + const agentJoinLeavePending = agentMembershipPending && membershipMutation.variables?.starred === undefined; return (
@@ -965,8 +970,8 @@ export function AgentDetail() { membershipMutation.mutate({ resourceType: "agent", @@ -1031,29 +1036,43 @@ export function AgentDetail() {

- - {mobileLiveRun && ( - - - - - - Live - - )} - +
+ membershipMutation.mutate({ + resourceType: "agent", + resourceId: agent.id, + resourceName: agent.name, + starred: next, + })} + /> + + {mobileLiveRun && ( + + + + + + Live + + )} + +
{!urlRunId && ( diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index b0940605b0..519496368b 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -13,6 +13,7 @@ import { queryKeys } from "../lib/queryKeys"; import { AgentStatusBadge, AgentStatusCapsule } from "../components/StatusBadge"; import { AgentActionButtons } from "../components/AgentActionButtons"; import { MembershipAction } from "../components/MembershipAction"; +import { StarToggle } from "../components/StarToggle"; import { EntityRow } from "../components/EntityRow"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; @@ -23,6 +24,7 @@ import { Button } from "@/components/ui/button"; import { AlertTriangle, Bot, Plus, List, GitBranch } from "lucide-react"; import { AGENT_ROLE_LABELS, type Agent, type Environment, type EnvironmentCapabilities } from "@paperclipai/shared"; import { + isStarred, resourceMembershipState, useResourceMembershipMutation, useResourceMemberships, @@ -271,6 +273,13 @@ export function Agents() { const renderAgentRow = (agent: Agent) => { const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain"; + const agentPending = + membershipMutation.isPending && + membershipMutation.variables?.resourceType === "agent" && + membershipMutation.variables.resourceId === agent.id; + const agentStarPending = agentPending && membershipMutation.variables?.starred !== undefined; + const agentJoinLeavePending = agentPending && membershipMutation.variables?.starred === undefined; + const agentStarred = isStarred(membershipsQuery.data, "agent", agent.id); return ( membershipMutation.mutate({ resourceType: "agent", @@ -369,6 +368,18 @@ export function Agents() { state: "left", })} /> + membershipMutation.mutate({ + resourceType: "agent", + resourceId: agent.id, + resourceName: agent.name, + starred: next, + })} + /> } /> @@ -514,6 +525,9 @@ function OrgTreeNode({ const pending = membershipMutation.isPending && membershipMutation.variables?.resourceType === "agent" && membershipMutation.variables.resourceId === node.id; + const starPending = pending && membershipMutation.variables?.starred !== undefined; + const joinLeavePending = pending && membershipMutation.variables?.starred === undefined; + const starred = isStarred(memberships, "agent", node.id); return (
@@ -576,8 +590,8 @@ function OrgTreeNode({
membershipMutation.mutate({ resourceType: "agent", @@ -592,6 +606,18 @@ function OrgTreeNode({ state: "left", })} /> + membershipMutation.mutate({ + resourceType: "agent", + resourceId: node.id, + resourceName: node.name, + starred: next, + })} + /> {node.reports && node.reports.length > 0 && ( diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index ea05f529b2..8f5b5cf669 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -122,7 +122,6 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233) await renderPage(); const headings = [...container.querySelectorAll("section h2")].map((h) => h.textContent); - expect(headings).toContain("Streamlined Left Navigation Bar"); expect(headings).not.toContain("Conference Room Chat"); expect(container.querySelector(CONFERENCE_TOGGLE_SELECTOR)).toBeNull(); }); @@ -147,21 +146,13 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233) expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled(); }); - it("renders the Streamlined Left Navigation toggle on by default and patches opt-out", async () => { + it("no longer renders the Streamlined Left Navigation toggle (opt-out retired, PAP-12472)", async () => { await renderPage(); - const toggle = container.querySelector(STREAMLINED_TOGGLE_SELECTOR); - expect(toggle?.getAttribute("aria-checked")).toBe("true"); - - await act(async () => { - toggle?.click(); - }); - await flushReact(); - - expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ - enableStreamlinedLeftNavigation: false, - }); - expect(toggle?.getAttribute("aria-checked")).toBe("false"); + const headings = [...container.querySelectorAll("section h2")].map((h) => h.textContent); + expect(headings).not.toContain("Streamlined Left Navigation Bar"); + expect(container.querySelector(STREAMLINED_TOGGLE_SELECTOR)).toBeNull(); + expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled(); }); it("renders and patches the Task Watchdogs experimental toggle on and off", async () => { diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index e8daeae9b3..54e5938582 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -232,10 +232,8 @@ export function InstanceExperimentalSettings() { const enableEnvironments = experimentalQuery.data?.enableEnvironments === true; const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true; - // Default ON: treat anything but an explicit `false` as enabled so - // the toggle reflects the streamlined sidebar being the default experience. - const enableStreamlinedLeftNavigation = - experimentalQuery.data?.enableStreamlinedLeftNavigation !== false; + // Streamlined left navigation is now the standard sidebar (PAP-12472); the + // experimental opt-out was retired, so it no longer surfaces a toggle here. const enableConferenceRoomChat = experimentalQuery.data?.enableConferenceRoomChat === true; const enableIssuePlanDecompositions = experimentalQuery.data?.enableIssuePlanDecompositions === true; @@ -393,28 +391,6 @@ export function InstanceExperimentalSettings() { -
-
-
-

Streamlined Left Navigation Bar

-

- Reduces the maximum number of items in the left navigation bar — nests Projects under Work with a - dedicated Projects page, and shows only active agents (max 5 recently-active) in the sidebar. -

-
- - toggleMutation.mutate({ - enableStreamlinedLeftNavigation: !enableStreamlinedLeftNavigation, - }) - } - disabled={toggleMutation.isPending} - aria-label="Toggle streamlined left navigation experimental setting" - /> -
-
- {SHOW_CONFERENCE_ROOM_EXPERIMENTAL_SETTING ? (
diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 5ed0139b45..f0e7990213 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -25,6 +25,7 @@ import { PageSkeleton } from "../components/PageSkeleton"; import { PageTabBar } from "../components/PageTabBar"; import { ProjectWorkspacesContent } from "../components/ProjectWorkspacesContent"; import { MembershipAction } from "../components/MembershipAction"; +import { StarToggle } from "../components/StarToggle"; import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab"; import { collectLiveIssueIds } from "../lib/liveIssueIds"; import { projectRouteRef } from "../lib/utils"; @@ -37,6 +38,7 @@ import { Tabs } from "@/components/ui/tabs"; import { PluginLauncherOutlet } from "@/plugins/launchers"; import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots"; import { + isStarred, resourceMembershipState, useResourceMembershipMutation, useResourceMemberships, @@ -696,6 +698,9 @@ export function ProjectDetail() { membershipMutation.isPending && membershipMutation.variables?.resourceType === "project" && membershipMutation.variables.resourceId === project.id; + const projectStarred = isStarred(membershipsQuery.data, "project", project.id); + const projectStarPending = projectMembershipPending && membershipMutation.variables?.starred !== undefined; + const projectJoinLeavePending = projectMembershipPending && membershipMutation.variables?.starred === undefined; const handleTabChange = (tab: ProjectTab) => { // Cache the active tab per project @@ -731,8 +736,8 @@ export function ProjectDetail() { membershipMutation.mutate({ resourceType: "project", @@ -786,6 +791,20 @@ export function ProjectDetail() {
) : null} +
+ membershipMutation.mutate({ + resourceType: "project", + resourceId: project.id, + resourceName: project.name, + starred: next, + })} + /> +
membershipMutation.mutate({ resourceType: "project", @@ -250,6 +255,18 @@ export function Projects() { state: "left", })} /> + membershipMutation.mutate({ + resourceType: "project", + resourceId: project.id, + resourceName: project.name, + starred: next, + })} + /> } />