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:
parent
4d50fa9f29
commit
bc85b456a1
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
Loading…
Reference in New Issue