From 29abbf9b424b122c52bb20dccffac68bee367c09 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 21:08:22 -0700 Subject: [PATCH] fix(web): collapsed composer bar stops flipping its labels while you scroll (#13555) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/BranchToolbar.tsx | 42 ++++++++++++++++------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 60f529ebd9113e443d8b10c70282ccc745cecc2e..1e5cb85a9737ef897c6c705f9ba09d46a9d5633f 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -312,6 +312,34 @@ 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]"; + +/** + * The width a label takes when shown, clipped parts included. + * + * Text keeps its full width when its box clips it, so each text run measures + * whole. A label can hold more than one run (MiddleTruncate splits a branch + * into a head and a tail), so the runs are added. Reading one element's + * scrollWidth drops the tail when the label is hidden or squeezed, and the + * strip then flips between labels and icons on every measure. + * + * A shown label never grows past its motion span's max width, so longer text + * reserves only that much. + */ +function labelTextWidth(label: HTMLElement, range: Range): number { + const walker = document.createTreeWalker(label, NodeFilter.SHOW_TEXT); + let width = 0; + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + range.selectNodeContents(node); + width += range.getBoundingClientRect().width; + } + const motion = label.querySelector("[data-composer-label-motion]"); + const maxWidth = motion ? Number.parseFloat(getComputedStyle(motion).maxWidth) : Number.NaN; + return Number.isFinite(maxWidth) ? Math.min(width, maxWidth) : width; +} + /** * Collapse the strip's labels to icons only when the text no longer fits. * @@ -320,10 +348,6 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ * the expanded width without remembered values that could go stale or latch * the strip compact. A small hysteresis keeps the boundary from flapping. */ -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]"; - function useLabelsOverflow(element: HTMLDivElement | null): boolean { const [overflows, setOverflows] = useState(false); const pendingLabelRectsRef = useRef | null>(null); @@ -384,17 +408,11 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { needed += width; } needed += stripGap * Math.max(0, groups - 1); + const range = document.createRange(); 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); - } // 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); + needed += Math.max(0, labelTextWidth(label, range) - label.getBoundingClientRect().width); } const nextOverflows = resolveContextStripLabelsCompact({ compact, -- 2.54.0