1import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
2import { readLocalApi } from "../localApi";
3import { stackedThreadToast, toastManager } from "./ui/toast";
4import { ComposerContextLabel } from "./ComposerContextLabel";
5import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
6import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
7import {
8 ChevronDownIcon,
9 FolderGit2Icon,
10 FolderGitIcon,
11 FolderIcon,
12 ScaleIcon,
13} from "lucide-react";
14import {
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
27import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
28import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
29import { useProject, useThreadShell, useThreadShellsForProjectRefs } from "../state/entities";
30import {
31 type EnvMode,
32 type EnvironmentOption,
33 resolveContextStripLabelsCompact,
34 resolveCurrentWorkspaceLabel,
35 resolveEnvModeLabel,
36 resolveLockedWorkspaceLabel,
37 resolvePreviousWorktreeLabel,
38 resolvePreviousWorktreeSeed,
39 shouldShowEnvironmentIndicator,
40} from "./BranchToolbar.logic";
41import {
42 BranchToolbarBranchSelector,
43 type BranchToolbarBranchSelectorHandle,
44} from "./BranchToolbarBranchSelector";
45import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector";
46import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector";
47import { PreviousWorktreeItemContent } from "./PreviousWorktreeItemContent";
48import { ThreadDetailsControl } from "./chat/ThreadDetailsControl";
49import {
50 THREAD_DETAILS_PANEL_ICON_CLASS,
51 THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS,
52} from "./chat/threadDetailsPanelStyles";
53import { ComposerControl } from "./chat/ComposerControl";
54import {
55 Menu,
56 MenuGroup,
57 MenuGroupLabel,
58 MenuPopup,
59 MenuRadioGroup,
60 MenuRadioItem,
61 MenuSeparator,
62 MenuTrigger,
63} from "./ui/menu";
64import { Separator } from "./ui/separator";
65import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
66import { MiddleTruncate } from "./ui/middle-truncate";
67import { ComposerSurface } from "./chat/ComposerSurface";
68import { useComposerMenuProps } from "./chat/composerEventScope";
69import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement";
70import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout";
71import { cn } from "~/lib/utils";
72import { observeResize } from "~/lib/observeResize";
73
74export interface BranchToolbarHandle {
75 openBranchPicker: () => void;
76 usePreviousWorktree: () => void;
77}
78
79interface 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
106function 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
141interface 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
162const 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
405const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180;
406const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
407const 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 */
421function 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 */
441function 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
595export 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});