mirror of
https://github.com/coder/coder.git
synced 2026-08-29 02:20:29 +08:00
fix(site): size chat pill selectors to content with an 8ch truncation floor (mobile and desktop fixes) (#28691)
## Problem #28487 gave the model selector and workspace pill a fixed `min-w-[calc(8ch+3.125rem)]` floor. That fixed the pill shrinking to nothing under width pressure, but caused regressions (#28685 reverts it): - Short labels (e.g. "Fable 5") were padded out to the floor with dead space. - Long labels were clamped (workspace pill at 200px) or truncated even when the toolbar had visible free space. The visible-free-space bug had a second root cause: overflowed badges stayed in flow (`invisible order-1`) so the overflow hook could re-measure them, and their reserved space silently consumed the row's flex free space. The model pill sat pinned at its floor next to a fake gap, and hidden badges could never return. ## Fix Keep the part of #28487 that works, the workspace pill collapsing into the `+N` overflow popover, and rebuild the sizing on two fronts. **Pills size to content with a growth floor** (model selector trigger and workspace pill wrapper): ``` basis-[calc(8ch_+_3.125rem)] /* the truncation floor */ shrink-0 /* never shrink below the floor */ grow /* expand into free row space */ max-w-max /* never wider than the label */ ``` Flexbox clamps the base size by `max-width`, so short labels sit at natural width (no dead space) and long labels truncate only under genuine pressure. The workspace pill's 200px clamp is removed, the toolbar's left group gains `flex-1` so free row space reaches the pills, and `ModelSelector`'s defaults return to `min-w-0 shrink` (sizing is owned by the chat-input callsite; other callsites unaffected). **Overflowed badges release their space.** They now hide with `display: none`, and `useOverflowCount` decides fit from cached last-visible widths against the toolbar group's right edge instead of in-flow positions. The hook also reserves the truncation deficit of the container's siblings, giving pills priority: the model label expands to its natural width before badges claim inline space, and badges that lose the contest stay reachable in the `+N` popover. Resulting priority under pressure: pills keep natural width while badges collapse into `+N`; once all badges are collapsed, pills shrink toward the ~8ch floor; below the floor the workspace pill collapses into `+N` too. Mobile polish: the `+N` popover anchors to its pill just above the toolbar row (previously it covered the row), the workspace stays an interactive pill with its menu inside the popover, status tooltips are hidden below `md` (touch focus left them stuck open), and both pills share a height at every breakpoint. Stacked on #28685: this branch contains the revert plus a revert-of-the-revert, which cancel out; the diff shrinks to just this fix once #28685 merges. ## Testing Three new Storybook interaction stories: `ShortModelNameHasNoDeadSpace` (crowded mobile toolbar, trigger narrower than the floor, label untruncated), `LongLabelsExpandWithoutMCPs` (labels untruncated, workspace pill wider than the old 200px clamp, no `+N` pill), and `ModelExpandsWhileBadgesOverflow` (wide badges collapse into `+N` and the model label renders untruncated in the freed space). The restored `OverflowBadges` and `LongWorkspaceNameMobile` stories still verify the `+N` collapse. All story tests pass, plus typecheck and biome. FE-rule note (FE10): the new stories assert geometry (widths, `scrollWidth`) because dead space, truncation, and the clamp have no semantic signal; the floor is measured via a probe resolved against the real font rather than hardcoded pixels. <details> <summary>Decision log</summary> - Considered `min-width: min(max-content, 8ch + 3.125rem)`: invalid CSS, intrinsic keywords are not allowed inside `min()`. - Considered a JS measurement hook setting inline `min-width`: works but adds a measure-clear-restore dance; the basis/grow/max-content scheme expresses the same clamp declaratively. - First iteration kept overflowed badges in flow (`invisible order-1`, the pre-existing mechanism). Reproduction showed their reserved space blocked pill growth entirely and produced a large fake gap after the `+N` pill, so hidden badges now use `display:none` with width caching in the hook. - Last-visible widths live in an element-keyed WeakMap that is never cleared: clearing on badge-count changes would reintroduce a one-frame all-badges-visible flicker. Consequence: a badge whose label changes while hidden keeps its stale cached width until it is next visible, then self-corrects. - Sibling-deficit reservation decides the pills-vs-badges contest in favor of pills. Without it, badges that fit at the model's floor width kept the model truncated; measured equilibria confirmed no oscillation because freed slack is always smaller than the badge that was hidden. - The mobile pill height jump (h-7 below md vs h-auto at md+) reported during review is pre-existing intentional touch-target sizing from #28399 and is out of scope here. </details> > Created by Coder Agents on behalf of @tracyjohnsonux.
This commit is contained in:
@@ -1449,6 +1449,25 @@ const pagerdutyMCP = buildMCPServer({
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
// Wide badges that cannot fit force into +N overflow.
|
||||
const confluenceWideMCP = buildMCPServer({
|
||||
id: "mcp-confluence-wide",
|
||||
display_name: "Confluence Cloud Enterprise Wiki",
|
||||
slug: "confluence-wide",
|
||||
availability: "default_on",
|
||||
auth_type: "none",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const datadogWideMCP = buildMCPServer({
|
||||
id: "mcp-datadog-wide",
|
||||
display_name: "Datadog Infrastructure Monitoring",
|
||||
slug: "datadog-wide",
|
||||
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: {
|
||||
@@ -1479,6 +1498,21 @@ export const OverflowBadges: Story = {
|
||||
],
|
||||
selectedWorkspaceId: "ws-1",
|
||||
onWorkspaceChange: fn(),
|
||||
attachedWorkspace: {
|
||||
id: "ws-1",
|
||||
name: "my-long-workspace-name",
|
||||
route: "/@admin/my-long-workspace-name",
|
||||
statusIcon: <MonitorDotIcon className="size-3" />,
|
||||
statusLabel: "Workspace running",
|
||||
},
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
id: "ws-1",
|
||||
name: "my-long-workspace-name",
|
||||
owner_name: "admin",
|
||||
},
|
||||
workspaceAgent: MockWorkspaceAgent,
|
||||
chatId: "overflow-chat-id",
|
||||
},
|
||||
parameters: {
|
||||
viewport: { defaultViewport: "mobile2" },
|
||||
@@ -1546,12 +1580,19 @@ export const ContextNearLimit: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Long workspace name at iPhone SE width - verifies truncation. */
|
||||
/** Long workspace name at iPhone SE width collapses into +N overflow. */
|
||||
export const LongWorkspaceNameMobile: Story = {
|
||||
args: {
|
||||
...mcpDefaults,
|
||||
mcpServers: [githubMCPConnected],
|
||||
selectedMCPServerIds: [githubMCPConnected.id],
|
||||
attachedWorkspace: {
|
||||
id: MockWorkspace.id,
|
||||
name: "my-super-extremely-long-workspace-name-that-overflows",
|
||||
route: `/@${MockWorkspace.owner_name}/my-super-extremely-long-workspace-name-that-overflows`,
|
||||
statusIcon: <MonitorDotIcon className="size-3" />,
|
||||
statusLabel: "Workspace running",
|
||||
},
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
name: "my-super-extremely-long-workspace-name-that-overflows",
|
||||
@@ -1565,15 +1606,48 @@ export const LongWorkspaceNameMobile: Story = {
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
// The workspace pill button should be present.
|
||||
const pill = await canvas.findByRole("button", {
|
||||
name: /workspace menu/,
|
||||
// Too narrow minimum width: collapse into overflow popover.
|
||||
const overflowPill = await canvas.findByRole("button", {
|
||||
name: /more item/,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(pill).toBeVisible();
|
||||
expect(overflowPill).toBeVisible();
|
||||
});
|
||||
await userEvent.click(overflowPill);
|
||||
const popover = await within(document.body).findByRole("dialog");
|
||||
expect(
|
||||
within(popover).getByText(
|
||||
"my-super-extremely-long-workspace-name-that-overflows",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
// The workspace stays an interactive pill inside the popover.
|
||||
const pillTrigger = within(popover).getByRole("button", {
|
||||
name: /workspace menu/,
|
||||
});
|
||||
// Focus (touch tap) must not surface status tooltip on mobile.
|
||||
pillTrigger.focus();
|
||||
for (const el of within(document.body).queryAllByText(
|
||||
"Workspace running",
|
||||
)) {
|
||||
expect(el).not.toBeVisible();
|
||||
}
|
||||
await userEvent.click(pillTrigger);
|
||||
// The menu fades in from opacity 0; retry instead of racing the
|
||||
// entrance animation.
|
||||
const menuItem = await within(document.body).findByRole("menuitem", {
|
||||
name: /View Workspace/,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(menuItem).toBeVisible();
|
||||
});
|
||||
// One outside click must dismiss both the menu and the popover.
|
||||
await userEvent.click(getEditor(canvasElement));
|
||||
await waitFor(() => {
|
||||
expect(within(document.body).queryByRole("menu")).toBeNull();
|
||||
expect(within(document.body).queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
// The toolbar row should not cause horizontal overflow.
|
||||
const toolbar = pill.closest(
|
||||
const toolbar = overflowPill.closest(
|
||||
".flex.items-center.justify-between",
|
||||
) as HTMLElement;
|
||||
if (toolbar?.parentElement) {
|
||||
@@ -1583,3 +1657,194 @@ export const LongWorkspaceNameMobile: Story = {
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Pill floor (8ch + fixed chrome) resolved against the pills' font so
|
||||
// width assertions do not hardcode metrics.
|
||||
const measurePillFloor = (canvasElement: HTMLElement): number => {
|
||||
const probe = document.createElement("span");
|
||||
probe.className = "text-xs font-medium";
|
||||
probe.style.position = "absolute";
|
||||
probe.style.visibility = "hidden";
|
||||
probe.style.width = "calc(8ch + 3.125rem)";
|
||||
canvasElement.appendChild(probe);
|
||||
const width = probe.getBoundingClientRect().width;
|
||||
probe.remove();
|
||||
return width;
|
||||
};
|
||||
|
||||
// +1 tolerance: scrollWidth is ceiled while clientWidth is rounded,
|
||||
// so an untruncated fractional-width label can differ by one.
|
||||
const expectNotTruncated = (el: HTMLElement) => {
|
||||
expect(el.scrollWidth).toBeLessThanOrEqual(el.clientWidth + 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* A short model name sizes the trigger to its content.
|
||||
*/
|
||||
export const ShortModelNameHasNoDeadSpace: Story = {
|
||||
args: {
|
||||
...mcpDefaults,
|
||||
selectedModel: "model-short",
|
||||
modelOptions: [
|
||||
{
|
||||
id: "model-short",
|
||||
provider: "openai",
|
||||
model: "fable-5",
|
||||
displayName: "Fable 5",
|
||||
},
|
||||
],
|
||||
mcpServers: [sentryMCP, linearMCP, githubMCPConnected],
|
||||
selectedMCPServerIds: [sentryMCP.id, linearMCP.id, githubMCPConnected.id],
|
||||
workspace: MockWorkspace,
|
||||
workspaceAgent: MockWorkspaceAgent,
|
||||
chatId: "short-model-chat-id",
|
||||
},
|
||||
parameters: {
|
||||
viewport: { defaultViewport: "mobile2" },
|
||||
pixel: { matrix: { viewports: ["phone"] } },
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const trigger = await canvas.findByRole("combobox", {
|
||||
name: /Fable 5/,
|
||||
});
|
||||
await waitFor(() => {
|
||||
// Re-measure the floor inside the retry so font loads cannot
|
||||
// skew the comparison.
|
||||
const floor = measurePillFloor(canvasElement);
|
||||
expect(trigger.getBoundingClientRect().width).toBeLessThan(floor);
|
||||
});
|
||||
expectNotTruncated(canvas.getByText("Fable 5"));
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* With no MCP badges competing for space, long model and workspace
|
||||
* names expand to their full width: no truncation and no fixed cap.
|
||||
*/
|
||||
export const LongLabelsExpandWithoutMCPs: Story = {
|
||||
args: {
|
||||
...mcpDefaults,
|
||||
selectedModel: "model-long",
|
||||
modelOptions: [
|
||||
{
|
||||
id: "model-long",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet 4.5",
|
||||
},
|
||||
],
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
name: "my-workspace-name-that-should-not-clamp",
|
||||
},
|
||||
workspaceAgent: MockWorkspaceAgent,
|
||||
chatId: "long-labels-chat-id",
|
||||
},
|
||||
parameters: {
|
||||
viewport: { defaultViewport: "ipad" },
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const modelLabel = await canvas.findByText("Claude Sonnet 4.5");
|
||||
const workspaceLabel = await canvas.findByText(
|
||||
"my-workspace-name-that-should-not-clamp",
|
||||
);
|
||||
await waitFor(() => {
|
||||
expectNotTruncated(modelLabel);
|
||||
expectNotTruncated(workspaceLabel);
|
||||
});
|
||||
// The pill can exceed 200px: no fixed cap.
|
||||
const pillButton = canvas.getByRole("button", {
|
||||
name: /workspace menu/,
|
||||
});
|
||||
expect(pillButton.getBoundingClientRect().width).toBeGreaterThan(200);
|
||||
expect(canvas.queryByRole("button", { name: /more item/ })).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* When badges overflow into +N, they release their layout space so
|
||||
* the model label expands to full width.
|
||||
*/
|
||||
export const ModelExpandsWhileBadgesOverflow: Story = {
|
||||
args: {
|
||||
...mcpDefaults,
|
||||
selectedModel: "model-long",
|
||||
modelOptions: [
|
||||
{
|
||||
id: "model-long",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet 4.5",
|
||||
},
|
||||
],
|
||||
mcpServers: [confluenceWideMCP, datadogWideMCP],
|
||||
selectedMCPServerIds: [confluenceWideMCP.id, datadogWideMCP.id],
|
||||
},
|
||||
parameters: {
|
||||
viewport: { defaultViewport: "mobile2" },
|
||||
pixel: { matrix: { viewports: ["phone"] } },
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const overflowPill = await canvas.findByRole("button", {
|
||||
name: /more item/,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(overflowPill).toBeVisible();
|
||||
});
|
||||
const modelLabel = canvas.getByText("Claude Sonnet 4.5");
|
||||
await waitFor(() => {
|
||||
expectNotTruncated(modelLabel);
|
||||
});
|
||||
await userEvent.click(overflowPill);
|
||||
const popover = await within(document.body).findByRole("dialog");
|
||||
expect(
|
||||
within(popover).getByText("Datadog Infrastructure Monitoring"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Opening the +N popover auto-focuses its first badge; the status
|
||||
* tooltip stays suppressed (md and up).
|
||||
*/
|
||||
export const OverflowPopoverSuppressesStatusTooltip: Story = {
|
||||
args: {
|
||||
...mcpDefaults,
|
||||
mcpServers: [githubMCPConnected],
|
||||
selectedMCPServerIds: [githubMCPConnected.id],
|
||||
attachedWorkspace: {
|
||||
id: MockWorkspace.id,
|
||||
// Wide enough to collapse into the +N popover at tablet width.
|
||||
name: "an-extremely-long-attached-workspace-name-that-cannot-fit-inline-at-tablet-width",
|
||||
route: `/@${MockWorkspace.owner_name}/attached`,
|
||||
statusIcon: <MonitorDotIcon className="size-3" />,
|
||||
statusLabel: "Workspace stopped",
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
viewport: { defaultViewport: "ipad" },
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const overflowPill = await canvas.findByRole("button", {
|
||||
name: /more item/,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(overflowPill).toBeVisible();
|
||||
});
|
||||
await userEvent.click(overflowPill);
|
||||
const popover = await within(document.body).findByRole("dialog");
|
||||
expect(
|
||||
within(popover).getByText(/an-extremely-long-attached-workspace/),
|
||||
).toBeInTheDocument();
|
||||
// Auto-focus lands on the badge; the status tooltip stays hidden.
|
||||
for (const el of within(document.body).queryAllByText(
|
||||
"Workspace stopped",
|
||||
)) {
|
||||
expect(el).not.toBeVisible();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -210,6 +210,12 @@ export interface AttachedWorkspaceInfo {
|
||||
statusIcon: React.ReactNode;
|
||||
statusLabel: string;
|
||||
}
|
||||
// Shared pill sizing: flex-basis sets a ~8ch floor (shrink-0 enforces
|
||||
// it), grow expands into free row space, and max-w-max caps at the
|
||||
// label's natural width. Below the floor the +N overflow takes over.
|
||||
const pillSizingClasses =
|
||||
"grow shrink-0 basis-[calc(8ch_+_3.125rem)] max-w-max";
|
||||
|
||||
type ToolBadgeData =
|
||||
| { kind: "workspace"; name: string }
|
||||
| ({ kind: "attached-workspace" } & AttachedWorkspaceInfo)
|
||||
@@ -245,6 +251,8 @@ const ToolBadge: FC<{
|
||||
onRemovePlanning?: () => void;
|
||||
isDisabled?: boolean;
|
||||
className?: string;
|
||||
// The overflow popover auto-focuses badges; suppress the tooltip there.
|
||||
disableTooltip?: boolean;
|
||||
}> = ({
|
||||
badge,
|
||||
onRemoveWorkspace,
|
||||
@@ -252,6 +260,7 @@ const ToolBadge: FC<{
|
||||
onRemovePlanning,
|
||||
isDisabled,
|
||||
className,
|
||||
disableTooltip,
|
||||
}) => {
|
||||
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",
|
||||
@@ -301,7 +310,12 @@ const ToolBadge: FC<{
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{badge.statusLabel}</TooltipContent>
|
||||
{/* Hidden below md: touch focus would stick the tooltip open. */}
|
||||
{!disableTooltip && (
|
||||
<TooltipContent className="hidden md:block">
|
||||
{badge.statusLabel}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -585,16 +599,22 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
const shouldOverflowPlanningBadge =
|
||||
planModeEnabled && contextUsage !== undefined;
|
||||
|
||||
let workspacePillBadge: ToolBadgeData | undefined;
|
||||
if (workspace && workspaceAgent && chatId) {
|
||||
workspacePillBadge = attachedWorkspace
|
||||
? { kind: "attached-workspace", ...attachedWorkspace }
|
||||
: { kind: "workspace", name: workspace.name };
|
||||
}
|
||||
|
||||
// Ordered list of active tool badge data so we can determine
|
||||
// which ones ended up in the overflow popover.
|
||||
const allBadges: ToolBadgeData[] = [];
|
||||
if (shouldOverflowPlanningBadge) {
|
||||
allBadges.push({ kind: "planning" });
|
||||
}
|
||||
// When workspace data is available, WorkspacePill handles
|
||||
// the display (including app dropdown). Otherwise fall back
|
||||
// to the simple attached-workspace ToolBadge.
|
||||
if (!(workspace && workspaceAgent && chatId) && attachedWorkspace) {
|
||||
if (workspacePillBadge) {
|
||||
allBadges.push(workspacePillBadge);
|
||||
} else if (attachedWorkspace) {
|
||||
allBadges.push({ kind: "attached-workspace", ...attachedWorkspace });
|
||||
}
|
||||
if (shouldShowSelectedWorkspaceBadge && selectedWorkspace) {
|
||||
@@ -1188,7 +1208,8 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2 px-2.5 pb-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{/* flex-1 routes free row space to the growing pills. */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
{/* Plus menu */}
|
||||
<Popover
|
||||
modal={false}
|
||||
@@ -1417,7 +1438,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
options={modelOptions}
|
||||
disabled={isDisabled}
|
||||
placeholder={modelSelectorPlaceholder}
|
||||
className="md:h-auto md:w-auto md:shrink"
|
||||
className={cn(pillSizingClasses, "md:h-auto")}
|
||||
dropdownSide="top"
|
||||
dropdownAlign="start"
|
||||
enableMobileFullWidthDropdown
|
||||
@@ -1441,30 +1462,41 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{/* 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. */}
|
||||
{workspace && workspaceAgent && chatId && (
|
||||
<span className="ml-1 sm:ml-0">
|
||||
<WorkspacePill
|
||||
workspace={workspace}
|
||||
agent={workspaceAgent}
|
||||
chatId={chatId}
|
||||
sshCommand={sshCommand}
|
||||
folder={folder}
|
||||
onRemoveWorkspace={removeWorkspaceHandler}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{/* Badges and the +N pill stay mounted for measurement:
|
||||
* overflowed badges are display:none, the pill merely
|
||||
* invisible so its width stays readable. */}
|
||||
<div
|
||||
ref={badgeContainerRef}
|
||||
className="flex min-w-0 items-center gap-1 overflow-hidden"
|
||||
>
|
||||
{allBadges.map((badge, i) => {
|
||||
const isOverflow = overflowCount > 0 && i >= visibleCount;
|
||||
if (
|
||||
badge === workspacePillBadge &&
|
||||
workspace &&
|
||||
workspaceAgent &&
|
||||
chatId
|
||||
) {
|
||||
return (
|
||||
<span
|
||||
key="workspace-pill"
|
||||
className={cn(
|
||||
"flex min-w-0 text-xs",
|
||||
pillSizingClasses,
|
||||
isOverflow && "hidden",
|
||||
)}
|
||||
>
|
||||
<WorkspacePill
|
||||
workspace={workspace}
|
||||
agent={workspaceAgent}
|
||||
chatId={chatId}
|
||||
sshCommand={sshCommand}
|
||||
folder={folder}
|
||||
onRemoveWorkspace={removeWorkspaceHandler}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ToolBadge
|
||||
key={badge.kind === "mcp" ? badge.server.id : badge.kind}
|
||||
@@ -1475,14 +1507,10 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
onPlanModeToggle ? handleDisablePlanMode : undefined
|
||||
}
|
||||
isDisabled={isDisabled}
|
||||
className={isOverflow ? "invisible order-1" : undefined}
|
||||
className={isOverflow ? "hidden" : 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}
|
||||
@@ -1500,27 +1528,72 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
+{overflowCount}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
{/* Anchored above the +N pill; hugs the toolbar row. */}
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="mobile-full-width-dropdown mobile-full-width-dropdown-bottom flex w-auto max-w-64 flex-wrap gap-1 p-2"
|
||||
className="flex w-auto max-w-64 flex-wrap gap-1 p-2"
|
||||
onInteractOutside={(event) => {
|
||||
// The workspace pill portals its menu outside
|
||||
// this popover; dismissing would unmount the
|
||||
// open menu. Ignore focus shifts and pointer
|
||||
// presses inside the menu.
|
||||
if (event.detail.originalEvent.type !== "pointerdown") {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
event.target.closest('[role="menu"]')
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{overflowBadges.map((badge) => (
|
||||
<ToolBadge
|
||||
key={
|
||||
badge.kind === "mcp"
|
||||
? badge.server.id
|
||||
: `${badge.kind}-overflow`
|
||||
}
|
||||
badge={badge}
|
||||
onRemoveWorkspace={removeWorkspaceHandler}
|
||||
onRemoveMcp={handleRemoveMcp}
|
||||
onRemovePlanning={
|
||||
onPlanModeToggle ? handleDisablePlanMode : undefined
|
||||
}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
))}
|
||||
{overflowBadges.map((badge, i) => {
|
||||
if (
|
||||
badge === workspacePillBadge &&
|
||||
workspace &&
|
||||
workspaceAgent &&
|
||||
chatId
|
||||
) {
|
||||
return (
|
||||
<span
|
||||
key="workspace-pill-overflow"
|
||||
className="flex min-w-0 text-xs"
|
||||
>
|
||||
<WorkspacePill
|
||||
workspace={workspace}
|
||||
agent={workspaceAgent}
|
||||
chatId={chatId}
|
||||
sshCommand={sshCommand}
|
||||
folder={folder}
|
||||
onRemoveWorkspace={removeWorkspaceHandler}
|
||||
inOverflowPopover
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ToolBadge
|
||||
// Non-MCP badges can share a kind, so keys
|
||||
// are position-qualified.
|
||||
key={
|
||||
badge.kind === "mcp"
|
||||
? badge.server.id
|
||||
: `${badge.kind}-overflow-${visibleCount + i}`
|
||||
}
|
||||
badge={badge}
|
||||
onRemoveWorkspace={removeWorkspaceHandler}
|
||||
onRemoveMcp={handleRemoveMcp}
|
||||
onRemovePlanning={
|
||||
onPlanModeToggle ? handleDisablePlanMode : undefined
|
||||
}
|
||||
isDisabled={isDisabled}
|
||||
disableTooltip
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -58,6 +58,10 @@ interface WorkspacePillProps {
|
||||
sshCommand?: string;
|
||||
folder?: string;
|
||||
onRemoveWorkspace?: () => void;
|
||||
// Rendered inside the +N overflow popover: suppresses the status
|
||||
// tooltip and makes the menu non-modal so one outside click
|
||||
// dismisses both layers.
|
||||
inOverflowPopover?: boolean;
|
||||
}
|
||||
|
||||
export const WorkspacePill: FC<WorkspacePillProps> = ({
|
||||
@@ -67,6 +71,7 @@ export const WorkspacePill: FC<WorkspacePillProps> = ({
|
||||
sshCommand,
|
||||
folder,
|
||||
onRemoveWorkspace,
|
||||
inOverflowPopover,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||
@@ -118,6 +123,7 @@ export const WorkspacePill: FC<WorkspacePillProps> = ({
|
||||
return (
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
modal={!inOverflowPopover}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
@@ -126,9 +132,11 @@ export const WorkspacePill: FC<WorkspacePillProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex min-w-0 items-center overflow-hidden rounded-full bg-surface-secondary text-xs font-medium text-content-secondary md:min-w-[2.75rem]">
|
||||
{/* Sizing (floor, growth, natural-width cap) is owned by the
|
||||
* wrapper in AgentChatInput; this span just fills it. */}
|
||||
<span className="inline-flex w-full min-w-0 items-center overflow-hidden rounded-full bg-surface-secondary text-xs font-medium text-content-secondary">
|
||||
<Tooltip
|
||||
open={tooltipOpen}
|
||||
open={!inOverflowPopover && tooltipOpen}
|
||||
onOpenChange={(v) => setTooltipOpen(v && !open)}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -137,26 +145,23 @@ export const WorkspacePill: FC<WorkspacePillProps> = ({
|
||||
type="button"
|
||||
aria-label={`${workspace.name} workspace menu`}
|
||||
className={cn(
|
||||
"inline-flex min-w-0 cursor-pointer items-center justify-center gap-1 rounded-full border-0 bg-transparent p-0 text-xs font-medium text-content-secondary transition-colors hover:bg-surface-tertiary hover:text-content-primary",
|
||||
"size-7 md:size-auto md:max-w-[200px] md:justify-start md:px-2 md:py-0.5",
|
||||
"inline-flex min-w-0 cursor-pointer items-center justify-start gap-1 rounded-full border-0 bg-transparent p-0 text-xs font-medium text-content-secondary transition-colors hover:bg-surface-tertiary hover:text-content-primary",
|
||||
// Heights match the model selector trigger.
|
||||
"h-7 w-full px-2 py-0.5 md:h-auto",
|
||||
)}
|
||||
>
|
||||
<StatusIcon
|
||||
type={effectiveType}
|
||||
className="size-icon-sm shrink-0 md:size-3"
|
||||
/>
|
||||
<span className="hidden min-w-0 truncate md:inline">
|
||||
{workspace.name}
|
||||
</span>
|
||||
<StatusIcon type={effectiveType} className="size-3 shrink-0" />
|
||||
<span className="min-w-0 truncate">{workspace.name}</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"hidden size-3.5 shrink-0 transition-transform md:block",
|
||||
"size-3.5 shrink-0 transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
{/* Hidden below md: touch focus would stick the tooltip open. */}
|
||||
<TooltipContent className="hidden md:block">
|
||||
{statusLabel}
|
||||
</TooltipContent>
|
||||
@@ -166,104 +171,115 @@ export const WorkspacePill: FC<WorkspacePillProps> = ({
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="mobile-full-width-dropdown mobile-full-width-dropdown-bottom w-48 p-1 [&_[role=menuitem]]:text-xs [&_[role=menuitem]]:py-1 [&_svg]:!size-3.5 [&_img]:!size-3.5"
|
||||
// Above the composer on mobile so the opening press cannot
|
||||
// release onto a menu item.
|
||||
className="mobile-full-width-dropdown mobile-full-width-dropdown-above-composer w-48 p-1 [&_[role=menuitem]]:text-xs [&_[role=menuitem]]:py-1 [&_svg]:!size-3.5 [&_img]:!size-3.5"
|
||||
>
|
||||
{showPortsView ? (
|
||||
<MobilePortsPanel
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
host={host}
|
||||
portsData={portsData}
|
||||
onBack={() => {
|
||||
setFocusPortsOnMain(true);
|
||||
setView("main");
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{hasVSCode && (
|
||||
<VSCodeMenuItem
|
||||
variant="vscode"
|
||||
label="VS Code"
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
chatId={chatId}
|
||||
folder={folder}
|
||||
isRunning={isRunning}
|
||||
generateKey={generateKey}
|
||||
isGeneratingKey={isGeneratingKey}
|
||||
/>
|
||||
)}
|
||||
{hasVSCodeInsiders && (
|
||||
<VSCodeMenuItem
|
||||
variant="vscode-insiders"
|
||||
label="VS Code Insiders"
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
chatId={chatId}
|
||||
folder={folder}
|
||||
isRunning={isRunning}
|
||||
generateKey={generateKey}
|
||||
isGeneratingKey={isGeneratingKey}
|
||||
/>
|
||||
)}
|
||||
{userApps.map((app) => (
|
||||
<AppMenuItem
|
||||
key={app.id}
|
||||
app={app}
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
))}
|
||||
{hasTerminal && (
|
||||
<TerminalMenuItem
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
)}
|
||||
{portForwardingEnabled && (
|
||||
<PortsMenuItem
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
host={host}
|
||||
portsData={portsData}
|
||||
isRunning={isRunning}
|
||||
isBelowMd={isBelowMd}
|
||||
focusOnMount={focusPortsOnMain}
|
||||
onFocusApplied={() => setFocusPortsOnMain(false)}
|
||||
onSelectInline={() => {
|
||||
setFocusPortsOnMain(false);
|
||||
setView("ports");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{hasItemsAboveSeparator && (
|
||||
<DropdownMenuSeparator className="my-1" />
|
||||
)}
|
||||
|
||||
{sshCommand && <CopySSHMenuItem sshCommand={sshCommand} />}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={route} target="_blank" rel="noreferrer">
|
||||
<MonitorIcon className="size-3.5" />
|
||||
View Workspace
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{onRemoveWorkspace && (
|
||||
<>
|
||||
{/* Scrolls within the capped above-composer height on mobile;
|
||||
* no-op on desktop. overflow-x-hidden avoids a horizontal
|
||||
* scrollbar; role=none keeps the wrapper out of the menu's
|
||||
* ARIA tree. */}
|
||||
<div
|
||||
role="none"
|
||||
className="mobile-full-width-dropdown-scroll-area min-h-0 overflow-x-hidden"
|
||||
>
|
||||
{showPortsView ? (
|
||||
<MobilePortsPanel
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
host={host}
|
||||
portsData={portsData}
|
||||
onBack={() => {
|
||||
setFocusPortsOnMain(true);
|
||||
setView("main");
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{hasVSCode && (
|
||||
<VSCodeMenuItem
|
||||
variant="vscode"
|
||||
label="VS Code"
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
chatId={chatId}
|
||||
folder={folder}
|
||||
isRunning={isRunning}
|
||||
generateKey={generateKey}
|
||||
isGeneratingKey={isGeneratingKey}
|
||||
/>
|
||||
)}
|
||||
{hasVSCodeInsiders && (
|
||||
<VSCodeMenuItem
|
||||
variant="vscode-insiders"
|
||||
label="VS Code Insiders"
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
chatId={chatId}
|
||||
folder={folder}
|
||||
isRunning={isRunning}
|
||||
generateKey={generateKey}
|
||||
isGeneratingKey={isGeneratingKey}
|
||||
/>
|
||||
)}
|
||||
{userApps.map((app) => (
|
||||
<AppMenuItem
|
||||
key={app.id}
|
||||
app={app}
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
))}
|
||||
{hasTerminal && (
|
||||
<TerminalMenuItem
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
)}
|
||||
{portForwardingEnabled && (
|
||||
<PortsMenuItem
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
host={host}
|
||||
portsData={portsData}
|
||||
isRunning={isRunning}
|
||||
isBelowMd={isBelowMd}
|
||||
focusOnMount={focusPortsOnMain}
|
||||
onFocusApplied={() => setFocusPortsOnMain(false)}
|
||||
onSelectInline={() => {
|
||||
setFocusPortsOnMain(false);
|
||||
setView("ports");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{hasItemsAboveSeparator && (
|
||||
<DropdownMenuSeparator className="my-1" />
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={onRemoveWorkspace}
|
||||
>
|
||||
<UnlinkIcon className="size-3.5" />
|
||||
Detach workspace
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
|
||||
{sshCommand && <CopySSHMenuItem sshCommand={sshCommand} />}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={route} target="_blank" rel="noreferrer">
|
||||
<MonitorIcon className="size-3.5" />
|
||||
View Workspace
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{onRemoveWorkspace && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="my-1" />
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={onRemoveWorkspace}
|
||||
>
|
||||
<UnlinkIcon className="size-3.5" />
|
||||
Detach workspace
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeOverflowCount, countThatFit } from "./useOverflowCount";
|
||||
|
||||
describe("countThatFit", () => {
|
||||
it("returns every item when the row fits exactly", () => {
|
||||
// 3 items of 100 with 2 gaps of 4 = 308.
|
||||
expect(countThatFit([100, 100, 100], 4, 308)).toBe(3);
|
||||
});
|
||||
|
||||
it("tolerates subpixel rounding", () => {
|
||||
expect(countThatFit([100, 100, 100], 4, 307.5)).toBe(3);
|
||||
});
|
||||
|
||||
it("counts only the leading items that fit", () => {
|
||||
// First item 100; the second needs 204 total, above the 202
|
||||
// budget even with tolerance.
|
||||
expect(countThatFit([100, 100, 100], 4, 202)).toBe(1);
|
||||
});
|
||||
|
||||
it("charges no gap before the first item", () => {
|
||||
expect(countThatFit([100], 4, 100)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns zero when nothing fits", () => {
|
||||
expect(countThatFit([100, 50], 4, 90)).toBe(0);
|
||||
});
|
||||
|
||||
it("treats unknown (zero) widths as free", () => {
|
||||
// Items never measured while visible cannot block the row.
|
||||
expect(countThatFit([0, 0, 100], 4, 110)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeOverflowCount", () => {
|
||||
it("reports zero when everything fits without the pill", () => {
|
||||
expect(
|
||||
computeOverflowCount({
|
||||
widths: [100, 100],
|
||||
gap: 4,
|
||||
available: 204,
|
||||
pillWidth: 30,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("reserves the pill width once something overflows", () => {
|
||||
// All three need 308; only 250 available. With the pill (30 + 4
|
||||
// gap) reserved, the budget is 216, fitting two items (204).
|
||||
expect(
|
||||
computeOverflowCount({
|
||||
widths: [100, 100, 100],
|
||||
gap: 4,
|
||||
available: 250,
|
||||
pillWidth: 30,
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("overflows everything when even one item cannot fit", () => {
|
||||
expect(
|
||||
computeOverflowCount({
|
||||
widths: [200, 210],
|
||||
gap: 4,
|
||||
available: 160,
|
||||
pillWidth: 30,
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("reports at least one overflow when the first pass fails", () => {
|
||||
// Borderline case: items fit without the pill's reservation but
|
||||
// not with it; the pill still needs one occupant.
|
||||
expect(
|
||||
computeOverflowCount({
|
||||
widths: [100, 100],
|
||||
gap: 4,
|
||||
available: 200,
|
||||
pillWidth: 30,
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,72 @@
|
||||
import { type RefObject, useLayoutEffect, useState } from "react";
|
||||
|
||||
// Tolerance for getBoundingClientRect subpixel rounding and integer
|
||||
// scrollWidth/clientWidth rounding.
|
||||
const TOLERANCE_PX = 1;
|
||||
|
||||
// Last visible width of each overflow-managed element: hidden items
|
||||
// report zero, so fit decisions reuse the width they had while
|
||||
// visible. Element-keyed, so it survives re-renders and count changes.
|
||||
const lastVisibleWidths = new WeakMap<Element, number>();
|
||||
|
||||
/**
|
||||
* Observes a flex container whose children are laid out as:
|
||||
* Number of leading items whose widths, plus the gaps between them,
|
||||
* fit within the budget.
|
||||
*/
|
||||
export function countThatFit(
|
||||
widths: readonly number[],
|
||||
gap: number,
|
||||
budget: number,
|
||||
): number {
|
||||
let used = 0;
|
||||
for (let i = 0; i < widths.length; i++) {
|
||||
used += widths[i] + (i > 0 ? gap : 0);
|
||||
if (used > budget + TOLERANCE_PX) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return widths.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overflow count for one measured snapshot: zero when every item fits
|
||||
* in the available space, otherwise how many trailing items must move
|
||||
* into the "+N" pill, whose width is reserved from the budget.
|
||||
*/
|
||||
export function computeOverflowCount(snapshot: {
|
||||
widths: readonly number[];
|
||||
gap: number;
|
||||
available: number;
|
||||
pillWidth: number;
|
||||
}): number {
|
||||
const { widths, gap, available, pillWidth } = snapshot;
|
||||
if (countThatFit(widths, gap, available) === widths.length) {
|
||||
return 0;
|
||||
}
|
||||
const visible = countThatFit(widths, gap, available - pillWidth - gap);
|
||||
// Defensive: once the first pass fails, at least one item overflows.
|
||||
return Math.max(widths.length - visible, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes a flex container 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.
|
||||
* and reports how many of the first `itemCount` children do not fit
|
||||
* in the space available to the container, updating as layout 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.
|
||||
* Contract with the caller:
|
||||
*
|
||||
* - Overflowed items are hidden with `display: none` so they release
|
||||
* their layout space; their last visible width is cached here.
|
||||
* - The "+N" pill always renders as the last child (invisible when
|
||||
* the count is 0) so its width can be read from the DOM.
|
||||
* - The container is the last child of its flex group (LTR assumed),
|
||||
* so the group's right edge bounds the items' space. Growing
|
||||
* siblings (the model pill) get priority: their truncation deficit
|
||||
* is reserved before items claim space.
|
||||
*/
|
||||
export function useOverflowCount(
|
||||
containerRef: RefObject<HTMLElement | null>,
|
||||
@@ -23,7 +76,8 @@ export function useOverflowCount(
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
const parent = container?.parentElement;
|
||||
if (!container || !parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -35,52 +89,56 @@ export function useOverflowCount(
|
||||
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;
|
||||
const widths: number[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (children[i].getBoundingClientRect().right > containerRight + 1) {
|
||||
allFit = false;
|
||||
break;
|
||||
const child = children[i];
|
||||
const width = child.getBoundingClientRect().width;
|
||||
if (width > 0) {
|
||||
lastVisibleWidths.set(child, width);
|
||||
}
|
||||
widths.push(lastVisibleWidths.get(child) ?? 0);
|
||||
}
|
||||
|
||||
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",
|
||||
setOverflowCount(
|
||||
computeOverflowCount({
|
||||
widths,
|
||||
gap: Number.parseFloat(getComputedStyle(container).columnGap || "0"),
|
||||
available:
|
||||
parent.getBoundingClientRect().right -
|
||||
container.getBoundingClientRect().left -
|
||||
siblingTruncationDeficit(parent, container),
|
||||
pillWidth: pill ? pill.getBoundingClientRect().width : 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++;
|
||||
const ro = new ResizeObserver(measure);
|
||||
// Available space also shifts when siblings grow or shrink
|
||||
// without resizing the parent, and when items hide or show
|
||||
// without resizing the container.
|
||||
const observeAll = () => {
|
||||
ro.observe(container);
|
||||
ro.observe(parent);
|
||||
for (const sibling of parent.children) {
|
||||
if (sibling !== container) {
|
||||
ro.observe(sibling);
|
||||
}
|
||||
}
|
||||
|
||||
setOverflowCount(Math.max(hidden, 1));
|
||||
for (const child of container.children) {
|
||||
ro.observe(child);
|
||||
}
|
||||
};
|
||||
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(container);
|
||||
observeAll();
|
||||
|
||||
const mo = new MutationObserver(measure);
|
||||
// Re-attach in case a child was replaced in place; re-observing
|
||||
// is a no-op.
|
||||
const mo = new MutationObserver(() => {
|
||||
observeAll();
|
||||
measure();
|
||||
});
|
||||
mo.observe(container, { childList: true });
|
||||
|
||||
return () => {
|
||||
@@ -91,3 +149,43 @@ export function useOverflowCount(
|
||||
|
||||
return overflowCount;
|
||||
}
|
||||
|
||||
// How much wider the container's siblings want to be: the widest
|
||||
// clipped overflow among each sibling's descendants. Only elements
|
||||
// that clip (overflow-x hidden or clip) count, and only the widest
|
||||
// per sibling, so nested wrappers cannot double-count. Reserving this
|
||||
// keeps sibling pills at full width in preference to inline items.
|
||||
//
|
||||
// Stability invariant: when a pill absorbs freed space and grows by
|
||||
// some delta, the container's left edge shifts right by that same
|
||||
// delta while the pill's clipped deficit shrinks by it, so the two
|
||||
// cancel and `available` stays constant regardless of how far the
|
||||
// pills have grown. Measurements therefore reach a fixed point
|
||||
// instead of oscillating between hide and show.
|
||||
function siblingTruncationDeficit(
|
||||
parent: HTMLElement,
|
||||
container: HTMLElement,
|
||||
): number {
|
||||
let deficit = 0;
|
||||
for (const sibling of parent.children) {
|
||||
if (sibling === container || !(sibling instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
let widest = 0;
|
||||
for (const el of [sibling, ...sibling.querySelectorAll("*")]) {
|
||||
if (!(el instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
const overflowX = getComputedStyle(el).overflowX;
|
||||
if (overflowX !== "hidden" && overflowX !== "clip") {
|
||||
continue;
|
||||
}
|
||||
const clipped = el.scrollWidth - el.clientWidth;
|
||||
if (clipped > TOLERANCE_PX) {
|
||||
widest = Math.max(widest, clipped);
|
||||
}
|
||||
}
|
||||
deficit += widest;
|
||||
}
|
||||
return deficit;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user