From d18e70069993c01f1f5b90aa0d0011a4988f66d5 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 26 Mar 2026 17:21:01 +0000 Subject: [PATCH] fix(site): collapse chat toolbar badges fluidly on overflow (#23663) --- .../components/AgentChatInput.stories.tsx | 74 +++++++ .../AgentsPage/components/AgentChatInput.tsx | 188 ++++++++++++++---- .../AgentsPage/hooks/useOverflowCount.ts | 93 +++++++++ 3 files changed, 312 insertions(+), 43 deletions(-) create mode 100644 site/src/pages/AgentsPage/hooks/useOverflowCount.ts diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index c334043bbc..755fb7a870 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -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(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 1395e07176..83bf8f4614 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -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 ( + + + {badge.name} + {onRemoveWorkspace && ( + + )} + + ); + } + + const isForceOn = badge.server.availability === "force_on"; + return ( + + {badge.server.icon_url ? ( + + ) : ( + + )} + {badge.server.display_name} + {!isForceOn && onRemoveMcp && ( + + )} + + ); +}; + export const AgentChatInput: FC = ({ onSend, placeholder = "Type a message...", @@ -598,8 +660,29 @@ export const AgentChatInput: FC = ({ !(s.auth_type === "oauth2" && !s.auth_connected), ); - const fileInputRef = useRef(null); + const badgeContainerRef = useRef(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(null); const handleFileSelect = (e: React.ChangeEvent) => { if (e.target.files && onAttach) { onAttach(Array.from(e.target.files)); @@ -1070,50 +1153,69 @@ export const AgentChatInput: FC = ({ dropdownAlign="center" /> )} - {selectedWorkspace && onWorkspaceChange && ( - - - {selectedWorkspace.name} - + + - - - - )} - {activeMcpServers.map((server) => { - const isForceOn = server.availability === "force_on"; - return ( - - {server.icon_url ? ( - ( + - ) : ( - - )} - {server.display_name} - {!isForceOn && ( - - )} - - ); - })} + ))} + + +
{speech.isSupported && !isStreaming && ( diff --git a/site/src/pages/AgentsPage/hooks/useOverflowCount.ts b/site/src/pages/AgentsPage/hooks/useOverflowCount.ts new file mode 100644 index 0000000000..d1d1dec8c4 --- /dev/null +++ b/site/src/pages/AgentsPage/hooks/useOverflowCount.ts @@ -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, + 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; +}