Files
openchamber/packages/ui/src/components/session/NewWorktreeDialog.tsx
T

1765 lines
70 KiB
TypeScript
Raw Normal View History

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<Mode>('new-branch');
// Separate state for each mode (persisted when switching tabs)
const [newBranchState, setNewBranchState] = React.useState<NewBranchState>({
branchName: '',
worktreeName: '',
isSyncingWorktreeName: true,
sourceBranch: '',
linkedIssue: null,
linkedPr: null,
includePrDiff: false,
});
const [existingBranchState, setExistingBranchState] = React.useState<ExistingBranchState>({
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<string>();
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<ValidationState>({
isValidating: false,
branchError: null,
worktreeError: null,
touched: false,
});
// Creation state
const [isCreating, setIsCreating] = React.useState(false);
const [validationAbortController, setValidationAbortController] = React.useState<AbortController | null>(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<string, unknown>) => (m as { id?: string }).id === modelID) as
| { variants?: Record<string, unknown> }
| 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: <one label>.
- 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:
- <issue> - <brief why> - <file:line-range> - Action: <one-line action>
Nice-to-have:
- <issue> - <brief why> - <file:line-range> - Action: <one-line 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}` : ''} - bootstrapping in background`,
});
void loadSessions().catch(() => undefined);
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 = (
<div className={cn('flex gap-2', isMobile ? 'flex-col w-full' : 'flex-row items-center')}>
{/* Validation error */}
<div className={cn('flex items-center gap-1.5 text-destructive', isMobile ? 'w-full justify-center order-first' : 'mr-auto')}>
{validation.touched && (validation.branchError || validation.worktreeError) && (
<>
<RiErrorWarningLine className="h-3.5 w-3.5" />
<span className="typography-micro">
{validation.branchError || validation.worktreeError}
</span>
</>
)}
</div>
{/* Buttons */}
<div className={cn('flex gap-2', isMobile && 'w-full')}>
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={isCreating}
className={cn(isMobile && 'flex-1')}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleCreate}
disabled={!canCreate || isCreating}
className={cn('gap-1.5', isMobile && 'flex-1')}
>
{isCreating && <RiLoader4Line className="h-3.5 w-3.5 animate-spin" />}
{isCreating ? 'Creating...' : 'Create Worktree'}
</Button>
</div>
</div>
);
return (
<>
{isMobile ? (
<MobileOverlayPanel
open={open}
title="New Worktree"
onClose={() => onOpenChange(false)}
footer={footerContent}
>
{/* Mode Selection - using SortableTabsStrip */}
<div className="w-full mb-4">
<SortableTabsStrip
items={[
{ id: 'new-branch', label: 'New Branch', icon: <RiGitBranchLine className="h-3.5 w-3.5" /> },
{ id: 'existing-branch', label: 'Existing Branch', icon: <RiGitRepositoryLine className="h-3.5 w-3.5" /> },
]}
activeId={mode}
onSelect={(id) => handleModeChange(id as Mode)}
variant="active-pill"
layoutMode="fit"
className="w-full"
/>
</div>
<div className="space-y-6">
{/* Branch Name / Existing Branch Selection */}
{mode === 'existing-branch' ? (
<div className="space-y-1.5">
<label className="typography-ui-label text-foreground block font-semibold">
Select Branch
</label>
<Button
variant="outline"
size="sm"
onClick={() => setExistingBranchPickerOpen(true)}
className="w-full justify-between h-9"
>
<span className={existingBranchState.selectedBranch ? 'text-foreground' : 'text-muted-foreground'}>
{existingBranchState.selectedBranch || 'Choose a branch...'}
</span>
<RiGitBranchLine className="h-4 w-4 text-muted-foreground" />
</Button>
{/* Mobile Branch Picker Overlay */}
<MobileOverlayPanel
open={existingBranchPickerOpen}
title="Select Branch"
onClose={() => setExistingBranchPickerOpen(false)}
>
<div className="space-y-4">
{isLoadingBranches ? (
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
Loading branches...
</div>
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
No branches found
</div>
) : (
<>
{localBranches.length > 0 && (
<div className="space-y-2">
<div className="typography-small font-semibold text-foreground px-2">
Local branches
</div>
<div className="space-y-1">
{localBranches.map(branch => (
<button
key={branch}
onClick={() => {
setExistingBranchState(prev => ({
...prev,
selectedBranch: branch,
worktreeName: slugifyWorktreeName(branch),
}));
setValidation(prev => ({ ...prev, touched: true }));
setExistingBranchPickerOpen(false);
}}
className={cn(
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
existingBranchState.selectedBranch === branch
? 'bg-interactive-selection text-interactive-selection-foreground'
: 'hover:bg-interactive-hover'
)}
>
<span className="typography-small break-all">{branch}</span>
</button>
))}
</div>
</div>
)}
{remoteBranches.length > 0 && (
<div className="space-y-2">
<div className="typography-small font-semibold text-foreground px-2">
Remote branches
</div>
<div className="space-y-1">
{remoteBranches.map(branch => (
<button
key={`remotes/${branch}`}
onClick={() => {
setExistingBranchState(prev => ({
...prev,
selectedBranch: `remotes/${branch}`,
worktreeName: slugifyWorktreeName(branch),
}));
setValidation(prev => ({ ...prev, touched: true }));
setExistingBranchPickerOpen(false);
}}
className={cn(
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
existingBranchState.selectedBranch === `remotes/${branch}`
? 'bg-interactive-selection text-interactive-selection-foreground'
: 'hover:bg-interactive-hover'
)}
>
<span className="typography-small break-all">{branch}</span>
</button>
))}
</div>
</div>
)}
</>
)}
</div>
</MobileOverlayPanel>
</div>
) : (
<div className="space-y-1.5">
<div className="flex flex-col items-start gap-1.5">
<label className="typography-ui-label text-foreground block font-semibold">
Branch Name
</label>
{mode === 'new-branch' && isGitHubConnected && (
<Button
variant="outline"
size="sm"
onClick={() => setGithubDialogOpen(true)}
className="gap-1.5 h-7"
>
<RiGithubLine className="size-4 text-status-success" />
{newBranchState.linkedIssue || newBranchState.linkedPr ? 'Change' : 'Start from GitHub Issue/PR'}
</Button>
)}
</div>
<Input
value={newBranchState.branchName}
onChange={(e) => {
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 && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<RiCheckLine className="h-3.5 w-3.5 text-status-success" />
<span className="typography-micro">
Using PR branch: {newBranchState.linkedPr.head}
</span>
</div>
)}
{newBranchState.linkedIssue && !newBranchState.linkedPr && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<RiCheckLine className="h-3.5 w-3.5 text-status-success" />
<span className="typography-micro">
From issue #{newBranchState.linkedIssue.number}: {newBranchState.linkedIssue.title}
</span>
</div>
)}
</div>
)}
{/* Worktree Directory */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="typography-ui-label text-foreground font-semibold">
Worktree Directory
</label>
{mode !== 'existing-branch' && (
<button
onClick={() => {
const syncedName = slugifyWorktreeName(mode === 'new-branch' ? newBranchState.branchName : '');
setNewBranchState(prev => ({
...prev,
worktreeName: syncedName,
isSyncingWorktreeName: true,
}));
}}
disabled={!newBranchState.branchName || newBranchState.worktreeName === slugifyWorktreeName(newBranchState.branchName)}
className={cn(
'flex items-center gap-1 typography-micro transition-colors px-1.5 py-0.5 rounded',
newBranchState.worktreeName === slugifyWorktreeName(newBranchState.branchName) || !newBranchState.branchName
? 'text-muted-foreground/40 cursor-not-allowed'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
title="Reset to match branch name"
>
<RiRefreshLine className="h-3 w-3" />
<span>Reset</span>
</button>
)}
</div>
<Input
value={currentState.worktreeName}
onChange={(e) => {
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'
)}
/>
</div>
{/* Source Branch - Only for New Branch mode, hide when PR is selected */}
{mode === 'new-branch' && !newBranchState.linkedPr && (
<div className="space-y-1.5">
<label className="typography-ui-label text-foreground block font-semibold">
Source Branch
</label>
<Button
variant="outline"
size="sm"
onClick={() => setSourceBranchPickerOpen(true)}
className="w-full justify-between h-9"
>
<span className={newBranchState.sourceBranch ? 'text-foreground' : 'text-muted-foreground'}>
{newBranchState.sourceBranch || 'Select source branch...'}
</span>
<RiGitBranchLine className="h-4 w-4 text-muted-foreground" />
</Button>
{newBranchState.sourceBranch && (
<div className="typography-micro text-muted-foreground">
New branch will be created from {newBranchState.sourceBranch}
</div>
)}
{/* Mobile Source Branch Picker Overlay */}
<MobileOverlayPanel
open={sourceBranchPickerOpen}
title="Select Source Branch"
onClose={() => setSourceBranchPickerOpen(false)}
>
<div className="space-y-4">
{isLoadingBranches ? (
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
Loading branches...
</div>
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
No branches found
</div>
) : (
<>
{localBranches.length > 0 && (
<div className="space-y-2">
<div className="typography-small font-semibold text-foreground px-2">
Local branches
</div>
<div className="space-y-1">
{localBranches.map(branch => (
<button
key={branch}
onClick={() => {
setNewBranchState(prev => ({ ...prev, sourceBranch: branch }));
setSourceBranchPickerOpen(false);
}}
className={cn(
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
newBranchState.sourceBranch === branch
? 'bg-interactive-selection text-interactive-selection-foreground'
: 'hover:bg-interactive-hover'
)}
>
<span className="typography-small break-all">{branch}</span>
</button>
))}
</div>
</div>
)}
{remoteBranches.length > 0 && (
<div className="space-y-2">
<div className="typography-small font-semibold text-foreground px-2">
Remote branches
</div>
<div className="space-y-1">
{remoteBranches.map(branch => (
<button
key={`remotes/${branch}`}
onClick={() => {
setNewBranchState(prev => ({ ...prev, sourceBranch: `remotes/${branch}` }));
setSourceBranchPickerOpen(false);
}}
className={cn(
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
newBranchState.sourceBranch === `remotes/${branch}`
? 'bg-interactive-selection text-interactive-selection-foreground'
: 'hover:bg-interactive-hover'
)}
>
<span className="typography-small break-all">{branch}</span>
</button>
))}
</div>
</div>
)}
</>
)}
</div>
</MobileOverlayPanel>
</div>
)}
{/* Linked Item Preview - Two row minimal display */}
{(newBranchState.linkedIssue || newBranchState.linkedPr) && mode === 'new-branch' && (
<div className="mt-2 px-2 py-1.5 rounded bg-muted/30">
{/* Row 1: Type, number, title, actions */}
<div className="flex items-center gap-2">
<RiGithubLine className="h-3.5 w-3.5 text-status-success shrink-0" />
{newBranchState.linkedIssue && (
<span className="typography-micro text-muted-foreground shrink-0">
Issue #{newBranchState.linkedIssue.number}
</span>
)}
{newBranchState.linkedPr && (
<span className="typography-micro text-muted-foreground shrink-0">
PR #{newBranchState.linkedPr.number}
</span>
)}
<span className="typography-micro text-foreground truncate flex-1">
{newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title}
</span>
<a
href={newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground shrink-0"
onClick={(e) => e.stopPropagation()}
>
<RiExternalLinkLine className="h-3 w-3" />
</a>
<button
onClick={handleClearLinkedItem}
className="text-muted-foreground hover:text-foreground shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
>
<RiCloseLine className="h-3.5 w-3.5" />
</button>
</div>
{/* Row 2: PR branch info + diff indicator */}
{newBranchState.linkedPr && (
<div className="flex items-center gap-2 mt-0.5 pl-5">
<span className="typography-micro text-muted-foreground">
{newBranchState.linkedPr.head} {newBranchState.linkedPr.base}
</span>
{newBranchState.includePrDiff && (
<span className="typography-micro px-1 py-0.5 rounded bg-status-success/10 text-status-success">
+diff
</span>
)}
</div>
)}
</div>
)}
</div>
</MobileOverlayPanel>
) : (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[80vh] flex flex-col">
<DialogHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-3">
<DialogTitle className="flex items-center gap-2 shrink-0">
<RiGitBranchLine className="h-5 w-5" />
New Worktree
</DialogTitle>
{/* Mode Selection - using SortableTabsStrip */}
<div className="w-[280px] shrink-0">
<SortableTabsStrip
items={[
{ id: 'new-branch', label: 'New Branch', icon: <RiGitBranchLine className="h-3.5 w-3.5" /> },
{ id: 'existing-branch', label: 'Existing Branch', icon: <RiGitRepositoryLine className="h-3.5 w-3.5" /> },
]}
activeId={mode}
onSelect={(id) => handleModeChange(id as Mode)}
variant="active-pill"
layoutMode="fit"
className="w-full"
/>
</div>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto mt-2 space-y-6">
{/* Branch Name / Existing Branch Selection */}
{mode === 'existing-branch' ? (
<div className="space-y-1.5">
<label className="typography-ui-label text-foreground block font-semibold">
Select Branch
</label>
<Select
value={existingBranchState.selectedBranch}
onValueChange={(value) => {
setExistingBranchState(prev => ({
...prev,
selectedBranch: value,
worktreeName: slugifyWorktreeName(value),
}));
setValidation(prev => ({ ...prev, touched: true }));
}}
>
<SelectTrigger size="lg" className="w-fit">
<SelectValue placeholder="Choose a branch..." />
</SelectTrigger>
<SelectContent className="max-h-[280px] max-w-[320px]">
{isLoadingBranches ? (
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
Loading branches...
</div>
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
No branches found
</div>
) : (
<>
{localBranches.length > 0 && (
<SelectGroup>
<SelectLabel className="typography-small font-semibold text-foreground">Local branches</SelectLabel>
{localBranches.map(branch => (
<SelectItem key={branch} value={branch} className="whitespace-normal break-all">
{branch}
</SelectItem>
))}
</SelectGroup>
)}
{localBranches.length > 0 && remoteBranches.length > 0 && (
<SelectSeparator />
)}
{remoteBranches.length > 0 && (
<SelectGroup>
<SelectLabel className="typography-small font-semibold text-foreground">Remote branches</SelectLabel>
{remoteBranches.map(branch => (
<SelectItem key={`remotes/${branch}`} value={`remotes/${branch}`} className="whitespace-normal break-all">
{branch}
</SelectItem>
))}
</SelectGroup>
)}
</>
)}
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="typography-ui-label text-foreground block font-semibold">
Branch Name
</label>
{mode === 'new-branch' && isGitHubConnected && (
<Button
variant="outline"
size="sm"
onClick={() => setGithubDialogOpen(true)}
className="gap-1.5 h-7"
>
<RiGithubLine className="size-4 text-status-success" />
{newBranchState.linkedIssue || newBranchState.linkedPr ? 'Change' : 'Start from GitHub Issue/PR'}
</Button>
)}
</div>
<Input
value={newBranchState.branchName}
onChange={(e) => {
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 && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<RiCheckLine className="h-3.5 w-3.5 text-status-success" />
<span className="typography-micro">
Using PR branch: {newBranchState.linkedPr.head}
</span>
</div>
)}
{newBranchState.linkedIssue && !newBranchState.linkedPr && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<RiCheckLine className="h-3.5 w-3.5 text-status-success" />
<span className="typography-micro">
From issue #{newBranchState.linkedIssue.number}: {newBranchState.linkedIssue.title}
</span>
</div>
)}
</div>
)}
{/* Worktree Directory */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="typography-ui-label text-foreground font-semibold">
Worktree Directory
</label>
{mode !== 'existing-branch' && (
<button
onClick={() => {
const syncedName = slugifyWorktreeName(mode === 'new-branch' ? newBranchState.branchName : '');
setNewBranchState(prev => ({
...prev,
worktreeName: syncedName,
isSyncingWorktreeName: true,
}));
}}
disabled={!newBranchState.branchName || newBranchState.worktreeName === slugifyWorktreeName(newBranchState.branchName)}
className={cn(
'flex items-center gap-1 typography-micro transition-colors px-1.5 py-0.5 rounded',
newBranchState.worktreeName === slugifyWorktreeName(newBranchState.branchName) || !newBranchState.branchName
? 'text-muted-foreground/40 cursor-not-allowed'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
title="Reset to match branch name"
>
<RiRefreshLine className="h-3 w-3" />
<span>Reset</span>
</button>
)}
</div>
<Input
value={currentState.worktreeName}
onChange={(e) => {
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'
)}
/>
</div>
{/* Source Branch - Only for New Branch mode, hide when PR is selected */}
{mode === 'new-branch' && !newBranchState.linkedPr && (
<div className="space-y-1.5">
<label className="typography-ui-label text-foreground block font-semibold">
Source Branch
</label>
<Select
value={newBranchState.sourceBranch}
onValueChange={(value) => setNewBranchState(prev => ({ ...prev, sourceBranch: value }))}
>
<SelectTrigger size="lg" className="w-fit">
<SelectValue placeholder="Select source branch..." />
</SelectTrigger>
<SelectContent className="max-h-[280px] max-w-[320px]">
{isLoadingBranches ? (
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
Loading branches...
</div>
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
No branches found
</div>
) : (
<>
{localBranches.length > 0 && (
<SelectGroup>
<SelectLabel className="typography-small font-semibold text-foreground">Local branches</SelectLabel>
{localBranches.map(branch => (
<SelectItem key={branch} value={branch} className="whitespace-normal break-all">
{branch}
</SelectItem>
))}
</SelectGroup>
)}
{localBranches.length > 0 && remoteBranches.length > 0 && (
<SelectSeparator />
)}
{remoteBranches.length > 0 && (
<SelectGroup>
<SelectLabel className="typography-small font-semibold text-foreground">Remote branches</SelectLabel>
{remoteBranches.map(branch => (
<SelectItem key={`remotes/${branch}`} value={`remotes/${branch}`} className="whitespace-normal break-all">
{branch}
</SelectItem>
))}
</SelectGroup>
)}
</>
)}
</SelectContent>
</Select>
{newBranchState.sourceBranch && (
<div className="typography-micro text-muted-foreground">
New branch will be created from {newBranchState.sourceBranch}
</div>
)}
</div>
)}
{/* Linked Item Preview - Two row minimal display */}
{(newBranchState.linkedIssue || newBranchState.linkedPr) && mode === 'new-branch' && (
<div className="mt-2 px-2 py-1.5 rounded bg-muted/30">
{/* Row 1: Type, number, title, actions */}
<div className="flex items-center gap-2">
<RiGithubLine className="h-3.5 w-3.5 text-status-success shrink-0" />
{newBranchState.linkedIssue && (
<span className="typography-micro text-muted-foreground shrink-0">
Issue #{newBranchState.linkedIssue.number}
</span>
)}
{newBranchState.linkedPr && (
<span className="typography-micro text-muted-foreground shrink-0">
PR #{newBranchState.linkedPr.number}
</span>
)}
<span className="typography-micro text-foreground truncate flex-1">
{newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title}
</span>
<a
href={newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground shrink-0"
onClick={(e) => e.stopPropagation()}
>
<RiExternalLinkLine className="h-3 w-3" />
</a>
<button
onClick={handleClearLinkedItem}
className="text-muted-foreground hover:text-foreground shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
>
<RiCloseLine className="h-3.5 w-3.5" />
</button>
</div>
{/* Row 2: PR branch info + diff indicator */}
{newBranchState.linkedPr && (
<div className="flex items-center gap-2 mt-0.5 pl-5">
<span className="typography-micro text-muted-foreground">
{newBranchState.linkedPr.head} {newBranchState.linkedPr.base}
</span>
{newBranchState.includePrDiff && (
<span className="typography-micro px-1 py-0.5 rounded bg-status-success/10 text-status-success">
+diff
</span>
)}
</div>
)}
</div>
)}
</div>
{/* Footer */}
<DialogFooter className="mt-1 flex items-center justify-between">
{/* Validation error - inline with buttons */}
<div className="flex items-center gap-1.5 text-destructive">
{validation.touched && (validation.branchError || validation.worktreeError) && (
<>
<RiErrorWarningLine className="h-3.5 w-3.5" />
<span className="typography-micro">
{validation.branchError || validation.worktreeError}
</span>
</>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={isCreating}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleCreate}
disabled={!canCreate || isCreating}
className="gap-1.5"
>
{isCreating && <RiLoader4Line className="h-3.5 w-3.5 animate-spin" />}
{isCreating ? 'Creating...' : 'Create Worktree'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)}
<GitHubIntegrationDialog
open={githubDialogOpen}
onOpenChange={setGithubDialogOpen}
onSelect={handleGitHubSelect}
/>
</>
);
}