| 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 | 9 | HistoryIcon, |
| 10 | 10 | MonitorIcon, |
| 11 | 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 | 14 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; |
| 15 | 15 | import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; |
| ... | ... | @@ -218,16 +218,20 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 218 | 218 | /** |
| 219 | 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, | |
| 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. | |
| 226 | 225 | */ |
| 227 | 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 | 231 | function useLabelsOverflow(element: HTMLDivElement | null): boolean { |
| 230 | 232 | const [overflows, setOverflows] = useState(false); |
| 233 | const pendingControlRectsRef = useRef<Map<HTMLElement, DOMRect> | null>(null); | |
| 234 | const controlAnimationsRef = useRef(new Map<HTMLElement, Animation>()); | |
| 231 | 235 | // A render-synced mirror instead of useEffectEvent: the compiler memoizes |
| 232 | 236 | // the event callback, which left observers reading the first render's null |
| 233 | 237 | // element forever. |
| ... | ... | @@ -241,7 +245,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { |
| 241 | 245 | if (available === 0) return; |
| 242 | 246 | // flex-1 stretches the groups to fill the strip, so their own boxes always |
| 243 | 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 | 249 | const contentWidth = (parent: Element): number => { |
| 246 | 250 | const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0; |
| 247 | 251 | let width = 0; |
| ... | ... | @@ -283,9 +287,71 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { |
| 283 | 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 | 355 | // Label widths can change without the strip box moving (font family or |
| 290 | 356 | // size preferences), so re-measure on every render as well as on resize |
| 291 | 357 | // and font loads. |
| ... | ... | @@ -403,7 +469,7 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 403 | 469 | <div |
| 404 | 470 | ref={setStripElement} |
| 405 | 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 | 474 | {isMobile && showGitControls ? ( |
| 409 | 475 | <MobileRunContextSelector |
| ... | ... | @@ -431,7 +497,11 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 431 | 497 | {...(showEnvironmentPicker && onEnvironmentChange ? { onEnvironmentChange } : {})} |
| 432 | 498 | /> |
| 433 | 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 | 505 | ) : null} |
| 436 | 506 | </> |
| 437 | 507 | )} |
apps/web/src/components/BranchToolbarBranchSelector.tsx+11-3| ... | ... | @@ -714,7 +714,10 @@ export function BranchToolbarBranchSelector({ |
| 714 | 714 | open={isBranchMenuOpen} |
| 715 | 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 | 721 | {branchPr && branchPrStatus ? ( |
| 719 | 722 | <Tooltip> |
| 720 | 723 | <TooltipTrigger |
| ... | ... | @@ -751,9 +754,14 @@ export function BranchToolbarBranchSelector({ |
| 751 | 754 | <GitBranchIcon className="size-3 shrink-0 opacity-70" /> |
| 752 | 755 | <span |
| 753 | 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 | 765 | </span> |
| 758 | 766 | <ChevronDownIcon className="size-3 shrink-0 opacity-50" /> |
| 759 | 767 | </ComboboxTrigger> |
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+12-3| ... | ... | @@ -50,7 +50,10 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 50 | 50 | |
| 51 | 51 | if (envLocked) { |
| 52 | 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 | 57 | {activeWorktreePath ? ( |
| 55 | 58 | <> |
| 56 | 59 | <FolderGitIcon className="size-3" /> |
| ... | ... | @@ -84,6 +87,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 84 | 87 | size="xs" |
| 85 | 88 | className="min-w-0 shrink font-medium" |
| 86 | 89 | aria-label="Workspace" |
| 90 | data-composer-context-control | |
| 87 | 91 | > |
| 88 | 92 | {effectiveEnvMode === "worktree" ? ( |
| 89 | 93 | <FolderGit2Icon className="size-3" /> |
| ... | ... | @@ -94,9 +98,14 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 94 | 98 | )} |
| 95 | 99 | <span |
| 96 | 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 | 109 | </span> |
| 101 | 110 | </SelectTrigger> |
| 102 | 111 | <SelectPopup> |
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+19-5| ... | ... | @@ -48,7 +48,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 48 | 48 | // only thing in the strip. |
| 49 | 49 | if (envLocked || onEnvironmentChange === undefined) { |
| 50 | 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 | 55 | {activeEnvironment?.isPrimary ? ( |
| 53 | 56 | <MonitorIcon className="size-3 shrink-0" /> |
| 54 | 57 | ) : ( |
| ... | ... | @@ -56,9 +59,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 56 | 59 | )} |
| 57 | 60 | <span |
| 58 | 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 | 70 | </span> |
| 63 | 71 | </span> |
| 64 | 72 | ); |
| ... | ... | @@ -76,6 +84,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 76 | 84 | size="xs" |
| 77 | 85 | className="min-w-0 max-w-full font-medium" |
| 78 | 86 | aria-label="Run on" |
| 87 | data-composer-context-control | |
| 79 | 88 | > |
| 80 | 89 | {activeEnvironment?.isPrimary ? ( |
| 81 | 90 | <MonitorIcon className="size-3 shrink-0" /> |
| ... | ... | @@ -84,9 +93,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 84 | 93 | )} |
| 85 | 94 | <span |
| 86 | 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 | 104 | </span> |
| 91 | 105 | </SelectTrigger> |
| 92 | 106 | <SelectPopup> |