| author | |
| committer | |
| log | c196f422ed387a1cc2cdb671b0472782e5610339 |
| tree | b9c90e066aa9ee8f0044cd97a2e10bdb12044749 |
| parent | f0b57ca2313bd7971cefb366d3b3808152b014fc |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 12 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | 12 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 13 | 13 | ||
| 14 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; | 14 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; |
| 15 | import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; | 15 | import { 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 in | 222 | * 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 or | 223 | * the expanded width without remembered values that could go stale or latch |
| 224 | * latch the strip compact. A small hysteresis keeps the boundary from | 224 | * the strip compact. A small hysteresis keeps the boundary from flapping. |
| 225 | * flapping between states. | ||
| 226 | */ | 225 | */ |
| 227 | const COMPACT_EXPAND_HYSTERESIS_PX = 16; | 226 | const COMPACT_EXPAND_HYSTERESIS_PX = 16; |
| 227 | const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180; | ||
| 228 | const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; | ||
| 229 | const COMPOSER_CONTEXT_CONTROL_SELECTOR = "[data-composer-context-control]"; | ||
| 228 | 230 | ||
| 229 | function useLabelsOverflow(element: HTMLDivElement | null): boolean { | 231 | function 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 memoizes | 235 | // A render-synced mirror instead of useEffectEvent: the compiler memoizes |
| 232 | // the event callback, which left observers reading the first render's null | 236 | // 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 always | 246 | // 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 form | 247 | // 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 | }, []); |
| 288 | 302 | ||
| 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 or | 355 | // 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 resize | 356 | // 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 | <div | 469 | <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 | <MobileRunContextSelector | 475 | <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 | <TooltipTrigger | 723 | <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 | <span | 755 | <span |
| 753 | data-composer-label | 756 | 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 |
| 50 | 50 | ||
| 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 | <span | 99 | <span |
| 96 | data-composer-label | 100 | 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 | <span | 60 | <span |
| 58 | data-composer-label | 61 | 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 | <span | 94 | <span |
| 86 | data-composer-label | 95 | 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> |