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 && (