1import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
2import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions";
3import { readLocalApi } from "../localApi";
4import { stackedThreadToast, toastManager } from "./ui/toast";
5import { ComposerContextLabel } from "./ComposerContextLabel";
6import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
7import type { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
8import { FolderGit2Icon, FolderGitIcon, FolderIcon, PlusIcon, ScaleIcon } from "lucide-react";
9import {
10 type Ref,
11 type MouseEvent as ReactMouseEvent,
12 type ReactNode,
13 memo,
14 useImperativeHandle,
15 useRef,
16 useState,
17} from "react";
18import { flushSync } from "react-dom";
19
20import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
21import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
22import { useProject, useThreadShellsForProjectRefs } from "../state/entities";
23import {
24 ADD_TO_MACHINE_LABEL_CLASS,
25 type EnvMode,
26 type EnvironmentOption,
27 resolveContextStripLabelsCompact,
28 resolveCurrentWorkspaceLabel,
29 resolveEnvModeLabel,
30 resolveLockedWorkspaceLabel,
31 resolvePreviousWorktreeLabel,
32 resolvePreviousWorktreeSeed,
33 shouldShowEnvironmentIndicator,
34} from "./BranchToolbar.logic";
35import {
36 BranchToolbarBranchSelector,
37 type BranchToolbarBranchSelectorHandle,
38} from "./BranchToolbarBranchSelector";
39import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector";
40import { openAddProjectToMachine } from "./AddProjectToMachineDialog";
41import { type AddProjectTarget, useAddProjectTargets } from "../hooks/useAddProjectTargets";
42import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector";
43import { PreviousWorktreeItemContent } from "./PreviousWorktreeItemContent";
44import { ThreadDetailsControl } from "./chat/ThreadDetailsControl";
45import {
46 THREAD_DETAILS_PANEL_ICON_CLASS,
47 THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS,
48} from "./chat/threadDetailsPanelStyles";
49import { ComposerControl } from "./chat/ComposerControl";
50import {
51 Menu,
52 MenuGroup,
53 MenuGroupLabel,
54 MenuItem,
55 MenuPopup,
56 MenuRadioGroup,
57 MenuRadioItem,
58 MenuSeparator,
59 MenuSub,
60 MenuSubPopup,
61 MenuSubTrigger,
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 { cn } from "~/lib/utils";
70import { observeResize } from "~/lib/observeResize";
71
72export interface BranchToolbarHandle {
73 openBranchPicker: () => void;
74 usePreviousWorktree: () => void;
75}
76
77interface BranchToolbarProps {
78 layout?: "composer" | "panel";
79 panelSection?: "all" | "workspace" | "branch";
80 forceNewWorktree?: boolean;
81 ref?: Ref<BranchToolbarHandle>;
82 environmentId: EnvironmentId;
83 threadId: ThreadId;
84 projectId: ProjectId;
85 worktreePath: string | null;
86 isServerThread: boolean;
87 showGitControls: boolean;
88 draftId?: DraftId;
89 onEnvModeChange: (mode: EnvMode) => void;
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 /** Shown at the end of the composer strip, such as the new thread's permissions. */
101 trailing?: ReactNode;
102 availableEnvironments?: readonly EnvironmentOption[];
103 onEnvironmentChange?: (environmentId: EnvironmentId) => void;
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 const clipboardPath = formatFilePathForClipboard(workspacePath);
119 void writeTextToClipboard(clipboardPath, "workspace path").then(
120 (didCopy) => {
121 if (didCopy) {
122 toastManager.add({
123 type: "success",
124 title: "Path copied",
125 description: clipboardPath,
126 });
127 }
128 },
129 (error: unknown) => {
130 toastManager.add(
131 stackedThreadToast({
132 type: "error",
133 title: "Failed to copy path",
134 description: error instanceof Error ? error.message : "An error occurred.",
135 }),
136 );
137 },
138 );
139 });
140}
141
142interface RunContextSelectorProps {
143 displayMode?: "toolbar" | "panel";
144 workspaceRoot?: string;
145 forceNewWorktree: boolean;
146 autoEnvironmentLabel?: string | undefined;
147 onAutoEnvironment?: (() => void) | undefined;
148 envLocked: boolean;
149 envModeLocked: boolean;
150 environmentId: EnvironmentId;
151 availableEnvironments: readonly EnvironmentOption[] | undefined;
152 showEnvironmentPicker: boolean;
153 showEnvironmentIndicator: boolean;
154 onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined;
155 effectiveEnvMode: EnvMode;
156 activeWorktreePath: string | null;
157 onEnvModeChange: (mode: EnvMode) => void;
158 previousWorktreeLabel: string | null;
159 previousWorktreeBranch: string | null;
160 onUsePreviousWorktree: () => void;
161 addTargets: readonly AddProjectTarget[];
162 onAddToMachine: (environmentId: EnvironmentId) => void;
163}
164
165const RunContextSelector = memo(function RunContextSelector({
166 displayMode = "toolbar",
167 workspaceRoot,
168 forceNewWorktree,
169 autoEnvironmentLabel,
170 onAutoEnvironment,
171 envLocked,
172 envModeLocked,
173 environmentId,
174 availableEnvironments,
175 showEnvironmentPicker,
176 showEnvironmentIndicator,
177 onEnvironmentChange,
178 effectiveEnvMode,
179 activeWorktreePath,
180 onEnvModeChange,
181 previousWorktreeLabel,
182 previousWorktreeBranch,
183 onUsePreviousWorktree,
184 addTargets,
185 onAddToMachine,
186}: RunContextSelectorProps) {
187 const composerFloatingLayerProps = useComposerMenuProps();
188 const activeEnvironment =
189 availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null;
190 const WorkspaceIcon =
191 effectiveEnvMode === "worktree"
192 ? FolderGit2Icon
193 : activeWorktreePath
194 ? FolderGitIcon
195 : FolderIcon;
196 const workspaceLabel = forceNewWorktree
197 ? resolveEnvModeLabel("worktree")
198 : envModeLocked
199 ? resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode)
200 : effectiveEnvMode === "worktree"
201 ? resolveEnvModeLabel("worktree")
202 : resolveCurrentWorkspaceLabel(activeWorktreePath);
203 const isPanel = displayMode === "panel";
204 const isLocked = envLocked || (envModeLocked && (!isPanel || !showEnvironmentPicker));
205 const workspacePath =
206 forceNewWorktree || (effectiveEnvMode === "worktree" && !activeWorktreePath)
207 ? null
208 : (activeWorktreePath ?? workspaceRoot);
209 const handleContextMenu = (event: ReactMouseEvent) => {
210 if (isPanel && workspacePath) {
211 showWorkspaceContextMenu(event, workspacePath);
212 }
213 };
214 const workspaceIcon = (
215 <Tooltip>
216 <TooltipTrigger render={<span className="inline-flex shrink-0" />}>
217 <WorkspaceIcon className={isPanel ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3 shrink-0"} />
218 </TooltipTrigger>
219 <TooltipPopup>
220 {isPanel
221 ? forceNewWorktree
222 ? "Each model starts in its own worktree."
223 : (workspacePath ?? workspaceLabel)
224 : workspaceLabel}
225 </TooltipPopup>
226 </Tooltip>
227 );
228 const environmentIcon = (
229 <Tooltip>
230 <TooltipTrigger render={<span className="inline-flex shrink-0" />}>
231 {autoEnvironmentLabel ? (
232 <ScaleIcon
233 className={isPanel ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3 shrink-0"}
234 aria-hidden="true"
235 />
236 ) : (
237 <EnvironmentMachineIcon
238 kind={activeEnvironment?.machine ?? "server"}
239 // Wide machine glyphs need a little more box to match the square row icons.
240 className={
241 isPanel ? cn(THREAD_DETAILS_PANEL_ICON_CLASS, "-mx-px size-4.5") : "size-3 shrink-0"
242 }
243 />
244 )}
245 </TooltipTrigger>
246 <TooltipPopup>{autoEnvironmentLabel ?? activeEnvironment?.label ?? "Run on"}</TooltipPopup>
247 </Tooltip>
248 );
249 const icon = showEnvironmentIndicator ? environmentIcon : workspaceIcon;
250 const triggerContent = (
251 <>
252 {icon}
253 <ComposerContextLabel displayMode={displayMode}>
254 {autoEnvironmentLabel ??
255 (showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel)}
256 </ComposerContextLabel>
257 {/* Trailing in the panel, so every row label starts on the same edge. */}
258 {isPanel && showEnvironmentIndicator ? workspaceIcon : null}
259 </>
260 );
261
262 if (isLocked) {
263 return (
264 <span
265 className={cn(
266 "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-foreground/70 text-sm sm:h-6 sm:text-xs dark:text-muted-foreground",
267 isPanel && "max-w-full",
268 isPanel && THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS,
269 )}
270 onContextMenu={handleContextMenu}
271 aria-label={
272 isPanel
273 ? `Run context: ${[autoEnvironmentLabel ?? activeEnvironment?.label, workspaceLabel].filter(Boolean).join(", ")}`
274 : undefined
275 }
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={
297 isPanel
298 ? `Run context: ${[autoEnvironmentLabel ?? activeEnvironment?.label, workspaceLabel].filter(Boolean).join(", ")}`
299 : undefined
300 }
301 data-composer-context-control
302 data-composer-shortcut={[
303 showEnvironmentPicker && !envLocked ? "composer.host" : "",
304 !envModeLocked ? "composer.workspace" : "",
305 ].join(" ")}
306 >
307 {triggerContent}
308 </MenuTrigger>
309 <MenuPopup
310 align="start"
311 side={isPanel ? "bottom" : "top"}
312 className={
313 isPanel
314 ? "w-(--anchor-width)"
315 : previousWorktreeLabel
316 ? "w-[min(21rem,calc(100vw-2rem))]"
317 : undefined
318 }
319 {...(!isPanel ? composerFloatingLayerProps : {})}
320 >
321 {availableEnvironments &&
322 ((showEnvironmentPicker && onEnvironmentChange) || addTargets.length > 0) ? (
323 <>
324 <MenuGroup>
325 <MenuGroupLabel>Run on</MenuGroupLabel>
326 <MenuRadioGroup
327 value={autoEnvironmentLabel ? "auto" : environmentId}
328 onValueChange={(value) =>
329 value === "auto"
330 ? onAutoEnvironment?.()
331 : onEnvironmentChange?.(value as EnvironmentId)
332 }
333 >
334 {onAutoEnvironment && (
335 <MenuRadioItem
336 value="auto"
337 disabled={envLocked}
338 closeOnClick
339 onClick={() => {
340 if (autoEnvironmentLabel) onAutoEnvironment?.();
341 }}
342 >
343 <span className="flex min-w-0 items-center gap-1.5">
344 <ScaleIcon className="size-3" aria-hidden="true" />
345 <span className="min-w-0 truncate">
346 {autoEnvironmentLabel ?? "Auto balance"}
347 </span>
348 </span>
349 </MenuRadioItem>
350 )}
351 {availableEnvironments.map((env) => (
352 <MenuRadioItem
353 key={env.environmentId}
354 disabled={
355 envLocked || (!onEnvironmentChange && env.environmentId !== environmentId)
356 }
357 value={env.environmentId}
358 closeOnClick
359 >
360 <span className="flex min-w-0 items-center gap-1.5">
361 <EnvironmentMachineIcon kind={env.machine} className="size-3" />
362 <span className="min-w-0 truncate">{env.label}</span>
363 </span>
364 </MenuRadioItem>
365 ))}
366 </MenuRadioGroup>
367 {addTargets.length > 0 ? (
368 <MenuSub>
369 <MenuSubTrigger>
370 <span className={ADD_TO_MACHINE_LABEL_CLASS}>
371 <PlusIcon className="size-3" />
372 <span className="min-w-0 truncate">Add to another machine</span>
373 </span>
374 </MenuSubTrigger>
375 <MenuSubPopup {...(!isPanel ? composerFloatingLayerProps : {})}>
376 {addTargets.map((target) => (
377 <MenuItem
378 key={target.environmentId}
379 onClick={() => onAddToMachine(target.environmentId)}
380 >
381 <span className="flex min-w-0 items-center gap-1.5">
382 <EnvironmentMachineIcon kind={target.machine} className="size-3" />
383 <span className="min-w-0 truncate">{target.label}</span>
384 </span>
385 </MenuItem>
386 ))}
387 </MenuSubPopup>
388 </MenuSub>
389 ) : null}
390 </MenuGroup>
391 <MenuSeparator />
392 </>
393 ) : null}
394 <MenuGroup>
395 <MenuGroupLabel>Workspace</MenuGroupLabel>
396 <MenuRadioGroup
397 value={effectiveEnvMode}
398 onValueChange={(value) => {
399 if (value === "previous-worktree") {
400 onUsePreviousWorktree();
401 return;
402 }
403 onEnvModeChange(value as EnvMode);
404 }}
405 >
406 <MenuRadioItem disabled={envModeLocked || forceNewWorktree} value="local" closeOnClick>
407 <span className="flex min-w-0 items-center gap-1.5">
408 {activeWorktreePath ? (
409 <FolderGitIcon className="size-3" />
410 ) : (
411 <FolderIcon className="size-3" />
412 )}
413 <MiddleTruncate value={resolveCurrentWorkspaceLabel(activeWorktreePath)} />
414 </span>
415 </MenuRadioItem>
416 <MenuRadioItem disabled={envModeLocked} value="worktree" closeOnClick>
417 <span className="flex min-w-0 items-center gap-1.5">
418 <FolderGit2Icon className="size-3" />
419 <span className="min-w-0 truncate">{resolveEnvModeLabel("worktree")}</span>
420 </span>
421 </MenuRadioItem>
422 {previousWorktreeLabel ? (
423 <MenuRadioItem disabled={envModeLocked} value="previous-worktree" closeOnClick>
424 <PreviousWorktreeItemContent branch={previousWorktreeBranch} />
425 </MenuRadioItem>
426 ) : null}
427 </MenuRadioGroup>
428 </MenuGroup>
429 </MenuPopup>
430 </Menu>
431 );
432});
433
434const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180;
435const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
436const COMPOSER_CONTEXT_LABEL_SELECTOR = "[data-composer-label]";
437
438/** A hidden or squeezed label's scrollWidth loses its tail, causing label/icon flapping. */
439function labelTextWidth(label: HTMLElement, range: Range): number {
440 const walker = document.createTreeWalker(label, NodeFilter.SHOW_TEXT);
441 let width = 0;
442 for (let node = walker.nextNode(); node; node = walker.nextNode()) {
443 range.selectNodeContents(node);
444 width += range.getBoundingClientRect().width;
445 }
446 const motion = label.querySelector<HTMLElement>("[data-composer-label-motion]");
447 const maxWidth = motion ? Number.parseFloat(getComputedStyle(motion).maxWidth) : Number.NaN;
448 return Number.isFinite(maxWidth) ? Math.min(width, maxWidth) : width;
449}
450
451function animateLabelWidths(
452 previousRects: ReadonlyMap<HTMLElement, DOMRect>,
453 animations: Map<HTMLElement, Animation>,
454) {
455 for (const animation of animations.values()) animation.cancel();
456 animations.clear();
457 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
458
459 for (const [label, previousRect] of previousRects) {
460 if (!label.isConnected) continue;
461 const nextWidth = label.getBoundingClientRect().width;
462 if (Math.abs(previousRect.width - nextWidth) < 0.5) continue;
463
464 const animation = label.animate(
465 [
466 { width: `${previousRect.width}px`, maxWidth: `${previousRect.width}px` },
467 { width: `${nextWidth}px`, maxWidth: `${nextWidth}px` },
468 ],
469 {
470 duration: COMPOSER_CONTEXT_MOTION_DURATION_MS,
471 easing: COMPOSER_CONTEXT_MOTION_EASING,
472 fill: "backwards",
473 },
474 );
475 animations.set(label, animation);
476 animation.addEventListener(
477 "finish",
478 () => {
479 if (animations.get(label) === animation) animations.delete(label);
480 },
481 { once: true },
482 );
483 }
484}
485
486/**
487 * Whether the strip's labels must collapse to icons. The strip is measured when
488 * it, a control group or a hosted control resizes, when its text or children
489 * change, and when fonts load, which covers everything the widths depend on.
490 */
491function useLabelsOverflow(): readonly [
492 attach: (element: HTMLDivElement | null) => (() => void) | undefined,
493 overflows: boolean,
494] {
495 const [overflows, setOverflows] = useState(false);
496
497 const attach = (current: HTMLDivElement | null) => {
498 if (!current) return;
499 const labelAnimations = new Map<HTMLElement, Animation>();
500
501 const measure = () => {
502 // The committed attribute, not render state, so a stale closure cannot misread it.
503 const compact = current.hasAttribute("data-compact");
504 const stripWidth = current.clientWidth;
505 if (stripWidth === 0) return;
506 const stripStyle = getComputedStyle(current);
507 const available =
508 stripWidth -
509 (Number.parseFloat(stripStyle.paddingInlineStart) || 0) -
510 (Number.parseFloat(stripStyle.paddingInlineEnd) || 0);
511 const contentWidth = (parent: Element): number => {
512 const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0;
513 let width = 0;
514 let counted = 0;
515 for (const child of parent.children) {
516 if (!(child instanceof HTMLElement)) continue;
517 if (child.offsetWidth === 0) continue;
518 const style = getComputedStyle(child);
519 const position = style.position;
520 if (position === "absolute" || position === "fixed") continue;
521 width +=
522 child.offsetWidth +
523 (Number.parseFloat(style.marginInlineStart) || 0) +
524 (Number.parseFloat(style.marginInlineEnd) || 0);
525 counted += 1;
526 }
527 return width + gap * Math.max(0, counted - 1);
528 };
529 const stripGap = Number.parseFloat(stripStyle.columnGap) || 0;
530 let needed = 0;
531 let groups = 0;
532 for (const child of current.children) {
533 if (!(child instanceof HTMLElement)) continue;
534 const width = contentWidth(child);
535 if (width <= 1) continue;
536 groups += 1;
537 needed += width;
538 }
539 needed += stripGap * Math.max(0, groups - 1);
540 const range = document.createRange();
541 for (const label of current.querySelectorAll<HTMLElement>("[data-composer-label]")) {
542 needed += Math.max(0, labelTextWidth(label, range) - label.getBoundingClientRect().width);
543 }
544 const nextOverflows = resolveContextStripLabelsCompact({
545 compact,
546 neededWidth: needed,
547 availableWidth: available,
548 });
549 if (nextOverflows === compact) return;
550 const previousRects = new Map(
551 Array.from(current.querySelectorAll<HTMLElement>(COMPOSER_CONTEXT_LABEL_SELECTOR)).map(
552 (label) => [label, label.getBoundingClientRect()] as const,
553 ),
554 );
555 flushSync(() => setOverflows(nextOverflows));
556 animateLabelWidths(previousRects, labelAnimations);
557 };
558
559 let stopResize = () => {};
560 const observeSizes = () => {
561 stopResize();
562 stopResize = observeResize(
563 [
564 current,
565 ...Array.from(current.children),
566 ...Array.from(current.querySelectorAll(COMPOSER_CONTEXT_LABEL_SELECTOR)),
567 ],
568 measure,
569 );
570 };
571 observeSizes();
572 // Text and control changes alter natural widths without resizing a hidden
573 // or squeezed label, so they re-measure too. Only added or removed elements
574 // can change the observed set; re-subscribing delivers a measure itself.
575 const mutations = new MutationObserver((records) => {
576 const elementsChanged = records.some((record) =>
577 [...record.addedNodes, ...record.removedNodes].some((node) => node instanceof Element),
578 );
579 if (elementsChanged) observeSizes();
580 else measure();
581 });
582 mutations.observe(current, { childList: true, characterData: true, subtree: true });
583 document.fonts.addEventListener("loadingdone", measure);
584 return () => {
585 stopResize();
586 mutations.disconnect();
587 document.fonts.removeEventListener("loadingdone", measure);
588 for (const animation of labelAnimations.values()) animation.cancel();
589 };
590 };
591
592 return [attach, overflows] as const;
593}
594
595export const BranchToolbar = memo(function BranchToolbar({
596 layout = "composer",
597 panelSection = "all",
598 forceNewWorktree = false,
599 ref,
600 environmentId,
601 threadId,
602 projectId,
603 worktreePath,
604 isServerThread,
605 showGitControls,
606 draftId,
607 onEnvModeChange,
608 envMode,
609 activeThreadBranchOverride,
610 onActiveThreadBranchOverrideChange,
611 startFromOrigin,
612 onStartFromOriginChange,
613 autoEnvironmentLabel,
614 onAutoEnvironment,
615 envLocked,
616 onCheckoutPullRequestRequest,
617 onComposerFocusRequest,
618 availableEnvironments,
619 onEnvironmentChange,
620 trailing,
621}: BranchToolbarProps) {
622 const branchSelectorRef = useRef<BranchToolbarBranchSelectorHandle>(null);
623 const threadRef = scopeThreadRef(environmentId, threadId);
624 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
625 // The composer retains resolved thread details while bootstrap cleanup can remove its shell.
626 const activeProjectRef = scopeProjectRef(environmentId, projectId);
627 const activeProject = useProject(activeProjectRef);
628 const activeWorktreePath = forceNewWorktree ? null : worktreePath;
629 const effectiveEnvMode = forceNewWorktree ? "worktree" : envMode;
630 const envModeLocked = envLocked || (isServerThread && activeWorktreePath !== null);
631
632 const canUsePreviousWorktree = !isServerThread && !envModeLocked && !forceNewWorktree;
633 const projectRefsForWorktreeLookup = canUsePreviousWorktree ? [activeProjectRef] : [];
634 const projectThreads = useThreadShellsForProjectRefs(projectRefsForWorktreeLookup);
635 const previousWorktreeSeed = canUsePreviousWorktree
636 ? resolvePreviousWorktreeSeed({
637 threads: projectThreads,
638 currentWorktreePath: activeWorktreePath,
639 })
640 : null;
641 const previousWorktreeLabel = previousWorktreeSeed
642 ? resolvePreviousWorktreeLabel(previousWorktreeSeed)
643 : null;
644 const onUsePreviousWorktree = () => {
645 if (!previousWorktreeSeed) return;
646 setDraftThreadContext(draftId ?? threadRef, {
647 branch: previousWorktreeSeed.branch,
648 worktreePath: previousWorktreeSeed.worktreePath,
649 envMode: "worktree",
650 projectRef: activeProjectRef,
651 });
652 };
653
654 useImperativeHandle(
655 ref,
656 () => ({
657 openBranchPicker: () => branchSelectorRef.current?.open(),
658 usePreviousWorktree: () => {
659 if (!showGitControls || !canUsePreviousWorktree || !previousWorktreeSeed) return;
660 onUsePreviousWorktree();
661 onComposerFocusRequest?.();
662 },
663 }),
664 [
665 canUsePreviousWorktree,
666 onComposerFocusRequest,
667 onUsePreviousWorktree,
668 previousWorktreeSeed,
669 showGitControls,
670 ],
671 );
672
673 const addTargets = useAddProjectTargets(activeProject, availableEnvironments);
674 const onAddToMachine = (targetEnvironmentId: EnvironmentId | null) =>
675 openAddProjectToMachine({ projectRef: activeProjectRef, targetEnvironmentId });
676 // A project on one machine still gets the picker when it can be added to another.
677 const hasMultipleEnvironments = (availableEnvironments?.length ?? 0) > 1 || addTargets.length > 0;
678 const showEnvironmentPicker = hasMultipleEnvironments && onEnvironmentChange !== undefined;
679 const activeEnvironmentOption =
680 availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null;
681 // Started threads get no handler but still show which machine they run on,
682 // matching the strip visibility ChatView computes from the same inputs.
683 const showEnvironmentIndicator = shouldShowEnvironmentIndicator({
684 activeEnvironment: activeEnvironmentOption,
685 canPickEnvironment: hasMultipleEnvironments,
686 });
687 const [attachStrip, labelsOverflow] = useLabelsOverflow();
688
689 if (!activeProject) return null;
690
691 if (layout === "panel") {
692 return (
693 <div className="flex w-full flex-col" data-thread-panel-run-context>
694 {panelSection !== "branch" ? (
695 <RunContextSelector
696 displayMode="panel"
697 workspaceRoot={activeProject.workspaceRoot}
698 forceNewWorktree={forceNewWorktree}
699 autoEnvironmentLabel={autoEnvironmentLabel}
700 onAutoEnvironment={onAutoEnvironment}
701 envLocked={envLocked}
702 envModeLocked={envModeLocked}
703 environmentId={environmentId}
704 availableEnvironments={availableEnvironments}
705 showEnvironmentPicker={showEnvironmentPicker}
706 showEnvironmentIndicator={activeEnvironmentOption !== null}
707 onEnvironmentChange={onEnvironmentChange}
708 effectiveEnvMode={effectiveEnvMode}
709 activeWorktreePath={activeWorktreePath}
710 onEnvModeChange={onEnvModeChange}
711 previousWorktreeLabel={previousWorktreeLabel}
712 previousWorktreeBranch={previousWorktreeSeed?.branch ?? null}
713 onUsePreviousWorktree={onUsePreviousWorktree}
714 addTargets={addTargets}
715 onAddToMachine={onAddToMachine}
716 />
717 ) : null}
718 {panelSection !== "workspace" ? (
719 <BranchToolbarBranchSelector
720 displayMode="panel"
721 className="w-full"
722 environmentId={environmentId}
723 threadId={threadId}
724 {...(draftId ? { draftId } : {})}
725 envLocked={envLocked}
726 effectiveEnvModeOverride={effectiveEnvMode}
727 {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})}
728 {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})}
729 startFromOrigin={startFromOrigin}
730 onStartFromOriginChange={onStartFromOriginChange}
731 {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})}
732 {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}
733 />
734 ) : null}
735 </div>
736 );
737 }
738
739 return (
740 <ComposerSurface.ContextStrip
741 ref={attachStrip}
742 data-compact={labelsOverflow ? "" : undefined}
743 className={cn("gap-1 text-xs font-normal text-foreground/70 dark:text-muted-foreground")}
744 >
745 {showGitControls ? (
746 <div className="contents @xl/composer-surface:hidden">
747 <RunContextSelector
748 forceNewWorktree={forceNewWorktree}
749 autoEnvironmentLabel={autoEnvironmentLabel}
750 onAutoEnvironment={onAutoEnvironment}
751 envLocked={envLocked}
752 envModeLocked={envModeLocked}
753 environmentId={environmentId}
754 availableEnvironments={availableEnvironments}
755 showEnvironmentPicker={showEnvironmentPicker}
756 showEnvironmentIndicator={showEnvironmentIndicator}
757 onEnvironmentChange={onEnvironmentChange}
758 effectiveEnvMode={effectiveEnvMode}
759 activeWorktreePath={activeWorktreePath}
760 onEnvModeChange={onEnvModeChange}
761 previousWorktreeLabel={previousWorktreeLabel}
762 previousWorktreeBranch={previousWorktreeSeed?.branch ?? null}
763 onUsePreviousWorktree={onUsePreviousWorktree}
764 addTargets={addTargets}
765 onAddToMachine={onAddToMachine}
766 />
767 </div>
768 ) : null}
769 {showGitControls || showEnvironmentIndicator ? (
770 <div
771 className={cn(
772 "min-h-7 min-w-10 items-center gap-1 sm:min-h-6",
773 showGitControls ? "hidden @xl/composer-surface:flex" : "flex",
774 "flex-1",
775 )}
776 >
777 {showEnvironmentIndicator && availableEnvironments && (
778 <>
779 <BranchToolbarEnvironmentSelector
780 autoEnvironmentLabel={autoEnvironmentLabel}
781 onAutoEnvironment={onAutoEnvironment}
782 envLocked={envLocked}
783 environmentId={environmentId}
784 availableEnvironments={availableEnvironments}
785 {...(showEnvironmentPicker ? { onEnvironmentChange } : {})}
786 {...(addTargets.length > 0 ? { onAddToMachine: () => onAddToMachine(null) } : {})}
787 />
788 {showGitControls ? (
789 <Separator
790 orientation="vertical"
791 className="mx-0.5 h-3.5!"
792 data-composer-context-control
793 />
794 ) : null}
795 </>
796 )}
797 {showGitControls ? (
798 <BranchToolbarEnvModeSelector
799 forceNewWorktree={forceNewWorktree}
800 envLocked={envModeLocked}
801 effectiveEnvMode={effectiveEnvMode}
802 activeWorktreePath={activeWorktreePath}
803 onEnvModeChange={onEnvModeChange}
804 previousWorktreeLabel={previousWorktreeLabel}
805 previousWorktreeBranch={previousWorktreeSeed?.branch ?? null}
806 onUsePreviousWorktree={onUsePreviousWorktree}
807 />
808 ) : null}
809 </div>
810 ) : null}
811
812 {showGitControls ? (
813 <BranchToolbarBranchSelector
814 forceNewWorktree={forceNewWorktree}
815 ref={branchSelectorRef}
816 className="min-w-0 flex-initial justify-end @xl/composer-surface:ml-auto"
817 environmentId={environmentId}
818 threadId={threadId}
819 {...(draftId ? { draftId } : {})}
820 envLocked={envLocked}
821 effectiveEnvModeOverride={effectiveEnvMode}
822 {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})}
823 {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})}
824 startFromOrigin={startFromOrigin}
825 onStartFromOriginChange={onStartFromOriginChange}
826 {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})}
827 {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}
828 />
829 ) : null}
830 {trailing}
831 </ComposerSurface.ContextStrip>
832 );
833});