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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectLabel, SelectGroup, SelectSeparator, } from '@/components/ui/select'; import { RiGitBranchLine, RiGitRepositoryLine, RiGithubLine, RiLoader4Line, RiRefreshLine, RiErrorWarningLine, RiCheckLine, RiExternalLinkLine, RiCloseLine, } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useUIStore } from '@/stores/useUIStore'; import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useMessageStore } from '@/stores/messageStore'; import { useContextStore } from '@/stores/contextStore'; import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager'; import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate'; import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { generateBranchSlug } from '@/lib/git/branchNameGenerator'; import { opencodeClient } from '@/lib/opencode/client'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useGitBranches, useGitStore } from '@/stores/useGitStore'; import { GitHubIntegrationDialog } from './GitHubIntegrationDialog'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import type { GitHubIssue, GitHubIssueComment, GitHubIssuesListResult, GitHubPullRequestContextResult, GitHubPullRequestSummary, } from '@/lib/api/types'; import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; type Mode = 'new-branch' | 'existing-branch'; interface ValidationState { isValidating: boolean; branchError: string | null; worktreeError: string | null; touched: boolean; } // State for New Branch mode interface NewBranchState { branchName: string; worktreeName: string; isSyncingWorktreeName: boolean; sourceBranch: string; linkedIssue: GitHubIssue | null; linkedPr: GitHubPullRequestSummary | 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 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); }; const LAST_SOURCE_BRANCH_KEY = 'oc:lastWorktreeSourceBranch'; 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, remoteBranches: string[]) => { const headBranch = normalizeBranchName(pr.head || ''); if (!headBranch) { throw new Error('PR head branch is missing'); } 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)}`; const remoteUrl = pr.headRepo?.sshUrl || pr.headRepo?.cloneUrl || ''; if (!remoteUrl) { throw new Error('PR head repository URL is unavailable'); } return { existingBranch: `remotes/${remoteName}/${headBranch}`, setUpstream: true as const, upstreamRemote: remoteName, upstreamBranch: headBranch, ensureRemoteName: remoteName, ensureRemoteUrl: remoteUrl, sourceLabel: `${remoteName}/${headBranch}`, }; }; 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)}`; }; export function NewWorktreeDialog({ open, onOpenChange, onWorktreeCreated, }: NewWorktreeDialogProps) { const { github, git } = useRuntimeAPIs(); const isMobile = useUIStore((state) => state.isMobile); const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthChecked = useGitHubAuthStore((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, 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 = useGitStore((state) => state.isLoadingBranches); const fetchBranches = useGitStore((state) => state.fetchBranches); React.useEffect(() => { if (!open || !projectDirectory || !git) return; if (branches?.all) return; void fetchBranches(projectDirectory, git); }, [open, projectDirectory, git, branches?.all, 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 = useSessionStore((state) => state.availableWorktreesByProject); const loadSessions = useSessionStore((state) => state.loadSessions); 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); // Mobile branch picker states const [existingBranchPickerOpen, setExistingBranchPickerOpen] = React.useState(false); const [sourceBranchPickerOpen, setSourceBranchPickerOpen] = React.useState(false); // 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 parts = settingsDefaultModel.split('/'); if (parts.length !== 2) return null; const [providerID, modelID] = parts; if (!providerID || !modelID) return null; 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; if (!settingsDefaultVariant) return 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 undefined; if (!Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) return undefined; return settingsDefaultVariant; }, []); const applySessionModelAndAgentDefaults = React.useCallback((args: { sessionId: string; providerID: string; modelID: string; agentName?: string; variant?: string; }) => { const configState = useConfigStore.getState(); try { useContextStore.getState().saveSessionModelSelection(args.sessionId, args.providerID, args.modelID); } catch { // ignore } if (!args.agentName) { return; } try { configState.setAgent(args.agentName); } catch { // ignore } try { useContextStore.getState().saveSessionAgentSelection(args.sessionId, args.agentName); } catch { // ignore } try { useContextStore.getState().saveAgentModelForSession(args.sessionId, args.agentName, args.providerID, args.modelID); } catch { // ignore } if (args.variant !== undefined) { try { configState.setCurrentVariant(args.variant); } catch { // ignore } try { useContextStore .getState() .saveAgentModelVariantForSession(args.sessionId, args.agentName, args.providerID, args.modelID, args.variant); } catch { // ignore } } }, []); const sendLinkedContextMessage = React.useCallback(async (args: { sessionId: string; issue: GitHubIssue | null; pr: GitHubPullRequestSummary | null; includeDiff: boolean; }) => { if (!projectDirectory || !github) { return; } const configState = useConfigStore.getState(); const lastUsedProvider = useMessageStore.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('No model selected'); return; } const variant = resolveDefaultVariant(providerID, modelID); applySessionModelAndAgentDefaults({ sessionId: args.sessionId, providerID, modelID, agentName, variant, }); if (args.issue) { if (!github.issueGet || !github.issueComments) { return; } const issueRes = await github.issueGet(projectDirectory, args.issue.number); 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); if (commentsRes.connected === false) { throw new Error('Failed to load issue comments'); } const visiblePromptText = `Review this issue #${args.issue.number} using the provided issue context`; const instructionsText = `Review this issue using the provided issue context: title, body, labels, assignees, comments, metadata. Process: - First classify the issue type (bug / feature request / question/support / refactor / ops) and state it as: Type: . - Gather any needed repository context (code, config, docs) to validate assumptions. - After gathering, if anything is still unclear or cannot be verified, do not speculate-state what's missing and ask targeted questions. Output rules: - Compact output; pick ONE template below and omit the others. - No emojis. No code snippets. No fenced blocks. - Short inline code identifiers allowed. - Reference evidence with file paths and line ranges when applicable; if exact lines aren't available, cite the file and say "approx" + why. - Keep the entire response under ~300 words. Templates (choose one): Bug: - Summary (1-2 sentences) - Likely cause (max 2) - Repro/diagnostics needed (max 3) - Fix approach (max 4 steps) - Verification (max 3) Feature: - Summary (1-2 sentences) - Requirements (max 4) - Unknowns/questions (max 4) - Proposed plan (max 5 steps) - Verification (max 3) Question/Support: - Summary (1-2 sentences) - Answer/guidance (max 6 lines) - Missing info (max 4) Do not implement changes until I confirm; end with: "Next actions: <1 sentence>".`; const contextText = buildIssueContextText({ repo: issueRes.repo, issue: issueRes.issue, comments: commentsRes.comments ?? [], }); await opencodeClient.sendMessage({ id: args.sessionId, providerID, modelID, agent: agentName, variant, text: visiblePromptText, additionalParts: [ { text: instructionsText, synthetic: true }, { text: contextText, synthetic: true }, ], }); toast.success('Session created from issue'); return; } if (args.pr) { if (!github.prContext) { return; } const prContext = await github.prContext(projectDirectory, args.pr.number, { includeDiff: args.includeDiff, includeCheckDetails: false, }); if (prContext.connected === false || !prContext.repo || !prContext.pr) { throw new Error('Failed to load PR context'); } const visiblePromptText = `Review this pull request #${args.pr.number} using the provided PR context`; const instructionsText = `Before reporting issues: - First identify the PR intent (what it's trying to achieve) from title/body/diff, then evaluate whether the implementation matches that intent; call out missing pieces, incorrect behavior vs intent, and scope creep. - Gather any needed repository context (code, config, docs) to validate assumptions. - No speculation: if something is unclear or cannot be verified, say what's missing and ask for it instead of guessing. Output rules: - Start with a 1-2 sentence summary. - Provide a single concise PR review comment. - No emojis. No code snippets. No fenced blocks. - Short inline code identifiers allowed, but no snippets or fenced blocks. - Reference evidence with file paths and line ranges (e.g., path/to/file.ts:120-138). If exact lines aren't available, cite the file and say "approx" + why. - Keep the entire comment under ~300 words. Report: - Must-fix issues (blocking)-brief why and a one-line action each. - Nice-to-have improvements (optional)-brief why and a one-line action each. Quality & safety (general): - Call out correctness risks, edge cases, performance regressions, security/privacy concerns, and backwards-compatibility risks. - Call out missing tests/verification steps and suggest the minimal validation needed. - Note readability/maintainability issues when they materially affect future changes. Applicability (only if relevant): - If changes affect multiple components/targets/environments (e.g., client/server, OSs, deployments), state what is affected vs not, and why. Architecture: - Call out breakages, missing implementations across modules/targets, boundary violations, and cross-cutting concerns (errors, logging/observability, accessibility). Precedence: - If local precedent conflicts with best practices, state it and suggest a follow-up task. Do not implement changes until I confirm; end with a short "Next actions" sentence describing the recommended plan. Format exactly: Must-fix: - - - - Action: Nice-to-have: - - - - Action: If no issues, write: Must-fix: - None Nice-to-have: - None`; const contextText = buildPullRequestContextText(prContext); await opencodeClient.sendMessage({ id: args.sessionId, providerID, modelID, agent: agentName, variant, text: visiblePromptText, additionalParts: [ { text: instructionsText, synthetic: true }, { text: contextText, synthetic: true }, ], }); toast.success('Session created from PR'); } }, [ applySessionModelAndAgentDefaults, github, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, ]); // Get current state based on mode const currentState = mode === 'new-branch' ? newBranchState : existingBranchState; // Set default source branch when branches become available React.useEffect(() => { if (!branches?.all || !projectDirectory) return; if (newBranchState.sourceBranch) return; // Already set const loadDefaultSourceBranch = async () => { try { const rootBranch = await getRootBranch(projectDirectory).catch(() => null); const savedSourceBranch = localStorage.getItem(LAST_SOURCE_BRANCH_KEY); const defaultSourceBranch = savedSourceBranch && branches.all?.includes(savedSourceBranch) ? savedSourceBranch : rootBranch && branches.all?.includes(rootBranch) ? rootBranch : branches.all?.includes('main') ? 'main' : branches.all?.includes('master') ? 'master' : branches.all?.[0] || ''; if (defaultSourceBranch) { setNewBranchState(prev => ({ ...prev, sourceBranch: defaultSourceBranch, })); } } catch { // ignore } }; void loadDefaultSourceBranch(); }, [branches, projectDirectory, newBranchState.sourceBranch]); // Reset state when dialog opens/closes React.useEffect(() => { if (!open) { setMode('new-branch'); setNewBranchState({ branchName: '', worktreeName: '', isSyncingWorktreeName: true, sourceBranch: '', linkedIssue: null, linkedPr: null, includePrDiff: false, }); setExistingBranchState({ selectedBranch: '', worktreeName: '', }); setValidation({ isValidating: false, branchError: null, worktreeError: null, touched: false, }); return; } // Generate unique slug when dialog opens const uniqueSlug = generateUniqueSlug(); setNewBranchState(prev => ({ ...prev, branchName: uniqueSlug, worktreeName: uniqueSlug, isSyncingWorktreeName: true, })); }, [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 = 'Branch name is required'; } if (!normalizedWorktree) { worktreeError = 'Worktree directory is required'; } // Only run server validation if we have values if (normalizedBranch && normalizedWorktree) { const result = await validateWorktreeCreate(projectRef, { mode: mode === 'existing-branch' ? 'existing' : 'new', branchName: normalizedBranch, worktreeName: normalizedWorktree, existingBranch: mode === 'existing-branch' ? normalizedBranch : undefined, }); 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, existingBranchState.selectedBranch, currentState.worktreeName, validation.touched, validationAbortController, isCreating, ]); // 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('No active project'); 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('Branch name is required'); return; } if (!normalizedWorktree) { toast.error('Worktree directory is required'); return; } if (validationAbortController) { validationAbortController.abort(); setValidationAbortController(null); } setValidation((prev) => ({ ...prev, isValidating: false, branchError: null, worktreeError: null, })); setIsCreating(true); try { const setupCommands = await getWorktreeSetupCommands(projectRef); const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null; const sourceBranch = newBranchState.sourceBranch; let sourceLabel = ''; const args = (() => { if (linkedPr) { const prConfig = resolvePrWorktreeConfig(linkedPr, 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, ...(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, ...(sourceBranch && mode === 'new-branch' ? { startRef: sourceBranch } : {}), }; })(); const resolvedArgs = await withWorktreeUpstreamDefaults(projectDirectory, args); const metadata = await createWorktree(projectRef, resolvedArgs); const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null; const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null; const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false; let createdSessionId: string | null = null; if (linkedIssue || linkedPrState) { const sessionTitle = linkedIssue ? `#${linkedIssue.number} ${linkedIssue.title}`.trim() : linkedPrState ? `#${linkedPrState.number} ${linkedPrState.title}`.trim() : 'New session'; const session = await useSessionStore.getState().createSession(sessionTitle, metadata.path, null); if (!session?.id) { throw new Error('Failed to create session'); } createdSessionId = session.id; void useSessionStore.getState().updateSessionTitle(session.id, sessionTitle).catch(() => undefined); try { useSessionStore.getState().initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents); } catch { // ignore } } // Save source branch preference (only if not from PR) if (newBranchState.sourceBranch && mode === 'new-branch' && !newBranchState.linkedPr) { localStorage.setItem(LAST_SOURCE_BRANCH_KEY, newBranchState.sourceBranch); } toast.success('Worktree created', { description: `${metadata.branch || metadata.name}${sourceLabel ? ` from ${sourceLabel}` : ''}`, }); try { await loadSessions(); } catch { // best effort } onOpenChange(false); if (createdSessionId) { onWorktreeCreated?.(metadata.path, { sessionId: createdSessionId }); void sendLinkedContextMessage({ sessionId: createdSessionId, issue: linkedIssue, pr: linkedPrState, includeDiff: includePrDiff, }).catch((error) => { const message = error instanceof Error ? error.message : 'Failed to send GitHub context'; toast.error('Failed to send GitHub context', { description: message }); }); } else { onWorktreeCreated?.(metadata.path); } } catch (error) { const message = error instanceof Error ? error.message : 'Failed to create worktree'; toast.error('Failed to create worktree', { 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, 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, 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, includePrDiff: result.includeDiff ?? false, branchName: pr.head, worktreeName: slugifyWorktreeName(pr.head), isSyncingWorktreeName: true, })); } }; // GitHub connection check const isGitHubConnected = githubAuthChecked && githubAuthStatus?.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, branchName: '', includePrDiff: false, isSyncingWorktreeName: true, })); }; // 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: 'Existing Branch', 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)} >
{isLoadingBranches ? (
Loading branches...
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
No branches found
) : ( <> {localBranches.length > 0 && (
Local branches
{localBranches.map(branch => ( ))}
)} {remoteBranches.length > 0 && (
Remote branches
{remoteBranches.map(branch => ( ))}
)} )}
) : (
{mode === 'new-branch' && isGitHubConnected && ( )}
{ setNewBranchState(prev => ({ ...prev, branchName: e.target.value, isSyncingWorktreeName: true, linkedIssue: null, linkedPr: null, })); }} onBlur={() => setValidation(prev => ({ ...prev, touched: true }))} placeholder="feature/my-awesome-feature" disabled={!!newBranchState.linkedPr} className={cn( 'h-8', validation.touched && validation.branchError && 'border-destructive', newBranchState.linkedPr && 'bg-muted text-muted-foreground' )} /> {newBranchState.linkedPr && (
Using PR branch: {newBranchState.linkedPr.head}
)} {newBranchState.linkedIssue && !newBranchState.linkedPr && (
From issue #{newBranchState.linkedIssue.number}: {newBranchState.linkedIssue.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="my-worktree-directory" 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 && (
New branch will be created from {newBranchState.sourceBranch}
)} {/* Mobile Source Branch Picker Overlay */} setSourceBranchPickerOpen(false)} >
{isLoadingBranches ? (
Loading branches...
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
No branches found
) : ( <> {localBranches.length > 0 && (
Local branches
{localBranches.map(branch => ( ))}
)} {remoteBranches.length > 0 && (
Remote branches
{remoteBranches.map(branch => ( ))}
)} )}
)} {/* Linked Item Preview - Two row minimal display */} {(newBranchState.linkedIssue || newBranchState.linkedPr) && mode === 'new-branch' && (
{/* Row 1: Type, number, title, actions */}
{newBranchState.linkedIssue && ( Issue #{newBranchState.linkedIssue.number} )} {newBranchState.linkedPr && ( PR #{newBranchState.linkedPr.number} )} {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 && ( +diff )}
)}
)}
) : (
New Worktree {/* Mode Selection - using SortableTabsStrip */}
}, { id: 'existing-branch', label: 'Existing Branch', 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' ? (
) : (
{mode === 'new-branch' && isGitHubConnected && ( )}
{ setNewBranchState(prev => ({ ...prev, branchName: e.target.value, isSyncingWorktreeName: true, linkedIssue: null, linkedPr: null, })); }} onBlur={() => setValidation(prev => ({ ...prev, touched: true }))} placeholder="feature/my-awesome-feature" disabled={!!newBranchState.linkedPr} className={cn( 'h-8', validation.touched && validation.branchError && 'border-destructive', newBranchState.linkedPr && 'bg-muted text-muted-foreground' )} /> {newBranchState.linkedPr && (
Using PR branch: {newBranchState.linkedPr.head}
)} {newBranchState.linkedIssue && !newBranchState.linkedPr && (
From issue #{newBranchState.linkedIssue.number}: {newBranchState.linkedIssue.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="my-worktree-directory" 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 && (
New branch will be created from {newBranchState.sourceBranch}
)}
)} {/* Linked Item Preview - Two row minimal display */} {(newBranchState.linkedIssue || newBranchState.linkedPr) && mode === 'new-branch' && (
{/* Row 1: Type, number, title, actions */}
{newBranchState.linkedIssue && ( Issue #{newBranchState.linkedIssue.number} )} {newBranchState.linkedPr && ( PR #{newBranchState.linkedPr.number} )} {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 && ( +diff )}
)}
)}
{/* Footer */} {/* Validation error - inline with buttons */}
{validation.touched && (validation.branchError || validation.worktreeError) && ( <> {validation.branchError || validation.worktreeError} )}
)} ); }