diff --git a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx index c94bffd4..e22714a2 100644 --- a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx +++ b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx @@ -247,7 +247,8 @@ export const WorktreeSectionContent: React.FC = () => { // Delete worktree handler const handleDeleteWorktree = React.useCallback((worktree: WorktreeMetadata) => { - const normalizedWorktreePath = worktree.path.replace(/\\/g, '/').replace(/\/+$/, ''); + const normalize = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, ''); + const normalizedWorktreePath = normalize(worktree.path); // Find sessions linked to this worktree by: // 1. Worktree metadata path match @@ -255,14 +256,14 @@ export const WorktreeSectionContent: React.FC = () => { const directSessions = sessions.filter((session) => { // Check worktree metadata const metadata = getWorktreeMetadata(session.id); - if (metadata?.path === worktree.path) { + if (metadata?.path && normalize(metadata.path) === normalizedWorktreePath) { return true; } // Check session directory const sessionDir = (session as { directory?: string }).directory; if (sessionDir) { - const normalizedSessionDir = sessionDir.replace(/\\/g, '/').replace(/\/+$/, ''); + const normalizedSessionDir = normalize(sessionDir); if (normalizedSessionDir === normalizedWorktreePath) { return true; } diff --git a/packages/ui/src/components/session/SessionDialogs.tsx b/packages/ui/src/components/session/SessionDialogs.tsx index 80818cfe..54c2e1f9 100644 --- a/packages/ui/src/components/session/SessionDialogs.tsx +++ b/packages/ui/src/components/session/SessionDialogs.tsx @@ -77,6 +77,13 @@ export const SessionDialogs: React.FC = () => { return normalizeProjectDirectory(targetPath); }, [activeProjectId, currentDirectory, projects]); + const getProjectRefForWorktree = React.useCallback((worktree: WorktreeMetadata) => { + const normalized = normalizeProjectDirectory(worktree.projectDirectory); + const fallbackPath = normalized || projectDirectory; + const match = projects.find((project) => normalizeProjectDirectory(project.path) === fallbackPath) ?? null; + return { id: match?.id ?? `path:${fallbackPath}`, path: fallbackPath }; + }, [projectDirectory, projects]); + const hasDirtyWorktrees = React.useMemo( () => (deleteDialog?.worktree?.status?.isDirty ?? false) || @@ -276,11 +283,19 @@ export const SessionDialogs: React.FC = () => { if (deleteDialog.sessions.length === 0 && isWorktreeDelete && deleteDialog.worktree) { const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; - await removeProjectWorktree( - { id: activeProjectId || `path:${projectDirectory}`, path: projectDirectory }, - deleteDialog.worktree, - { deleteRemoteBranch: shouldRemoveRemote, force: true } - ); + try { + await removeProjectWorktree( + getProjectRefForWorktree(deleteDialog.worktree), + deleteDialog.worktree, + { deleteRemoteBranch: shouldRemoveRemote, force: true } + ); + } catch (error) { + toast.error('Failed to remove worktree', { + description: renderToastDescription(error instanceof Error ? error.message : 'Please try again.'), + }); + closeDeleteDialog(); + return; + } const archiveNote = shouldRemoveRemote ? 'Worktree and remote branch removed.' : 'Worktree removed.'; toast.success('Worktree removed', { description: renderToastDescription(archiveNote), @@ -293,7 +308,9 @@ export const SessionDialogs: React.FC = () => { if (deleteDialog.sessions.length === 1) { const target = deleteDialog.sessions[0]; const success = await deleteSession(target.id, { - archiveWorktree: shouldArchive, + // In "worktree" mode, remove the selected worktree explicitly below. + // Don't try to derive worktree removal from per-session metadata (may be missing). + archiveWorktree: isWorktreeDelete ? false : shouldArchive, deleteRemoteBranch: removeRemoteBranch, }); if (!success) { @@ -301,7 +318,7 @@ export const SessionDialogs: React.FC = () => { setIsProcessingDelete(false); return; } - const archiveNote = shouldArchive + const archiveNote = !isWorktreeDelete && shouldArchive ? removeRemoteBranch ? 'Worktree and remote branch removed.' : 'Attached worktree archived.' @@ -316,12 +333,30 @@ export const SessionDialogs: React.FC = () => { } else { const ids = deleteDialog.sessions.map((session) => session.id); const { deletedIds, failedIds } = await deleteSessions(ids, { - archiveWorktree: shouldArchive, + archiveWorktree: isWorktreeDelete ? false : shouldArchive, deleteRemoteBranch: removeRemoteBranch, }); + 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. + const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; + try { + await removeProjectWorktree( + getProjectRefForWorktree(deleteDialog.worktree), + deleteDialog.worktree, + { deleteRemoteBranch: shouldRemoveRemote, force: true } + ); + await loadSessions(); + } catch (error) { + toast.error('Failed to remove worktree', { + description: renderToastDescription(error instanceof Error ? error.message : 'Please try again.'), + }); + } + } + if (deletedIds.length > 0) { - const archiveNote = shouldArchive + const archiveNote = !isWorktreeDelete && shouldArchive ? removeRemoteBranch ? 'Archived worktrees and removed remote branches.' : 'Attached worktrees archived.' @@ -353,6 +388,22 @@ export const SessionDialogs: React.FC = () => { } } + if (isWorktreeDelete && deleteDialog.sessions.length === 1 && deleteDialog.worktree) { + const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; + try { + await removeProjectWorktree( + getProjectRefForWorktree(deleteDialog.worktree), + deleteDialog.worktree, + { deleteRemoteBranch: shouldRemoveRemote, force: true } + ); + await loadSessions(); + } catch (error) { + toast.error('Failed to remove worktree', { + description: renderToastDescription(error instanceof Error ? error.message : 'Please try again.'), + }); + } + } + closeDeleteDialog(); } finally { setIsProcessingDelete(false); @@ -366,8 +417,7 @@ export const SessionDialogs: React.FC = () => { shouldArchiveWorktree, isWorktreeDelete, canRemoveRemoteBranches, - projectDirectory, - activeProjectId, + getProjectRefForWorktree, loadSessions, ]); diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index 8cebd170..94aad900 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -73,7 +73,8 @@ const getWorktreeMethod = (client: unknown, key: string): WorktreeRemovalMethod if (typeof candidate !== 'function') { return null; } - return candidate as WorktreeRemovalMethod; + // Keep method binding; SDK methods use `this.client`. + return (params?: WorktreeRemovalParams) => (candidate as (this: unknown, p?: WorktreeRemovalParams) => Promise).call(client, params); }; export const buildSdkStartCommand = (args: { @@ -245,23 +246,44 @@ export async function removeProjectWorktree(project: ProjectRef, worktree: Workt if (worktree.source === 'sdk') { const scoped = opencodeClient.getScopedApiClient(projectDirectory); const worktreeClient = scoped.worktree as unknown; + const force = Boolean(options?.force ?? true); + + const fallbackRemoveViaGit = async () => { + await removeGitWorktree(projectDirectory, { path: worktree.path, force }); + }; + const removeMethod = getWorktreeMethod(worktreeClient, 'remove'); if (removeMethod) { - await removeMethod({ worktreeRemoveInput: { directory: worktree.path } }); + const raw = await removeMethod({ worktreeRemoveInput: { directory: worktree.path } }); + const ok = unwrapSdkData(raw); + if (ok !== true) { + await fallbackRemoveViaGit(); + } } else { const deleteMethod = getWorktreeMethod(worktreeClient, 'delete'); if (deleteMethod) { - await deleteMethod({ worktreeDeleteInput: { directory: worktree.path } }); + const raw = await deleteMethod({ worktreeDeleteInput: { directory: worktree.path } }); + const ok = unwrapSdkData(raw); + if (ok !== true) { + await fallbackRemoveViaGit(); + } } else { const archiveMethod = getWorktreeMethod(worktreeClient, 'archive'); if (archiveMethod) { - await archiveMethod({ worktreeArchiveInput: { directory: worktree.path } }); + const raw = await archiveMethod({ worktreeArchiveInput: { directory: worktree.path } }); + const ok = unwrapSdkData(raw); + if (ok !== true) { + await fallbackRemoveViaGit(); + } } else { throw new Error('Worktree removal is not supported by this SDK version.'); } } } + // Some OpenCode builds only update internal state; remove git worktree best-effort. + await fallbackRemoveViaGit().catch(() => undefined); + // Best-effort branch cleanup. Some OpenCode builds may keep the branch. const branchName = (worktree.branch || '').replace(/^refs\/heads\//, '').trim(); if (deleteLocalBranch && branchName) { diff --git a/packages/ui/src/stores/sessionStore.ts b/packages/ui/src/stores/sessionStore.ts index 5e6ea550..b185c11d 100644 --- a/packages/ui/src/stores/sessionStore.ts +++ b/packages/ui/src/stores/sessionStore.ts @@ -157,6 +157,16 @@ const archiveSessionWorktree = async ( ); }; +const deleteSessionOnServer = async (sessionId: string, directory?: string | null): Promise => { + const apiClient = opencodeClient.getApiClient(); + const normalizedDirectory = normalizePath(directory ?? null); + const response = await apiClient.session.delete({ + sessionID: sessionId, + ...(normalizedDirectory ? { directory: normalizedDirectory } : {}), + }); + return Boolean(response.data); +}; + const normalizePath = (value?: string | null): string | null => { if (typeof value !== "string") { return null; @@ -913,54 +923,55 @@ export const useSessionStore = create()( deleteSession: async (id: string, options) => { set({ isLoading: true, error: null }); const metadata = get().worktreeMetadata.get(id); + const metadataPath = typeof metadata?.path === 'string' ? metadata.path : null; + const metadataProjectDirectory = typeof metadata?.projectDirectory === 'string' ? metadata.projectDirectory : null; const sessionDirectory = getSessionDirectory(get().sessions, id); - const overrideDirectory = metadata?.path ?? sessionDirectory; - let archivedMetadata: WorktreeMetadata | null = null; - try { - if (metadata && options?.archiveWorktree) { - await archiveSessionWorktree(metadata, { - deleteRemoteBranch: options?.deleteRemoteBranch, - remoteName: options?.remoteName, - }); - archivedMetadata = metadata; - } + const requestDirectory = normalizePath(metadataProjectDirectory) + ?? normalizePath(sessionDirectory) + ?? normalizePath(opencodeClient.getDirectory() ?? null) + ?? null; - const deleteRequest = () => opencodeClient.deleteSession(id); - const success = overrideDirectory - ? await opencodeClient.withDirectory(overrideDirectory, deleteRequest) - : await deleteRequest(); + let archiveSucceeded = false; + try { + const success = await deleteSessionOnServer(id, requestDirectory); if (!success) { - set((state) => { - const update: Partial = { - isLoading: false, - error: "Failed to delete session", - }; - if (archivedMetadata) { - const nextMetadata = new Map(state.worktreeMetadata); - nextMetadata.delete(id); - update.worktreeMetadata = nextMetadata; - } - return update; + set({ + isLoading: false, + error: "Failed to delete session", }); return false; } + if (metadata && options?.archiveWorktree) { + try { + await archiveSessionWorktree(metadata, { + deleteRemoteBranch: options?.deleteRemoteBranch, + remoteName: options?.remoteName, + }); + archiveSucceeded = true; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to delete worktree"; + set({ error: message }); + } + } + let nextCurrentId: string | null = null; set((state) => { const filteredSessions = state.sessions.filter((s) => s.id !== id); nextCurrentId = state.currentSessionId === id ? null : state.currentSessionId; const nextMetadata = new Map(state.worktreeMetadata); nextMetadata.delete(id); - const nextAvailableWorktrees = options?.archiveWorktree && metadata - ? state.availableWorktrees.filter((entry) => normalizePath(entry.path) !== normalizePath(metadata.path)) + const shouldRemoveWorktreeFromLists = Boolean(metadataPath && options?.archiveWorktree && archiveSucceeded); + const nextAvailableWorktrees = shouldRemoveWorktreeFromLists + ? state.availableWorktrees.filter((entry) => normalizePath(entry.path) !== normalizePath(metadataPath)) : state.availableWorktrees; const nextAvailableWorktreesByProject = new Map(state.availableWorktreesByProject); - if (options?.archiveWorktree && metadata) { - const projectKey = normalizePath(metadata.projectDirectory) ?? metadata.projectDirectory; + if (shouldRemoveWorktreeFromLists && metadataProjectDirectory) { + const projectKey = normalizePath(metadataProjectDirectory) ?? metadataProjectDirectory; const projectWorktrees = nextAvailableWorktreesByProject.get(projectKey) ?? []; nextAvailableWorktreesByProject.set( projectKey, - projectWorktrees.filter((entry) => normalizePath(entry.path) !== normalizePath(metadata.path)) + projectWorktrees.filter((entry) => normalizePath(entry.path) !== normalizePath(metadataPath)) ); } return { @@ -974,28 +985,18 @@ export const useSessionStore = create()( }; }); - const directoryToStore = overrideDirectory ?? opencodeClient.getDirectory() ?? null; + const directoryToStore = normalizePath(sessionDirectory) + ?? normalizePath(opencodeClient.getDirectory() ?? null) + ?? null; storeSessionForDirectory(directoryToStore, nextCurrentId); return true; } catch (error) { const message = error instanceof Error ? error.message : "Failed to delete session"; - if (archivedMetadata) { - set((state) => { - const nextMetadata = new Map(state.worktreeMetadata); - nextMetadata.delete(id); - return { - worktreeMetadata: nextMetadata, - error: message, - isLoading: false, - }; - }); - } else { - set({ - error: message, - isLoading: false, - }); - } + set({ + error: message, + isLoading: false, + }); return false; } }, @@ -1015,35 +1016,29 @@ export const useSessionStore = create()( } const deletedIds: string[] = []; const failedIds: string[] = []; - const archivedIds = new Set(); - - const removedWorktrees: Array<{ path: string; projectDirectory: string }> = []; + const worktreesToArchive = new Map(); const archivedWorktreePaths = new Set(); for (const id of uniqueIds) { try { const metadata = get().worktreeMetadata.get(id); const sessionDirectory = getSessionDirectory(get().sessions, id); - const overrideDirectory = metadata?.path ?? sessionDirectory; - if (metadata && options?.archiveWorktree && !archivedWorktreePaths.has(metadata.path)) { - await archiveSessionWorktree(metadata, { - deleteRemoteBranch: options?.deleteRemoteBranch, - remoteName: options?.remoteName, - }); - archivedIds.add(id); - removedWorktrees.push({ path: metadata.path, projectDirectory: metadata.projectDirectory }); - archivedWorktreePaths.add(metadata.path); + const requestDirectory = normalizePath(metadata?.projectDirectory ?? null) + ?? normalizePath(sessionDirectory) + ?? normalizePath(opencodeClient.getDirectory() ?? null) + ?? null; + + if (metadata && options?.archiveWorktree) { + const key = normalizePath(metadata.path) ?? metadata.path; + if (!archivedWorktreePaths.has(key)) { + archivedWorktreePaths.add(key); + worktreesToArchive.set(key, metadata); + } } - const deleteRequest = () => opencodeClient.deleteSession(id); - const success = overrideDirectory - ? await opencodeClient.withDirectory(overrideDirectory, deleteRequest) - : await deleteRequest(); + const success = await deleteSessionOnServer(id, requestDirectory); if (success) { deletedIds.push(id); - if (metadata?.path && !removedWorktrees.some((entry) => entry.path === metadata.path)) { - removedWorktrees.push({ path: metadata.path, projectDirectory: metadata.projectDirectory }); - } } else { failedIds.push(id); } @@ -1052,8 +1047,30 @@ export const useSessionStore = create()( } } + const archivedWorktrees: Array<{ path: string; projectDirectory: string }> = []; + const archiveFailures: string[] = []; + + if (options?.archiveWorktree && worktreesToArchive.size > 0) { + for (const metadata of worktreesToArchive.values()) { + try { + await archiveSessionWorktree(metadata, { + deleteRemoteBranch: options?.deleteRemoteBranch, + remoteName: options?.remoteName, + }); + archivedWorktrees.push({ path: metadata.path, projectDirectory: metadata.projectDirectory }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to delete worktree"; + archiveFailures.push(message); + } + } + } + + if (archiveFailures.length > 0) { + set({ error: archiveFailures[0] }); + } + const directoryStore = useDirectoryStore.getState(); - removedWorktrees.forEach(({ path, projectDirectory }) => { + archivedWorktrees.forEach(({ path, projectDirectory }) => { if (directoryStore.currentDirectory === path) { directoryStore.setDirectory(projectDirectory, { showOverlay: false }); } @@ -1077,25 +1094,19 @@ export const useSessionStore = create()( for (const removedId of deletedSet) { nextMetadata.delete(removedId); } - for (const archivedId of archivedIds) { - nextMetadata.delete(archivedId); - } const removedPaths = new Set( - removedWorktrees + archivedWorktrees .map((entry) => normalizePath(entry.path)) .filter((p): p is string => Boolean(p)) ); - const nextAvailableWorktrees = - removedPaths.size > 0 - ? state.availableWorktrees.filter( - (entry) => !removedPaths.has(normalizePath(entry.path) ?? entry.path) - ) - : state.availableWorktrees; + const nextAvailableWorktrees = removedPaths.size > 0 + ? state.availableWorktrees.filter((entry) => !removedPaths.has(normalizePath(entry.path) ?? entry.path)) + : state.availableWorktrees; const nextAvailableWorktreesByProject = new Map(state.availableWorktreesByProject); - if (removedWorktrees.length > 0) { - const removedPathsByProject = removedWorktrees.reduce>>((accumulator, entry) => { + if (archivedWorktrees.length > 0) { + const removedPathsByProject = archivedWorktrees.reduce>>((accumulator, entry) => { const projectKey = normalizePath(entry.projectDirectory) ?? entry.projectDirectory; const pathKey = normalizePath(entry.path) ?? entry.path; if (!accumulator.has(projectKey)) {