| 1 | import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; |
| 2 | import { readLocalApi } from "../localApi"; |
| 3 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 4 | import { ComposerContextLabel } from "./ComposerContextLabel"; |
| 5 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 6 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 7 | import { |
| 8 | ChevronDownIcon, |
| 9 | FolderGit2Icon, |
| 10 | FolderGitIcon, |
| 11 | FolderIcon, |
| 12 | ScaleIcon, |
| 13 | } from "lucide-react"; |
| 14 | import { |
| 15 | type Ref, |
| 16 | type MouseEvent as ReactMouseEvent, |
| 17 | memo, |
| 18 | useImperativeHandle, |
| 19 | useCallback, |
| 20 | useEffect, |
| 21 | useLayoutEffect, |
| 22 | useMemo, |
| 23 | useRef, |
| 24 | useState, |
| 25 | } from "react"; |
| 26 | |
| 27 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; |
| 28 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 29 | import { useProject, useThreadShell, useThreadShellsForProjectRefs } from "../state/entities"; |
| 30 | import { |
| 31 | type EnvMode, |
| 32 | type EnvironmentOption, |
| 33 | resolveContextStripLabelsCompact, |
| 34 | resolveCurrentWorkspaceLabel, |
| 35 | resolveEnvModeLabel, |
| 36 | resolveLockedWorkspaceLabel, |
| 37 | resolvePreviousWorktreeLabel, |
| 38 | resolvePreviousWorktreeSeed, |
| 39 | shouldShowEnvironmentIndicator, |
| 40 | } from "./BranchToolbar.logic"; |
| 41 | import { |
| 42 | BranchToolbarBranchSelector, |
| 43 | type BranchToolbarBranchSelectorHandle, |
| 44 | } from "./BranchToolbarBranchSelector"; |
| 45 | import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; |
| 46 | import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; |
| 47 | import { PreviousWorktreeItemContent } from "./PreviousWorktreeItemContent"; |
| 48 | import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; |
| 49 | import { |
| 50 | THREAD_DETAILS_PANEL_ICON_CLASS, |
| 51 | THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS, |
| 52 | } from "./chat/threadDetailsPanelStyles"; |
| 53 | import { ComposerControl } from "./chat/ComposerControl"; |
| 54 | import { |
| 55 | Menu, |
| 56 | MenuGroup, |
| 57 | MenuGroupLabel, |
| 58 | MenuPopup, |
| 59 | MenuRadioGroup, |
| 60 | MenuRadioItem, |
| 61 | MenuSeparator, |
| 62 | MenuTrigger, |
| 63 | } from "./ui/menu"; |
| 64 | import { Separator } from "./ui/separator"; |
| 65 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 66 | import { MiddleTruncate } from "./ui/middle-truncate"; |
| 67 | import { ComposerSurface } from "./chat/ComposerSurface"; |
| 68 | import { useComposerMenuProps } from "./chat/composerEventScope"; |
| 69 | import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; |
| 70 | import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; |
| 71 | import { cn } from "~/lib/utils"; |
| 72 | import { observeResize } from "~/lib/observeResize"; |
| 73 | |
| 74 | export interface BranchToolbarHandle { |
| 75 | openBranchPicker: () => void; |
| 76 | usePreviousWorktree: () => void; |
| 77 | } |
| 78 | |
| 79 | interface BranchToolbarProps { |
| 80 | layout?: "composer" | "panel"; |
| 81 | panelSection?: "all" | "workspace" | "branch"; |
| 82 | forceNewWorktree?: boolean; |
| 83 | ref?: Ref<BranchToolbarHandle>; |
| 84 | environmentId: EnvironmentId; |
| 85 | threadId: ThreadId; |
| 86 | showGitControls: boolean; |
| 87 | draftId?: DraftId; |
| 88 | onEnvModeChange: (mode: EnvMode) => void; |
| 89 | /** The thread's env mode as ChatView resolves it. */ |
| 90 | envMode: EnvMode; |
| 91 | activeThreadBranchOverride?: string | null; |
| 92 | onActiveThreadBranchOverrideChange?: (branch: string | null) => void; |
| 93 | startFromOrigin: boolean; |
| 94 | onStartFromOriginChange: (startFromOrigin: boolean) => void; |
| 95 | autoEnvironmentLabel?: string | undefined; |
| 96 | onAutoEnvironment?: (() => void) | undefined; |
| 97 | envLocked: boolean; |
| 98 | onCheckoutPullRequestRequest?: (reference: string) => void; |
| 99 | onComposerFocusRequest?: () => void; |
| 100 | availableEnvironments?: readonly EnvironmentOption[]; |
| 101 | onEnvironmentChange?: (environmentId: EnvironmentId) => void; |
| 102 | composerControlsHostRef?: (element: HTMLDivElement | null) => void; |
| 103 | contextStripVisible?: boolean; |
| 104 | } |
| 105 | |
| 106 | function showWorkspaceContextMenu(event: ReactMouseEvent, workspacePath: string) { |
| 107 | const api = readLocalApi(); |
| 108 | if (!api) return; |
| 109 | event.preventDefault(); |
| 110 | event.stopPropagation(); |
| 111 | void api.contextMenu |
| 112 | .show([{ id: "copy-path", label: "Copy full path", icon: "copy" }], { |
| 113 | x: event.clientX, |
| 114 | y: event.clientY, |
| 115 | }) |
| 116 | .then((action) => { |
| 117 | if (action !== "copy-path") return; |
| 118 | void writeTextToClipboard(workspacePath, "workspace path").then( |
| 119 | (didCopy) => { |
| 120 | if (didCopy) { |
| 121 | toastManager.add({ |
| 122 | type: "success", |
| 123 | title: "Path copied", |
| 124 | description: workspacePath, |
| 125 | }); |
| 126 | } |
| 127 | }, |
| 128 | (error: unknown) => { |
| 129 | toastManager.add( |
| 130 | stackedThreadToast({ |
| 131 | type: "error", |
| 132 | title: "Failed to copy path", |
| 133 | description: error instanceof Error ? error.message : "An error occurred.", |
| 134 | }), |
| 135 | ); |
| 136 | }, |
| 137 | ); |
| 138 | }); |
| 139 | } |
| 140 | |
| 141 | interface RunContextSelectorProps { |
| 142 | displayMode?: "toolbar" | "panel"; |
| 143 | workspaceRoot?: string; |
| 144 | forceNewWorktree: boolean; |
| 145 | autoEnvironmentLabel?: string | undefined; |
| 146 | onAutoEnvironment?: (() => void) | undefined; |
| 147 | envLocked: boolean; |
| 148 | envModeLocked: boolean; |
| 149 | environmentId: EnvironmentId; |
| 150 | availableEnvironments: readonly EnvironmentOption[] | undefined; |
| 151 | showEnvironmentPicker: boolean; |
| 152 | showEnvironmentIndicator: boolean; |
| 153 | onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined; |
| 154 | effectiveEnvMode: EnvMode; |
| 155 | activeWorktreePath: string | null; |
| 156 | onEnvModeChange: (mode: EnvMode) => void; |
| 157 | previousWorktreeLabel: string | null; |
| 158 | previousWorktreeBranch: string | null; |
| 159 | onUsePreviousWorktree: () => void; |
| 160 | } |
| 161 | |
| 162 | const RunContextSelector = memo(function RunContextSelector({ |
| 163 | displayMode = "toolbar", |
| 164 | workspaceRoot, |
| 165 | forceNewWorktree, |
| 166 | autoEnvironmentLabel, |
| 167 | onAutoEnvironment, |
| 168 | envLocked, |
| 169 | envModeLocked, |
| 170 | environmentId, |
| 171 | availableEnvironments, |
| 172 | showEnvironmentPicker, |
| 173 | showEnvironmentIndicator, |
| 174 | onEnvironmentChange, |
| 175 | effectiveEnvMode, |
| 176 | activeWorktreePath, |
| 177 | onEnvModeChange, |
| 178 | previousWorktreeLabel, |
| 179 | previousWorktreeBranch, |
| 180 | onUsePreviousWorktree, |
| 181 | }: RunContextSelectorProps) { |
| 182 | const composerFloatingLayerProps = useComposerMenuProps(); |
| 183 | const activeEnvironment = useMemo( |
| 184 | () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, |
| 185 | [availableEnvironments, environmentId], |
| 186 | ); |
| 187 | const WorkspaceIcon = |
| 188 | effectiveEnvMode === "worktree" |
| 189 | ? FolderGit2Icon |
| 190 | : activeWorktreePath |
| 191 | ? FolderGitIcon |
| 192 | : FolderIcon; |
| 193 | const workspaceLabel = forceNewWorktree |
| 194 | ? resolveEnvModeLabel("worktree") |
| 195 | : envModeLocked |
| 196 | ? resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode) |
| 197 | : effectiveEnvMode === "worktree" |
| 198 | ? resolveEnvModeLabel("worktree") |
| 199 | : resolveCurrentWorkspaceLabel(activeWorktreePath); |
| 200 | const isPanel = displayMode === "panel"; |
| 201 | const isLocked = envLocked || (envModeLocked && (!isPanel || !showEnvironmentPicker)); |
| 202 | const workspacePath = |
| 203 | forceNewWorktree || (effectiveEnvMode === "worktree" && !activeWorktreePath) |
| 204 | ? null |
| 205 | : (activeWorktreePath ?? workspaceRoot); |
| 206 | const handleContextMenu = (event: ReactMouseEvent) => { |
| 207 | if (isPanel && workspacePath) { |
| 208 | showWorkspaceContextMenu(event, workspacePath); |
| 209 | } |
| 210 | }; |
| 211 | const workspaceIcon = ( |
| 212 | <Tooltip> |
| 213 | <TooltipTrigger render={<span className="inline-flex shrink-0" />}> |
| 214 | <WorkspaceIcon |
| 215 | className={ |
| 216 | isPanel |
| 217 | ? THREAD_DETAILS_PANEL_ICON_CLASS |
| 218 | : cn("size-3 shrink-0", showEnvironmentIndicator && "mx-0!") |
| 219 | } |
| 220 | /> |
| 221 | </TooltipTrigger> |
| 222 | <TooltipPopup> |
| 223 | {isPanel |
| 224 | ? forceNewWorktree |
| 225 | ? "Each model starts in its own worktree." |
| 226 | : (workspacePath ?? workspaceLabel) |
| 227 | : workspaceLabel} |
| 228 | </TooltipPopup> |
| 229 | </Tooltip> |
| 230 | ); |
| 231 | const icon = showEnvironmentIndicator ? ( |
| 232 | // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px |
| 233 | // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. |
| 234 | <span className="inline-flex shrink-0 items-center gap-0.5"> |
| 235 | <Tooltip> |
| 236 | <TooltipTrigger render={<span className="inline-flex shrink-0" />}> |
| 237 | {autoEnvironmentLabel ? ( |
| 238 | <ScaleIcon |
| 239 | className={isPanel ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3 shrink-0 mx-0!"} |
| 240 | aria-hidden="true" |
| 241 | /> |
| 242 | ) : ( |
| 243 | <EnvironmentMachineIcon |
| 244 | kind={activeEnvironment?.machine ?? "server"} |
| 245 | className={isPanel ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3 shrink-0 mx-0!"} |
| 246 | /> |
| 247 | )} |
| 248 | </TooltipTrigger> |
| 249 | <TooltipPopup>{autoEnvironmentLabel ?? activeEnvironment?.label ?? "Run on"}</TooltipPopup> |
| 250 | </Tooltip> |
| 251 | {workspaceIcon} |
| 252 | </span> |
| 253 | ) : ( |
| 254 | workspaceIcon |
| 255 | ); |
| 256 | const triggerContent = ( |
| 257 | <> |
| 258 | {icon} |
| 259 | <ComposerContextLabel displayMode={displayMode}> |
| 260 | {autoEnvironmentLabel ?? |
| 261 | (showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel)} |
| 262 | </ComposerContextLabel> |
| 263 | </> |
| 264 | ); |
| 265 | |
| 266 | if (isLocked) { |
| 267 | return ( |
| 268 | <span |
| 269 | className={cn( |
| 270 | "inline-flex h-7 min-w-0 max-w-[48%] flex-initial items-center justify-start gap-1 rounded-md border border-transparent px-1.75 font-normal text-muted-foreground/70 text-xs sm:h-6", |
| 271 | isPanel && "max-w-full", |
| 272 | isPanel && THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS, |
| 273 | )} |
| 274 | onContextMenu={handleContextMenu} |
| 275 | aria-label={isPanel ? "Run context" : undefined} |
| 276 | data-composer-context-control |
| 277 | > |
| 278 | {triggerContent} |
| 279 | </span> |
| 280 | ); |
| 281 | } |
| 282 | |
| 283 | return ( |
| 284 | <Menu> |
| 285 | <MenuTrigger |
| 286 | render={isPanel ? <ThreadDetailsControl part="select" /> : <ComposerControl size="xs" />} |
| 287 | className={isPanel ? undefined : "min-w-0 max-w-[48%] flex-initial justify-start"} |
| 288 | onContextMenu={handleContextMenu} |
| 289 | onMouseDownCapture={ |
| 290 | isPanel |
| 291 | ? (event) => { |
| 292 | if (event.button !== 0 || event.ctrlKey) event.stopPropagation(); |
| 293 | } |
| 294 | : undefined |
| 295 | } |
| 296 | aria-label={isPanel ? "Run context" : undefined} |
| 297 | data-composer-context-control |
| 298 | data-composer-shortcut={[ |
| 299 | showEnvironmentPicker && !envLocked ? "composer.host" : "", |
| 300 | !envModeLocked ? "composer.workspace" : "", |
| 301 | ].join(" ")} |
| 302 | > |
| 303 | {triggerContent} |
| 304 | {!isPanel ? <ChevronDownIcon className="size-3 shrink-0 opacity-50" /> : null} |
| 305 | </MenuTrigger> |
| 306 | <MenuPopup |
| 307 | align="start" |
| 308 | side={isPanel ? "bottom" : "top"} |
| 309 | className={ |
| 310 | isPanel |
| 311 | ? "w-(--anchor-width)" |
| 312 | : previousWorktreeLabel |
| 313 | ? "w-[min(21rem,calc(100vw-2rem))]" |
| 314 | : undefined |
| 315 | } |
| 316 | {...(!isPanel ? composerFloatingLayerProps : {})} |
| 317 | > |
| 318 | {showEnvironmentPicker && availableEnvironments && onEnvironmentChange ? ( |
| 319 | <> |
| 320 | <MenuGroup> |
| 321 | <MenuGroupLabel>Run on</MenuGroupLabel> |
| 322 | <MenuRadioGroup |
| 323 | value={autoEnvironmentLabel ? "auto" : environmentId} |
| 324 | onValueChange={(value) => |
| 325 | value === "auto" |
| 326 | ? onAutoEnvironment?.() |
| 327 | : onEnvironmentChange(value as EnvironmentId) |
| 328 | } |
| 329 | > |
| 330 | {onAutoEnvironment && ( |
| 331 | <MenuRadioItem |
| 332 | value="auto" |
| 333 | disabled={envLocked} |
| 334 | closeOnClick |
| 335 | onClick={() => { |
| 336 | if (autoEnvironmentLabel) onAutoEnvironment?.(); |
| 337 | }} |
| 338 | > |
| 339 | <span className="flex min-w-0 items-center gap-1.5"> |
| 340 | <ScaleIcon className="size-3" aria-hidden="true" /> |
| 341 | <span className="min-w-0 truncate"> |
| 342 | {autoEnvironmentLabel ?? "Auto balance"} |
| 343 | </span> |
| 344 | </span> |
| 345 | </MenuRadioItem> |
| 346 | )} |
| 347 | {availableEnvironments.map((env) => ( |
| 348 | <MenuRadioItem |
| 349 | key={env.environmentId} |
| 350 | disabled={envLocked} |
| 351 | value={env.environmentId} |
| 352 | closeOnClick |
| 353 | > |
| 354 | <span className="flex min-w-0 items-center gap-1.5"> |
| 355 | <EnvironmentMachineIcon kind={env.machine} className="size-3" /> |
| 356 | <span className="min-w-0 truncate">{env.label}</span> |
| 357 | </span> |
| 358 | </MenuRadioItem> |
| 359 | ))} |
| 360 | </MenuRadioGroup> |
| 361 | </MenuGroup> |
| 362 | <MenuSeparator /> |
| 363 | </> |
| 364 | ) : null} |
| 365 | <MenuGroup> |
| 366 | <MenuGroupLabel>Workspace</MenuGroupLabel> |
| 367 | <MenuRadioGroup |
| 368 | value={effectiveEnvMode} |
| 369 | onValueChange={(value) => { |
| 370 | if (value === "previous-worktree") { |
| 371 | onUsePreviousWorktree(); |
| 372 | return; |
| 373 | } |
| 374 | onEnvModeChange(value as EnvMode); |
| 375 | }} |
| 376 | > |
| 377 | <MenuRadioItem disabled={envModeLocked || forceNewWorktree} value="local" closeOnClick> |
| 378 | <span className="flex min-w-0 items-center gap-1.5"> |
| 379 | {activeWorktreePath ? ( |
| 380 | <FolderGitIcon className="size-3" /> |
| 381 | ) : ( |
| 382 | <FolderIcon className="size-3" /> |
| 383 | )} |
| 384 | <MiddleTruncate value={resolveCurrentWorkspaceLabel(activeWorktreePath)} /> |
| 385 | </span> |
| 386 | </MenuRadioItem> |
| 387 | <MenuRadioItem disabled={envModeLocked} value="worktree" closeOnClick> |
| 388 | <span className="flex min-w-0 items-center gap-1.5"> |
| 389 | <FolderGit2Icon className="size-3" /> |
| 390 | <span className="min-w-0 truncate">{resolveEnvModeLabel("worktree")}</span> |
| 391 | </span> |
| 392 | </MenuRadioItem> |
| 393 | {previousWorktreeLabel ? ( |
| 394 | <MenuRadioItem disabled={envModeLocked} value="previous-worktree" closeOnClick> |
| 395 | <PreviousWorktreeItemContent branch={previousWorktreeBranch} /> |
| 396 | </MenuRadioItem> |
| 397 | ) : null} |
| 398 | </MenuRadioGroup> |
| 399 | </MenuGroup> |
| 400 | </MenuPopup> |
| 401 | </Menu> |
| 402 | ); |
| 403 | }); |
| 404 | |
| 405 | const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180; |
| 406 | const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; |
| 407 | const COMPOSER_CONTEXT_LABEL_SELECTOR = "[data-composer-label]"; |
| 408 | |
| 409 | /** |
| 410 | * The width a label takes when shown, clipped parts included. |
| 411 | * |
| 412 | * Text keeps its full width when its box clips it, so each text run measures |
| 413 | * whole. A label can hold more than one run (MiddleTruncate splits a branch |
| 414 | * into a head and a tail), so the runs are added. Reading one element's |
| 415 | * scrollWidth drops the tail when the label is hidden or squeezed, and the |
| 416 | * strip then flips between labels and icons on every measure. |
| 417 | * |
| 418 | * A shown label never grows past its motion span's max width, so longer text |
| 419 | * reserves only that much. |
| 420 | */ |
| 421 | function labelTextWidth(label: HTMLElement, range: Range): number { |
| 422 | const walker = document.createTreeWalker(label, NodeFilter.SHOW_TEXT); |
| 423 | let width = 0; |
| 424 | for (let node = walker.nextNode(); node; node = walker.nextNode()) { |
| 425 | range.selectNodeContents(node); |
| 426 | width += range.getBoundingClientRect().width; |
| 427 | } |
| 428 | const motion = label.querySelector<HTMLElement>("[data-composer-label-motion]"); |
| 429 | const maxWidth = motion ? Number.parseFloat(getComputedStyle(motion).maxWidth) : Number.NaN; |
| 430 | return Number.isFinite(maxWidth) ? Math.min(width, maxWidth) : width; |
| 431 | } |
| 432 | |
| 433 | /** |
| 434 | * Collapse the strip's labels to icons only when the text no longer fits. |
| 435 | * |
| 436 | * Hidden labels stay measurable because their inner text keeps its natural |
| 437 | * width while the outer layout box collapses. This lets every pass recompute |
| 438 | * the expanded width without remembered values that could go stale or latch |
| 439 | * the strip compact. A small hysteresis keeps the boundary from flapping. |
| 440 | */ |
| 441 | function useLabelsOverflow(element: HTMLDivElement | null): boolean { |
| 442 | const [overflows, setOverflows] = useState(false); |
| 443 | const pendingLabelRectsRef = useRef<Map<HTMLElement, DOMRect> | null>(null); |
| 444 | const labelAnimationsRef = useRef(new Map<HTMLElement, Animation>()); |
| 445 | // A render-synced mirror instead of useEffectEvent: the compiler memoizes |
| 446 | // the event callback, which left observers reading the first render's null |
| 447 | // element forever. |
| 448 | const stateRef = useRef({ element, overflows }); |
| 449 | stateRef.current = { element, overflows }; |
| 450 | |
| 451 | const measure = useCallback(() => { |
| 452 | const { element: current, overflows: compact } = stateRef.current; |
| 453 | if (!current) return; |
| 454 | const available = current.clientWidth; |
| 455 | if (available === 0) return; |
| 456 | // flex-1 stretches the groups to fill the strip, so their own boxes always |
| 457 | // measure "full". Sum the laid-out content instead, skipping hidden form |
| 458 | // artifacts and other out-of-flow nodes. |
| 459 | const contentWidth = (parent: Element): number => { |
| 460 | const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0; |
| 461 | let width = 0; |
| 462 | let counted = 0; |
| 463 | for (const child of parent.children) { |
| 464 | if (!(child instanceof HTMLElement)) continue; |
| 465 | if (child.offsetWidth === 0) continue; |
| 466 | const style = getComputedStyle(child); |
| 467 | const position = style.position; |
| 468 | if (position === "absolute" || position === "fixed") continue; |
| 469 | width += |
| 470 | child.offsetWidth + |
| 471 | (Number.parseFloat(style.marginInlineStart) || 0) + |
| 472 | (Number.parseFloat(style.marginInlineEnd) || 0); |
| 473 | counted += 1; |
| 474 | } |
| 475 | return width + gap * Math.max(0, counted - 1); |
| 476 | }; |
| 477 | const stripGap = Number.parseFloat(getComputedStyle(current).columnGap) || 0; |
| 478 | let needed = 0; |
| 479 | let groups = 0; |
| 480 | for (const child of current.children) { |
| 481 | if (!(child instanceof HTMLElement)) continue; |
| 482 | // The host itself flexes into all remaining room. Reserve the natural |
| 483 | // width of the controls inside it, blocks in overflow included, so Git |
| 484 | // labels compact before squeezing out the model picker. Reserving only |
| 485 | // the visible controls would let the labels expand into room the |
| 486 | // composer just freed, shrink the host, and hide the controls again. |
| 487 | const hostedControls = child.matches('[data-chat-resting-composer-controls-host="true"]') |
| 488 | ? child.querySelector<HTMLElement>('[data-chat-composer-resting-controls="true"]') |
| 489 | : null; |
| 490 | const hostedMeasurement = hostedControls |
| 491 | ? measureRestingComposerControls(hostedControls) |
| 492 | : null; |
| 493 | const width = hostedMeasurement |
| 494 | ? resolveRestingComposerControlsNaturalWidth(hostedMeasurement) |
| 495 | : contentWidth(hostedControls ?? child); |
| 496 | if (width <= 1) continue; |
| 497 | groups += 1; |
| 498 | needed += width; |
| 499 | } |
| 500 | needed += stripGap * Math.max(0, groups - 1); |
| 501 | const range = document.createRange(); |
| 502 | for (const label of current.querySelectorAll<HTMLElement>("[data-composer-label]")) { |
| 503 | // Subtract the visible width even during an animation. The content |
| 504 | // sum already includes it; only the hidden text needs reserving. |
| 505 | needed += Math.max(0, labelTextWidth(label, range) - label.getBoundingClientRect().width); |
| 506 | } |
| 507 | const nextOverflows = resolveContextStripLabelsCompact({ |
| 508 | compact, |
| 509 | neededWidth: needed, |
| 510 | availableWidth: available, |
| 511 | }); |
| 512 | if (nextOverflows !== compact) { |
| 513 | pendingLabelRectsRef.current = new Map( |
| 514 | Array.from(current.querySelectorAll<HTMLElement>(COMPOSER_CONTEXT_LABEL_SELECTOR)).map( |
| 515 | (label) => [label, label.getBoundingClientRect()], |
| 516 | ), |
| 517 | ); |
| 518 | } |
| 519 | setOverflows(nextOverflows); |
| 520 | }, []); |
| 521 | |
| 522 | useLayoutEffect(() => { |
| 523 | const previousRects = pendingLabelRectsRef.current; |
| 524 | if (!previousRects) return; |
| 525 | pendingLabelRectsRef.current = null; |
| 526 | |
| 527 | for (const animation of labelAnimationsRef.current.values()) { |
| 528 | animation.cancel(); |
| 529 | } |
| 530 | labelAnimationsRef.current.clear(); |
| 531 | |
| 532 | if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; |
| 533 | |
| 534 | for (const [label, previousRect] of previousRects) { |
| 535 | if (!label.isConnected) continue; |
| 536 | const nextWidth = label.getBoundingClientRect().width; |
| 537 | if (Math.abs(previousRect.width - nextWidth) < 0.5) continue; |
| 538 | |
| 539 | // Animate the space occupied by each label so flex layout keeps the |
| 540 | // trailing controls anchored. Translating the whole group after its |
| 541 | // width snaps sends expanded text beyond the strip's right edge. |
| 542 | const animation = label.animate( |
| 543 | [ |
| 544 | { width: `${previousRect.width}px`, maxWidth: `${previousRect.width}px` }, |
| 545 | { width: `${nextWidth}px`, maxWidth: `${nextWidth}px` }, |
| 546 | ], |
| 547 | { |
| 548 | duration: COMPOSER_CONTEXT_MOTION_DURATION_MS, |
| 549 | easing: COMPOSER_CONTEXT_MOTION_EASING, |
| 550 | fill: "backwards", |
| 551 | }, |
| 552 | ); |
| 553 | labelAnimationsRef.current.set(label, animation); |
| 554 | animation.addEventListener( |
| 555 | "finish", |
| 556 | () => { |
| 557 | if (labelAnimationsRef.current.get(label) === animation) { |
| 558 | labelAnimationsRef.current.delete(label); |
| 559 | } |
| 560 | }, |
| 561 | { once: true }, |
| 562 | ); |
| 563 | } |
| 564 | }, [overflows]); |
| 565 | |
| 566 | useEffect( |
| 567 | () => () => { |
| 568 | for (const animation of labelAnimationsRef.current.values()) { |
| 569 | animation.cancel(); |
| 570 | } |
| 571 | }, |
| 572 | [], |
| 573 | ); |
| 574 | |
| 575 | // Label widths can change without the strip box moving (font family or |
| 576 | // size preferences), so re-measure on every render as well as on resize |
| 577 | // and font loads. |
| 578 | useLayoutEffect(() => { |
| 579 | measure(); |
| 580 | }); |
| 581 | |
| 582 | useEffect(() => { |
| 583 | if (!element) return; |
| 584 | const stopObserving = observeResize(element, measure); |
| 585 | document.fonts.addEventListener("loadingdone", measure); |
| 586 | return () => { |
| 587 | stopObserving(); |
| 588 | document.fonts.removeEventListener("loadingdone", measure); |
| 589 | }; |
| 590 | }, [element, measure]); |
| 591 | |
| 592 | return overflows; |
| 593 | } |
| 594 | |
| 595 | export const BranchToolbar = memo(function BranchToolbar({ |
| 596 | layout = "composer", |
| 597 | panelSection = "all", |
| 598 | forceNewWorktree = false, |
| 599 | ref, |
| 600 | environmentId, |
| 601 | threadId, |
| 602 | showGitControls, |
| 603 | draftId, |
| 604 | onEnvModeChange, |
| 605 | envMode, |
| 606 | activeThreadBranchOverride, |
| 607 | onActiveThreadBranchOverrideChange, |
| 608 | startFromOrigin, |
| 609 | onStartFromOriginChange, |
| 610 | autoEnvironmentLabel, |
| 611 | onAutoEnvironment, |
| 612 | envLocked, |
| 613 | onCheckoutPullRequestRequest, |
| 614 | onComposerFocusRequest, |
| 615 | availableEnvironments, |
| 616 | onEnvironmentChange, |
| 617 | composerControlsHostRef, |
| 618 | contextStripVisible = true, |
| 619 | }: BranchToolbarProps) { |
| 620 | const branchSelectorRef = useRef<BranchToolbarBranchSelectorHandle>(null); |
| 621 | const threadRef = useMemo( |
| 622 | () => scopeThreadRef(environmentId, threadId), |
| 623 | [environmentId, threadId], |
| 624 | ); |
| 625 | const draftThread = useComposerDraftStore((store) => |
| 626 | draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), |
| 627 | ); |
| 628 | const serverThread = useThreadShell(threadRef); |
| 629 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 630 | const activeProjectRef = serverThread |
| 631 | ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) |
| 632 | : draftThread |
| 633 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) |
| 634 | : null; |
| 635 | const activeProject = useProject(activeProjectRef); |
| 636 | const hasActiveThread = serverThread !== null || draftThread !== null; |
| 637 | const activeWorktreePath = forceNewWorktree |
| 638 | ? null |
| 639 | : (serverThread?.worktreePath ?? draftThread?.worktreePath ?? null); |
| 640 | const effectiveEnvMode = forceNewWorktree ? "worktree" : envMode; |
| 641 | const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null); |
| 642 | |
| 643 | // "Previous worktree" hops a draft into the most recently active worktree |
| 644 | // of this project — the "keep going where I just was" follow-up flow. Only |
| 645 | // drafts can hop; started server threads have their workspace pinned. |
| 646 | const canUsePreviousWorktree = |
| 647 | draftThread !== null && serverThread === null && !envModeLocked && !forceNewWorktree; |
| 648 | const projectRefsForWorktreeLookup = useMemo( |
| 649 | () => (canUsePreviousWorktree && activeProjectRef ? [activeProjectRef] : []), |
| 650 | [canUsePreviousWorktree, activeProjectRef], |
| 651 | ); |
| 652 | const projectThreads = useThreadShellsForProjectRefs(projectRefsForWorktreeLookup); |
| 653 | const previousWorktreeSeed = useMemo( |
| 654 | () => |
| 655 | canUsePreviousWorktree |
| 656 | ? resolvePreviousWorktreeSeed({ |
| 657 | threads: projectThreads, |
| 658 | currentWorktreePath: activeWorktreePath, |
| 659 | }) |
| 660 | : null, |
| 661 | [activeWorktreePath, canUsePreviousWorktree, projectThreads], |
| 662 | ); |
| 663 | const previousWorktreeLabel = previousWorktreeSeed |
| 664 | ? resolvePreviousWorktreeLabel(previousWorktreeSeed) |
| 665 | : null; |
| 666 | const onUsePreviousWorktree = useCallback(() => { |
| 667 | if (!previousWorktreeSeed || !activeProjectRef) return; |
| 668 | // Same shape the branch selector writes when picking a branch that |
| 669 | // already lives in a worktree: point the draft at the existing tree. |
| 670 | setDraftThreadContext(draftId ?? threadRef, { |
| 671 | branch: previousWorktreeSeed.branch, |
| 672 | worktreePath: previousWorktreeSeed.worktreePath, |
| 673 | envMode: "worktree", |
| 674 | projectRef: activeProjectRef, |
| 675 | }); |
| 676 | }, [activeProjectRef, draftId, previousWorktreeSeed, setDraftThreadContext, threadRef]); |
| 677 | |
| 678 | useImperativeHandle( |
| 679 | ref, |
| 680 | () => ({ |
| 681 | openBranchPicker: () => branchSelectorRef.current?.open(), |
| 682 | usePreviousWorktree: () => { |
| 683 | if (!showGitControls || !canUsePreviousWorktree || !previousWorktreeSeed) return; |
| 684 | onUsePreviousWorktree(); |
| 685 | onComposerFocusRequest?.(); |
| 686 | }, |
| 687 | }), |
| 688 | [ |
| 689 | canUsePreviousWorktree, |
| 690 | onComposerFocusRequest, |
| 691 | onUsePreviousWorktree, |
| 692 | previousWorktreeSeed, |
| 693 | showGitControls, |
| 694 | ], |
| 695 | ); |
| 696 | |
| 697 | const showEnvironmentPicker = Boolean( |
| 698 | availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, |
| 699 | ); |
| 700 | const activeEnvironmentOption = |
| 701 | availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null; |
| 702 | const showEnvironmentIndicator = shouldShowEnvironmentIndicator({ |
| 703 | activeEnvironment: activeEnvironmentOption, |
| 704 | canPickEnvironment: showEnvironmentPicker, |
| 705 | }); |
| 706 | const [stripElement, setStripElement] = useState<HTMLDivElement | null>(null); |
| 707 | const labelsOverflow = useLabelsOverflow(stripElement); |
| 708 | |
| 709 | if (!hasActiveThread || !activeProject) return null; |
| 710 | |
| 711 | if (layout === "panel") { |
| 712 | return ( |
| 713 | <div className="flex w-full flex-col" data-thread-panel-run-context> |
| 714 | {panelSection !== "branch" ? ( |
| 715 | <RunContextSelector |
| 716 | displayMode="panel" |
| 717 | workspaceRoot={activeProject.workspaceRoot} |
| 718 | forceNewWorktree={forceNewWorktree} |
| 719 | autoEnvironmentLabel={autoEnvironmentLabel} |
| 720 | onAutoEnvironment={onAutoEnvironment} |
| 721 | envLocked={envLocked} |
| 722 | envModeLocked={envModeLocked} |
| 723 | environmentId={environmentId} |
| 724 | availableEnvironments={availableEnvironments} |
| 725 | showEnvironmentPicker={showEnvironmentPicker} |
| 726 | showEnvironmentIndicator={activeEnvironmentOption !== null} |
| 727 | onEnvironmentChange={onEnvironmentChange} |
| 728 | effectiveEnvMode={effectiveEnvMode} |
| 729 | activeWorktreePath={activeWorktreePath} |
| 730 | onEnvModeChange={onEnvModeChange} |
| 731 | previousWorktreeLabel={previousWorktreeLabel} |
| 732 | previousWorktreeBranch={previousWorktreeSeed?.branch ?? null} |
| 733 | onUsePreviousWorktree={onUsePreviousWorktree} |
| 734 | /> |
| 735 | ) : null} |
| 736 | {panelSection !== "workspace" ? ( |
| 737 | <BranchToolbarBranchSelector |
| 738 | displayMode="panel" |
| 739 | className="w-full" |
| 740 | environmentId={environmentId} |
| 741 | threadId={threadId} |
| 742 | {...(draftId ? { draftId } : {})} |
| 743 | envLocked={envLocked} |
| 744 | effectiveEnvModeOverride={effectiveEnvMode} |
| 745 | {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})} |
| 746 | {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} |
| 747 | startFromOrigin={startFromOrigin} |
| 748 | onStartFromOriginChange={onStartFromOriginChange} |
| 749 | {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} |
| 750 | {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} |
| 751 | /> |
| 752 | ) : null} |
| 753 | </div> |
| 754 | ); |
| 755 | } |
| 756 | |
| 757 | return ( |
| 758 | <ComposerSurface.ContextStrip |
| 759 | ref={setStripElement} |
| 760 | data-compact={labelsOverflow ? "" : undefined} |
| 761 | className={cn( |
| 762 | "gap-1 text-xs font-normal text-muted-foreground/70", |
| 763 | // A non-Git strip with no visible composer controls should occupy no |
| 764 | // space, but its host must retain a prospective width so controls can |
| 765 | // become visible again when the chat view grows. |
| 766 | !contextStripVisible && "pointer-events-none invisible absolute inset-x-0 top-full", |
| 767 | )} |
| 768 | > |
| 769 | {showGitControls ? ( |
| 770 | <div className="contents @3xl/composer-surface:hidden"> |
| 771 | <RunContextSelector |
| 772 | forceNewWorktree={forceNewWorktree} |
| 773 | autoEnvironmentLabel={autoEnvironmentLabel} |
| 774 | onAutoEnvironment={onAutoEnvironment} |
| 775 | envLocked={envLocked} |
| 776 | envModeLocked={envModeLocked} |
| 777 | environmentId={environmentId} |
| 778 | availableEnvironments={availableEnvironments} |
| 779 | showEnvironmentPicker={showEnvironmentPicker} |
| 780 | showEnvironmentIndicator={showEnvironmentIndicator} |
| 781 | onEnvironmentChange={onEnvironmentChange} |
| 782 | effectiveEnvMode={effectiveEnvMode} |
| 783 | activeWorktreePath={activeWorktreePath} |
| 784 | onEnvModeChange={onEnvModeChange} |
| 785 | previousWorktreeLabel={previousWorktreeLabel} |
| 786 | previousWorktreeBranch={previousWorktreeSeed?.branch ?? null} |
| 787 | onUsePreviousWorktree={onUsePreviousWorktree} |
| 788 | /> |
| 789 | </div> |
| 790 | ) : null} |
| 791 | {showGitControls || showEnvironmentIndicator ? ( |
| 792 | <div |
| 793 | className={cn( |
| 794 | "min-h-7 min-w-10 items-center gap-1 sm:min-h-6", |
| 795 | showGitControls ? "hidden @3xl/composer-surface:flex" : "flex", |
| 796 | composerControlsHostRef ? "shrink" : "flex-1", |
| 797 | )} |
| 798 | > |
| 799 | {showEnvironmentIndicator && availableEnvironments && ( |
| 800 | <> |
| 801 | <BranchToolbarEnvironmentSelector |
| 802 | autoEnvironmentLabel={autoEnvironmentLabel} |
| 803 | onAutoEnvironment={onAutoEnvironment} |
| 804 | envLocked={envLocked} |
| 805 | environmentId={environmentId} |
| 806 | availableEnvironments={availableEnvironments} |
| 807 | {...(showEnvironmentPicker && onEnvironmentChange ? { onEnvironmentChange } : {})} |
| 808 | /> |
| 809 | {showGitControls ? ( |
| 810 | <Separator |
| 811 | orientation="vertical" |
| 812 | className="mx-0.5 h-3.5!" |
| 813 | data-composer-context-control |
| 814 | /> |
| 815 | ) : null} |
| 816 | </> |
| 817 | )} |
| 818 | {showGitControls ? ( |
| 819 | <BranchToolbarEnvModeSelector |
| 820 | forceNewWorktree={forceNewWorktree} |
| 821 | envLocked={envModeLocked} |
| 822 | effectiveEnvMode={effectiveEnvMode} |
| 823 | activeWorktreePath={activeWorktreePath} |
| 824 | onEnvModeChange={onEnvModeChange} |
| 825 | previousWorktreeLabel={previousWorktreeLabel} |
| 826 | previousWorktreeBranch={previousWorktreeSeed?.branch ?? null} |
| 827 | onUsePreviousWorktree={onUsePreviousWorktree} |
| 828 | /> |
| 829 | ) : null} |
| 830 | </div> |
| 831 | ) : null} |
| 832 | |
| 833 | {composerControlsHostRef ? ( |
| 834 | // The host takes whatever the workspace and branch controls leave |
| 835 | // over, in both strip layouts, so a collapsed composer can show its |
| 836 | // model and mode controls wherever they fit. |
| 837 | <div |
| 838 | ref={composerControlsHostRef} |
| 839 | data-composer-context-control |
| 840 | data-chat-resting-composer-controls-host="true" |
| 841 | className="flex min-w-0 flex-1 items-center justify-start overflow-x-clip overflow-y-visible" |
| 842 | /> |
| 843 | ) : null} |
| 844 | |
| 845 | {showGitControls ? ( |
| 846 | <BranchToolbarBranchSelector |
| 847 | forceNewWorktree={forceNewWorktree} |
| 848 | ref={branchSelectorRef} |
| 849 | className="min-w-0 flex-initial justify-end @3xl/composer-surface:ml-auto" |
| 850 | environmentId={environmentId} |
| 851 | threadId={threadId} |
| 852 | {...(draftId ? { draftId } : {})} |
| 853 | envLocked={envLocked} |
| 854 | effectiveEnvModeOverride={effectiveEnvMode} |
| 855 | {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})} |
| 856 | {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} |
| 857 | startFromOrigin={startFromOrigin} |
| 858 | onStartFromOriginChange={onStartFromOriginChange} |
| 859 | {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} |
| 860 | {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} |
| 861 | /> |
| 862 | ) : null} |
| 863 | </ComposerSurface.ContextStrip> |
| 864 | ); |
| 865 | }); |