mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
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:
1 parent
c1d8d98adc
commit
c35876aa40
5 files changed
+187
-78
No files matched your search
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user