From ae625d277cc7cca1160b3c3756151aeca8b1e99f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 00:45:41 -0700 Subject: [PATCH] Handle missing workspaces across server and UI - validate workspace paths before git, provider, terminal, and WS ops - surface workspace availability in projections and client state - add tests for missing cwd recovery and status handling --- .../Layers/CheckpointDiffQuery.test.ts | 4 + apps/server/src/git/Layers/GitCore.test.ts | 17 +- apps/server/src/git/Layers/GitCore.ts | 41 ++++- .../orchestration/Layers/CheckpointReactor.ts | 27 +++- .../Layers/ProjectionSnapshotQuery.test.ts | 13 +- .../Layers/ProjectionSnapshotQuery.ts | 95 ++++++++--- .../Layers/ProviderCommandReactor.test.ts | 2 + .../Layers/ProviderCommandReactor.ts | 7 + .../orchestration/commandInvariants.test.ts | 8 + apps/server/src/orchestration/projector.ts | 1 + .../provider/Layers/ProviderService.test.ts | 53 +++++- .../src/provider/Layers/ProviderService.ts | 23 +++ apps/server/src/terminal/Layers/Manager.ts | 19 +-- apps/server/src/workspacePaths.ts | 100 ++++++++++++ apps/server/src/wsServer.ts | 35 +++- apps/web/src/components/BranchToolbar.tsx | 9 +- apps/web/src/components/ChatView.browser.tsx | 7 + apps/web/src/components/ChatView.logic.ts | 3 + apps/web/src/components/ChatView.tsx | 151 ++++++++++++++++-- .../components/KeybindingsToast.browser.tsx | 4 + apps/web/src/components/Sidebar.logic.test.ts | 4 + apps/web/src/components/Sidebar.tsx | 16 +- apps/web/src/components/chat/ChatHeader.tsx | 23 ++- apps/web/src/hooks/useThreadActions.ts | 6 +- apps/web/src/lib/gitReactQuery.ts | 19 +-- apps/web/src/lib/projectReactQuery.ts | 2 + apps/web/src/routes/__root.tsx | 15 ++ apps/web/src/store.test.ts | 14 ++ apps/web/src/store.ts | 4 + apps/web/src/types.ts | 6 + apps/web/src/workspaceAvailability.ts | 35 ++++ apps/web/src/worktreeCleanup.test.ts | 3 + packages/contracts/src/orchestration.ts | 21 +++ 33 files changed, 706 insertions(+), 81 deletions(-) create mode 100644 apps/server/src/workspacePaths.ts create mode 100644 apps/web/src/workspaceAvailability.ts diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 9fb2500ce4..87b5e2a356 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -31,6 +31,7 @@ function makeSnapshot(input: { id: input.projectId, title: "Project", workspaceRoot: input.workspaceRoot, + workspaceState: "available", defaultModelSelection: null, scripts: [], createdAt: "2026-01-01T00:00:00.000Z", @@ -51,6 +52,9 @@ function makeSnapshot(input: { runtimeMode: "full-access", branch: null, worktreePath: input.worktreePath, + effectiveCwd: input.worktreePath ?? input.workspaceRoot, + effectiveCwdSource: input.worktreePath ? "worktree" : "project", + effectiveCwdState: "available", latestTurn: { turnId: TurnId.makeUnsafe("turn-1"), state: "completed", diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 547a69e7e1..fd8ba83536 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -1,9 +1,10 @@ import { existsSync } from "node:fs"; +import os from "node:os"; import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, PlatformError, Scope } from "effect"; import { describe, expect, vi } from "vitest"; import { GitCoreLive, makeGitCore } from "./GitCore.ts"; @@ -1283,6 +1284,20 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect("fails cleanly when statusDetails is requested for a missing workspace", () => + Effect.gen(function* () { + const missingCwd = path.join(os.tmpdir(), `git-missing-${crypto.randomUUID()}`); + const result = yield* Effect.exit((yield* GitCore).statusDetails(missingCwd)); + + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Cause.squash(result.cause); + expect(error).toBeInstanceOf(GitCommandError); + expect((error as GitCommandError).detail).toContain("Workspace folder is missing"); + } + }), + ); + it.effect("computes ahead count against base branch when no upstream is configured", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 64ed409508..fc7799f5a5 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -29,6 +29,7 @@ import { } from "../Services/GitCore.ts"; import { ServerConfig } from "../../config.ts"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { assertWorkspaceDirectory } from "../../workspacePaths.ts"; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; @@ -518,12 +519,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const { worktreesDir } = yield* ServerConfig; let execute: GitCoreShape["execute"]; + let rawExecute: GitCoreShape["execute"]; if (options?.executeOverride) { - execute = options.executeOverride; + rawExecute = options.executeOverride; } else { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - execute = Effect.fnUntraced(function* (input) { + rawExecute = Effect.fnUntraced(function* (input) { const commandInput = { ...input, args: [...input.args], @@ -613,6 +615,22 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }); } + execute = Effect.fnUntraced(function* (input) { + yield* assertWorkspaceDirectory(input.cwd, input.operation).pipe( + Effect.mapError( + (error) => + new GitCommandError({ + operation: input.operation, + command: quoteGitCommand(input.args), + cwd: input.cwd, + detail: error.message, + cause: error, + }), + ), + ); + return yield* rawExecute(input); + }); + const executeGit = ( operation: string, cwd: string, @@ -655,6 +673,20 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }), ); + const ensureGitWorkspace = (operation: string, cwd: string, args: readonly string[] = []) => + assertWorkspaceDirectory(cwd, operation).pipe( + Effect.mapError( + (error) => + new GitCommandError({ + operation, + command: quoteGitCommand(args), + cwd, + detail: error.message, + cause: error, + }), + ), + ); + const runGit = ( operation: string, cwd: string, @@ -1041,6 +1073,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }); const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { + yield* ensureGitWorkspace("GitCore.statusDetails", cwd, [ + "status", + "--porcelain=2", + "--branch", + ]); yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all( diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 561626b8de..462f948e0e 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -25,6 +25,10 @@ import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; import { OrchestrationDispatchError } from "../Errors.ts"; import { isGitRepository } from "../../git/Utils.ts"; +import { + inspectWorkspacePathState, + resolveWorkspaceUnavailableReason, +} from "../../workspacePaths.ts"; type ReactorInput = | { @@ -154,7 +158,12 @@ const make = Effect.gen(function* () { // a git repository. const resolveCheckpointCwd = Effect.fnUntraced(function* (input: { readonly threadId: ThreadId; - readonly thread: { readonly projectId: ProjectId; readonly worktreePath: string | null }; + readonly thread: { + readonly projectId: ProjectId; + readonly worktreePath: string | null; + readonly effectiveCwd: string | null; + readonly effectiveCwdState: string; + }; readonly projects: ReadonlyArray<{ readonly id: ProjectId; readonly workspaceRoot: string }>; readonly preferSessionRuntime: boolean; }): Effect.fn.Return { @@ -175,7 +184,7 @@ const make = Effect.gen(function* () { onSome: (runtime) => runtime.cwd, })); - if (!cwd) { + if (input.thread.effectiveCwdState !== "available" || !cwd) { return undefined; } if (!isGitWorkspace(cwd)) { @@ -583,6 +592,20 @@ const make = Effect.gen(function* () { }).pipe(Effect.catch(() => Effect.void)); return; } + const sessionWorkspaceState = yield* Effect.promise(() => + inspectWorkspacePathState(sessionRuntime.value.cwd), + ); + if (sessionWorkspaceState !== "available") { + yield* appendRevertFailureActivity({ + threadId: event.payload.threadId, + turnCount: event.payload.turnCount, + detail: + resolveWorkspaceUnavailableReason(sessionWorkspaceState) ?? + "Workspace folder is unavailable.", + createdAt: now, + }).pipe(Effect.catch(() => Effect.void)); + return; + } if (!isGitWorkspace(sessionRuntime.value.cwd)) { yield* appendRevertFailureActivity({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 32143d751f..801d3694ab 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -1,3 +1,7 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { CheckpointRef, EventId, MessageId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; @@ -23,6 +27,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { Effect.gen(function* () { const snapshotQuery = yield* ProjectionSnapshotQuery; const sql = yield* SqlClient.SqlClient; + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "projection-snapshot-")); yield* sql`DELETE FROM projection_projects`; yield* sql`DELETE FROM projection_state`; @@ -43,7 +48,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { VALUES ( 'project-1', 'Project 1', - '/tmp/project-1', + ${workspaceRoot}, '{"provider":"codex","model":"gpt-5-codex"}', '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', '2026-02-24T00:00:00.000Z', @@ -233,7 +238,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { { id: asProjectId("project-1"), title: "Project 1", - workspaceRoot: "/tmp/project-1", + workspaceRoot, + workspaceState: "available", defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -265,6 +271,9 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, + effectiveCwd: workspaceRoot, + effectiveCwdSource: "project", + effectiveCwdState: "available", latestTurn: { turnId: asTurnId("turn-1"), state: "completed", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index f951c54b5b..1aa92e8e32 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -18,6 +18,7 @@ import { type OrchestrationThread, type OrchestrationThreadActivity, ModelSelection, + type WorkspaceAvailabilityState, } from "@t3tools/contracts"; import { Effect, Layer, Schema, Struct } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -42,6 +43,7 @@ import { ProjectionSnapshotQuery, type ProjectionSnapshotQueryShape, } from "../Services/ProjectionSnapshotQuery.ts"; +import { inspectWorkspacePathState } from "../../workspacePaths.ts"; const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( @@ -135,6 +137,12 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st : toPersistenceSqlError(sqlOperation)(cause); } +type EffectiveWorkspaceMetadata = { + readonly effectiveCwd: string | null; + readonly effectiveCwdSource: "project" | "worktree" | null; + readonly effectiveCwdState: WorkspaceAvailabilityState; +}; + const makeProjectionSnapshotQuery = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -538,10 +546,35 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); } + const workspaceRoots = new Set(projectRows.map((row) => row.workspaceRoot)); + const worktreePaths = new Set( + threadRows.flatMap((row) => (row.worktreePath ? [row.worktreePath] : [])), + ); + const workspaceStateEntries = yield* Effect.promise(() => + Promise.all( + [...new Set([...workspaceRoots, ...worktreePaths])].map(async (workspacePath) => { + const workspaceState = await inspectWorkspacePathState(workspacePath); + return [workspacePath, workspaceState] as const; + }), + ), + ); + const workspaceStates = new Map(workspaceStateEntries); + const projectRowsById = new Map(projectRows.map((row) => [row.projectId, row] as const)); + const projectWorkspaceStateById = new Map( + projectRows.map((row) => [ + row.projectId, + workspaceStates.get(row.workspaceRoot) ?? + ("inaccessible" satisfies WorkspaceAvailabilityState), + ]), + ); + const projects: ReadonlyArray = projectRows.map((row) => ({ id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, + workspaceState: + projectWorkspaceStateById.get(row.projectId) ?? + ("inaccessible" satisfies WorkspaceAvailabilityState), defaultModelSelection: row.defaultModelSelection, scripts: row.scripts, createdAt: row.createdAt, @@ -549,26 +582,48 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { deletedAt: row.deletedAt, })); - const threads: ReadonlyArray = threadRows.map((row) => ({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - modelSelection: row.modelSelection, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - archivedAt: row.archivedAt, - deletedAt: row.deletedAt, - messages: messagesByThread.get(row.threadId) ?? [], - proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], - activities: activitiesByThread.get(row.threadId) ?? [], - checkpoints: checkpointsByThread.get(row.threadId) ?? [], - session: sessionsByThread.get(row.threadId) ?? null, - })); + const threads: ReadonlyArray = threadRows.map((row) => { + const effectiveWorkspace: EffectiveWorkspaceMetadata = + row.worktreePath !== null + ? { + effectiveCwd: row.worktreePath, + effectiveCwdSource: "worktree", + effectiveCwdState: + workspaceStates.get(row.worktreePath) ?? + ("inaccessible" satisfies WorkspaceAvailabilityState), + } + : { + effectiveCwd: projectRowsById.get(row.projectId)?.workspaceRoot ?? null, + effectiveCwdSource: projectRowsById.has(row.projectId) ? "project" : null, + effectiveCwdState: + projectWorkspaceStateById.get(row.projectId) ?? + ("inaccessible" satisfies WorkspaceAvailabilityState), + }; + + return { + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + effectiveCwd: effectiveWorkspace.effectiveCwd, + effectiveCwdSource: effectiveWorkspace.effectiveCwdSource, + effectiveCwdState: effectiveWorkspace.effectiveCwdState, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + deletedAt: row.deletedAt, + messages: messagesByThread.get(row.threadId) ?? [], + proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], + activities: activitiesByThread.get(row.threadId) ?? [], + checkpoints: checkpointsByThread.get(row.threadId) ?? [], + session: sessionsByThread.get(row.threadId) ?? null, + }; + }); const snapshot = { snapshotSequence: computeSnapshotSequence(stateRows), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ed7037695f..0fc18af645 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -45,6 +45,8 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); const deriveServerPathsSync = (baseDir: string, devUrl: URL | undefined) => Effect.runSync(deriveServerPaths(baseDir, devUrl).pipe(Effect.provide(NodeServices.layer))); +fs.mkdirSync("/tmp/provider-project", { recursive: true }); + async function waitFor( predicate: () => boolean | Promise, timeoutMs = 2000, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index d4f13ec727..328381555a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -25,6 +25,7 @@ import { type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { assertWorkspaceDirectory } from "../../workspacePaths.ts"; type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -254,6 +255,12 @@ const make = Effect.gen(function* () { thread, projects: readModel.projects, }); + if (effectiveCwd) { + yield* assertWorkspaceDirectory( + effectiveCwd, + "ProviderCommandReactor.ensureSessionForThread", + ); + } const resolveActiveSession = (threadId: ThreadId) => providerService diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 43d665a2c9..14c53bb64f 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -28,6 +28,7 @@ const readModel: OrchestrationReadModel = { id: ProjectId.makeUnsafe("project-a"), title: "Project A", workspaceRoot: "/tmp/project-a", + workspaceState: "available", defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -41,6 +42,7 @@ const readModel: OrchestrationReadModel = { id: ProjectId.makeUnsafe("project-b"), title: "Project B", workspaceRoot: "/tmp/project-b", + workspaceState: "available", defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -64,6 +66,9 @@ const readModel: OrchestrationReadModel = { runtimeMode: "full-access", branch: null, worktreePath: null, + effectiveCwd: "/tmp/project-a", + effectiveCwdSource: "project", + effectiveCwdState: "available", createdAt: now, updatedAt: now, archivedAt: null, @@ -87,6 +92,9 @@ const readModel: OrchestrationReadModel = { runtimeMode: "full-access", branch: null, worktreePath: null, + effectiveCwd: "/tmp/project-b", + effectiveCwdSource: "project", + effectiveCwdState: "available", createdAt: now, updatedAt: now, archivedAt: null, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index deb8a6d44d..3f920db459 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -183,6 +183,7 @@ export function projectEvent( id: payload.projectId, title: payload.title, workspaceRoot: payload.workspaceRoot, + workspaceState: "available" as const, defaultModelSelection: payload.defaultModelSelection, scripts: payload.scripts, createdAt: payload.createdAt, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 651a611649..6d97f7aa04 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -20,7 +20,7 @@ import { import { it, assert, vi } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; -import { Effect, Fiber, Layer, Option, PubSub, Ref, Stream } from "effect"; +import { Cause, Effect, Fiber, Layer, Option, PubSub, Ref, Schema, Stream } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { @@ -44,6 +44,7 @@ import { } from "../../persistence/Layers/Sqlite.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; +import { clearWorkspacePathStateCache } from "../../workspacePaths.ts"; const defaultServerSettingsLayer = ServerSettingsService.layerTest(); @@ -230,6 +231,16 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { const sleep = (ms: number) => Effect.promise(() => new Promise((resolve) => setTimeout(resolve, ms))); +for (const workspaceRoot of [ + "/tmp/project", + "/tmp/project-claude", + "/tmp/project-send-turn", + "/tmp/project-claude-send-turn", + "/tmp/project-claude-start", +]) { + fs.mkdirSync(workspaceRoot, { recursive: true }); +} + function makeProviderServiceLayer() { const codex = makeFakeCodexAdapter(); const claude = makeFakeCodexAdapter("claudeAgent"); @@ -681,6 +692,46 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("fails recovery cleanly when the persisted workspace has been deleted", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "provider-missing-cwd-")); + + const initial = yield* provider.startSession(asThreadId("thread-missing-cwd"), { + provider: "codex", + threadId: asThreadId("thread-missing-cwd"), + cwd: workspaceRoot, + runtimeMode: "full-access", + }); + + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + clearWorkspacePathStateCache(workspaceRoot); + yield* routing.codex.stopAll(); + routing.codex.startSession.mockClear(); + routing.codex.sendTurn.mockClear(); + + const exit = yield* Effect.exit( + provider.sendTurn({ + threadId: initial.threadId, + input: "resume", + attachments: [], + }), + ); + + assert.equal(routing.codex.startSession.mock.calls.length, 0); + assert.equal(routing.codex.sendTurn.mock.calls.length, 0); + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.equal(Schema.is(ProviderValidationError)(error), true); + assert.equal( + (error as ProviderValidationError).message.includes("Workspace folder is missing"), + true, + ); + } + }), + ); + it.effect("recovers stale claudeAgent sessions for sendTurn using persisted cwd", () => Effect.gen(function* () { const provider = yield* ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 0137152e83..9ae80e75a9 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -34,6 +34,7 @@ import { import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { assertWorkspaceDirectory } from "../../workspacePaths.ts"; export interface ProviderServiceLiveOptions { readonly canonicalEventLogPath?: string; @@ -228,6 +229,21 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const persistedCwd = readPersistedCwd(input.binding.runtimePayload); const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); + if (persistedCwd) { + yield* assertWorkspaceDirectory( + persistedCwd, + "ProviderService.recoverSessionForThread", + ).pipe( + Effect.mapError((error) => + toValidationError( + input.operation, + `Cannot recover thread '${input.binding.threadId}'. ${error.message}`, + error, + ), + ), + ); + } + const resumed = yield* adapter.startSession({ threadId: input.binding.threadId, provider: input.binding.provider, @@ -315,6 +331,13 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => (persistedBinding?.provider === input.provider ? persistedBinding.resumeCursor : undefined); + if (input.cwd) { + yield* assertWorkspaceDirectory(input.cwd, "ProviderService.startSession").pipe( + Effect.mapError((error) => + toValidationError("ProviderService.startSession", error.message, error), + ), + ); + } const adapter = yield* registry.getByProvider(input.provider); const session = yield* adapter.startSession({ ...input, diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index b5085220c2..1c3517d24c 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -27,6 +27,7 @@ import { TerminalSessionState, TerminalStartInput, } from "../Services/Manager"; +import { inspectWorkspacePathState, WorkspacePathError } from "../../workspacePaths.ts"; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; @@ -1242,17 +1243,13 @@ export class TerminalManagerRuntime extends EventEmitter } private async assertValidCwd(cwd: string): Promise { - let stats: fs.Stats; - try { - stats = await fs.promises.stat(cwd); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - throw new Error(`Terminal cwd does not exist: ${cwd}`, { cause: error }); - } - throw error; - } - if (!stats.isDirectory()) { - throw new Error(`Terminal cwd is not a directory: ${cwd}`); + const state = await inspectWorkspacePathState(cwd); + if (state !== "available") { + throw new WorkspacePathError({ + operation: "TerminalManager.assertValidCwd", + path: cwd, + state, + }); } } diff --git a/apps/server/src/workspacePaths.ts b/apps/server/src/workspacePaths.ts new file mode 100644 index 0000000000..28fe79f923 --- /dev/null +++ b/apps/server/src/workspacePaths.ts @@ -0,0 +1,100 @@ +import fs from "node:fs/promises"; + +import { type WorkspaceAvailabilityState } from "@t3tools/contracts"; +import { Effect, Schema } from "effect"; + +const WORKSPACE_PATH_CACHE_TTL_MS = 1_000; + +type CachedWorkspacePathState = { + readonly expiresAt: number; + readonly state: WorkspaceAvailabilityState; +}; + +const workspacePathStateCache = new Map(); + +export class WorkspacePathError extends Schema.TaggedErrorClass()( + "WorkspacePathError", + { + operation: Schema.String, + path: Schema.String, + state: Schema.Literals(["missing", "not_directory", "inaccessible"]), + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + switch (this.state) { + case "missing": + return `Workspace folder is missing: ${this.path}`; + case "not_directory": + return `Workspace path is not a folder: ${this.path}`; + case "inaccessible": + default: + return `Workspace folder is inaccessible: ${this.path}`; + } + } +} + +export function clearWorkspacePathStateCache(path?: string): void { + if (path) { + workspacePathStateCache.delete(path); + return; + } + workspacePathStateCache.clear(); +} + +export async function inspectWorkspacePathState(path: string): Promise { + const cached = workspacePathStateCache.get(path); + const now = Date.now(); + if (cached && cached.expiresAt > now) { + return cached.state; + } + + let state: WorkspaceAvailabilityState; + try { + const stat = await fs.stat(path); + state = stat.isDirectory() ? "available" : "not_directory"; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + state = code === "ENOENT" ? "missing" : "inaccessible"; + } + + workspacePathStateCache.set(path, { + expiresAt: now + WORKSPACE_PATH_CACHE_TTL_MS, + state, + }); + return state; +} + +export const getWorkspacePathState = (path: string) => + Effect.promise(() => inspectWorkspacePathState(path)); + +export const assertWorkspaceDirectory = (path: string, operation: string) => + getWorkspacePathState(path).pipe( + Effect.flatMap((state) => + state === "available" + ? Effect.succeed(path) + : Effect.fail( + new WorkspacePathError({ + operation, + path, + state, + }), + ), + ), + ); + +export function resolveWorkspaceUnavailableReason( + state: WorkspaceAvailabilityState, +): string | null { + switch (state) { + case "missing": + return "Workspace folder is missing."; + case "not_directory": + return "Workspace path is not a folder."; + case "inaccessible": + return "Workspace folder is inaccessible."; + case "available": + default: + return null; + } +} diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index c04d913d52..ba034cdbce 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -79,6 +79,7 @@ import { expandHomePath } from "./os-jank.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; +import { assertWorkspaceDirectory, WorkspacePathError } from "./workspacePaths.ts"; /** * ServerShape - Service API for server lifecycle control. @@ -233,6 +234,22 @@ class RouteRequestError extends Schema.TaggedErrorClass()("Ro message: Schema.String, }) {} +function formatOperationalError(cause: Cause.Cause): string { + const error = Cause.squash(cause); + if ( + Schema.is(RouteRequestError)(error) || + Schema.is(WorkspacePathError)(error) || + (typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" && + error.message.trim().length > 0) + ) { + return (error as { message: string }).message; + } + return Cause.pretty(cause); +} + export const createServer = Effect.fn(function* (): Effect.fn.Return< http.Server, ServerLifecycleError, @@ -768,6 +785,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case WS_METHODS.projectsSearchEntries: { const body = stripRequestTag(request.body); + yield* assertWorkspaceDirectory(body.cwd, "ws.projects.searchEntries"); return yield* Effect.tryPromise({ try: () => searchWorkspaceEntries(body), catch: (cause) => @@ -779,6 +797,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case WS_METHODS.projectsWriteFile: { const body = stripRequestTag(request.body); + yield* assertWorkspaceDirectory(body.cwd, "ws.projects.writeFile"); const target = yield* resolveWorkspaceWritePath({ workspaceRoot: body.cwd, relativePath: body.relativePath, @@ -807,21 +826,25 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case WS_METHODS.shellOpenInEditor: { const body = stripRequestTag(request.body); + yield* assertWorkspaceDirectory(body.cwd, "ws.shell.openInEditor"); return yield* openInEditor(body); } case WS_METHODS.gitStatus: { const body = stripRequestTag(request.body); + yield* assertWorkspaceDirectory(body.cwd, "ws.git.status"); return yield* gitManager.status(body); } case WS_METHODS.gitPull: { const body = stripRequestTag(request.body); + yield* assertWorkspaceDirectory(body.cwd, "ws.git.pull"); return yield* git.pullCurrentBranch(body.cwd); } case WS_METHODS.gitRunStackedAction: { const body = stripRequestTag(request.body); + yield* assertWorkspaceDirectory(body.cwd, "ws.git.runStackedAction"); return yield* gitManager.runStackedAction(body, { actionId: body.actionId, progressReporter: { @@ -833,46 +856,55 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case WS_METHODS.gitResolvePullRequest: { const body = stripRequestTag(request.body); + yield* assertWorkspaceDirectory(body.cwd, "ws.git.resolvePullRequest"); return yield* gitManager.resolvePullRequest(body); } case WS_METHODS.gitPreparePullRequestThread: { const body = stripRequestTag(request.body); + yield* assertWorkspaceDirectory(body.cwd, "ws.git.preparePullRequestThread"); return yield* gitManager.preparePullRequestThread(body); } case WS_METHODS.gitListBranches: { const body = stripRequestTag(request.body); + yield* assertWorkspaceDirectory(body.cwd, "ws.git.listBranches"); return yield* git.listBranches(body); } case WS_METHODS.gitCreateWorktree: { const body = stripRequestTag(request.body); + yield* assertWorkspaceDirectory(body.cwd, "ws.git.createWorktree"); return yield* git.createWorktree(body); } case WS_METHODS.gitRemoveWorktree: { const body = stripRequestTag(request.body); + yield* assertWorkspaceDirectory(body.cwd, "ws.git.removeWorktree"); return yield* git.removeWorktree(body); } case WS_METHODS.gitCreateBranch: { const body = stripRequestTag(request.body); + yield* assertWorkspaceDirectory(body.cwd, "ws.git.createBranch"); return yield* git.createBranch(body); } case WS_METHODS.gitCheckout: { const body = stripRequestTag(request.body); + yield* assertWorkspaceDirectory(body.cwd, "ws.git.checkout"); return yield* Effect.scoped(git.checkoutBranch(body)); } case WS_METHODS.gitInit: { const body = stripRequestTag(request.body); + yield* assertWorkspaceDirectory(body.cwd, "ws.git.init"); return yield* git.initRepo(body); } case WS_METHODS.terminalOpen: { const body = stripRequestTag(request.body); + yield* assertWorkspaceDirectory(body.cwd, "ws.terminal.open"); return yield* terminalManager.open(body); } @@ -893,6 +925,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case WS_METHODS.terminalRestart: { const body = stripRequestTag(request.body); + yield* assertWorkspaceDirectory(body.cwd, "ws.terminal.restart"); return yield* terminalManager.restart(body); } @@ -973,7 +1006,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< if (Exit.isFailure(result)) { return yield* sendWsResponse({ id: request.success.id, - error: { message: Cause.pretty(result.cause) }, + error: { message: formatOperationalError(result.cause) }, }); } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 79c453c0f5..bd2b61ad61 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -13,6 +13,7 @@ import { } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; +import { isProjectWorkspaceAvailable, isThreadWorkspaceAvailable } from "../workspaceAvailability"; const envModeItems = [ { value: "local", label: "Local" }, @@ -46,7 +47,13 @@ export default function BranchToolbar({ const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const branchCwd = activeWorktreePath ?? activeProject?.cwd ?? null; + const branchCwd = serverThread + ? isThreadWorkspaceAvailable(serverThread) + ? serverThread.effectiveCwd + : null + : activeProject && isProjectWorkspaceAvailable(activeProject) + ? activeProject.cwd + : null; const hasServerThread = serverThread !== undefined; const effectiveEnvMode = resolveEffectiveEnvMode({ activeWorktreePath, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 06cbf0efbd..fc1f64267e 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -232,6 +232,7 @@ function createSnapshotForTargetUser(options: { id: PROJECT_ID, title: "Project", workspaceRoot: "/repo/project", + workspaceState: "available", defaultModelSelection: { provider: "codex", model: "gpt-5", @@ -255,6 +256,9 @@ function createSnapshotForTargetUser(options: { runtimeMode: "full-access", branch: "main", worktreePath: null, + effectiveCwd: "/repo/project", + effectiveCwdSource: "project", + effectiveCwdState: "available", latestTurn: null, createdAt: NOW_ISO, updatedAt: NOW_ISO, @@ -313,6 +317,9 @@ function addThreadToSnapshot( runtimeMode: "full-access", branch: "main", worktreePath: null, + effectiveCwd: "/repo/project", + effectiveCwdSource: "project", + effectiveCwdState: "available", latestTurn: null, createdAt: NOW_ISO, updatedAt: NOW_ISO, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0a27fb203e..c0168f28b9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -37,6 +37,9 @@ export function buildLocalDraftThread( lastVisitedAt: draftThread.createdAt, branch: draftThread.branch, worktreePath: draftThread.worktreePath, + effectiveCwd: draftThread.worktreePath ?? null, + effectiveCwdSource: draftThread.worktreePath ? "worktree" : null, + effectiveCwdState: "available", turnDiffSummaries: [], activities: [], proposedPlans: [], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fbd332354a..1f6b206488 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -107,7 +107,6 @@ import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { commandForProjectScript, nextProjectScriptId, - projectScriptCwd, projectScriptRuntimeEnv, projectScriptIdFromCommand, setupProjectScript, @@ -178,6 +177,12 @@ import { SendPhase, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { + isProjectWorkspaceAvailable, + isThreadWorkspaceAvailable, + workspaceUnavailableReason, +} from "../workspaceAvailability"; +import { useThreadActions } from "../hooks/useThreadActions"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -253,6 +258,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const settings = useSettings(); + const { confirmAndDeleteThread } = useThreadActions(); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); @@ -501,6 +507,16 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProject = projects.find((p) => p.id === activeThread?.projectId); + const projectWorkspaceAvailable = activeProject + ? isProjectWorkspaceAvailable(activeProject) + : false; + const threadWorkspaceAvailable = activeThread ? isThreadWorkspaceAvailable(activeThread) : false; + const missingProjectWorkspace = activeProject !== undefined && !projectWorkspaceAvailable; + const missingWorktreeWorkspace = + activeThread?.effectiveCwdSource === "worktree" && + activeThread.effectiveCwd !== null && + !threadWorkspaceAvailable && + projectWorkspaceAvailable; const openPullRequestDialog = useCallback( (reference?: string) => { @@ -1009,12 +1025,7 @@ export default function ChatView({ threadId }: ChatViewProps) { latestTurnSettled, timelineEntries, ]); - const gitCwd = activeProject - ? projectScriptCwd({ - project: { cwd: activeProject.cwd }, - worktreePath: activeThread?.worktreePath ?? null, - }) - : null; + const gitCwd = threadWorkspaceAvailable ? (activeThread?.effectiveCwd ?? null) : null; const composerTriggerKind = composerTrigger?.kind ?? null; const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; const isPathTrigger = composerTriggerKind === "path"; @@ -1024,7 +1035,7 @@ export default function ChatView({ threadId }: ChatViewProps) { (debouncerState) => ({ isPending: debouncerState.isPending }), ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; - const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); + const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd, threadWorkspaceAvailable)); const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; const modelOptionsByProvider = useMemo( @@ -1062,7 +1073,7 @@ export default function ChatView({ threadId }: ChatViewProps) { projectSearchEntriesQueryOptions({ cwd: gitCwd, query: effectivePathQuery, - enabled: isPathTrigger, + enabled: isPathTrigger && threadWorkspaceAvailable, limit: 80, }), ); @@ -1160,8 +1171,7 @@ export default function ChatView({ threadId }: ChatViewProps) { worktreePath: activeThreadWorktreePath, }); }, [activeProjectCwd, activeThreadWorktreePath]); - // Default true while loading to avoid toolbar flicker. - const isGitRepo = branchesQuery.data?.isRepo ?? true; + const isGitRepo = threadWorkspaceAvailable && (branchesQuery.data?.isRepo ?? true); const terminalShortcutLabelOptions = useMemo( () => ({ context: { @@ -1246,6 +1256,50 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [setStoreThreadError, threads], ); + const relinkProjectWorkspace = useCallback(async () => { + const api = readNativeApi(); + if (!api || !activeProject) return; + const nextWorkspaceRoot = await api.dialogs.pickFolder(); + if (!nextWorkspaceRoot) { + return; + } + await api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId: activeProject.id, + workspaceRoot: nextWorkspaceRoot, + }); + }, [activeProject]); + const useProjectRootForThread = useCallback(async () => { + const api = readNativeApi(); + if (!api || !activeThread) return; + if (activeThread.session && activeThread.session.status !== "closed") { + await api.orchestration + .dispatchCommand({ + type: "thread.session.stop", + commandId: newCommandId(), + threadId: activeThread.id, + createdAt: new Date().toISOString(), + }) + .catch(() => undefined); + } + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: activeThread.id, + branch: null, + worktreePath: null, + }); + }, [activeThread]); + const removeActiveProject = useCallback(async () => { + const api = readNativeApi(); + if (!api || !activeProject) return; + await api.orchestration.dispatchCommand({ + type: "project.delete", + commandId: newCommandId(), + projectId: activeProject.id, + }); + }, [activeProject]); const focusComposer = useCallback(() => { composerEditorRef.current?.focusAtEnd(); @@ -3569,6 +3623,18 @@ export default function ChatView({ threadId }: ChatViewProps) { ); } + const headerWorkspaceUnavailableReason = missingProjectWorkspace + ? workspaceUnavailableReason({ + state: activeProject?.workspaceState ?? "available", + kind: "project", + }) + : missingWorktreeWorkspace + ? workspaceUnavailableReason({ + state: activeThread.effectiveCwdState, + kind: "worktree", + }) + : null; + return (
{/* Top bar */} @@ -3583,14 +3649,15 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThreadTitle={activeThread.title} activeProjectName={activeProject?.name} isGitRepo={isGitRepo} + workspaceUnavailableReason={headerWorkspaceUnavailableReason} openInCwd={gitCwd} - activeProjectScripts={activeProject?.scripts} + activeProjectScripts={projectWorkspaceAvailable ? activeProject?.scripts : undefined} preferredScriptId={ activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null } keybindings={keybindings} availableEditors={availableEditors} - terminalAvailable={activeProject !== undefined} + terminalAvailable={activeProject !== undefined && threadWorkspaceAvailable} terminalOpen={terminalState.terminalOpen} terminalToggleShortcutLabel={terminalToggleShortcutLabel} diffToggleShortcutLabel={diffPanelShortcutLabel} @@ -3613,6 +3680,52 @@ export default function ChatView({ threadId }: ChatViewProps) { error={activeThread.error} onDismiss={() => setThreadError(activeThread.id, null)} /> + {missingProjectWorkspace && activeProject && ( +
+
+
+
Project folder is missing
+
+ {workspaceUnavailableReason({ + state: activeProject.workspaceState, + kind: "project", + })} +
+
+ + +
+
+ )} + {missingWorktreeWorkspace && activeThread && ( +
+
+
+
Worktree is missing
+
+ {workspaceUnavailableReason({ + state: activeThread.effectiveCwdState, + kind: "worktree", + })} +
+
+ + +
+
+ )} {/* Main content area with optional plan sidebar */}
{/* Chat column */} @@ -3656,7 +3769,9 @@ export default function ChatView({ threadId }: ChatViewProps) { markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} - workspaceRoot={activeProject?.cwd ?? undefined} + workspaceRoot={ + projectWorkspaceAvailable ? (activeProject?.cwd ?? undefined) : undefined + } />
@@ -4208,7 +4323,9 @@ export default function ChatView({ threadId }: ChatViewProps) { activePlan={activePlan} activeProposedPlan={sidebarProposedPlan} markdownCwd={gitCwd ?? undefined} - workspaceRoot={activeProject?.cwd ?? undefined} + workspaceRoot={ + projectWorkspaceAvailable ? (activeProject?.cwd ?? undefined) : undefined + } timestampFormat={timestampFormat} onClose={() => { setPlanSidebarOpen(false); @@ -4224,14 +4341,14 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* end horizontal flex container */} {(() => { - if (!terminalState.terminalOpen || !activeProject) { + if (!terminalState.terminalOpen || !activeProject || !threadWorkspaceAvailable || !gitCwd) { return null; } return ( = {}): Project { id: ProjectId.makeUnsafe("project-1"), name: "Project", cwd: "/tmp/project", + workspaceState: "available", defaultModelSelection: { provider: "codex", model: "gpt-5.4", @@ -494,6 +495,9 @@ function makeThread(overrides: Partial = {}): Thread { latestTurn: null, branch: null, worktreePath: null, + effectiveCwd: "/tmp/project", + effectiveCwdSource: "project", + effectiveCwdState: "available", turnDiffSummaries: [], activities: [], ...overrides, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 100d0e3f47..aa6eb7b4ab 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -120,6 +120,7 @@ import { import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; +import { isProjectWorkspaceAvailable, isThreadWorkspaceAvailable } from "../workspaceAvailability"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -400,15 +401,16 @@ export default function Sidebar() { threads.map((thread) => ({ threadId: thread.id, branch: thread.branch, - cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, + cwd: thread.effectiveCwd, + enabled: isThreadWorkspaceAvailable(thread), })), - [projectCwdById, threads], + [threads], ); const threadGitStatusCwds = useMemo( () => [ ...new Set( threadGitTargets - .filter((target) => target.branch !== null) + .filter((target) => target.enabled && target.branch !== null) .map((target) => target.cwd) .filter((cwd): cwd is string => cwd !== null), ), @@ -417,7 +419,7 @@ export default function Sidebar() { ); const threadGitStatusQueries = useQueries({ queries: threadGitStatusCwds.map((cwd) => ({ - ...gitStatusQueryOptions(cwd), + ...gitStatusQueryOptions(cwd, true), staleTime: 30_000, refetchInterval: 60_000, })), @@ -1206,10 +1208,12 @@ export default function Sidebar() { shouldShowThreadPanel, isThreadListExpanded, } = renderedProject; + const projectWorkspaceAvailable = isProjectWorkspaceAvailable(project); const renderThreadRow = (thread: (typeof projectThreads)[number]) => { const isActive = routeThreadId === thread.id; const isSelected = selectedThreadIds.has(thread.id); const isHighlighted = isActive || isSelected; + const threadWorkspaceAvailable = isThreadWorkspaceAvailable(thread); const jumpLabel = threadJumpLabelById.get(thread.id) ?? null; const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; @@ -1243,7 +1247,7 @@ export default function Sidebar() { className={`${resolveThreadRowClassName({ isActive, isSelected, - })} relative`} + })} relative ${threadWorkspaceAvailable ? "" : "opacity-60"}`} onClick={(event) => { handleThreadClick(event, thread.id, orderedProjectThreadIds); }} @@ -1456,7 +1460,7 @@ export default function Sidebar() { size="sm" className={`gap-2 px-2 py-1.5 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground ${ isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer" - }`} + } ${projectWorkspaceAvailable ? "" : "opacity-60"}`} {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})} {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.listeners : {})} onPointerDownCapture={handleProjectTitlePointerDownCapture} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index f04c9879fa..23e7ea3c56 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -19,6 +19,7 @@ interface ChatHeaderProps { activeThreadTitle: string; activeProjectName: string | undefined; isGitRepo: boolean; + workspaceUnavailableReason?: string | null; openInCwd: string | null; activeProjectScripts: ProjectScript[] | undefined; preferredScriptId: string | null; @@ -43,6 +44,7 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadTitle, activeProjectName, isGitRepo, + workspaceUnavailableReason, openInCwd, activeProjectScripts, preferredScriptId, @@ -76,11 +78,16 @@ export const ChatHeader = memo(function ChatHeader({ {activeProjectName} )} - {activeProjectName && !isGitRepo && ( - - No Git - - )} + {activeProjectName && + (workspaceUnavailableReason ? ( + + Workspace Missing + + ) : !isGitRepo ? ( + + No Git + + ) : null)}
{activeProjectScripts && ( @@ -120,7 +127,8 @@ export const ChatHeader = memo(function ChatHeader({ /> {!terminalAvailable - ? "Terminal is unavailable until this thread has an active project." + ? (workspaceUnavailableReason ?? + "Terminal is unavailable until this thread has an active project.") : terminalToggleShortcutLabel ? `Toggle terminal drawer (${terminalToggleShortcutLabel})` : "Toggle terminal drawer"} @@ -144,7 +152,8 @@ export const ChatHeader = memo(function ChatHeader({ /> {!isGitRepo - ? "Diff panel is unavailable because this project is not a git repository." + ? (workspaceUnavailableReason ?? + "Diff panel is unavailable because this project is not a git repository.") : diffToggleShortcutLabel ? `Toggle diff panel (${diffToggleShortcutLabel})` : "Toggle diff panel"} diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 83cfe911fc..6ce37912ef 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -14,6 +14,7 @@ import { useTerminalStateStore } from "../terminalStateStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { toastManager } from "../components/ui/toast"; import { useSettings } from "./useSettings"; +import { isProjectWorkspaceAvailable } from "../workspaceAvailability"; export function useThreadActions() { const threads = useStore((store) => store.threads); @@ -82,7 +83,10 @@ export function useThreadActions() { const displayWorktreePath = orphanedWorktreePath ? formatWorktreePathForDisplay(orphanedWorktreePath) : null; - const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; + const canDeleteWorktree = + orphanedWorktreePath !== null && + threadProject !== undefined && + isProjectWorkspaceAvailable(threadProject); const shouldDeleteWorktree = canDeleteWorktree && (await api.dialogs.confirm( diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index cfa2c72f74..c7281a6326 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -26,7 +26,7 @@ export function invalidateGitQueries(queryClient: QueryClient) { return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); } -export function gitStatusQueryOptions(cwd: string | null) { +export function gitStatusQueryOptions(cwd: string | null, enabled = true) { return queryOptions({ queryKey: gitQueryKeys.status(cwd), queryFn: async () => { @@ -34,15 +34,15 @@ export function gitStatusQueryOptions(cwd: string | null) { if (!cwd) throw new Error("Git status is unavailable."); return api.git.status({ cwd }); }, - enabled: cwd !== null, + enabled: enabled && cwd !== null, staleTime: GIT_STATUS_STALE_TIME_MS, - refetchOnWindowFocus: "always", - refetchOnReconnect: "always", + refetchOnWindowFocus: false, + refetchOnReconnect: false, refetchInterval: GIT_STATUS_REFETCH_INTERVAL_MS, }); } -export function gitBranchesQueryOptions(cwd: string | null) { +export function gitBranchesQueryOptions(cwd: string | null, enabled = true) { return queryOptions({ queryKey: gitQueryKeys.branches(cwd), queryFn: async () => { @@ -50,10 +50,10 @@ export function gitBranchesQueryOptions(cwd: string | null) { if (!cwd) throw new Error("Git branches are unavailable."); return api.git.listBranches({ cwd }); }, - enabled: cwd !== null, + enabled: enabled && cwd !== null, staleTime: GIT_BRANCHES_STALE_TIME_MS, - refetchOnWindowFocus: true, - refetchOnReconnect: true, + refetchOnWindowFocus: false, + refetchOnReconnect: false, refetchInterval: GIT_BRANCHES_REFETCH_INTERVAL_MS, }); } @@ -61,6 +61,7 @@ export function gitBranchesQueryOptions(cwd: string | null) { export function gitResolvePullRequestQueryOptions(input: { cwd: string | null; reference: string | null; + enabled?: boolean; }) { return queryOptions({ queryKey: ["git", "pull-request", input.cwd, input.reference] as const, @@ -71,7 +72,7 @@ export function gitResolvePullRequestQueryOptions(input: { } return api.git.resolvePullRequest({ cwd: input.cwd, reference: input.reference }); }, - enabled: input.cwd !== null && input.reference !== null, + enabled: (input.enabled ?? true) && input.cwd !== null && input.reference !== null, staleTime: 30_000, refetchOnWindowFocus: false, refetchOnReconnect: false, diff --git a/apps/web/src/lib/projectReactQuery.ts b/apps/web/src/lib/projectReactQuery.ts index 20aa265b87..25711d5187 100644 --- a/apps/web/src/lib/projectReactQuery.ts +++ b/apps/web/src/lib/projectReactQuery.ts @@ -39,5 +39,7 @@ export function projectSearchEntriesQueryOptions(input: { enabled: (input.enabled ?? true) && input.cwd !== null && input.query.length > 0, staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME, placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT, + refetchOnWindowFocus: false, + refetchOnReconnect: false, }); } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index e99ec50226..1cfb93355d 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -26,6 +26,7 @@ import { migrateLocalSettingsToServer } from "../hooks/useSettings"; import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; +import { gitQueryKeys } from "../lib/gitReactQuery"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -171,6 +172,10 @@ function EventRouter() { draftThreadIds, }); removeOrphanedTerminalStates(activeThreadIds); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }), + queryClient.invalidateQueries({ queryKey: projectQueryKeys.all }), + ]); if (pending) { pending = false; await flushSnapshotSync(); @@ -261,6 +266,14 @@ function EventRouter() { handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; })().catch(() => undefined); }); + const handleWorkspaceRefresh = () => { + if (document.visibilityState === "hidden") { + return; + } + void syncSnapshot(); + }; + window.addEventListener("focus", handleWorkspaceRefresh); + document.addEventListener("visibilitychange", handleWorkspaceRefresh); // onServerConfigUpdated replays the latest cached value synchronously // during subscribe. Skip the toast for that replay so effect re-runs // don't produce duplicate toasts. @@ -323,6 +336,8 @@ function EventRouter() { unsubDomainEvent(); unsubTerminalEvent(); unsubWelcome(); + window.removeEventListener("focus", handleWorkspaceRefresh); + document.removeEventListener("visibilitychange", handleWorkspaceRefresh); unsubServerConfigUpdated(); unsubProvidersUpdated(); }; diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index db62bad523..97dd7d7b78 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -33,6 +33,9 @@ function makeThread(overrides: Partial = {}): Thread { latestTurn: null, branch: null, worktreePath: null, + effectiveCwd: "/tmp/project", + effectiveCwdSource: "project", + effectiveCwdState: "available", ...overrides, }; } @@ -44,6 +47,7 @@ function makeState(thread: Thread): AppState { id: ProjectId.makeUnsafe("project-1"), name: "Project", cwd: "/tmp/project", + workspaceState: "available", defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -70,6 +74,9 @@ function makeReadModelThread(overrides: Partial { id: project1, name: "Project 1", cwd: "/tmp/project-1", + workspaceState: "available", defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, @@ -187,6 +197,7 @@ describe("store pure functions", () => { id: project2, name: "Project 2", cwd: "/tmp/project-2", + workspaceState: "available", defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, @@ -198,6 +209,7 @@ describe("store pure functions", () => { id: project3, name: "Project 3", cwd: "/tmp/project-3", + workspaceState: "available", defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, @@ -297,6 +309,7 @@ describe("store read model sync", () => { id: project2, name: "Project 2", cwd: "/tmp/project-2", + workspaceState: "available", defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, @@ -308,6 +321,7 @@ describe("store read model sync", () => { id: project1, name: "Project 1", cwd: "/tmp/project-1", + workspaceState: "available", defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index a5beb5b1bf..263138a511 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -132,6 +132,7 @@ function mapProjectsFromReadModel( id: project.id, name: project.title, cwd: project.workspaceRoot, + workspaceState: project.workspaceState, defaultModelSelection: existing?.defaultModelSelection ?? (project.defaultModelSelection @@ -305,6 +306,9 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, branch: thread.branch, worktreePath: thread.worktreePath, + effectiveCwd: thread.effectiveCwd, + effectiveCwdSource: thread.effectiveCwdSource, + effectiveCwdState: thread.effectiveCwdState, turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({ turnId: checkpoint.turnId, completedAt: checkpoint.completedAt, diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index e6cb1efea6..3deafb1004 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -13,6 +13,8 @@ import type { CheckpointRef, ProviderInteractionMode, RuntimeMode, + WorkspaceAvailabilityState, + ThreadEffectiveCwdSource, } from "@t3tools/contracts"; export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; @@ -81,6 +83,7 @@ export interface Project { id: ProjectId; name: string; cwd: string; + workspaceState: WorkspaceAvailabilityState; defaultModelSelection: ModelSelection | null; expanded: boolean; createdAt?: string | undefined; @@ -107,6 +110,9 @@ export interface Thread { lastVisitedAt?: string | undefined; branch: string | null; worktreePath: string | null; + effectiveCwd: string | null; + effectiveCwdSource: ThreadEffectiveCwdSource | null; + effectiveCwdState: WorkspaceAvailabilityState; turnDiffSummaries: TurnDiffSummary[]; activities: OrchestrationThreadActivity[]; } diff --git a/apps/web/src/workspaceAvailability.ts b/apps/web/src/workspaceAvailability.ts new file mode 100644 index 0000000000..962b7c0b1f --- /dev/null +++ b/apps/web/src/workspaceAvailability.ts @@ -0,0 +1,35 @@ +import type { Project, Thread } from "./types"; + +export function isWorkspaceStateAvailable( + state: Project["workspaceState"] | Thread["effectiveCwdState"], +): boolean { + return state === "available"; +} + +export function isProjectWorkspaceAvailable(project: Pick): boolean { + return isWorkspaceStateAvailable(project.workspaceState); +} + +export function isThreadWorkspaceAvailable(thread: Pick): boolean { + return isWorkspaceStateAvailable(thread.effectiveCwdState); +} + +export function workspaceUnavailableReason(input: { + state: Project["workspaceState"] | Thread["effectiveCwdState"]; + kind: "project" | "worktree"; +}): string | null { + if (input.state === "available") { + return null; + } + + const subject = input.kind === "project" ? "Project folder" : "Worktree"; + switch (input.state) { + case "missing": + return `${subject} is missing.`; + case "not_directory": + return `${subject} path is not a folder.`; + case "inaccessible": + default: + return `${subject} is inaccessible.`; + } +} diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 723661ccbb..b22328f540 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -27,6 +27,9 @@ function makeThread(overrides: Partial = {}): Thread { latestTurn: null, branch: null, worktreePath: null, + effectiveCwd: "/tmp/project", + effectiveCwdSource: "project", + effectiveCwdState: "available", ...overrides, }; } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index a780a55c78..20f97cdc34 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -141,10 +141,24 @@ export const ProjectScript = Schema.Struct({ }); export type ProjectScript = typeof ProjectScript.Type; +export const WorkspaceAvailabilityState = Schema.Literals([ + "available", + "missing", + "not_directory", + "inaccessible", +]); +export type WorkspaceAvailabilityState = typeof WorkspaceAvailabilityState.Type; + +export const ThreadEffectiveCwdSource = Schema.Literals(["project", "worktree"]); +export type ThreadEffectiveCwdSource = typeof ThreadEffectiveCwdSource.Type; + export const OrchestrationProject = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + workspaceState: WorkspaceAvailabilityState.pipe( + Schema.withDecodingDefault(() => "available" as const), + ), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, @@ -281,6 +295,13 @@ export const OrchestrationThread = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + effectiveCwd: Schema.NullOr(TrimmedNonEmptyString).pipe(Schema.withDecodingDefault(() => null)), + effectiveCwdSource: Schema.NullOr(ThreadEffectiveCwdSource).pipe( + Schema.withDecodingDefault(() => null), + ), + effectiveCwdState: WorkspaceAvailabilityState.pipe( + Schema.withDecodingDefault(() => "available" as const), + ), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime,