From 982c0d66a463a709ea1e4766e5e606e47213af94 Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:11:08 +0300 Subject: [PATCH 1/4] fix(web): smooth composer tools sidebar transition --- apps/web/src/components/BranchToolbar.tsx | 90 ++++++++++++++++--- .../BranchToolbarBranchSelector.tsx | 14 ++- .../BranchToolbarEnvModeSelector.tsx | 10 ++- .../BranchToolbarEnvironmentSelector.tsx | 24 +++-- 4 files changed, 118 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 5ceec813187..5d11cce11fb 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -9,7 +9,7 @@ import { HistoryIcon, MonitorIcon, } from "lucide-react"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; @@ -218,16 +218,20 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ /** * Collapse the strip's labels to icons only when the text no longer fits. * - * Hidden labels stay measurable (they collapse to invisible absolute boxes, - * which keep their natural width), so the required width can be recomputed in - * either state on every pass - no remembered widths that could go stale or - * latch the strip compact. A small hysteresis keeps the boundary from - * flapping between states. + * Hidden labels stay measurable because their inner text keeps its natural + * width while the outer layout box collapses. This lets every pass recompute + * the expanded width without remembered values that could go stale or latch + * the strip compact. A small hysteresis keeps the boundary from flapping. */ const COMPACT_EXPAND_HYSTERESIS_PX = 16; +const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180; +const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; +const COMPOSER_CONTEXT_CONTROL_SELECTOR = "[data-composer-context-control]"; function useLabelsOverflow(element: HTMLDivElement | null): boolean { const [overflows, setOverflows] = useState(false); + const pendingControlRectsRef = useRef | null>(null); + const controlAnimationsRef = useRef(new Map()); // A render-synced mirror instead of useEffectEvent: the compiler memoizes // the event callback, which left observers reading the first render's null // element forever. @@ -241,7 +245,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { if (available === 0) return; // flex-1 stretches the groups to fill the strip, so their own boxes always // measure "full". Sum the laid-out content instead, skipping hidden form - // artifacts and absolutely-positioned nodes (the compact-hidden labels). + // artifacts and other out-of-flow nodes. const contentWidth = (parent: Element): number => { const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0; let width = 0; @@ -283,9 +287,71 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { needed += Math.max(0, textWidth - label.clientWidth); } } - setOverflows(compact ? needed > available - COMPACT_EXPAND_HYSTERESIS_PX : needed > available); + const nextOverflows = compact + ? needed > available - COMPACT_EXPAND_HYSTERESIS_PX + : needed > available; + if (nextOverflows !== compact) { + pendingControlRectsRef.current = new Map( + Array.from(current.querySelectorAll(COMPOSER_CONTEXT_CONTROL_SELECTOR)).map( + (control) => [control, control.getBoundingClientRect()], + ), + ); + } + setOverflows(nextOverflows); }, []); + useLayoutEffect(() => { + const previousRects = pendingControlRectsRef.current; + if (!previousRects) return; + pendingControlRectsRef.current = null; + + for (const animation of controlAnimationsRef.current.values()) { + animation.cancel(); + } + controlAnimationsRef.current.clear(); + + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + + for (const [control, previousRect] of previousRects) { + if (!control.isConnected) continue; + const nextRect = control.getBoundingClientRect(); + const deltaX = previousRect.left - nextRect.left; + const deltaY = previousRect.top - nextRect.top; + if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) continue; + + const animation = control.animate( + [ + { transform: `translate3d(${deltaX}px, ${deltaY}px, 0)` }, + { transform: "translate3d(0, 0, 0)" }, + ], + { + duration: COMPOSER_CONTEXT_MOTION_DURATION_MS, + easing: COMPOSER_CONTEXT_MOTION_EASING, + fill: "backwards", + }, + ); + controlAnimationsRef.current.set(control, animation); + animation.addEventListener( + "finish", + () => { + if (controlAnimationsRef.current.get(control) === animation) { + controlAnimationsRef.current.delete(control); + } + }, + { once: true }, + ); + } + }, [overflows]); + + useEffect( + () => () => { + for (const animation of controlAnimationsRef.current.values()) { + animation.cancel(); + } + }, + [], + ); + // Label widths can change without the strip box moving (font family or // size preferences), so re-measure on every render as well as on resize // and font loads. @@ -403,7 +469,7 @@ export const BranchToolbar = memo(function BranchToolbar({
{isMobile && showGitControls ? ( {showGitControls ? ( - + ) : null} )} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 251e59a40a4..f39b2ac7091 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -714,7 +714,10 @@ export function BranchToolbarBranchSelector({ open={isBranchMenuOpen} value={resolvedActiveBranch} > -
+
{branchPr && branchPrStatus ? ( - {triggerLabel} + + {triggerLabel} + diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 64bcd8c57cb..5208e863eb9 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -84,6 +84,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe size="xs" className="min-w-0 shrink font-medium" aria-label="Workspace" + data-composer-context-control > {effectiveEnvMode === "worktree" ? ( @@ -94,9 +95,14 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe )} - + + + diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 56fb91fb4b8..4be90f283c3 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -48,7 +48,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir // only thing in the strip. if (envLocked || onEnvironmentChange === undefined) { return ( - + {activeEnvironment?.isPrimary ? ( ) : ( @@ -56,9 +59,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir )} - {activeEnvironment?.label ?? "Run on"} + + {activeEnvironment?.label ?? "Run on"} + ); @@ -76,6 +84,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir size="xs" className="min-w-0 max-w-full font-medium" aria-label="Run on" + data-composer-context-control > {activeEnvironment?.isPrimary ? ( @@ -84,9 +93,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir )} - + + + From ee6ea648e6954e7bafbaefef298a40b752b01edf Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:32:46 +0300 Subject: [PATCH 2/4] fix(web): animate composer context label transforms --- apps/web/src/components/BranchToolbarBranchSelector.tsx | 2 +- apps/web/src/components/BranchToolbarEnvModeSelector.tsx | 2 +- apps/web/src/components/BranchToolbarEnvironmentSelector.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index f39b2ac7091..b263d80304d 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -758,7 +758,7 @@ export function BranchToolbarBranchSelector({ > {triggerLabel} diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 5208e863eb9..2f8572287b0 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -99,7 +99,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe > diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 4be90f283c3..a7ba53563e7 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -63,7 +63,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir > {activeEnvironment?.label ?? "Run on"} @@ -97,7 +97,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir > From aa54a6a37e58836624fc95ee4eb259849c45c66c Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:40:21 +0300 Subject: [PATCH 3/4] fix(web): keep composer labels clear of chevrons --- apps/web/src/components/BranchToolbarBranchSelector.tsx | 2 +- apps/web/src/components/BranchToolbarEnvModeSelector.tsx | 2 +- apps/web/src/components/BranchToolbarEnvironmentSelector.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index b263d80304d..05ed533acbc 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -758,7 +758,7 @@ export function BranchToolbarBranchSelector({ > {triggerLabel} diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 2f8572287b0..25449b1fa43 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -99,7 +99,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe > diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index a7ba53563e7..b5d5751a280 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -63,7 +63,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir > {activeEnvironment?.label ?? "Run on"} @@ -97,7 +97,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir > From 7755d240796b2e19f74a4bfca70d9f4c12e457d6 Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:07:50 +0300 Subject: [PATCH 4/4] fix(web): animate locked workspace control Mark the locked workspace span as a composer context control so FLIP measurements include it when nearby labels collapse. --- apps/web/src/components/BranchToolbarEnvModeSelector.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 25449b1fa43..9fc2d4892e2 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -50,7 +50,10 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe if (envLocked) { return ( - + {activeWorktreePath ? ( <>