mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(site): collapse chat toolbar badges fluidly on overflow (#23663)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user