import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; import { ComposerContextLabel } from "./ComposerContextLabel"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/client-runtime/thread-pull-requests"; import { useRightPanelStore } from "../rightPanelStore"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope, type ContextMenuItem, type EnvironmentId, type VcsRef, type ThreadId, } from "@t3tools/contracts"; import { GitBranchIcon } from "lucide-react"; import { useEffect, useImperativeHandle, useOptimistic, useState, useTransition, type MouseEvent as ReactMouseEvent, type Ref, } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { readLocalApi } from "../localApi"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { usePaginatedBranches } from "../state/queries"; import { useProject, useQuietThreadShell } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { threadEnvironment } from "../state/threads"; import { useAtomCommand } from "../state/use-atom-command"; import { vcsEnvironment } from "../state/vcs"; import { cn } from "../lib/utils"; import { THREAD_DETAILS_PANEL_ICON_CLASS } from "./chat/threadDetailsPanelStyles"; import { ThreadDetailsPrRows } from "./chat/ThreadDetailsPrRows"; import { parsePullRequestReference } from "@t3tools/client-runtime/pull-request-link-input"; import { getSourceControlPresentation } from "../sourceControlPresentation"; import { useComposerMenuProps } from "./chat/composerEventScope"; import { deriveLocalBranchNameFromRemoteRef, resolveBranchTriggerLabel, resolveBranchToolbarPrBranch, resolveBranchSelectionTarget, resolveBranchToolbarValue, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; import { ThreadPullRequestBadgeControl, prStatusIndicator, resolveThreadPullRequestBadge, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import { ComboboxItem, ComboboxTrigger } from "./ui/combobox"; import { ComposerControl } from "./chat/ComposerControl"; import { MiddleTruncate } from "./ui/middle-truncate"; import { BranchPicker, BranchPickerRefItem } from "./BranchPicker"; import { stackedThreadToast, toastManager } from "./ui/toast"; export interface BranchToolbarBranchSelectorHandle { open: () => void; } interface BranchToolbarBranchSelectorProps { forceNewWorktree?: boolean; ref?: Ref; className?: string; displayMode?: "toolbar" | "panel"; environmentId: EnvironmentId; threadId: ThreadId; draftId?: DraftId; envLocked: boolean; effectiveEnvModeOverride?: "local" | "worktree"; activeThreadBranchOverride?: string | null; onActiveThreadBranchOverrideChange?: (refName: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } function toBranchActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } export function BranchToolbarBranchSelector({ forceNewWorktree = false, ref, className, displayMode = "toolbar", environmentId, threadId, draftId, envLocked, effectiveEnvModeOverride, activeThreadBranchOverride, onActiveThreadBranchOverrideChange, startFromOrigin, onStartFromOriginChange, onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { const composerFloatingLayerProps = useComposerMenuProps(); const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); const updateThreadMetadata = useAtomCommand( threadEnvironment.updateMetadata, "thread metadata update", ); const updateEmptyThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false, }); const refreshStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false, }); const createRefMutation = useAtomCommand(vcsEnvironment.createRef, { reportFailure: false, }); const threadRef = scopeThreadRef(environmentId, threadId); const serverThread = useQuietThreadShell(threadRef); const serverSession = serverThread?.runtime ?? null; const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const activeProjectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; const activeProject = useProject(activeProjectRef); const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); const activeThreadBranch = activeThreadBranchOverride !== undefined ? activeThreadBranchOverride : (serverThread?.branch ?? draftThread?.branch ?? null); const activeWorktreePath = forceNewWorktree ? null : (serverThread?.worktreePath ?? draftThread?.worktreePath ?? null); const activeProjectCwd = activeProject?.workspaceRoot ?? null; const branchCwd = activeWorktreePath ?? activeProjectCwd; const hasServerThread = serverThread !== null; const canUpdateThreadBranch = !hasServerThread || canOperateThread; const canChangeThreadBranch = canWriteSourceControl && canUpdateThreadBranch; const effectiveEnvMode = effectiveEnvModeOverride ?? resolveEffectiveEnvMode({ activeWorktreePath, hasServerThread, draftThreadEnvMode: draftThread?.envMode, }); const setThreadBranch = ( branch: string | null, worktreePath: string | null, automatic = false, ) => { if ( !activeThreadId || !activeProject || (hasServerThread && !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) ) return; if (serverSession && worktreePath !== activeWorktreePath) { void stopThreadSession({ environmentId, input: { threadId: activeThreadId }, }); } if (hasServerThread) { void (automatic ? updateEmptyThreadMetadata : updateThreadMetadata)({ environmentId, input: { threadId: activeThreadId, branch, worktreePath, ...(automatic ? { expectedEmpty: true } : {}), }, }); } if (hasServerThread) { onActiveThreadBranchOverrideChange?.(branch); return; } const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ nextWorktreePath: worktreePath, currentWorktreePath: activeWorktreePath, effectiveEnvMode, }); setDraftThreadContext(draftId ?? threadRef, { branch, worktreePath, envMode: nextDraftEnvMode, environmentSelection: automatic ? (draftThread?.environmentSelection ?? "auto") : "manual", projectRef: scopeProjectRef(environmentId, activeProject.id), }); }; const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(""); const branchStatusQuery = useEnvironmentQuery( branchCwd === null ? null : vcsEnvironment.status({ environmentId, input: { cwd: branchCwd }, }), ); const projectStatusQuery = useEnvironmentQuery( activeProjectCwd === null ? null : vcsEnvironment.status({ environmentId, input: { cwd: activeProjectCwd } }), ); const activeProjectCheckoutRoot = projectStatusQuery.data?.workingTree.rootPath; const trimmedBranchQuery = branchQuery.trim(); const branchRefState = usePaginatedBranches({ environmentId, cwd: branchCwd, // Substring search needs sanitized names to detect new-branch collisions; refs cannot contain ASCII // spaces. query: sanitizeNewRefName(trimmedBranchQuery), }); const refs = branchRefState.refs; const branchByName = new Map(refs.map((refName) => [refName.name, refName] as const)); const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); const prReference = parsePullRequestReference(trimmedBranchQuery); const isSelectingWorktreeBase = effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath; const checkoutPullRequestItemValue = canChangeThreadBranch && prReference && onCheckoutPullRequestRequest ? `__checkout_pull_request__:${prReference}` : null; const canCreateBranch = canChangeThreadBranch && !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; const newRefName = sanitizeNewRefName(trimmedBranchQuery); const hasExactBranchMatch = branchByName.has(newRefName); const createBranchItemValue = canCreateBranch ? `__create_new_branch__:${trimmedBranchQuery}` : null; const defaultBranchName = refs.find((refName) => refName.isDefault)?.name ?? null; const pickerCreateBranchItemValue = hasExactBranchMatch ? null : createBranchItemValue; const branchPickerItems = [ ...(checkoutPullRequestItemValue ? [checkoutPullRequestItemValue] : []), ...refs.map((refName) => refName.name), ...(pickerCreateBranchItemValue ? [pickerCreateBranchItemValue] : []), ]; const filteredBranchPickerItems = normalizedBranchQuery.length === 0 ? branchPickerItems : branchPickerItems.filter((itemValue) => shouldIncludeBranchPickerItem({ itemValue, normalizedQuery: normalizedBranchQuery, createBranchItemValue: pickerCreateBranchItemValue, checkoutPullRequestItemValue, }), ); const hasNextPage = branchRefState.data?.nextCursor !== null && branchRefState.data?.nextCursor !== undefined; const isFetchingNextPage = branchRefState.isFetchingNextPage; const isInitialBranchesLoadPending = branchRefState.isPending && branchRefState.data === null; const currentGitBranch = branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null; const sourceControlPresentation = getSourceControlPresentation( branchStatusQuery.data?.sourceControlProvider, ); const SourceControlIcon = sourceControlPresentation.Icon; const canonicalActiveBranch = resolveBranchToolbarValue({ envMode: effectiveEnvMode, activeWorktreePath, activeThreadBranch, currentGitBranch, }); const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic( canonicalActiveBranch, (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, ); const listedActiveBranch = resolvedActiveBranch === null ? null : (branchByName.get(resolvedActiveBranch) ?? null); const activeBranchRefQuery = useEnvironmentQuery( branchCwd !== null && resolvedActiveBranch !== null ? vcsEnvironment.listRefs({ environmentId, input: { cwd: branchCwd, query: resolvedActiveBranch, limit: 10, }, }) : null, ); const queriedActiveBranch = activeBranchRefQuery.data?.refs.find( (refName) => refName.name === resolvedActiveBranch, ); const resolvedActiveBranchIsRemote = listedActiveBranch !== null ? listedActiveBranch.isRemote === true : queriedActiveBranch ? queriedActiveBranch.isRemote === true : null; const [isBranchActionPending, startBranchActionTransition] = useTransition(); const totalBranchCount = branchRefState.data?.totalCount ?? 0; const branchStatusText = isInitialBranchesLoadPending ? "Loading refs..." : isFetchingNextPage ? "Loading more refs..." : hasNextPage ? `Showing ${refs.length} of ${totalBranchCount} refs` : null; const copyBranchName = (branchName: string) => { void writeTextToClipboard(branchName, "branch name").then( (didCopy) => { if (!didCopy) return; toastManager.add({ type: "success", title: "Branch name copied", description: branchName, }); }, (error: unknown) => { toastManager.add( stackedThreadToast({ type: "error", title: "Failed to copy branch name", description: toBranchActionErrorMessage(error), }), ); }, ); }; const handleBranchContextMenu = (event: ReactMouseEvent, branchName: string | null) => { if (!branchName) return; const api = readLocalApi(); if (!api) return; event.preventDefault(); event.stopPropagation(); const items: ContextMenuItem<"copy-branch-name">[] = [ { id: "copy-branch-name", label: "Copy branch name", icon: "copy" }, ]; void api.contextMenu.show(items, { x: event.clientX, y: event.clientY }).then((action) => { if (action === "copy-branch-name") copyBranchName(branchName); }); }; const runBranchAction = (action: () => Promise) => { if ( !readEnvironmentScope(environmentId, AuthSourceControlWriteScope) || (hasServerThread && !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) ) { toastManager.add( stackedThreadToast({ type: "error", title: "Action unavailable", description: "This connection cannot change the thread's branch.", }), ); return; } startBranchActionTransition(async () => { await action(); branchRefState.refresh(); branchStatusQuery.refresh(); }); }; const selectBranch = (refName: VcsRef) => { if (!canUpdateThreadBranch || !branchCwd || !activeProjectCwd || isBranchActionPending) return; if (isSelectingWorktreeBase) { setThreadBranch(refName.name, null); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); return; } const selectionTarget = resolveBranchSelectionTarget({ activeProjectCwd, activeProjectCheckoutRoot, activeWorktreePath, refName, }); if (selectionTarget.reuseExistingWorktree) { setThreadBranch(refName.name, selectionTarget.nextWorktreePath); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); return; } const selectedBranchName = refName.isRemote ? deriveLocalBranchNameFromRemoteRef(refName.name) : refName.name; setIsBranchMenuOpen(false); onComposerFocusRequest?.(); runBranchAction(async () => { const previousBranch = resolvedActiveBranch; setOptimisticBranch(selectedBranchName); const checkoutResult = await switchRef({ environmentId, input: { cwd: selectionTarget.checkoutCwd, refName: refName.name, }, }); if (checkoutResult._tag === "Success") { const nextBranchName = refName.isRemote ? (checkoutResult.value.refName ?? selectedBranchName) : selectedBranchName; setOptimisticBranch(nextBranchName); setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); return; } setOptimisticBranch(previousBranch); if (!isAtomCommandInterrupted(checkoutResult)) { toastManager.add( stackedThreadToast({ type: "error", title: "Failed to switch ref.", description: toBranchActionErrorMessage(squashAtomCommandFailure(checkoutResult)), }), ); } }); }; const createRef = (rawName: string) => { if (!canChangeThreadBranch) return; const name = sanitizeNewRefName(rawName); if (!branchCwd || !name || isBranchActionPending) return; setIsBranchMenuOpen(false); onComposerFocusRequest?.(); runBranchAction(async () => { const previousBranch = resolvedActiveBranch; setOptimisticBranch(name); const createBranchResult = await createRefMutation({ environmentId, input: { cwd: branchCwd, refName: name, switchRef: true, }, }); if (createBranchResult._tag === "Success") { setOptimisticBranch(createBranchResult.value.refName); setThreadBranch(createBranchResult.value.refName, activeWorktreePath); return; } setOptimisticBranch(previousBranch); if (!isAtomCommandInterrupted(createBranchResult)) { toastManager.add( stackedThreadToast({ type: "error", title: "Failed to create and switch ref.", description: toBranchActionErrorMessage(squashAtomCommandFailure(createBranchResult)), }), ); } }); }; const worktreeBaseBranchCandidate = isInitialBranchesLoadPending ? null : (defaultBranchName ?? currentGitBranch); // The server sets started threads' branches; defaulting here would detach the agent from its worktree. const threadStarted = serverSession !== null; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- writes a default base branch to the server thread or draft once branches load for a new worktree thread useEffect(() => { if ( threadStarted || effectiveEnvMode !== "worktree" || activeWorktreePath || activeThreadBranch || !worktreeBaseBranchCandidate ) { return; } setThreadBranch(worktreeBaseBranchCandidate, null, true); }, [ activeThreadBranch, activeWorktreePath, effectiveEnvMode, setThreadBranch, threadStarted, worktreeBaseBranchCandidate, ]); const handleOpenChange = (open: boolean) => { setIsBranchMenuOpen(open); if (open && !isBranchMenuOpen && branchCwd !== null) { void refreshStatus({ environmentId, input: { cwd: branchCwd, localOnly: true } }); } if (!open) { setBranchQuery(""); } }; useImperativeHandle( ref, () => ({ open: () => { if (isInitialBranchesLoadPending || isBranchActionPending) return; handleOpenChange(true); }, }), [handleOpenChange, isBranchActionPending, isInitialBranchesLoadPending], ); const triggerLabel = resolveBranchTriggerLabel({ activeWorktreePath, effectiveEnvMode, resolvedActiveBranch, resolvedActiveBranchIsRemote, startFromOrigin, }); const branchPrBranch = resolveBranchToolbarPrBranch({ activeThreadBranch, resolvedActiveBranch, }); const branchPr = branchPrBranch !== null && branchStatusQuery.data?.refName === branchPrBranch ? (branchStatusQuery.data.pr ?? null) : null; const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId); const linkedStatus = useLinkedThreadPullRequest( environmentId, serverThread?.linkedPullRequest, true, serverThread?.pullRequests, serverThread?.branchPullRequest, ); const currentLinkedPr = supportsMultiplePullRequests ? resolveThreadCurrentPullRequestLink(serverThread?.pullRequests ?? []) : null; const prBadge = supportsMultiplePullRequests ? resolveThreadPullRequestBadge(serverThread?.pullRequests) : null; const displayedPr = linkedStatus?.pr ?? (currentLinkedPr === null ? branchPr : null); const displayedPrStatus = prStatusIndicator( displayedPr, linkedStatus?.sourceControlProvider ?? branchStatusQuery.data?.sourceControlProvider, ); const prNumber = currentLinkedPr?.number ?? displayedPr?.number; const prUrl = currentLinkedPr?.url ?? displayedPr?.url; const openPrLink = useOpenPrLink(threadRef); const panelPrLabel = prNumber === undefined ? "" : `#${prNumber}${displayedPr?.title.trim() ? `: ${displayedPr.title}` : ""}`; function selectPickerItem(itemValue: string) { if (itemValue === checkoutPullRequestItemValue && prReference && onCheckoutPullRequestRequest) { handleOpenChange(false); onComposerFocusRequest?.(); onCheckoutPullRequestRequest(prReference); } else if (itemValue === createBranchItemValue) { createRef(trimmedBranchQuery); } else { const refName = branchByName.get(itemValue); if (refName) selectBranch(refName); } } function renderPickerItem(itemValue: string, index: number) { if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { return ( selectPickerItem(itemValue)} >
Checkout {sourceControlPresentation.terminology.singular} {prReference}
); } if (createBranchItemValue && itemValue === createBranchItemValue) { return ( selectPickerItem(itemValue)} > Create new ref "{newRefName}" ); } const refName = branchByName.get(itemValue); if (!refName) return null; return ( selectPickerItem(itemValue)} onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} /> ); } return ( item === checkoutPullRequestItemValue ? "checkout-pull-request" : item === createBranchItemValue ? "create-branch" : "branch" } originControl={ isSelectingWorktreeBase ? { checked: startFromOrigin, onCheckedChange: onStartFromOriginChange } : undefined } popupProps={{ align: displayMode === "panel" ? "start" : "end", side: displayMode === "panel" ? "bottom" : "top", className: cn("flex flex-col", displayMode === "panel" ? "w-(--anchor-width)" : "w-80"), ...(displayMode === "toolbar" ? composerFloatingLayerProps : {}), }} >
{displayMode !== "panel" ? ( } badge={prBadge} pullRequests={serverThread?.pullRequests ?? []} number={prNumber} url={prUrl} status={displayedPrStatus} onOpenList={() => useRightPanelStore.getState().open(threadRef, "pull-requests")} onOpenPullRequest={(event, targetUrl = prUrl) => { if (targetUrl) openPrLink(event, targetUrl); }} /> ) : null} { if (event.button !== 0 || event.ctrlKey) { event.stopPropagation(); } }} onContextMenu={(event) => handleBranchContextMenu(event, resolvedActiveBranch)} > ) : ( ) } className="min-w-0 max-w-full active:scale-100" disabled={isInitialBranchesLoadPending || isBranchActionPending} > {displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? ( openPrLink(event, prUrl)} onActed={() => branchStatusQuery.refresh()} /> ) : null}
); }