[codex] Add starred resource sidebar controls (#9085)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The board UI is the main daily navigation surface for agents,
projects, and their related resources.
> - Operators need a lightweight way to keep frequently used agents and
projects close without changing company-wide ordering or ownership.
> - Resource memberships already model per-user relationships to
projects and agents, so they are the right place to store user-specific
starred state.
> - This pull request extends that membership contract with a starred
timestamp and exposes star controls in list/detail views.
> - The sidebar then uses those starred memberships to show compact,
user-specific shortcuts.
> - The benefit is faster navigation without introducing a separate
favorites system or leaking preferences across users.

## Linked Issues or Issue Description

No public GitHub issue exists.

Feature request:

## Problem or motivation

Users cannot pin frequently used agents or projects into the main
sidebar. Returning to important resources requires scanning full
project/agent lists or navigating through detail pages, which adds
friction to repeated daily workflows.

## Proposed solution

Store a per-user `starred_at` timestamp on agent and project
memberships, expose API actions to set or clear that state, add star
toggle controls to list/detail pages, and render starred projects and
agents as compact sidebar shortcuts.

## Alternatives considered

A separate favorites table would work, but it would duplicate membership
scoping and require another resource relationship model. Keeping starred
state on memberships preserves existing company/user boundaries and
avoids a second source of truth.

## Roadmap alignment

Checked `ROADMAP.md`; no overlapping planned core work for starred
resource/sidebar navigation was found.

## Additional context

The affected subsystems are `packages/db`, `packages/shared`, `server/`,
and `ui/`. The migration is idempotent with `IF NOT EXISTS` guards so
environments that saw an earlier local migration name can still apply
the final ordered migration safely.

## What Changed

- Added idempotent migration `0133_resource_membership_stars` for
`starred_at` columns and lookup indexes on agent/project memberships.
- Extended shared resource membership types and validators with starred
metadata and actions.
- Updated server resource membership services/routes to read and mutate
starred resource state.
- Added reusable star toggle UI and resource membership hook support for
starred state.
- Added starred projects and agents sidebar rendering, plus star
controls on list and detail pages.
- Added focused shared, server, and UI coverage for starred membership
behavior and sidebar rendering.

## Verification

- Rebased and force-with-lease pushed current PR head
`a086fc965391c9e50a51b5b83b5b44a797b2a6f4` onto current
`paperclipai/paperclip:master`; `gh pr view` reports `MERGEABLE` with no
merge conflicts. GitHub checks are green for this fresh head.
- `pnpm exec vitest run packages/shared/src/resource-memberships.test.ts
server/src/__tests__/resource-memberships-routes.test.ts
server/src/__tests__/workspace-runtime.test.ts
ui/src/components/Sidebar.test.tsx
ui/src/components/SidebarAgents.test.tsx
ui/src/components/SidebarStarredProjects.test.tsx
ui/src/components/StarToggle.test.tsx
ui/src/pages/InstanceExperimentalSettings.test.tsx` passed after the
rebase: 8 files, 143 tests.
- Greptile re-review is 5/5; the remaining screenshot thread was
resolved as non-blocking because this task explicitly requested no
screenshots/images in the PR.
- `pnpm exec vitest run
ui/src/components/SidebarStarredProjects.test.tsx` passed after the
mobile pending-spinner fix.
- `pnpm exec vitest run packages/shared/src/resource-memberships.test.ts
server/src/__tests__/resource-memberships-routes.test.ts
ui/src/components/Sidebar.test.tsx
ui/src/components/SidebarAgents.test.tsx
ui/src/components/SidebarStarredProjects.test.tsx
ui/src/components/StarToggle.test.tsx
ui/src/pages/InstanceExperimentalSettings.test.tsx` passed: 7 files, 68
tests.
- `pnpm --filter @paperclipai/db typecheck && pnpm --filter
@paperclipai/shared typecheck && pnpm --filter @paperclipai/server
typecheck && pnpm --filter @paperclipai/ui typecheck` passed
db/shared/server, then failed in pre-existing UI code outside this PR:
`src/pages/CompanyEnvironments.tsx` missing `@xterm/*` type declarations
and `previous` possibly null.
- Checked that the PR diff does not include `pnpm-lock.yaml` or
`.github/workflows` changes.
- Checked `ROADMAP.md` and found no overlapping planned core work for
starred resource/sidebar navigation.
- Searched existing GitHub PRs for duplicate starred-resource/sidebar
work and found none.

## Risks

- Migration touches membership tables. The SQL uses `IF NOT EXISTS` for
columns and indexes so environments that saw an earlier local migration
name can still apply this safely.
- Sidebar ordering and visibility changes could affect users who rely on
the previous flat sidebar layout.
- Starred state is per-user membership metadata; code paths must
continue preserving company/user scoping around memberships.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI GPT-5 Codex, tool-enabled coding agent with shell/GitHub access.
Context window not disclosed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-06 14:09:11 -05:00 committed by GitHub
parent 70c86d2c73
commit 903886bc79
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1643 additions and 187 deletions

View File

@ -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");

View File

@ -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
}
]
}

View File

@ -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,

View File

@ -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,

View File

@ -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",
);
});
});

View File

@ -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;
}

View File

@ -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>;

View File

@ -435,8 +435,60 @@ list_base_node_modules_paths() {
! -path './.paperclip/*' \
| sed 's#^\./##'
}
compute_pnpm_install_fingerprint() {
WORKTREE_CWD="$worktree_cwd" node <<'EOF'
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const root = process.env.WORKTREE_CWD;
const ignoredDirs = new Set([".git", ".paperclip", "node_modules", "dist", "storybook-static"]);
const files = [];
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (ignoredDirs.has(entry.name)) continue;
const absolutePath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(absolutePath);
continue;
}
if (
entry.isFile()
&& (entry.name === "package.json" || entry.name === "pnpm-lock.yaml" || entry.name === "pnpm-workspace.yaml")
) {
files.push(absolutePath);
}
}
}
walk(root);
files.sort((left, right) => path.relative(root, left).localeCompare(path.relative(root, right)));
const hash = crypto.createHash("sha256");
for (const file of files) {
const relativePath = path.relative(root, file).replaceAll(path.sep, "/");
hash.update(relativePath);
hash.update("\0");
hash.update(fs.readFileSync(file));
hash.update("\0");
}
process.stdout.write(hash.digest("hex"));
EOF
}
if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; then
needs_install=0
install_fingerprint_path="$paperclip_dir/pnpm-install-fingerprint"
current_install_fingerprint="$(compute_pnpm_install_fingerprint)"
previous_install_fingerprint=""
if [[ -f "$install_fingerprint_path" ]]; then
previous_install_fingerprint="$(cat "$install_fingerprint_path")"
fi
while IFS= read -r relative_path; do
[[ -n "$relative_path" ]] || continue
@ -448,6 +500,10 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t
fi
done < <(list_base_node_modules_paths)
if [[ "$needs_install" -eq 0 && "$current_install_fingerprint" != "$previous_install_fingerprint" ]]; then
needs_install=1
fi
if [[ "$needs_install" -eq 1 ]]; then
backup_suffix=".paperclip-backup-${BASHPID:-$$}"
moved_symlink_paths=()
@ -492,7 +548,7 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t
if (
cd "$worktree_cwd"
pnpm install "$@"
pnpm install --prod=false "$@"
) >"$stdout_path" 2>"$stderr_path"; then
cat "$stdout_path"
cat "$stderr_path" >&2
@ -529,6 +585,8 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t
fi
cleanup_moved_symlinks
current_install_fingerprint="$(compute_pnpm_install_fingerprint)"
printf '%s\n' "$current_install_fingerprint" >"$install_fingerprint_path"
fi
exit 0

