From f1591bb68d128a9c96a79753b110166cc6a1443c Mon Sep 17 00:00:00 2001 From: Chukwudi Nwobodo Date: Mon, 9 Mar 2026 05:13:52 +0000 Subject: [PATCH 1/7] chore: add .worktrees/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8478eb6fc..2ed026dbb 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ release/ apps/web/.playwright apps/web/playwright-report apps/web/src/components/__screenshots__ +.worktrees/ From 5b49c711bcb0e8ad90520b9d2d6b01db01339ab9 Mon Sep 17 00:00:00 2001 From: Chukwudi Nwobodo Date: Fri, 13 Mar 2026 02:40:25 +0000 Subject: [PATCH 2/7] fix(server): disambiguate missing directory from missing binary in ENOENT error --- apps/server/src/codexAppServerManager.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index a8a8ce460..0df1aeadd 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -1,6 +1,7 @@ import { type ChildProcessWithoutNullStreams, spawn, spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; +import { existsSync } from "node:fs"; import readline from "node:readline"; import { @@ -1546,6 +1547,12 @@ function assertSupportedCodexCliVersion(input: { lower.includes("command not found") || lower.includes("not found") ) { + // Disambiguate: is the cwd missing, or is the binary missing? + if (!existsSync(input.cwd)) { + throw new Error( + `Project directory does not exist: ${input.cwd}. The folder may have been moved or deleted.`, + ); + } throw new Error(`Codex CLI (${input.binaryPath}) is not installed or not executable.`); } throw new Error( From 8eec3fe353c4d46b869bdc5f29a3ce3c4e75362e Mon Sep 17 00:00:00 2001 From: Chukwudi Nwobodo Date: Fri, 13 Mar 2026 02:43:59 +0000 Subject: [PATCH 3/7] feat(web): add missingProjectCwds store slice for tracking deleted project directories --- apps/web/src/store.test.ts | 3 +++ apps/web/src/store.ts | 10 +++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 92d084f2d..d88216c0f 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -47,6 +47,7 @@ function makeState(thread: Thread): AppState { ], threads: [thread], threadsHydrated: true, + missingProjectCwds: new Set(), }; } @@ -182,6 +183,7 @@ describe("store pure functions", () => { ], threads: [], threadsHydrated: true, + missingProjectCwds: new Set(), }; const next = reorderProjects(state, project1, project3); @@ -229,6 +231,7 @@ describe("store read model sync", () => { ], threads: [], threadsHydrated: true, + missingProjectCwds: new Set(), }; const readModel: OrchestrationReadModel = { snapshotSequence: 2, diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index faebe4b0f..9e7090d89 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -22,6 +22,7 @@ export interface AppState { projects: Project[]; threads: Thread[]; threadsHydrated: boolean; + missingProjectCwds: ReadonlySet; } const PERSISTED_STATE_KEY = "t3code:renderer-state:v8"; @@ -41,6 +42,7 @@ const initialState: AppState = { projects: [], threads: [], threadsHydrated: false, + missingProjectCwds: new Set(), }; const persistedExpandedProjectCwds = new Set(); const persistedProjectOrderCwds: string[] = []; @@ -441,9 +443,10 @@ interface AppStore extends AppState { reorderProjects: (draggedProjectId: Project["id"], targetProjectId: Project["id"]) => void; setError: (threadId: ThreadId, error: string | null) => void; setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void; + setMissingProjectCwds: (cwds: ReadonlySet) => void; } -export const useStore = create((set) => ({ +export const useStore = create((set, get) => ({ ...readPersistedState(), syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)), markThreadVisited: (threadId, visitedAt) => @@ -457,6 +460,11 @@ export const useStore = create((set) => ({ setError: (threadId, error) => set((state) => setError(state, threadId, error)), setThreadBranch: (threadId, branch, worktreePath) => set((state) => setThreadBranch(state, threadId, branch, worktreePath)), + setMissingProjectCwds: (cwds) => { + const current = get().missingProjectCwds; + if (cwds.size === current.size && [...cwds].every((c) => current.has(c))) return; + set({ missingProjectCwds: cwds }); + }, })); // Persist state changes with debouncing to avoid localStorage thrashing From 124788fa87c4bf7ea12bb172ae57dfdc748b2455 Mon Sep 17 00:00:00 2001 From: Chukwudi Nwobodo Date: Fri, 13 Mar 2026 03:16:56 +0000 Subject: [PATCH 4/7] feat: add project directory existence check on mount and window focus Add a WS endpoint (projects.checkDirectories) that checks whether project directories exist on disk, a React hook that calls it on mount and window focus (debounced at 500ms), and a generic request() method on NativeApi for untyped RPC calls. --- apps/server/src/wsServer.ts | 14 ++++++ .../web/src/hooks/useProjectDirectoryCheck.ts | 47 +++++++++++++++++++ apps/web/src/routes/__root.tsx | 7 +++ apps/web/src/wsNativeApi.ts | 2 + packages/contracts/src/ipc.ts | 2 + packages/contracts/src/ws.ts | 4 ++ 6 files changed, 76 insertions(+) create mode 100644 apps/web/src/hooks/useProjectDirectoryCheck.ts diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7..e49deddf0 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -776,6 +776,20 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { relativePath: target.relativePath }; } + case WS_METHODS.checkProjectDirectories: { + const { cwds } = request.body; + const missing: string[] = []; + for (const cwd of cwds) { + const stat = yield* fileSystem + .stat(cwd) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!stat || stat.type !== "Directory") { + missing.push(cwd); + } + } + return { missing }; + } + case WS_METHODS.shellOpenInEditor: { const body = stripRequestTag(request.body); return yield* openInEditor(body); diff --git a/apps/web/src/hooks/useProjectDirectoryCheck.ts b/apps/web/src/hooks/useProjectDirectoryCheck.ts new file mode 100644 index 000000000..cf66d3c3a --- /dev/null +++ b/apps/web/src/hooks/useProjectDirectoryCheck.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useRef } from "react"; +import { WS_METHODS } from "@t3tools/contracts"; +import { readNativeApi } from "../nativeApi"; +import { useStore } from "../store"; + +const CHECK_DEBOUNCE_MS = 500; + +export function useProjectDirectoryCheck(): void { + const projects = useStore((s) => s.projects); + const setMissingProjectCwds = useStore((s) => s.setMissingProjectCwds); + const debounceRef = useRef | null>(null); + + const checkDirectories = useCallback(async () => { + const api = readNativeApi(); + if (!api || projects.length === 0) return; + + const cwds = projects.map((p) => p.cwd); + try { + const result = await api.request(WS_METHODS.checkProjectDirectories, { cwds }); + const missing: string[] = (result as { missing: string[] }).missing ?? []; + setMissingProjectCwds(new Set(missing)); + } catch { + // If the check itself fails, don't block the UI + } + }, [projects, setMissingProjectCwds]); + + // Check on mount and when projects change + useEffect(() => { + void checkDirectories(); + }, [checkDirectories]); + + // Check on window focus (debounced) + useEffect(() => { + const onFocus = () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + void checkDirectories(); + }, CHECK_DEBOUNCE_MS); + }; + + window.addEventListener("focus", onFocus); + return () => { + window.removeEventListener("focus", onFocus); + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [checkDirectories]); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 2fe07505f..056f47be8 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -18,6 +18,7 @@ import { readNativeApi } from "../nativeApi"; import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore"; import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; +import { useProjectDirectoryCheck } from "../hooks/useProjectDirectoryCheck"; import { preferredTerminalEditor } from "../terminal-links"; import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi"; @@ -52,6 +53,7 @@ function RootRouteView() { + @@ -317,6 +319,11 @@ function EventRouter() { return null; } +function ProjectDirectoryChecker() { + useProjectDirectoryCheck(); + return null; +} + function DesktopProjectBootstrap() { // Desktop hydration runs through EventRouter project + orchestration sync. return null; diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index ddfffbde6..fc8394734 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -175,6 +175,8 @@ export function createWsNativeApi(): NativeApi { callback(message.data), ), }, + request: (method: string, params?: unknown) => + transport.request(method, params), }; instance = { api, transport }; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb17..e9acd58a3 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -170,4 +170,6 @@ export interface NativeApi { replayEvents: (fromSequenceExclusive: number) => Promise; onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void; }; + /** Low-level RPC call for methods without dedicated typed wrappers. */ + request: (method: string, params?: unknown) => Promise; } diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index ebb76138b..afb26c578 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -47,6 +47,7 @@ export const WS_METHODS = { projectsRemove: "projects.remove", projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", + checkProjectDirectories: "projects.checkDirectories", // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -111,6 +112,9 @@ const WebSocketRequestBody = Schema.Union([ // Project Search tagRequestBody(WS_METHODS.projectsSearchEntries, ProjectSearchEntriesInput), tagRequestBody(WS_METHODS.projectsWriteFile, ProjectWriteFileInput), + tagRequestBody(WS_METHODS.checkProjectDirectories, Schema.Struct({ + cwds: Schema.Array(Schema.String), + })), // Shell methods tagRequestBody(WS_METHODS.shellOpenInEditor, OpenInEditorInput), From 981381c96d1d720d5e135d79ef7eac8bf1f12f2d Mon Sep 17 00:00:00 2001 From: Chukwudi Nwobodo Date: Fri, 13 Mar 2026 03:18:27 +0000 Subject: [PATCH 5/7] feat(web): show strikethrough on sidebar threads when project directory is missing --- apps/web/src/components/Sidebar.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8b68c3b80..e703f1e06 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -40,7 +40,7 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; -import { isMacPlatform, newCommandId, newProjectId, newThreadId } from "../lib/utils"; +import { cn, isMacPlatform, newCommandId, newProjectId, newThreadId } from "../lib/utils"; import { useStore } from "../store"; import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; @@ -265,6 +265,7 @@ export default function Sidebar() { const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); + const missingProjectCwds = useStore((store) => store.missingProjectCwds); const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const clearProjectDraftThreadId = useComposerDraftStore( @@ -1520,6 +1521,8 @@ export default function Sidebar() { selectThreadTerminalState(terminalStateByThreadId, thread.id) .runningTerminalIds, ); + const projectCwd = projectCwdById.get(thread.projectId); + const isProjectMissing = projectCwd ? missingProjectCwds.has(projectCwd) : false; return ( e.stopPropagation()} /> ) : ( - + {thread.title} )} From 8c9cf77db8fd525138ab2638b353ac563e2cecd3 Mon Sep 17 00:00:00 2001 From: Chukwudi Nwobodo Date: Fri, 13 Mar 2026 03:20:44 +0000 Subject: [PATCH 6/7] feat(web): disable composer and show error banner when project directory is missing --- apps/web/src/components/ChatView.tsx | 29 ++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3c8a0a152..70a2d4d0b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -765,6 +765,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProject = projects.find((p) => p.id === activeThread?.projectId); + const missingProjectCwds = useStore((s) => s.missingProjectCwds); + const isProjectDirectoryMissing = activeProject + ? missingProjectCwds.has(activeProject.cwd) + : false; + const openPullRequestDialog = useCallback( (reference?: string) => { if (!canCheckoutPullRequestIntoThread) { @@ -2552,7 +2557,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); const api = readNativeApi(); - if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; + if (!api || !activeThread || isSendBusy || isConnecting || isProjectDirectoryMissing || sendInFlightRef.current) return; if (activePendingProgress) { onAdvanceActivePendingUserInput(); return; @@ -3578,8 +3583,14 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Error banner */} setThreadError(activeThread.id, null)} + error={ + isProjectDirectoryMissing + ? `Project directory does not exist: ${activeProject?.cwd ?? "unknown"}. The folder may have been moved or deleted.` + : activeThread.error + } + {...(isProjectDirectoryMissing + ? {} + : { onDismiss: () => setThreadError(activeThread.id, null) })} /> {/* Main content area with optional plan sidebar */}
@@ -3773,10 +3784,12 @@ export default function ChatView({ threadId }: ChatViewProps) { onCommandKeyDown={onComposerCommandKey} onPaste={onComposerPaste} placeholder={ - isComposerApprovalState - ? (activePendingApproval?.detail ?? - "Resolve this approval request to continue") - : activePendingProgress + isProjectDirectoryMissing + ? "Project directory is missing. Restore the folder to continue." + : isComposerApprovalState + ? (activePendingApproval?.detail ?? + "Resolve this approval request to continue") + : activePendingProgress ? "Type your own answer, or leave this blank to use the selected option" : showPlanFollowUpPrompt && activeProposedPlan ? "Add feedback to refine the plan, or leave this blank to implement it" @@ -3784,7 +3797,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ? "Ask for follow-up changes or attach images" : "Ask anything, @tag files/folders, or use / to show available commands" } - disabled={isConnecting || isComposerApprovalState} + disabled={isConnecting || isComposerApprovalState || isProjectDirectoryMissing} />
From 17f3f5ace171117726fc4da0226d8175cce2b125 Mon Sep 17 00:00:00 2001 From: Chukwudi Nwobodo Date: Fri, 13 Mar 2026 03:29:05 +0000 Subject: [PATCH 7/7] perf: optimize directory check hook, set comparison, and parallel stat calls --- apps/server/src/wsServer.ts | 18 ++++++++---------- apps/web/src/hooks/useProjectDirectoryCheck.ts | 15 +++++++++------ apps/web/src/store.ts | 7 +++++-- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index e49deddf0..fa62a1999 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -778,16 +778,14 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case WS_METHODS.checkProjectDirectories: { const { cwds } = request.body; - const missing: string[] = []; - for (const cwd of cwds) { - const stat = yield* fileSystem - .stat(cwd) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (!stat || stat.type !== "Directory") { - missing.push(cwd); - } - } - return { missing }; + const results = yield* Effect.forEach(cwds, (cwd) => + fileSystem.stat(cwd).pipe( + Effect.map((stat) => (stat.type !== "Directory" ? cwd : null)), + Effect.catch(() => Effect.succeed(cwd)), + ), + { concurrency: "unbounded" }, + ); + return { missing: results.filter((r): r is string => r !== null) }; } case WS_METHODS.shellOpenInEditor: { diff --git a/apps/web/src/hooks/useProjectDirectoryCheck.ts b/apps/web/src/hooks/useProjectDirectoryCheck.ts index cf66d3c3a..c46e9022a 100644 --- a/apps/web/src/hooks/useProjectDirectoryCheck.ts +++ b/apps/web/src/hooks/useProjectDirectoryCheck.ts @@ -1,28 +1,31 @@ import { useCallback, useEffect, useRef } from "react"; import { WS_METHODS } from "@t3tools/contracts"; +import { useShallow } from "zustand/react/shallow"; import { readNativeApi } from "../nativeApi"; import { useStore } from "../store"; const CHECK_DEBOUNCE_MS = 500; +const EMPTY_SET: ReadonlySet = new Set(); export function useProjectDirectoryCheck(): void { - const projects = useStore((s) => s.projects); + const projectCwds = useStore( + useShallow((s) => s.projects.map((p) => p.cwd)), + ); const setMissingProjectCwds = useStore((s) => s.setMissingProjectCwds); const debounceRef = useRef | null>(null); const checkDirectories = useCallback(async () => { const api = readNativeApi(); - if (!api || projects.length === 0) return; + if (!api || projectCwds.length === 0) return; - const cwds = projects.map((p) => p.cwd); try { - const result = await api.request(WS_METHODS.checkProjectDirectories, { cwds }); + const result = await api.request(WS_METHODS.checkProjectDirectories, { cwds: projectCwds }); const missing: string[] = (result as { missing: string[] }).missing ?? []; - setMissingProjectCwds(new Set(missing)); + setMissingProjectCwds(missing.length === 0 ? EMPTY_SET : new Set(missing)); } catch { // If the check itself fails, don't block the UI } - }, [projects, setMissingProjectCwds]); + }, [projectCwds, setMissingProjectCwds]); // Check on mount and when projects change useEffect(() => { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 9e7090d89..ae1e89523 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -462,8 +462,11 @@ export const useStore = create((set, get) => ({ set((state) => setThreadBranch(state, threadId, branch, worktreePath)), setMissingProjectCwds: (cwds) => { const current = get().missingProjectCwds; - if (cwds.size === current.size && [...cwds].every((c) => current.has(c))) return; - set({ missingProjectCwds: cwds }); + if (cwds.size !== current.size) { set({ missingProjectCwds: cwds }); return; } + for (const c of cwds) { + if (!current.has(c)) { set({ missingProjectCwds: cwds }); return; } + } + // Sets are equal, no update needed }, }));