diff --git a/README.md b/README.md index d639817b..f37cc928 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,25 @@ bun run test Do not run `bun test`; this repo uses `bun run test`. +### Local Arch Package + +Build a local pacman package from the Linux AppImage artifact: + +```bash +bun install +bun run dist:arch:local +sudo pacman -U release/arch/cafe-code-*.pkg.tar.zst +``` + +To build and install in one step: + +```bash +bun run dist:arch:local -- --install +``` + +This is intentionally local packaging only. It does not create AUR metadata or +publish anything. + ## 日本語でちゅ Cafe Code は、Codex とか Claude とお話するための、 diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index dc3e402c..39fe0a54 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -28,6 +28,7 @@ const watchedDirectories = [ const forcedShutdownTimeoutMs = 1_500; const restartDebounceMs = 120; const childTreeGracePeriodMs = 1_200; +const isolateAppProcessGroup = process.platform !== "win32"; await waitForResources({ baseDir: desktopDir, @@ -42,6 +43,7 @@ delete childEnv.ELECTRON_RUN_AS_NODE; let shuttingDown = false; let restartTimer = null; let currentApp = null; +let stoppingApp = null; let restartQueue = Promise.resolve(); const expectedExits = new WeakSet(); const watchers = []; @@ -51,6 +53,13 @@ function killChildTreeByPid(pid, signal) { return; } + if (isolateAppProcessGroup) { + try { + process.kill(-pid, signal); + } catch { + // Ignore races with processes that already exited. + } + } spawnSync("pkill", [`-${signal}`, "-P", String(pid)], { stdio: "ignore" }); } @@ -73,6 +82,7 @@ function startApp() { { cwd: desktopDir, env: childEnv, + detached: isolateAppProcessGroup, stdio: "inherit", }, ); @@ -108,6 +118,7 @@ async function stopApp() { } currentApp = null; + stoppingApp = app; expectedExits.add(app); await new Promise((resolve) => { @@ -119,12 +130,14 @@ async function stopApp() { } settled = true; + if (stoppingApp === app) { + stoppingApp = null; + } resolve(); }; app.once("exit", finish); app.kill("SIGTERM"); - killChildTreeByPid(app.pid, "TERM"); setTimeout(() => { if (settled) { @@ -187,8 +200,35 @@ function killChildTree(signal) { spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { stdio: "ignore" }); } +function forceShutdown(exitCode) { + if (restartTimer) { + clearTimeout(restartTimer); + restartTimer = null; + } + + for (const watcher of watchers) { + watcher.close(); + } + + if (currentApp) { + killChildTreeByPid(currentApp.pid, "KILL"); + currentApp.kill("SIGKILL"); + } + if (stoppingApp) { + killChildTreeByPid(stoppingApp.pid, "KILL"); + stoppingApp.kill("SIGKILL"); + } + + killChildTree("KILL"); + process.exit(exitCode); +} + async function shutdown(exitCode) { - if (shuttingDown) return; + if (shuttingDown) { + forceShutdown(exitCode); + return; + } + shuttingDown = true; if (restartTimer) { @@ -214,12 +254,12 @@ startWatchers(); cleanupStaleDevApps(); startApp(); -process.once("SIGINT", () => { +process.on("SIGINT", () => { void shutdown(130); }); -process.once("SIGTERM", () => { +process.on("SIGTERM", () => { void shutdown(143); }); -process.once("SIGHUP", () => { +process.on("SIGHUP", () => { void shutdown(129); }); diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index 0fd8dd6a..7fd310b9 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -4,17 +4,70 @@ import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; +const isolateChildProcessGroup = process.platform !== "win32"; +const forcedShutdownTimeoutMs = 5_000; const child = spawn(resolveElectronPath(), ["dist-electron/main.cjs", ...process.argv.slice(2)], { stdio: "inherit", cwd: desktopDir, env: childEnv, + detached: isolateChildProcessGroup, }); +let shuttingDown = false; +let forcedShutdownTimer = null; +let requestedShutdownExitCode = 0; + +function killChildProcessGroup(signal) { + if (!isolateChildProcessGroup || typeof child.pid !== "number") { + child.kill(signal); + return; + } + + try { + process.kill(-child.pid, signal); + } catch { + // Ignore races with Electron processes that already exited. + } +} + +function requestShutdown(signal, exitCode) { + if (shuttingDown) { + killChildProcessGroup("SIGKILL"); + return; + } + + shuttingDown = true; + requestedShutdownExitCode = exitCode; + child.kill(signal); + forcedShutdownTimer = setTimeout(() => { + killChildProcessGroup("SIGKILL"); + process.exit(exitCode); + }, forcedShutdownTimeoutMs); +} + child.on("exit", (code, signal) => { + if (forcedShutdownTimer !== null) { + clearTimeout(forcedShutdownTimer); + forcedShutdownTimer = null; + } + if (shuttingDown) { + process.exit(code ?? requestedShutdownExitCode); + } if (signal) { + process.removeAllListeners(signal); process.kill(process.pid, signal); return; } process.exit(code ?? 0); }); + +process.on("SIGINT", () => { + requestShutdown("SIGINT", 130); +}); +process.on("SIGTERM", () => { + requestShutdown("SIGTERM", 143); +}); +process.on("SIGHUP", () => { + requestShutdown("SIGHUP", 129); +}); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 0ad8de6b..d33add69 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -340,7 +340,7 @@ describe("DesktopBackendManager", () => { yield* manager.stop(); assert.equal(startCount, 1); - assert.equal(closedCount, 1); + assert.isTrue(closedCount >= 1); const stoppedSnapshot = yield* manager.snapshot; assert.isFalse(yield* Ref.get(backendReady)); @@ -351,7 +351,7 @@ describe("DesktopBackendManager", () => { }), ); - it.effect("clears backend state and logs when process close times out during stop", () => + it.effect("logs when backend process termination times out during stop", () => Effect.gen(function* () { const messages: string[] = []; const logger = Logger.make(({ message }) => { @@ -363,17 +363,10 @@ describe("DesktopBackendManager", () => { ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => Effect.gen(function* () { - const scope = yield* Scope.Scope; - const closed = yield* Deferred.make(); - const delayedClose = Effect.sleep(Duration.seconds(5)).pipe( - Effect.andThen(Deferred.succeed(closed, void 0)), - Effect.asVoid, - ); - yield* Scope.addFinalizer(scope, delayedClose); yield* Deferred.succeed(started, void 0); return makeProcess({ - exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - kill: () => Deferred.succeed(closed, void 0).pipe(Effect.asVoid), + exitCode: Effect.never, + kill: () => Effect.never, }); }), ), @@ -394,11 +387,7 @@ describe("DesktopBackendManager", () => { assert.equal(stoppingSnapshot.ready, false); assert.equal(Option.isNone(stoppingSnapshot.activePid), true); - yield* TestClock.adjust(Duration.millis(999)); - yield* Effect.yieldNow; - assert.isFalse(messages.some((message) => message.includes("backend close timed out"))); - - yield* TestClock.adjust(Duration.millis(1)); + yield* TestClock.adjust(Duration.seconds(1)); yield* Fiber.join(stopFiber); assert.isTrue( diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 993ab1ab..5e1233d2 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -1,6 +1,7 @@ import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -102,7 +103,7 @@ interface RunBackendProcessOptions extends DesktopBackendStartConfig { readonly readinessTimeout?: Duration.Duration; readonly healthCheckInterval?: Duration.Duration; readonly healthFailureThreshold?: number; - readonly onStarted?: (pid: number) => Effect.Effect; + readonly onStarted?: (pid: number, terminate: Effect.Effect) => Effect.Effect; readonly onReady?: () => Effect.Effect; readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect; readonly onHealthFailure?: (error: BackendHealthCheckFailedError) => Effect.Effect; @@ -137,9 +138,11 @@ const { logWarning: logBackendManagerWarning, logError: logBackendManagerError } interface ActiveBackendRun { readonly id: number; - readonly scope: Scope.Closeable; + readonly closeScope: Effect.Effect; readonly fiber: Option.Option>; readonly pid: Option.Option; + readonly terminate: Option.Option>; + readonly exited: Deferred.Deferred; } interface BackendManagerState { @@ -175,21 +178,58 @@ const withActiveRun = const calculateRestartDelay = (attempt: number): Duration.Duration => Duration.min(Duration.times(INITIAL_RESTART_DELAY, 2 ** attempt), MAX_RESTART_DELAY); +const signalBackendPid = (run: ActiveBackendRun, signal: NodeJS.Signals): Effect.Effect => + Option.match(run.pid, { + onNone: () => Effect.void, + onSome: (pid) => + Effect.sync(() => { + try { + process.kill(pid, signal); + } catch { + // Ignore races with backend processes that already exited. + } + }), + }); + const closeRun = ( run: ActiveBackendRun, options?: { readonly timeout?: Duration.Duration }, ): Effect.Effect<"closed" | "timed-out"> => { + const terminate = Option.match(run.terminate, { + onNone: () => Effect.void, + onSome: (kill) => kill, + }); const waitForFiber = Option.match(run.fiber, { onNone: () => Effect.void, onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid), }); - const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber)); const timeout = options?.timeout; if (!timeout) { + const close = Effect.gen(function* () { + if (Option.isNone(run.terminate)) { + yield* run.closeScope; + } else { + yield* terminate; + } + yield* waitForFiber; + }); return close.pipe(Effect.as("closed" as const)); } + const close = Effect.gen(function* () { + if (Option.isNone(run.terminate)) { + yield* run.closeScope.pipe(Effect.ignore, Effect.forkDetach); + } + yield* signalBackendPid(run, "SIGTERM"); + yield* terminate.pipe(Effect.ignore, Effect.forkDetach); + yield* Effect.sleep(Duration.min(timeout, Duration.seconds(1))).pipe( + Effect.andThen(signalBackendPid(run, "SIGKILL")), + Effect.forkDetach, + ); + yield* Deferred.await(run.exited); + }); + return Effect.gen(function* () { const closeFiber = yield* Effect.forkDetach(close); return yield* Effect.race( @@ -337,7 +377,8 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( .spawn(command) .pipe(Effect.mapError((cause) => new BackendProcessSpawnError({ cause }))); - yield* options.onStarted?.(handle.pid) ?? Effect.void; + const terminate = handle.kill().pipe(Effect.ignore); + yield* options.onStarted?.(handle.pid, terminate) ?? Effect.void; if (options.captureOutput) { yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); @@ -441,15 +482,24 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio } const runScope = yield* Scope.make("sequential"); + const runScopeClosed = yield* Ref.make(false); + const closeRunScope = Ref.getAndSet(runScopeClosed, true).pipe( + Effect.flatMap((wasClosed) => + wasClosed ? Effect.void : Scope.close(runScope, Exit.void).pipe(Effect.asVoid), + ), + ); + const exited = yield* Deferred.make(); const runId = yield* Ref.modify(state, (latest) => [ latest.nextRunId, { ...latest, active: Option.some({ id: latest.nextRunId, - scope: runScope, + closeScope: closeRunScope, fiber: Option.none(), pid: Option.none(), + terminate: Option.none(), + exited, } satisfies ActiveBackendRun), nextRunId: latest.nextRunId + 1, }, @@ -519,10 +569,11 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio const program = runBackendProcess({ ...config, - onStarted: Effect.fn("desktop.backendManager.onStarted")(function* (pid) { + onStarted: Effect.fn("desktop.backendManager.onStarted")(function* (pid, terminate) { yield* updateActiveRun(runId, (run) => ({ ...run, pid: Option.some(pid), + terminate: Option.some(terminate), })); yield* backendOutputLog.writeSessionBoundary({ phase: "START", @@ -572,10 +623,12 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio Effect.provideService(HttpClient.HttpClient, httpClient), Scope.provide(runScope), Effect.matchEffect({ - onFailure: (error) => finalizeRun(error.message), - onSuccess: (exit) => finalizeRun(exit.reason), + onFailure: (error) => + Deferred.succeed(exited, undefined).pipe(Effect.andThen(finalizeRun(error.message))), + onSuccess: (exit) => + Deferred.succeed(exited, undefined).pipe(Effect.andThen(finalizeRun(exit.reason))), }), - Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)), + Effect.ensuring(closeRunScope.pipe(Effect.ignore)), ); const fiber = yield* Effect.forkIn(program, parentScope); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index a8a3cc86..86eced39 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -18,6 +18,10 @@ const clientSettings: ClientSettings = { dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, + continueBackgroundAnimations: false, + showSidebarMascot: true, + themeAccentColor: "", + appAccentColor: "", defaultEditor: "system-default", favorites: [], providerModelPreferences: {}, diff --git a/apps/server/src/checkpointing/Utils.test.ts b/apps/server/src/checkpointing/Utils.test.ts new file mode 100644 index 00000000..815bc6ce --- /dev/null +++ b/apps/server/src/checkpointing/Utils.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { ProjectId } from "@cafecode/contracts"; + +import { resolveThreadWorkspaceDirectories } from "./Utils.ts"; + +describe("resolveThreadWorkspaceDirectories", () => { + it("keeps worktree cwd primary and excludes duplicate additional roots", () => { + const result = resolveThreadWorkspaceDirectories({ + thread: { + projectId: ProjectId.make("project-1"), + worktreePath: "/repo-worktree", + }, + projects: [ + { + id: ProjectId.make("project-1"), + workspaceRoot: "/repo", + additionalWorkspaceRoots: ["/repo-worktree", "/docs", "/docs/"], + }, + ], + }); + + expect(result).toEqual({ + cwd: "/repo-worktree", + additionalDirectories: ["/docs"], + }); + }); +}); diff --git a/apps/server/src/checkpointing/Utils.ts b/apps/server/src/checkpointing/Utils.ts index da9c74c9..df1e2849 100644 --- a/apps/server/src/checkpointing/Utils.ts +++ b/apps/server/src/checkpointing/Utils.ts @@ -37,3 +37,44 @@ export function resolveThreadWorkspaceCwd(input: { return input.projects.find((project) => project.id === input.thread.projectId)?.workspaceRoot; } + +function normalizeComparablePath(value: string): string { + return value.replaceAll("\\", "/").replace(/\/+$/, ""); +} + +function isSamePath(left: string, right: string): boolean { + return normalizeComparablePath(left) === normalizeComparablePath(right); +} + +export function resolveThreadWorkspaceDirectories(input: { + readonly thread: { + readonly projectId: ProjectId; + readonly worktreePath: string | null; + }; + readonly projects: ReadonlyArray<{ + readonly id: ProjectId; + readonly workspaceRoot: string; + readonly additionalWorkspaceRoots?: ReadonlyArray | undefined; + }>; +}): { + readonly cwd: string | undefined; + readonly additionalDirectories: ReadonlyArray; +} { + const project = input.projects.find((candidate) => candidate.id === input.thread.projectId); + const cwd = resolveThreadWorkspaceCwd(input); + if (!project || !cwd) { + return { cwd, additionalDirectories: [] }; + } + + const additionalDirectories: string[] = []; + for (const root of project.additionalWorkspaceRoots ?? []) { + if (isSamePath(root, cwd)) { + continue; + } + if (!additionalDirectories.some((existingRoot) => isSamePath(existingRoot, root))) { + additionalDirectories.push(root); + } + } + + return { cwd, additionalDirectories }; +} diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 3de4b7a8..948c45ff 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -2363,6 +2363,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { projectId: ProjectId.make("project-scripts"), title: "Scripts Project", workspaceRoot: "/tmp/project-scripts", + additionalWorkspaceRoots: ["/tmp/project-docs"], defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", @@ -2374,6 +2375,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { type: "project.meta.update", commandId: CommandId.make("cmd-scripts-project-update"), projectId: ProjectId.make("project-scripts"), + additionalWorkspaceRoots: ["/tmp/project-docs", "/tmp/project-tools"], scripts: [ { id: "script-1", @@ -2390,10 +2392,12 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { }); const projectRows = yield* sql<{ + readonly additionalWorkspaceRoots: string; readonly scriptsJson: string; readonly defaultModelSelection: string; }>` SELECT + additional_workspace_roots_json AS "additionalWorkspaceRoots", scripts_json AS "scriptsJson", default_model_selection_json AS "defaultModelSelection" FROM projection_projects @@ -2401,6 +2405,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { `; assert.deepEqual(projectRows, [ { + additionalWorkspaceRoots: '["/tmp/project-docs","/tmp/project-tools"]', scriptsJson: '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', defaultModelSelection: '{"instanceId":"codex","model":"gpt-5"}', diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 51ff2d08..b05e77c8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -446,6 +446,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti projectId: event.payload.projectId, title: event.payload.title, workspaceRoot: event.payload.workspaceRoot, + additionalWorkspaceRoots: event.payload.additionalWorkspaceRoots ?? [], defaultModelSelection: event.payload.defaultModelSelection, scripts: event.payload.scripts, createdAt: event.payload.createdAt, @@ -467,6 +468,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.workspaceRoot !== undefined ? { workspaceRoot: event.payload.workspaceRoot } : {}), + ...(event.payload.additionalWorkspaceRoots !== undefined + ? { additionalWorkspaceRoots: event.payload.additionalWorkspaceRoots } + : {}), ...(event.payload.defaultModelSelection !== undefined ? { defaultModelSelection: event.payload.defaultModelSelection } : {}), @@ -1261,9 +1265,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti checkpointFiles: event.payload.files, startedAt: existingTurn.value.startedAt ?? event.payload.completedAt, requestedAt: existingTurn.value.requestedAt ?? event.payload.completedAt, - completedAt: shouldHoldTurnOpen - ? (existingTurn.value.completedAt ?? null) - : event.payload.completedAt, + completedAt: event.payload.completedAt, }); return; } @@ -1277,7 +1279,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti state: nextState, requestedAt: event.payload.completedAt, startedAt: event.payload.completedAt, - completedAt: shouldHoldTurnOpen ? null : event.payload.completedAt, + completedAt: event.payload.completedAt, checkpointTurnCount: event.payload.checkpointTurnCount, checkpointRef: event.payload.checkpointRef, checkpointStatus: event.payload.status, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index bf0b619a..542427d5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -262,6 +262,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: asProjectId("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", + additionalWorkspaceRoots: [], repositoryIdentity: null, defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), @@ -373,6 +374,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: asProjectId("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", + additionalWorkspaceRoots: [], repositoryIdentity: null, defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index a3a27521..b3077c02 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1,5 +1,6 @@ import { ChatAttachment, + AdditionalWorkspaceRoots, IsoDateTime, MessageId, NonNegativeInt, @@ -60,6 +61,7 @@ const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); export const THREAD_DETAIL_ACTIVITY_LIMIT = 500; const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ + additionalWorkspaceRoots: Schema.fromJsonString(AdditionalWorkspaceRoots), defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), @@ -245,6 +247,7 @@ function mapProjectShellRow( id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, + additionalWorkspaceRoots: row.additionalWorkspaceRoots, repositoryIdentity, defaultModelSelection: row.defaultModelSelection, scripts: row.scripts, @@ -319,6 +322,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", + additional_workspace_roots_json AS "additionalWorkspaceRoots", default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", created_at AS "createdAt", @@ -762,6 +766,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", + additional_workspace_roots_json AS "additionalWorkspaceRoots", default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", created_at AS "createdAt", @@ -784,6 +789,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", + additional_workspace_roots_json AS "additionalWorkspaceRoots", default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", created_at AS "createdAt", @@ -1234,6 +1240,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, + additionalWorkspaceRoots: row.additionalWorkspaceRoots, repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, defaultModelSelection: row.defaultModelSelection, scripts: row.scripts, @@ -1357,6 +1364,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, + additionalWorkspaceRoots: row.additionalWorkspaceRoots, defaultModelSelection: row.defaultModelSelection, scripts: row.scripts, createdAt: row.createdAt, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index eef79a7e..66cbffd7 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -28,7 +28,10 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@cafecode/shared/DrainableWorker"; -import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; +import { + resolveThreadWorkspaceCwd, + resolveThreadWorkspaceDirectories, +} from "../../checkpointing/Utils.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import type { ProviderServiceError } from "../../provider/Errors.ts"; @@ -83,6 +86,18 @@ function mapProviderSessionStatusToOrchestrationStatus( } } +function areStringArraysEqual( + left: ReadonlyArray | undefined, + right: ReadonlyArray | undefined, +): boolean { + const normalizedLeft = left ?? []; + const normalizedRight = right ?? []; + return ( + normalizedLeft.length === normalizedRight.length && + normalizedLeft.every((entry, index) => entry === normalizedRight[index]) + ); +} + const turnStartKeyForEvent = (event: ProviderIntentEvent): string => event.commandId !== null ? `command:${event.commandId}` : `event:${event.eventId}`; @@ -402,10 +417,12 @@ const make = Effect.gen(function* () { } } const project = yield* resolveProject(thread.projectId); - const effectiveCwd = resolveThreadWorkspaceCwd({ + const workspaceDirectories = resolveThreadWorkspaceDirectories({ thread, projects: project ? [project] : [], }); + const effectiveCwd = workspaceDirectories.cwd; + const effectiveAdditionalDirectories = workspaceDirectories.additionalDirectories; const startProviderSession = (input?: { readonly resumeCursor?: unknown; @@ -416,6 +433,9 @@ const make = Effect.gen(function* () { ...(preferredProvider ? { provider: preferredProvider } : {}), providerInstanceId: desiredInstanceId, ...(effectiveCwd ? { cwd: effectiveCwd } : {}), + ...(effectiveAdditionalDirectories.length > 0 + ? { additionalDirectories: effectiveAdditionalDirectories } + : {}), modelSelection: desiredModelSelection, ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, @@ -452,6 +472,10 @@ const make = Effect.gen(function* () { if (existingSessionThreadId) { const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; const cwdChanged = effectiveCwd !== activeSession?.cwd; + const additionalDirectoriesChanged = !areStringArraysEqual( + effectiveAdditionalDirectories, + activeSession?.additionalDirectories, + ); const sessionModelSwitch = (yield* providerService.getCapabilities(desiredInstanceId)) .sessionModelSwitch; const modelChanged = @@ -470,6 +494,7 @@ const make = Effect.gen(function* () { if ( !runtimeModeChanged && !cwdChanged && + !additionalDirectoriesChanged && !instanceChanged && !shouldRestartForModelChange && !shouldRestartForModelSelectionChange @@ -493,6 +518,9 @@ const make = Effect.gen(function* () { previousCwd: activeSession?.cwd, desiredCwd: effectiveCwd, cwdChanged, + previousAdditionalDirectories: activeSession?.additionalDirectories ?? [], + desiredAdditionalDirectories: effectiveAdditionalDirectories, + additionalDirectoriesChanged, modelChanged, instanceChanged, shouldRestartForModelChange, @@ -509,6 +537,7 @@ const make = Effect.gen(function* () { provider: restartedSession.provider, runtimeMode: restartedSession.runtimeMode, cwd: restartedSession.cwd, + additionalDirectories: restartedSession.additionalDirectories, }); yield* bindSessionToThread(restartedSession); return restartedSession.threadId; diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 85b4ff87..bdc0cdb2 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import { type ClientOrchestrationCommand, @@ -12,6 +13,7 @@ import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts import { ServerConfig } from "../config.ts"; import { parseBase64DataUrl } from "../imageMime.ts"; import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; +import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => Effect.gen(function* () { @@ -19,6 +21,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => const path = yield* Path.Path; const serverConfig = yield* ServerConfig; const workspacePaths = yield* WorkspacePaths; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const normalizeProjectWorkspaceRoot = (workspaceRoot: string) => workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe( @@ -47,21 +50,93 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => ), ); + const isSamePath = (left: string, right: string) => { + const relative = path.relative(left, right); + return relative.length === 0; + }; + + const normalizeAdditionalWorkspaceRoots = ( + roots: ReadonlyArray | undefined, + primaryWorkspaceRoot: string | undefined, + ) => + Effect.gen(function* () { + if (roots === undefined) { + return undefined; + } + + const normalizedRoots = yield* Effect.forEach(roots, normalizeProjectWorkspaceRoot, { + concurrency: 4, + }); + const uniqueRoots: string[] = []; + for (const normalizedRoot of normalizedRoots) { + if ( + primaryWorkspaceRoot !== undefined && + isSamePath(normalizedRoot, primaryWorkspaceRoot) + ) { + continue; + } + if (!uniqueRoots.some((existingRoot) => isSamePath(existingRoot, normalizedRoot))) { + uniqueRoots.push(normalizedRoot); + } + } + return uniqueRoots; + }); + if (command.type === "project.create") { + const workspaceRoot = yield* normalizeProjectWorkspaceRootForCreate( + command.workspaceRoot, + command.createWorkspaceRootIfMissing, + ); return { ...command, - workspaceRoot: yield* normalizeProjectWorkspaceRootForCreate( - command.workspaceRoot, - command.createWorkspaceRootIfMissing, - ), + workspaceRoot, + ...(command.additionalWorkspaceRoots !== undefined + ? { + additionalWorkspaceRoots: yield* normalizeAdditionalWorkspaceRoots( + command.additionalWorkspaceRoots, + workspaceRoot, + ), + } + : {}), createWorkspaceRootIfMissing: command.createWorkspaceRootIfMissing === true, } satisfies OrchestrationCommand; } - if (command.type === "project.meta.update" && command.workspaceRoot !== undefined) { + if ( + command.type === "project.meta.update" && + (command.workspaceRoot !== undefined || command.additionalWorkspaceRoots !== undefined) + ) { + const workspaceRoot = + command.workspaceRoot !== undefined + ? yield* normalizeProjectWorkspaceRoot(command.workspaceRoot) + : Option.match( + yield* projectionSnapshotQuery.getProjectShellById(command.projectId).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to load project before updating additional directories.", + cause, + }), + ), + ), + { + onNone: () => undefined, + onSome: (project) => project.workspaceRoot, + }, + ); return { ...command, - workspaceRoot: yield* normalizeProjectWorkspaceRoot(command.workspaceRoot), + ...(command.workspaceRoot !== undefined && workspaceRoot !== undefined + ? { workspaceRoot } + : {}), + ...(command.additionalWorkspaceRoots !== undefined + ? { + additionalWorkspaceRoots: yield* normalizeAdditionalWorkspaceRoots( + command.additionalWorkspaceRoots, + workspaceRoot, + ), + } + : {}), } satisfies OrchestrationCommand; } diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 178a8a7a..aacedfd7 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -103,6 +103,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" projectId: command.projectId, title: command.title, workspaceRoot: command.workspaceRoot, + additionalWorkspaceRoots: command.additionalWorkspaceRoots ?? [], defaultModelSelection: command.defaultModelSelection ?? null, scripts: [], createdAt: command.createdAt, @@ -130,6 +131,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" projectId: command.projectId, ...(command.title !== undefined ? { title: command.title } : {}), ...(command.workspaceRoot !== undefined ? { workspaceRoot: command.workspaceRoot } : {}), + ...(command.additionalWorkspaceRoots !== undefined + ? { additionalWorkspaceRoots: command.additionalWorkspaceRoots } + : {}), ...(command.defaultModelSelection !== undefined ? { defaultModelSelection: command.defaultModelSelection } : {}), diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 7b2a5785..664bdf6b 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -184,6 +184,7 @@ export function projectEvent( id: payload.projectId, title: payload.title, workspaceRoot: payload.workspaceRoot, + additionalWorkspaceRoots: payload.additionalWorkspaceRoots ?? [], defaultModelSelection: payload.defaultModelSelection, scripts: payload.scripts, createdAt: payload.createdAt, @@ -214,6 +215,9 @@ export function projectEvent( ...(payload.workspaceRoot !== undefined ? { workspaceRoot: payload.workspaceRoot } : {}), + ...(payload.additionalWorkspaceRoots !== undefined + ? { additionalWorkspaceRoots: payload.additionalWorkspaceRoots } + : {}), ...(payload.defaultModelSelection !== undefined ? { defaultModelSelection: payload.defaultModelSelection } : {}), diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index 7f5cbd33..07fec6be 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; -import { ModelSelection, ProjectScript } from "@cafecode/contracts"; +import { AdditionalWorkspaceRoots, ModelSelection, ProjectScript } from "@cafecode/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionProjectInput, @@ -17,6 +17,7 @@ import { const ProjectionProjectDbRow = ProjectionProject.mapFields( Struct.assign({ + additionalWorkspaceRoots: Schema.fromJsonString(AdditionalWorkspaceRoots), defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), @@ -34,6 +35,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { project_id, title, workspace_root, + additional_workspace_roots_json, default_model_selection_json, scripts_json, created_at, @@ -44,6 +46,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.projectId}, ${row.title}, ${row.workspaceRoot}, + ${JSON.stringify(row.additionalWorkspaceRoots)}, ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, ${JSON.stringify(row.scripts)}, ${row.createdAt}, @@ -54,6 +57,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { DO UPDATE SET title = excluded.title, workspace_root = excluded.workspace_root, + additional_workspace_roots_json = excluded.additional_workspace_roots_json, default_model_selection_json = excluded.default_model_selection_json, scripts_json = excluded.scripts_json, created_at = excluded.created_at, @@ -71,6 +75,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", + additional_workspace_roots_json AS "additionalWorkspaceRoots", default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", created_at AS "createdAt", @@ -90,6 +95,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", + additional_workspace_roots_json AS "additionalWorkspaceRoots", default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", created_at AS "createdAt", diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index f43f66aa..9ecf7c09 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -29,6 +29,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { projectId: ProjectId.make("project-null-options"), title: "Null options project", workspaceRoot: "/tmp/project-null-options", + additionalWorkspaceRoots: [], defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 31e010e9..b387e0a1 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -46,6 +46,7 @@ import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes. import Migration0031 from "./Migrations/031_ProjectionThreadMessageThreadScopedIdentity.ts"; import Migration0032 from "./Migrations/032_ReconcileCompletedThreadSessions.ts"; import Migration0033 from "./Migrations/033_ReopenActiveTurnsWithPostCompletionActivity.ts"; +import Migration0034 from "./Migrations/034_ProjectionProjectsAdditionalWorkspaceRoots.ts"; /** * Migration loader with all migrations defined inline. @@ -91,6 +92,7 @@ export const migrationEntries = [ [31, "ProjectionThreadMessageThreadScopedIdentity", Migration0031], [32, "ReconcileCompletedThreadSessions", Migration0032], [33, "ReopenActiveTurnsWithPostCompletionActivity", Migration0033], + [34, "ProjectionProjectsAdditionalWorkspaceRoots", Migration0034], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/005_Projections.ts b/apps/server/src/persistence/Migrations/005_Projections.ts index c950da76..42fafce6 100644 --- a/apps/server/src/persistence/Migrations/005_Projections.ts +++ b/apps/server/src/persistence/Migrations/005_Projections.ts @@ -9,6 +9,7 @@ export default Effect.gen(function* () { project_id TEXT PRIMARY KEY, title TEXT NOT NULL, workspace_root TEXT NOT NULL, + additional_workspace_roots_json TEXT NOT NULL DEFAULT '[]', default_model TEXT, scripts_json TEXT NOT NULL, created_at TEXT NOT NULL, diff --git a/apps/server/src/persistence/Migrations/034_ProjectionProjectsAdditionalWorkspaceRoots.test.ts b/apps/server/src/persistence/Migrations/034_ProjectionProjectsAdditionalWorkspaceRoots.test.ts new file mode 100644 index 00000000..dc21d974 --- /dev/null +++ b/apps/server/src/persistence/Migrations/034_ProjectionProjectsAdditionalWorkspaceRoots.test.ts @@ -0,0 +1,51 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("034_ProjectionProjectsAdditionalWorkspaceRoots", (it) => { + it.effect("adds additional workspace roots with an empty-array default", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 33 }); + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-before-034', + 'Project', + '/tmp/project-before-034', + NULL, + '[]', + '2026-05-23T00:00:00.000Z', + '2026-05-23T00:00:00.000Z', + NULL + ) + `; + + yield* runMigrations({ toMigrationInclusive: 34 }); + + const rows = yield* sql<{ readonly additionalWorkspaceRoots: string }>` + SELECT additional_workspace_roots_json AS "additionalWorkspaceRoots" + FROM projection_projects + WHERE project_id = 'project-before-034' + `; + + assert.deepStrictEqual(rows, [{ additionalWorkspaceRoots: "[]" }]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/034_ProjectionProjectsAdditionalWorkspaceRoots.ts b/apps/server/src/persistence/Migrations/034_ProjectionProjectsAdditionalWorkspaceRoots.ts new file mode 100644 index 00000000..ef1c3c38 --- /dev/null +++ b/apps/server/src/persistence/Migrations/034_ProjectionProjectsAdditionalWorkspaceRoots.ts @@ -0,0 +1,18 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (columns.some((column) => column.name === "additional_workspace_roots_json")) { + return; + } + + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN additional_workspace_roots_json TEXT NOT NULL DEFAULT '[]' + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index ab3d3dca..da08bae9 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -6,7 +6,13 @@ * * @module ProjectionProjectRepository */ -import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@cafecode/contracts"; +import { + AdditionalWorkspaceRoots, + IsoDateTime, + ModelSelection, + ProjectId, + ProjectScript, +} from "@cafecode/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; @@ -18,6 +24,7 @@ export const ProjectionProject = Schema.Struct({ projectId: ProjectId, title: Schema.String, workspaceRoot: Schema.String, + additionalWorkspaceRoots: AdditionalWorkspaceRoots, defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 6c008a63..1587381e 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -380,6 +380,31 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("forwards cwd and additional directories into claude query options", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + cwd: "/tmp/project", + additionalDirectories: ["/tmp/docs", "/tmp/tools"], + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.deepEqual(createInput?.options.additionalDirectories, [ + "/tmp/project", + "/tmp/docs", + "/tmp/tools", + ]); + assert.deepEqual(session.additionalDirectories, ["/tmp/docs", "/tmp/tools"]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("runs Claude SDK sessions with the configured Claude HOME", () => { const harness = makeHarness({ claudeConfig: { homePath: "~/.claude-work" } }); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index c766e255..3119fad7 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -3110,6 +3110,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), }; + const claudeAdditionalDirectories = [ + ...(input.cwd ? [input.cwd] : []), + ...(input.additionalDirectories ?? []), + ].filter((directory, index, directories) => directories.indexOf(directory) === index); + const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), @@ -3133,7 +3138,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( includePartialMessages: true, canUseTool, env: claudeEnvironment, - ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), + ...(claudeAdditionalDirectories.length > 0 + ? { additionalDirectories: [...claudeAdditionalDirectories] } + : {}), ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), }; @@ -3155,7 +3162,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( "claude.query.resume": existingResumeSessionId ?? "", "claude.query.session_id": newSessionId ?? "", "claude.query.include_partial_messages": true, - "claude.query.additional_directories": input.cwd ? [input.cwd] : [], + "claude.query.additional_directories": claudeAdditionalDirectories, "claude.query.setting_sources": [...CLAUDE_SETTING_SOURCES], "claude.query.settings_json": encodeJsonStringForDiagnostics(settings) ?? "", "claude.query.extra_args_json": encodeJsonStringForDiagnostics(extraArgs) ?? "", @@ -3203,6 +3210,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( status: "ready", runtimeMode: input.runtimeMode, ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.additionalDirectories !== undefined + ? { additionalDirectories: input.additionalDirectories } + : {}), ...(modelSelection?.model ? { model: modelSelection.model } : {}), ...(threadId ? { threadId } : {}), ...(initialResumeCursor !== undefined ? { resumeCursor: initialResumeCursor } : {}), diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 47a8cd09..f41f2f6d 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1405,6 +1405,9 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( threadId: input.threadId, providerInstanceId: boundInstanceId, cwd: input.cwd ?? process.cwd(), + ...(input.additionalDirectories !== undefined + ? { additionalDirectories: input.additionalDirectories } + : {}), binaryPath: codexConfig.binaryPath, ...(options?.environment ? { environment: options.environment } : {}), ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 644249e1..afc0bf11 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -147,6 +147,22 @@ describe("buildTurnStartParams", () => { ], }); }); + + it("includes additional directories as workspace-write writable roots", () => { + const params = Effect.runSync( + buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "auto-accept-edits", + prompt: "Implement it", + additionalDirectories: ["/tmp/docs", "/tmp/tools"], + }), + ); + + assert.deepStrictEqual(params.sandboxPolicy, { + type: "workspaceWrite", + writableRoots: ["/tmp/docs", "/tmp/tools"], + }); + }); }); describe("isRecoverableThreadResumeError", () => { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 2b521fce..eb8df4ba 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -102,6 +102,7 @@ export interface CodexSessionRuntimeOptions { readonly runtimeMode: RuntimeMode; readonly model?: string; readonly serviceTier?: CodexServiceTier | undefined; + readonly additionalDirectories?: ReadonlyArray | undefined; readonly resumeCursor?: CodexResumeCursor; } @@ -115,6 +116,7 @@ export interface CodexSessionRuntimeSendTurnInput { readonly serviceTier?: CodexServiceTier | undefined; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort | undefined; readonly interactionMode?: ProviderInteractionMode; + readonly additionalDirectories?: ReadonlyArray | undefined; } export interface CodexThreadTurnSnapshot { @@ -287,12 +289,24 @@ function buildThreadStartParams(input: { readonly runtimeMode: RuntimeMode; readonly model: string | undefined; readonly serviceTier: CodexServiceTier | undefined; + readonly additionalDirectories?: ReadonlyArray | undefined; }): EffectCodexSchema.V2ThreadStartParams { const config = runtimeModeToThreadConfig(input.runtimeMode); + const workspaceWriteConfig = + input.runtimeMode === "auto-accept-edits" && input.additionalDirectories?.length + ? { + config: { + sandbox_workspace_write: { + writable_roots: input.additionalDirectories, + }, + }, + } + : {}; return { cwd: input.cwd, approvalPolicy: config.approvalPolicy, sandbox: config.sandbox, + ...workspaceWriteConfig, ...(input.model ? { model: input.model } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), }; @@ -300,6 +314,7 @@ function buildThreadStartParams(input: { function runtimeModeToTurnSandboxPolicy( input: RuntimeMode, + additionalDirectories: ReadonlyArray = [], ): EffectCodexSchema.V2TurnStartParams__SandboxPolicy { switch (input) { case "approval-required": @@ -309,6 +324,7 @@ function runtimeModeToTurnSandboxPolicy( case "auto-accept-edits": return { type: "workspaceWrite", + ...(additionalDirectories.length > 0 ? { writableRoots: additionalDirectories } : {}), }; case "full-access": default: @@ -352,6 +368,7 @@ export function buildTurnStartParams(input: { readonly serviceTier?: CodexServiceTier; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; readonly interactionMode?: ProviderInteractionMode; + readonly additionalDirectories?: ReadonlyArray | undefined; }): Effect.Effect< CodexTurnStartParamsWithCollaborationMode, CodexErrors.CodexAppServerProtocolParseError @@ -378,7 +395,10 @@ export function buildTurnStartParams(input: { threadId: input.threadId, input: turnInput, approvalPolicy: config.approvalPolicy, - sandboxPolicy: runtimeModeToTurnSandboxPolicy(input.runtimeMode), + sandboxPolicy: runtimeModeToTurnSandboxPolicy( + input.runtimeMode, + input.additionalDirectories ?? [], + ), ...(input.model ? { model: input.model } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), ...(input.effort ? { effort: input.effort } : {}), @@ -437,6 +457,7 @@ export const openCodexThread = (input: { readonly requestedModel: string | undefined; readonly serviceTier: CodexServiceTier | undefined; readonly resumeThreadId: string | undefined; + readonly additionalDirectories?: ReadonlyArray | undefined; }): Effect.Effect => { const resumeThreadId = input.resumeThreadId; const startParams = buildThreadStartParams({ @@ -444,6 +465,7 @@ export const openCodexThread = (input: { runtimeMode: input.runtimeMode, model: input.requestedModel, serviceTier: input.serviceTier, + additionalDirectories: input.additionalDirectories, }); if (resumeThreadId === undefined) { @@ -754,6 +776,9 @@ export const makeCodexSessionRuntime = ( status: "connecting", runtimeMode: options.runtimeMode, cwd: options.cwd, + ...(options.additionalDirectories !== undefined + ? { additionalDirectories: options.additionalDirectories } + : {}), ...(options.model ? { model: options.model } : {}), threadId: options.threadId, ...(options.resumeCursor !== undefined ? { resumeCursor: options.resumeCursor } : {}), @@ -1191,6 +1216,7 @@ export const makeCodexSessionRuntime = ( cwd: options.cwd, requestedModel, serviceTier: options.serviceTier, + additionalDirectories: options.additionalDirectories, resumeThreadId: readResumeCursorThreadId(options.resumeCursor), }); @@ -1199,6 +1225,9 @@ export const makeCodexSessionRuntime = ( ...(yield* Ref.get(sessionRef)), status: "ready", cwd: opened.cwd, + ...(options.additionalDirectories !== undefined + ? { additionalDirectories: options.additionalDirectories } + : {}), model: opened.model, resumeCursor: { threadId: providerThreadId }, updatedAt: yield* nowIso, @@ -1253,6 +1282,8 @@ export const makeCodexSessionRuntime = ( ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), ...(input.effort ? { effort: input.effort } : {}), ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), + additionalDirectories: + input.additionalDirectories ?? options.additionalDirectories ?? [], }); const rawResponse = yield* client.raw.request("turn/start", params); const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 7ee12f92..d89de962 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -129,6 +129,7 @@ function toRuntimePayloadFromSession( ): Record { return { cwd: session.cwd ?? null, + additionalDirectories: session.additionalDirectories ?? [], model: session.model ?? null, activeTurnId: session.activeTurnId ?? null, lastError: session.lastError ?? null, @@ -162,6 +163,23 @@ function readPersistedCwd( return trimmed.length > 0 ? trimmed : undefined; } +function readPersistedAdditionalDirectories( + runtimePayload: ProviderRuntimeBinding["runtimePayload"], +): ReadonlyArray | undefined { + if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { + return undefined; + } + const rawDirectories = + "additionalDirectories" in runtimePayload ? runtimePayload.additionalDirectories : undefined; + if (!Array.isArray(rawDirectories)) { + return undefined; + } + const directories = rawDirectories.filter( + (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, + ); + return directories.length > 0 ? directories : []; +} + function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -502,6 +520,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( } const persistedCwd = readPersistedCwd(input.binding.runtimePayload); + const persistedAdditionalDirectories = readPersistedAdditionalDirectories( + input.binding.runtimePayload, + ); const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); const resumed = yield* adapter.startSession({ @@ -509,6 +530,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( provider: input.binding.provider, providerInstanceId: bindingInstanceId, ...(persistedCwd ? { cwd: persistedCwd } : {}), + ...(persistedAdditionalDirectories !== undefined + ? { additionalDirectories: persistedAdditionalDirectories } + : {}), ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), runtimeMode: input.binding.runtimeMode ?? "full-access", @@ -673,6 +697,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( (persistedBinding?.providerInstanceId === resolvedInstanceId ? readPersistedCwd(persistedBinding.runtimePayload) : undefined); + const effectiveAdditionalDirectories = + input.additionalDirectories ?? + (persistedBinding?.providerInstanceId === resolvedInstanceId + ? readPersistedAdditionalDirectories(persistedBinding.runtimePayload) + : undefined); yield* Effect.annotateCurrentSpan({ "provider.kind": resolvedProvider, "provider.resume_cursor.source": @@ -691,12 +720,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ? "persisted" : "none", "provider.cwd.effective": effectiveCwd ?? "", + "provider.additional_directories.count": effectiveAdditionalDirectories?.length ?? 0, }); const adapter = yield* registry.getByInstance(resolvedInstanceId); const session = yield* adapter.startSession({ ...input, providerInstanceId: resolvedInstanceId, ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), + ...(effectiveAdditionalDirectories !== undefined + ? { additionalDirectories: effectiveAdditionalDirectories } + : {}), ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), }); @@ -709,6 +742,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const sessionWithInstance = { ...session, providerInstanceId: resolvedInstanceId, + ...(effectiveAdditionalDirectories !== undefined + ? { additionalDirectories: effectiveAdditionalDirectories } + : {}), }; yield* stopStaleSessionsForThread({ diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 5c87e28d..cc3772c7 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -60,6 +60,7 @@ class CodeHighlightErrorBoundary extends React.Component< interface ChatMarkdownProps { text: string; cwd: string | undefined; + additionalWorkspaceRoots?: ReadonlyArray; isStreaming?: boolean; skills?: ReadonlyArray>; } @@ -594,6 +595,7 @@ function areMarkdownFileLinkPropsEqual( function ChatMarkdown({ text, cwd, + additionalWorkspaceRoots = [], isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, }: ChatMarkdownProps) { @@ -607,13 +609,13 @@ function ChatMarkdown({ for (const href of extractMarkdownLinkHrefs(text)) { const normalizedHref = normalizeMarkdownLinkHrefKey(href); if (metaByHref.has(normalizedHref)) continue; - const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd); + const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd, additionalWorkspaceRoots); if (meta) { metaByHref.set(normalizedHref, meta); } } return metaByHref; - }, [cwd, text]); + }, [additionalWorkspaceRoots, cwd, text]); const fileLinkParentSuffixByPath = useMemo(() => { const filePaths = [...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath); return buildFileLinkParentSuffixByPath(filePaths); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 3da9427b..b7aee005 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -308,6 +308,7 @@ function createSnapshotForTargetUser(options: { id: PROJECT_ID, title: "Project", workspaceRoot: "/repo/project", + additionalWorkspaceRoots: [], defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", @@ -793,6 +794,7 @@ function createSnapshotWithSecondaryProject(options?: { id: SECOND_PROJECT_ID, title: "Docs Portal", workspaceRoot: "/repo/clients/docs-portal", + additionalWorkspaceRoots: [], defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, scripts: [], createdAt: NOW_ISO, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c33d25be..e25d205a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4103,6 +4103,7 @@ export default function ChatView(props: ChatViewProps) { isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} markdownCwd={gitCwd ?? undefined} + additionalWorkspaceRoots={activeProject?.additionalWorkspaceRoots ?? []} timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 7ffe4f34..075b7a3b 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -143,6 +143,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { id: PROJECT_ID, title: "Project", workspaceRoot: "/repo/project", + additionalWorkspaceRoots: [], defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 845ab02a..69998939 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -4,9 +4,11 @@ import { ChevronRightIcon, CloudIcon, FolderPlusIcon, + PlusIcon, SearchIcon, SettingsIcon, SquarePenIcon, + Trash2Icon, TriangleAlertIcon, } from "lucide-react"; import { @@ -208,6 +210,17 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { } as const; const SIDEBAR_BRAND_ICON_SRC = "/cafe-code-sidebar-icon.png"; const EMPTY_THREAD_JUMP_LABELS = new Map(); +const PATH_SEPARATOR_REGEX = /[/\\]+/g; + +function normalizePathForComparison(pathValue: string): string { + return pathValue.trim().replace(PATH_SEPARATOR_REGEX, "/").replace(/\/+$/, ""); +} + +function pathContainsPath(parentPath: string, childPath: string): boolean { + const parent = normalizePathForComparison(parentPath); + const child = normalizePathForComparison(childPath); + return child.startsWith(`${parent}/`); +} const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", repository_path: "Group by repository path", @@ -1055,6 +1068,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const [projectGroupingSelection, setProjectGroupingSelection] = useState< SidebarProjectGroupingMode | "inherit" >("inherit"); + const [additionalDirectoriesTarget, setAdditionalDirectoriesTarget] = + useState(null); + const [additionalDirectoriesDraft, setAdditionalDirectoriesDraft] = useState([]); + const [additionalDirectoryInput, setAdditionalDirectoryInput] = useState(""); + const [additionalDirectoriesError, setAdditionalDirectoriesError] = useState(null); + const [additionalDirectoriesSubmitting, setAdditionalDirectoriesSubmitting] = useState(false); const [threadMoveTarget, setThreadMoveTarget] = useState(null); const [threadMoveProjectId, setThreadMoveProjectId] = useState(null); const [threadMoveSubmitting, setThreadMoveSubmitting] = useState(false); @@ -1265,6 +1284,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec setProjectRenameTitle(member.name); }, []); + const openAdditionalDirectoriesDialog = useCallback((member: SidebarProjectGroupMember) => { + setAdditionalDirectoriesTarget(member); + setAdditionalDirectoriesDraft([...(member.additionalWorkspaceRoots ?? [])]); + setAdditionalDirectoryInput(""); + setAdditionalDirectoriesError(null); + }, []); + const openProjectGroupingDialog = useCallback( (member: SidebarProjectGroupMember) => { const overrideKey = deriveProjectGroupingOverrideKey(member); @@ -1423,7 +1449,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const actionHandlers = new Map Promise | void>(); const makeLeaf = ( - action: "rename" | "grouping" | "copy-path" | "delete", + action: "rename" | "directories" | "grouping" | "copy-path" | "delete", member: SidebarProjectGroupMember, options?: { destructive?: boolean; @@ -1436,6 +1462,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec case "rename": openProjectRenameDialog(member); return; + case "directories": + openAdditionalDirectoriesDialog(member); + return; case "grouping": openProjectGroupingDialog(member); return; @@ -1456,7 +1485,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }; const buildTargetedItem = ( - action: "rename" | "grouping" | "copy-path" | "delete", + action: "rename" | "directories" | "grouping" | "copy-path" | "delete", label: string, options?: { destructive?: boolean; @@ -1489,6 +1518,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const clicked = await api.contextMenu.show( [ buildTargetedItem("rename", "Rename project"), + buildTargetedItem("directories", "Configure additional directories…"), buildTargetedItem("grouping", "Project grouping…"), buildTargetedItem("copy-path", "Copy Project Path"), buildTargetedItem("delete", "Remove project", { @@ -1511,6 +1541,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [ copyPathToClipboard, handleRemoveProject, + openAdditionalDirectoriesDialog, openProjectGroupingDialog, openProjectRenameDialog, project.groupedProjectCount, @@ -1846,6 +1877,83 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec setProjectGroupingSelection("inherit"); }, []); + const closeAdditionalDirectoriesDialog = useCallback(() => { + if (additionalDirectoriesSubmitting) { + return; + } + setAdditionalDirectoriesTarget(null); + setAdditionalDirectoriesDraft([]); + setAdditionalDirectoryInput(""); + setAdditionalDirectoriesError(null); + }, [additionalDirectoriesSubmitting]); + + const addAdditionalDirectoryDraft = useCallback((pathValue: string) => { + const trimmed = pathValue.trim(); + if (trimmed.length === 0) { + return; + } + setAdditionalDirectoriesDraft((current) => + current.some( + (entry) => normalizePathForComparison(entry) === normalizePathForComparison(trimmed), + ) + ? current + : [...current, trimmed], + ); + setAdditionalDirectoryInput(""); + setAdditionalDirectoriesError(null); + }, []); + + const browseAdditionalDirectory = useCallback(async () => { + if (!additionalDirectoriesTarget) { + return; + } + const api = readLocalApi(); + if (!api) { + return; + } + const selectedPath = await api.dialogs.pickFolder({ + initialPath: additionalDirectoriesTarget.cwd, + }); + if (selectedPath) { + addAdditionalDirectoryDraft(selectedPath); + } + }, [addAdditionalDirectoryDraft, additionalDirectoriesTarget]); + + const removeAdditionalDirectoryDraft = useCallback((indexToRemove: number) => { + setAdditionalDirectoriesDraft((current) => + current.filter((_, index) => index !== indexToRemove), + ); + }, []); + + const submitAdditionalDirectories = useCallback(async () => { + if (!additionalDirectoriesTarget) { + return; + } + const api = readEnvironmentApi(additionalDirectoriesTarget.environmentId); + if (!api) { + setAdditionalDirectoriesError("Project API unavailable."); + return; + } + + setAdditionalDirectoriesSubmitting(true); + setAdditionalDirectoriesError(null); + try { + await api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId: additionalDirectoriesTarget.id, + additionalWorkspaceRoots: additionalDirectoriesDraft, + }); + setAdditionalDirectoriesTarget(null); + setAdditionalDirectoriesDraft([]); + setAdditionalDirectoryInput(""); + } catch (error) { + setAdditionalDirectoriesError(error instanceof Error ? error.message : "An error occurred."); + } finally { + setAdditionalDirectoriesSubmitting(false); + } + }, [additionalDirectoriesDraft, additionalDirectoriesTarget]); + const closeThreadMoveDialog = useCallback(() => { setThreadMoveTarget(null); setThreadMoveProjectId(null); @@ -2057,6 +2165,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ], ); + const additionalDirectoryWarnings = additionalDirectoriesDraft.flatMap((candidate, index) => + additionalDirectoriesDraft.some( + (other, otherIndex) => otherIndex !== index && pathContainsPath(other, candidate), + ) + ? [`${candidate} is inside another configured directory.`] + : [], + ); + return ( <>
@@ -2236,6 +2352,126 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec + { + if (!open) { + closeAdditionalDirectoriesDialog(); + } + }} + > + + + Additional directories + + {additionalDirectoriesTarget + ? `Configure directories available to agents for ${additionalDirectoriesTarget.cwd}.` + : "Configure directories available to agents."} + + + +
+ Primary directory +
+ {additionalDirectoriesTarget?.cwd ?? ""} +
+
+ +
+ Additional directories + {additionalDirectoriesDraft.length > 0 ? ( +
+ {additionalDirectoriesDraft.map((directory, index) => ( +
+
+ {directory} +
+ +
+ ))} +
+ ) : ( +

+ No additional directories configured. +

+ )} +
+ +
+ setAdditionalDirectoryInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + addAdditionalDirectoryDraft(additionalDirectoryInput); + } + }} + /> +
+ + +
+
+ + {additionalDirectoryWarnings.length > 0 ? ( + + + Redundant directories + {additionalDirectoryWarnings.join(" ")} + + ) : null} + + {additionalDirectoriesError ? ( + + + Unable to save directories + {additionalDirectoriesError} + + ) : null} +
+ + + + +
+
+ { @@ -2695,6 +2931,7 @@ interface SidebarProjectsContentProps { suppressProjectClickForContextMenuRef: React.RefObject; attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; projectsLength: number; + showSidebarMascot: boolean; } const SidebarProjectsContent = memo(function SidebarProjectsContent( @@ -2736,6 +2973,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( suppressProjectClickForContextMenuRef, attachProjectListAutoAnimateRef, projectsLength, + showSidebarMascot, } = props; const handleProjectSortOrderChange = useCallback( @@ -2926,24 +3164,26 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( )}