2026-03-03 00:20:15 +02:00
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' ;
2026-03-04 01:41:01 +02:00
import { useGitBranches , useGitStore } from '@/stores/useGitStore' ;
2026-03-03 00:20:15 +02:00
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' ;
2026-03-03 00:52:36 +02:00
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 } ` ,
};
};
2026-03-03 00:20:15 +02:00
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 ) {
2026-03-04 01:41:01 +02:00
const { github , git } = useRuntimeAPIs ();
2026-03-03 00:20:15 +02:00
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 );
2026-03-04 01:41:01 +02:00
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 ]);
2026-03-03 00:20:15 +02:00
// 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 );
2026-03-03 00:52:36 +02:00
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 } : {}),
};
})();
2026-03-03 00:20:15 +02:00
const resolvedArgs = await withWorktreeUpstreamDefaults ( projectDirectory , args );
const metadata = await createWorktree ( projectRef , resolvedArgs );
const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null ;
2026-03-03 00:52:36 +02:00
const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null ;
2026-03-03 00:20:15 +02:00
const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false ;
let createdSessionId : string | null = null ;
2026-03-03 00:52:36 +02:00
if ( linkedIssue || linkedPrState ) {
2026-03-03 00:20:15 +02:00
const sessionTitle = linkedIssue
? `# ${ linkedIssue . number } ${ linkedIssue . title } ` . trim ()
2026-03-03 00:52:36 +02:00
: linkedPrState
? `# ${ linkedPrState . number } ${ linkedPrState . title } ` . trim ()
2026-03-03 00:20:15 +02:00
: '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' , {
2026-03-22 22:31:29 +02:00
description : ` ${ metadata . branch || metadata . name }${ sourceLabel ? ` from ${ sourceLabel } ` : '' } - bootstrapping in background` ,
2026-03-03 00:20:15 +02:00
});
2026-03-22 22:31:29 +02:00
void loadSessions (). catch (() => undefined );
2026-03-03 00:20:15 +02:00
onOpenChange ( false );
if ( createdSessionId ) {
onWorktreeCreated ? .( metadata . path , { sessionId : createdSessionId });
void sendLinkedContextMessage ({
sessionId : createdSessionId ,
issue : linkedIssue ,
2026-03-03 00:52:36 +02:00
pr : linkedPrState ,
2026-03-03 00:20:15 +02:00
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" >
2026-03-04 01:41:01 +02:00
{ isLoadingBranches ? (
< div className = "px-2 py-8 text-center typography-small text-muted-foreground" >
Loading branches ...
</ div >
) : localBranches . length === 0 && remoteBranches . length === 0 ? (
2026-03-03 00:20:15 +02:00
< 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" >
2026-03-04 01:41:01 +02:00
{ isLoadingBranches ? (
< div className = "px-2 py-8 text-center typography-small text-muted-foreground" >
Loading branches ...
</ div >
) : localBranches . length === 0 && remoteBranches . length === 0 ? (
2026-03-03 00:20:15 +02:00
< 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]" >
2026-03-04 01:41:01 +02:00
{ isLoadingBranches ? (
< div className = "px-2 py-4 text-center typography-small text-muted-foreground" >
Loading branches ...
</ div >
) : localBranches . length === 0 && remoteBranches . length === 0 ? (
2026-03-03 00:20:15 +02:00
< 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]" >
2026-03-04 01:41:01 +02:00
{ isLoadingBranches ? (
< div className = "px-2 py-4 text-center typography-small text-muted-foreground" >
Loading branches ...
</ div >
) : localBranches . length === 0 && remoteBranches . length === 0 ? (
2026-03-03 00:20:15 +02:00
< 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 }
/>
</>
);
}