authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-24 21:08:22-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-24 21:08:22-07:00
log29abbf9b424b122c52bb20dccffac68bee367c09
tree105eb9bf4645bc62f80cd55239edb95579bf6d93
parentebdcda135e31693c5283f8ac409019bd69e07f8e
signature Signed by PGP key B5690EEEBB952194

fix(web): collapsed composer bar stops flipping its labels while you scroll (#13555)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

1 files changed, 30 insertions(+), 12 deletions(-)

apps/web/src/components/BranchToolbar.tsx+30-12
......@@ -312,6 +312,34 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
312312 );
313313});
314314
315const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180;
316const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
317const COMPOSER_CONTEXT_LABEL_SELECTOR = "[data-composer-label]";
318
319/**
320 * The width a label takes when shown, clipped parts included.
321 *
322 * Text keeps its full width when its box clips it, so each text run measures
323 * whole. A label can hold more than one run (MiddleTruncate splits a branch
324 * into a head and a tail), so the runs are added. Reading one element's
325 * scrollWidth drops the tail when the label is hidden or squeezed, and the
326 * strip then flips between labels and icons on every measure.
327 *
328 * A shown label never grows past its motion span's max width, so longer text
329 * reserves only that much.
330 */
331function labelTextWidth(label: HTMLElement, range: Range): number {
332 const walker = document.createTreeWalker(label, NodeFilter.SHOW_TEXT);
333 let width = 0;
334 for (let node = walker.nextNode(); node; node = walker.nextNode()) {
335 range.selectNodeContents(node);
336 width += range.getBoundingClientRect().width;
337 }
338 const motion = label.querySelector<HTMLElement>("[data-composer-label-motion]");
339 const maxWidth = motion ? Number.parseFloat(getComputedStyle(motion).maxWidth) : Number.NaN;
340 return Number.isFinite(maxWidth) ? Math.min(width, maxWidth) : width;
341}
342
315343/**
316344 * Collapse the strip's labels to icons only when the text no longer fits.
317345 *
......@@ -320,10 +348,6 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
320348 * the expanded width without remembered values that could go stale or latch
321349 * the strip compact. A small hysteresis keeps the boundary from flapping.
322350 */
323const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180;
324const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
325const COMPOSER_CONTEXT_LABEL_SELECTOR = "[data-composer-label]";
326
327351function useLabelsOverflow(element: HTMLDivElement | null): boolean {
328352 const [overflows, setOverflows] = useState(false);
329353 const pendingLabelRectsRef = useRef<Map<HTMLElement, DOMRect> | null>(null);
......@@ -384,17 +408,11 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
384408 needed += width;
385409 }
386410 needed += stripGap * Math.max(0, groups - 1);
411 const range = document.createRange();
387412 for (const label of current.querySelectorAll<HTMLElement>("[data-composer-label]")) {
388 // The clipping can happen below the marker (SelectValue truncates
389 // internally), where the outer span's scrollWidth matches its clipped
390 // box. The text's real width is the largest scrollWidth in the subtree.
391 let textWidth = label.scrollWidth;
392 for (const inner of label.querySelectorAll<HTMLElement>("*")) {
393 textWidth = Math.max(textWidth, inner.scrollWidth);
394 }
395413 // Subtract the visible width even during an animation. The content
396414 // sum already includes it; only the hidden text needs reserving.
397 needed += Math.max(0, textWidth - label.getBoundingClientRect().width);
415 needed += Math.max(0, labelTextWidth(label, range) - label.getBoundingClientRect().width);
398416 }
399417 const nextOverflows = resolveContextStripLabelsCompact({
400418 compact,