From 90e79b04a4b6f0fdf850ae6b2d7b8006faadc9d9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 04:26:39 +0000 Subject: [PATCH] fix(sessions): fall back when lastDirectory is a deleted worktree New chats inherited a persisted lastDirectory even after that worktree was removed, so the first message saved but the prompt never started. Validate the implicit draft directory, fall back to the active project only when OpenCode confirms the path is missing, and leave explicit worktree targets and unknown probes unchanged. Co-authored-by: serkraser --- CHANGELOG.md | 1 + packages/ui/src/lib/opencode/client.test.ts | 21 +++ packages/ui/src/lib/opencode/client.ts | 40 +++++- packages/ui/src/sync/DOCUMENTATION.md | 3 +- packages/ui/src/sync/session-ui-store.test.js | 124 ++++++++++++++++++ packages/ui/src/sync/session-ui-store.ts | 63 ++++++++- packages/vscode/CHANGELOG.md | 4 + 7 files changed, 246 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a980e698..c047c348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting. - **Usage/Claude:** Claude plan limits now work when you are signed in through Claude Code, without also signing into Anthropic in OpenCode; the account is read from Claude Code's own login on macOS, Linux, and WSL. The page shows your session and weekly limits again, adds per-model weekly limits and extra usage spending, and names your plan. Limits are kept on screen instead of disappearing when Anthropic temporarily blocks refreshes. - **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders. - Chat: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech). diff --git a/packages/ui/src/lib/opencode/client.test.ts b/packages/ui/src/lib/opencode/client.test.ts index a691a79d..824b5827 100644 --- a/packages/ui/src/lib/opencode/client.test.ts +++ b/packages/ui/src/lib/opencode/client.test.ts @@ -9,6 +9,7 @@ let configCalls = 0; let runtimeKey = 'test-runtime'; const promptAsyncCalls: unknown[][] = []; const promptAsyncResults: Array = []; +const pathGetResults: Array = []; const promptAsyncMock = mock(async (...args: unknown[]) => { promptAsyncCalls.push(args); @@ -17,6 +18,12 @@ const promptAsyncMock = mock(async (...args: unknown[]) => { return next ?? { response: new Response(null, { status: 200 }) }; }); +const pathGetMock = mock(async () => { + const next = pathGetResults.shift(); + if (next instanceof Error) throw next; + return next ?? { data: { directory: '/workspace/project' } }; +}); + mock.module('@opencode-ai/sdk/v2', () => ({ createOpencodeClient: mock(() => ({ config: { @@ -30,6 +37,9 @@ mock.module('@opencode-ai/sdk/v2', () => ({ session: { promptAsync: promptAsyncMock, }, + path: { + get: pathGetMock, + }, })), })); @@ -64,6 +74,17 @@ beforeEach(() => { runtimeKey = 'test-runtime'; promptAsyncCalls.length = 0; promptAsyncResults.length = 0; + pathGetResults.length = 0; +}); + +describe('opencodeClient directory availability', () => { + test('distinguishes a missing directory from an unavailable path probe', async () => { + pathGetResults.push({ error: { code: 'ENOENT', message: 'no such file or directory' } }); + expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('missing'); + + pathGetResults.push(new Error('offline')); + expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown'); + }); }); describe('opencodeClient getConfig cache', () => { diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index c817acb3..1af33e97 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -68,6 +68,21 @@ type SdkResult = { response?: { status?: number }; }; +export type DirectoryAvailability = "available" | "missing" | "unknown"; + +const isMissingDirectoryError = (error: unknown): boolean => { + if (error instanceof FilesystemError) { + return error.reason === "not-found" || error.reason === "not-directory"; + } + if (error && typeof error === "object") { + const code = (error as { code?: unknown }).code; + if (code === "ENOENT" || code === "ENOTDIR") { + return true; + } + } + return /\bENOENT\b|\bENOTDIR\b|no such file or directory/i.test(formatSdkError(error)); +}; + function unwrapSdkData(result: SdkResult, operation: string): T { if (result.error) { const status = result.response?.status; @@ -506,17 +521,28 @@ class OpencodeService { * This is intentionally NOT the same as local filesystem access in the UI runtime. */ async probeDirectory(directory: string): Promise { + return (await this.getDirectoryAvailability(directory)) === "available"; + } + + /** + * Distinguishes a confirmed-missing directory from an unavailable probe. + * Offline, permission, and other transport failures stay `unknown` so callers + * do not treat a temporary outage as proof the path was deleted. + */ + async getDirectoryAvailability(directory: string): Promise { const normalized = this.normalizeCandidatePath(directory); if (!normalized) { - return false; + return "unknown"; } try { - const response = await this.client.path.get({ directory: normalized }); - const info = response.data as { directory?: unknown } | undefined; - const returned = typeof info?.directory === 'string' ? info.directory : null; - return Boolean(returned && returned.trim().length > 0); - } catch { - return false; + const response = await this.client.path.get({ directory: normalized }) as SdkResult<{ directory?: unknown }>; + if (response.error) { + return isMissingDirectoryError(response.error) ? "missing" : "unknown"; + } + const returned = typeof response.data?.directory === "string" ? response.data.directory.trim() : ""; + return returned ? "available" : "unknown"; + } catch (error) { + return isMissingDirectoryError(error) ? "missing" : "unknown"; } } diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 7dde6772..f094e0e4 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -251,7 +251,8 @@ Rules: 4. Sending after a revert commits the new branch optimistically: remove the reverted tail and marker before inserting the new message, and restore both if the send is rejected. 5. Composer and queued sends carry their captured runtime, directory, and session through asynchronous preparation. A runtime change cancels the send instead of re-resolving it against the new runtime. 6. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session. -7. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message. +7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenCode reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds. +8. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message. Examples of global-store updates performed in `session-actions.ts`: diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index 23ace4aa..565d5294 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -9,6 +9,7 @@ import { useSkillsStore } from '@/stores/useSkillsStore'; import { useCommandsStore } from '@/stores/useCommandsStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { getRuntimeKey } from '@/lib/runtime-switch'; +import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; /** * Unit tests for session worktree routing through the authoritative store. @@ -408,9 +409,21 @@ describe('openNewSessionDraft project binding', () => { describe('createSession draft lifecycle', () => { let originalCreateSession; + let originalGetDirectoryAvailability; + let originalProjects; + let originalActiveProjectId; + let originalDirectoryState; + let originalClientDirectory; + let originalLastDirectory; beforeEach(() => { originalCreateSession = opencodeClient.createSession; + originalGetDirectoryAvailability = opencodeClient.getDirectoryAvailability; + originalProjects = useProjectsStore.getState().projects; + originalActiveProjectId = useProjectsStore.getState().activeProjectId; + originalDirectoryState = useDirectoryStore.getState(); + originalClientDirectory = opencodeClient.getDirectory(); + originalLastDirectory = getDeferredSafeStorage().getItem('lastDirectory'); useSessionUIStore.setState({ currentSessionId: null, currentSessionDirectory: null, @@ -420,6 +433,15 @@ describe('createSession draft lifecycle', () => { afterEach(() => { opencodeClient.createSession = originalCreateSession; + opencodeClient.getDirectoryAvailability = originalGetDirectoryAvailability; + useProjectsStore.setState({ projects: originalProjects, activeProjectId: originalActiveProjectId }); + useDirectoryStore.setState(originalDirectoryState, true); + opencodeClient.setDirectory(originalClientDirectory ?? undefined); + if (originalLastDirectory === null) { + getDeferredSafeStorage().removeItem('lastDirectory'); + } else { + getDeferredSafeStorage().setItem('lastDirectory', originalLastDirectory); + } }); test('keeps the draft open when session creation fails', async () => { @@ -433,6 +455,108 @@ describe('createSession draft lifecycle', () => { expect(useSessionUIStore.getState().newSessionDraft.open).toBe(true); expect(useSessionUIStore.getState().newSessionDraft.title).toBe('Draft title'); }); + + test('falls back to the current active project when a regular new-chat directory is missing', async () => { + const createSessionCalls = []; + useProjectsStore.setState({ + projects: [ + { id: 'project-draft', path: '/projects/draft', label: 'Draft' }, + { id: 'project-active', path: '/projects/active', label: 'Active' }, + ], + activeProjectId: 'project-active', + }); + useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false }); + useSessionUIStore.getState().openNewSessionDraft(); + opencodeClient.getDirectoryAvailability = async () => 'missing'; + opencodeClient.createSession = async (_params, directory) => { + createSessionCalls.push(directory); + return { id: 'session-fallback', directory }; + }; + + await useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree'); + + expect(createSessionCalls).toEqual(['/projects/active']); + expect(useDirectoryStore.getState().currentDirectory).toBe('/projects/active'); + expect(getDeferredSafeStorage().getItem('lastDirectory')).toBe('/projects/active'); + }); + + test('keeps an explicitly pinned worktree directory unchanged', async () => { + const createSessionCalls = []; + useProjectsStore.setState({ + projects: [{ id: 'project-main', path: '/projects/main', label: 'Main' }], + activeProjectId: 'project-main', + }); + useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree', preserveDirectoryOverride: true }); + expect(useSessionUIStore.getState().newSessionDraft.preserveDirectoryOverride).toBe(true); + opencodeClient.getDirectoryAvailability = async () => 'missing'; + opencodeClient.createSession = async (_params, directory) => { + createSessionCalls.push(directory); + return { id: 'session-pinned', directory }; + }; + + await useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree'); + + expect(createSessionCalls).toEqual(['/private/deleted-worktree']); + }); + + test('keeps a ChatInput-style current-directory draft recoverable when that path is missing', async () => { + const createSessionCalls = []; + useProjectsStore.setState({ + projects: [{ id: 'project-main', path: '/projects/main', label: 'Main' }], + activeProjectId: 'project-main', + }); + useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false }); + useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' }); + expect(useSessionUIStore.getState().newSessionDraft.preserveDirectoryOverride).not.toBe(true); + opencodeClient.getDirectoryAvailability = async () => 'missing'; + opencodeClient.createSession = async (_params, directory) => { + createSessionCalls.push(directory); + return { id: 'session-chat-input', directory }; + }; + + await useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree'); + + expect(createSessionCalls).toEqual(['/projects/main']); + }); + + test('keeps the stale directory when its availability cannot be confirmed', async () => { + const createSessionCalls = []; + useProjectsStore.setState({ + projects: [{ id: 'project-main', path: '/projects/main', label: 'Main' }], + activeProjectId: 'project-main', + }); + useDirectoryStore.getState().setDirectory('/private/unavailable-worktree', { showOverlay: false }); + useSessionUIStore.getState().openNewSessionDraft(); + opencodeClient.getDirectoryAvailability = async () => 'unknown'; + opencodeClient.createSession = async (_params, directory) => { + createSessionCalls.push(directory); + return { id: 'session-unavailable', directory }; + }; + + await useSessionUIStore.getState().createSession('Draft title', '/private/unavailable-worktree'); + + expect(createSessionCalls).toEqual(['/private/unavailable-worktree']); + expect(useDirectoryStore.getState().currentDirectory).toBe('/private/unavailable-worktree'); + }); + + test('does not persist a fallback when session creation fails', async () => { + useProjectsStore.setState({ + projects: [{ id: 'project-main', path: '/projects/main', label: 'Main' }], + activeProjectId: 'project-main', + }); + useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false }); + useSessionUIStore.getState().openNewSessionDraft(); + opencodeClient.getDirectoryAvailability = async () => 'missing'; + opencodeClient.createSession = async () => { + throw new Error('offline'); + }; + + const session = await useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree'); + + expect(session).toBeNull(); + expect(useDirectoryStore.getState().currentDirectory).toBe('/private/deleted-worktree'); + expect(getDeferredSafeStorage().getItem('lastDirectory')).toBe('/private/deleted-worktree'); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 1db06f4c..ac4fa453 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -603,6 +603,63 @@ const waitForWorktreeBootstrapIfConfigured = async (directory: string | null, pr } } +const resolveActiveProjectDirectory = (draft: NewSessionDraftState): string | null => { + const projectsState = useProjectsStore.getState() + return normalizePath( + projectsState.getActiveProject()?.path + ?? (draft.selectedProjectId + ? projectsState.projects.find((project) => project.id === draft.selectedProjectId)?.path + : null) + ?? null, + ) +} + +/** + * Regular new-chat drafts inherit the persisted current/last directory. If that + * path is confirmed missing (deleted worktree), fall back to the active project. + * Explicit worktree targets, in-flight worktree creation, and unknown/offline + * probes stay unchanged so a temporary outage cannot rewrite the destination. + */ +const resolveCreatableDraftDirectory = async ( + draft: NewSessionDraftState, + requestedDirectory: string | null | undefined, +): Promise<{ status: "ok"; directory: string | null | undefined } | { status: "aborted" }> => { + const directory = requestedDirectory ?? opencodeClient.getDirectory() ?? null + const isRecoverableDraftDirectory = + draft.open + && draft.preserveDirectoryOverride !== true + && !draft.pendingWorktreeRequestId + && !draft.bootstrapPendingDirectory + && normalizePath(draft.directoryOverride) === normalizePath(directory) + + if (!isRecoverableDraftDirectory || !directory) { + return { status: "ok", directory } + } + + const activeProjectDirectory = resolveActiveProjectDirectory(draft) + if (!activeProjectDirectory || normalizePath(directory) === activeProjectDirectory) { + return { status: "ok", directory } + } + + const runtimeKey = getRuntimeKey() + const draftDirectory = draft.directoryOverride + const availability = await opencodeClient.getDirectoryAvailability(directory) + const currentDraft = useSessionUIStore.getState().newSessionDraft + const draftChanged = !currentDraft.open + || currentDraft.preserveDirectoryOverride !== draft.preserveDirectoryOverride + || currentDraft.pendingWorktreeRequestId !== draft.pendingWorktreeRequestId + || normalizePath(currentDraft.directoryOverride) !== normalizePath(draftDirectory) + + if (getRuntimeKey() !== runtimeKey || draftChanged) { + return { status: "aborted" } + } + + return { + status: "ok", + directory: availability === "missing" ? activeProjectDirectory : directory, + } +} + export async function materializeOpenDraftSession(selection: { providerID: string modelID: string @@ -1416,14 +1473,16 @@ export const useSessionUIStore = create()((set, get) => ({ const targetFolderId = draft.targetFolderId try { - const dir = directoryOverride ?? opencodeClient.getDirectory() + const resolved = await resolveCreatableDraftDirectory(draft, directoryOverride) + if (resolved.status === "aborted") return null + const dir = resolved.directory const session = await createSessionAction(title, dir, parentID ?? null, metadata) if (!session) return null get().closeNewSessionDraft() if (targetFolderId) { - const scopeKey = directoryOverride || get().lastLoadedDirectory || session.directory + const scopeKey = dir || get().lastLoadedDirectory || session.directory if (scopeKey) { useSessionFoldersStore.getState().addSessionToFolder(scopeKey, targetFolderId, session.id) } diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 8132a578..86d45514 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,3 +1,7 @@ +## [Unreleased] + +- Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting. + ## [1.18.4] - 2026-08-14 - **Chat:** new messages now remain at the end of the conversation instead of jumping before older messages after the message ID sequence rolls over; history loading, revert, and redo follow the same chronological order.