View File

@ -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, {

View File

@ -1500,6 +1500,99 @@ describe("realizeExecutionWorkspace", () => {
);
}, 30_000);
it("reinstalls worktree-local pnpm dependencies when package metadata changes", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-stale-deps-"));
const baseRoot = path.join(tempRoot, "base");
const worktreeRoot = path.join(tempRoot, "worktree");
const fakeBin = path.join(tempRoot, "bin");
const fakePnpmPath = path.join(fakeBin, "pnpm");
const scriptPath = path.join(worktreeRoot, "provision-worktree.sh");
const installLogPath = path.join(tempRoot, "install.log");
try {
await fs.mkdir(path.join(baseRoot, "node_modules"), { recursive: true });
await fs.mkdir(path.join(worktreeRoot, "node_modules"), { recursive: true });
await fs.mkdir(path.join(worktreeRoot, "ui"), { recursive: true });
await fs.mkdir(fakeBin, { recursive: true });
await fs.copyFile(provisionWorktreeScriptPath, scriptPath);
await fs.chmod(scriptPath, 0o755);
await fs.writeFile(
path.join(worktreeRoot, "package.json"),
JSON.stringify(
{
name: "workspace-root",
private: true,
packageManager: "pnpm@9.15.4",
},
null,
2,
),
"utf8",
);
await fs.writeFile(
path.join(worktreeRoot, "pnpm-lock.yaml"),
["lockfileVersion: '9.0'", "", "importers:", " .: {}", ""].join("\n"),
"utf8",
);
await fs.writeFile(
path.join(worktreeRoot, "ui", "package.json"),
JSON.stringify({ name: "ui", private: true, dependencies: {} }, null, 2),
"utf8",
);
await fs.writeFile(
fakePnpmPath,
[
"#!/bin/sh",
"if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then",
" exit 1",
"fi",
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--prod=false\" ] && [ \"$3\" = \"--frozen-lockfile\" ]; then",
" mkdir -p \"$PWD/node_modules\"",
` echo "install:$*" >> ${JSON.stringify(installLogPath)}`,
" exit 0",
"fi",
"exit 1",
"",
].join("\n"),
"utf8",
);
await fs.chmod(fakePnpmPath, 0o755);
const runScript = () => execFileAsync(scriptPath, [], {
cwd: worktreeRoot,
env: {
...process.env,
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
PAPERCLIP_WORKSPACE_BASE_CWD: baseRoot,
PAPERCLIP_WORKSPACE_CWD: worktreeRoot,
},
});
await runScript();
await runScript();
await expect(fs.readFile(installLogPath, "utf8")).resolves.toBe(
"install:install --prod=false --frozen-lockfile\n",
);
await fs.writeFile(
path.join(worktreeRoot, "ui", "package.json"),
JSON.stringify(
{ name: "ui", private: true, dependencies: { "@xterm/addon-fit": "^0.11.0" } },
null,
2,
),
"utf8",
);
await runScript();
await expect(fs.readFile(installLogPath, "utf8")).resolves.toBe(
"install:install --prod=false --frozen-lockfile\ninstall:install --prod=false --frozen-lockfile\n",
);
} finally {
await fs.rm(tempRoot, { recursive: true, force: true });
}
}, 30_000);
it("fails instead of writing an unseeded fallback config when worktree init errors after CLI detection succeeds", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-provision-fail-"));
const baseRoot = path.join(tempRoot, "base");
@ -1686,11 +1779,11 @@ describe("realizeExecutionWorkspace", () => {
"if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then",
" exit 1",
"fi",
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--frozen-lockfile\" ]; then",
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--prod=false\" ] && [ \"$3\" = \"--frozen-lockfile\" ]; then",
" echo \"ERR_PNPM_OUTDATED_LOCKFILE\" >&2",
" exit 1",
"fi",
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--no-frozen-lockfile\" ]; then",
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--prod=false\" ] && [ \"$3\" = \"--no-frozen-lockfile\" ]; then",
" mkdir -p \"$PWD/node_modules\"",
" : > \"$PWD/node_modules/.retry-success\"",
" exit 0",

View File

@ -22,6 +22,8 @@ async function logMembershipChange(
resourceType: "project" | "agent";
resourceId: string;
state: "joined" | "left";
starredAt: Date | null;
changeKind: "joined" | "left" | "starred" | "unstarred";
policySource: string;
},
) {
@ -32,7 +34,7 @@ async function logMembershipChange(
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: `resource_membership.${input.state}`,
action: `resource_membership.${input.changeKind}`,
entityType: input.resourceType,
entityId: input.resourceId,
details: {
@ -40,6 +42,8 @@ async function logMembershipChange(
resourceType: input.resourceType,
resourceId: input.resourceId,
state: input.state,
starredAt: input.starredAt,
starred: input.starredAt !== null,
policySource: input.policySource,
},
});
@ -69,19 +73,22 @@ export function resourceMembershipRoutes(db: Db) {
projectId,
userId,
state: req.body.state,
starred: req.body.starred,
actor: req.actor,
});
if (result.changed) {
if (result.changed && result.changeKind) {
await logMembershipChange(db, req, {
companyId,
userId,
resourceType: "project",
resourceId: projectId,
state: result.state,
starredAt: result.starredAt,
changeKind: result.changeKind,
policySource: result.policySource,
});
}
const { changed: _changed, policySource: _policySource, ...response } = result;
const { changed: _changed, changeKind: _changeKind, policySource: _policySource, ...response } = result;
res.json(response);
},
);
@ -99,19 +106,22 @@ export function resourceMembershipRoutes(db: Db) {
agentId,
userId,
state: req.body.state,
starred: req.body.starred,
actor: req.actor,
});
if (result.changed) {
if (result.changed && result.changeKind) {
await logMembershipChange(db, req, {
companyId,
userId,
resourceType: "agent",
resourceId: agentId,
state: result.state,
starredAt: result.starredAt,
changeKind: result.changeKind,
policySource: result.policySource,
});
}
const { changed: _changed, policySource: _policySource, ...response } = result;
const { changed: _changed, changeKind: _changeKind, policySource: _policySource, ...response } = result;
res.json(response);
},
);

View File

