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),