diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index ff4bb76bf12a..e2711f846aa4 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, + measureContextStripLabelWidth, resolveEnvironmentOptionLabel, resolveBranchSelectionTarget, resolveCurrentWorkspaceLabel, @@ -25,6 +26,21 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); +it.each([200, 100, 0])("measures the full branch-name width when rendered at %ipx", (width) => { + const head = { scrollWidth: 120, dataset: {} }; + const tail = { scrollWidth: 80, dataset: {} }; + const text = { + scrollWidth: Math.max(width, tail.scrollWidth), + dataset: { slot: "middle-truncate" }, + children: [head, tail], + }; + const label = { + scrollWidth: width, + querySelectorAll: () => [text, head, tail], + } as unknown as HTMLElement; + expect(measureContextStripLabelWidth(label)).toBe(200); +}); + describe("resolvePreviousWorktreeSeed", () => { it("picks the most recently updated worktree thread", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 5b046ed6a686..339cdf35c5f9 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -78,6 +78,17 @@ export function shouldShowComposerContextStrip(input: { // hysteresis on the way back out keeps the boundary from flapping. const CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX = 16; +export function measureContextStripLabelWidth(label: HTMLElement): number { + return Array.from(label.querySelectorAll("*")).reduce((width, inner) => { + // Sum both halves of middle-truncated text, even when collapsed. + const innerWidth = + inner.dataset.slot === "middle-truncate" + ? Array.from(inner.children).reduce((total, child) => total + child.scrollWidth, 0) + : inner.scrollWidth; + return Math.max(width, innerWidth); + }, label.scrollWidth); +} + export function resolveContextStripLabelsCompact(input: { compact: boolean; neededWidth: number; diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index ffc89c33e794..257a5b9b480a 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -26,6 +26,7 @@ import { useProject, useThreadShell, useThreadShellsForProjectRefs } from "../st import { type EnvMode, type EnvironmentOption, + measureContextStripLabelWidth, resolveContextStripLabelsCompact, resolveCurrentWorkspaceLabel, resolveEnvModeLabel, @@ -381,13 +382,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { } needed += stripGap * Math.max(0, groups - 1); for (const label of current.querySelectorAll("[data-composer-label]")) { - // The clipping can happen below the marker (SelectValue truncates - // internally), where the outer span's scrollWidth matches its clipped - // box. The text's real width is the largest scrollWidth in the subtree. - let textWidth = label.scrollWidth; - for (const inner of label.querySelectorAll("*")) { - textWidth = Math.max(textWidth, inner.scrollWidth); - } + const textWidth = measureContextStripLabelWidth(label); // Subtract the visible width even during an animation. The content // sum already includes it; only the hidden text needs reserving. needed += Math.max(0, textWidth - label.getBoundingClientRect().width); diff --git a/apps/web/src/components/ui/middle-truncate.tsx b/apps/web/src/components/ui/middle-truncate.tsx index fd8a25447738..f321fd1e8aa8 100644 --- a/apps/web/src/components/ui/middle-truncate.tsx +++ b/apps/web/src/components/ui/middle-truncate.tsx @@ -28,6 +28,7 @@ export function MiddleTruncate({ const split = splitForMiddleTruncate(value, tail); return (