fix(site): collapse chat toolbar badges fluidly on overflow (#23663)

This commit is contained in:
Danielle Maywood
2026-03-26 17:21:01 +00:00
committed by GitHub
parent 0234e8fffd
commit d18e700699
3 changed files with 312 additions and 43 deletions
@@ -578,3 +578,77 @@ export const PlusMenuOpen: Story = {
await userEvent.click(canvas.getByRole("button", { name: "More options" }));
},
};
const confluenceMCP = makeMCPServer({
id: "mcp-confluence",
display_name: "Confluence Cloud",
slug: "confluence",
availability: "default_on",
auth_type: "none",
enabled: true,
});
const datadogMCP = makeMCPServer({
id: "mcp-datadog",
display_name: "Datadog Monitoring",
slug: "datadog",
availability: "default_on",
auth_type: "none",
enabled: true,
});
const pagerdutyMCP = makeMCPServer({
id: "mcp-pagerduty",
display_name: "PagerDuty",
slug: "pagerduty",
availability: "default_on",
auth_type: "none",
enabled: true,
});
/** Many tools with a workspace at 414px — forces overflow and "+N" pill. */
export const OverflowBadges: Story = {
args: {
...mcpDefaults,
mcpServers: [
sentryMCP,
linearMCP,
githubMCPConnected,
confluenceMCP,
datadogMCP,
pagerdutyMCP,
],
selectedMCPServerIds: [
sentryMCP.id,
linearMCP.id,
githubMCPConnected.id,
confluenceMCP.id,
datadogMCP.id,
pagerdutyMCP.id,
],
workspaceOptions: [
{ id: "ws-1", name: "my-long-workspace-name", owner_name: "admin" },
],
selectedWorkspaceId: "ws-1",
onWorkspaceChange: fn(),
},
parameters: {
viewport: { defaultViewport: "mobile2" },
chromatic: { viewports: [414] },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Wait for the overflow hook to measure and show the pill.
const pill = await canvas.findByRole("button", {
name: /more item/,
});
await waitFor(() => {
expect(pill).toBeVisible();
});
await userEvent.click(pill);
// The popover renders via a Radix portal outside the
// canvas. Find it by role, then assert content within it.
const popover = await within(document.body).findByRole("dialog");
expect(within(popover).getByText("Confluence Cloud")).toBeInTheDocument();
},
};
@@ -61,6 +61,7 @@ import { useSpeechRecognition } from "#/hooks/useSpeechRecognition";
import { cn } from "#/utils/cn";
import { countInvisibleCharacters } from "#/utils/invisibleUnicode";
import { isMobileViewport } from "#/utils/mobile";
import { useOverflowCount } from "../hooks/useOverflowCount";
import {
fetchTextAttachmentContent,
formatTextAttachmentPreview,
@@ -456,6 +457,67 @@ export const AttachmentPreview: FC<{
);
};
type ToolBadgeData =
| { kind: "workspace"; name: string }
| { kind: "mcp"; server: TypesGen.MCPServerConfig };
const ToolBadge: FC<{
badge: ToolBadgeData;
onRemoveWorkspace?: () => void;
onRemoveMcp?: (serverId: string) => void;
className?: string;
}> = ({ badge, onRemoveWorkspace, onRemoveMcp, className }) => {
const badgeCls = cn(
"inline-flex shrink-0 items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content-secondary",
className,
);
if (badge.kind === "workspace") {
return (
<span className={badgeCls}>
<MonitorIcon className="size-3" />
{badge.name}
{onRemoveWorkspace && (
<button
type="button"
onClick={onRemoveWorkspace}
className="ml-0.5 inline-flex cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-0.5 text-content-secondary transition-colors hover:bg-surface-tertiary hover:text-content-primary"
aria-label={`Remove workspace ${badge.name}`}
>
<XIcon className="!size-2.5" />
</button>
)}
</span>
);
}
const isForceOn = badge.server.availability === "force_on";
return (
<span className={badgeCls}>
{badge.server.icon_url ? (
<ExternalImage
src={badge.server.icon_url}
alt=""
className="size-3 rounded-sm"
/>
) : (
<ServerIcon className="size-3" />
)}
{badge.server.display_name}
{!isForceOn && onRemoveMcp && (
<button
type="button"
onClick={() => onRemoveMcp(badge.server.id)}
className="ml-0.5 inline-flex cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-0.5 text-content-secondary transition-colors hover:bg-surface-tertiary hover:text-content-primary"
aria-label={`Remove ${badge.server.display_name}`}
>
<XIcon className="!size-2.5" />
</button>
)}
</span>
);
};
export const AgentChatInput: FC<AgentChatInputProps> = ({
onSend,
placeholder = "Type a message...",
@@ -598,8 +660,29 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
!(s.auth_type === "oauth2" && !s.auth_connected),
);
const fileInputRef = useRef<HTMLInputElement>(null);
const badgeContainerRef = useRef<HTMLDivElement>(null);
const [overflowPopoverOpen, setOverflowPopoverOpen] = useState(false);
// Ordered list of active tool badge data so we can determine
// which ones ended up in the overflow popover.
const allBadges: ToolBadgeData[] = [];
if (selectedWorkspace && onWorkspaceChange) {
allBadges.push({ kind: "workspace", name: selectedWorkspace.name });
}
for (const s of activeMcpServers) {
allBadges.push({ kind: "mcp", server: s });
}
const overflowCount = useOverflowCount(badgeContainerRef, allBadges.length);
const visibleCount = Math.max(0, allBadges.length - overflowCount);
const overflowBadges = allBadges.slice(visibleCount);
const handleRemoveWorkspace = () => onWorkspaceChange?.(null);
const handleRemoveMcp = (serverId: string) =>
handleMcpToggle(serverId, false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && onAttach) {
onAttach(Array.from(e.target.files));
@@ -1070,50 +1153,69 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
dropdownAlign="center"
/>
)}
{selectedWorkspace && onWorkspaceChange && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content-secondary">
<MonitorIcon className="size-3" />
{selectedWorkspace.name}
<button
type="button"
onClick={() => onWorkspaceChange(null)}
className="ml-0.5 inline-flex cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-0.5 text-content-secondary transition-colors hover:bg-surface-tertiary hover:text-content-primary"
aria-label={`Remove workspace ${selectedWorkspace.name}`}
{/* Badge row — all badges and the pill always
* render so the DOM structure never changes.
* Overflow badges use invisible + order-1 to
* hide and reorder via CSS. The pill is invisible
* when there's no overflow but still occupies
* layout space, preventing measurement flicker. */}
<div
ref={badgeContainerRef}
className="flex min-w-0 items-center gap-1 overflow-hidden"
>
{allBadges.map((badge, i) => {
const isOverflow = overflowCount > 0 && i >= visibleCount;
return (
<ToolBadge
key={badge.kind === "workspace" ? "ws" : badge.server.id}
badge={badge}
onRemoveWorkspace={handleRemoveWorkspace}
onRemoveMcp={handleRemoveMcp}
className={isOverflow ? "invisible order-1" : undefined}
/>
);
})}
{/* Pill — always in the DOM so it permanently
* reserves layout space. Invisible when nothing
* overflows. CSS order keeps it before order-1
* (overflow) badges. */}
<Popover
open={overflowPopoverOpen && overflowCount > 0}
onOpenChange={setOverflowPopoverOpen}
>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"inline-flex shrink-0 cursor-pointer items-center gap-1 rounded-full border-0 bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content-secondary transition-colors hover:bg-surface-tertiary hover:text-content-primary",
overflowCount === 0 && "invisible",
)}
aria-label={`${overflowCount} more item${overflowCount !== 1 ? "s" : ""}`}
aria-hidden={overflowCount === 0}
>
+{overflowCount}
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="flex w-auto max-w-64 flex-wrap gap-1 p-2"
>
<XIcon className="!size-2.5" />
</button>
</span>
)}
{activeMcpServers.map((server) => {
const isForceOn = server.availability === "force_on";
return (
<span
key={server.id}
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content-secondary"
>
{server.icon_url ? (
<ExternalImage
src={server.icon_url}
alt=""
className="size-3 rounded-sm"
{overflowBadges.map((badge) => (
<ToolBadge
key={
badge.kind === "workspace"
? "ws-overflow"
: badge.server.id
}
badge={badge}
onRemoveWorkspace={handleRemoveWorkspace}
onRemoveMcp={handleRemoveMcp}
/>
) : (
<ServerIcon className="size-3" />
)}
{server.display_name}
{!isForceOn && (
<button
type="button"
onClick={() => handleMcpToggle(server.id, false)}
className="ml-0.5 inline-flex cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-0.5 text-content-secondary transition-colors hover:bg-surface-tertiary hover:text-content-primary"
aria-label={`Remove ${server.display_name}`}
>
<XIcon className="!size-2.5" />
</button>
)}
</span>
);
})}
))}
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex items-center gap-2">
{speech.isSupported && !isStreaming && (
@@ -0,0 +1,93 @@
import { type RefObject, useLayoutEffect, useState } from "react";
/**
* Observes a flex container whose children are laid out as:
*
* [item₀] [item₁] … [itemₙ₋₁] [pill]
*
* and reports how many of the first `itemCount` children overflow
* past the container's visible width. The count updates
* automatically when the container resizes or children change.
*
* The caller should always render a "+N" pill as the last child
* (using `visibility: hidden` when the count is 0) so its layout
* space is permanently reserved. The hook reads the pill's actual
* rendered width and the container's CSS `gap` from the DOM, so
* there are no hardcoded sizing assumptions.
*/
export function useOverflowCount(
containerRef: RefObject<HTMLElement | null>,
itemCount: number,
): number {
const [overflowCount, setOverflowCount] = useState(0);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
const measure = () => {
const children = container.children;
const count = Math.min(itemCount, children.length);
if (count === 0) {
setOverflowCount(0);
return;
}
const containerRight = container.getBoundingClientRect().right;
// First pass: check if all items fit at full width.
// If so, no pill needed and we're done.
// +1px tolerance for subpixel rounding in getBoundingClientRect.
let allFit = true;
for (let i = 0; i < count; i++) {
if (children[i].getBoundingClientRect().right > containerRight + 1) {
allFit = false;
break;
}
}
if (allFit) {
setOverflowCount(0);
return;
}
// Something genuinely overflows. Reserve space for the
// pill (last child) so it won't be clipped. Read its
// width and the container gap from the DOM rather than
// hardcoding values that break under font scaling or
// double-digit overflow counts.
const pill = children[children.length - 1];
const pillWidth = pill ? pill.getBoundingClientRect().width : 0;
const gap = Number.parseFloat(
getComputedStyle(container).columnGap || "0",
);
const effectiveRight = containerRight - pillWidth - gap;
// +1px tolerance for subpixel rounding in getBoundingClientRect.
let hidden = 0;
for (let i = 0; i < count; i++) {
if (children[i].getBoundingClientRect().right > effectiveRight + 1) {
hidden++;
}
}
setOverflowCount(Math.max(hidden, 1));
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(container);
const mo = new MutationObserver(measure);
mo.observe(container, { childList: true });
return () => {
ro.disconnect();
mo.disconnect();
};
}, [containerRef, itemCount]);
return overflowCount;
}