Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
14 changes: 14 additions & 0 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
49 changes: 35 additions & 14 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,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 @@ -2197,7 +2202,15 @@ 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 @@ -3252,8 +3265,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 @@ -3448,18 +3467,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
onCommandKeyDown={onComposerCommandKey}
onPaste={onComposerPaste}
placeholder={
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"
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
15 changes: 13 additions & 2 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,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 } from "../lib/utils";
import { cn, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
import { useStore } from "../store";
import { shortcutLabelForCommand } from "../keybindings";
import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
Expand Down Expand Up @@ -263,6 +263,7 @@ export default function Sidebar() {
);
const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
const missingProjectCwds = useStore((store) => store.missingProjectCwds);
const clearProjectDraftThreadId = useComposerDraftStore(
(store) => store.clearProjectDraftThreadId,
);
Expand Down Expand Up @@ -1395,6 +1396,10 @@ 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 @@ -1526,7 +1531,13 @@ 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
48 changes: 48 additions & 0 deletions apps/web/src/hooks/useProjectDirectoryCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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 @@ -19,6 +19,7 @@ import { readNativeApi } from "../nativeApi";
import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore";
import { useStore } from "../store";
import { useTerminalStateStore } from "../terminalStateStore";
import { useProjectDirectoryCheck } from "../hooks/useProjectDirectoryCheck";
import { terminalRunningSubprocessFromEvent } from "../terminalActivity";
import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi";
import { providerQueryKeys } from "../lib/providerReactQuery";
Expand Down Expand Up @@ -52,6 +53,7 @@ function RootRouteView() {
<ToastProvider>
<AnchoredToastProvider>
<EventRouter />
<ProjectDirectoryChecker />
<DesktopProjectBootstrap />
<Outlet />
</AnchoredToastProvider>
Expand Down Expand Up @@ -321,6 +323,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
19 changes: 18 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,20 @@ 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>;
}
7 changes: 7 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,12 @@ 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
Loading