feat(ui): remove star and leave buttons from agent index (#15400)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The Agents index page (`/agents/all`) shows one row per agent in
both the streamlined and production sides of the UI
> - Each row had a star toggle and a leave/join button that write
per-user state through the resource-memberships API (`agent_memberships`
table)
> - The default streamlined sidebar no longer lists agents, so the star
and leave actions on the index no longer feed a visible pinned list
> - This pull request removes the star toggle and the leave/join button
from agent index rows and org-tree nodes
> - The backend resource-memberships endpoints and the
`agent_memberships` table stay live because the agent detail page and
the legacy sidebar still use them
> - The benefit is a cleaner index page with no redundant actions for
state that is not surfaced on the page itself

## Linked Issues or Issue Description

No public GitHub issue exists for this change; the description follows
the feature request issue template inline.

**Subsystem affected**
ui/ — React + Vite board UI. The change is presentational only; no
backend or schema code is touched.

**Problem or motivation**
The agent index rows show a star toggle and a leave/join button. Those
two buttons write per-user membership state that the default streamlined
UI no longer surfaces anywhere on the page itself. The default sidebar
shows a plain "Agents" nav item and does not list agents. The buttons
are now redundant on the index.

**Proposed solution**
Remove both buttons from the agent index list rows and org-tree nodes in
both UI variants. Keep the data layer. The agent detail page still
renders the header star toggle and the join/leave banner, and the legacy
sidebar still renders star and leave rows for instances that opt out of
the streamlined UI.

**Alternatives considered**
- Remove the backend endpoints too. Rejected: the agent detail page and
the legacy sidebar still read and write the same state.
- Keep the buttons hidden behind a setting. Rejected: no product need,
and the plan approved the straightforward removal.

**Roadmap alignment**
This is a small presentational change. It does not overlap any section
in `ROADMAP.md`.

**Additional context**
Rows whose membership state is `left` keep their dimmed styling on the
index.

## What Changed

- Removed the `StarToggle` component from agent index list rows in
`ui/src/pages/Agents.tsx` and `ui/src/pages/Agents.production.tsx`
- Removed the `MembershipAction` (Leave/Join) component from agent index
list rows in both files
- Removed both actions from the org-tree nodes in both files
- Dropped now-unused imports and derived values (`StarToggle`,
`MembershipAction`, `isStarred`, `useResourceMembershipMutation`, and
the per-row pending/starred variables)
- Kept `useResourceMemberships` and the dim-on-left row styling so rows
whose membership is `left` remain visually identified
- Updated `ui/src/pages/Agents.test.tsx`: a new test asserts both modes
omit star and leave/join actions from list and org-chart views; the
dim-on-left tests are kept

## Verification

- `pnpm --filter @paperclipai/ui typecheck` passes
- `pnpm --filter @paperclipai/ui build` passes
- UI Vitest suite for `Agents.test.tsx` passes (21 tests)
- `pnpm check:token-gates` is clean
- Manual check on `/agents/all`: no star and no Leave/Join buttons in
the list view or the org-chart view; star/leave still work on the agent
detail page

## Risks

Low risk. This is a presentational UI change with no backend or schema
changes. Star/leave remain available on the agent detail page and the
legacy sidebar. No migration is involved.

## Model Used

- Provider: DeepSeek (via the Paperclip opencode_local adapter, model
`openrouter/~deepseek/deepseek-v4-flash-latest`)
- Model: deepseek-v4-flash (OpenRouter
`openrouter/~deepseek/deepseek-v4-flash-latest`)
- Context window: 128K; used with tool use in the repository
- Assisted with the code change and this PR body; the plan and scope
came from the tracking work item

## 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)
- [ ] 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
- [ ] 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
- [ ] 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: devinfoley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
authored and GitHub committed 2026-10-06 17:25:41 -07:00
1 parent f77fcbf4bf
commit ac8f3eb143
3 files changed
+25 -187

No files matched your search

-84
View File
@@ -14,8 +14,6 @@ import { useSidebar } from "../context/SidebarContext";
import { queryKeys } from "../lib/queryKeys";
import { isPlatformManagedEnvironment } from "../lib/managed-sandbox-environment";
import { AgentStatusBadge, AgentStatusCapsule } from "../components/StatusBadge";
import { MembershipAction } from "../components/MembershipAction";
import { StarToggle } from "../components/StarToggle";
import { EntityRow } from "../components/EntityRow";
import { BuiltInLifecycleChip } from "../components/BuiltInAgentBadges";
import { EmptyState } from "../components/EmptyState";
@@ -27,9 +25,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,
} from "../hooks/useResourceMemberships";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
@@ -272,7 +268,6 @@ export function Agents() {
});
usePublishSharedQueryData(sharedRuns, runs, runsUpdatedAt);
const membershipsQuery = useResourceMemberships(selectedCompanyId);
const membershipMutation = useResourceMembershipMutation(selectedCompanyId);
// Map agentId -> first live run + live run count
const liveRunByAgent = useMemo(() => {
@@ -347,13 +342,6 @@ 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);
const builtInState = builtInByAgentId.get(agent.id);
const showBuiltInLifecycle = builtInState?.status === "needs_setup" || builtInState?.status === "pending_approval";
// Lifecycle chip + inline `Set up`. Rendered inline in
@@ -442,37 +430,7 @@ export function Agents() {
<span className="w-20 flex justify-end">
<AgentStatusBadge status={agent.status} />
</span>
<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>
<MembershipAction
state={resourceMembershipState(membershipsQuery.data, "agent", agent.id)}
pending={agentJoinLeavePending}
pendingState={agentJoinLeavePending ? membershipMutation.variables?.state ?? null : null}
resourceName={agent.name}
onJoin={() => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
state: "joined",
})}
onLeave={() => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
state: "left",
})}
/>
</div>
}
/>
@@ -569,7 +527,6 @@ export function Agents() {
showEnvironment={showEnvironmentColumn}
tab={tab}
memberships={membershipsQuery.data}
membershipMutation={membershipMutation}
builtInByAgentId={builtInByAgentId}
onConfigureBuiltIn={setConfigureState}
/>
@@ -614,7 +571,6 @@ function OrgTreeNode({
showEnvironment,
tab,
memberships,
membershipMutation,
builtInByAgentId,
onConfigureBuiltIn,
}: {
@@ -627,7 +583,6 @@ function OrgTreeNode({
showEnvironment: boolean;
tab: FilterTab;
memberships: ReturnType<typeof useResourceMemberships>["data"];
membershipMutation: ReturnType<typeof useResourceMembershipMutation>;
builtInByAgentId: Map<string, BuiltInAgentState>;
onConfigureBuiltIn: (state: BuiltInAgentState) => void;
}) {
@@ -636,12 +591,6 @@ function OrgTreeNode({
const showBuiltInLifecycle = builtInState?.status === "needs_setup" || builtInState?.status === "pending_approval";
const hasInvalidOrgChain = Boolean(agent && agent.orgChainHealth?.status === "invalid_org_chain");
const membershipState = resourceMembershipState(memberships, "agent", node.id);
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 }}>
@@ -724,38 +673,6 @@ function OrgTreeNode({
<AgentStatusBadge status={node.status} />
</span>
</div>
<MembershipAction
state={membershipState}
pending={joinLeavePending}
pendingState={joinLeavePending ? membershipMutation.variables?.state : null}
resourceName={node.name}
onJoin={() => membershipMutation.mutate({
resourceType: "agent",
resourceId: node.id,
resourceName: node.name,
state: "joined",
})}
onLeave={() => membershipMutation.mutate({
resourceType: "agent",
resourceId: node.id,
resourceName: node.name,
state: "left",
})}
/>
<div className="hidden sm:flex items-center gap-3">
<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>
</div>
</Link>
{node.reports && node.reports.length > 0 && (
@@ -772,7 +689,6 @@ function OrgTreeNode({
showEnvironment={showEnvironment}
tab={tab}
memberships={memberships}
membershipMutation={membershipMutation}
builtInByAgentId={builtInByAgentId}
onConfigureBuiltIn={onConfigureBuiltIn}
/>
+25 -20
View File
@@ -1005,13 +1005,16 @@ describe("Agents", () => {
expect(container.querySelector(".min-w-\\(--sz-7rem\\)")).toBeNull();
});
it("keeps row membership actions reachable while hiding star actions on mobile", async () => {
it.each([
["streamlined", Agents],
["production", ProductionAgents],
] as const)("omits star and leave/join actions from %s agent index list and org views", async (_mode, AgentList) => {
root = createRoot(container);
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Agents />
<AgentList />
</ToastProvider>
</QueryClientProvider>,
);
@@ -1019,29 +1022,31 @@ describe("Agents", () => {
await flushReact();
await flushReact();
// List view (default).
const orgAction = container.querySelector('[aria-label="Leave Alpha"]');
const orgStar = container.querySelector('[aria-label="Star Alpha"]');
expect(orgAction).not.toBeNull();
expect(orgStar).not.toBeNull();
expect(orgAction?.closest(".hidden")).toBeNull();
expect(orgStar?.closest(".hidden")).not.toBeNull();
// List view remains stable after explicitly selecting it.
const listToggle = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.querySelector("svg.lucide-list"),
);
// Explicitly select the list view and assert the row renders there.
const listToggle = container.querySelector<HTMLButtonElement>('button[aria-label="List view"]');
expect(listToggle).not.toBeNull();
await act(async () => {
listToggle!.click();
});
await flushReact();
const listRow = findAgentRow(container, "Alpha");
expect(listRow).not.toBeNull();
expect(container.querySelector('[aria-label="Leave Alpha"]')).toBeNull();
expect(container.querySelector('[aria-label="Join Alpha"]')).toBeNull();
expect(container.querySelector('[aria-label="Star Alpha"]')).toBeNull();
const listAction = container.querySelector('[aria-label="Leave Alpha"]');
const listStar = container.querySelector('[aria-label="Star Alpha"]');
expect(listAction).not.toBeNull();
expect(listStar).not.toBeNull();
expect(listAction?.closest(".hidden")).toBeNull();
expect(listStar?.closest(".hidden")).not.toBeNull();
// Explicitly select the org view and assert no membership actions there.
const orgToggle = container.querySelector<HTMLButtonElement>('button[aria-label="Org chart view"]');
expect(orgToggle).not.toBeNull();
await act(async () => {
orgToggle!.click();
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Alpha");
expect(container.querySelector('[aria-label="Leave Alpha"]')).toBeNull();
expect(container.querySelector('[aria-label="Join Alpha"]')).toBeNull();
expect(container.querySelector('[aria-label="Star Alpha"]')).toBeNull();
});
it("does not dim left-membership agent names on mobile", async () => {
-83
View File
@@ -16,8 +16,6 @@ import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import { queryKeys } from "../lib/queryKeys";
import { isPlatformManagedEnvironment } from "../lib/managed-sandbox-environment";
import { AgentStatusBadge, AgentStatusCapsule } from "../components/StatusBadge";
import { MembershipAction } from "../components/MembershipAction";
import { StarToggle } from "../components/StarToggle";
import { EntityRow } from "../components/EntityRow";
import { BuiltInLifecycleChip } from "../components/BuiltInAgentBadges";
import { EmptyState } from "../components/EmptyState";
@@ -30,9 +28,7 @@ import { Button } from "@/components/ui/button";
import { AlertTriangle, Bot, Plus, List, Network } from "lucide-react";
import { AGENT_ROLE_LABELS, type Agent, type Environment, type EnvironmentCapabilities } from "@paperclipai/shared";
import {
isStarred,
resourceMembershipState,
useResourceMembershipMutation,
useResourceMemberships,
} from "../hooks/useResourceMemberships";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
@@ -283,7 +279,6 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
});
usePublishSharedQueryData(sharedRuns, runs, runsUpdatedAt);
const membershipsQuery = useResourceMemberships(selectedCompanyId);
const membershipMutation = useResourceMembershipMutation(selectedCompanyId);
// Map agentId -> first live run + live run count
const liveRunByAgent = useMemo(() => {
@@ -352,13 +347,6 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
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);
const builtInState = builtInByAgentId.get(agent.id);
const showBuiltInLifecycle = builtInState?.status === "needs_setup" || builtInState?.status === "pending_approval";
// Keep lifecycle controls with the metadata only when the content area
@@ -439,37 +427,7 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
<span className="w-20 flex justify-end">
<AgentStatusBadge status={agent.status} />
</span>
<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>
<MembershipAction
state={resourceMembershipState(membershipsQuery.data, "agent", agent.id)}
pending={agentJoinLeavePending}
pendingState={agentJoinLeavePending ? membershipMutation.variables?.state ?? null : null}
resourceName={agent.name}
onJoin={() => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
state: "joined",
})}
onLeave={() => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
state: "left",
})}
/>
</div>
}
/>
@@ -595,7 +553,6 @@ function OrgTreeNode({
showEnvironment,
tab,
memberships,
membershipMutation,
builtInByAgentId,
onConfigureBuiltIn,
}: {
@@ -608,7 +565,6 @@ function OrgTreeNode({
showEnvironment: boolean;
tab: FilterTab;
memberships: ReturnType<typeof useResourceMemberships>["data"];
membershipMutation: ReturnType<typeof useResourceMembershipMutation>;
builtInByAgentId: Map<string, BuiltInAgentState>;
onConfigureBuiltIn: (state: BuiltInAgentState) => void;
}) {
@@ -617,12 +573,6 @@ function OrgTreeNode({
const showBuiltInLifecycle = builtInState?.status === "needs_setup" || builtInState?.status === "pending_approval";
const hasInvalidOrgChain = Boolean(agent && agent.orgChainHealth?.status === "invalid_org_chain");
const membershipState = resourceMembershipState(memberships, "agent", node.id);
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 }}>
@@ -705,38 +655,6 @@ function OrgTreeNode({
<AgentStatusBadge status={node.status} />
</span>
</div>
<MembershipAction
state={membershipState}
pending={joinLeavePending}
pendingState={joinLeavePending ? membershipMutation.variables?.state : null}
resourceName={node.name}
onJoin={() => membershipMutation.mutate({
resourceType: "agent",
resourceId: node.id,
resourceName: node.name,
state: "joined",
})}
onLeave={() => membershipMutation.mutate({
resourceType: "agent",
resourceId: node.id,
resourceName: node.name,
state: "left",
})}
/>
<div className="hidden sm:flex items-center gap-3">
<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>
</div>
</Link>
{node.reports && node.reports.length > 0 && (
@@ -753,7 +671,6 @@ function OrgTreeNode({
showEnvironment={showEnvironment}
tab={tab}
memberships={memberships}
membershipMutation={membershipMutation}
builtInByAgentId={builtInByAgentId}
onConfigureBuiltIn={onConfigureBuiltIn}
/>