feat: instant draft-first worktree creation and multi-run launcher redesign (#741)
## Summary - **Instant worktree creation from chat draft**: selecting "+ New worktree" in the draft branch selector immediately creates a session draft and bootstraps the worktree in the background — no modal interruption - **Redesigned multi-run launcher**: compact 2-column grid layout in a right-sized dialog with scroll shadow, sticky footer, tooltips replacing verbose descriptions, and project icons in the selector - **Branch selector aligned across surfaces**: multi-run and agent manager branch pickers now use the shared git store and match NewWorktreeDialog behavior (same default resolution cascade, no synthetic HEAD option, all branches shown) - **Opaque model multi-select dropdown**: fixes text bleed-through on translucent backgrounds by compositing `--surface-elevated` over `--surface-background` - **"+ New" inline button in sidebar worktree headers** for faster worktree creation ## Why Worktree creation was behind modal flow that interrupted the user's train of thought. The draft-first approach lets users start typing immediately while the worktree bootstraps. The multi-run launcher had an oversized form layout with redundant explanations, and its branch picker behaved differently from the main worktree dialog - causing confusion about which branches were available and what the default was.
This commit is contained in:
committed by
GitHub
parent
c66d480782
commit
53c2a0d919
@@ -306,6 +306,12 @@ export interface GitWorktreeValidationResult {
|
||||
};
|
||||
}
|
||||
|
||||
export interface GitWorktreeBootstrapStatus {
|
||||
status: 'pending' | 'ready' | 'failed';
|
||||
error: string | null;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface CreateGitWorktreePayload {
|
||||
mode?: 'new' | 'existing';
|
||||
/** Worktree folder name (falls back to OpenCode name generation when omitted). */
|
||||
@@ -380,6 +386,8 @@ export interface GeneratedPullRequestDescription {
|
||||
export interface GitWorktreeAPI {
|
||||
list(directory: string): Promise<GitWorktreeInfo[]>;
|
||||
validate?(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeValidationResult>;
|
||||
bootstrapStatus?(directory: string): Promise<GitWorktreeBootstrapStatus>;
|
||||
preview?(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult>;
|
||||
create?(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult>;
|
||||
remove?(directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }>;
|
||||
}
|
||||
@@ -402,6 +410,8 @@ export interface GitAPI {
|
||||
): Promise<GeneratedPullRequestDescription>;
|
||||
listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]>;
|
||||
validateGitWorktree?(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeValidationResult>;
|
||||
getGitWorktreeBootstrapStatus?(directory: string): Promise<GitWorktreeBootstrapStatus>;
|
||||
previewGitWorktree?(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult>;
|
||||
createGitWorktree?(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult>;
|
||||
deleteGitWorktree?(directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }>;
|
||||
createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions): Promise<GitCommitResult>;
|
||||
|
||||
@@ -453,6 +453,33 @@ export async function validateGitWorktree(
|
||||
return gitHttp.validateGitWorktree(directory, payload);
|
||||
}
|
||||
|
||||
export async function getGitWorktreeBootstrapStatus(
|
||||
directory: string,
|
||||
): Promise<import('./api/types').GitWorktreeBootstrapStatus> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime?.worktree?.bootstrapStatus) {
|
||||
return runtime.worktree.bootstrapStatus(directory);
|
||||
}
|
||||
if (runtime?.getGitWorktreeBootstrapStatus) {
|
||||
return runtime.getGitWorktreeBootstrapStatus(directory);
|
||||
}
|
||||
return gitHttp.getGitWorktreeBootstrapStatus(directory);
|
||||
}
|
||||
|
||||
export async function previewGitWorktree(
|
||||
directory: string,
|
||||
payload: import('./api/types').CreateGitWorktreePayload
|
||||
): Promise<import('./api/types').GitWorktreeCreateResult> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime?.worktree?.preview) {
|
||||
return runtime.worktree.preview(directory, payload);
|
||||
}
|
||||
if (runtime?.previewGitWorktree) {
|
||||
return runtime.previewGitWorktree(directory, payload);
|
||||
}
|
||||
return gitHttp.previewGitWorktree(directory, payload);
|
||||
}
|
||||
|
||||
export async function createGitWorktree(
|
||||
directory: string,
|
||||
payload: import('./api/types').CreateGitWorktreePayload
|
||||
|
||||
@@ -424,6 +424,30 @@ export async function validateGitWorktree(directory: string, payload: CreateGitW
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getGitWorktreeBootstrapStatus(directory: string): Promise<import('./api/types').GitWorktreeBootstrapStatus> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/worktrees/bootstrap-status`, directory));
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to get worktree bootstrap status');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function previewGitWorktree(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/worktrees/preview`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload ?? {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to preview worktree');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createGitWorktree(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory), {
|
||||
method: 'POST',
|
||||
|
||||
@@ -15,6 +15,7 @@ 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";
|
||||
type StreamEvent<TData> = {
|
||||
data: TData;
|
||||
event?: string;
|
||||
@@ -701,6 +702,10 @@ class OpencodeService {
|
||||
throw new Error('Message must have at least one part (text or file)');
|
||||
}
|
||||
|
||||
if (this.currentDirectory) {
|
||||
await waitForWorktreeBootstrap(this.currentDirectory);
|
||||
}
|
||||
|
||||
// Use async prompt endpoint so the client doesn't block waiting
|
||||
// for model work (SSE will deliver output/status).
|
||||
// This avoids 504s from proxy timeouts on long-running turns.
|
||||
|
||||
@@ -196,8 +196,8 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
|
||||
{
|
||||
id: 'new_chat_worktree',
|
||||
defaultCombo: 'mod+shift+n',
|
||||
label: 'New session with worktree',
|
||||
description: 'Start a new session in a worktree',
|
||||
label: 'New worktree draft',
|
||||
description: 'Create a new worktree and open a draft in it',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Utility for creating a new session with an auto-generated worktree.
|
||||
* This is a standalone function that can be called from keyboard shortcuts,
|
||||
* menu actions, or other non-hook contexts.
|
||||
* Utilities for creating worktrees and, when needed, sessions bound to them.
|
||||
* This is a standalone entrypoint for keyboard shortcuts, menu actions,
|
||||
* and other non-hook contexts.
|
||||
*/
|
||||
|
||||
import { toast } from '@/components/ui';
|
||||
@@ -10,16 +10,20 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useContextStore } from '@/stores/contextStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||
import { checkIsGitRepository, previewGitWorktree } from '@/lib/gitApi';
|
||||
import { generateBranchName } from '@/lib/git/branchNameGenerator';
|
||||
import { getRootBranch, getWorktreeStatus } from '@/lib/worktrees/worktreeStatus';
|
||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import {
|
||||
removeProjectWorktree,
|
||||
type ProjectRef,
|
||||
} from '@/lib/worktrees/worktreeManager';
|
||||
import { createWorktreeWithDefaults } from '@/lib/worktrees/worktreeCreate';
|
||||
import { startConfigUpdate, finishConfigUpdate } from '@/lib/configUpdate';
|
||||
import {
|
||||
createPendingDraftWorktreeRequest,
|
||||
rejectPendingDraftWorktreeRequest,
|
||||
resolvePendingDraftWorktreeRequest,
|
||||
} from '@/lib/worktrees/pendingDraftWorktree';
|
||||
|
||||
const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '') || value;
|
||||
|
||||
@@ -48,16 +52,101 @@ const resolveProjectRef = (directory: string): ProjectRef | null => {
|
||||
return match ? { id: match.id, path: match.path } : null;
|
||||
};
|
||||
|
||||
// Track if we're currently creating a worktree session
|
||||
// Track if a worktree creation flow is already running
|
||||
let isCreatingWorktreeSession = false;
|
||||
|
||||
/**
|
||||
* Create a new session with an auto-generated worktree.
|
||||
* Uses project's worktree defaults for naming/metadata.
|
||||
*
|
||||
* @returns The created session, or null if creation failed
|
||||
*/
|
||||
export async function createWorktreeSession(): Promise<{ id: string } | null> {
|
||||
|
||||
|
||||
const applyDefaultAgentAndModelSelection = (sessionId: string, configState = useConfigStore.getState()) => {
|
||||
try {
|
||||
const visibleAgents = configState.getVisibleAgents();
|
||||
let agentName: string | undefined;
|
||||
|
||||
if (configState.settingsDefaultAgent) {
|
||||
const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent);
|
||||
if (settingsAgent) {
|
||||
agentName = settingsAgent.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (!agentName) {
|
||||
agentName =
|
||||
visibleAgents.find((agent) => agent.name === 'build')?.name ||
|
||||
visibleAgents[0]?.name;
|
||||
}
|
||||
|
||||
if (!agentName) {
|
||||
return;
|
||||
}
|
||||
|
||||
configState.setAgent(agentName);
|
||||
useContextStore.getState().saveSessionAgentSelection(sessionId, agentName);
|
||||
|
||||
const settingsDefaultModel = configState.settingsDefaultModel;
|
||||
if (!settingsDefaultModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = settingsDefaultModel.split('/');
|
||||
if (parts.length !== 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [providerId, modelId] = parts;
|
||||
const modelMetadata = configState.getModelMetadata(providerId, modelId);
|
||||
if (!modelMetadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
useContextStore.getState().saveSessionModelSelection(sessionId, providerId, modelId);
|
||||
useContextStore.getState().saveAgentModelForSession(sessionId, agentName, providerId, modelId);
|
||||
|
||||
const settingsDefaultVariant = configState.settingsDefaultVariant;
|
||||
if (!settingsDefaultVariant) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) {
|
||||
configState.setCurrentVariant(settingsDefaultVariant);
|
||||
useContextStore
|
||||
.getState()
|
||||
.saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, settingsDefaultVariant);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors setting default agent
|
||||
}
|
||||
};
|
||||
|
||||
const initializeSessionForWorktree = (sessionId: string, metadata: {
|
||||
path: string;
|
||||
projectDirectory: string;
|
||||
branch: string;
|
||||
label: string;
|
||||
name?: string;
|
||||
createdFromBranch?: string;
|
||||
kind?: 'pr' | 'standard';
|
||||
}) => {
|
||||
const sessionStore = useSessionStore.getState();
|
||||
const configState = useConfigStore.getState();
|
||||
sessionStore.initializeNewOpenChamberSession(sessionId, configState.agents);
|
||||
sessionStore.setSessionDirectory(sessionId, metadata.path);
|
||||
sessionStore.setWorktreeMetadata(sessionId, metadata);
|
||||
applyDefaultAgentAndModelSelection(sessionId, configState);
|
||||
useDirectoryStore.getState().setDirectory(metadata.path, { showOverlay: false });
|
||||
void sessionStore.loadSessions().catch(() => undefined);
|
||||
};
|
||||
|
||||
|
||||
const createInstantWorktreeDraft = async (options?: {
|
||||
initialPrompt?: string;
|
||||
title?: string;
|
||||
}): Promise<string | null> => {
|
||||
if (isCreatingWorktreeSession) {
|
||||
return null;
|
||||
}
|
||||
@@ -72,7 +161,6 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> {
|
||||
|
||||
const projectDirectory = activeProject.path;
|
||||
|
||||
// Check if it's a git repo
|
||||
let isGitRepo = false;
|
||||
try {
|
||||
isGitRepo = await checkIsGitRepository(projectDirectory);
|
||||
@@ -88,16 +176,57 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> {
|
||||
}
|
||||
|
||||
isCreatingWorktreeSession = true;
|
||||
startConfigUpdate("Creating new worktree session...");
|
||||
|
||||
try {
|
||||
const projectRef: ProjectRef = { id: activeProject.id, path: projectDirectory };
|
||||
const pendingRequestId = createPendingDraftWorktreeRequest();
|
||||
|
||||
// Lock the draft immediately so no React effect can reset it to the project
|
||||
// root while we await the preview / worktree creation below.
|
||||
const sessionStore = useSessionStore.getState();
|
||||
if (sessionStore.newSessionDraft?.open) {
|
||||
sessionStore.overrideNewSessionDraftTarget({
|
||||
projectId: projectRef.id,
|
||||
directoryOverride: sessionStore.newSessionDraft.directoryOverride ?? projectRef.path,
|
||||
pendingWorktreeRequestId: pendingRequestId,
|
||||
preserveDirectoryOverride: true,
|
||||
title: options?.title,
|
||||
initialPrompt: options?.initialPrompt,
|
||||
});
|
||||
} else {
|
||||
sessionStore.openNewSessionDraft({
|
||||
projectId: projectRef.id,
|
||||
directoryOverride: projectRef.path,
|
||||
pendingWorktreeRequestId: pendingRequestId,
|
||||
preserveDirectoryOverride: true,
|
||||
title: options?.title,
|
||||
initialPrompt: options?.initialPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
// Generate a friendly name (SDK will slugify + ensure uniqueness).
|
||||
const preferredName = generateBranchName();
|
||||
|
||||
const preview = await previewGitWorktree(projectRef.path, {
|
||||
mode: 'new',
|
||||
branchName: preferredName,
|
||||
worktreeName: preferredName,
|
||||
}).catch(() => null);
|
||||
|
||||
// Refine draft target once we know the actual worktree path from the preview.
|
||||
if (preview?.path) {
|
||||
useSessionStore.getState().overrideNewSessionDraftTarget({
|
||||
projectId: projectRef.id,
|
||||
directoryOverride: preview.path,
|
||||
pendingWorktreeRequestId: pendingRequestId,
|
||||
bootstrapPendingDirectory: preview.path,
|
||||
preserveDirectoryOverride: true,
|
||||
title: options?.title,
|
||||
initialPrompt: options?.initialPrompt,
|
||||
});
|
||||
useDirectoryStore.getState().setDirectory(preview.path, { showOverlay: false });
|
||||
}
|
||||
|
||||
const setupCommands = await getWorktreeSetupCommands(projectRef);
|
||||
const rootBranch = await getRootBranch(projectRef.path);
|
||||
const metadata = await createWorktreeWithDefaults(projectRef, {
|
||||
preferredName,
|
||||
mode: 'new',
|
||||
@@ -106,123 +235,44 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> {
|
||||
setupCommands,
|
||||
});
|
||||
|
||||
const createdMetadata = {
|
||||
...metadata,
|
||||
createdFromBranch: rootBranch,
|
||||
kind: 'standard' as const,
|
||||
};
|
||||
|
||||
// Get worktree status
|
||||
const status = await getWorktreeStatus(metadata.path).catch(() => undefined);
|
||||
const createdMetadataWithStatus = status ? { ...createdMetadata, status } : createdMetadata;
|
||||
|
||||
// Create the session
|
||||
const sessionStore = useSessionStore.getState();
|
||||
const session = await sessionStore.createSession(undefined, metadata.path);
|
||||
if (!session) {
|
||||
// Clean up the worktree if session creation failed
|
||||
await removeProjectWorktree(projectRef, metadata, { deleteLocalBranch: true }).catch(() => undefined);
|
||||
toast.error('Failed to create session', {
|
||||
description: 'Could not create a session for the worktree.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Initialize the session
|
||||
const configState = useConfigStore.getState();
|
||||
const agents = configState.agents;
|
||||
sessionStore.initializeNewOpenChamberSession(session.id, agents);
|
||||
sessionStore.setSessionDirectory(session.id, metadata.path);
|
||||
sessionStore.setWorktreeMetadata(session.id, createdMetadataWithStatus);
|
||||
|
||||
// Apply default agent and model settings
|
||||
try {
|
||||
const visibleAgents = configState.getVisibleAgents();
|
||||
let agentName: string | undefined;
|
||||
|
||||
// Priority: settingsDefaultAgent → build → first visible
|
||||
if (configState.settingsDefaultAgent) {
|
||||
const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent);
|
||||
if (settingsAgent) {
|
||||
agentName = settingsAgent.name;
|
||||
}
|
||||
}
|
||||
if (!agentName) {
|
||||
agentName =
|
||||
visibleAgents.find((agent) => agent.name === 'build')?.name ||
|
||||
visibleAgents[0]?.name;
|
||||
}
|
||||
|
||||
if (agentName) {
|
||||
// 1. Update global UI state
|
||||
configState.setAgent(agentName);
|
||||
|
||||
// 2. Persist to session context so it sticks after reload/switch
|
||||
useContextStore.getState().saveSessionAgentSelection(session.id, agentName);
|
||||
|
||||
// 3. Handle default model for the agent if set in global settings
|
||||
const settingsDefaultModel = configState.settingsDefaultModel;
|
||||
if (settingsDefaultModel) {
|
||||
const parts = settingsDefaultModel.split('/');
|
||||
if (parts.length === 2) {
|
||||
const [providerId, modelId] = parts;
|
||||
// Validate model exists (optional, but good practice)
|
||||
const modelMetadata = configState.getModelMetadata(providerId, modelId);
|
||||
if (modelMetadata) {
|
||||
useContextStore.getState().saveSessionModelSelection(session.id, providerId, modelId);
|
||||
// Also save the specific agent's model preference for this session
|
||||
useContextStore.getState().saveAgentModelForSession(session.id, agentName, providerId, modelId);
|
||||
|
||||
// Seed default variant into session context so ModelControls restore logic
|
||||
// doesn't wipe it on first switch to the new session.
|
||||
const settingsDefaultVariant = configState.settingsDefaultVariant;
|
||||
if (settingsDefaultVariant) {
|
||||
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 && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) {
|
||||
configState.setCurrentVariant(settingsDefaultVariant);
|
||||
useContextStore
|
||||
.getState()
|
||||
.saveAgentModelVariantForSession(session.id, agentName, providerId, modelId, settingsDefaultVariant);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors setting default agent
|
||||
}
|
||||
|
||||
// Update directory
|
||||
useDirectoryStore.getState().setDirectory(metadata.path, { showOverlay: false });
|
||||
|
||||
// Refresh sessions list
|
||||
try {
|
||||
await sessionStore.loadSessions();
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
toast.success('Worktree created', {
|
||||
description: metadata.branch ? `Branch: ${metadata.branch}` : 'Ready',
|
||||
resolvePendingDraftWorktreeRequest(pendingRequestId, metadata.path);
|
||||
useSessionStore.getState().overrideNewSessionDraftTarget({
|
||||
projectId: projectRef.id,
|
||||
directoryOverride: metadata.path,
|
||||
pendingWorktreeRequestId: null,
|
||||
bootstrapPendingDirectory: metadata.path,
|
||||
preserveDirectoryOverride: true,
|
||||
title: options?.title,
|
||||
initialPrompt: options?.initialPrompt,
|
||||
});
|
||||
useDirectoryStore.getState().setDirectory(metadata.path, { showOverlay: false });
|
||||
void useSessionStore.getState().loadSessions().catch(() => undefined);
|
||||
|
||||
return session;
|
||||
return metadata.path;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create worktree session';
|
||||
const message = error instanceof Error ? error.message : 'Failed to create worktree';
|
||||
const requestId = useSessionStore.getState().newSessionDraft.pendingWorktreeRequestId;
|
||||
if (requestId) {
|
||||
rejectPendingDraftWorktreeRequest(requestId, error instanceof Error ? error : new Error(message));
|
||||
useSessionStore.getState().resolvePendingDraftWorktreeTarget(requestId, null);
|
||||
}
|
||||
useSessionStore.getState().setDraftBootstrapPendingDirectory(null);
|
||||
toast.error('Failed to create worktree', {
|
||||
description: message,
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
finishConfigUpdate();
|
||||
isCreatingWorktreeSession = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new worktree and open a draft scoped to it.
|
||||
*
|
||||
* @returns The worktree path, or null if creation failed
|
||||
*/
|
||||
export async function createWorktreeSession(): Promise<string | null> {
|
||||
return createInstantWorktreeDraft();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,6 +282,10 @@ export function isCreatingWorktree(): boolean {
|
||||
return isCreatingWorktreeSession;
|
||||
}
|
||||
|
||||
export async function createWorktreeDraft(options?: { initialPrompt?: string; title?: string }): Promise<string | null> {
|
||||
return createInstantWorktreeDraft(options);
|
||||
}
|
||||
|
||||
export async function createWorktreeOnly(): Promise<string | null> {
|
||||
if (isCreatingWorktreeSession) {
|
||||
return null;
|
||||
@@ -261,7 +315,6 @@ export async function createWorktreeOnly(): Promise<string | null> {
|
||||
}
|
||||
|
||||
isCreatingWorktreeSession = true;
|
||||
startConfigUpdate('Creating new worktree...');
|
||||
|
||||
try {
|
||||
const projectRef: ProjectRef = { id: activeProject.id, path: projectDirectory };
|
||||
@@ -275,17 +328,8 @@ export async function createWorktreeOnly(): Promise<string | null> {
|
||||
setupCommands,
|
||||
});
|
||||
|
||||
const rootBranch = await getRootBranch(projectRef.path).catch(() => undefined);
|
||||
const status = await getWorktreeStatus(metadata.path).catch(() => undefined);
|
||||
|
||||
const branchLabel = metadata.branch || metadata.label || metadata.name;
|
||||
toast.success('Worktree created', {
|
||||
description: branchLabel
|
||||
? `${branchLabel}${rootBranch ? ` from ${rootBranch}` : ''}`
|
||||
: status?.isDirty ? 'Created (dirty)' : 'Ready',
|
||||
});
|
||||
|
||||
await useSessionStore.getState().loadSessions();
|
||||
void useSessionStore.getState().loadSessions().catch(() => undefined);
|
||||
return metadata.path;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create worktree';
|
||||
@@ -294,7 +338,6 @@ export async function createWorktreeOnly(): Promise<string | null> {
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
finishConfigUpdate();
|
||||
isCreatingWorktreeSession = false;
|
||||
}
|
||||
}
|
||||
@@ -327,7 +370,6 @@ export async function createWorktreeSessionForBranch(
|
||||
}
|
||||
|
||||
isCreatingWorktreeSession = true;
|
||||
startConfigUpdate("Creating worktree session...");
|
||||
|
||||
try {
|
||||
const projectRef = resolveProjectRef(projectDirectory);
|
||||
@@ -373,10 +415,6 @@ export async function createWorktreeSessionForBranch(
|
||||
kind,
|
||||
};
|
||||
|
||||
// Get worktree status
|
||||
const status = await getWorktreeStatus(metadata.path).catch(() => undefined);
|
||||
const createdMetadataWithStatus = status ? { ...createdMetadata, status } : createdMetadata;
|
||||
|
||||
// Create the session
|
||||
const sessionStore = useSessionStore.getState();
|
||||
const session = await sessionStore.createSession(undefined, metadata.path);
|
||||
@@ -389,89 +427,7 @@ export async function createWorktreeSessionForBranch(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Initialize the session
|
||||
const configState = useConfigStore.getState();
|
||||
const agents = configState.agents;
|
||||
sessionStore.initializeNewOpenChamberSession(session.id, agents);
|
||||
sessionStore.setSessionDirectory(session.id, metadata.path);
|
||||
sessionStore.setWorktreeMetadata(session.id, createdMetadataWithStatus);
|
||||
|
||||
// Apply default agent and model settings
|
||||
try {
|
||||
const visibleAgents = configState.getVisibleAgents();
|
||||
let agentName: string | undefined;
|
||||
|
||||
// Priority: settingsDefaultAgent → build → first visible
|
||||
if (configState.settingsDefaultAgent) {
|
||||
const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent);
|
||||
if (settingsAgent) {
|
||||
agentName = settingsAgent.name;
|
||||
}
|
||||
}
|
||||
if (!agentName) {
|
||||
agentName =
|
||||
visibleAgents.find((agent) => agent.name === 'build')?.name ||
|
||||
visibleAgents[0]?.name;
|
||||
}
|
||||
|
||||
if (agentName) {
|
||||
// 1. Update global UI state
|
||||
configState.setAgent(agentName);
|
||||
|
||||
// 2. Persist to session context so it sticks after reload/switch
|
||||
useContextStore.getState().saveSessionAgentSelection(session.id, agentName);
|
||||
|
||||
// 3. Handle default model for the agent if set in global settings
|
||||
const settingsDefaultModel = configState.settingsDefaultModel;
|
||||
if (settingsDefaultModel) {
|
||||
const parts = settingsDefaultModel.split('/');
|
||||
if (parts.length === 2) {
|
||||
const [providerId, modelId] = parts;
|
||||
// Validate model exists (optional, but good practice)
|
||||
const modelMetadata = configState.getModelMetadata(providerId, modelId);
|
||||
if (modelMetadata) {
|
||||
useContextStore.getState().saveSessionModelSelection(session.id, providerId, modelId);
|
||||
// Also save the specific agent's model preference for this session
|
||||
useContextStore.getState().saveAgentModelForSession(session.id, agentName, providerId, modelId);
|
||||
|
||||
// Seed default variant into session context so ModelControls restore logic
|
||||
// doesn't wipe it on first switch to the new session.
|
||||
const settingsDefaultVariant = configState.settingsDefaultVariant;
|
||||
if (settingsDefaultVariant) {
|
||||
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 && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) {
|
||||
configState.setCurrentVariant(settingsDefaultVariant);
|
||||
useContextStore
|
||||
.getState()
|
||||
.saveAgentModelVariantForSession(session.id, agentName, providerId, modelId, settingsDefaultVariant);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors setting default agent
|
||||
}
|
||||
|
||||
// Update directory
|
||||
useDirectoryStore.getState().setDirectory(metadata.path, { showOverlay: false });
|
||||
|
||||
// Refresh sessions list
|
||||
try {
|
||||
await sessionStore.loadSessions();
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
toast.success('Worktree created', {
|
||||
description: metadata.branch ? `Branch: ${metadata.branch}` : 'Ready',
|
||||
});
|
||||
initializeSessionForWorktree(session.id, createdMetadata);
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
@@ -481,7 +437,6 @@ export async function createWorktreeSessionForBranch(
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
finishConfigUpdate();
|
||||
isCreatingWorktreeSession = false;
|
||||
}
|
||||
}
|
||||
@@ -510,7 +465,6 @@ export async function createWorktreeSessionForNewBranch(
|
||||
}
|
||||
|
||||
isCreatingWorktreeSession = true;
|
||||
startConfigUpdate('Creating worktree session...');
|
||||
|
||||
try {
|
||||
const start = startPoint?.trim() || 'HEAD';
|
||||
@@ -562,92 +516,22 @@ export async function createWorktreeSessionForNewBranch(
|
||||
kind,
|
||||
};
|
||||
|
||||
const status = await getWorktreeStatus(metadata.path).catch(() => undefined);
|
||||
const createdMetadataWithStatus = status ? { ...createdMetadata, status } : createdMetadata;
|
||||
const sessionStore = useSessionStore.getState();
|
||||
const session = await sessionStore.createSession(undefined, metadata.path);
|
||||
if (!session) {
|
||||
await removeProjectWorktree(projectRef, metadata, { deleteLocalBranch: true }).catch(() => undefined);
|
||||
throw new Error('Could not create a session for the worktree.');
|
||||
}
|
||||
|
||||
const sessionStore = useSessionStore.getState();
|
||||
const session = await sessionStore.createSession(undefined, metadata.path);
|
||||
if (!session) {
|
||||
await removeProjectWorktree(projectRef, metadata, { deleteLocalBranch: true }).catch(() => undefined);
|
||||
throw new Error('Could not create a session for the worktree.');
|
||||
}
|
||||
initializeSessionForWorktree(session.id, createdMetadata);
|
||||
|
||||
const configState = useConfigStore.getState();
|
||||
sessionStore.initializeNewOpenChamberSession(session.id, configState.agents);
|
||||
sessionStore.setSessionDirectory(session.id, metadata.path);
|
||||
sessionStore.setWorktreeMetadata(session.id, createdMetadataWithStatus);
|
||||
|
||||
// Apply default agent/model/variant settings (reuse same logic as createWorktreeSessionForBranch)
|
||||
try {
|
||||
const visibleAgents = configState.getVisibleAgents();
|
||||
let agentName: string | undefined;
|
||||
if (configState.settingsDefaultAgent) {
|
||||
const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent);
|
||||
if (settingsAgent) {
|
||||
agentName = settingsAgent.name;
|
||||
}
|
||||
}
|
||||
if (!agentName) {
|
||||
agentName =
|
||||
visibleAgents.find((agent) => agent.name === 'build')?.name ||
|
||||
visibleAgents[0]?.name;
|
||||
}
|
||||
|
||||
if (agentName) {
|
||||
configState.setAgent(agentName);
|
||||
useContextStore.getState().saveSessionAgentSelection(session.id, agentName);
|
||||
|
||||
const settingsDefaultModel = configState.settingsDefaultModel;
|
||||
if (settingsDefaultModel) {
|
||||
const parts = settingsDefaultModel.split('/');
|
||||
if (parts.length === 2) {
|
||||
const [providerId, modelId] = parts;
|
||||
const modelMetadata = configState.getModelMetadata(providerId, modelId);
|
||||
if (modelMetadata) {
|
||||
useContextStore.getState().saveSessionModelSelection(session.id, providerId, modelId);
|
||||
useContextStore.getState().saveAgentModelForSession(session.id, agentName, providerId, modelId);
|
||||
|
||||
const settingsDefaultVariant = configState.settingsDefaultVariant;
|
||||
if (settingsDefaultVariant) {
|
||||
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 && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) {
|
||||
configState.setCurrentVariant(settingsDefaultVariant);
|
||||
useContextStore
|
||||
.getState()
|
||||
.saveAgentModelVariantForSession(session.id, agentName, providerId, modelId, settingsDefaultVariant);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
useDirectoryStore.getState().setDirectory(metadata.path, { showOverlay: false });
|
||||
try {
|
||||
await sessionStore.loadSessions();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
toast.success('Worktree created', {
|
||||
description: metadata.branch ? `Branch: ${metadata.branch}` : 'Ready',
|
||||
});
|
||||
|
||||
return { id: session.id, branch: metadata.branch || base };
|
||||
return { id: session.id, branch: metadata.branch || base };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create worktree session';
|
||||
toast.error('Failed to create worktree', { description: message });
|
||||
return null;
|
||||
}
|
||||
} finally {
|
||||
finishConfigUpdate();
|
||||
isCreatingWorktreeSession = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
type Deferred = {
|
||||
promise: Promise<string>;
|
||||
resolve: (directory: string) => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
|
||||
const requests = new Map<string, Deferred>();
|
||||
|
||||
const createDeferred = (): Deferred => {
|
||||
let resolve!: (directory: string) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<string>((innerResolve, innerReject) => {
|
||||
resolve = innerResolve;
|
||||
reject = innerReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const createId = (): string => `worktree_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
export const createPendingDraftWorktreeRequest = (): string => {
|
||||
const id = createId();
|
||||
requests.set(id, createDeferred());
|
||||
return id;
|
||||
};
|
||||
|
||||
export const resolvePendingDraftWorktreeRequest = (id: string, directory: string): void => {
|
||||
const entry = requests.get(id);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
requests.delete(id);
|
||||
entry.resolve(directory);
|
||||
};
|
||||
|
||||
export const rejectPendingDraftWorktreeRequest = (id: string, error: Error): void => {
|
||||
const entry = requests.get(id);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
requests.delete(id);
|
||||
entry.reject(error);
|
||||
};
|
||||
|
||||
export const waitForPendingDraftWorktreeRequest = (id: string): Promise<string> => {
|
||||
const entry = requests.get(id);
|
||||
if (!entry) {
|
||||
return Promise.reject(new Error('Pending worktree request not found'));
|
||||
}
|
||||
return entry.promise;
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import * as gitHttp from '@/lib/gitApiHttp';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import type { GitWorktreeBootstrapStatus } from '@/lib/api/types';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
|
||||
}
|
||||
}
|
||||
|
||||
type WorktreeBootstrapState = GitWorktreeBootstrapStatus;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const POLL_INTERVAL_MS = 250;
|
||||
|
||||
const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '') || value;
|
||||
|
||||
const state = new Map<string, WorktreeBootstrapState>();
|
||||
const waiters = new Map<string, Promise<void>>();
|
||||
|
||||
const getKey = (directory: string): string => normalizePath(directory);
|
||||
|
||||
const getGitWorktreeBootstrapStatus = async (directory: string): Promise<GitWorktreeBootstrapStatus> => {
|
||||
const runtimeGit = typeof window !== 'undefined' ? window.__OPENCHAMBER_RUNTIME_APIS__?.git : undefined;
|
||||
if (runtimeGit?.worktree?.bootstrapStatus) {
|
||||
return runtimeGit.worktree.bootstrapStatus(directory);
|
||||
}
|
||||
if (runtimeGit?.getGitWorktreeBootstrapStatus) {
|
||||
return runtimeGit.getGitWorktreeBootstrapStatus(directory);
|
||||
}
|
||||
return gitHttp.getGitWorktreeBootstrapStatus(directory);
|
||||
};
|
||||
|
||||
export const markWorktreeBootstrapPending = (directory: string): void => {
|
||||
const key = getKey(directory);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
state.set(key, {
|
||||
status: 'pending',
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
};
|
||||
|
||||
export const clearWorktreeBootstrapState = (directory: string): void => {
|
||||
const key = getKey(directory);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
state.delete(key);
|
||||
waiters.delete(key);
|
||||
};
|
||||
|
||||
export const setWorktreeBootstrapState = (directory: string, next: WorktreeBootstrapState): void => {
|
||||
const key = getKey(directory);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
state.set(key, next);
|
||||
if (next.status !== 'pending') {
|
||||
waiters.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
export const getWorktreeBootstrapState = (directory: string): WorktreeBootstrapState | null => {
|
||||
const key = getKey(directory);
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
return state.get(key) ?? null;
|
||||
};
|
||||
|
||||
const pollWorktreeBootstrapUntilSettled = async (directory: string, timeoutMs: number): Promise<void> => {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const result = await getGitWorktreeBootstrapStatus(directory);
|
||||
setWorktreeBootstrapState(directory, result);
|
||||
|
||||
if (result.status === 'ready') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'failed') {
|
||||
throw new Error(result.error || 'Worktree bootstrap failed');
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for worktree bootstrap');
|
||||
};
|
||||
|
||||
export const waitForWorktreeBootstrap = async (directory: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> => {
|
||||
const key = getKey(directory);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = state.get(key);
|
||||
if (current?.status === 'ready') {
|
||||
return;
|
||||
}
|
||||
if (current?.status === 'failed') {
|
||||
throw new Error(current.error || 'Worktree bootstrap failed');
|
||||
}
|
||||
|
||||
const existing = waiters.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const pending = pollWorktreeBootstrapUntilSettled(directory, timeoutMs).finally(() => {
|
||||
waiters.delete(key);
|
||||
});
|
||||
waiters.set(key, pending);
|
||||
return pending;
|
||||
};
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
deleteRemoteBranch,
|
||||
git,
|
||||
} from '@/lib/gitApi';
|
||||
import {
|
||||
clearWorktreeBootstrapState,
|
||||
markWorktreeBootstrapPending,
|
||||
} from '@/lib/worktrees/worktreeBootstrap';
|
||||
import type {
|
||||
CreateGitWorktreePayload,
|
||||
GitWorktreeValidationResult,
|
||||
@@ -241,6 +245,8 @@ export async function createWorktree(project: ProjectRef, args: CreateWorktreeAr
|
||||
label: returnedBranch || returnedName,
|
||||
};
|
||||
|
||||
markWorktreeBootstrapPending(metadata.path);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
@@ -268,6 +274,8 @@ export async function removeProjectWorktree(project: ProjectRef, worktree: Workt
|
||||
throw new Error('Worktree removal failed');
|
||||
}
|
||||
|
||||
clearWorktreeBootstrapState(worktree.path);
|
||||
|
||||
const branchName = (worktree.branch || '').replace(/^refs\/heads\//, '').trim();
|
||||
if (deleteRemote && branchName) {
|
||||
await deleteRemoteBranch(projectDirectory, { branch: branchName, remote: remoteName }).catch(() => undefined);
|
||||
|
||||
Reference in New Issue
Block a user