authorgravatar for 118054752+extoci@users.noreply.github.comExotic <118054752+extoci@users.noreply.github.com> 2026-08-12 00:21:44+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-11 23:21:44+02:00
logc196f422ed387a1cc2cdb671b0472782e5610339
treeb9c90e066aa9ee8f0044cd97a2e10bdb12044749
parentf0b57ca2313bd7971cefb366d3b3808152b014fc
signature Signed by PGP key B5690EEEBB952194

fix(web): clean up composer resize animation (#6209)


4 files changed, 122 insertions(+), 21 deletions(-)

apps/web/src/components/BranchToolbar.tsx+80-10
...@@ -9,7 +9,7 @@ import {...@@ -9,7 +9,7 @@ import {
9 HistoryIcon,9 HistoryIcon,
10 MonitorIcon,10 MonitorIcon,
11} from "lucide-react";11} from "lucide-react";
12import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";12import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
1313
14import { useComposerDraftStore, type DraftId } from "../composerDraftStore";14import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
15import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities";15import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities";
...@@ -218,16 +218,20 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -218,16 +218,20 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
218/**218/**
219 * Collapse the strip's labels to icons only when the text no longer fits.219 * Collapse the strip's labels to icons only when the text no longer fits.
220 *220 *
221 * Hidden labels stay measurable (they collapse to invisible absolute boxes,221 * Hidden labels stay measurable because their inner text keeps its natural
222 * which keep their natural width), so the required width can be recomputed in222 * width while the outer layout box collapses. This lets every pass recompute
223 * either state on every pass - no remembered widths that could go stale or223 * the expanded width without remembered values that could go stale or latch
224 * latch the strip compact. A small hysteresis keeps the boundary from224 * the strip compact. A small hysteresis keeps the boundary from flapping.
225 * flapping between states.
226 */225 */
227const COMPACT_EXPAND_HYSTERESIS_PX = 16;226const COMPACT_EXPAND_HYSTERESIS_PX = 16;
227const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180;
228const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
229const COMPOSER_CONTEXT_CONTROL_SELECTOR = "[data-composer-context-control]";
228230
229function useLabelsOverflow(element: HTMLDivElement | null): boolean {231function useLabelsOverflow(element: HTMLDivElement | null): boolean {
230 const [overflows, setOverflows] = useState(false);232 const [overflows, setOverflows] = useState(false);
233 const pendingControlRectsRef = useRef<Map<HTMLElement, DOMRect> | null>(null);
234 const controlAnimationsRef = useRef(new Map<HTMLElement, Animation>());
231 // A render-synced mirror instead of useEffectEvent: the compiler memoizes235 // A render-synced mirror instead of useEffectEvent: the compiler memoizes
232 // the event callback, which left observers reading the first render's null236 // the event callback, which left observers reading the first render's null
233 // element forever.237 // element forever.
...@@ -241,7 +245,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {...@@ -241,7 +245,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
241 if (available === 0) return;245 if (available === 0) return;
242 // flex-1 stretches the groups to fill the strip, so their own boxes always246 // flex-1 stretches the groups to fill the strip, so their own boxes always
243 // measure "full". Sum the laid-out content instead, skipping hidden form247 // measure "full". Sum the laid-out content instead, skipping hidden form
244 // artifacts and absolutely-positioned nodes (the compact-hidden labels).248 // artifacts and other out-of-flow nodes.
245 const contentWidth = (parent: Element): number => {249 const contentWidth = (parent: Element): number => {
246 const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0;250 const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0;
247 let width = 0;251 let width = 0;
...@@ -283,9 +287,71 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {...@@ -283,9 +287,71 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
283 needed += Math.max(0, textWidth - label.clientWidth);287 needed += Math.max(0, textWidth - label.clientWidth);
284 }288 }
285 }289 }
286 setOverflows(compact ? needed > available - COMPACT_EXPAND_HYSTERESIS_PX : needed > available);290 const nextOverflows = compact
291 ? needed > available - COMPACT_EXPAND_HYSTERESIS_PX
292 : needed > available;
293 if (nextOverflows !== compact) {
294 pendingControlRectsRef.current = new Map(
295 Array.from(current.querySelectorAll<HTMLElement>(COMPOSER_CONTEXT_CONTROL_SELECTOR)).map(
296 (control) => [control, control.getBoundingClientRect()],
297 ),
298 );
299 }
300 setOverflows(nextOverflows);
287 }, []);301 }, []);
288302
303 useLayoutEffect(() => {
304 const previousRects = pendingControlRectsRef.current;
305 if (!previousRects) return;
306 pendingControlRectsRef.current = null;
307
308 for (const animation of controlAnimationsRef.current.values()) {
309 animation.cancel();
310 }
311 controlAnimationsRef.current.clear();
312
313 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
314
315 for (const [control, previousRect] of previousRects) {
316 if (!control.isConnected) continue;
317 const nextRect = control.getBoundingClientRect();
318 const deltaX = previousRect.left - nextRect.left;
319 const deltaY = previousRect.top - nextRect.top;
320 if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) continue;
321
322 const animation = control.animate(
323 [
324 { transform: `translate3d(${deltaX}px, ${deltaY}px, 0)` },
325 { transform: "translate3d(0, 0, 0)" },
326 ],
327 {
328 duration: COMPOSER_CONTEXT_MOTION_DURATION_MS,
329 easing: COMPOSER_CONTEXT_MOTION_EASING,
330 fill: "backwards",
331 },
332 );
333 controlAnimationsRef.current.set(control, animation);
334 animation.addEventListener(
335 "finish",
336 () => {
337 if (controlAnimationsRef.current.get(control) === animation) {
338 controlAnimationsRef.current.delete(control);
339 }
340 },
341 { once: true },
342 );
343 }
344 }, [overflows]);
345
346 useEffect(
347 () => () => {
348 for (const animation of controlAnimationsRef.current.values()) {
349 animation.cancel();
350 }
351 },
352 [],
353 );
354
289 // Label widths can change without the strip box moving (font family or355 // Label widths can change without the strip box moving (font family or
290 // size preferences), so re-measure on every render as well as on resize356 // size preferences), so re-measure on every render as well as on resize
291 // and font loads.357 // and font loads.
...@@ -403,7 +469,7 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -403,7 +469,7 @@ export const BranchToolbar = memo(function BranchToolbar({
403 <div469 <div
404 ref={setStripElement}470 ref={setStripElement}
405 data-compact={labelsOverflow ? "" : undefined}471 data-compact={labelsOverflow ? "" : undefined}
406 className="chat-composer-context-strip group/composer-context -mt-4 mx-auto flex w-[calc(100%-2.75rem)] max-w-[calc(48rem-2.75rem)] items-center gap-2 ps-1 pe-2 pt-5 pb-1"472 className="chat-composer-context-strip group/composer-context -mt-4 mx-auto flex w-[calc(100%-2.75rem)] max-w-[calc(48rem-2.75rem)] items-center gap-2 overflow-x-clip overflow-y-visible ps-1 pe-2 pt-5 pb-1"
407 >473 >
408 {isMobile && showGitControls ? (474 {isMobile && showGitControls ? (
409 <MobileRunContextSelector475 <MobileRunContextSelector
...@@ -431,7 +497,11 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -431,7 +497,11 @@ export const BranchToolbar = memo(function BranchToolbar({
431 {...(showEnvironmentPicker && onEnvironmentChange ? { onEnvironmentChange } : {})}497 {...(showEnvironmentPicker && onEnvironmentChange ? { onEnvironmentChange } : {})}
432 />498 />
433 {showGitControls ? (499 {showGitControls ? (
434 <Separator orientation="vertical" className="mx-0.5 h-3.5!" />500 <Separator
501 orientation="vertical"
502 className="mx-0.5 h-3.5!"
503 data-composer-context-control
504 />
435 ) : null}505 ) : null}
436 </>506 </>
437 )}507 )}
apps/web/src/components/BranchToolbarBranchSelector.tsx+11-3
...@@ -714,7 +714,10 @@ export function BranchToolbarBranchSelector({...@@ -714,7 +714,10 @@ export function BranchToolbarBranchSelector({
714 open={isBranchMenuOpen}714 open={isBranchMenuOpen}
715 value={resolvedActiveBranch}715 value={resolvedActiveBranch}
716 >716 >
717 <div className={cn("flex min-w-0 items-center gap-1", className)}>717 <div
718 className={cn("flex min-w-0 items-center gap-1", className)}
719 data-composer-context-control
720 >
718 {branchPr && branchPrStatus ? (721 {branchPr && branchPrStatus ? (
719 <Tooltip>722 <Tooltip>
720 <TooltipTrigger723 <TooltipTrigger
...@@ -751,9 +754,14 @@ export function BranchToolbarBranchSelector({...@@ -751,9 +754,14 @@ export function BranchToolbarBranchSelector({
751 <GitBranchIcon className="size-3 shrink-0 opacity-70" />754 <GitBranchIcon className="size-3 shrink-0 opacity-70" />
752 <span755 <span
753 data-composer-label756 data-composer-label
754 className="min-w-0 max-w-[240px] truncate transition-[max-width,opacity] duration-300 ease-out group-data-[compact]/composer-context:max-w-0 group-data-[compact]/composer-context:opacity-0"757 className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0"
755 >758 >
756 {triggerLabel}759 <span
760 data-composer-label-motion
761 className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity"
762 >
763 {triggerLabel}
764 </span>
757 </span>765 </span>
758 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />766 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />
759 </ComboboxTrigger>767 </ComboboxTrigger>
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+12-3
...@@ -50,7 +50,10 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe...@@ -50,7 +50,10 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
5050
51 if (envLocked) {51 if (envLocked) {
52 return (52 return (
53 <span className="inline-flex h-7 shrink-0 items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:h-6 sm:text-xs">53 <span
54 className="inline-flex h-7 shrink-0 items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:h-6 sm:text-xs"
55 data-composer-context-control
56 >
54 {activeWorktreePath ? (57 {activeWorktreePath ? (
55 <>58 <>
56 <FolderGitIcon className="size-3" />59 <FolderGitIcon className="size-3" />
...@@ -84,6 +87,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe...@@ -84,6 +87,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
84 size="xs"87 size="xs"
85 className="min-w-0 shrink font-medium"88 className="min-w-0 shrink font-medium"
86 aria-label="Workspace"89 aria-label="Workspace"
90 data-composer-context-control
87 >91 >
88 {effectiveEnvMode === "worktree" ? (92 {effectiveEnvMode === "worktree" ? (
89 <FolderGit2Icon className="size-3" />93 <FolderGit2Icon className="size-3" />
...@@ -94,9 +98,14 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe...@@ -94,9 +98,14 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
94 )}98 )}
95 <span99 <span
96 data-composer-label100 data-composer-label
97 className="min-w-0 max-w-[240px] truncate transition-[max-width,opacity] duration-300 ease-out group-data-[compact]/composer-context:max-w-0 group-data-[compact]/composer-context:opacity-0"101 className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0"
98 >102 >
99 <SelectValue />103 <span
104 data-composer-label-motion
105 className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity"
106 >
107 <SelectValue />
108 </span>
100 </span>109 </span>
101 </SelectTrigger>110 </SelectTrigger>
102 <SelectPopup>111 <SelectPopup>
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+19-5
...@@ -48,7 +48,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir...@@ -48,7 +48,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
48 // only thing in the strip.48 // only thing in the strip.
49 if (envLocked || onEnvironmentChange === undefined) {49 if (envLocked || onEnvironmentChange === undefined) {
50 return (50 return (
51 <span className="inline-flex h-7 min-w-0 max-w-full items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:h-6 sm:text-xs">51 <span
52 className="inline-flex h-7 min-w-0 max-w-full items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:h-6 sm:text-xs"
53 data-composer-context-control
54 >
52 {activeEnvironment?.isPrimary ? (55 {activeEnvironment?.isPrimary ? (
53 <MonitorIcon className="size-3 shrink-0" />56 <MonitorIcon className="size-3 shrink-0" />
54 ) : (57 ) : (
...@@ -56,9 +59,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir...@@ -56,9 +59,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
56 )}59 )}
57 <span60 <span
58 data-composer-label61 data-composer-label
59 className="min-w-0 max-w-[240px] truncate transition-[max-width,opacity] duration-300 ease-out group-data-[compact]/composer-context:max-w-0 group-data-[compact]/composer-context:opacity-0"62 className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0"
60 >63 >
61 {activeEnvironment?.label ?? "Run on"}64 <span
65 data-composer-label-motion
66 className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity"
67 >
68 {activeEnvironment?.label ?? "Run on"}
69 </span>
62 </span>70 </span>
63 </span>71 </span>
64 );72 );
...@@ -76,6 +84,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir...@@ -76,6 +84,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
76 size="xs"84 size="xs"
77 className="min-w-0 max-w-full font-medium"85 className="min-w-0 max-w-full font-medium"
78 aria-label="Run on"86 aria-label="Run on"
87 data-composer-context-control
79 >88 >
80 {activeEnvironment?.isPrimary ? (89 {activeEnvironment?.isPrimary ? (
81 <MonitorIcon className="size-3 shrink-0" />90 <MonitorIcon className="size-3 shrink-0" />
...@@ -84,9 +93,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir...@@ -84,9 +93,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
84 )}93 )}
85 <span94 <span
86 data-composer-label95 data-composer-label
87 className="min-w-0 max-w-[240px] truncate transition-[max-width,opacity] duration-300 ease-out group-data-[compact]/composer-context:max-w-0 group-data-[compact]/composer-context:opacity-0"96 className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0"
88 >97 >
89 <SelectValue />98 <span
99 data-composer-label-motion
100 className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity"
101 >
102 <SelectValue />
103 </span>
90 </span>104 </span>
91 </SelectTrigger>105 </SelectTrigger>
92 <SelectPopup>106 <SelectPopup>