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/ 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( diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7..fa62a1999 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -776,6 +776,18 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { relativePath: target.relativePath }; } + case WS_METHODS.checkProjectDirectories: { + const { cwds } = request.body; + 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: { const body = stripRequestTag(request.body); return yield* openInEditor(body); 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} />
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} )} diff --git a/apps/web/src/hooks/useProjectDirectoryCheck.ts b/apps/web/src/hooks/useProjectDirectoryCheck.ts new file mode 100644 index 000000000..c46e9022a --- /dev/null +++ b/apps/web/src/hooks/useProjectDirectoryCheck.ts @@ -0,0 +1,50 @@ +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 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 || projectCwds.length === 0) return; + + try { + const result = await api.request(WS_METHODS.checkProjectDirectories, { cwds: projectCwds }); + const missing: string[] = (result as { missing: string[] }).missing ?? []; + setMissingProjectCwds(missing.length === 0 ? EMPTY_SET : new Set(missing)); + } catch { + // If the check itself fails, don't block the UI + } + }, [projectCwds, 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/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..ae1e89523 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,14 @@ 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) { set({ missingProjectCwds: cwds }); return; } + for (const c of cwds) { + if (!current.has(c)) { set({ missingProjectCwds: cwds }); return; } + } + // Sets are equal, no update needed + }, })); // Persist state changes with debouncing to avoid localStorage thrashing 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),