import * as React from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { toast } from '@/components/ui'; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from '@/components/ui/command'; import { cn } from '@/lib/utils'; import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; import { useUIStore } from '@/stores/useUIStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; import * as sessionActions from '@/sync/session-actions'; import { buildLinkedIssue, buildLinkedLinearIssue } from '@/lib/linkedIssues'; import { useConfigStore } from '@/stores/useConfigStore'; import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager'; import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate'; import { waitForWorktreeBootstrap } from '@/lib/worktrees/worktreeBootstrap'; import { getWorktreeSetupCommands, getWorktreeSetupWaitEnabled } from '@/lib/openchamberConfig'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { generateBranchSlug } from '@/lib/git/branchNameGenerator'; import { renderMagicPrompt } from '@/lib/magicPrompts'; import { postLinearSessionStarted } from '@/lib/linearSessionStatus'; import { parseModelIdentifier } from '@/lib/modelIdentifier'; import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch'; import { LAST_WORKTREE_SOURCE_BRANCH_KEY, resolveWorktreeSourceBranchPreference, resolveWorktreeSourceBranchToPersist, } from '@/lib/worktrees/worktreeSourceBranchPreference'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useGitBranches, useGitStore, useGitLoadingBranches } from '@/stores/useGitStore'; import { GitHubIntegrationDialog } from './GitHubIntegrationDialog'; import { LinearIssuePickerDialog } from './LinearIssuePickerDialog'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { Icon } from "@/components/icon/Icon"; import type { GitHubIssue, GitHubIssueComment, GitHubIssuesListResult, GitHubPullRequestContextResult, GitHubPullRequestSummary, LinearIssue, LinearIssueComment, } from '@/lib/api/types'; import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; import { useI18n } from '@/lib/i18n'; type Mode = 'new-branch' | 'existing-branch'; interface ValidationState { isValidating: boolean; branchError: string | null; worktreeError: string | null; touched: boolean; } type LinkedLinearWorktreeIssue = { identifier: string; title: string; url: string; author?: { login: string; avatarUrl?: string }; }; // State for New Branch mode interface NewBranchState { branchName: string; worktreeName: string; isSyncingWorktreeName: boolean; sourceBranch: string; linkedIssue: GitHubIssue | null; linkedPr: GitHubPullRequestSummary | null; linkedLinearIssue: LinkedLinearWorktreeIssue | null; includePrDiff: boolean; } // State for Existing Branch mode interface ExistingBranchState { selectedBranch: string; worktreeName: string; } const normalizeBranchName = (value: string): string => { return value .trim() .replace(/^refs\/heads\//, '') .replace(/^heads\//, '') .replace(/\s+/g, '-') .replace(/^\/+|\/+$/g, ''); }; const sanitizeRemoteName = (value: string): string => { const normalized = String(value || '') .trim() .toLowerCase() .replace(/[^a-z0-9._-]+/g, '-') .replace(/-+/g, '-') .replace(/^-+|-+$/g, ''); return normalized || 'pr-head'; }; const resolvePrWorktreeConfig = (pr: GitHubPullRequestSummary, localBranches: string[], remoteBranches: string[]) => { const headBranch = normalizeBranchName(pr.head || ''); if (!headBranch) { throw new Error('PR head branch is missing'); } if (localBranches.includes(headBranch)) { return { existingBranch: headBranch, setUpstream: undefined, upstreamRemote: undefined, upstreamBranch: undefined, ensureRemoteName: undefined, ensureRemoteUrl: undefined, sourceLabel: headBranch, }; } const availableRemoteBranch = remoteBranches.find((remoteBranch) => { const slashIndex = remoteBranch.indexOf('/'); if (slashIndex <= 0 || slashIndex >= remoteBranch.length - 1) { return false; } return remoteBranch.slice(slashIndex + 1) === headBranch; }); if (availableRemoteBranch) { const slashIndex = availableRemoteBranch.indexOf('/'); const remoteName = availableRemoteBranch.slice(0, slashIndex); return { existingBranch: `remotes/${availableRemoteBranch}`, setUpstream: true as const, upstreamRemote: remoteName, upstreamBranch: headBranch, ensureRemoteName: undefined, ensureRemoteUrl: undefined, sourceLabel: `${remoteName}/${headBranch}`, }; } const ownerFromLabel = String(pr.headLabel || '').split(':')[0]?.trim(); const remoteSeed = pr.headRepo?.owner || ownerFromLabel || 'pr-head'; const remoteName = `pr-${sanitizeRemoteName(remoteSeed)}`; // Prefer HTTPS so anonymous public fetches do not require SSH agent setup. const remoteUrl = pr.headRepo?.cloneUrl || pr.headRepo?.sshUrl || ''; if (!remoteUrl) { throw new Error( 'PR head repository URL is unavailable. The fork may have been deleted; ' + 'push the branch to a reachable repository and try again.' ); } return { existingBranch: `remotes/${remoteName}/${headBranch}`, setUpstream: true as const, upstreamRemote: remoteName, upstreamBranch: headBranch, ensureRemoteName: remoteName, ensureRemoteUrl: remoteUrl, sourceLabel: `${remoteName}/${headBranch}`, }; }; const slugifyWorktreeName = (value: string): string => { return value .trim() .replace(/^refs\/heads\//, '') .replace(/^heads\//, '') .replace(/\s+/g, '-') .replace(/^\/+|\/+$/g, '') .split('/').join('-') .replace(/[^A-Za-z0-9._-]+/g, '-') .replace(/-+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 80); }; interface NewWorktreeDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onWorktreeCreated?: (worktreePath: string, options?: { sessionId?: string }) => void; } const buildIssueContextText = (args: { repo: GitHubIssuesListResult['repo'] | undefined; issue: GitHubIssue; comments: GitHubIssueComment[]; }) => { const payload = { repo: args.repo ?? null, issue: args.issue, comments: args.comments, }; return `GitHub issue context (JSON)\n${JSON.stringify(payload, null, 2)}`; }; const buildPullRequestContextText = (payload: GitHubPullRequestContextResult) => { return `GitHub pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`; }; const buildLinearIssueContextText = (args: { issue: LinearIssue; comments: LinearIssueComment[]; }) => { const payload = { issue: args.issue, comments: args.comments, }; return `Linear issue context (JSON)\n${JSON.stringify(payload, null, 2)}`; }; export function NewWorktreeDialog({ open, onOpenChange, onWorktreeCreated, }: NewWorktreeDialogProps) { const { t } = useI18n(); const { github, git, linear } = useRuntimeAPIs(); const isMobile = useUIStore((state) => state.isMobile); const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); const linearAuthStatus = useLinearAuthStore((state) => state.status); const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked); const activeProject = useProjectsStore((state) => state.getActiveProject()); const projectDirectory = activeProject?.path ?? null; const projectRef: ProjectRef | null = React.useMemo(() => { if (projectDirectory && activeProject) { return { id: activeProject.id, path: projectDirectory }; } return null; }, [activeProject, projectDirectory]); // Mode state const [mode, setMode] = React.useState('new-branch'); // Separate state for each mode (persisted when switching tabs) const [newBranchState, setNewBranchState] = React.useState({ branchName: '', worktreeName: '', isSyncingWorktreeName: true, sourceBranch: '', linkedIssue: null, linkedPr: null, linkedLinearIssue: null, includePrDiff: false, }); const [existingBranchState, setExistingBranchState] = React.useState({ selectedBranch: '', worktreeName: '', }); // Use cached branches from Git store (instant if already fetched) const branches = useGitBranches(projectDirectory); const isLoadingBranches = useGitLoadingBranches(projectDirectory); const fetchBranches = useGitStore((state) => state.fetchBranches); // Compute local and remote branch lists (same pattern as GitView) const localBranches = React.useMemo(() => { if (!branches?.all) return []; return branches.all .filter((branchName: string) => !branchName.startsWith('remotes/')) .sort(); }, [branches]); const remoteBranches = React.useMemo(() => { if (!branches?.all) return []; return branches.all .filter((branchName: string) => branchName.startsWith('remotes/')) .map((branchName: string) => branchName.replace(/^remotes\//, '')) .sort(); }, [branches]); // Get existing worktrees for the current project to avoid conflicts const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); const existingWorktreeNames = React.useMemo(() => { if (!projectDirectory) return new Set(); const worktrees = availableWorktreesByProject.get(projectDirectory) ?? []; return new Set(worktrees.map(wt => wt.name)); }, [availableWorktreesByProject, projectDirectory]); // Generate a unique slug that doesn't conflict with existing worktrees const generateUniqueSlug = React.useCallback((maxAttempts = 10): string => { for (let attempt = 0; attempt < maxAttempts; attempt++) { const slug = generateBranchSlug(); if (!existingWorktreeNames.has(slug)) { return slug; } } // Fallback: add timestamp if all attempts failed return `${generateBranchSlug()}-${Date.now().toString(36).slice(-4)}`; }, [existingWorktreeNames]); const [githubDialogOpen, setGithubDialogOpen] = React.useState(false); const [linearDialogOpen, setLinearDialogOpen] = React.useState(false); // Desktop branch picker states const [existingBranchDropdownOpen, setExistingBranchDropdownOpen] = React.useState(false); const [sourceBranchDropdownOpen, setSourceBranchDropdownOpen] = React.useState(false); // Mobile branch picker states const [existingBranchPickerOpen, setExistingBranchPickerOpen] = React.useState(false); const [sourceBranchPickerOpen, setSourceBranchPickerOpen] = React.useState(false); // Shared query state per picker (desktop + mobile) const [existingBranchQuery, setExistingBranchQuery] = React.useState(''); const [sourceBranchQuery, setSourceBranchQuery] = React.useState(''); const existingBranchDropdownContentRef = React.useRef(null); const sourceBranchDropdownContentRef = React.useRef(null); const existingBranchMobileListWrapperRef = React.useRef(null); const sourceBranchMobileListWrapperRef = React.useRef(null); const stopDropdownTypeahead = React.useCallback((event: React.KeyboardEvent) => { event.stopPropagation(); }, []); const findScrollableContainer = React.useCallback((startNode: HTMLElement | null): HTMLElement | null => { let node: HTMLElement | null = startNode; while (node && node !== document.body) { const { overflowY } = window.getComputedStyle(node); if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) { return node; } node = node.parentElement; } return null; }, []); const resetScrollToTop = React.useCallback((container: HTMLElement | null) => { if (!container) { return; } container.scrollTop = 0; }, []); const resetDesktopPickerScroll = React.useCallback((contentRef: React.RefObject) => { const list = contentRef.current?.querySelector('[data-slot="command-list"]') ?? null; resetScrollToTop(list); }, [resetScrollToTop]); const resetMobilePickerScroll = React.useCallback((wrapperRef: React.RefObject) => { const scrollContainer = findScrollableContainer(wrapperRef.current); resetScrollToTop(scrollContainer); }, [findScrollableContainer, resetScrollToTop]); const existingBranchRankedGroups = React.useMemo(() => { return rankBranchesForQuery({ localBranches, remoteBranches, query: existingBranchQuery, }); }, [localBranches, remoteBranches, existingBranchQuery]); const sourceBranchRankedGroups = React.useMemo(() => { return rankBranchesForQuery({ localBranches, remoteBranches, query: sourceBranchQuery, }); }, [localBranches, remoteBranches, sourceBranchQuery]); const hasExistingBranchQuery = existingBranchQuery.trim().length > 0; const hasSourceBranchQuery = sourceBranchQuery.trim().length > 0; const hasExistingBranchMatches = existingBranchRankedGroups.matching.length > 0; const hasSourceBranchMatches = sourceBranchRankedGroups.matching.length > 0; const canFetchBranches = Boolean(projectDirectory && git); const handleFetchBranches = React.useCallback(() => { if (!projectDirectory || !git) { return; } void fetchBranches(projectDirectory, git); }, [projectDirectory, git, fetchBranches]); React.useEffect(() => { if (!open || !projectDirectory || !git) return; if (branches?.all) return; void fetchBranches(projectDirectory, git); }, [open, projectDirectory, git, branches?.all, fetchBranches]); React.useEffect(() => { if (!existingBranchDropdownOpen && !existingBranchPickerOpen) { setExistingBranchQuery(''); } }, [existingBranchDropdownOpen, existingBranchPickerOpen]); React.useEffect(() => { if (!sourceBranchDropdownOpen && !sourceBranchPickerOpen) { setSourceBranchQuery(''); } }, [sourceBranchDropdownOpen, sourceBranchPickerOpen]); React.useEffect(() => { if (existingBranchDropdownOpen) { resetDesktopPickerScroll(existingBranchDropdownContentRef); } if (existingBranchPickerOpen) { resetMobilePickerScroll(existingBranchMobileListWrapperRef); } }, [ existingBranchDropdownOpen, existingBranchPickerOpen, existingBranchQuery, resetDesktopPickerScroll, resetMobilePickerScroll, ]); React.useEffect(() => { if (sourceBranchDropdownOpen) { resetDesktopPickerScroll(sourceBranchDropdownContentRef); } if (sourceBranchPickerOpen) { resetMobilePickerScroll(sourceBranchMobileListWrapperRef); } }, [ sourceBranchDropdownOpen, sourceBranchPickerOpen, sourceBranchQuery, resetDesktopPickerScroll, resetMobilePickerScroll, ]); // Validation state const [validation, setValidation] = React.useState({ isValidating: false, branchError: null, worktreeError: null, touched: false, }); // Creation state const [isCreating, setIsCreating] = React.useState(false); const [validationAbortController, setValidationAbortController] = React.useState(null); const resolveDefaultAgentName = React.useCallback((): string | undefined => { const configState = useConfigStore.getState(); const visibleAgents = configState.getVisibleAgents(); if (configState.settingsDefaultAgent) { const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent); if (settingsAgent) { return settingsAgent.name; } } return visibleAgents.find((agent) => agent.name === 'build')?.name || visibleAgents[0]?.name; }, []); const resolveDefaultModelSelection = React.useCallback((): { providerID: string; modelID: string } | null => { const configState = useConfigStore.getState(); const settingsDefaultModel = configState.settingsDefaultModel; if (!settingsDefaultModel) return null; const parsed = parseModelIdentifier(settingsDefaultModel); if (!parsed) return null; const { providerId: providerID, modelId: modelID } = parsed; const modelMetadata = configState.getModelMetadata(providerID, modelID); if (!modelMetadata) return null; return { providerID, modelID }; }, []); const resolveDefaultVariant = React.useCallback((providerID: string, modelID: string): string | undefined => { const configState = useConfigStore.getState(); const settingsDefaultVariant = configState.settingsDefaultVariant; const currentVariant = configState.currentProviderId === providerID && configState.currentModelId === modelID ? configState.currentVariant : undefined; const provider = configState.providers.find((p) => p.id === providerID); const model = provider?.models.find((m: Record) => (m as { id?: string }).id === modelID) as | { variants?: Record } | undefined; const variants = model?.variants; if (!variants) return settingsDefaultVariant || currentVariant || undefined; if (settingsDefaultVariant && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) return settingsDefaultVariant; if (currentVariant && Object.prototype.hasOwnProperty.call(variants, currentVariant)) return currentVariant; return undefined; }, []); const sendLinkedContextMessage = React.useCallback(async (args: { sessionId: string; directory: string; issue: GitHubIssue | null; pr: GitHubPullRequestSummary | null; linearIssue: LinkedLinearWorktreeIssue | null; includeDiff: boolean; }) => { const configState = useConfigStore.getState(); const lastUsedProvider = useSelectionStore.getState().lastUsedProvider; const defaultModel = resolveDefaultModelSelection(); const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID; const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID; const agentName = resolveDefaultAgentName() || configState.currentAgentName || undefined; if (!providerID || !modelID) { toast.error(t('session.newWorktree.error.noModelSelected')); return; } const variant = resolveDefaultVariant(providerID, modelID); if (args.linearIssue) { if (!linear?.issueGet) { return; } const issueRes = await linear.issueGet(args.linearIssue.identifier); if (issueRes.connected === false || !issueRes.issue) { throw new Error('Failed to load issue context'); } const issue = issueRes.issue; const comments = issue.comments ?? []; const login = issue.assignee?.displayName || issue.assignee?.name; const visiblePromptText = await renderMagicPrompt('linear.issue.review.visible', { identifier: issue.identifier, }); const instructionsText = await renderMagicPrompt('linear.issue.review.instructions'); const contextText = buildLinearIssueContextText({ issue, comments }); postLinearSessionStarted(linear, { sessionId: args.sessionId, issueIdentifier: issue.identifier, }); await useSessionUIStore.getState().sendMessage( visiblePromptText, providerID, modelID, agentName, undefined, undefined, [ { text: instructionsText, synthetic: true }, { text: contextText, synthetic: true }, ], variant, undefined, { sessionId: args.sessionId, directory: args.directory }, ); void sessionActions.setLinkedIssue( args.sessionId, args.directory, buildLinkedLinearIssue({ identifier: issue.identifier, title: issue.title, url: issue.url, author: login ? { login, avatarUrl: issue.assignee?.avatarUrl || undefined } : args.linearIssue.author, linkedAt: Date.now(), }), true, ).catch(() => undefined); toast.success(t('session.newWorktree.toast.sessionFromIssue')); return; } if (!projectDirectory || !github) { return; } if (args.issue) { if (!github.issueGet || !github.issueComments) { return; } const issueRes = await github.issueGet(projectDirectory, args.issue.number, { sourceRepo: args.issue.sourceRepo ?? null }); if (issueRes.connected === false || !issueRes.repo || !issueRes.issue) { throw new Error('Failed to load issue context'); } const commentsRes = await github.issueComments(projectDirectory, args.issue.number, { sourceRepo: args.issue.sourceRepo ?? null }); if (commentsRes.connected === false) { throw new Error('Failed to load issue comments'); } const visiblePromptText = await renderMagicPrompt('github.issue.review.visible', { issue_number: String(args.issue.number), }); const instructionsText = await renderMagicPrompt('github.issue.review.instructions'); const contextText = buildIssueContextText({ repo: issueRes.repo, issue: issueRes.issue, comments: commentsRes.comments ?? [], }); await useSessionUIStore.getState().sendMessage( visiblePromptText, providerID, modelID, agentName, undefined, undefined, [ { text: instructionsText, synthetic: true }, { text: contextText, synthetic: true }, ], variant, undefined, { sessionId: args.sessionId }, ); // Record the thread this worktree session was created for, so it stays // visible as a context source after the opening message scrolls away. void sessionActions.setLinkedIssue( args.sessionId, args.directory, buildLinkedIssue({ url: issueRes.issue.url, number: issueRes.issue.number, title: issueRes.issue.title, kind: 'issue', author: issueRes.issue.author, linkedAt: Date.now(), }), true, ).catch(() => undefined); toast.success(t('session.newWorktree.toast.sessionFromIssue')); return; } if (args.pr) { if (!github.prContext) { return; } const prContext = await github.prContext(projectDirectory, args.pr.number, { sourceRepo: args.pr.sourceRepo ?? null, includeDiff: args.includeDiff, includeCheckDetails: false, }); if (prContext.connected === false || !prContext.repo || !prContext.pr) { throw new Error('Failed to load PR context'); } const visiblePromptText = await renderMagicPrompt('github.pr.review.visible', { pr_number: String(args.pr.number), }); const instructionsText = await renderMagicPrompt('github.pr.review.instructions'); const contextText = buildPullRequestContextText(prContext); await useSessionUIStore.getState().sendMessage( visiblePromptText, providerID, modelID, agentName, undefined, undefined, [ { text: instructionsText, synthetic: true }, { text: contextText, synthetic: true }, ], variant, undefined, { sessionId: args.sessionId }, ); void sessionActions.setLinkedIssue( args.sessionId, args.directory, buildLinkedIssue({ url: prContext.pr.url, number: prContext.pr.number, title: prContext.pr.title, kind: 'pull', author: prContext.pr.author, linkedAt: Date.now(), }), true, ).catch(() => undefined); toast.success(t('session.newWorktree.toast.sessionFromPr')); } }, [ github, linear, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, t, ]); // Get current state based on mode const currentState = mode === 'new-branch' ? newBranchState : existingBranchState; // Set default source branch when the dialog opens and branches become available React.useEffect(() => { if (!open || !branches?.all || !projectDirectory) return; if (newBranchState.sourceBranch) return; const currentSourceBranch = newBranchState.sourceBranch; let cancelled = false; const loadDefaultSourceBranch = async () => { try { const rootBranch = await getRootBranch(projectDirectory).catch(() => null); if (cancelled) return; const savedSourceBranch = localStorage.getItem(LAST_WORKTREE_SOURCE_BRANCH_KEY); const { sourceBranch: defaultSourceBranch, shouldClearSavedSourceBranch, } = resolveWorktreeSourceBranchPreference({ branches: branches.all, savedSourceBranch, rootBranch, }); if (shouldClearSavedSourceBranch) { localStorage.removeItem(LAST_WORKTREE_SOURCE_BRANCH_KEY); } if (cancelled || currentSourceBranch) return; if (defaultSourceBranch) { setNewBranchState(prev => ({ ...prev, sourceBranch: defaultSourceBranch, })); } } catch { // ignore } }; void loadDefaultSourceBranch(); return () => { cancelled = true; }; }, [open, branches?.all, projectDirectory, newBranchState.sourceBranch]); // Reset state on each open. Resetting on close would empty the form during // the close animation, causing visible flicker. React.useLayoutEffect(() => { if (!open) return; setMode('new-branch'); setExistingBranchState({ selectedBranch: '', worktreeName: '', }); setExistingBranchDropdownOpen(false); setSourceBranchDropdownOpen(false); setExistingBranchPickerOpen(false); setSourceBranchPickerOpen(false); setExistingBranchQuery(''); setSourceBranchQuery(''); setValidation({ isValidating: false, branchError: null, worktreeError: null, touched: false, }); const uniqueSlug = generateUniqueSlug(); setNewBranchState({ branchName: uniqueSlug, worktreeName: uniqueSlug, isSyncingWorktreeName: true, sourceBranch: '', linkedIssue: null, linkedPr: null, linkedLinearIssue: null, includePrDiff: false, }); }, [open, generateUniqueSlug]); // Sync worktree name with branch name for new-branch mode React.useEffect(() => { if (mode !== 'new-branch' || !newBranchState.isSyncingWorktreeName) return; const normalizedBranch = normalizeBranchName(newBranchState.branchName); const newWorktreeName = slugifyWorktreeName(normalizedBranch); setNewBranchState(prev => ({ ...prev, worktreeName: newWorktreeName })); }, [mode, newBranchState.branchName, newBranchState.isSyncingWorktreeName]); // Validation - only runs after fields are touched const validateInputs = React.useCallback(async () => { if (!projectRef || !validation.touched || isCreating) return; // Cancel previous validation if (validationAbortController) { validationAbortController.abort(); } const abortController = new AbortController(); setValidationAbortController(abortController); setValidation(prev => ({ ...prev, isValidating: true })); try { const branchName = mode === 'new-branch' ? newBranchState.branchName : existingBranchState.selectedBranch; const worktreeName = currentState.worktreeName; const normalizedBranch = normalizeBranchName(branchName); const normalizedWorktree = slugifyWorktreeName(worktreeName); let branchError: string | null = null; let worktreeError: string | null = null; if (!normalizedBranch) { branchError = t('session.newWorktree.error.branchNameRequired'); } if (!normalizedWorktree) { worktreeError = t('session.newWorktree.error.worktreeDirectoryRequired'); } // Only run server validation if we have values if (normalizedBranch && normalizedWorktree) { const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null; const prConfig = linkedPr ? resolvePrWorktreeConfig(linkedPr, localBranches, remoteBranches) : null; const result = await validateWorktreeCreate(projectRef, { mode: mode === 'existing-branch' || prConfig ? 'existing' : 'new', branchName: normalizedBranch, worktreeName: normalizedWorktree, existingBranch: prConfig?.existingBranch ?? (mode === 'existing-branch' ? normalizedBranch : undefined), ...(prConfig?.ensureRemoteName ? { ensureRemoteName: prConfig.ensureRemoteName } : {}), ...(prConfig?.ensureRemoteUrl ? { ensureRemoteUrl: prConfig.ensureRemoteUrl } : {}), }); if (abortController.signal.aborted) return; if (!result.ok) { result.errors.forEach((error) => { if (error.code === 'worktree_exists') { worktreeError = worktreeError ?? error.message; return; } if (error.code.startsWith('branch_')) { branchError = branchError ?? error.message; } }); } } if (!abortController.signal.aborted) { setValidation(prev => ({ ...prev, isValidating: false, branchError, worktreeError, })); } } catch { if (!abortController.signal.aborted) { setValidation(prev => ({ ...prev, isValidating: false, })); } } }, [ projectRef, mode, newBranchState.branchName, newBranchState.linkedPr, existingBranchState.selectedBranch, currentState.worktreeName, localBranches, remoteBranches, validation.touched, validationAbortController, isCreating, t, ]); // Extract branch name for dependency array const currentBranchName = mode === 'new-branch' ? newBranchState.branchName : existingBranchState.selectedBranch; // Trigger validation on input changes (only after touched) React.useEffect(() => { if (!open || !projectRef || !validation.touched || isCreating) return; const timer = setTimeout(() => { void validateInputs(); }, 300); return () => clearTimeout(timer); }, [currentState.worktreeName, currentBranchName, open, projectRef, validateInputs, validation.touched, isCreating]); // Handle worktree creation const handleCreate = async () => { if (!projectRef || !projectDirectory) { toast.error(t('session.newWorktree.error.noActiveProject')); return; } // Mark as touched and validate immediately setValidation(prev => ({ ...prev, touched: true })); const branchName = mode === 'new-branch' ? newBranchState.branchName : existingBranchState.selectedBranch; const worktreeName = currentState.worktreeName; const normalizedBranch = normalizeBranchName(branchName); const normalizedWorktree = slugifyWorktreeName(worktreeName); if (!normalizedBranch) { toast.error(t('session.newWorktree.error.branchNameRequired')); return; } if (!normalizedWorktree) { toast.error(t('session.newWorktree.error.worktreeDirectoryRequired')); return; } if (validationAbortController) { validationAbortController.abort(); setValidationAbortController(null); } setValidation((prev) => ({ ...prev, isValidating: false, branchError: null, worktreeError: null, })); setIsCreating(true); try { const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null; const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null; const linkedLinearIssue = mode === 'new-branch' ? newBranchState.linkedLinearIssue : null; const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null; const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false; const shouldCreateSession = Boolean(linkedIssue || linkedPrState || linkedLinearIssue); const setupCommands = await getWorktreeSetupCommands(projectRef); const sourceBranch = newBranchState.sourceBranch; let sourceLabel = ''; const args = (() => { if (linkedPr) { const prConfig = resolvePrWorktreeConfig(linkedPr, localBranches, remoteBranches); sourceLabel = prConfig.sourceLabel; return { preferredName: normalizedBranch || normalizedWorktree, mode: 'existing' as const, branchName: normalizedBranch, worktreeName: normalizedWorktree, existingBranch: prConfig.existingBranch, setupCommands, setUpstream: prConfig.setUpstream, upstreamRemote: prConfig.upstreamRemote, upstreamBranch: prConfig.upstreamBranch, returnAfterDirectoryCreated: true, ...(prConfig.ensureRemoteName ? { ensureRemoteName: prConfig.ensureRemoteName } : {}), ...(prConfig.ensureRemoteUrl ? { ensureRemoteUrl: prConfig.ensureRemoteUrl } : {}), }; } sourceLabel = mode === 'new-branch' ? sourceBranch : ''; return { preferredName: normalizedBranch || normalizedWorktree, mode: mode === 'existing-branch' ? 'existing' as const : 'new' as const, branchName: mode === 'existing-branch' ? undefined : normalizedBranch, worktreeName: normalizedWorktree, existingBranch: mode === 'existing-branch' ? normalizedBranch : undefined, setupCommands, returnAfterDirectoryCreated: true, ...(sourceBranch && mode === 'new-branch' ? { startRef: sourceBranch } : {}), }; })(); const resolvedArgs = await withWorktreeUpstreamDefaults(projectDirectory, args); const metadata = await createWorktree(projectRef, resolvedArgs); let createdSessionId: string | null = null; if (shouldCreateSession) { if (await getWorktreeSetupWaitEnabled(projectRef)) { await waitForWorktreeBootstrap(metadata.path); } const sessionTitle = linkedLinearIssue ? `${linkedLinearIssue.identifier} ${linkedLinearIssue.title}`.trim() : linkedIssue ? `#${linkedIssue.number} ${linkedIssue.title}`.trim() : linkedPrState ? `#${linkedPrState.number} ${linkedPrState.title}`.trim() : t('session.newWorktree.newSessionTitle'); const session = await sessionActions.createSession(sessionTitle, metadata.path, null); if (!session?.id) { throw new Error('Failed to create session'); } createdSessionId = session.id; onWorktreeCreated?.(metadata.path, { sessionId: createdSessionId }); onOpenChange(false); setIsCreating(false); void sessionActions.updateSessionTitle(session.id, sessionTitle).catch(() => undefined); try { useSessionUIStore.getState().initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents); } catch { // ignore } } else { onOpenChange(false); setIsCreating(false); } // Save the last source-branch choice for the next open. const lastSourceBranch = resolveWorktreeSourceBranchToPersist({ mode, sourceBranch: newBranchState.sourceBranch, linkedPr: !!newBranchState.linkedPr, selectedBranch: existingBranchState.selectedBranch, }); if (lastSourceBranch) { localStorage.setItem(LAST_WORKTREE_SOURCE_BRANCH_KEY, lastSourceBranch); } toast.success(t('session.newWorktree.toast.worktreeCreated'), { description: t('session.newWorktree.toast.worktreeCreatedDescription', { target: `${metadata.branch || metadata.name}${sourceLabel ? ` ${t('session.newWorktree.fromSource', { source: sourceLabel })}` : ''}`, }), }); if (createdSessionId) { void sendLinkedContextMessage({ sessionId: createdSessionId, directory: metadata.path, issue: linkedIssue, pr: linkedPrState, linearIssue: linkedLinearIssue, includeDiff: includePrDiff, }).catch((error) => { const fallback = linkedLinearIssue ? t('session.newWorktree.error.sendLinearContextFailed') : t('session.newWorktree.error.sendGitHubContextFailed'); const message = error instanceof Error ? error.message : fallback; toast.error(fallback, { description: message }); }); } else { onWorktreeCreated?.(metadata.path); } } catch (error) { const message = error instanceof Error ? error.message : t('session.newWorktree.error.createWorktreeFailed'); toast.error(t('session.newWorktree.error.createWorktreeFailed'), { description: message }); } finally { setIsCreating(false); } }; // Handle mode change const handleModeChange = (newMode: Mode) => { setMode(newMode); setValidation(prev => ({ ...prev, touched: false, branchError: null, worktreeError: null })); }; // Handle GitHub selection const handleGitHubSelect = (result: { type: 'issue' | 'pr'; item: GitHubIssue | GitHubPullRequestSummary; includeDiff?: boolean; } | null) => { if (!result) { setNewBranchState(prev => ({ ...prev, linkedIssue: null, linkedPr: null, linkedLinearIssue: null, includePrDiff: false, branchName: '', })); return; } if (result.type === 'issue') { const issue = result.item as GitHubIssue; const newBranchName = `issue-${issue.number}-${generateBranchSlug()}`; setNewBranchState(prev => ({ ...prev, linkedIssue: issue, linkedPr: null, linkedLinearIssue: null, includePrDiff: false, branchName: newBranchName, worktreeName: slugifyWorktreeName(newBranchName), isSyncingWorktreeName: true, })); } else if (result.type === 'pr') { const pr = result.item as GitHubPullRequestSummary; setNewBranchState(prev => ({ ...prev, linkedPr: pr, linkedIssue: null, linkedLinearIssue: null, includePrDiff: result.includeDiff ?? false, branchName: pr.head, worktreeName: slugifyWorktreeName(pr.head), isSyncingWorktreeName: true, })); } }; const handleLinearSelect = (issue: { identifier: string; title: string; url: string; author?: { login: string; avatarUrl?: string }; }) => { const newBranchName = `issue-${issue.identifier}-${generateBranchSlug()}`; setNewBranchState(prev => ({ ...prev, linkedLinearIssue: { identifier: issue.identifier, title: issue.title, url: issue.url, author: issue.author, }, linkedIssue: null, linkedPr: null, includePrDiff: false, branchName: newBranchName, worktreeName: slugifyWorktreeName(newBranchName), isSyncingWorktreeName: true, })); }; // GitHub connection check const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true; const isLinearConnected = Boolean(linear) && linearAuthChecked && linearAuthStatus?.connected === true; // Check if form is valid for submission const isFormValid = mode === 'existing-branch' ? !!existingBranchState.selectedBranch && !!existingBranchState.worktreeName && !validation.branchError && !validation.worktreeError : !!normalizeBranchName(newBranchState.branchName) && !!newBranchState.worktreeName && !validation.branchError && !validation.worktreeError; const canCreate = isFormValid && !isCreating; const handleClearLinkedItem = () => { setNewBranchState(prev => ({ ...prev, linkedIssue: null, linkedPr: null, linkedLinearIssue: null, branchName: '', includePrDiff: false, isSyncingWorktreeName: true, })); }; const startFromIssueButtons = mode === 'new-branch' && (isGitHubConnected || isLinearConnected) ? (
{isGitHubConnected && ( )} {isLinearConnected && ( )}
) : null; // Footer content const footerContent = (
{/* Validation error */}
{validation.touched && (validation.branchError || validation.worktreeError) && ( <> {validation.branchError || validation.worktreeError} )}
{/* Buttons */}
); return ( <> {isMobile ? ( onOpenChange(false)} footer={footerContent} > {/* Mode Selection - using SortableTabsStrip */}
}, { id: 'existing-branch', label: t('session.newWorktree.mode.existingBranch'), icon: }, ]} activeId={mode} onSelect={(id) => handleModeChange(id as Mode)} variant="active-pill" layoutMode="fit" className="w-full" />
{/* Branch Name / Existing Branch Selection */} {mode === 'existing-branch' ? (
{/* Mobile Branch Picker Overlay */} setExistingBranchPickerOpen(false)} >
setExistingBranchQuery(e.target.value)} placeholder={t('session.newWorktree.searchBranches')} className="h-8" /> {isLoadingBranches ? (
{t('session.newWorktree.loadingBranches')}
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
{t('session.newWorktree.noBranchesFound')}
) : (
{hasExistingBranchQuery && hasExistingBranchMatches && (
{t('session.newWorktree.matchingBranches')}
{existingBranchRankedGroups.matching.map((branch) => ( ))}
)} {hasExistingBranchQuery && !hasExistingBranchMatches && (
{t('session.newWorktree.noMatchingBranches')}
)} {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && (
{t('session.newWorktree.localBranches')}
{existingBranchRankedGroups.otherLocal.map((branch) => ( ))}
)} {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && (
{t('session.newWorktree.remoteBranches')}
{existingBranchRankedGroups.otherRemote.map((branch) => ( ))}
)}
)}
) : (
{startFromIssueButtons}
{ setNewBranchState(prev => ({ ...prev, branchName: e.target.value, isSyncingWorktreeName: true, linkedIssue: null, linkedPr: null, linkedLinearIssue: null, })); }} onBlur={() => setValidation(prev => ({ ...prev, touched: true }))} placeholder={t('session.newWorktree.branchNamePlaceholder')} disabled={!!newBranchState.linkedPr} className={cn( 'h-8', validation.touched && validation.branchError && 'border-destructive', newBranchState.linkedPr && 'bg-muted text-muted-foreground' )} /> {newBranchState.linkedPr && (
{t('session.newWorktree.usingPrBranch', { branch: newBranchState.linkedPr.head })}
)} {newBranchState.linkedIssue && !newBranchState.linkedPr && (
{t('session.newWorktree.fromIssue', { number: newBranchState.linkedIssue.number, title: newBranchState.linkedIssue.title })}
)} {newBranchState.linkedLinearIssue && (
{t('session.newWorktree.fromLinearIssue', { identifier: newBranchState.linkedLinearIssue.identifier, title: newBranchState.linkedLinearIssue.title, })}
)}
)} {/* Worktree Directory */}
{mode !== 'existing-branch' && ( )}
{ if (mode === 'new-branch') { setNewBranchState(prev => ({ ...prev, worktreeName: e.target.value, isSyncingWorktreeName: false, })); } else { setExistingBranchState(prev => ({ ...prev, worktreeName: e.target.value, })); } }} onBlur={() => setValidation(prev => ({ ...prev, touched: true }))} placeholder={t('session.newWorktree.worktreeDirectoryPlaceholder')} className={cn( 'h-8', validation.touched && validation.worktreeError && 'border-destructive' )} />
{/* Source Branch - Only for New Branch mode, hide when PR is selected */} {mode === 'new-branch' && !newBranchState.linkedPr && (
{newBranchState.sourceBranch && (
{t('session.newWorktree.newBranchFromSource', { source: newBranchState.sourceBranch })}
)} {/* Mobile Source Branch Picker Overlay */} setSourceBranchPickerOpen(false)} >
setSourceBranchQuery(e.target.value)} placeholder={t('session.newWorktree.searchBranches')} className="h-8" /> {isLoadingBranches ? (
{t('session.newWorktree.loadingBranches')}
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
{t('session.newWorktree.noBranchesFound')}
) : (
{hasSourceBranchQuery && hasSourceBranchMatches && (
{t('session.newWorktree.matchingBranches')}
{sourceBranchRankedGroups.matching.map((branch) => ( ))}
)} {hasSourceBranchQuery && !hasSourceBranchMatches && (
{t('session.newWorktree.noMatchingBranches')}
)} {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && (
{t('session.newWorktree.localBranches')}
{sourceBranchRankedGroups.otherLocal.map((branch) => ( ))}
)} {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && (
{t('session.newWorktree.remoteBranches')}
{sourceBranchRankedGroups.otherRemote.map((branch) => ( ))}
)}
)}
)} {/* Linked Item Preview - Two row minimal display */} {(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedLinearIssue) && mode === 'new-branch' && (
{/* Row 1: Type, number, title, actions */}
{newBranchState.linkedLinearIssue && ( {newBranchState.linkedLinearIssue.identifier} )} {newBranchState.linkedIssue && ( {t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })} )} {newBranchState.linkedPr && ( {t('session.newWorktree.prNumber', { number: newBranchState.linkedPr.number })} )} {newBranchState.linkedLinearIssue?.title || newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title} e.stopPropagation()} >
{/* Row 2: PR branch info + diff indicator */} {newBranchState.linkedPr && (
{newBranchState.linkedPr.head} → {newBranchState.linkedPr.base} {newBranchState.includePrDiff && ( {t('session.newWorktree.includeDiffBadge')} )}
)}
)}
) : (
{t('session.newWorktree.title')} {/* Mode Selection - using SortableTabsStrip */}
}, { id: 'existing-branch', label: t('session.newWorktree.mode.existingBranch'), icon: }, ]} activeId={mode} onSelect={(id) => handleModeChange(id as Mode)} variant="active-pill" layoutMode="fit" className="w-full" />
{/* Branch Name / Existing Branch Selection */} {mode === 'existing-branch' ? (
{isLoadingBranches ? (
{t('session.newWorktree.loadingBranches')}
) : localBranches.length === 0 && remoteBranches.length === 0 ? ( {t('session.newWorktree.noBranchesFound')} ) : ( <> {hasExistingBranchQuery && hasExistingBranchMatches && ( {existingBranchRankedGroups.matching.map((branch) => ( { setExistingBranchState((prev) => ({ ...prev, selectedBranch: branch.value, worktreeName: slugifyWorktreeName(branch.label), })); setValidation((prev) => ({ ...prev, touched: true })); setExistingBranchDropdownOpen(false); }} > {branch.label} ))} )} {hasExistingBranchQuery && !hasExistingBranchMatches && (
{t('session.newWorktree.noMatchingBranches')}
)} {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && ( <> {existingBranchRankedGroups.otherLocal.map((branch) => ( { setExistingBranchState((prev) => ({ ...prev, selectedBranch: branch, worktreeName: slugifyWorktreeName(branch), })); setValidation((prev) => ({ ...prev, touched: true })); setExistingBranchDropdownOpen(false); }} > {branch} ))} )} {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && ( <> {existingBranchRankedGroups.otherLocal.length > 0 && ( )} {existingBranchRankedGroups.otherRemote.map((branch) => ( { setExistingBranchState((prev) => ({ ...prev, selectedBranch: `remotes/${branch}`, worktreeName: slugifyWorktreeName(branch), })); setValidation((prev) => ({ ...prev, touched: true })); setExistingBranchDropdownOpen(false); }} > {branch} ))} )} )}
) : (
{startFromIssueButtons}
{ setNewBranchState(prev => ({ ...prev, branchName: e.target.value, isSyncingWorktreeName: true, linkedIssue: null, linkedPr: null, linkedLinearIssue: null, })); }} onBlur={() => setValidation(prev => ({ ...prev, touched: true }))} placeholder={t('session.newWorktree.branchNamePlaceholder')} disabled={!!newBranchState.linkedPr} className={cn( 'h-8', validation.touched && validation.branchError && 'border-destructive', newBranchState.linkedPr && 'bg-muted text-muted-foreground' )} /> {newBranchState.linkedPr && (
{t('session.newWorktree.usingPrBranch', { branch: newBranchState.linkedPr.head })}
)} {newBranchState.linkedIssue && !newBranchState.linkedPr && (
{t('session.newWorktree.fromIssue', { number: newBranchState.linkedIssue.number, title: newBranchState.linkedIssue.title })}
)} {newBranchState.linkedLinearIssue && (
{t('session.newWorktree.fromLinearIssue', { identifier: newBranchState.linkedLinearIssue.identifier, title: newBranchState.linkedLinearIssue.title, })}
)}
)} {/* Worktree Directory */}
{mode !== 'existing-branch' && ( )}
{ if (mode === 'new-branch') { setNewBranchState(prev => ({ ...prev, worktreeName: e.target.value, isSyncingWorktreeName: false, })); } else { setExistingBranchState(prev => ({ ...prev, worktreeName: e.target.value, })); } }} onBlur={() => setValidation(prev => ({ ...prev, touched: true }))} placeholder={t('session.newWorktree.worktreeDirectoryPlaceholder')} className={cn( 'h-8', validation.touched && validation.worktreeError && 'border-destructive' )} />
{/* Source Branch - Only for New Branch mode, hide when PR is selected */} {mode === 'new-branch' && !newBranchState.linkedPr && (
{isLoadingBranches ? (
{t('session.newWorktree.loadingBranches')}
) : localBranches.length === 0 && remoteBranches.length === 0 ? ( {t('session.newWorktree.noBranchesFound')} ) : ( <> {hasSourceBranchQuery && hasSourceBranchMatches && ( {sourceBranchRankedGroups.matching.map((branch) => ( { setNewBranchState((prev) => ({ ...prev, sourceBranch: branch.value })); setSourceBranchDropdownOpen(false); }} > {branch.label} ))} )} {hasSourceBranchQuery && !hasSourceBranchMatches && (
{t('session.newWorktree.noMatchingBranches')}
)} {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && ( <> {sourceBranchRankedGroups.otherLocal.map((branch) => ( { setNewBranchState((prev) => ({ ...prev, sourceBranch: branch })); setSourceBranchDropdownOpen(false); }} > {branch} ))} )} {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && ( <> {sourceBranchRankedGroups.otherLocal.length > 0 && ( )} {sourceBranchRankedGroups.otherRemote.map((branch) => ( { setNewBranchState((prev) => ({ ...prev, sourceBranch: `remotes/${branch}` })); setSourceBranchDropdownOpen(false); }} > {branch} ))} )} )}
{newBranchState.sourceBranch && (
{t('session.newWorktree.newBranchFromSource', { source: newBranchState.sourceBranch })}
)}
)} {/* Linked Item Preview - Two row minimal display */} {(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedLinearIssue) && mode === 'new-branch' && (
{/* Row 1: Type, number, title, actions */}
{newBranchState.linkedLinearIssue && ( {newBranchState.linkedLinearIssue.identifier} )} {newBranchState.linkedIssue && ( {t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })} )} {newBranchState.linkedPr && ( {t('session.newWorktree.prNumber', { number: newBranchState.linkedPr.number })} )} {newBranchState.linkedLinearIssue?.title || newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title} e.stopPropagation()} >
{/* Row 2: PR branch info + diff indicator */} {newBranchState.linkedPr && (
{newBranchState.linkedPr.head} → {newBranchState.linkedPr.base} {newBranchState.includePrDiff && ( {t('session.newWorktree.includeDiffBadge')} )}
)}
)}
{/* Footer */} {/* Validation error - inline with buttons */}
{validation.touched && (validation.branchError || validation.worktreeError) && ( <> {validation.branchError || validation.worktreeError} )}
)} ); }