Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ release/
apps/web/.playwright
apps/web/playwright-report
apps/web/src/components/__screenshots__
.worktrees/
7 changes: 7 additions & 0 deletions apps/server/src/codexAppServerManager.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
29 changes: 21 additions & 8 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -3578,8 +3583,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
{/* Error banner */}
<ProviderHealthBanner status={activeProviderStatus} />
<ThreadErrorBanner
error={activeThread.error}
onDismiss={() => 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 */}
<div className="flex min-h-0 min-w-0 flex-1">
Expand Down Expand Up @@ -3773,18 +3784,20 @@ 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"
: phase === "disconnected"
? "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}
/>
</div>

Expand Down
12 changes: 10 additions & 2 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 (
<SidebarMenuSubItem
Expand Down Expand Up @@ -1654,7 +1657,12 @@ export default function Sidebar() {
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="min-w-0 flex-1 truncate text-xs">
<span
className={cn(
"min-w-0 flex-1 truncate text-xs",
isProjectMissing && "line-through text-destructive/60",
)}
>
{thread.title}
</span>
)}
Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/hooks/useProjectDirectoryCheck.ts
Original file line number Diff line number Diff line change
@@ -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<string> = 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<ReturnType<typeof setTimeout> | 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]);
}
7 changes: 7 additions & 0 deletions apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -52,6 +53,7 @@ function RootRouteView() {
<ToastProvider>
<AnchoredToastProvider>
<EventRouter />
<ProjectDirectoryChecker />
<DesktopProjectBootstrap />
<Outlet />
</AnchoredToastProvider>
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ function makeState(thread: Thread): AppState {
],
threads: [thread],
threadsHydrated: true,
missingProjectCwds: new Set(),
};
}

Expand Down Expand Up @@ -182,6 +183,7 @@ describe("store pure functions", () => {
],
threads: [],
threadsHydrated: true,
missingProjectCwds: new Set(),
};

const next = reorderProjects(state, project1, project3);
Expand Down Expand Up @@ -229,6 +231,7 @@ describe("store read model sync", () => {
],
threads: [],
threadsHydrated: true,
missingProjectCwds: new Set(),
};
const readModel: OrchestrationReadModel = {
snapshotSequence: 2,
Expand Down
13 changes: 12 additions & 1 deletion apps/web/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface AppState {
projects: Project[];
threads: Thread[];
threadsHydrated: boolean;
missingProjectCwds: ReadonlySet<string>;
}

const PERSISTED_STATE_KEY = "t3code:renderer-state:v8";
Expand All @@ -41,6 +42,7 @@ const initialState: AppState = {
projects: [],
threads: [],
threadsHydrated: false,
missingProjectCwds: new Set(),
};
const persistedExpandedProjectCwds = new Set<string>();
const persistedProjectOrderCwds: string[] = [];
Expand Down Expand Up @@ -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<string>) => void;
}

export const useStore = create<AppStore>((set) => ({
export const useStore = create<AppStore>((set, get) => ({
...readPersistedState(),
syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)),
markThreadVisited: (threadId, visitedAt) =>
Expand All @@ -457,6 +460,14 @@ export const useStore = create<AppStore>((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
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/wsNativeApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ export function createWsNativeApi(): NativeApi {
callback(message.data),
),
},
request: <T = unknown>(method: string, params?: unknown) =>
transport.request<T>(method, params),
};

instance = { api, transport };
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,4 +170,6 @@ export interface NativeApi {
replayEvents: (fromSequenceExclusive: number) => Promise<OrchestrationEvent[]>;
onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void;
};
/** Low-level RPC call for methods without dedicated typed wrappers. */
request: <T = unknown>(method: string, params?: unknown) => Promise<T>;
}
4 changes: 4 additions & 0 deletions packages/contracts/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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),
Expand Down