fix(ui): keep agent names visible on mobile agents index (#9236)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Devin Foley 2026-07-09 13:37:17 -07:00 committed by GitHub
parent 4d50fa9f29
commit bc85b456a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 226 additions and 65 deletions

View File

@ -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(
<EntityRow
title="Alpha"
meta={<span data-testid="meta-cell">gpt-5.4</span>}
trailing={<span data-testid="trailing-cell">badge</span>}
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(<EntityRow title="Alpha" />);
expect(markup).toContain("min-w-0 flex-1");

View File

@ -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}
</span>
)}
<span className="truncate" title={title}>{title}</span>
<span className={cn(!titleTextClassName && "truncate", titleTextClassName)} title={title}>
{title}
</span>
</div>
{(subtitle || reserveSubtitleSpace) && (
<p
className={cn("text-xs text-muted-foreground truncate mt-0.5 min-h-4", !subtitle && "invisible")}
className={cn(
"text-xs text-muted-foreground mt-0.5 min-h-4",
!subtitleClassName && "truncate",
subtitleClassName,
!subtitle && "invisible",
)}
aria-hidden={!subtitle}
>
{subtitle}
@ -66,7 +79,7 @@ export function EntityRow({
)}
</div>
{meta && <div className="flex items-center gap-2 shrink-0">{meta}</div>}
{meta && <div className="flex-1" />}
{meta && <div className={cn("flex-1", metaSpacerClassName)} />}
{trailing && <div className="flex items-center gap-2 shrink-0">{trailing}</div>}
</>
);

View File

@ -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(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Agents />
</ToastProvider>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Agents />
</ToastProvider>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Agents />
</ToastProvider>
</QueryClientProvider>,
);
});
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 () => {

View File

@ -284,17 +284,20 @@ export function Agents() {
<EntityRow
key={agent.id}
title={agent.name}
// Fixed (truncating) title width so the `meta` group starts at a
// Fixed (truncating) title width at xl so the `meta` group starts at a
// constant x on every row — that's what makes the model + timestamp
// columns line up vertically. Agent names vary in width, so
// a content-sized title (`min-w-(--sz-7rem)`) shifted meta's start per row.
titleClassName="w-56"
// columns line up vertically. Below xl the meta columns are hidden, so
// the title flexes instead: a fixed width there let the shrink-0
// trailing actions squeeze the name to zero width on mobile.
titleClassName="flex-1 xl:flex-none xl:w-56"
titleTextClassName="whitespace-normal break-words xl:truncate xl:whitespace-nowrap"
subtitleClassName="whitespace-normal break-words xl:truncate xl:whitespace-nowrap"
subtitle={`${roleLabels[agent.role] ?? agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
to={agentUrl(agent)}
className={cn(
"group",
agent.pausedAt && tab !== "paused" ? "opacity-50" : "",
resourceMembershipState(membershipsQuery.data, "agent", agent.id) === "left" ? "text-foreground/55" : "",
resourceMembershipState(membershipsQuery.data, "agent", agent.id) === "left" ? "sm:text-foreground/55" : "",
)}
leading={hasInvalidOrgChain ? (
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label="Invalid reporting chain" />
@ -310,19 +313,9 @@ export function Agents() {
/>
</div>
}
metaSpacerClassName="hidden xl:block"
trailing={
<div className="flex items-center gap-3">
<span className="sm:hidden">
{liveRunByAgent.has(agent.id) ? (
<LiveRunIndicator
agentRef={agentRouteRef(agent)}
runId={liveRunByAgent.get(agent.id)!.runId}
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
/>
) : (
<AgentStatusBadge status={agent.status} />
)}
</span>
<div className="hidden sm:flex items-center gap-3">
{liveRunByAgent.has(agent.id) && (
<LiveRunIndicator
@ -334,20 +327,33 @@ export function Agents() {
<span className="w-20 flex justify-end">
<AgentStatusBadge status={agent.status} />
</span>
</div>
{/* Row actions mirror the agent detail page; stop the click
from bubbling to the row link so buttons don't navigate. */}
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<AgentActionButtons
agent={agent}
companyId={selectedCompanyId}
runLabel="Run Heartbeat"
showStatus={false}
{/* Row actions mirror the agent detail page; stop the click
from bubbling to the row link so buttons don't navigate.
Hidden on mobile so the agent name keeps room to render. */}
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<AgentActionButtons
agent={agent}
companyId={selectedCompanyId}
runLabel="Run Heartbeat"
showStatus={false}
/>
</div>
<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
@ -368,18 +374,6 @@ 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>
}
/>
@ -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({
) : (
<AgentStatusCapsule status={node.status} />
)}
<div className="flex-1 min-w-(--sz-7rem)">
{/* min-w-0 + truncate so deep indentation on narrow screens shortens
the name with an ellipsis instead of overflowing the row. */}
<div className="flex-1 min-w-0 truncate">
<span className="text-sm font-medium">{node.name}</span>
<span className="text-xs text-muted-foreground ml-2">
{roleLabels[node.role] ?? node.role}
@ -612,18 +608,20 @@ 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 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 && (