1import { ThreadDetailsControl } from "./chat/ThreadDetailsControl";
2import { ComposerContextLabel } from "./ComposerContextLabel";
3import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
4import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
5import { useRightPanelStore } from "../rightPanelStore";
6import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
7import {
8 isAtomCommandInterrupted,
9 squashAtomCommandFailure,
10} from "@t3tools/client-runtime/state/runtime";
11import {
12 AuthOrchestrationOperateScope,
13 AuthSourceControlWriteScope,
14 type ContextMenuItem,
15 type EnvironmentId,
16 type VcsRef,
17 type ThreadId,
18} from "@t3tools/contracts";
19import { ChevronDownIcon, GitBranchIcon } from "lucide-react";
20import {
21 useCallback,
22 useDeferredValue,
23 useEffect,
24 useImperativeHandle,
25 useMemo,
26 useOptimistic,
27 useState,
28 useTransition,
29 type MouseEvent as ReactMouseEvent,
30 type Ref,
31} from "react";
32
33import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
34import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
35import { readLocalApi } from "../localApi";
36import { useOpenPrLink } from "../lib/openPullRequestLink";
37import { usePaginatedBranches } from "../state/queries";
38import { useProject, useThreadShell } from "../state/entities";
39import { useEnvironmentQuery } from "../state/query";
40import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
41import { threadEnvironment } from "../state/threads";
42import { useAtomCommand } from "../state/use-atom-command";
43import { vcsEnvironment } from "../state/vcs";
44import { cn } from "../lib/utils";
45import { THREAD_DETAILS_PANEL_ICON_CLASS } from "./chat/threadDetailsPanelStyles";
46import { ThreadDetailsPrRows } from "./chat/ThreadDetailsPrRows";
47import { parsePullRequestReference } from "../pullRequestReference";
48import { getSourceControlPresentation } from "../sourceControlPresentation";
49import { useComposerMenuProps } from "./chat/composerEventScope";
50import {
51 deriveLocalBranchNameFromRemoteRef,
52 resolveBranchTriggerLabel,
53 resolveBranchToolbarPrBranch,
54 resolveBranchSelectionTarget,
55 resolveBranchToolbarValue,
56 resolveDraftEnvModeAfterBranchChange,
57 resolveEffectiveEnvMode,
58 sanitizeNewRefName,
59 shouldIncludeBranchPickerItem,
60} from "./BranchToolbar.logic";
61import {
62 ThreadPullRequestBadgeControl,
63 prStatusIndicator,
64 resolveThreadPullRequestBadge,
65 useLinkedThreadPullRequest,
66} from "./ThreadStatusIndicators";
67
68import { ComboboxItem, ComboboxTrigger } from "./ui/combobox";
69import { ComposerControl } from "./chat/ComposerControl";
70import { MiddleTruncate } from "./ui/middle-truncate";
71import { BranchPicker, BranchPickerRefItem } from "./BranchPicker";
72import { stackedThreadToast, toastManager } from "./ui/toast";
73
74export interface BranchToolbarBranchSelectorHandle {
75 open: () => void;
76}
77
78interface BranchToolbarBranchSelectorProps {
79 forceNewWorktree?: boolean;
80 ref?: Ref<BranchToolbarBranchSelectorHandle>;
81 className?: string;
82 displayMode?: "toolbar" | "panel";
83 environmentId: EnvironmentId;
84 threadId: ThreadId;
85 draftId?: DraftId;
86 envLocked: boolean;
87 effectiveEnvModeOverride?: "local" | "worktree";
88 activeThreadBranchOverride?: string | null;
89 onActiveThreadBranchOverrideChange?: (refName: string | null) => void;
90 startFromOrigin: boolean;
91 onStartFromOriginChange: (startFromOrigin: boolean) => void;
92 onCheckoutPullRequestRequest?: (reference: string) => void;
93 onComposerFocusRequest?: () => void;
94}
95
96function toBranchActionErrorMessage(error: unknown): string {
97 return error instanceof Error ? error.message : "An error occurred.";
98}
99
100export function BranchToolbarBranchSelector({
101 forceNewWorktree = false,
102 ref,
103 className,
104 displayMode = "toolbar",
105 environmentId,
106 threadId,
107 draftId,
108 envLocked,
109 effectiveEnvModeOverride,
110 activeThreadBranchOverride,
111 onActiveThreadBranchOverrideChange,
112 startFromOrigin,
113 onStartFromOriginChange,
114 onCheckoutPullRequestRequest,
115 onComposerFocusRequest,
116}: BranchToolbarBranchSelectorProps) {
117 const composerFloatingLayerProps = useComposerMenuProps();
118 const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope);
119 const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
120 const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop");
121 const updateThreadMetadata = useAtomCommand(
122 threadEnvironment.updateMetadata,
123 "thread metadata update",
124 );
125 // An automatic default loses to any launch that lands first; that rejection is expected.
126 const updateEmptyThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
127 reportFailure: false,
128 });
129 const switchRef = useAtomCommand(vcsEnvironment.switchRef, {
130 reportFailure: false,
131 });
132 const createRefMutation = useAtomCommand(vcsEnvironment.createRef, {
133 reportFailure: false,
134 });
135 // ---------------------------------------------------------------------------
136 // Thread / project state (pushed down from parent to colocate with mutation)
137 // ---------------------------------------------------------------------------
138 const threadRef = useMemo(
139 () => scopeThreadRef(environmentId, threadId),
140 [environmentId, threadId],
141 );
142 const serverThread = useThreadShell(threadRef);
143 const serverSession = serverThread?.runtime ?? null;
144 const draftThread = useComposerDraftStore((store) =>
145 draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef),
146 );
147 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
148
149 const activeProjectRef = serverThread
150 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
151 : draftThread
152 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
153 : null;
154 const activeProject = useProject(activeProjectRef);
155
156 const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);
157 const activeThreadBranch =
158 activeThreadBranchOverride !== undefined
159 ? activeThreadBranchOverride
160 : (serverThread?.branch ?? draftThread?.branch ?? null);
161 const activeWorktreePath = forceNewWorktree
162 ? null
163 : (serverThread?.worktreePath ?? draftThread?.worktreePath ?? null);
164 const activeProjectCwd = activeProject?.workspaceRoot ?? null;
165 const branchCwd = activeWorktreePath ?? activeProjectCwd;
166 const hasServerThread = serverThread !== null;
167 const canUpdateThreadBranch = !hasServerThread || canOperateThread;
168 const canChangeThreadBranch = canWriteSourceControl && canUpdateThreadBranch;
169 const effectiveEnvMode =
170 effectiveEnvModeOverride ??
171 resolveEffectiveEnvMode({
172 activeWorktreePath,
173 hasServerThread,
174 draftThreadEnvMode: draftThread?.envMode,
175 });
176
177 // ---------------------------------------------------------------------------
178 // Thread branch mutation (colocated — only this component calls it)
179 // ---------------------------------------------------------------------------
180 const setThreadBranch = useCallback(
181 (branch: string | null, worktreePath: string | null, automatic = false) => {
182 if (
183 !activeThreadId ||
184 !activeProject ||
185 (hasServerThread && !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
186 )
187 return;
188 if (serverSession && worktreePath !== activeWorktreePath) {
189 void stopThreadSession({
190 environmentId,
191 input: { threadId: activeThreadId },
192 });
193 }
194 if (hasServerThread) {
195 void (automatic ? updateEmptyThreadMetadata : updateThreadMetadata)({
196 environmentId,
197 input: {
198 threadId: activeThreadId,
199 branch,
200 worktreePath,
201 // A launched thread records its own worktree; a late default must not reset it.
202 ...(automatic ? { expectedEmpty: true } : {}),
203 },
204 });
205 }
206 if (hasServerThread) {
207 onActiveThreadBranchOverrideChange?.(branch);
208 return;
209 }
210 const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({
211 nextWorktreePath: worktreePath,
212 currentWorktreePath: activeWorktreePath,
213 effectiveEnvMode,
214 });
215 setDraftThreadContext(draftId ?? threadRef, {
216 branch,
217 worktreePath,
218 envMode: nextDraftEnvMode,
219 environmentSelection: automatic ? (draftThread?.environmentSelection ?? "auto") : "manual",
220 projectRef: scopeProjectRef(environmentId, activeProject.id),
221 });
222 },
223 [
224 activeThreadId,
225 activeProject,
226 serverSession,
227 activeWorktreePath,
228 hasServerThread,
229 onActiveThreadBranchOverrideChange,
230 setDraftThreadContext,
231 draftId,
232 threadRef,
233 environmentId,
234 effectiveEnvMode,
235 draftThread?.environmentSelection,
236 stopThreadSession,
237 updateEmptyThreadMetadata,
238 updateThreadMetadata,
239 ],
240 );
241
242 // ---------------------------------------------------------------------------
243 // Git ref queries
244 // ---------------------------------------------------------------------------
245 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
246 const [branchQuery, setBranchQuery] = useState("");
247 const deferredBranchQuery = useDeferredValue(branchQuery);
248
249 const branchStatusQuery = useEnvironmentQuery(
250 branchCwd === null
251 ? null
252 : vcsEnvironment.status({
253 environmentId,
254 input: { cwd: branchCwd },
255 }),
256 );
257 const trimmedBranchQuery = branchQuery.trim();
258 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();
259 // The server filters refs by substring, so it has to be given the sanitized
260 // name as well: querying the raw "new branch" drops an existing new-branch
261 // from the response entirely, which would defeat the collision check below.
262 // Ref names cannot contain an ASCII space, so sanitizing loses no matches.
263 const branchRefQuery = sanitizeNewRefName(deferredTrimmedBranchQuery);
264 const branchRefState = usePaginatedBranches({
265 environmentId,
266 cwd: branchCwd,
267 query: branchRefQuery,
268 });
269 const refs = branchRefState.refs;
270 const hasNextPage =
271 branchRefState.data?.nextCursor !== null && branchRefState.data?.nextCursor !== undefined;
272 const isFetchingNextPage = branchRefState.isFetchingNextPage;
273 const isInitialBranchesLoadPending = branchRefState.isPending && branchRefState.data === null;
274 const currentGitBranch =
275 branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null;
276 const sourceControlPresentation = useMemo(
277 () => getSourceControlPresentation(branchStatusQuery.data?.sourceControlProvider),
278 [branchStatusQuery.data?.sourceControlProvider],
279 );
280 const SourceControlIcon = sourceControlPresentation.Icon;
281 const canonicalActiveBranch = resolveBranchToolbarValue({
282 envMode: effectiveEnvMode,
283 activeWorktreePath,
284 activeThreadBranch,
285 currentGitBranch,
286 });
287 const branchNames = useMemo(() => refs.map((refName) => refName.name), [refs]);
288 const branchByName = useMemo(
289 () => new Map(refs.map((refName) => [refName.name, refName] as const)),
290 [refs],
291 );
292 const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase();
293 const prReference = parsePullRequestReference(trimmedBranchQuery);
294 const isSelectingWorktreeBase =
295 effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath;
296 const checkoutPullRequestItemValue =
297 canChangeThreadBranch && prReference && onCheckoutPullRequestRequest
298 ? `__checkout_pull_request__:${prReference}`
299 : null;
300 const canCreateBranch =
301 canChangeThreadBranch && !isSelectingWorktreeBase && trimmedBranchQuery.length > 0;
302 // The ref is created under its sanitized name, so the collision check has to
303 // use that name too. Matching on the raw query would offer to create a ref
304 // that already exists whenever sanitizing changes the name.
305 const newRefName = sanitizeNewRefName(trimmedBranchQuery);
306 const hasExactBranchMatch = branchByName.has(newRefName);
307 const createBranchItemValue = canCreateBranch
308 ? `__create_new_branch__:${trimmedBranchQuery}`
309 : null;
310 const branchPickerItems = useMemo(() => {
311 const items = [...branchNames];
312 if (createBranchItemValue && !hasExactBranchMatch) {
313 items.push(createBranchItemValue);
314 }
315 if (checkoutPullRequestItemValue) {
316 items.unshift(checkoutPullRequestItemValue);
317 }
318 return items;
319 }, [branchNames, checkoutPullRequestItemValue, createBranchItemValue, hasExactBranchMatch]);
320 const filteredBranchPickerItems = useMemo(
321 () =>
322 normalizedDeferredBranchQuery.length === 0
323 ? branchPickerItems
324 : branchPickerItems.filter((itemValue) =>
325 shouldIncludeBranchPickerItem({
326 itemValue,
327 normalizedQuery: normalizedDeferredBranchQuery,
328 createBranchItemValue,
329 checkoutPullRequestItemValue,
330 }),
331 ),
332 [
333 branchPickerItems,
334 checkoutPullRequestItemValue,
335 createBranchItemValue,
336 normalizedDeferredBranchQuery,
337 ],
338 );
339 const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic(
340 canonicalActiveBranch,
341 (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch,
342 );
343 const listedActiveBranch =
344 resolvedActiveBranch === null ? null : (branchByName.get(resolvedActiveBranch) ?? null);
345 const activeBranchRefQuery = useEnvironmentQuery(
346 branchCwd !== null && resolvedActiveBranch !== null
347 ? vcsEnvironment.listRefs({
348 environmentId,
349 input: {
350 cwd: branchCwd,
351 query: resolvedActiveBranch,
352 limit: 10,
353 },
354 })
355 : null,
356 );
357 const queriedActiveBranch = activeBranchRefQuery.data?.refs.find(
358 (refName) => refName.name === resolvedActiveBranch,
359 );
360 const resolvedActiveBranchIsRemote =
361 listedActiveBranch !== null
362 ? listedActiveBranch.isRemote === true
363 : queriedActiveBranch
364 ? queriedActiveBranch.isRemote === true
365 : null;
366 const [isBranchActionPending, startBranchActionTransition] = useTransition();
367 const totalBranchCount = branchRefState.data?.totalCount ?? 0;
368 const branchStatusText = isInitialBranchesLoadPending
369 ? "Loading refs..."
370 : isFetchingNextPage
371 ? "Loading more refs..."
372 : hasNextPage
373 ? `Showing ${refs.length} of ${totalBranchCount} refs`
374 : null;
375
376 // ---------------------------------------------------------------------------
377 // Branch actions
378 // ---------------------------------------------------------------------------
379 const copyBranchName = useCallback((branchName: string) => {
380 void writeTextToClipboard(branchName, "branch name").then(
381 (didCopy) => {
382 if (!didCopy) return;
383 toastManager.add({
384 type: "success",
385 title: "Branch name copied",
386 description: branchName,
387 });
388 },
389 (error: unknown) => {
390 toastManager.add(
391 stackedThreadToast({
392 type: "error",
393 title: "Failed to copy branch name",
394 description: toBranchActionErrorMessage(error),
395 }),
396 );
397 },
398 );
399 }, []);
400
401 const handleBranchContextMenu = useCallback(
402 (event: ReactMouseEvent, branchName: string | null) => {
403 if (!branchName) return;
404 const api = readLocalApi();
405 if (!api) return;
406 event.preventDefault();
407 event.stopPropagation();
408 const items: ContextMenuItem<"copy-branch-name">[] = [
409 { id: "copy-branch-name", label: "Copy branch name", icon: "copy" },
410 ];
411 void api.contextMenu.show(items, { x: event.clientX, y: event.clientY }).then((action) => {
412 if (action === "copy-branch-name") copyBranchName(branchName);
413 });
414 },
415 [copyBranchName],
416 );
417
418 const runBranchAction = (action: () => Promise<void>) => {
419 if (
420 !readEnvironmentScope(environmentId, AuthSourceControlWriteScope) ||
421 (hasServerThread && !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
422 ) {
423 // The menu already closed when the item was chosen; explain the no-op.
424 toastManager.add(
425 stackedThreadToast({
426 type: "error",
427 title: "Action unavailable",
428 description: "This connection cannot change the thread's branch.",
429 }),
430 );
431 return;
432 }
433 startBranchActionTransition(async () => {
434 await action();
435 branchRefState.refresh();
436 branchStatusQuery.refresh();
437 });
438 };
439
440 const selectBranch = (refName: VcsRef) => {
441 if (!canUpdateThreadBranch || !branchCwd || !activeProjectCwd || isBranchActionPending) return;
442
443 if (isSelectingWorktreeBase) {
444 setThreadBranch(refName.name, null);
445 setIsBranchMenuOpen(false);
446 onComposerFocusRequest?.();
447 return;
448 }
449
450 const selectionTarget = resolveBranchSelectionTarget({
451 activeProjectCwd,
452 activeWorktreePath,
453 refName,
454 });
455
456 if (selectionTarget.reuseExistingWorktree) {
457 setThreadBranch(refName.name, selectionTarget.nextWorktreePath);
458 setIsBranchMenuOpen(false);
459 onComposerFocusRequest?.();
460 return;
461 }
462
463 const selectedBranchName = refName.isRemote
464 ? deriveLocalBranchNameFromRemoteRef(refName.name)
465 : refName.name;
466
467 setIsBranchMenuOpen(false);
468 onComposerFocusRequest?.();
469
470 runBranchAction(async () => {
471 const previousBranch = resolvedActiveBranch;
472 setOptimisticBranch(selectedBranchName);
473 const checkoutResult = await switchRef({
474 environmentId,
475 input: {
476 cwd: selectionTarget.checkoutCwd,
477 refName: refName.name,
478 },
479 });
480 if (checkoutResult._tag === "Success") {
481 const nextBranchName = refName.isRemote
482 ? (checkoutResult.value.refName ?? selectedBranchName)
483 : selectedBranchName;
484 setOptimisticBranch(nextBranchName);
485 setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath);
486 return;
487 }
488 setOptimisticBranch(previousBranch);
489 if (!isAtomCommandInterrupted(checkoutResult)) {
490 toastManager.add(
491 stackedThreadToast({
492 type: "error",
493 title: "Failed to switch ref.",
494 description: toBranchActionErrorMessage(squashAtomCommandFailure(checkoutResult)),
495 }),
496 );
497 }
498 });
499 };
500
501 const createRef = (rawName: string) => {
502 if (!canChangeThreadBranch) return;
503 const name = sanitizeNewRefName(rawName);
504 if (!branchCwd || !name || isBranchActionPending) return;
505
506 setIsBranchMenuOpen(false);
507 onComposerFocusRequest?.();
508
509 runBranchAction(async () => {
510 const previousBranch = resolvedActiveBranch;
511 setOptimisticBranch(name);
512 const createBranchResult = await createRefMutation({
513 environmentId,
514 input: {
515 cwd: branchCwd,
516 refName: name,
517 switchRef: true,
518 },
519 });
520 if (createBranchResult._tag === "Success") {
521 setOptimisticBranch(createBranchResult.value.refName);
522 setThreadBranch(createBranchResult.value.refName, activeWorktreePath);
523 return;
524 }
525 setOptimisticBranch(previousBranch);
526 if (!isAtomCommandInterrupted(createBranchResult)) {
527 toastManager.add(
528 stackedThreadToast({
529 type: "error",
530 title: "Failed to create and switch ref.",
531 description: toBranchActionErrorMessage(squashAtomCommandFailure(createBranchResult)),
532 }),
533 );
534 }
535 });
536 };
537
538 // Default the worktree base to the repo default branch (origin/HEAD), only
539 // falling back to the checked-out branch when no default is known.
540 const defaultBranchName = useMemo(
541 () => refs.find((refName) => refName.isDefault)?.name ?? null,
542 [refs],
543 );
544 const worktreeBaseBranchCandidate = isInitialBranchesLoadPending
545 ? null
546 : (defaultBranchName ?? currentGitBranch);
547
548 // A started thread's server is creating its worktree and records the branch
549 // itself; defaulting it here would reset the thread to the base checkout and
550 // detach the agent that just started.
551 const threadStarted = serverSession !== null;
552 useEffect(() => {
553 if (
554 threadStarted ||
555 effectiveEnvMode !== "worktree" ||
556 activeWorktreePath ||
557 activeThreadBranch ||
558 !worktreeBaseBranchCandidate
559 ) {
560 return;
561 }
562 setThreadBranch(worktreeBaseBranchCandidate, null, true);
563 }, [
564 activeThreadBranch,
565 activeWorktreePath,
566 effectiveEnvMode,
567 setThreadBranch,
568 threadStarted,
569 worktreeBaseBranchCandidate,
570 ]);
571
572 // ---------------------------------------------------------------------------
573 // Combobox / list plumbing
574 // ---------------------------------------------------------------------------
575 const handleOpenChange = useCallback((open: boolean) => {
576 setIsBranchMenuOpen(open);
577 if (!open) {
578 setBranchQuery("");
579 }
580 }, []);
581
582 useImperativeHandle(
583 ref,
584 () => ({
585 open: () => {
586 if (isInitialBranchesLoadPending || isBranchActionPending) return;
587 handleOpenChange(true);
588 },
589 }),
590 [handleOpenChange, isBranchActionPending, isInitialBranchesLoadPending],
591 );
592
593 const triggerLabel = resolveBranchTriggerLabel({
594 activeWorktreePath,
595 effectiveEnvMode,
596 resolvedActiveBranch,
597 resolvedActiveBranchIsRemote,
598 startFromOrigin,
599 });
600
601 // Branch status is the fallback when this thread has no linked pull requests.
602 const branchPrBranch = resolveBranchToolbarPrBranch({
603 activeThreadBranch,
604 resolvedActiveBranch,
605 });
606 const branchPr =
607 branchPrBranch !== null && branchStatusQuery.data?.refName === branchPrBranch
608 ? (branchStatusQuery.data.pr ?? null)
609 : null;
610 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId);
611 const linkedStatus = useLinkedThreadPullRequest(
612 environmentId,
613 serverThread?.linkedPullRequest,
614 true,
615 serverThread?.pullRequests,
616 serverThread?.branchPullRequest,
617 );
618 const currentLinkedPr = supportsMultiplePullRequests
619 ? resolveThreadCurrentPullRequestLink(serverThread?.pullRequests ?? [])
620 : null;
621 const prBadge = supportsMultiplePullRequests
622 ? resolveThreadPullRequestBadge(serverThread?.pullRequests)
623 : null;
624 const displayedPr = linkedStatus?.pr ?? (currentLinkedPr === null ? branchPr : null);
625 const displayedPrStatus = prStatusIndicator(
626 displayedPr,
627 linkedStatus?.sourceControlProvider ?? branchStatusQuery.data?.sourceControlProvider,
628 );
629 const prNumber = currentLinkedPr?.number ?? displayedPr?.number;
630 const prUrl = currentLinkedPr?.url ?? displayedPr?.url;
631 const openPrLink = useOpenPrLink(threadRef);
632 const panelPrLabel =
633 prNumber === undefined
634 ? ""
635 : `#${prNumber}${displayedPr?.title.trim() ? `: ${displayedPr.title}` : ""}`;
636
637 function selectPickerItem(itemValue: string) {
638 if (itemValue === checkoutPullRequestItemValue && prReference && onCheckoutPullRequestRequest) {
639 handleOpenChange(false);
640 onComposerFocusRequest?.();
641 onCheckoutPullRequestRequest(prReference);
642 } else if (itemValue === createBranchItemValue) {
643 createRef(trimmedBranchQuery);
644 } else {
645 const refName = branchByName.get(itemValue);
646 if (refName) selectBranch(refName);
647 }
648 }
649
650 function renderPickerItem(itemValue: string, index: number) {
651 if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) {
652 return (
653 <ComboboxItem
654 hideIndicator
655 key={itemValue}
656 index={index}
657 value={itemValue}
658 onClick={() => selectPickerItem(itemValue)}
659 >
660 <div className="flex min-w-0 items-center gap-2 py-1">
661 <SourceControlIcon className="size-3.5 shrink-0 text-muted-foreground" />
662 <span className="flex min-w-0 flex-col items-start">
663 <span className="truncate font-medium">
664 Checkout {sourceControlPresentation.terminology.singular}
665 </span>
666 <span className="truncate text-muted-foreground text-xs">{prReference}</span>
667 </span>
668 </div>
669 </ComboboxItem>
670 );
671 }
672 if (createBranchItemValue && itemValue === createBranchItemValue) {
673 return (
674 <ComboboxItem
675 hideIndicator
676 key={itemValue}
677 index={index}
678 value={itemValue}
679 onClick={() => selectPickerItem(itemValue)}
680 >
681 <span className="truncate">Create new ref &quot;{newRefName}&quot;</span>
682 </ComboboxItem>
683 );
684 }
685
686 const refName = branchByName.get(itemValue);
687 if (!refName) return null;
688
689 return (
690 <BranchPickerRefItem
691 branch={refName}
692 projectCwd={activeProjectCwd}
693 index={index}
694 value={itemValue}
695 disabled={
696 !canUpdateThreadBranch ||
697 (!canWriteSourceControl &&
698 !isSelectingWorktreeBase &&
699 (!activeProjectCwd ||
700 !resolveBranchSelectionTarget({
701 activeProjectCwd,
702 activeWorktreePath,
703 refName,
704 }).reuseExistingWorktree))
705 }
706 onClick={() => selectPickerItem(itemValue)}
707 onContextMenu={(event) => handleBranchContextMenu(event, itemValue)}
708 />
709 );
710 }
711
712 return (
713 <BranchPicker
714 items={branchPickerItems}
715 filteredItems={filteredBranchPickerItems}
716 open={isBranchMenuOpen}
717 onOpenChange={handleOpenChange}
718 onSelectItem={selectPickerItem}
719 value={resolvedActiveBranch}
720 query={branchQuery}
721 resultsQuery={deferredTrimmedBranchQuery}
722 onQueryChange={setBranchQuery}
723 hasNextPage={hasNextPage}
724 isFetchingNextPage={isFetchingNextPage}
725 onLoadNext={branchRefState.loadNext}
726 statusText={branchStatusText}
727 renderItem={renderPickerItem}
728 getItemType={(item) =>
729 item === checkoutPullRequestItemValue
730 ? "checkout-pull-request"
731 : item === createBranchItemValue
732 ? "create-branch"
733 : "branch"
734 }
735 originControl={
736 isSelectingWorktreeBase
737 ? { checked: startFromOrigin, onCheckedChange: onStartFromOriginChange }
738 : undefined
739 }
740 popupProps={{
741 align: displayMode === "panel" ? "start" : "end",
742 side: displayMode === "panel" ? "bottom" : "top",
743 className: cn("flex flex-col", displayMode === "panel" ? "w-(--anchor-width)" : "w-80"),
744 ...(displayMode === "toolbar" ? composerFloatingLayerProps : {}),
745 }}
746 >
747 <div
748 className={cn(
749 "flex min-w-0",
750 displayMode === "panel" ? "w-full flex-col items-stretch" : "items-center gap-1",
751 className,
752 )}
753 >
754 {displayMode !== "panel" ? (
755 <ThreadPullRequestBadgeControl
756 render={<ComposerControl size="xs" />}
757 badge={prBadge}
758 pullRequests={serverThread?.pullRequests ?? []}
759 number={prNumber}
760 url={prUrl}
761 status={displayedPrStatus}
762 onOpenList={() => useRightPanelStore.getState().open(threadRef, "pull-requests")}
763 onOpenPullRequest={(event, targetUrl = prUrl) => {
764 if (targetUrl) openPrLink(event, targetUrl);
765 }}
766 />
767 ) : null}
768 <span
769 className="flex min-w-0"
770 onMouseDownCapture={(event) => {
771 if (event.button !== 0 || event.ctrlKey) {
772 event.stopPropagation();
773 }
774 }}
775 onContextMenu={(event) => handleBranchContextMenu(event, resolvedActiveBranch)}
776 >
777 <ComboboxTrigger
778 render={
779 displayMode === "panel" ? (
780 <ThreadDetailsControl part="select" />
781 ) : (
782 <ComposerControl size="xs" />
783 )
784 }
785 className="min-w-0 max-w-full active:scale-100"
786 disabled={isInitialBranchesLoadPending || isBranchActionPending}
787 >
788 <GitBranchIcon
789 className={cn(
790 "size-3 shrink-0 opacity-70",
791 displayMode === "panel" && THREAD_DETAILS_PANEL_ICON_CLASS,
792 )}
793 />
794 <ComposerContextLabel displayMode={displayMode}>
795 <MiddleTruncate
796 value={triggerLabel}
797 variant={displayMode === "panel" ? "cap-aligned" : "default"}
798 className="w-full"
799 />
800 </ComposerContextLabel>
801 {displayMode !== "panel" ? (
802 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />
803 ) : null}
804 </ComboboxTrigger>
805 </span>
806 {displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? (
807 <ThreadDetailsPrRows
808 threadRef={threadRef}
809 links={serverThread?.pullRequests ?? []}
810 currentLink={currentLinkedPr}
811 onOpenLink={openPrLink}
812 environmentId={environmentId}
813 pr={displayedPr}
814 number={prNumber}
815 reference={currentLinkedPr}
816 status={displayedPrStatus}
817 project={activeProject}
818 label={panelPrLabel}
819 openAriaLabel={prUrl ?? "Open pull request"}
820 onOpen={(event) => openPrLink(event, prUrl)}
821 onActed={() => branchStatusQuery.refresh()}
822 />
823 ) : null}
824 </div>
825 </BranchPicker>
826 );
827}