diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index ff4bb76bf12a..5476476620a8 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -12,6 +12,8 @@ import { resolveBranchTriggerLabel, resolveBranchToolbarPrBranch, resolveBranchToolbarValue, + resolveContextStripLabelHiddenWidth, + resolveContextStripLabelsCompact, resolveLockedWorkspaceLabel, resolveLocalCheckoutBranchMismatch, resolvePreviousWorktreeLabel, @@ -834,3 +836,68 @@ describe("sanitizeNewRefName", () => { expect(sanitizeNewRefName("foo--bar")).toBe("foo--bar"); }); }); + +describe("resolveContextStripLabelHiddenWidth", () => { + it("reserves the whole text while compact and nothing once expanded", () => { + expect( + resolveContextStripLabelHiddenWidth({ leafWidths: [120], maxWidth: 240, visibleWidth: 0 }), + ).toBe(120); + expect( + resolveContextStripLabelHiddenWidth({ leafWidths: [120], maxWidth: 240, visibleWidth: 120 }), + ).toBe(0); + }); + + it("counts a middle-truncated head and tail together, capped at the expanded width", () => { + expect( + resolveContextStripLabelHiddenWidth({ + leafWidths: [100, 60], + maxWidth: 240, + visibleWidth: 0, + }), + ).toBe(160); + expect( + resolveContextStripLabelHiddenWidth({ + leafWidths: [200, 60], + maxWidth: 240, + visibleWidth: 0, + }), + ).toBe(240); + expect( + resolveContextStripLabelHiddenWidth({ + leafWidths: [200, 60], + maxWidth: 240, + visibleWidth: 240, + }), + ).toBe(0); + }); + + it("settles the compact decision in one pass at every strip width", () => { + // Compact and expanded passes disagreeing by more than the hysteresis + // flip forever until React throws error #185 (#12891). + const controlsWidth = 300; + const label = { leafWidths: [200, 60], maxWidth: 240 }; + const needed = (compact: boolean) => { + const visibleWidth = compact ? 0 : Math.min(200 + 60, label.maxWidth); + return ( + controlsWidth + + visibleWidth + + resolveContextStripLabelHiddenWidth({ ...label, visibleWidth }) + ); + }; + for (let availableWidth = 400; availableWidth <= 700; availableWidth += 1) { + for (const compact of [false, true]) { + const next = resolveContextStripLabelsCompact({ + compact, + neededWidth: needed(compact), + availableWidth, + }); + const settled = resolveContextStripLabelsCompact({ + compact: next, + neededWidth: needed(next), + availableWidth, + }); + expect(settled).toBe(next); + } + } + }); +}); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 5b046ed6a686..631caed2ade9 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -88,6 +88,20 @@ export function resolveContextStripLabelsCompact(input: { : input.neededWidth > input.availableWidth; } +/** + * Width of a strip label's text that its current box hides. Leaf spans are + * summed so a middle-truncated head and tail count together, and capped at + * the expanded max-width so compact and expanded passes measure the same need. + */ +export function resolveContextStripLabelHiddenWidth(input: { + leafWidths: readonly number[]; + maxWidth: number; + visibleWidth: number; +}): number { + const naturalWidth = input.leafWidths.reduce((sum, width) => sum + width, 0); + return Math.max(0, Math.min(naturalWidth, input.maxWidth) - input.visibleWidth); +} + export function resolveEnvModeLabel(mode: EnvMode): string { return mode === "worktree" ? "New worktree" : "Current checkout"; } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index ffc89c33e794..4e787bf3432b 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, + resolveContextStripLabelHiddenWidth, resolveContextStripLabelsCompact, resolveCurrentWorkspaceLabel, resolveEnvModeLabel, @@ -319,6 +320,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180; const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; const COMPOSER_CONTEXT_LABEL_SELECTOR = "[data-composer-label]"; +const COMPOSER_CONTEXT_LABEL_MOTION_SELECTOR = "[data-composer-label-motion]"; function useLabelsOverflow(element: HTMLDivElement | null): boolean { const [overflows, setOverflows] = useState(false); @@ -380,17 +382,23 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { needed += width; } 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 label of current.querySelectorAll(COMPOSER_CONTEXT_LABEL_SELECTOR)) { + // Sum the leaf spans so a middle-truncated head and tail count together. + // The motion element keeps its max-width in both states; the marker's + // collapses to zero while compact. + const leafWidths: number[] = []; for (const inner of label.querySelectorAll("*")) { - textWidth = Math.max(textWidth, inner.scrollWidth); + if (inner.children.length === 0) leafWidths.push(inner.scrollWidth); } - // 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); + const motion = label.querySelector(COMPOSER_CONTEXT_LABEL_MOTION_SELECTOR); + const maxWidth = motion ? Number.parseFloat(getComputedStyle(motion).maxWidth) : Number.NaN; + needed += resolveContextStripLabelHiddenWidth({ + leafWidths: leafWidths.length > 0 ? leafWidths : [label.scrollWidth], + maxWidth: Number.isFinite(maxWidth) ? maxWidth : Number.POSITIVE_INFINITY, + // Subtract the visible width even during an animation. The content + // sum already includes it; only the hidden text needs reserving. + visibleWidth: label.getBoundingClientRect().width, + }); } const nextOverflows = resolveContextStripLabelsCompact({ compact,