Fix built-in agent roster name collapsing under chip cluster

At narrow widths the agent name could collapse when the built-in badge and setup chip cluster stayed at full width. Give the name a readable floor and let the lower-priority chip cluster wrap instead.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-09 18:49:42 +00:00
1 parent c1d8d98adc
commit c35876aa40
5 files changed
+187 -78

No files matched your search

+33
View File
@@ -46,4 +46,37 @@ describe("EntityRow", () => {
const markup = renderToStaticMarkup(<EntityRow title="Alpha" />);
expect(markup).toContain("min-w-0 flex-1");
});
it("gives the title a min-width floor and lets meta shrink under titlePriority (PAP-12988)", () => {
const markup = renderToStaticMarkup(
<EntityRow
title="Alpha"
titlePriority
meta={<span data-testid="meta-cell">chips</span>}
/>,
);
// The name keeps a usable floor instead of collapsing to zero...
expect(markup).toContain("min-w-[6rem]");
// ...and the meta cluster is the item that yields (shrinks), not the title.
expect(markup).toContain("min-w-0 shrink");
expect(markup).not.toContain('class="flex items-center gap-2 shrink-0"');
});
it("stacks a secondaryRow on its own line beneath the main row (PAP-12988)", () => {
const markup = renderToStaticMarkup(
<EntityRow
title="Alpha"
titlePriority
meta={<span>chips-inline</span>}
secondaryRow={<span data-testid="secondary-cell">chips-stacked</span>}
/>,
);
// The secondary content renders, and the shell switches from a single flex
// row to a stacked block layout so the cluster gets its own full-width line.
expect(markup).toContain("secondary-cell");
expect(markup).toContain("chips-stacked");
expect(markup).not.toMatch(/^<div class="flex items-center gap-3 px-4/);
});
});
+59 -8
View File
@@ -20,6 +20,21 @@ interface EntityRowProps {
className?: string;
titleClassName?: string;
reserveSubtitleSpace?: boolean;
/**
* Make the title (the row's primary identifier) win the flex fight: it keeps a
* usable min-width floor and ellipsizes, while the `meta` cluster is the item
* that shrinks (and can wrap its own children). Without this, a wide `meta`
* cluster starves the title down to zero at narrow widths. Opt-in so existing
* callers keep the "title shrinks first" behavior.
*/
titlePriority?: boolean;
/**
* Optional content rendered on its own full-width line beneath the main row.
* Use this for a chip/action cluster that would otherwise starve the title at
* narrow widths — gate it with `xl:hidden` and keep the inline copy in `meta`
* behind `hidden xl:flex` so wide layouts are unchanged.
*/
secondaryRow?: ReactNode;
}
export function EntityRow({
@@ -35,10 +50,15 @@ export function EntityRow({
className,
titleClassName,
reserveSubtitleSpace,
titlePriority,
secondaryRow,
}: EntityRowProps) {
const isClickable = !!(to || onClick);
const classes = cn(
"flex items-center gap-3 px-4 py-2 text-sm border-b border-border last:border-b-0 transition-colors",
const shellClasses = cn(
// When a secondaryRow is present the shell stacks (main line + secondary
// line); otherwise the shell itself is the single flex row.
secondaryRow ? "block" : "flex items-center gap-3",
"px-4 py-2 text-sm border-b border-border last:border-b-0 transition-colors",
isClickable && "cursor-pointer hover:bg-accent/50",
selected && "bg-accent/30",
className
@@ -47,7 +67,15 @@ export function EntityRow({
const content = (
<>
{leading && <div className="flex items-center gap-2 shrink-0">{leading}</div>}
<div className={cn("min-w-0", !meta && "flex-1", titleClassName)}>
<div
className={cn(
// `titlePriority` gives the name a floor so it ellipsizes instead of
// collapsing to zero; otherwise the title may shrink to nothing.
titlePriority ? "min-w-[6rem]" : "min-w-0",
!meta && "flex-1",
titleClassName,
)}
>
<div className="flex items-center gap-2">
{identifier && (
<span className="text-xs text-muted-foreground font-mono shrink-0 relative top-(--sz-1px)">
@@ -65,23 +93,46 @@ export function EntityRow({
</p>
)}
</div>
{meta && <div className="flex items-center gap-2 shrink-0">{meta}</div>}
{meta && (
<div
className={cn(
"flex items-center gap-2",
// In title-priority mode the meta cluster yields (shrinks/wraps)
// before the name does; otherwise it holds its width.
titlePriority ? "min-w-0 shrink" : "shrink-0",
)}
>
{meta}
</div>
)}
{meta && <div className="flex-1" />}
{trailing && <div className="flex items-center gap-2 shrink-0">{trailing}</div>}
</>
);
// With a secondaryRow, wrap the main line in its own flex row and stack the
// secondary content beneath it (indented to align under the title, past the
// leading capsule). Without it, `content` is rendered directly (unchanged).
const body = secondaryRow ? (
<>
<div className="flex items-center gap-3">{content}</div>
<div className="mt-1 pl-5">{secondaryRow}</div>
</>
) : (
content
);
if (to) {
return (
<Link to={to} className={cn("no-underline text-inherit", classes)} onClick={onClick}>
{content}
<Link to={to} className={cn("no-underline text-inherit", shellClasses)} onClick={onClick}>
{body}
</Link>
);
}
return (
<div className={classes} onClick={onClick}>
{content}
<div className={shellClasses} onClick={onClick}>
{body}
</div>
);
}
+6 -13
View File
@@ -151,17 +151,10 @@ function SidebarAgentItem({
: isPaused && hasInvalidOrgChain
? "Invalid org chain"
: pauseResumeLabel;
const trailingLabelParts: string[] = [];
if (builtInStatus) {
trailingLabelParts.push(
builtInStatus === "pending_approval"
? "Built-in, pending approval"
: builtInStatus === "needs_setup"
? "Built-in, needs setup"
: "Built-in",
);
}
if (hasInvalidOrgChain) trailingLabelParts.push("Invalid reporting chain");
const trailingLabel = [
builtInStatus ? `Built-in agent ${builtInStatus.replace(/_/g, " ")}` : null,
hasInvalidOrgChain ? "Invalid reporting chain" : null,
].filter(Boolean).join(", ") || undefined;
// C11 (DECISION-SHEET.md): the row itself is a SidebarNavItem, so agent rows
// share the nav-row chrome (type, active state, rail tooltip, live dot).
@@ -180,7 +173,7 @@ function SidebarAgentItem({
)}
trailing={
builtInStatus || hasInvalidOrgChain ? (
<span className="flex min-w-0 shrink-0 items-center gap-1">
<span className="ml-1 flex shrink-0 items-center gap-1">
{builtInStatus ? (
<>
<BuiltInAgentBadge compact />
@@ -193,7 +186,7 @@ function SidebarAgentItem({
</span>
) : undefined
}
trailingLabel={trailingLabelParts.length > 0 ? trailingLabelParts.join(", ") : undefined}
trailingLabel={trailingLabel}
liveAccessory={
agent.pauseReason === "budget" ? <BudgetSidebarMarker title="Agent paused by budget" /> : undefined
}
+45 -24
View File
@@ -314,6 +314,32 @@ export function Agents() {
const agentJoinLeavePending = agentPending && membershipMutation.variables?.starred === undefined;
const agentStarred = isStarred(membershipsQuery.data, "agent", agent.id);
const builtInState = builtInByAgentId.get(agent.id);
// Provenance badge + lifecycle chip + inline `Set up`. Rendered inline in
// `meta` at xl (where there's room and the meta columns align) and on a
// dedicated full-width line beneath the name below xl, so the chips never
// starve the name — the row's primary identifier — at narrow widths.
const builtInCluster = builtInState ? (
<>
<BuiltInAgentBadge />
<BuiltInLifecycleChip status={builtInState.status} />
{builtInState.status === "needs_setup" && (
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Button
size="xs"
variant="outline"
onClick={() => setConfigureState(builtInState)}
>
Set up
</Button>
</span>
)}
</>
) : null;
return (
<EntityRow
key={agent.id}
@@ -323,6 +349,10 @@ export function Agents() {
// 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"
// The name is the row's primary identifier: keep it above a usable
// floor and let the badge/action cluster shrink & wrap instead of
// letting the chips starve the name to zero at narrow widths (PAP-12988).
titlePriority
subtitle={`${roleLabels[agent.role] ?? agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
to={agentUrl(agent)}
className={cn(
@@ -335,30 +365,18 @@ export function Agents() {
) : (
<AgentStatusCapsule status={agent.status} />
)}
secondaryRow={
builtInCluster ? (
<div className="xl:hidden flex flex-wrap items-center gap-1.5">
{builtInCluster}
</div>
) : undefined
}
meta={
<div className="flex items-center gap-3">
{builtInState && (
<div className="flex items-center gap-1.5">
{/* Provenance sits immediately after the name, then the
lifecycle chip; the real agent status stays in `trailing`. */}
<BuiltInAgentBadge />
<BuiltInLifecycleChip status={builtInState.status} />
{builtInState.status === "needs_setup" && (
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Button
size="xs"
variant="outline"
onClick={() => setConfigureState(builtInState)}
>
Set up
</Button>
</span>
)}
{builtInCluster && (
<div className="hidden xl:flex items-center gap-1.5">
{builtInCluster}
</div>
)}
<div className="hidden xl:flex items-center gap-3">
@@ -630,8 +648,11 @@ function OrgTreeNode({
) : (
<AgentStatusCapsule status={node.status} />
)}
<div className="flex-1 min-w-(--sz-7rem) flex items-center gap-2">
<div className="min-w-0">
<div className="flex-1 min-w-0 flex flex-wrap items-center gap-2">
{/* Name floor + `truncate` keeps the primary identifier readable; the
cluster wraps to a second line under pressure instead of starving
the name at narrow widths (PAP-12988). */}
<div className="min-w-[7rem] truncate">
<span className="text-sm font-medium">{node.name}</span>
<span className="text-xs text-muted-foreground ml-2">
{roleLabels[node.role] ?? node.role}
@@ -66,6 +66,42 @@ const notProvisionedState: BuiltInAgentState = {
pauseReason: null,
};
/**
* Mirrors Agents.tsx renderAgentRow: the built-in cluster sits inline in `meta`
* at xl and drops to a full-width `secondaryRow` beneath the name below xl, with
* `titlePriority` giving the name a floor so it never collapses (PAP-12988).
*/
function RosterRow({
name,
lifecycle,
status,
}: {
name: string;
lifecycle?: "needs_setup" | "pending_approval";
status: string;
}) {
const cluster = (
<>
<BuiltInAgentBadge />
{lifecycle && <BuiltInLifecycleChip status={lifecycle} />}
{lifecycle === "needs_setup" && (
<Button size="xs" variant="outline">Set up</Button>
)}
</>
);
return (
<EntityRow
title={name}
titleClassName="w-56"
titlePriority
subtitle="General"
secondaryRow={<div className="xl:hidden flex flex-wrap items-center gap-1.5">{cluster}</div>}
meta={<div className="hidden xl:flex items-center gap-1.5">{cluster}</div>}
trailing={<AgentStatusBadge status={status} />}
/>
);
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
@@ -89,40 +125,15 @@ export const SurfaceGallery: Story = {
<div className="space-y-3">
<SectionLabel>Board 1 — Roster rows</SectionLabel>
<div className="rounded-lg border border-border divide-y divide-border">
<EntityRow
title="Briefs Agent"
subtitle="General"
meta={
<div className="flex items-center gap-1.5">
<BuiltInAgentBadge />
<BuiltInLifecycleChip status="needs_setup" />
<Button size="xs" variant="outline">Set up</Button>
</div>
}
trailing={<AgentStatusBadge status="idle" />}
/>
<EntityRow
title="Learning Agent"
subtitle="General"
meta={
<div className="flex items-center gap-1.5">
<BuiltInAgentBadge />
</div>
}
trailing={<AgentStatusBadge status="active" />}
/>
<EntityRow
title="Briefs Agent"
subtitle="General"
meta={
<div className="flex items-center gap-1.5">
<BuiltInAgentBadge />
<BuiltInLifecycleChip status="pending_approval" />
</div>
}
trailing={<AgentStatusBadge status="idle" />}
/>
<RosterRow name="Briefs Agent" lifecycle="needs_setup" status="idle" />
<RosterRow name="Learning Agent" status="active" />
<RosterRow name="Briefs Agent" lifecycle="pending_approval" status="idle" />
</div>
<p className="text-[11px] text-muted-foreground">
Resize below the <code>xl</code> breakpoint to see the badge/action
cluster drop to a second line so the agent name never collapses
(PAP-12988).
</p>
</div>
<div className="space-y-3">