diff --git a/packages/ui/src/apps/MobileDeleteWorktreeDialog.tsx b/packages/ui/src/apps/MobileDeleteWorktreeDialog.tsx index 9980f0a5..df852c45 100644 --- a/packages/ui/src/apps/MobileDeleteWorktreeDialog.tsx +++ b/packages/ui/src/apps/MobileDeleteWorktreeDialog.tsx @@ -91,37 +91,69 @@ export const MobileDeleteWorktreeDialog: React.FC { + void (async () => { + try { + await removeProjectWorktree(project, target, { + deleteRemoteBranch: hasBranch && deleteRemoteBranch, + deleteLocalBranch: hasBranch && deleteLocalBranch, + }); + + // If the removed worktree was the active directory, fall back to the project root. + if (normalizePath(currentDirectory) === worktreePath && normalizePath(project.path)) { + useDirectoryStore.getState().setDirectory(normalizePath(project.path), { showOverlay: false }); + } + + toast.success(t('sessions.sidebar.sessionDialogs.worktree.removedTitle'), { + description: + hasBranch && deleteRemoteBranch + ? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote') + : t('sessions.sidebar.sessionDialogs.worktree.removed'), + }); + onDeleted?.(); + } catch (error) { + toast.error(t('sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle'), { + description: error instanceof Error ? error.message : t('sessions.sidebar.dialogs.deleteResult.tryAgain'), + }); + } + })(); + }, [currentDirectory, deleteLocalBranch, deleteRemoteBranch, hasBranch, onDeleted, project, t, worktreePath]); + const handleConfirm = async () => { if (!worktree || isProcessing) return; setIsProcessing(true); try { if (linkedSessions.length > 0) { - await archiveSessions(linkedSessions.map((session) => session.id)); + const { archivedIds, failedIds } = await archiveSessions(linkedSessions.map((session) => session.id)); + if (failedIds.length > 0) { + if (archivedIds.length > 0) { + toast.success( + archivedIds.length === 1 + ? t('sessions.sidebar.bulkActions.archivedSingle', { count: archivedIds.length }) + : t('sessions.sidebar.bulkActions.archivedPlural', { count: archivedIds.length }), + ); + } + toast.error( + failedIds.length === 1 + ? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length }) + : t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }), + { description: t('sessions.sidebar.dialogs.deleteResult.tryAgain') }, + ); + setIsProcessing(false); + return; + } } - await removeProjectWorktree(project, worktree, { - deleteRemoteBranch: hasBranch && deleteRemoteBranch, - deleteLocalBranch: hasBranch && deleteLocalBranch, - }); - - // If the removed worktree was the active directory, fall back to the project root. - if (normalizePath(currentDirectory) === worktreePath && normalizePath(project.path)) { - useDirectoryStore.getState().setDirectory(normalizePath(project.path), { showOverlay: false }); - } - - toast.success(t('sessions.sidebar.sessionDialogs.worktree.removedTitle'), { - description: - hasBranch && deleteRemoteBranch - ? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote') - : t('sessions.sidebar.sessionDialogs.worktree.removed'), - }); - onDeleted?.(); + removeWorktreeInBackground(worktree); onClose(); } catch (error) { toast.error(t('sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle'), { description: error instanceof Error ? error.message : t('sessions.sidebar.dialogs.deleteResult.tryAgain'), }); - } finally { setIsProcessing(false); + } finally { + if (!open) { + setIsProcessing(false); + } } }; diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 67bce021..8b896873 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -1219,13 +1219,16 @@ export const Header: React.FC = ({ const worktreeBadge = React.useMemo(() => { if (!worktreeAttachment) return null; - return formatSessionWorktreeBadge(worktreeAttachment); - }, [worktreeAttachment]); + return formatSessionWorktreeBadge(worktreeAttachment, { + pending: t('gitView.empty.worktreeSetupInProgress'), + }); + }, [t, worktreeAttachment]); const worktreeBadgeKind = React.useMemo(() => { if (!worktreeAttachment) return null; if (worktreeAttachment.legacy) return 'legacy'; if (worktreeAttachment.degraded) return 'degraded'; + if (worktreeAttachment.worktreeStatus === 'pending') return 'pending'; if (worktreeAttachment.worktreeStatus === 'missing') return 'missing'; if (worktreeAttachment.worktreeStatus === 'invalid') return 'invalid'; if (worktreeAttachment.attentionReason) return 'attention'; diff --git a/packages/ui/src/components/session/ForkSessionDialog.tsx b/packages/ui/src/components/session/ForkSessionDialog.tsx index 922f92ab..b14b5e9b 100644 --- a/packages/ui/src/components/session/ForkSessionDialog.tsx +++ b/packages/ui/src/components/session/ForkSessionDialog.tsx @@ -7,6 +7,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Textarea } from '@/components/ui/textarea'; +import { Checkbox } from '@/components/ui/checkbox'; import { ModelSelector } from '@/components/sections/agents/ModelSelector'; import { AgentSelector } from '@/components/sections/commands/AgentSelector'; import { ThinkingPill } from '@/components/session/ThinkingPill'; @@ -15,6 +16,7 @@ import { useAgentsStore } from '@/stores/useAgentsStore'; import { isPrimaryMode } from '@/components/chat/mobileControlsUtils'; import { EXECUTION_FORK_DEFAULT_INSTRUCTIONS } from '@/lib/messages/executionMeta'; import { useI18n } from '@/lib/i18n'; +import { isVSCodeRuntime } from '@/lib/desktop'; export type ForkSessionExecution = { providerID: string; @@ -22,6 +24,7 @@ export type ForkSessionExecution = { variant: string; agent: string; instructions: string; + createWorktree?: boolean; }; type ForkSessionDialogProps = { @@ -50,6 +53,8 @@ export function ForkSessionDialog(props: ForkSessionDialogProps) { const [variant, setVariant] = React.useState(currentVariant); const [agent, setAgent] = React.useState(currentAgentName); const [instructions, setInstructions] = React.useState(EXECUTION_FORK_DEFAULT_INSTRUCTIONS); + const [createWorktree, setCreateWorktree] = React.useState(false); + const showCreateWorktree = React.useMemo(() => !isVSCodeRuntime(), []); React.useEffect(() => { if (!open) return; @@ -69,6 +74,7 @@ export function ForkSessionDialog(props: ForkSessionDialogProps) { setVariant(config.currentVariant || ''); setAgent(config.currentAgentName || ''); setInstructions(EXECUTION_FORK_DEFAULT_INSTRUCTIONS); + setCreateWorktree(false); }, [open]); React.useEffect(() => { @@ -106,8 +112,15 @@ export function ForkSessionDialog(props: ForkSessionDialogProps) { const handleSubmit = React.useCallback(() => { if (!canConfirm || submitting) return; - void onConfirm({ providerID, modelID, variant, agent, instructions }); - }, [canConfirm, submitting, onConfirm, providerID, modelID, variant, agent, instructions]); + void onConfirm({ + providerID, + modelID, + variant, + agent, + instructions, + createWorktree: showCreateWorktree && createWorktree, + }); + }, [canConfirm, submitting, onConfirm, providerID, modelID, variant, agent, instructions, showCreateWorktree, createWorktree]); React.useEffect(() => { if (!open) return; @@ -173,15 +186,35 @@ export function ForkSessionDialog(props: ForkSessionDialogProps) { -
- - +
+ {showCreateWorktree ? ( +
+ + +
+ ) : null} +
+ + +
diff --git a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx index 3b3eb3f8..556b26c6 100644 --- a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx +++ b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx @@ -19,10 +19,8 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; import * as sessionActions from '@/sync/session-actions'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useContextStore } from '@/stores/contextStore'; import { useUIStore } from '@/stores/useUIStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; -import { opencodeClient } from '@/lib/opencode/client'; import { renderMagicPrompt } from '@/lib/magicPrompts'; import { parseModelIdentifier } from '@/lib/modelIdentifier'; import { useDeviceInfo } from '@/lib/device'; @@ -248,9 +246,9 @@ export function GitHubIssuePickerDialog({ const resolveDefaultVariant = React.useCallback((providerID: string, modelID: string): string | undefined => { const configState = useConfigStore.getState(); const settingsDefaultVariant = configState.settingsDefaultVariant; - if (!settingsDefaultVariant) { - return undefined; - } + const currentVariant = configState.currentProviderId === providerID && configState.currentModelId === modelID + ? configState.currentVariant + : undefined; const provider = configState.providers.find((p) => p.id === providerID); const model = provider?.models.find((m: Record) => (m as { id?: string }).id === modelID) as @@ -258,12 +256,15 @@ export function GitHubIssuePickerDialog({ | undefined; const variants = model?.variants; if (!variants) { - return undefined; + return settingsDefaultVariant || currentVariant || undefined; } - if (!Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) { - return undefined; + if (settingsDefaultVariant && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) { + return settingsDefaultVariant; } - return settingsDefaultVariant; + if (currentVariant && Object.prototype.hasOwnProperty.call(variants, currentVariant)) { + return currentVariant; + } + return undefined; }, []); const startSession = React.useCallback(async (issueNumber: number, sourceRepo?: GitHubRepoSelector | null) => { @@ -366,12 +367,14 @@ export function GitHubIssuePickerDialog({ const sessionTitle = `#${issue.number} ${issue.title}`.trim(); - const { sessionId, sessionDirectory } = await (async () => { + const { sessionId } = await (async () => { if (createInWorktree) { const preferred = `issue-${issue.number}-${generateBranchSlug()}`; const created = await createWorktreeSessionForNewBranch( projectDirectory, - preferred + preferred, + undefined, + { returnAfterDirectoryCreated: true } ); if (!created?.id) { throw new Error('Failed to create worktree session'); @@ -412,64 +415,27 @@ export function GitHubIssuePickerDialog({ const variant = resolveDefaultVariant(providerID, modelID); - try { - useContextStore.getState().saveSessionModelSelection(sessionId, providerID, modelID); - } catch { - // ignore - } - - if (agentName) { - try { - configState.setAgent(agentName); - } catch { - // ignore - } - - try { - useContextStore.getState().saveSessionAgentSelection(sessionId, agentName); - } catch { - // ignore - } - - try { - useContextStore.getState().saveAgentModelForSession(sessionId, agentName, providerID, modelID); - } catch { - // ignore - } - - if (variant !== undefined) { - try { - configState.setCurrentVariant(variant); - } catch { - // ignore - } - try { - useContextStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerID, modelID, variant); - } catch { - // ignore - } - } - } - const visiblePromptText = await renderMagicPrompt('github.issue.review.visible', { issue_number: String(issue.number), }); const instructionsText = await renderMagicPrompt('github.issue.review.instructions'); const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments }); - void opencodeClient.sendMessage({ - id: sessionId, + void useSessionUIStore.getState().sendMessage( + visiblePromptText, providerID, modelID, - agent: agentName, - variant, - text: visiblePromptText, - additionalParts: [ + agentName, + undefined, + undefined, + [ { text: instructionsText, synthetic: true }, { text: contextText, synthetic: true }, ], - directory: sessionDirectory, - }).catch((e) => { + variant, + undefined, + { sessionId }, + ).catch((e) => { const message = e instanceof Error ? e.message : String(e); toast.error(t('session.githubIssuePicker.toast.sendContextFailed'), { description: message, diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index 821da471..6c974590 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -31,13 +31,11 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; import * as sessionActions from '@/sync/session-actions'; import { useConfigStore } from '@/stores/useConfigStore'; -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 { renderMagicPrompt } from '@/lib/magicPrompts'; import { parseModelIdentifier } from '@/lib/modelIdentifier'; import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch'; @@ -446,66 +444,19 @@ export function NewWorktreeDialog({ const resolveDefaultVariant = React.useCallback((providerID: string, modelID: string): string | undefined => { const configState = useConfigStore.getState(); const settingsDefaultVariant = configState.settingsDefaultVariant; - if (!settingsDefaultVariant) return undefined; + const currentVariant = configState.currentProviderId === providerID && configState.currentModelId === modelID + ? configState.currentVariant + : undefined; const provider = configState.providers.find((p) => p.id === providerID); const model = provider?.models.find((m: Record) => (m as { id?: string }).id === modelID) as | { variants?: Record } | undefined; const variants = model?.variants; - if (!variants) return 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 - } - } + if (!variants) return settingsDefaultVariant || currentVariant || undefined; + if (settingsDefaultVariant && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) return settingsDefaultVariant; + if (currentVariant && Object.prototype.hasOwnProperty.call(variants, currentVariant)) return currentVariant; + return undefined; }, []); const sendLinkedContextMessage = React.useCallback(async (args: { @@ -533,14 +484,6 @@ export function NewWorktreeDialog({ const variant = resolveDefaultVariant(providerID, modelID); - applySessionModelAndAgentDefaults({ - sessionId: args.sessionId, - providerID, - modelID, - agentName, - variant, - }); - if (args.issue) { if (!github.issueGet || !github.issueComments) { return; @@ -566,19 +509,21 @@ export function NewWorktreeDialog({ comments: commentsRes.comments ?? [], }); - await opencodeClient.sendMessage({ - id: args.sessionId, + await useSessionUIStore.getState().sendMessage( + visiblePromptText, providerID, modelID, - agent: agentName, - variant, - text: visiblePromptText, - additionalParts: [ + agentName, + undefined, + undefined, + [ { text: instructionsText, synthetic: true }, { text: contextText, synthetic: true }, ], - directory: args.directory, - }); + variant, + undefined, + { sessionId: args.sessionId }, + ); toast.success(t('session.newWorktree.toast.sessionFromIssue')); return; @@ -603,24 +548,25 @@ export function NewWorktreeDialog({ const instructionsText = await renderMagicPrompt('github.pr.review.instructions'); const contextText = buildPullRequestContextText(prContext); - await opencodeClient.sendMessage({ - id: args.sessionId, + await useSessionUIStore.getState().sendMessage( + visiblePromptText, providerID, modelID, - agent: agentName, - variant, - text: visiblePromptText, - additionalParts: [ + agentName, + undefined, + undefined, + [ { text: instructionsText, synthetic: true }, { text: contextText, synthetic: true }, ], - directory: args.directory, - }); + variant, + undefined, + { sessionId: args.sessionId }, + ); toast.success(t('session.newWorktree.toast.sessionFromPr')); } }, [ - applySessionModelAndAgentDefaults, github, projectDirectory, resolveDefaultAgentName, @@ -854,8 +800,13 @@ export function NewWorktreeDialog({ setIsCreating(true); try { - const setupCommands = await getWorktreeSetupCommands(projectRef); const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null; + const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null; + const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null; + const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false; + const shouldCreateSession = Boolean(linkedIssue || linkedPrState); + + const setupCommands = await getWorktreeSetupCommands(projectRef); const sourceBranch = newBranchState.sourceBranch; let sourceLabel = ''; @@ -873,6 +824,7 @@ export function NewWorktreeDialog({ setUpstream: prConfig.setUpstream, upstreamRemote: prConfig.upstreamRemote, upstreamBranch: prConfig.upstreamBranch, + returnAfterDirectoryCreated: true, ...(prConfig.ensureRemoteName ? { ensureRemoteName: prConfig.ensureRemoteName } : {}), ...(prConfig.ensureRemoteUrl ? { ensureRemoteUrl: prConfig.ensureRemoteUrl } : {}), }; @@ -886,20 +838,18 @@ export function NewWorktreeDialog({ worktreeName: normalizedWorktree, existingBranch: mode === 'existing-branch' ? normalizedBranch : undefined, setupCommands, + returnAfterDirectoryCreated: true, ...(sourceBranch && mode === 'new-branch' ? { startRef: sourceBranch } : {}), }; })(); const resolvedArgs = await withWorktreeUpstreamDefaults(projectDirectory, args); - const metadata = await createWorktree(projectRef, resolvedArgs); - const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null; - const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null; - const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false; + const metadata = await createWorktree(projectRef, resolvedArgs); let createdSessionId: string | null = null; - if (linkedIssue || linkedPrState) { + if (shouldCreateSession) { const sessionTitle = linkedIssue ? `#${linkedIssue.number} ${linkedIssue.title}`.trim() : linkedPrState @@ -912,6 +862,10 @@ export function NewWorktreeDialog({ } createdSessionId = session.id; + onWorktreeCreated?.(metadata.path, { sessionId: createdSessionId }); + onOpenChange(false); + setIsCreating(false); + void sessionActions.updateSessionTitle(session.id, sessionTitle).catch(() => undefined); try { @@ -919,6 +873,9 @@ export function NewWorktreeDialog({ } catch { // ignore } + } else { + onOpenChange(false); + setIsCreating(false); } // Save source branch preference (only if not from PR) @@ -932,10 +889,7 @@ export function NewWorktreeDialog({ }), }); - onOpenChange(false); - if (createdSessionId) { - onWorktreeCreated?.(metadata.path, { sessionId: createdSessionId }); void sendLinkedContextMessage({ sessionId: createdSessionId, directory: metadata.path, diff --git a/packages/ui/src/components/session/SessionDialogs.tsx b/packages/ui/src/components/session/SessionDialogs.tsx index 2f594bf2..ce3128e7 100644 --- a/packages/ui/src/components/session/SessionDialogs.tsx +++ b/packages/ui/src/components/session/SessionDialogs.tsx @@ -373,6 +373,25 @@ export const SessionDialogs: React.FC = () => { } }, [canRemoveRemoteBranches, currentDirectory, deleteDialogShouldRemoveRemote, getProjectRefForWorktree, newSessionDraft?.directoryOverride, newSessionDraft?.open, setDraftBootstrapPendingDirectory, setNewSessionDraftTarget, t]); + const removeSelectedWorktreeInBackground = React.useCallback(( + worktree: WorktreeMetadata, + deleteLocalBranch: boolean + ): void => { + const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; + void (async () => { + const removed = await removeSelectedWorktree(worktree, deleteLocalBranch); + if (!removed) { + return; + } + const archiveNote = shouldRemoveRemote + ? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote') + : t('sessions.sidebar.sessionDialogs.worktree.removed'); + toast.success(t('sessions.sidebar.sessionDialogs.worktree.removedTitle'), { + description: renderToastDescription(archiveNote), + }); + })(); + }, [canRemoveRemoteBranches, deleteDialogShouldRemoveRemote, removeSelectedWorktree, t]); + const handleConfirmDelete = React.useCallback(async () => { if (!deleteDialog) { return; @@ -385,18 +404,7 @@ export const SessionDialogs: React.FC = () => { const deleteLocalBranch = shouldArchive && deleteDialogShouldDeleteLocalBranch; if (deleteDialog.sessions.length === 0 && isWorktreeDelete && deleteDialog.worktree) { - const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); - if (!removed) { - closeDeleteDialog(); - return; - } - const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; - const archiveNote = shouldRemoveRemote - ? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote') - : t('sessions.sidebar.sessionDialogs.worktree.removed'); - toast.success(t('sessions.sidebar.sessionDialogs.worktree.removedTitle'), { - description: renderToastDescription(archiveNote), - }); + removeSelectedWorktreeInBackground(deleteDialog.worktree, deleteLocalBranch); closeDeleteDialog(); return; } @@ -450,7 +458,7 @@ export const SessionDialogs: React.FC = () => { if (isWorktreeDelete && deleteDialog.worktree && failedIds.length === 0) { // Remove selected worktree even if per-session metadata is missing. // Use same projectRef logic as the no-sessions path. - await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); + removeSelectedWorktreeInBackground(deleteDialog.worktree, deleteLocalBranch); // sync handles session refresh automatically } @@ -506,7 +514,7 @@ export const SessionDialogs: React.FC = () => { } if (isWorktreeDelete && deleteDialog.sessions.length === 1 && deleteDialog.worktree) { - await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); + removeSelectedWorktreeInBackground(deleteDialog.worktree, deleteLocalBranch); // sync bootstrap refreshes sessions automatically } @@ -525,8 +533,7 @@ export const SessionDialogs: React.FC = () => { closeDeleteDialog, shouldArchiveWorktree, isWorktreeDelete, - canRemoveRemoteBranches, - removeSelectedWorktree, + removeSelectedWorktreeInBackground, t, ]); diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 1aad887e..2c79addd 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -1719,7 +1719,7 @@ export const SessionSidebar: React.FC = ({ setCurrentSession(options.sessionId, worktreePath); return; } - openNewSessionDraft({ directoryOverride: worktreePath }); + openNewSessionDraft({ directoryOverride: worktreePath, preserveDirectoryOverride: true }); }} /> diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 0eb5e92a..86858eda 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -398,6 +398,8 @@ export interface CreateGitWorktreePayload { /** Optional remote provisioning (used for fork PR workflows). */ ensureRemoteName?: string; ensureRemoteUrl?: string; + /** Return once the target directory exists and finish Git worktree setup in the background. */ + returnAfterDirectoryCreated?: boolean; } export interface GitWorktreeCreateResult { @@ -405,6 +407,8 @@ export interface GitWorktreeCreateResult { name: string; branch: string; path: string; + directoryCreated?: true; + bootstrapStatus?: GitWorktreeBootstrapStatus; } export interface RemoveGitWorktreePayload { @@ -538,7 +542,7 @@ export interface GitAPI { cwd: string | null; branch: string | null; headState: 'branch' | 'detached' | 'unborn'; - worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; + worktreeStatus: 'pending' | 'ready' | 'missing' | 'invalid' | 'not-a-repo'; legacy: boolean; degraded: boolean; attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index b7992a02..78ee52d4 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -883,7 +883,7 @@ export async function canonicalizeWorktreeState( cwd: string | null; branch: string | null; headState: 'branch' | 'detached' | 'unborn'; - worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; + worktreeStatus: 'pending' | 'ready' | 'missing' | 'invalid' | 'not-a-repo'; legacy: boolean; degraded: boolean; attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index be79c6ad..d67c40f4 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -1052,7 +1052,7 @@ export async function canonicalizeWorktreeState( cwd: string | null; branch: string | null; headState: 'branch' | 'detached' | 'unborn'; - worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; + worktreeStatus: 'pending' | 'ready' | 'missing' | 'invalid' | 'not-a-repo'; legacy: boolean; degraded: boolean; attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; diff --git a/packages/ui/src/lib/i18n/index.ts b/packages/ui/src/lib/i18n/index.ts index d5f0393f..2344dcfd 100644 --- a/packages/ui/src/lib/i18n/index.ts +++ b/packages/ui/src/lib/i18n/index.ts @@ -1,4 +1,4 @@ export { I18nProvider } from './context'; export { useI18n } from './useI18n'; -export { initializeLocale } from './store'; -export type { I18nKey, Locale } from './store'; +export { formatMessage, initializeLocale, useI18nStore } from './store'; +export type { I18nKey, I18nParams, Locale } from './store'; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index b17806a6..4e9a8ce6 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -765,6 +765,9 @@ export const dict = { 'gitView.empty.worktreeFeaturesUnavailable': 'Worktree features are unavailable in this workspace mode.', 'gitView.empty.worktreeSetupDescription': 'Finishing worktree setup and preparing repository state.', 'gitView.empty.worktreeSetupInProgress': 'Worktree setup in progress', + 'worktree.bootstrap.toast.failed': 'Worktree setup failed', + 'worktree.bootstrap.toast.failedDescription': 'The worktree was created, but background setup did not finish.', + 'worktree.bootstrap.toast.timeoutDescription': 'The worktree was created, but background setup timed out.', 'gitView.gitmoji.empty': 'No gitmoji found', 'gitView.gitmoji.searchPlaceholder': 'Search gitmoji...', 'gitView.gitmoji.title': 'Insert gitmoji', @@ -1716,6 +1719,7 @@ export const dict = { 'chat.messageBody.actions.startNewMultiRun': 'Start new multi-run from this answer', 'chat.messageBody.forkDialog.instructions.label': 'Instructions', 'chat.messageBody.forkDialog.instructions.placeholder': 'Add instructions for the new session…', + 'chat.messageBody.forkDialog.createWorktree': 'Create worktree', 'chat.generatedResult.actions.copy': 'Copy', 'chat.generatedResult.actions.copied': 'Copied', 'chat.generatedResult.commit.title': 'Generated commit message', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 70e83adf..56e46246 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -766,6 +766,9 @@ export const dict: Record = { "gitView.empty.worktreeFeaturesUnavailable": "Las características de worktree no están disponibles en este modo de espacio de trabajo.", "gitView.empty.worktreeSetupDescription": "Finalizando la configuración de worktree y preparando el estado del repositorio.", "gitView.empty.worktreeSetupInProgress": "Configuración de worktree en progreso", + "worktree.bootstrap.toast.failed": "Error al configurar el worktree", + "worktree.bootstrap.toast.failedDescription": "El worktree se creó, pero la configuración en segundo plano no terminó.", + "worktree.bootstrap.toast.timeoutDescription": "El worktree se creó, pero la configuración en segundo plano agotó el tiempo de espera.", "gitView.gitmoji.empty": "No se encontraron gitmojis", "gitView.gitmoji.searchPlaceholder": "Buscar gitmojis...", "gitView.gitmoji.title": "Insertar gitmoji", @@ -1682,6 +1685,7 @@ export const dict: Record = { "chat.messageBody.actions.startNewMultiRun": "Iniciar nuevo multi-run desde esta respuesta", "chat.messageBody.forkDialog.instructions.label": "Instrucciones", "chat.messageBody.forkDialog.instructions.placeholder": "Añade instrucciones para la nueva sesión…", + "chat.messageBody.forkDialog.createWorktree": "Crear worktree", "chat.generatedResult.actions.copy": "Copiar", "chat.generatedResult.actions.copied": "Copiado", "chat.generatedResult.commit.title": "Mensaje de commit generado", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index f2e81f3e..345b1631 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -766,6 +766,9 @@ export const dict: Record = { 'gitView.empty.worktreeFeaturesUnavailable': '이 워크스페이스 모드에서는 워크트리 기능을 사용할 수 없습니다.', 'gitView.empty.worktreeSetupDescription': '워크트리 설정을 마치고 레포지토리 상태를 준비하고 있습니다.', 'gitView.empty.worktreeSetupInProgress': '워크트리 설정 중', + 'worktree.bootstrap.toast.failed': '워크트리 설정 실패', + 'worktree.bootstrap.toast.failedDescription': '워크트리는 생성되었지만 백그라운드 설정이 완료되지 않았습니다.', + 'worktree.bootstrap.toast.timeoutDescription': '워크트리는 생성되었지만 백그라운드 설정 시간이 초과되었습니다.', 'gitView.gitmoji.empty': 'gitmoji 없음', 'gitView.gitmoji.searchPlaceholder': 'gitmoji 검색…', 'gitView.gitmoji.title': 'gitmoji 삽입', @@ -1716,6 +1719,7 @@ export const dict: Record = { 'chat.messageBody.actions.startNewMultiRun': '이 응답에서 새 멀티런 시작', 'chat.messageBody.forkDialog.instructions.label': '지침', 'chat.messageBody.forkDialog.instructions.placeholder': '새 세션에 대한 지침을 입력하세요…', + 'chat.messageBody.forkDialog.createWorktree': '워크트리 만들기', 'chat.generatedResult.actions.copy': '복사', 'chat.generatedResult.actions.copied': '복사됨', 'chat.generatedResult.commit.title': '생성된 커밋 메시지', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 86288a12..68430a76 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -700,6 +700,7 @@ export const dict: Record = { 'chat.messageBody.actions.startNewMultiRun': 'Rozpocznij nowe wielokrotne uruchomienie z tej odpowiedzi', 'chat.messageBody.forkDialog.instructions.label': 'Instrukcje', 'chat.messageBody.forkDialog.instructions.placeholder': 'Dodaj instrukcje dla nowej sesji…', + 'chat.messageBody.forkDialog.createWorktree': 'Utwórz drzewo pracy', 'chat.generatedResult.actions.copy': 'Kopiuj', 'chat.generatedResult.actions.copied': 'Skopiowano', 'chat.generatedResult.commit.title': 'Wygenerowana wiadomość commita', @@ -1639,6 +1640,9 @@ export const dict: Record = { 'gitView.empty.worktreeFeaturesUnavailable': 'Worktree features are unavailable in this workspace mode.', 'gitView.empty.worktreeSetupDescription': 'Finishing worktree setup and preparing repository state.', 'gitView.empty.worktreeSetupInProgress': 'Worktree setup in progress', + 'worktree.bootstrap.toast.failed': 'Konfiguracja drzewa pracy nie powiodła się', + 'worktree.bootstrap.toast.failedDescription': 'Drzewo pracy zostało utworzone, ale konfiguracja w tle nie została ukończona.', + 'worktree.bootstrap.toast.timeoutDescription': 'Drzewo pracy zostało utworzone, ale konfiguracja w tle przekroczyła limit czasu.', 'gitView.gitmoji.empty': 'No gitmoji found', 'gitView.gitmoji.searchPlaceholder': 'Search gitmoji...', 'gitView.gitmoji.title': 'Insert gitmoji', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 3d3faac6..a0e4f870 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -766,6 +766,9 @@ export const dict: Record = { "gitView.empty.worktreeFeaturesUnavailable": "Os recursos de worktree não estão disponíveis neste modo de workspace.", "gitView.empty.worktreeSetupDescription": "Finalizando a configuração de worktree e preparando o status do repositório.", "gitView.empty.worktreeSetupInProgress": "Configuração de worktree em andamento", + "worktree.bootstrap.toast.failed": "Falha na configuração do worktree", + "worktree.bootstrap.toast.failedDescription": "O worktree foi criado, mas a configuração em segundo plano não terminou.", + "worktree.bootstrap.toast.timeoutDescription": "O worktree foi criado, mas a configuração em segundo plano atingiu o tempo limite.", "gitView.gitmoji.empty": "Nenhum gitmoji encontrado", "gitView.gitmoji.searchPlaceholder": "Buscar gitmojis...", "gitView.gitmoji.title": "Insertar gitmoji", @@ -1682,6 +1685,7 @@ export const dict: Record = { "chat.messageBody.actions.startNewMultiRun": "Iniciar novo multi-run a partir desta resposta", "chat.messageBody.forkDialog.instructions.label": "Instruções", "chat.messageBody.forkDialog.instructions.placeholder": "Adicione instruções para a nova sessão…", + "chat.messageBody.forkDialog.createWorktree": "Criar worktree", "chat.generatedResult.actions.copy": "Copiar", "chat.generatedResult.actions.copied": "Copiado", "chat.generatedResult.commit.title": "Mensagem de commit gerada", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index fa7dd492..522d8306 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -766,6 +766,9 @@ export const dict: Record = { "gitView.empty.worktreeFeaturesUnavailable": "У цьому режимі робочої області функції worktree недоступні.", "gitView.empty.worktreeSetupDescription": "Завершення налаштування worktree та підготовка стану сховища.", "gitView.empty.worktreeSetupInProgress": "Виконується налаштування worktree", + "worktree.bootstrap.toast.failed": "Не вдалося налаштувати worktree", + "worktree.bootstrap.toast.failedDescription": "Worktree створено, але фонове налаштування не завершилося.", + "worktree.bootstrap.toast.timeoutDescription": "Worktree створено, але час очікування фонового налаштування минув.", "gitView.gitmoji.empty": "Gitmoji не знайдено", "gitView.gitmoji.searchPlaceholder": "Пошук gitmoji...", "gitView.gitmoji.title": "Вставте gitmoji", @@ -1682,6 +1685,7 @@ export const dict: Record = { "chat.messageBody.actions.startNewMultiRun": "Почніть новий Multi-run із цієї відповіді", "chat.messageBody.forkDialog.instructions.label": "Інструкції", "chat.messageBody.forkDialog.instructions.placeholder": "Додайте інструкції для нової сесії…", + "chat.messageBody.forkDialog.createWorktree": "Створити worktree", "chat.generatedResult.actions.copy": "Копіювати", "chat.generatedResult.actions.copied": "Скопійовано", "chat.generatedResult.commit.title": "Згенероване повідомлення коміту", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index d650e247..89b95199 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -766,6 +766,9 @@ export const dict: Record = { 'gitView.empty.worktreeFeaturesUnavailable': '当前工作区模式下,工作树功能不可用。', 'gitView.empty.worktreeSetupDescription': '正在完成工作树设置并准备仓库状态。', 'gitView.empty.worktreeSetupInProgress': '工作树设置进行中', + 'worktree.bootstrap.toast.failed': '工作树设置失败', + 'worktree.bootstrap.toast.failedDescription': '工作树已创建,但后台设置未完成。', + 'worktree.bootstrap.toast.timeoutDescription': '工作树已创建,但后台设置超时。', 'gitView.gitmoji.empty': '未找到 gitmoji', 'gitView.gitmoji.searchPlaceholder': '搜索 gitmoji...', 'gitView.gitmoji.title': '插入 gitmoji', @@ -1682,6 +1685,7 @@ export const dict: Record = { 'chat.messageBody.actions.startNewMultiRun': '基于此回答开始新的多运行', 'chat.messageBody.forkDialog.instructions.label': '说明', 'chat.messageBody.forkDialog.instructions.placeholder': '为新会话添加说明…', + 'chat.messageBody.forkDialog.createWorktree': '创建工作树', 'chat.generatedResult.actions.copy': '复制', 'chat.generatedResult.actions.copied': '已复制', 'chat.generatedResult.commit.title': '生成的提交消息', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 5c81ca41..9c86ed54 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -778,6 +778,9 @@ export const dict: Record = { 'gitView.empty.worktreeFeaturesUnavailable': '目前工作區模式下,worktree 功能無法使用。', 'gitView.empty.worktreeSetupDescription': '正在完成 worktree 設定並準備儲存庫狀態。', 'gitView.empty.worktreeSetupInProgress': 'worktree 設定進行中', + 'worktree.bootstrap.toast.failed': 'worktree 設定失敗', + 'worktree.bootstrap.toast.failedDescription': 'worktree 已建立,但背景設定未完成。', + 'worktree.bootstrap.toast.timeoutDescription': 'worktree 已建立,但背景設定逾時。', 'gitView.gitmoji.empty': '找不到 gitmoji', 'gitView.gitmoji.searchPlaceholder': '搜尋 gitmoji...', 'gitView.gitmoji.title': '插入 gitmoji', @@ -1686,6 +1689,7 @@ export const dict: Record = { 'chat.messageBody.actions.startNewMultiRun': '基於此回答開始新的 Multi-run', 'chat.messageBody.forkDialog.instructions.label': '說明', 'chat.messageBody.forkDialog.instructions.placeholder': '為新工作階段新增說明…', + 'chat.messageBody.forkDialog.createWorktree': '建立 worktree', 'chat.generatedResult.actions.copy': '複製', 'chat.generatedResult.actions.copied': '已複製', 'chat.generatedResult.commit.title': '生成的提交訊息', diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 9d89213f..ee83a075 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -14,7 +14,6 @@ import type { } from "@opencode-ai/sdk/v2"; import type { PermissionRequest } from "@/types/permission"; import type { QuestionRequest } from "@/types/question"; -import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap"; import { getRuntimeUrlResolver } from "@/lib/runtime-url"; import { runtimeFetch } from "@/lib/runtime-fetch"; import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"; @@ -800,10 +799,6 @@ class OpencodeService { const requestDirectory = this.normalizeCandidatePath(params.directory ?? null) ?? this.currentDirectory; - if (requestDirectory) { - await waitForWorktreeBootstrap(requestDirectory); - } - if (params.format) { console.info('[git-generation][browser] send structured message', { sessionId: params.id, diff --git a/packages/ui/src/lib/worktreeSessionCreator.ts b/packages/ui/src/lib/worktreeSessionCreator.ts index 591f2715..1e152559 100644 --- a/packages/ui/src/lib/worktreeSessionCreator.ts +++ b/packages/ui/src/lib/worktreeSessionCreator.ts @@ -233,6 +233,7 @@ const createInstantWorktreeDraft = async (options?: { branchName: preferredName, worktreeName: preferredName, setupCommands, + returnAfterDirectoryCreated: true, }); resolvePendingDraftWorktreeRequest(pendingRequestId, metadata.path); @@ -325,6 +326,7 @@ export async function createWorktreeOnly(): Promise { branchName: preferredName, worktreeName: preferredName, setupCommands, + returnAfterDirectoryCreated: true, }); @@ -361,6 +363,7 @@ export async function createWorktreeSessionForBranch( ensureRemoteName?: string; ensureRemoteUrl?: string; createdFromBranch?: string; + returnAfterDirectoryCreated?: boolean; } ): Promise<{ id: string } | null> { if (isCreatingWorktreeSession) { @@ -404,6 +407,7 @@ export async function createWorktreeSessionForBranch( ensureRemoteName: options?.ensureRemoteName, ensureRemoteUrl: options?.ensureRemoteUrl, setupCommands, + returnAfterDirectoryCreated: options?.returnAfterDirectoryCreated, }); const kind = options?.kind ?? 'standard'; @@ -456,6 +460,7 @@ export async function createWorktreeSessionForNewBranch( ensureRemoteName?: string; ensureRemoteUrl?: string; createdFromBranch?: string; + returnAfterDirectoryCreated?: boolean; } ): Promise<{ id: string; branch: string; path: string } | null> { if (isCreatingWorktreeSession) { @@ -507,6 +512,7 @@ export async function createWorktreeSessionForNewBranch( ensureRemoteName: options?.ensureRemoteName, ensureRemoteUrl: options?.ensureRemoteUrl, setupCommands, + returnAfterDirectoryCreated: options?.returnAfterDirectoryCreated, }); const createdMetadata = { ...metadata, @@ -551,6 +557,7 @@ export async function createWorktreeSessionForNewBranchExact( ensureRemoteName?: string; ensureRemoteUrl?: string; createdFromBranch?: string; + returnAfterDirectoryCreated?: boolean; } ): Promise<{ id: string; branch: string; path: string } | null> { return createWorktreeSessionForNewBranch(projectDirectory, branchName, startPoint, { @@ -562,5 +569,6 @@ export async function createWorktreeSessionForNewBranchExact( ensureRemoteName: options?.ensureRemoteName, ensureRemoteUrl: options?.ensureRemoteUrl, createdFromBranch: options?.createdFromBranch, + returnAfterDirectoryCreated: options?.returnAfterDirectoryCreated, }); } diff --git a/packages/ui/src/lib/worktrees/worktreeBootstrap.test.ts b/packages/ui/src/lib/worktrees/worktreeBootstrap.test.ts index 4d11ec48..12ff2919 100644 --- a/packages/ui/src/lib/worktrees/worktreeBootstrap.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeBootstrap.test.ts @@ -1,7 +1,27 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test'; const bootstrapStatusCalls: string[] = []; -let bootstrapStatusResult = { status: 'ready' as const, error: null, updatedAt: 1 }; +let bootstrapStatusResult: { status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number } = { + status: 'ready', + error: null, + updatedAt: 1, +}; +const toastErrors: Array<{ title: string; description?: string }> = []; + +mock.module('@/components/ui', () => ({ + toast: { + error: (title: string, options?: { description?: string }) => { + toastErrors.push({ title, description: options?.description }); + }, + }, +})); + +mock.module('@/lib/i18n', () => ({ + formatMessage: (_dictionary: Record, key: string) => key, + useI18nStore: { + getState: () => ({ dictionary: {} }), + }, +})); mock.module('@/contexts/runtimeAPIRegistry', () => ({ getRegisteredRuntimeAPIs: () => ({ @@ -25,13 +45,24 @@ mock.module('@/lib/gitApiHttp', () => ({ const { clearWorktreeBootstrapState, + getWorktreeBootstrapState, markWorktreeBootstrapPending, + startWorktreeBootstrapWatcher, waitForWorktreeBootstrap, } = await import('./worktreeBootstrap'); +const waitFor = async (predicate: () => boolean): Promise => { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error('Timed out waiting for condition'); +}; + describe('worktreeBootstrap.waitForWorktreeBootstrap', () => { beforeEach(() => { bootstrapStatusCalls.length = 0; + toastErrors.length = 0; bootstrapStatusResult = { status: 'ready', error: null, updatedAt: 1 }; clearWorktreeBootstrapState('/repo'); clearWorktreeBootstrapState('/repo-wt'); @@ -50,4 +81,61 @@ describe('worktreeBootstrap.waitForWorktreeBootstrap', () => { expect(bootstrapStatusCalls).toEqual(['/repo-wt']); }); + + test('background watcher polls pending worktrees without blocking', async () => { + markWorktreeBootstrapPending('/repo-wt'); + const readyStatuses: Array<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> = []; + + startWorktreeBootstrapWatcher('/repo-wt', { + pollIntervalMs: 0, + onReady: (status) => readyStatuses.push(status), + }); + + await waitFor(() => readyStatuses.length === 1); + expect(bootstrapStatusCalls).toEqual(['/repo-wt']); + expect(readyStatuses.map((status) => status.status)).toEqual(['ready']); + expect(toastErrors).toEqual([]); + }); + + test('background watcher shows a toast when bootstrap fails', async () => { + bootstrapStatusResult = { status: 'failed', error: 'setup failed', updatedAt: 2 }; + markWorktreeBootstrapPending('/repo-wt'); + + startWorktreeBootstrapWatcher('/repo-wt', { pollIntervalMs: 0 }); + + await waitFor(() => toastErrors.length === 1); + expect(toastErrors).toEqual([{ title: 'worktree.bootstrap.toast.failed', description: 'setup failed' }]); + }); + + test('background watcher marks failed and toasts when bootstrap times out', async () => { + bootstrapStatusResult = { status: 'pending', error: null, updatedAt: 2 }; + markWorktreeBootstrapPending('/repo-wt'); + const failedStatuses: Array<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> = []; + + startWorktreeBootstrapWatcher('/repo-wt', { + timeoutMs: 0, + pollIntervalMs: 0, + onFailed: (status) => failedStatuses.push(status), + }); + + await waitFor(() => toastErrors.length === 1); + expect(getWorktreeBootstrapState('/repo-wt')?.status).toBe('failed'); + expect(failedStatuses.map((status) => status.status)).toEqual(['failed']); + expect(toastErrors).toEqual([{ + title: 'worktree.bootstrap.toast.failed', + description: 'worktree.bootstrap.toast.timeoutDescription', + }]); + }); + + test('background watcher is deduped per directory', async () => { + bootstrapStatusResult = { status: 'pending', error: null, updatedAt: 2 }; + markWorktreeBootstrapPending('/repo-wt'); + + startWorktreeBootstrapWatcher('/repo-wt', { pollIntervalMs: 1000 }); + startWorktreeBootstrapWatcher('/repo-wt', { pollIntervalMs: 1000 }); + + await waitFor(() => bootstrapStatusCalls.length === 1); + expect(bootstrapStatusCalls).toEqual(['/repo-wt']); + clearWorktreeBootstrapState('/repo-wt'); + }); }); diff --git a/packages/ui/src/lib/worktrees/worktreeBootstrap.ts b/packages/ui/src/lib/worktrees/worktreeBootstrap.ts index ab531afd..97c97f2b 100644 --- a/packages/ui/src/lib/worktrees/worktreeBootstrap.ts +++ b/packages/ui/src/lib/worktrees/worktreeBootstrap.ts @@ -1,8 +1,12 @@ import * as gitHttp from '@/lib/gitApiHttp'; import type { GitWorktreeBootstrapStatus } from '@/lib/api/types'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { toast } from '@/components/ui'; +import { formatMessage, useI18nStore, type I18nKey, type I18nParams } from '@/lib/i18n'; type WorktreeBootstrapState = GitWorktreeBootstrapStatus; +type WorktreeBootstrapFailureHandler = (status: GitWorktreeBootstrapStatus) => void; +type WorktreeBootstrapReadyHandler = (status: GitWorktreeBootstrapStatus) => void; const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; const POLL_INTERVAL_MS = 250; @@ -11,6 +15,7 @@ const normalizePath = (value: string): string => value.replace(/\\/g, '/').repla const state = new Map(); const waiters = new Map>(); +const watchers = new Map }>(); const getKey = (directory: string): string => normalizePath(directory); @@ -42,6 +47,11 @@ export const clearWorktreeBootstrapState = (directory: string): void => { if (!key) { return; } + const watcher = watchers.get(key); + if (watcher) { + watcher.cancelled = true; + watchers.delete(key); + } state.delete(key); waiters.delete(key); }; @@ -65,6 +75,28 @@ export const getWorktreeBootstrapState = (directory: string): WorktreeBootstrapS return state.get(key) ?? null; }; +const t = (key: I18nKey, params?: I18nParams): string => { + const dictionary = useI18nStore.getState().dictionary; + return formatMessage(dictionary, key, params); +}; + +const createFailedStatus = (error: string): GitWorktreeBootstrapStatus => ({ + status: 'failed', + error, + updatedAt: Date.now(), +}); + +const markBootstrapFailed = ( + directory: string, + error: string, + onFailed?: WorktreeBootstrapFailureHandler, +): GitWorktreeBootstrapStatus => { + const failed = createFailedStatus(error); + setWorktreeBootstrapState(directory, failed); + onFailed?.(failed); + return failed; +}; + const pollWorktreeBootstrapUntilSettled = async (directory: string, timeoutMs: number): Promise => { const startedAt = Date.now(); @@ -83,7 +115,100 @@ const pollWorktreeBootstrapUntilSettled = async (directory: string, timeoutMs: n await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } - throw new Error('Timed out waiting for worktree bootstrap'); + const failed = markBootstrapFailed(directory, t('worktree.bootstrap.toast.timeoutDescription')); + throw new Error(failed.error || 'Timed out waiting for worktree bootstrap'); +}; + +const pollWorktreeBootstrapInBackground = async ( + directory: string, + watcher: { cancelled: boolean }, + timeoutMs: number, + pollIntervalMs: number, + onFailed?: WorktreeBootstrapFailureHandler, + onReady?: WorktreeBootstrapReadyHandler, +): Promise => { + const startedAt = Date.now(); + + while (!watcher.cancelled && Date.now() - startedAt < timeoutMs) { + const result = await getGitWorktreeBootstrapStatus(directory); + if (watcher.cancelled) { + return; + } + setWorktreeBootstrapState(directory, result); + + if (result.status === 'ready') { + onReady?.(result); + return; + } + + if (result.status === 'failed') { + onFailed?.(result); + toast.error(t('worktree.bootstrap.toast.failed'), { + description: result.error || t('worktree.bootstrap.toast.failedDescription'), + }); + return; + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + if (!watcher.cancelled) { + const failed = markBootstrapFailed(directory, t('worktree.bootstrap.toast.timeoutDescription'), onFailed); + toast.error(t('worktree.bootstrap.toast.failed'), { + description: failed.error || t('worktree.bootstrap.toast.failedDescription'), + }); + } +}; + +export const startWorktreeBootstrapWatcher = ( + directory: string, + options?: { + timeoutMs?: number; + pollIntervalMs?: number; + onFailed?: WorktreeBootstrapFailureHandler; + onReady?: WorktreeBootstrapReadyHandler; + }, +): void => { + const key = getKey(directory); + if (!key) { + return; + } + + const current = state.get(key); + if (current?.status !== 'pending') { + return; + } + + if (watchers.has(key)) { + return; + } + + const watcher = { cancelled: false, promise: Promise.resolve() }; + watcher.promise = pollWorktreeBootstrapInBackground( + directory, + watcher, + options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, + options?.pollIntervalMs ?? POLL_INTERVAL_MS, + options?.onFailed, + options?.onReady, + ).catch((error) => { + if (watcher.cancelled) { + return; + } + const failed = markBootstrapFailed( + directory, + error instanceof Error ? error.message : String(error), + options?.onFailed, + ); + toast.error(t('worktree.bootstrap.toast.failed'), { + description: failed.error || t('worktree.bootstrap.toast.failedDescription'), + }); + }).finally(() => { + if (watchers.get(key) === watcher) { + watchers.delete(key); + } + }); + watchers.set(key, watcher); }; export const waitForWorktreeBootstrap = async (directory: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise => { diff --git a/packages/ui/src/lib/worktrees/worktreeManager.test.ts b/packages/ui/src/lib/worktrees/worktreeManager.test.ts index 7344a7ad..7d2e93e6 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.test.ts @@ -14,6 +14,8 @@ const createdWorktree = { name: 'feature', branch: 'feature', path: '/repo-feature', + directoryCreated: true as const, + bootstrapStatus: { status: 'pending' as const, error: null, updatedAt: 1 }, }; const sessionState = { @@ -28,6 +30,8 @@ mock.module('@/lib/openchamberConfig', () => ({ mock.module('@/lib/worktrees/worktreeBootstrap', () => ({ clearWorktreeBootstrapState: mock(), markWorktreeBootstrapPending: mock(), + setWorktreeBootstrapState: mock(), + startWorktreeBootstrapWatcher: mock(), })); mock.module('@/lib/worktrees/worktreeStatus', () => ({ @@ -103,4 +107,17 @@ describe('worktreeManager list invalidation', () => { expect(listCalls).toEqual(['/repo', '/repo']); expect(result.map((entry) => entry.path)).toEqual(['/repo-feature']); }); + + test('marks fast-created worktrees pending until bootstrap settles', async () => { + const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, { + preferredName: 'feature', + mode: 'new', + branchName: 'feature', + worktreeName: 'feature', + returnAfterDirectoryCreated: true, + }); + + expect(metadata.worktreeStatus).toBe('pending'); + expect(sessionState.availableWorktrees[0]?.worktreeStatus).toBe('pending'); + }); }); diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index f4f9a666..c66b7fe2 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -7,10 +7,13 @@ import { import { clearWorktreeBootstrapState, markWorktreeBootstrapPending, + setWorktreeBootstrapState, + startWorktreeBootstrapWatcher, } from '@/lib/worktrees/worktreeBootstrap'; import { invalidateResolvedProjectRootCache, resolveProjectRoot } from '@/lib/worktrees/worktreeStatus'; import type { CreateGitWorktreePayload, + GitWorktreeBootstrapStatus, GitWorktreeValidationResult, } from '@/lib/api/types'; import { useSessionUIStore } from '@/sync/session-ui-store'; @@ -77,6 +80,83 @@ const normalizeBranchName = (value: string): string => { .replace(/^\/+|\/+$/g, ''); }; +const setStoredWorktreeStatus = (directory: string, status: NonNullable): void => { + const target = normalizePath(directory); + if (!target) { + return; + } + + useSessionUIStore.setState((state) => { + let changed = false; + + const applyStatus = (metadata: WorktreeMetadata): WorktreeMetadata => { + if (normalizePath(metadata.path) !== target || metadata.worktreeStatus === status) { + return metadata; + } + changed = true; + return { ...metadata, worktreeStatus: status }; + }; + + let availableWorktrees = state.availableWorktrees; + let availableWorktreesChanged = false; + const nextAvailableWorktrees = state.availableWorktrees.map((metadata) => { + const next = applyStatus(metadata); + if (next !== metadata) { + availableWorktreesChanged = true; + } + return next; + }); + if (availableWorktreesChanged) { + availableWorktrees = nextAvailableWorktrees; + } + let availableWorktreesByProject = state.availableWorktreesByProject; + for (const [projectKey, entries] of state.availableWorktreesByProject) { + let projectChanged = false; + const nextEntries = entries.map((metadata) => { + const next = applyStatus(metadata); + if (next !== metadata) { + projectChanged = true; + } + return next; + }); + if (projectChanged) { + if (availableWorktreesByProject === state.availableWorktreesByProject) { + availableWorktreesByProject = new Map(state.availableWorktreesByProject); + } + availableWorktreesByProject.set(projectKey, nextEntries); + } + } + + let worktreeMetadata = state.worktreeMetadata; + for (const [sessionId, metadata] of state.worktreeMetadata) { + const next = applyStatus(metadata); + if (next !== metadata) { + if (worktreeMetadata === state.worktreeMetadata) { + worktreeMetadata = new Map(state.worktreeMetadata); + } + worktreeMetadata.set(sessionId, next); + } + } + + if (!changed) { + return {}; + } + + return { + availableWorktrees, + availableWorktreesByProject, + worktreeMetadata, + }; + }); +}; + +const getWorktreeStatusFromBootstrap = (status?: GitWorktreeBootstrapStatus): WorktreeMetadata['worktreeStatus'] => { + if (status?.status === 'pending') { + return 'pending'; + } + return status?.status === 'failed' ? 'invalid' : 'ready'; +}; + const deriveSdkWorktreeNameFromDirectory = (directory: string): string => { const normalized = normalizePath(directory); const parts = normalized.split('/').filter(Boolean); @@ -101,7 +181,7 @@ export const buildSdkStartCommand = (args: { return joined.trim().length > 0 ? joined : undefined; }; -const toCreatePayload = (args: { +export const toCreatePayload = (args: { preferredName?: string; setupCommands?: string[]; mode?: 'new' | 'existing'; @@ -114,6 +194,7 @@ const toCreatePayload = (args: { upstreamBranch?: string; ensureRemoteName?: string; ensureRemoteUrl?: string; + returnAfterDirectoryCreated?: boolean; }, projectDirectory: string): CreateGitWorktreePayload => { const mode = args.mode === 'existing' ? 'existing' : 'new'; @@ -144,6 +225,7 @@ const toCreatePayload = (args: { ...(args.upstreamBranch ? { upstreamBranch: args.upstreamBranch } : {}), ...(args.ensureRemoteName ? { ensureRemoteName: args.ensureRemoteName } : {}), ...(args.ensureRemoteUrl ? { ensureRemoteUrl: args.ensureRemoteUrl } : {}), + ...(args.returnAfterDirectoryCreated ? { returnAfterDirectoryCreated: true } : {}), }; }; @@ -247,6 +329,7 @@ export type CreateWorktreeArgs = { upstreamBranch?: string; ensureRemoteName?: string; ensureRemoteUrl?: string; + returnAfterDirectoryCreated?: boolean; }; export async function createWorktree(project: ProjectRef, args: CreateWorktreeArgs): Promise { @@ -271,12 +354,20 @@ export async function createWorktree(project: ProjectRef, args: CreateWorktreeAr branch: returnedBranch, label: returnedBranch || returnedName, worktreeRoot: normalizePath(returnedPath), - worktreeStatus: 'ready', + worktreeStatus: getWorktreeStatusFromBootstrap(created?.bootstrapStatus), headState: returnedBranch ? 'branch' : 'unborn', worktreeSource: 'created-for-session', }; - markWorktreeBootstrapPending(metadata.path); + if (created?.bootstrapStatus) { + setWorktreeBootstrapState(metadata.path, created.bootstrapStatus); + } else { + markWorktreeBootstrapPending(metadata.path); + } + startWorktreeBootstrapWatcher(metadata.path, { + onFailed: () => setStoredWorktreeStatus(metadata.path, 'invalid'), + onReady: () => setStoredWorktreeStatus(metadata.path, 'ready'), + }); invalidateWorktreeList(projectDirectory); // The new worktree changes the repo's worktree topology; drop cached root diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index 12814a7c..57a53952 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -7,7 +7,7 @@ export type SessionWorktreeAttachment = { cwd: string | null; branch: string | null; headState: 'branch' | 'detached' | 'unborn'; - worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; + worktreeStatus: 'pending' | 'ready' | 'missing' | 'invalid' | 'not-a-repo'; worktreeSource: 'existing' | 'created-for-session' | null; legacy: boolean; degraded: boolean; @@ -235,7 +235,7 @@ export interface SessionStore { closeNewSessionDraft: () => void; createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise; - createSessionFromAssistantMessage: (sourceMessageId: string, execution: { providerID: string; modelID: string; variant: string; agent: string; instructions: string }) => Promise; + createSessionFromAssistantMessage: (sourceMessageId: string, execution: { providerID: string; modelID: string; variant: string; agent: string; instructions: string; createWorktree?: boolean }) => Promise; deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string }) => Promise; deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>; diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 0c2777f1..261a0b86 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -28,7 +28,6 @@ import { getSafeStorage } from "@/stores/utils/safeStorage" import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" import { flattenAssistantTextParts } from "@/lib/messages/messageText" import { composeForkSessionMessage } from "@/lib/messages/executionMeta" -import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap" import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree" import { resolveProjectForSessionDirectory } from "@/lib/projectResolution" import { @@ -158,6 +157,15 @@ type SendMessageOptions = { sessionId?: string } +type AssistantMessageSessionExecution = { + providerID: string + modelID: string + variant: string + agent: string + instructions: string + createWorktree?: boolean +} + function notifyMessageSent(sessionId: string): void { runtimeFetch(`/api/sessions/${sessionId}/message-sent`, { method: "POST" }) .catch(() => { /* ignore */ }) @@ -272,7 +280,7 @@ export type SessionUIState = { forkFromMessage: (sessionId: string, messageId: string) => Promise handleSlashUndo: (sessionId: string) => Promise handleSlashRedo: (sessionId: string, options?: { fullUnrevert?: boolean }) => Promise - createSessionFromAssistantMessage: (sourceMessageId: string, execution: { providerID: string; modelID: string; variant: string; agent: string; instructions: string }) => Promise + createSessionFromAssistantMessage: (sourceMessageId: string, execution: AssistantMessageSessionExecution) => Promise // Data access helpers (read from sync) getSessionsByDirectory: (directory: string) => Session[] @@ -874,10 +882,6 @@ export const useSessionUIStore = create()((set, get) => ({ ? [...(additionalParts || []), ...draftSyntheticParts] : additionalParts - if (createdDirectory) { - await waitForWorktreeBootstrap(createdDirectory) - } - notifyMessageSent(created.id) markPendingUserSendAnimation(created.id) @@ -922,8 +926,13 @@ export const useSessionUIStore = create()((set, get) => ({ const configAgentName = useConfigStore.getState().currentAgentName const effectiveAgent = trimmedAgent || sessionAgentSelection || configAgentName || undefined + if (targetSessionId) { + useSelectionStore.getState().saveSessionModelSelection(targetSessionId, providerID, modelID) + } + if (targetSessionId && effectiveAgent) { useSelectionStore.getState().saveSessionAgentSelection(targetSessionId, effectiveAgent) + useSelectionStore.getState().saveAgentModelForSession(targetSessionId, effectiveAgent, providerID, modelID) useSelectionStore.getState().saveAgentModelVariantForSession(targetSessionId, effectiveAgent, providerID, modelID, variant) } @@ -947,10 +956,6 @@ export const useSessionUIStore = create()((set, get) => ({ const currentSessionDirectory = targetSessionId ? normalizePath(get().getDirectoryForSession(targetSessionId)) : null - if (currentSessionDirectory) { - await waitForWorktreeBootstrap(currentSessionDirectory) - } - if (targetSessionId) { notifyMessageSent(targetSessionId) } @@ -1203,25 +1208,81 @@ export const useSessionUIStore = create()((set, get) => ({ sourceSessionId ?? null, (sid) => get().worktreeMetadata.get(sid), ) - - const session = await get().createSession(undefined, directory ?? null, null) - if (!session) return + const sourceWorktreeMetadata = sourceSessionId ? get().worktreeMetadata.get(sourceSessionId) : undefined const pID = execution.providerID || useSelectionStore.getState().lastUsedProvider?.providerID const mID = execution.modelID || useSelectionStore.getState().lastUsedProvider?.modelID if (!pID || !mID) return - const sessionDirectory = normalizePath(directory ?? session.directory ?? null) - await opencodeClient.sendMessage({ - id: session.id, - providerID: pID, - modelID: mID, - variant: execution.variant || undefined, - text: composeForkSessionMessage(execution.instructions, assistantPlanText), - agent: execution.agent || undefined, - directory: sessionDirectory, - }) + const sourceDirectory = normalizePath(directory ?? opencodeClient.getDirectory() ?? null) + let sessionDirectory = sourceDirectory + let createdWorktree: WorktreeMetadata | null = null + let createdWorktreeProject: { id: string; path: string } | null = null + + if (execution.createWorktree) { + const projects = useProjectsStore.getState().projects + const project = resolveProjectForSessionDirectory( + projects, + get().availableWorktreesByProject, + sourceDirectory, + ) ?? resolveProjectForSessionDirectory( + projects, + get().availableWorktreesByProject, + sourceWorktreeMetadata?.projectDirectory ?? null, + ) + if (!project?.path) { + throw new Error("Project is not registered in OpenChamber") + } + + const [branchNameModule, configModule, createModule] = await Promise.all([ + import("@/lib/git/branchNameGenerator"), + import("@/lib/openchamberConfig"), + import("@/lib/worktrees/worktreeCreate"), + ]) + const branchName = branchNameModule.generateBranchName() + createdWorktreeProject = { id: project.id, path: project.path } + const setupCommands = await configModule.getWorktreeSetupCommands(createdWorktreeProject) + createdWorktree = await createModule.createWorktreeWithDefaults(createdWorktreeProject, { + preferredName: branchName, + mode: "new", + branchName, + worktreeName: branchName, + setupCommands, + returnAfterDirectoryCreated: true, + }) + sessionDirectory = normalizePath(createdWorktree.path) + } + + const session = await get().createSession(undefined, sessionDirectory || null, null) + if (!session) { + if (createdWorktree && createdWorktreeProject) { + const { removeProjectWorktree } = await import("@/lib/worktrees/worktreeManager") + await removeProjectWorktree(createdWorktreeProject, createdWorktree, { deleteLocalBranch: true }).catch(() => undefined) + } + return + } + + if (createdWorktree) { + get().setWorktreeMetadata(session.id, { + ...createdWorktree, + kind: "standard", + }) + useDirectoryStore.getState().setDirectory(createdWorktree.path, { showOverlay: false }) + } + + await get().sendMessage( + composeForkSessionMessage(execution.instructions, assistantPlanText), + pID, + mID, + execution.agent || undefined, + undefined, + undefined, + undefined, + execution.variant || undefined, + undefined, + { sessionId: session.id }, + ) }, // --------------------------------------------------------------------------- diff --git a/packages/ui/src/sync/session-worktree-contract.ts b/packages/ui/src/sync/session-worktree-contract.ts index 6fbf4c0e..4279042c 100644 --- a/packages/ui/src/sync/session-worktree-contract.ts +++ b/packages/ui/src/sync/session-worktree-contract.ts @@ -20,7 +20,7 @@ export type WorktreeCanonicalizationResult = { cwd: string | null; branch: string | null; headState: 'branch' | 'detached' | 'unborn'; - worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; + worktreeStatus: 'pending' | 'ready' | 'missing' | 'invalid' | 'not-a-repo'; legacy: boolean; degraded: boolean; attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; @@ -143,8 +143,12 @@ export function resolveSessionWorktreeState( }; } -export function formatSessionWorktreeBadge(attachment: SessionWorktreeAttachment): string { +export function formatSessionWorktreeBadge( + attachment: SessionWorktreeAttachment, + labels?: { pending?: string } +): string { if (attachment.legacy) return 'Legacy session'; + if (attachment.worktreeStatus === 'pending') return labels?.pending ?? 'Needs attention'; if (attachment.worktreeStatus === 'missing') return 'Worktree missing'; if (attachment.worktreeStatus === 'not-a-repo') return 'Not a repo'; if (attachment.worktreeStatus === 'invalid') return 'Needs attention'; diff --git a/packages/ui/src/types/worktree.ts b/packages/ui/src/types/worktree.ts index cf50e7b2..08f62d21 100644 --- a/packages/ui/src/types/worktree.ts +++ b/packages/ui/src/types/worktree.ts @@ -40,7 +40,7 @@ export interface WorktreeMetadata { worktreeRoot?: string; /** Operational status of this worktree. */ - worktreeStatus?: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; + worktreeStatus?: 'pending' | 'ready' | 'missing' | 'invalid' | 'not-a-repo'; /** Git HEAD state classification. */ headState?: 'branch' | 'detached' | 'unborn'; diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index e9acb694..af887a09 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -814,6 +814,8 @@ export interface GitWorktreeInfo { name: string; branch: string; path: string; + directoryCreated?: true; + bootstrapStatus?: { status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }; } type WorktreeListEntry = { @@ -850,6 +852,7 @@ export interface CreateGitWorktreePayload { upstreamBranch?: string; ensureRemoteName?: string; ensureRemoteUrl?: string; + returnAfterDirectoryCreated?: boolean; } export interface RemoveGitWorktreePayload { @@ -1359,6 +1362,54 @@ const syncProjectSandboxRemove = async (projectID: string, primaryWorktree: stri }); }; +const isInsideOrSameDirectory = (root: string, target: string): boolean => { + const relative = path.relative(root, target); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +}; + +const isAttachedGitWorktreeDirectory = async (directory: string): Promise => { + try { + const result = await runGitCommand(directory, ['rev-parse', '--is-inside-work-tree']); + return result.success && String(result.stdout || '').trim() === 'true'; + } catch { + return false; + } +}; + +const cleanupFailedFastWorktreeCreate = async ( + context: Awaited>, + candidate: { directory: string } +): Promise => { + const candidateDirectory = path.resolve(candidate.directory); + const worktreeRoot = path.resolve(context.worktreeRoot); + const isInsideWorktreeRoot = isInsideOrSameDirectory(worktreeRoot, candidateDirectory) && candidateDirectory !== worktreeRoot; + const isAttached = await isAttachedGitWorktreeDirectory(candidateDirectory); + + if (!isAttached) { + try { + await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, candidateDirectory); + } catch (error) { + console.warn('[GitService] Failed to clean up OpenCode sandbox metadata after worktree failure:', error instanceof Error ? error.message : String(error)); + } + } + + if (!isInsideWorktreeRoot || isAttached) { + return; + } + + try { + const entries = await fs.promises.readdir(candidateDirectory); + if (entries.length === 0) { + await fs.promises.rmdir(candidateDirectory); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(String(code || ''))) { + console.warn('[GitService] Failed to clean up empty worktree directory after creation failure:', error instanceof Error ? error.message : String(error)); + } + } +}; + const runWorktreeStartScripts = async (directory: string, projectID: string, startCommand: string | undefined) => { const projectStart = await loadProjectStartCommand(projectID); if (projectStart) { @@ -1725,24 +1776,17 @@ export async function previewWorktreeCreate(directory: string, input: CreateGitW }; } -export async function createWorktree(directory: string, input: CreateGitWorktreePayload = {}): Promise { +async function attachGitWorktreeToCandidate( + context: Awaited>, + candidate: { name: string; directory: string; branch: string }, + input: CreateGitWorktreePayload = {}, +): Promise { const mode = input?.mode === 'existing' ? 'existing' : 'new'; - const context = await resolveWorktreeProjectContext(directory); - await fs.promises.mkdir(context.worktreeRoot, { recursive: true }); - - const preferredName = String(input?.worktreeName || input?.name || '').trim(); const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim()); const startRef = normalizeStartRef(input?.startRef); const ensureRemoteName = String(input?.ensureRemoteName || '').trim(); const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim(); - const candidate = await resolveCandidateDirectory( - context.worktreeRoot, - preferredName, - mode === 'new' && preferredBranchName ? preferredBranchName : '', - context.primaryWorktree - ); - let localBranch = ''; let inferredUpstream: { remote: string; branch: string } | null = null; const worktreeAddArgs = ['worktree', 'add', '--no-checkout']; @@ -1828,6 +1872,11 @@ export async function createWorktree(directory: string, input: CreateGitWorktree const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim(); setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING); + const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? { + status: WORKTREE_BOOTSTRAP_PENDING, + error: null, + updatedAt: Date.now(), + }; queueWorktreeBootstrap({ directory: candidate.directory, @@ -1850,9 +1899,68 @@ export async function createWorktree(directory: string, input: CreateGitWorktree name: candidate.name, branch: localBranch, path: candidate.directory, + directoryCreated: true, + bootstrapStatus, }; } +export async function createWorktree(directory: string, input: CreateGitWorktreePayload = {}): Promise { + const mode = input?.mode === 'existing' ? 'existing' : 'new'; + const context = await resolveWorktreeProjectContext(directory); + await fs.promises.mkdir(context.worktreeRoot, { recursive: true }); + + const preferredName = String(input?.worktreeName || input?.name || '').trim(); + const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim()); + + const candidate = await resolveCandidateDirectory( + context.worktreeRoot, + preferredName, + mode === 'new' && preferredBranchName ? preferredBranchName : '', + context.primaryWorktree + ); + + if (input?.returnAfterDirectoryCreated === true) { + await fs.promises.mkdir(candidate.directory, { recursive: false }); + + try { + await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory); + } catch (error) { + console.warn('[GitService] Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error)); + } + + setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING); + const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? { + status: WORKTREE_BOOTSTRAP_PENDING, + error: null, + updatedAt: Date.now(), + }; + const localBranch = mode === 'existing' + ? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim()) + : candidate.branch; + + void attachGitWorktreeToCandidate(context, candidate, input).catch((error) => { + setWorktreeBootstrapState( + candidate.directory, + WORKTREE_BOOTSTRAP_FAILED, + error instanceof Error ? error.message : String(error) + ); + void cleanupFailedFastWorktreeCreate(context, candidate); + console.warn('[GitService] Background worktree creation failed:', error instanceof Error ? error.message : String(error)); + }); + + return { + head: '', + name: candidate.name, + branch: localBranch, + path: candidate.directory, + directoryCreated: true, + bootstrapStatus, + }; + } + + return attachGitWorktreeToCandidate(context, candidate, input); +} + export async function getWorktreeBootstrapStatus(directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> { const key = toBootstrapStateKey(directory); if (!key) { diff --git a/packages/vscode/webview/api/git.ts b/packages/vscode/webview/api/git.ts index 5d64eb64..0a4479ba 100644 --- a/packages/vscode/webview/api/git.ts +++ b/packages/vscode/webview/api/git.ts @@ -446,6 +446,18 @@ export const createVSCodeGitAPI = (): GitAPI => ({ ...(payload || {}), }); }, + bootstrapStatus: async (directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> => { + return sendBridgeMessage<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }>('api:git/worktrees/bootstrap-status', { + directory, + }); + }, + preview: async (directory: string, payload: CreateGitWorktreePayload): Promise => { + return sendBridgeMessage('api:git/worktrees/preview', { + directory, + method: 'POST', + ...(payload || {}), + }); + }, create: async (directory: string, payload: CreateGitWorktreePayload): Promise => { return sendBridgeMessage('api:git/worktrees', { directory, diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 4bb0020e..31d3be6d 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -118,6 +118,9 @@ The following functions are internal helpers used by exported functions: - `name`: Worktree name. - `branch`: Local branch name. - `path`: Absolute path to worktree directory. +- `directoryCreated`: Present when create returned after the target directory exists while background Git/bootstrap work continues. +- `bootstrapStatus`: Background setup status, with `pending`, `ready`, or `failed`. +- Fast-create background failures remove OpenCode sandbox metadata for directories that never became Git worktrees, and remove the pre-created directory only if it is still empty. User-created files are never recursively deleted by this cleanup. ### Log Response - `all`: Array of commit objects with hash, date, message, author info, stats. diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index 85455625..95b0ba56 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -1168,6 +1168,45 @@ const syncProjectSandboxRemove = async (projectID, primaryWorktree, sandboxPath) }); }; +const isAttachedGitWorktreeDirectory = async (directory) => { + try { + const result = await runGitCommand(directory, ['rev-parse', '--is-inside-work-tree']); + return result.success && String(result.stdout || '').trim() === 'true'; + } catch { + return false; + } +}; + +const cleanupFailedFastWorktreeCreate = async (context, candidate) => { + const candidateDirectory = path.resolve(candidate.directory); + const worktreeRoot = path.resolve(context.worktreeRoot); + const isInsideWorktreeRoot = isInsideOrSameDirectory(worktreeRoot, candidateDirectory) && candidateDirectory !== worktreeRoot; + const isAttached = await isAttachedGitWorktreeDirectory(candidateDirectory); + + if (!isAttached) { + try { + await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, candidateDirectory); + } catch (error) { + console.warn('Failed to clean up OpenCode sandbox metadata after worktree failure:', error instanceof Error ? error.message : String(error)); + } + } + + if (!isInsideWorktreeRoot || isAttached) { + return; + } + + try { + const entries = await fsp.readdir(candidateDirectory); + if (entries.length === 0) { + await fsp.rmdir(candidateDirectory); + } + } catch (error) { + if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error?.code)) { + console.warn('Failed to clean up empty worktree directory after creation failure:', error instanceof Error ? error.message : String(error)); + } + } +}; + const runWorktreeStartScripts = async (directory, projectID, startCommand) => { const projectStart = await loadProjectStartCommand(projectID); if (projectStart) { @@ -3010,24 +3049,13 @@ export async function previewWorktreeCreate(directory, input = {}) { }; } -export async function createWorktree(directory, input = {}) { +async function attachGitWorktreeToCandidate(context, candidate, input = {}) { const mode = input?.mode === 'existing' ? 'existing' : 'new'; - const context = await resolveWorktreeProjectContext(directory); - await fsp.mkdir(context.worktreeRoot, { recursive: true }); - - const preferredName = String(input?.worktreeName || input?.name || '').trim(); const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim()); const startRef = normalizeStartRef(input?.startRef); const ensureRemoteName = String(input?.ensureRemoteName || '').trim(); const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim(); - const candidate = await resolveCandidateDirectory( - context.worktreeRoot, - preferredName, - mode === 'new' && preferredBranchName ? preferredBranchName : '', - context.primaryWorktree - ); - let localBranch = ''; let inferredUpstream = null; const worktreeAddArgs = ['worktree', 'add', '--no-checkout']; @@ -3113,6 +3141,11 @@ export async function createWorktree(directory, input = {}) { const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim(); setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING); + const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? { + status: WORKTREE_BOOTSTRAP_PENDING, + error: null, + updatedAt: Date.now(), + }; queueWorktreeBootstrap({ directory: candidate.directory, @@ -3135,9 +3168,68 @@ export async function createWorktree(directory, input = {}) { name: candidate.name, branch: localBranch, path: candidate.directory, + directoryCreated: true, + bootstrapStatus, }; } +export async function createWorktree(directory, input = {}) { + const mode = input?.mode === 'existing' ? 'existing' : 'new'; + const context = await resolveWorktreeProjectContext(directory); + await fsp.mkdir(context.worktreeRoot, { recursive: true }); + + const preferredName = String(input?.worktreeName || input?.name || '').trim(); + const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim()); + + const candidate = await resolveCandidateDirectory( + context.worktreeRoot, + preferredName, + mode === 'new' && preferredBranchName ? preferredBranchName : '', + context.primaryWorktree + ); + + if (input?.returnAfterDirectoryCreated === true) { + await fsp.mkdir(candidate.directory, { recursive: false }); + + try { + await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory); + } catch (error) { + console.warn('Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error)); + } + + setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING); + const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? { + status: WORKTREE_BOOTSTRAP_PENDING, + error: null, + updatedAt: Date.now(), + }; + const localBranch = mode === 'existing' + ? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim()) + : candidate.branch; + + void attachGitWorktreeToCandidate(context, candidate, input).catch((error) => { + setWorktreeBootstrapState( + candidate.directory, + WORKTREE_BOOTSTRAP_FAILED, + error instanceof Error ? error.message : String(error) + ); + void cleanupFailedFastWorktreeCreate(context, candidate); + console.warn('Background worktree creation failed:', error instanceof Error ? error.message : String(error)); + }); + + return { + head: '', + name: candidate.name, + branch: localBranch, + path: candidate.directory, + directoryCreated: true, + bootstrapStatus, + }; + } + + return attachGitWorktreeToCandidate(context, candidate, input); +} + export async function getWorktreeBootstrapStatus(directory) { const key = toBootstrapStateKey(directory); if (!key) { diff --git a/packages/web/src/api/git.ts b/packages/web/src/api/git.ts index 77d36cf0..76fc85ee 100644 --- a/packages/web/src/api/git.ts +++ b/packages/web/src/api/git.ts @@ -71,6 +71,8 @@ export const createWebGitAPI = (): GitAPI => ({ worktree: { list: gitApiHttp.listGitWorktrees, validate: gitApiHttp.validateGitWorktree, + bootstrapStatus: gitApiHttp.getGitWorktreeBootstrapStatus, + preview: gitApiHttp.previewGitWorktree, create: gitApiHttp.createGitWorktree, remove: gitApiHttp.deleteGitWorktree, },