@ -41,12 +41,21 @@ export type ResourceMembershipPolicyHook = (input: {
resourceType: ResourceMembershipResourceType;
resourceId: string;
state: ResourceMembershipState;
starred?: boolean;
}) => Promise<PolicyDecision> | PolicyDecision;
type ResourceMembershipServiceOptions = {
policyHook?: ResourceMembershipPolicyHook | null;
};
type MembershipChangeKind = ResourceMembershipState | "starred" | "unstarred";
type MembershipUpdateResult = ResourceMembershipUpdateResult & {
changed: boolean;
changeKind: MembershipChangeKind | null;
policySource: string;
};
function defaultJoinedMap<T extends { projectId?: string; agentId?: string; state: string }>(
rows: T[],
key: "projectId" | "agentId",
@ -60,6 +69,30 @@ function defaultJoinedMap<T extends { projectId?: string; agentId?: string; stat
return result;
}
function starredAtMap<T extends { projectId?: string; agentId?: string; starredAt: Date | null }>(
rows: T[],
key: "projectId" | "agentId",
): Record<string, Date> {
const result: Record<string, Date> = {};
for (const row of rows) {
const id = row[key];
if (typeof id !== "string" || !row.starredAt) continue;
result[id] = row.starredAt;
}
return result;
}
function starredIds<T extends { projectId?: string; agentId?: string; starredAt: Date | null }>(
rows: T[],
key: "projectId" | "agentId",
): string[] {
return rows
.filter((row) => row.starredAt)
.sort((a, b) => b.starredAt!.getTime() - a.starredAt!.getTime())
.map((row) => row[key])
.filter((id): id is string => typeof id === "string");
}
function latestDate(...dates: Array<Date | null | undefined>): Date | null {
let latest: Date | null = null;
for (const date of dates) {
@ -116,6 +149,7 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
resourceType: ResourceMembershipResourceType;
resourceId: string;
state: ResourceMembershipState;
starred?: boolean;
}): Promise<PolicyDecision> {
assertBoardSelfMembershipAccess(input.actor, input.companyId, input.userId);
const decision = await evaluatePolicy(policyHook, input);
@ -144,9 +178,15 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
.select({
projectId: projectMemberships.projectId,
state: projectMemberships.state,
starredAt: projectMemberships.starredAt,
updatedAt: projectMemberships.updatedAt,
projectArchivedAt: projects.archivedAt,
})
.from(projectMemberships)
.innerJoin(projects, and(
eq(projects.id, projectMemberships.projectId),
eq(projects.companyId, projectMemberships.companyId),
))
.where(and(
eq(projectMemberships.companyId, companyId),
eq(projectMemberships.userId, userId),
@ -155,17 +195,29 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
.select({
agentId: agentMemberships.agentId,
state: agentMemberships.state,
starredAt: agentMemberships.starredAt,
updatedAt: agentMemberships.updatedAt,
agentStatus: agents.status,
})
.from(agentMemberships)
.innerJoin(agents, and(
eq(agents.id, agentMemberships.agentId),
eq(agents.companyId, agentMemberships.companyId),
))
.where(and(
eq(agentMemberships.companyId, companyId),
eq(agentMemberships.userId, userId),
)),
]);
const starEligibleProjectRows = projectRows.filter((row) => row.starredAt && !row.projectArchivedAt);
const starEligibleAgentRows = agentRows.filter((row) => row.starredAt && row.agentStatus !== "terminated");
return {
projectMemberships: defaultJoinedMap(projectRows, "projectId"),
agentMemberships: defaultJoinedMap(agentRows, "agentId"),
starredProjectIds: starredIds(starEligibleProjectRows, "projectId"),
starredAgentIds: starredIds(starEligibleAgentRows, "agentId"),
projectStarredAt: starredAtMap(starEligibleProjectRows, "projectId"),
agentStarredAt: starredAtMap(starEligibleAgentRows, "agentId"),
updatedAt: latestDate(
...projectRows.map((row) => row.updatedAt),
...agentRows.map((row) => row.updatedAt),
@ -177,24 +229,17 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
companyId: string;
userId: string;
projectId: string;
state: ResourceMembershipState;
state?: ResourceMembershipState;
starred?: boolean;
actor: BoardActor;
}): Promise<ResourceMembershipUpdateResult & { changed: boolean; policySource: string }> {
}): Promise<MembershipUpdateResult> {
const project = await db.query.projects.findFirst({
where: and(
eq(projects.id, input.projectId),
eq(projects.companyId, input.companyId),
),
});
if (!project) throw notFound("Project not found");
const decision = await assertMutationAllowed({
actor: input.actor,
companyId: input.companyId,
userId: input.userId,
resourceType: "project",
resourceId: input.projectId,
state: input.state,
});
if (!project || project.archivedAt) throw notFound("Project not found");
const existing = await db.query.projectMemberships.findFirst({
where: and(
@ -204,13 +249,36 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
),
});
const previousState: ResourceMembershipState = existing?.state === "left" ? "left" : "joined";
if (previousState === input.state) {
const previousStarredAt = existing?.starredAt ?? null;
const nextState: ResourceMembershipState = input.starred === true ? "joined" : input.state ?? previousState;
const nextStarredAt = nextState === "left"
? null
: input.starred === true
? previousStarredAt ?? new Date()
: input.starred === false
? null
: previousStarredAt;
const stateChanged = previousState !== nextState;
const starredChanged = (previousStarredAt?.getTime() ?? null) !== (nextStarredAt?.getTime() ?? null);
const decision = await assertMutationAllowed({
actor: input.actor,
companyId: input.companyId,
userId: input.userId,
resourceType: "project",
resourceId: input.projectId,
state: nextState,
starred: input.starred,
});
if (!stateChanged && !starredChanged) {
return {
resourceType: "project",
resourceId: input.projectId,
state: input.state,
state: nextState,
starredAt: previousStarredAt,
updatedAt: existing?.updatedAt ?? new Date(),
changed: false,
changeKind: null,
policySource: decision.source ?? "oss_default",
};
}
@ -222,13 +290,15 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
companyId: input.companyId,
projectId: input.projectId,
userId: input.userId,
state: input.state,
state: nextState,
starredAt: nextStarredAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [projectMemberships.companyId, projectMemberships.userId, projectMemberships.projectId],
set: {
state: input.state,
state: nextState,
starredAt: nextStarredAt,
updatedAt: now,
},
})
@ -238,8 +308,12 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
resourceType: "project",
resourceId: input.projectId,
state: row?.state === "left" ? "left" : "joined",
starredAt: row?.starredAt ?? null,
updatedAt: row?.updatedAt ?? now,
changed: true,
changeKind: input.starred !== undefined && starredChanged
? input.starred ? "starred" : "unstarred"
: stateChanged ? nextState : nextStarredAt ? "starred" : "unstarred",
policySource: decision.source ?? "oss_default",
};
},
@ -248,24 +322,17 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
companyId: string;
userId: string;
agentId: string;
state: ResourceMembershipState;
state?: ResourceMembershipState;
starred?: boolean;
actor: BoardActor;
}): Promise<ResourceMembershipUpdateResult & { changed: boolean; policySource: string }> {
}): Promise<MembershipUpdateResult> {
const agent = await db.query.agents.findFirst({
where: and(
eq(agents.id, input.agentId),
eq(agents.companyId, input.companyId),
),
});
if (!agent) throw notFound("Agent not found");
const decision = await assertMutationAllowed({
actor: input.actor,
companyId: input.companyId,
userId: input.userId,
resourceType: "agent",
resourceId: input.agentId,
state: input.state,
});
if (!agent || agent.status === "terminated") throw notFound("Agent not found");
const existing = await db.query.agentMemberships.findFirst({
where: and(
@ -275,13 +342,36 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
),
});
const previousState: ResourceMembershipState = existing?.state === "left" ? "left" : "joined";
if (previousState === input.state) {
const previousStarredAt = existing?.starredAt ?? null;
const nextState: ResourceMembershipState = input.starred === true ? "joined" : input.state ?? previousState;
const nextStarredAt = nextState === "left"
? null
: input.starred === true
? previousStarredAt ?? new Date()
: input.starred === false
? null
: previousStarredAt;
const stateChanged = previousState !== nextState;
const starredChanged = (previousStarredAt?.getTime() ?? null) !== (nextStarredAt?.getTime() ?? null);
const decision = await assertMutationAllowed({
actor: input.actor,
companyId: input.companyId,
userId: input.userId,
resourceType: "agent",
resourceId: input.agentId,
state: nextState,
starred: input.starred,
});
if (!stateChanged && !starredChanged) {
return {
resourceType: "agent",
resourceId: input.agentId,
state: input.state,
state: nextState,
starredAt: previousStarredAt,
updatedAt: existing?.updatedAt ?? new Date(),
changed: false,
changeKind: null,
policySource: decision.source ?? "oss_default",
};
}
@ -293,13 +383,15 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
companyId: input.companyId,
agentId: input.agentId,
userId: input.userId,
state: input.state,
state: nextState,
starredAt: nextStarredAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [agentMemberships.companyId, agentMemberships.userId, agentMemberships.agentId],
set: {
state: input.state,
state: nextState,
starredAt: nextStarredAt,
updatedAt: now,
},
})
@ -309,8 +401,12 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
resourceType: "agent",
resourceId: input.agentId,
state: row?.state === "left" ? "left" : "joined",
starredAt: row?.starredAt ?? null,
updatedAt: row?.updatedAt ?? now,
changed: true,
changeKind: input.starred !== undefined && starredChanged
? input.starred ? "starred" : "unstarred"
: stateChanged ? nextState : nextStarredAt ? "starred" : "unstarred",
policySource: decision.source ?? "oss_default",
};
},

