From bc85b456a153d904525461d629b420ba1cc6aef5 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 9 Jul 2026 13:37:17 -0700 Subject: [PATCH] fix(ui): keep agent names visible on mobile agents index (#9236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The Agents index (`ui/src/pages/Agents.tsx`) is the main roster view, with both a list layout and an org-tree layout > - On mobile viewports the roster could render rows with no visible agent names because shrink-resistant trailing controls competed with the fixed-width title cell > - A roster where names are unreadable is unusable on phones, and the org-tree view had a related narrow-width overflow risk for deeply indented names > - This pull request lets the list title flex below `xl`, hides nonessential row controls on mobile, keeps Join/Leave reachable, and truncates org-tree names safely > - The result is a readable agents index on mobile while preserving desktop meta-column alignment and truncation ## Linked Issues or Issue Description No existing public GitHub issue; describing the bug per the bug report template: **What happened?** On a mobile-width viewport, the agents index showed rows where agent names could become unreadable or disappear because trailing controls consumed the available row width. In the org view, deeply indented names could overflow the row. **Expected behavior** Agent names remain visible on every viewport, Join/Leave stays reachable, and desktop rows continue to align and truncate as before. **Steps to reproduce** Open Paperclip in a browser at a narrow viewport, navigate to the Agents index, and observe rows with long names or left-membership controls. **Paperclip version or commit** Reproducible on `master` prior to this fix. **Deployment mode** Local dev instance. The bug is viewport-width dependent, not deployment-mode dependent. ## What Changed - `EntityRow` now lets callers control title text, subtitle text, and the meta spacer classes while keeping the default truncation behavior unchanged. - Agents list rows now use `flex-1 xl:flex-none xl:w-56`, so names get mobile width while desktop meta columns keep their aligned fixed title column. - Long list-view names/subtitles wrap below `xl` but return to truncation at `xl` and above. - Join/Leave stays visible on mobile; run/status/star row controls stay hidden on mobile to avoid squeezing names. - Org-tree rows use `min-w-0 truncate`, so deep indentation shortens names with an ellipsis instead of overflowing. - Regression tests cover mobile name visibility, left-membership dimming, responsive title/meta behavior, and mobile Join/Leave reachability. ## Verification - `pnpm --filter @paperclipai/ui exec vitest run src/components/EntityRow.test.tsx src/pages/Agents.test.tsx` — 2 files, 18 tests passed. - `pnpm check:token-gates` — all gates clean. - `git diff --cached | rg -n "(AKIA|ASIA|SECRET|TOKEN|PASSWORD|PRIVATE KEY|BEGIN RSA|BEGIN OPENSSH|api[_-]?key|bearer|paperclip_api_key|DATABASE_URL|postgres://|sk-[A-Za-z0-9]|xox[baprs]-)"` — no matches before push. ## Risks - Low risk: UI-only change scoped to the agents index. The main behavior change is that nonessential row controls remain hidden on mobile while Join/Leave remains available there and all controls remain available on larger screens/detail pages. ## Model Used - OpenAI GPT-5 via Codex (`codex_local` adapter), agentic code editing and local/GitHub verification. ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups Co-authored-by: Paperclip --- ui/src/components/EntityRow.test.tsx | 13 +++ ui/src/components/EntityRow.tsx | 19 +++- ui/src/pages/Agents.test.tsx | 149 +++++++++++++++++++++++++-- ui/src/pages/Agents.tsx | 110 ++++++++++---------- 4 files changed, 226 insertions(+), 65 deletions(-) diff --git a/ui/src/components/EntityRow.test.tsx b/ui/src/components/EntityRow.test.tsx index 90e7666f1e..d4f7eb9b27 100644 --- a/ui/src/components/EntityRow.test.tsx +++ b/ui/src/components/EntityRow.test.tsx @@ -42,6 +42,19 @@ describe("EntityRow", () => { expect(markup).not.toContain("min-w-0 flex-1"); }); + it("lets callers make the meta spacer responsive", () => { + const markup = renderToStaticMarkup( + gpt-5.4} + trailing={badge} + metaSpacerClassName="hidden xl:block" + />, + ); + + expect(markup).toContain('class="flex-1 hidden xl:block"'); + }); + it("keeps the title flex-growing when no meta is provided", () => { const markup = renderToStaticMarkup(); expect(markup).toContain("min-w-0 flex-1"); diff --git a/ui/src/components/EntityRow.tsx b/ui/src/components/EntityRow.tsx index abc734280d..2671e27402 100644 --- a/ui/src/components/EntityRow.tsx +++ b/ui/src/components/EntityRow.tsx @@ -13,12 +13,15 @@ interface EntityRowProps { * `trailing`, so meta sits next to the name while trailing stays pinned right. */ meta?: ReactNode; + metaSpacerClassName?: string; trailing?: ReactNode; selected?: boolean; to?: string; onClick?: () => void; className?: string; titleClassName?: string; + titleTextClassName?: string; + subtitleClassName?: string; reserveSubtitleSpace?: boolean; } @@ -28,12 +31,15 @@ export function EntityRow({ title, subtitle, meta, + metaSpacerClassName, trailing, selected, to, onClick, className, titleClassName, + titleTextClassName, + subtitleClassName, reserveSubtitleSpace, }: EntityRowProps) { const isClickable = !!(to || onClick); @@ -54,11 +60,18 @@ export function EntityRow({ {identifier} )} - {title} + + {title} + {(subtitle || reserveSubtitleSpace) && (

{subtitle} @@ -66,7 +79,7 @@ export function EntityRow({ )} {meta &&

{meta}
} - {meta &&
} + {meta &&
} {trailing &&
{trailing}
} ); diff --git a/ui/src/pages/Agents.test.tsx b/ui/src/pages/Agents.test.tsx index b73c5d8e2d..351d97d41a 100644 --- a/ui/src/pages/Agents.test.tsx +++ b/ui/src/pages/Agents.test.tsx @@ -35,6 +35,7 @@ const mockResourceMembershipsApi = vi.hoisted(() => ({ const mockOpenNewAgent = vi.hoisted(() => vi.fn()); const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); +const mockSidebarState = vi.hoisted(() => ({ isMobile: false })); vi.mock("@/lib/router", () => ({ Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => ( @@ -57,7 +58,7 @@ vi.mock("../context/BreadcrumbContext", () => ({ })); vi.mock("../context/SidebarContext", () => ({ - useSidebar: () => ({ isMobile: false }), + useSidebar: () => ({ isMobile: mockSidebarState.isMobile }), })); vi.mock("../api/agents", () => ({ @@ -302,6 +303,7 @@ describe("Agents", () => { state: "left", updatedAt: new Date("2026-01-02T00:00:00Z"), }); + mockSidebarState.isMobile = false; }); afterEach(async () => { @@ -341,6 +343,65 @@ describe("Agents", () => { expect(heartbeatCell?.textContent).not.toContain("\n"); }); + it("gives mobile agent names the full row width after the leading status indicator", async () => { + mockSidebarState.isMobile = true; + mockResourceMembershipsApi.listMine.mockResolvedValue({ + projectMemberships: {}, + agentMemberships: { + "agent-mobile": "left", + }, + starredProjectIds: [], + starredAgentIds: [], + projectStarredAt: {}, + agentStarredAt: {}, + updatedAt: new Date("2026-01-02T00:00:00Z"), + }); + mockAgentsApi.list.mockResolvedValue([ + makeAgent({ + id: "agent-mobile", + name: "Paperclip Engineer With A Much Longer Display Name", + title: "Software Engineer With A Much Longer Specialty Title", + urlKey: "paperclip-engineer-long", + }), + ]); + + root = createRoot(container); + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + const row = findAgentRow(container, "Paperclip Engineer With A Much Longer Display Name"); + expect(row).not.toBeNull(); + expect(row?.querySelector(".sm\\:hidden")).toBeNull(); + expect(row?.querySelector(".hidden.sm\\:flex")).not.toBeNull(); + expect(row?.querySelector(".flex-1.hidden.xl\\:block")).not.toBeNull(); + expect(row?.classList.contains("text-foreground/55")).toBe(false); + expect(row?.classList.contains("sm:text-foreground/55")).toBe(true); + const name = row?.querySelector("span[title='Paperclip Engineer With A Much Longer Display Name']"); + const subtitle = Array.from(row?.querySelectorAll("p") ?? []).find((node) => + node.textContent?.includes("Software Engineer With A Much Longer Specialty Title"), + ); + expect(name?.classList.contains("whitespace-normal")).toBe(true); + expect(name?.classList.contains("break-words")).toBe(true); + expect(name?.classList.contains("xl:truncate")).toBe(true); + expect(name?.classList.contains("xl:whitespace-nowrap")).toBe(true); + expect(name?.classList.contains("truncate")).toBe(false); + expect(subtitle).toBeDefined(); + expect(subtitle?.classList.contains("whitespace-normal")).toBe(true); + expect(subtitle?.classList.contains("break-words")).toBe(true); + expect(subtitle?.classList.contains("xl:truncate")).toBe(true); + expect(subtitle?.classList.contains("xl:whitespace-nowrap")).toBe(true); + expect(subtitle?.classList.contains("truncate")).toBe(false); + }); + it("shows effective environment and sandbox provider beside agents", async () => { mockAgentsApi.list.mockResolvedValue([ makeAgent({ @@ -683,13 +744,89 @@ describe("Agents", () => { }); await flushReact(); - // The title cell carries a constant width (`w-56`), not a content-sized - // `min-w-(--sz-7rem)`, so the `meta` group starts at the same x on every row and - // the model + timestamp columns line up vertically. - const titleCell = container.querySelector(".w-56"); + // The title cell carries a constant width at xl (`xl:w-56`), not a + // content-sized `min-w-(--sz-7rem)`, so the `meta` group starts at the same + // x on every row and the model + timestamp columns line up vertically. + // Below xl the meta columns are hidden and the title flexes (`flex-1`) + // instead, so the shrink-0 trailing actions can't squeeze the agent name + // to zero width on mobile. + const titleCell = container.querySelector(".xl\\:w-56"); expect(titleCell).not.toBeNull(); expect(titleCell?.textContent).toContain("Alpha"); - expect(container.querySelector(".min-w-\\[7rem\\]")).toBeNull(); + expect(titleCell?.classList.contains("flex-1")).toBe(true); + expect(container.querySelector(".min-w-\\(--sz-7rem\\)")).toBeNull(); + }); + + it("keeps row membership actions reachable while hiding star actions on mobile", async () => { + root = createRoot(container); + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + // Org 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. + const listToggle = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.querySelector("svg.lucide-list"), + ); + await act(async () => { + listToggle!.click(); + }); + await flushReact(); + + 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(); + }); + + it("does not dim left-membership agent names on mobile", async () => { + mockSidebarState.isMobile = true; + mockResourceMembershipsApi.listMine.mockResolvedValue({ + projectMemberships: {}, + agentMemberships: { + "agent-1": "left", + }, + starredProjectIds: [], + starredAgentIds: [], + projectStarredAt: {}, + agentStarredAt: {}, + updatedAt: new Date("2026-01-02T00:00:00Z"), + }); + + root = createRoot(container); + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + const row = findAgentRow(container, "Alpha"); + expect(row).not.toBeNull(); + expect(row?.classList.contains("text-foreground/55")).toBe(false); + expect(row?.classList.contains("sm:text-foreground/55")).toBe(true); }); it("keeps invalid-org-chain agents visible with a warning marker", async () => { diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 2f0ff53210..4c6ec29e47 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -284,17 +284,20 @@ export function Agents() { @@ -310,19 +313,9 @@ export function Agents() { />
} + metaSpacerClassName="hidden xl:block" trailing={
- - {liveRunByAgent.has(agent.id) ? ( - - ) : ( - - )} -
{liveRunByAgent.has(agent.id) && ( -
- {/* Row actions mirror the agent detail page; stop the click - from bubbling to the row link so buttons don't navigate. */} -
{ - e.preventDefault(); - e.stopPropagation(); - }} - > -
+ membershipMutation.mutate({ + resourceType: "agent", + resourceId: agent.id, + resourceName: agent.name, + starred: next, + })} />
- membershipMutation.mutate({ - resourceType: "agent", - resourceId: agent.id, - resourceName: agent.name, - starred: next, - })} - />
} /> @@ -542,7 +536,7 @@ function OrgTreeNode({ className={cn( "group flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-accent/50 transition-colors w-full text-left no-underline text-inherit", agent?.pausedAt && tab !== "paused" && "opacity-50", - membershipState === "left" && "text-foreground/55", + membershipState === "left" && "sm:text-foreground/55", )} > {hasInvalidOrgChain ? ( @@ -550,7 +544,9 @@ function OrgTreeNode({ ) : ( )} -
+ {/* min-w-0 + truncate so deep indentation on narrow screens shortens + the name with an ellipsis instead of overflowing the row. */} +
{node.name} {roleLabels[node.role] ?? node.role} @@ -612,18 +608,20 @@ function OrgTreeNode({ state: "left", })} /> - membershipMutation.mutate({ - resourceType: "agent", - resourceId: node.id, - resourceName: node.name, - starred: next, - })} - /> +
+ membershipMutation.mutate({ + resourceType: "agent", + resourceId: node.id, + resourceName: node.name, + starred: next, + })} + /> +
{node.reports && node.reports.length > 0 && (