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 {
99 HistoryIcon,
1010 MonitorIcon,
1111} from "lucide-react";
12import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
12import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
1313
1414import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
1515import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities";
......@@ -218,16 +218,20 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
218218/**
219219 * Collapse the strip's labels to icons only when the text no longer fits.
220220 *
221 * Hidden labels stay measurable (they collapse to invisible absolute boxes,
222 * which keep their natural width), so the required width can be recomputed in
223 * either state on every pass - no remembered widths that could go stale or
224 * latch the strip compact. A small hysteresis keeps the boundary from
225 * flapping between states.
221 * Hidden labels stay measurable because their inner text keeps its natural
222 * width while the outer layout box collapses. This lets every pass recompute
223 * the expanded width without remembered values that could go stale or latch
224 * the strip compact. A small hysteresis keeps the boundary from flapping.
226225 */
227226const 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
229231function useLabelsOverflow(element: HTMLDivElement | null): boolean {
230232 const [overflows, setOverflows] = useState(false);
233 const pendingControlRectsRef = useRef<Map<HTMLElement, DOMRect> | null>(null);
234 const controlAnimationsRef = useRef(new Map<HTMLElement, Animation>());
231235 // A render-synced mirror instead of useEffectEvent: the compiler memoizes
232236 // the event callback, which left observers reading the first render's null
233237 // element forever.
......@@ -241,7 +245,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
241245 if (available === 0) return;
242246 // flex-1 stretches the groups to fill the strip, so their own boxes always
243247 // 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.
245249 const contentWidth = (parent: Element): number => {
246250 const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0;
247251 let width = 0;
......@@ -283,9 +287,71 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
283287 needed += Math.max(0, textWidth - label.clientWidth);
284288 }
285289 }
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);
287301 }, []);
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
289355 // Label widths can change without the strip box moving (font family or
290356 // size preferences), so re-measure on every render as well as on resize
291357 // and font loads.
......@@ -403,7 +469,7 @@ export const BranchToolbar = memo(function BranchToolbar({
403469 <div
404470 ref={setStripElement}
405471 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"
407473 >
408474 {isMobile && showGitControls ? (
409475 <MobileRunContextSelector
......@@ -431,7 +497,11 @@ export const BranchToolbar = memo(function BranchToolbar({
431497 {...(showEnvironmentPicker && onEnvironmentChange ? { onEnvironmentChange } : {})}
432498 />
433499 {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 />
435505 ) : null}
436506 </>
437507 )}
apps/web/src/components/BranchToolbarBranchSelector.tsx+11-3
......@@ -714,7 +714,10 @@ export function BranchToolbarBranchSelector({
714714 open={isBranchMenuOpen}
715715 value={resolvedActiveBranch}
716716 >
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 >
718721 {branchPr && branchPrStatus ? (
719722 <Tooltip>
720723 <TooltipTrigger
......@@ -751,9 +754,14 @@ export function BranchToolbarBranchSelector({
751754 <GitBranchIcon className="size-3 shrink-0 opacity-70" />
752755 <span
753756 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"
755758 >
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>
757765 </span>
758766 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />
759767 </ComboboxTrigger>
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+12-3
......@@ -50,7 +50,10 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
5050
5151 if (envLocked) {
5252 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 >
5457 {activeWorktreePath ? (
5558 <>
5659 <FolderGitIcon className="size-3" />
......@@ -84,6 +87,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
8487 size="xs"
8588 className="min-w-0 shrink font-medium"
8689 aria-label="Workspace"
90 data-composer-context-control
8791 >
8892 {effectiveEnvMode === "worktree" ? (
8993 <FolderGit2Icon className="size-3" />
......@@ -94,9 +98,14 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
9498 )}
9599 <span
96100 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"
98102 >
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>
100109 </span>
101110 </SelectTrigger>
102111 <SelectPopup>
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+19-5
......@@ -48,7 +48,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
4848 // only thing in the strip.
4949 if (envLocked || onEnvironmentChange === undefined) {
5050 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 >
5255 {activeEnvironment?.isPrimary ? (
5356 <MonitorIcon className="size-3 shrink-0" />
5457 ) : (
......@@ -56,9 +59,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
5659 )}
5760 <span
5861 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"
6063 >
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>
6270 </span>
6371 </span>
6472 );
......@@ -76,6 +84,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
7684 size="xs"
7785 className="min-w-0 max-w-full font-medium"
7886 aria-label="Run on"
87 data-composer-context-control
7988 >
8089 {activeEnvironment?.isPrimary ? (
8190 <MonitorIcon className="size-3 shrink-0" />
......@@ -84,9 +93,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
8493 )}
8594 <span
8695 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"
8897 >
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>
90104 </span>
91105 </SelectTrigger>
92106 <SelectPopup>