View File

@ -100,6 +100,10 @@ vi.mock("./SidebarProjects", () => ({
SidebarProjects: () => <div data-testid="sidebar-projects">Projects collapsible</div>,
}));
vi.mock("./SidebarStarredProjects", () => ({
SidebarStarredProjects: () => <div data-testid="sidebar-starred-projects" />,
}));
async function flushReact() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
@ -223,27 +227,24 @@ describe("Sidebar", () => {
});
});
it("classic (flag OFF): New Task button, Tasks label, per-project collapsible, no top-level Projects link", async () => {
it("streamlined is now standard: a stale enableStreamlinedLeftNavigation=false opt-out is ignored", async () => {
// PAP-12472 retired the experimental opt-out; the streamlined sidebar is the
// only path, so an old `false` setting no longer restores classic mode.
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableStreamlinedLeftNavigation: false,
});
const root = await renderSidebar();
expect(container.textContent).toContain("New Task");
expect(container.textContent).not.toContain("New Issue");
const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim());
expect(navLabels).toContain("Tasks");
expect(navLabels).not.toContain("Issues");
// No top-level Projects nav link in classic mode (D5 option A).
expect(navLabels).not.toContain("Projects");
// Per-project collapsible restored below Work.
expect(container.querySelector('[data-testid="sidebar-projects"]')).not.toBeNull();
// Top-level Projects link + starred children stay, per-project collapsible gone.
expect(navLabels).toContain("Projects");
expect(container.querySelector('[data-testid="sidebar-projects"]')).toBeNull();
expect(container.querySelector('[data-testid="sidebar-starred-projects"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
).toBe("false");
).toBe("true");
flushSync(() => {
root.unmount();

View File

@ -26,6 +26,7 @@ import { SidebarSection } from "./SidebarSection";
import { SidebarNavItem } from "./SidebarNavItem";
import { SidebarAgents } from "./SidebarAgents";
import { SidebarProjects } from "./SidebarProjects";
import { SidebarStarredProjects } from "./SidebarStarredProjects";
import { useDialogActions } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
@ -59,12 +60,12 @@ export function Sidebar() {
const liveRunCount = liveRuns?.length ?? 0;
const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true;
const showPipelines = experimentalSettings?.enablePipelines === true;
// IA flag: branch the sidebar nav presentation. Default ON =
// streamlined (top-level Projects link). Users can opt out in experiments to
// get classic (per-project collapsible, no Projects nav link). Issue/Task
// wording is split to PR #7651. Gating is navigation-only; all routes stay
// registered in both modes.
const streamlined = experimentalSettings?.enableStreamlinedLeftNavigation !== false;
// Streamlined left navigation (top-level Projects link + starred children) is
// now the standard product sidebar (PAP-12472). The former experimental
// opt-out was retired; classic per-project collapsible mode is no longer
// user-selectable. Kept as a constant so the classic branch below stays as a
// documented reference until it is fully removed. Routes are unaffected.
const streamlined = true;
// Conference Room Chat flag (PAP-136/PAP-137): the Conference Room nav item
// is a new surface, hidden entirely while the flag is off (same no-flash
// pattern as showWorkspacesLink above).
@ -187,7 +188,10 @@ export function Sidebar() {
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
) : null}
{streamlined ? (
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
<>
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
<SidebarStarredProjects />
</>
) : null}
<PluginSlotOutlet
slotTypes={["sidebar"]}

View File

@ -233,18 +233,28 @@ describe("SidebarAgents", () => {
};
mockResourceMembershipsApi.listMine.mockImplementation(() => Promise.resolve(memberships));
mockResourceMembershipsApi.updateAgent.mockImplementation((_companyId, agentId, data) => {
const previousState = memberships.agentMemberships[agentId] ?? "joined";
const nextState = data.starred === true ? "joined" : data.state ?? previousState;
const starredAgentIds = memberships.starredAgentIds ?? [];
const nextStarredAgentIds = data.starred === true
? starredAgentIds.includes(agentId) ? starredAgentIds : [agentId, ...starredAgentIds]
: data.starred === false || nextState === "left"
? starredAgentIds.filter((id) => id !== agentId)
: starredAgentIds;
memberships = {
...memberships,
agentMemberships: {
...memberships.agentMemberships,
[agentId]: data.state,
[agentId]: nextState,
},
starredAgentIds: nextStarredAgentIds,
updatedAt: new Date(),
};
return Promise.resolve({
resourceType: "agent",
resourceId: agentId,
state: data.state,
state: nextState,
starredAt: data.starred === true ? new Date() : null,
});
});
localStorage.clear();
@ -329,6 +339,88 @@ describe("SidebarAgents", () => {
expect(container.querySelector('button[aria-label="Agents section actions"]')).toBeNull();
});
it("pins starred agents at the top without subheadings and dedupes them from the recent list", async () => {
mockAgentsApi.list.mockResolvedValue([
makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }),
makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }),
]);
memberships = {
projectMemberships: {},
agentMemberships: {},
starredProjectIds: [],
starredAgentIds: ["agent-b"],
projectStarredAt: {},
agentStarredAt: {},
updatedAt: new Date(),
};
await renderSidebarAgents();
expect(container.textContent).not.toContain("Starred");
expect(container.textContent).not.toContain("Recently active");
// Bravo is starred -> shown once at the top, deduped from recent.
const labels = agentLinkLabels(container);
expect(labels.filter((label) => label === "Bravo")).toHaveLength(1);
expect(labels).toContain("Alpha");
// Starred order lands the starred agent first.
expect(labels[0]).toBe("Bravo");
// The starred row offers an explicit "Remove from starred" menu action.
await openAgentMenu("Open actions for Bravo");
expect(document.body.textContent).toContain("Remove from starred");
});
it("offers star agent from an unstarred sidebar agent menu", async () => {
await renderSidebarAgents();
await openAgentMenu();
const starItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("Star agent"));
expect(starItem).toBeTruthy();
await act(async () => {
starItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockResourceMembershipsApi.updateAgent).toHaveBeenCalledWith(
"company-1",
"agent-1",
{ state: undefined, starred: true },
);
expect(document.body.querySelector('button[aria-label="Unstar Alpha"]')).not.toBeNull();
});
it("keeps the agent starred and toasts when an unstar request fails", async () => {
mockAgentsApi.list.mockResolvedValue([makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" })]);
memberships = {
projectMemberships: {},
agentMemberships: { "agent-b": "joined" },
starredProjectIds: [],
starredAgentIds: ["agent-b"],
projectStarredAt: {},
agentStarredAt: {},
updatedAt: new Date(),
};
mockResourceMembershipsApi.updateAgent.mockRejectedValue(new Error("nope"));
await renderSidebarAgents();
const unstar = document.body.querySelector('button[aria-label="Unstar Bravo"]');
expect(unstar).not.toBeNull();
await act(async () => {
unstar?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
// Optimistic unstar is rolled back → the row stays in the starred group.
expect(document.body.querySelector('button[aria-label="Unstar Bravo"]')).not.toBeNull();
expect(mockPushToast).toHaveBeenCalledWith(
expect.objectContaining({ tone: "error" }),
);
});
it("keeps top mode in stored org-aware order", async () => {
localStorage.setItem("paperclip.agentOrder:company-1:user-1", JSON.stringify(["agent-b", "agent-a", "agent-c"]));
mockAgentsApi.list.mockResolvedValue([

View File

@ -9,6 +9,7 @@ import {
Pencil,
PlayCircle,
Plus,
Star,
Users,
AlertTriangle,
} from "lucide-react";
@ -23,7 +24,13 @@ import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { queryKeys } from "../lib/queryKeys";
import { cn, agentRouteRef, agentUrl, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import { useAgentOrder } from "../hooks/useAgentOrder";
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
import {
isStarred,
resourceMembershipState,
starredResourceIds,
useResourceMembershipMutation,
useResourceMemberships,
} from "../hooks/useResourceMemberships";
import {
AGENT_SORT_MODE_UPDATED_EVENT,
getAgentSortModeStorageKey,
@ -35,6 +42,7 @@ import {
import { AgentIcon } from "./AgentIconPicker";
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection";
import { StarToggle } from "./StarToggle";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -87,6 +95,10 @@ function sortAgents(agents: Agent[], sortMode: AgentSidebarSortMode): Agent[] {
return sorted;
}
// Sidebar star reveals with the agent row's own group, not the shared group.
const AGENT_STAR_ROW_REVEAL =
"opacity-0 transition-opacity group-hover/agent:opacity-100 group-focus-within/agent:opacity-100";
function SidebarAgentItem({
activeAgentId,
activeTab,
@ -99,6 +111,9 @@ function SidebarAgentItem({
rail,
runCount,
setSidebarOpen,
starred = false,
onToggleStar,
starPending = false,
}: {
activeAgentId: string | null;
activeTab: string | null;
@ -111,6 +126,9 @@ function SidebarAgentItem({
rail: boolean;
runCount: number;
setSidebarOpen: (open: boolean) => void;
starred?: boolean;
onToggleStar?: (agent: Agent, starred: boolean) => void;
starPending?: boolean;
}) {
const routeRef = agentRouteRef(agent);
const href = activeTab ? `${agentUrl(agent)}/${activeTab}` : agentUrl(agent);
@ -137,7 +155,9 @@ function SidebarAgentItem({
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 pr-8 text-[13px] font-medium transition-colors",
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 text-[13px] font-medium transition-colors",
// Reserve room for the ⋯ menu, plus the inline unstar star on starred rows.
starred && !isMobile ? "pr-14" : "pr-8",
isActive
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
@ -187,6 +207,21 @@ function SidebarAgentItem({
link
)}
{!rail && starred && !isMobile && onToggleStar ? (
// Desktop: quiet inline unstar, left of the ⋯ menu, revealed on hover/focus.
<span className="absolute right-8 top-1/2 -translate-y-1/2">
<StarToggle
size="row"
quiet
starred
pending={starPending}
resourceName={agent.name}
onToggle={() => onToggleStar(agent, false)}
revealClassName={AGENT_STAR_ROW_REVEAL}
/>
</span>
) : null}
{!rail && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -204,7 +239,26 @@ function SidebarAgentItem({
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuContent align="end" className="w-48">
{onToggleStar ? (
<>
<DropdownMenuItem
onClick={() => {
if (starPending) return;
onToggleStar(agent, !starred);
}}
disabled={starPending}
>
{starPending ? (
<Loader2 className="size-4 motion-safe:animate-spin" />
) : (
<Star className={cn("size-4", starred && "fill-amber-500 text-amber-500")} />
)}
<span>{starred ? "Remove from starred" : "Star agent"}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuItem asChild>
<Link
to={editHref}
@ -441,6 +495,59 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
[membershipMutation.isPending, membershipMutation.variables],
);
const toggleStarAgent = useCallback(
(agent: Agent, starred: boolean) => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
starred,
}),
[membershipMutation],
);
const agentStarPending = useCallback(
(agent: Agent) =>
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id &&
membershipMutation.variables.starred !== undefined,
[membershipMutation.isPending, membershipMutation.variables],
);
// Starred agents pin to the top of the section (name order), and are deduped
// out of the active/recent subset so no agent appears twice.
const starredAgentIdSet = useMemo(
() => new Set(starredResourceIds(membershipsQuery.data, "agent")),
[membershipsQuery.data],
);
const starredAgents = useMemo(
() => sortAgents(visibleAgents.filter((agent: Agent) => starredAgentIdSet.has(agent.id)), "alphabetical"),
[visibleAgents, starredAgentIdSet],
);
const dedupedDisplayedAgents = useMemo(
() => displayedAgents.filter((agent: Agent) => !starredAgentIdSet.has(agent.id)),
[displayedAgents, starredAgentIdSet],
);
const renderAgentRow = (agent: Agent, isStarredRow: boolean) => (
<SidebarAgentItem
key={agent.id}
activeAgentId={activeAgentId}
activeTab={activeTab}
agent={agent}
disabled={pendingAgentIds.has(agent.id)}
isMobile={isMobile}
leaving={agentLeaving(agent)}
onLeaveAgent={leaveAgent}
onPauseResume={(targetAgent, action) => pauseResumeAgent.mutate({ agent: targetAgent, action })}
rail={rail}
runCount={liveCountByAgent.get(agent.id) ?? 0}
setSidebarOpen={setSidebarOpen}
starred={isStarredRow || isStarred(membershipsQuery.data, "agent", agent.id)}
onToggleStar={toggleStarAgent}
starPending={agentStarPending(agent)}
/>
);
return (
<SidebarSection
label="Agents"
@ -462,25 +569,8 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
onRadioValueChange: persistSortMode,
}}
>
{displayedAgents.map((agent: Agent) => {
const runCount = liveCountByAgent.get(agent.id) ?? 0;
return (
<SidebarAgentItem
key={agent.id}
activeAgentId={activeAgentId}
activeTab={activeTab}
agent={agent}
disabled={pendingAgentIds.has(agent.id)}
isMobile={isMobile}
leaving={agentLeaving(agent)}
onLeaveAgent={leaveAgent}
onPauseResume={(targetAgent, action) => pauseResumeAgent.mutate({ agent: targetAgent, action })}
rail={rail}
runCount={runCount}
setSidebarOpen={setSidebarOpen}
/>
);
})}
{starredAgents.map((agent: Agent) => renderAgentRow(agent, true))}
{dedupedDisplayedAgents.map((agent: Agent) => renderAgentRow(agent, false))}
{showSeeAllLink && (() => {
const seeAllLink = (
<Link

View File

@ -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([]);
});
});

View File

@ -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>
);
}

View File

@ -0,0 +1,106 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StarToggle } from "./StarToggle";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
describe("StarToggle", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
});
afterEach(async () => {
if (root) {
await act(async () => { root?.unmount(); });
}
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
async function render(node: React.ReactElement) {
root = createRoot(container);
await act(async () => {
root?.render(node);
});
}
function button() {
return container.querySelector("button");
}
it("labels and announces the unstarred state and toggles toward starred", async () => {
const onToggle = vi.fn();
await render(<StarToggle starred={false} resourceName="Alpha" onToggle={onToggle} />);
const btn = button();
expect(btn?.getAttribute("aria-label")).toBe("Star Alpha");
expect(btn?.getAttribute("aria-pressed")).toBe("false");
await act(async () => { btn?.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
expect(onToggle).toHaveBeenCalledWith(true);
});
it("labels and announces the starred state and toggles toward unstarred", async () => {
const onToggle = vi.fn();
await render(<StarToggle starred resourceName="Alpha" onToggle={onToggle} />);
const btn = button();
expect(btn?.getAttribute("aria-label")).toBe("Unstar Alpha");
expect(btn?.getAttribute("aria-pressed")).toBe("true");
// A starred (non-quiet) row control is visible at rest.
expect(btn?.className).toContain("opacity-100");
await act(async () => { btn?.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
expect(onToggle).toHaveBeenCalledWith(false);
});
it("stays quiet (hidden at rest) for a starred sidebar row", async () => {
await render(
<StarToggle starred quiet resourceName="Alpha" onToggle={() => {}} revealClassName="reveal-me" />,
);
const btn = button();
// Quiet: even starred, hidden at rest and revealed via the passed class.
expect(btn?.className).toContain("reveal-me");
expect(btn?.className).not.toContain("opacity-100");
});
it("blocks input and spins while pending", async () => {
const onToggle = vi.fn();
await render(<StarToggle starred={false} pending resourceName="Alpha" onToggle={onToggle} />);
const btn = button();
expect(btn?.hasAttribute("disabled")).toBe(true);
expect(btn?.getAttribute("aria-busy")).toBe("true");
await act(async () => { btn?.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
expect(onToggle).not.toHaveBeenCalled();
});
it("surfaces a retry affordance on error for the button variant", async () => {
await render(<StarToggle starred size="button" error resourceName="Alpha" onToggle={() => {}} />);
const btn = button();
expect(btn?.textContent).toContain("Retry star");
expect(btn?.getAttribute("title")).toBe("Couldn't save — retry");
});
it("renders the labelled Star/Starred button variant", async () => {
await render(<StarToggle starred size="button" resourceName="Alpha" onToggle={() => {}} />);
expect(button()?.textContent).toContain("Starred");
});
});

View File

@ -0,0 +1,132 @@
import type { MouseEvent } from "react";
import { Loader2, Star } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "../lib/utils";
export interface StarToggleProps {
/** Whether the resource is currently starred (post-optimistic value). */
starred: boolean;
/** Human-readable resource name, used for the accessible label. */
resourceName: string;
/** Optimistic mutation in flight — shows a spinner and blocks input. */
pending?: boolean;
/** Last mutation failed — surface a retry affordance (red star). */
error?: boolean;
/**
* "row" quiet icon-only control for sidebar and browse-list rows.
* "button" labelled Star/Starred button for detail headers.
*/
size?: "row" | "button";
/** Called with the desired next starred value. */
onToggle: (nextStarred: boolean) => void;
/**
* Row variant only: keep the control hidden at rest even when starred, so it
* only appears on hover/focus. Sidebar rows are "intentionally quiet"; browse
* rows keep the starred control visible.
*/
quiet?: boolean;
/** Extra classes for the control itself. */
className?: string;
/**
* Row variant only: classes that control at-rest visibility when the resource
* is not starred (e.g. reveal on hover/focus). Ignored when starred (a starred
* control is always visible) or on the button variant. Defaults to the shared
* unnamed-`group` reveal used by browse rows; sidebar passes a named-group
* variant so it reveals with `group/project` / `group/agent`.
*/
revealClassName?: string;
}
const DEFAULT_ROW_REVEAL =
"opacity-100 sm:opacity-0 sm:transition-opacity sm:group-hover:opacity-100 sm:group-focus-within:opacity-100";
export function StarToggle({
starred,
resourceName,
pending = false,
error = false,
size = "row",
onToggle,
quiet = false,
className,
revealClassName,
}: StarToggleProps) {
const ariaLabel = starred ? `Unstar ${resourceName}` : `Star ${resourceName}`;
const Icon = pending ? Loader2 : Star;
function handleClick(event: MouseEvent<HTMLButtonElement>) {
event.preventDefault();
event.stopPropagation();
if (pending) return;
// On error, retry the last intent (toggle toward the desired end state).
onToggle(!starred);
}
if (size === "button") {
const label = pending ? "Saving..." : error ? "Retry star" : starred ? "Starred" : "Star";
return (
<Button
type="button"
size="sm"
variant="outline"
aria-label={ariaLabel}
aria-pressed={starred}
aria-busy={pending ? "true" : undefined}
disabled={pending}
onClick={handleClick}
title={error ? "Couldn't save — retry" : undefined}
className={cn(
error
? "text-red-500 hover:text-red-500"
: starred
? "text-amber-600 dark:text-amber-500"
: undefined,
className,
)}
>
<Icon
className={cn(
"h-4 w-4",
pending && "motion-safe:animate-spin",
!pending && starred && !error && "fill-amber-500 text-amber-500",
!pending && error && "text-red-500",
)}
/>
<span>{label}</span>
</Button>
);
}
// Row variant: a starred (or errored) control is always visible; an unstarred
// one is quiet and revealed on hover/focus so the nav stays calm. When `quiet`
// (sidebar), even a starred control hides at rest and reveals on hover/focus.
const visible = error || pending || (starred && !quiet);
return (
<Button
type="button"
size="icon-xs"
variant="ghost"
aria-label={ariaLabel}
aria-pressed={starred}
aria-busy={pending ? "true" : undefined}
disabled={pending}
onClick={handleClick}
title={error ? "Couldn't save — retry" : undefined}
className={cn(
"h-6 w-6 shrink-0",
visible ? "opacity-100" : revealClassName ?? DEFAULT_ROW_REVEAL,
className,
)}
>
<Icon
className={cn(
"h-3.5 w-3.5",
pending && "motion-safe:animate-spin",
!pending && error && "text-red-500",
!pending && !error && starred && "fill-amber-500 text-amber-500",
!pending && !error && !starred && "text-muted-foreground",
)}
/>
</Button>
);
}

View File

@ -12,40 +12,84 @@ type MutationVariables = {
resourceType: ResourceMembershipResourceType;
resourceId: string;
resourceName: string;
state: ResourceMembershipState;
/** Join / leave transition. Omit to only change the starred flag. */
state?: ResourceMembershipState;
/** Star / unstar transition. Omit to only change join/leave state. */
starred?: boolean;
};
function emptyMemberships(): ResourceMemberships {
return {
projectMemberships: {},
agentMemberships: {},
starredProjectIds: [],
starredAgentIds: [],
projectStarredAt: {},
agentStarredAt: {},
updatedAt: null,
};
}
function applyMembershipState(
function starKeys(resourceType: ResourceMembershipResourceType) {
return resourceType === "project"
? { ids: "starredProjectIds", at: "projectStarredAt", state: "projectMemberships" }
: { ids: "starredAgentIds", at: "agentStarredAt", state: "agentMemberships" };
}
/**
* Apply an optimistic membership change to the cached memberships snapshot.
* Mirrors the server rules (see server/src/services/resource-memberships.ts):
* - starred=true always implies joined and stamps starredAt,
* - starred=false clears the star but leaves join/leave state alone,
* - state="left" clears any star (you cannot star a left resource).
*/
function applyMembershipChange(
current: ResourceMemberships | undefined,
resourceType: ResourceMembershipResourceType,
resourceId: string,
state: ResourceMembershipState,
change: { state?: ResourceMembershipState; starred?: boolean },
): ResourceMemberships {
const base = current ?? emptyMemberships();
if (resourceType === "project") {
return {
...base,
projectMemberships: {
...base.projectMemberships,
[resourceId]: state,
},
updatedAt: new Date(),
};
const keys = starKeys(resourceType);
// Resolve next join/leave state (starring implies joined).
const currentStateMap = base[keys.state as "projectMemberships"] ?? {};
const previousState: ResourceMembershipState =
currentStateMap[resourceId] === "left" ? "left" : "joined";
const nextState: ResourceMembershipState =
change.starred === true ? "joined" : change.state ?? previousState;
// Resolve next starred set.
const currentStarredIds = base[keys.ids as "starredProjectIds"] ?? [];
const nextStarredAt = { ...(base[keys.at as "projectStarredAt"] ?? {}) };
const previouslyStarred = currentStarredIds.includes(resourceId);
const nextStarred =
nextState === "left"
? false
: change.starred === true
? true
: change.starred === false
? false
: previouslyStarred;
let starredIds = currentStarredIds;
if (nextStarred && !previouslyStarred) {
// Newest star sorts first, matching the server's starredAt DESC ordering.
starredIds = [resourceId, ...currentStarredIds];
nextStarredAt[resourceId] = new Date();
} else if (!nextStarred && previouslyStarred) {
starredIds = currentStarredIds.filter((id) => id !== resourceId);
delete nextStarredAt[resourceId];
}
return {
...base,
agentMemberships: {
...base.agentMemberships,
[resourceId]: state,
[keys.state]: {
...currentStateMap,
[resourceId]: nextState,
},
[keys.ids]: starredIds,
[keys.at]: nextStarredAt,
updatedAt: new Date(),
};
}
@ -61,6 +105,29 @@ export function resourceMembershipState(
return state === "left" ? "left" : "joined";
}
/** Whether the current viewer has starred this resource (navigation preference). */
export function isStarred(
memberships: ResourceMemberships | undefined,
resourceType: ResourceMembershipResourceType,
resourceId: string,
): boolean {
const ids = resourceType === "project"
? memberships?.starredProjectIds
: memberships?.starredAgentIds;
return Array.isArray(ids) && ids.includes(resourceId);
}
/** Ordered starred ids (server returns starredAt DESC; falls back to empty). */
export function starredResourceIds(
memberships: ResourceMemberships | undefined,
resourceType: ResourceMembershipResourceType,
): string[] {
const ids = resourceType === "project"
? memberships?.starredProjectIds
: memberships?.starredAgentIds;
return Array.isArray(ids) ? ids : [];
}
export function useResourceMemberships(companyId: string | null | undefined) {
return useQuery({
queryKey: queryKeys.resourceMemberships.mine(companyId ?? "__none__"),
@ -77,16 +144,20 @@ export function useResourceMembershipMutation(companyId: string | null | undefin
return useMutation({
mutationFn: (variables: MutationVariables) => {
if (!companyId) throw new Error("Select a company first.");
const body = { state: variables.state, starred: variables.starred };
return variables.resourceType === "project"
? resourceMembershipsApi.updateProject(companyId, variables.resourceId, { state: variables.state })
: resourceMembershipsApi.updateAgent(companyId, variables.resourceId, { state: variables.state });
? resourceMembershipsApi.updateProject(companyId, variables.resourceId, body)
: resourceMembershipsApi.updateAgent(companyId, variables.resourceId, body);
},
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<ResourceMemberships>(queryKey);
queryClient.setQueryData<ResourceMemberships>(
queryKey,
applyMembershipState(previous, variables.resourceType, variables.resourceId, variables.state),
applyMembershipChange(previous, variables.resourceType, variables.resourceId, {
state: variables.state,
starred: variables.starred,
}),
);
return { previous };
},
@ -94,7 +165,9 @@ export function useResourceMembershipMutation(companyId: string | null | undefin
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous);
}
const verb = variables.state === "left" ? "leave" : "join";
const verb = variables.starred !== undefined
? variables.starred ? "star" : "unstar"
: variables.state === "left" ? "leave" : "join";
pushToast({
title: `Couldn't ${verb} ${variables.resourceName}.`,
body: error instanceof Error ? error.message : "Try again.",
@ -104,7 +177,11 @@ export function useResourceMembershipMutation(companyId: string | null | undefin
onSuccess: (result, variables) => {
queryClient.setQueryData<ResourceMemberships>(
queryKey,
(current) => applyMembershipState(current, variables.resourceType, result.resourceId, result.state),
// Loose null-check: a missing or null starredAt both mean "not starred".
(current) => applyMembershipChange(current, variables.resourceType, result.resourceId, {
state: result.state,
starred: result.starredAt != null,
}),
);
},
onSettled: () => {

View File

@ -38,6 +38,7 @@ import { MarkdownBody } from "../components/MarkdownBody";
import { CopyText } from "../components/CopyText";
import { EntityRow } from "../components/EntityRow";
import { MembershipAction } from "../components/MembershipAction";
import { StarToggle } from "../components/StarToggle";
import { Identity } from "../components/Identity";
import { PageSkeleton } from "../components/PageSkeleton";
import { AgentActionButtons } from "../components/AgentActionButtons";
@ -101,6 +102,7 @@ import { buildPermissionsForTrustPreset, getTrustPreset } from "../lib/trust-pol
import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils";
import { agentRouteRef } from "../lib/utils";
import {
isStarred,
resourceMembershipState,
useResourceMembershipMutation,
useResourceMemberships,
@ -954,6 +956,9 @@ export function AgentDetail() {
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id;
const agentStarred = isStarred(membershipsQuery.data, "agent", agent.id);
const agentStarPending = agentMembershipPending && membershipMutation.variables?.starred !== undefined;
const agentJoinLeavePending = agentMembershipPending && membershipMutation.variables?.starred === undefined;
return (
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
@ -965,8 +970,8 @@ export function AgentDetail() {
<MembershipAction
compact
state="left"
pending={agentMembershipPending}
pendingState={agentMembershipPending ? membershipMutation.variables?.state : null}
pending={agentJoinLeavePending}
pendingState={agentJoinLeavePending ? membershipMutation.variables?.state : null}
resourceName={agent.name}
onJoin={() => membershipMutation.mutate({
resourceType: "agent",
@ -1031,29 +1036,43 @@ export function AgentDetail() {
</p>
</div>
</div>
<AgentActionButtons
agent={agent}
companyId={resolvedCompanyId}
assignLabel="Assign Task"
runLabel="Run Heartbeat"
actionsDisabled={agentAction.isPending}
workActionsDisabled={hasInvalidOrgChain}
workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs"
onActionError={setActionError}
>
{mobileLiveRun && (
<Link
to={`/agents/${canonicalAgentRef}/runs/${mobileLiveRun.id}`}
className="sm:hidden flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline"
>
<span className="relative flex h-2 w-2">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
</Link>
)}
</AgentActionButtons>
<div className="flex items-center gap-2 shrink-0">
<StarToggle
size="button"
starred={agentStarred}
pending={agentStarPending}
resourceName={agent.name}
onToggle={(next) => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
starred: next,
})}
/>
<AgentActionButtons
agent={agent}
companyId={resolvedCompanyId}
assignLabel="Assign Task"
runLabel="Run Heartbeat"
actionsDisabled={agentAction.isPending}
workActionsDisabled={hasInvalidOrgChain}
workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs"
onActionError={setActionError}
>
{mobileLiveRun && (
<Link
to={`/agents/${canonicalAgentRef}/runs/${mobileLiveRun.id}`}
className="sm:hidden flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline"
>
<span className="relative flex h-2 w-2">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
</Link>
)}
</AgentActionButtons>
</div>
</div>
{!urlRunId && (

View File

@ -13,6 +13,7 @@ import { queryKeys } from "../lib/queryKeys";
import { AgentStatusBadge, AgentStatusCapsule } from "../components/StatusBadge";
import { AgentActionButtons } from "../components/AgentActionButtons";
import { MembershipAction } from "../components/MembershipAction";
import { StarToggle } from "../components/StarToggle";
import { EntityRow } from "../components/EntityRow";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
@ -23,6 +24,7 @@ import { Button } from "@/components/ui/button";
import { AlertTriangle, Bot, Plus, List, GitBranch } from "lucide-react";
import { AGENT_ROLE_LABELS, type Agent, type Environment, type EnvironmentCapabilities } from "@paperclipai/shared";
import {
isStarred,
resourceMembershipState,
useResourceMembershipMutation,
useResourceMemberships,
@ -271,6 +273,13 @@ export function Agents() {
const renderAgentRow = (agent: Agent) => {
const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain";
const agentPending =
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id;
const agentStarPending = agentPending && membershipMutation.variables?.starred !== undefined;
const agentJoinLeavePending = agentPending && membershipMutation.variables?.starred === undefined;
const agentStarred = isStarred(membershipsQuery.data, "agent", agent.id);
return (
<EntityRow
key={agent.id}
@ -343,18 +352,8 @@ export function Agents() {
</div>
<MembershipAction
state={resourceMembershipState(membershipsQuery.data, "agent", agent.id)}
pending={
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id
}
pendingState={
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id
? membershipMutation.variables.state
: null
}
pending={agentJoinLeavePending}
pendingState={agentJoinLeavePending ? membershipMutation.variables?.state ?? null : null}
resourceName={agent.name}
onJoin={() => membershipMutation.mutate({
resourceType: "agent",
@ -369,6 +368,18 @@ export function Agents() {
state: "left",
})}
/>
<StarToggle
size="row"
starred={agentStarred}
pending={agentStarPending}
resourceName={agent.name}
onToggle={(next) => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
starred: next,
})}
/>
</div>
}
/>
@ -514,6 +525,9 @@ function OrgTreeNode({
const pending = membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === node.id;
const starPending = pending && membershipMutation.variables?.starred !== undefined;
const joinLeavePending = pending && membershipMutation.variables?.starred === undefined;
const starred = isStarred(memberships, "agent", node.id);
return (
<div style={{ paddingLeft: depth * 24 }}>
@ -576,8 +590,8 @@ function OrgTreeNode({
</div>
<MembershipAction
state={membershipState}
pending={pending}
pendingState={pending ? membershipMutation.variables?.state : null}
pending={joinLeavePending}
pendingState={joinLeavePending ? membershipMutation.variables?.state : null}
resourceName={node.name}
onJoin={() => membershipMutation.mutate({
resourceType: "agent",
@ -592,6 +606,18 @@ function OrgTreeNode({
state: "left",
})}
/>
<StarToggle
size="row"
starred={starred}
pending={starPending}
resourceName={node.name}
onToggle={(next) => membershipMutation.mutate({
resourceType: "agent",
resourceId: node.id,
resourceName: node.name,
starred: next,
})}
/>
</div>
</Link>
{node.reports && node.reports.length > 0 && (

View File

@ -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 () => {

View File

@ -232,10 +232,8 @@ export function InstanceExperimentalSettings() {
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
// Default ON: treat anything but an explicit `false` as enabled so
// the toggle reflects the streamlined sidebar being the default experience.
const enableStreamlinedLeftNavigation =
experimentalQuery.data?.enableStreamlinedLeftNavigation !== false;
// Streamlined left navigation is now the standard sidebar (PAP-12472); the
// experimental opt-out was retired, so it no longer surfaces a toggle here.
const enableConferenceRoomChat = experimentalQuery.data?.enableConferenceRoomChat === true;
const enableIssuePlanDecompositions =
experimentalQuery.data?.enableIssuePlanDecompositions === true;
@ -393,28 +391,6 @@ export function InstanceExperimentalSettings() {
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Streamlined Left Navigation Bar</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Reduces the maximum number of items in the left navigation bar nests Projects under Work with a
dedicated Projects page, and shows only active agents (max 5 recently-active) in the sidebar.
</p>
</div>
<ToggleSwitch
checked={enableStreamlinedLeftNavigation}
onCheckedChange={() =>
toggleMutation.mutate({
enableStreamlinedLeftNavigation: !enableStreamlinedLeftNavigation,
})
}
disabled={toggleMutation.isPending}
aria-label="Toggle streamlined left navigation experimental setting"
/>
</div>
</section>
{SHOW_CONFERENCE_ROOM_EXPERIMENTAL_SETTING ? (
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">

View File

@ -25,6 +25,7 @@ import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
import { ProjectWorkspacesContent } from "../components/ProjectWorkspacesContent";
import { MembershipAction } from "../components/MembershipAction";
import { StarToggle } from "../components/StarToggle";
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
import { projectRouteRef } from "../lib/utils";
@ -37,6 +38,7 @@ import { Tabs } from "@/components/ui/tabs";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import {
isStarred,
resourceMembershipState,
useResourceMembershipMutation,
useResourceMemberships,
@ -696,6 +698,9 @@ export function ProjectDetail() {
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "project" &&
membershipMutation.variables.resourceId === project.id;
const projectStarred = isStarred(membershipsQuery.data, "project", project.id);
const projectStarPending = projectMembershipPending && membershipMutation.variables?.starred !== undefined;
const projectJoinLeavePending = projectMembershipPending && membershipMutation.variables?.starred === undefined;
const handleTabChange = (tab: ProjectTab) => {
// Cache the active tab per project
@ -731,8 +736,8 @@ export function ProjectDetail() {
<MembershipAction
compact
state="left"
pending={projectMembershipPending}
pendingState={projectMembershipPending ? membershipMutation.variables?.state : null}
pending={projectJoinLeavePending}
pendingState={projectJoinLeavePending ? membershipMutation.variables?.state : null}
resourceName={project.name}
onJoin={() => membershipMutation.mutate({
resourceType: "project",
@ -786,6 +791,20 @@ export function ProjectDetail() {
</div>
) : null}
</div>
<div className="ml-auto flex items-center gap-2">
<StarToggle
size="button"
starred={projectStarred}
pending={projectStarPending}
resourceName={project.name}
onToggle={(next) => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
starred: next,
})}
/>
</div>
</div>
<PluginSlotOutlet

View File

@ -10,10 +10,12 @@ import { EntityRow } from "../components/EntityRow";
import { ProjectTile } from "../components/ProjectTile";
import { StatusBadge } from "../components/StatusBadge";
import { MembershipAction } from "../components/MembershipAction";
import { StarToggle } from "../components/StarToggle";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { formatDate, formatNumber, formatProjectBudget, projectUrl } from "../lib/utils";
import {
isStarred,
resourceMembershipState,
useResourceMembershipMutation,
useResourceMemberships,
@ -204,6 +206,9 @@ export function Projects() {
const pending = membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "project" &&
membershipMutation.variables.resourceId === project.id;
const starPending = pending && membershipMutation.variables?.starred !== undefined;
const joinLeavePending = pending && membershipMutation.variables?.starred === undefined;
const starred = isStarred(membershipsQuery.data, "project", project.id);
return (
<EntityRow
key={project.id}
@ -234,8 +239,8 @@ export function Projects() {
<StatusBadge status={project.status} />
<MembershipAction
state={state}
pending={pending}
pendingState={pending ? membershipMutation.variables?.state : null}
pending={joinLeavePending}
pendingState={joinLeavePending ? membershipMutation.variables?.state : null}
resourceName={project.name}
onJoin={() => membershipMutation.mutate({
resourceType: "project",
@ -250,6 +255,18 @@ export function Projects() {
state: "left",
})}
/>
<StarToggle
size="row"
starred={starred}
pending={starPending}
resourceName={project.name}
onToggle={(next) => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
starred: next,
})}
/>
</div>
}
/>