From 829acd3c62deae8f7f40c35d362006541e785a56 Mon Sep 17 00:00:00 2001 From: saltacc Date: Sat, 23 May 2026 13:38:19 -0700 Subject: [PATCH 1/5] Add local Arch package builder --- README.md | 19 ++ package.json | 1 + scripts/build-arch-package.ts | 388 ++++++++++++++++++++++++++++++++++ 3 files changed, 408 insertions(+) create mode 100644 scripts/build-arch-package.ts 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/package.json b/package.json index 5661c44e..c4c1bf6c 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis", "dist:desktop:win:arm64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch arm64", "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", + "dist:arch:local": "node scripts/build-arch-package.ts", "release:smoke": "node scripts/release-smoke.ts", "db:compact-activity-payloads": "node scripts/compact-activity-payloads.ts", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", diff --git a/scripts/build-arch-package.ts b/scripts/build-arch-package.ts new file mode 100644 index 00000000..d3e4761e --- /dev/null +++ b/scripts/build-arch-package.ts @@ -0,0 +1,388 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off + +import { spawnSync } from "node:child_process"; +import { + chmodSync, + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + readdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { performance } from "node:perf_hooks"; + +interface CliOptions { + readonly arch: DesktopArch; + readonly install: boolean; + readonly keepStage: boolean; + readonly outputDir: string; + readonly skipDesktopBuild: boolean; + readonly verbose: boolean; + readonly version: string | undefined; +} + +type DesktopArch = "x64" | "arm64"; +type PacmanArch = "x86_64" | "aarch64"; + +const repoRoot = realpathSync(new URL("..", import.meta.url)); +const serverPackageJsonPath = join(repoRoot, "apps/server/package.json"); +const desktopIconPath = join(repoRoot, "apps/desktop/resources/icon.png"); + +function writeStdout(message: string) { + process.stdout.write(`${message}\n`); +} + +function writeStderr(message: string) { + process.stderr.write(`${message}\n`); +} + +function fail(message: string): never { + writeStderr(`error: ${message}`); + process.exit(1); +} + +function commandExists(command: string): boolean { + const result = spawnSync(command, ["--version"], { + cwd: repoRoot, + stdio: "ignore", + }); + return result.status === 0; +} + +function run(command: string, args: ReadonlyArray, options?: { readonly cwd?: string }) { + const result = spawnSync(command, [...args], { + cwd: options?.cwd ?? repoRoot, + stdio: "inherit", + }); + + if (result.error) { + fail(`failed to run ${command}: ${result.error.message}`); + } + if (result.signal) { + fail(`${command} exited after signal ${result.signal}`); + } + if (result.status !== 0) { + fail(`${command} exited with status ${String(result.status)}`); + } +} + +function readJsonObject(path: string): Record { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + fail(`expected JSON object at ${path}`); + } + return parsed as Record; +} + +function readDefaultVersion(): string { + const packageJson = readJsonObject(serverPackageJsonPath); + const version = packageJson.version; + if (typeof version !== "string" || version.length === 0) { + fail(`missing version in ${serverPackageJsonPath}`); + } + return version; +} + +function mapHostArch(): DesktopArch { + if (process.arch === "arm64") return "arm64"; + return "x64"; +} + +function mapPacmanArch(arch: DesktopArch): PacmanArch { + return arch === "arm64" ? "aarch64" : "x86_64"; +} + +function mapLinuxArtifactArch(arch: DesktopArch): string { + return arch === "x64" ? "x86_64" : arch; +} + +function toPacmanPkgver(version: string): string { + return version.replaceAll("-", "_"); +} + +function parseArgs(argv: ReadonlyArray): CliOptions { + let arch: DesktopArch = mapHostArch(); + let install = false; + let keepStage = false; + let outputDir = "release/arch"; + let skipDesktopBuild = false; + let verbose = false; + let version: string | undefined; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--arch") { + const value = argv[index + 1]; + if (value !== "x64" && value !== "arm64") { + fail("--arch must be x64 or arm64"); + } + arch = value; + index += 1; + continue; + } + if (arg === "--install") { + install = true; + continue; + } + if (arg === "--keep-stage") { + keepStage = true; + continue; + } + if (arg === "--output-dir") { + const value = argv[index + 1]; + if (!value) fail("--output-dir requires a path"); + outputDir = value; + index += 1; + continue; + } + if (arg === "--skip-desktop-build") { + skipDesktopBuild = true; + continue; + } + if (arg === "--verbose") { + verbose = true; + continue; + } + if (arg === "--version") { + const value = argv[index + 1]; + if (!value) fail("--version requires a value"); + version = value; + index += 1; + continue; + } + if (arg === "--help" || arg === "-h") { + writeStdout(`Build a local Arch Linux pacman package for Cafe Code. + +Usage: + bun run dist:arch:local [options] + +Options: + --arch x64|arm64 Desktop artifact architecture. Defaults to host arch. + --install Run sudo pacman -U after building the package. + --keep-stage Keep the temporary package staging directory. + --output-dir Package output directory. Defaults to release/arch. + --skip-desktop-build Reuse an existing AppImage from release/arch-appimage. + --verbose Pass verbose output through to the desktop artifact build. + --version Package version. Defaults to apps/server/package.json. +`); + process.exit(0); + } + + fail(`unknown argument: ${arg ?? ""}`); + } + + return { arch, install, keepStage, outputDir, skipDesktopBuild, verbose, version }; +} + +function ensureAppImage(options: CliOptions, version: string): string { + const appImageDir = join(repoRoot, "release/arch-appimage"); + const artifactArch = mapLinuxArtifactArch(options.arch); + const appImagePath = join(appImageDir, `Cafe-Code-${version}-${artifactArch}.AppImage`); + + if (!options.skipDesktopBuild) { + const buildArgs = [ + "scripts/build-desktop-artifact.ts", + "--platform", + "linux", + "--target", + "AppImage", + "--arch", + options.arch, + "--build-version", + version, + "--output-dir", + "release/arch-appimage", + ]; + if (options.verbose) buildArgs.push("--verbose"); + run("node", buildArgs); + } + + if (!existsSync(appImagePath)) { + fail(`missing AppImage at ${appImagePath}; rerun without --skip-desktop-build`); + } + + return appImagePath; +} + +function ensureParent(path: string) { + mkdirSync(dirname(path), { recursive: true }); +} + +function writePackageFile(path: string, contents: string, mode: number) { + ensureParent(path); + writeFileSync(path, contents); + chmodSync(path, mode); +} + +function copyPackageFile(from: string, to: string, mode: number) { + ensureParent(to); + copyFileSync(from, to); + chmodSync(to, mode); +} + +function installedSize(path: string): number { + const stat = lstatSync(path); + if (stat.isSymbolicLink()) { + return Buffer.byteLength(readlinkSync(path)); + } + if (stat.isFile()) { + return stat.size; + } + if (!stat.isDirectory()) { + return 0; + } + + let total = 0; + for (const entry of readdirSync(path)) { + total += installedSize(join(path, entry)); + } + return total; +} + +function unixNowSeconds(): number { + const sourceDateEpoch = process.env.SOURCE_DATE_EPOCH; + if (sourceDateEpoch) { + const parsed = Number(sourceDateEpoch); + if (Number.isInteger(parsed) && parsed > 0) return parsed; + } + return Math.floor(performance.timeOrigin / 1000); +} + +function createStage(options: CliOptions, version: string, appImagePath: string): string { + const stageRoot = mkdtempSync(join(tmpdir(), "cafecode-arch-package-")); + const appImageTarget = join(stageRoot, "opt/cafe-code/cafe-code.AppImage"); + const wrapperTarget = join(stageRoot, "usr/bin/cafe-code"); + const desktopTarget = join(stageRoot, "usr/share/applications/cafecode.desktop"); + const iconTarget = join(stageRoot, "usr/share/icons/hicolor/1024x1024/apps/cafecode.png"); + + copyPackageFile(appImagePath, appImageTarget, 0o755); + writePackageFile( + wrapperTarget, + `#!/bin/sh +exec /opt/cafe-code/cafe-code.AppImage "$@" +`, + 0o755, + ); + writePackageFile( + desktopTarget, + `[Desktop Entry] +Type=Application +Name=Cafe Code +Comment=Minimal desktop GUI for coding agents +Exec=cafe-code %U +Icon=cafecode +Terminal=false +Categories=Development; +StartupWMClass=cafecode +`, + 0o644, + ); + copyPackageFile(desktopIconPath, iconTarget, 0o644); + + const pkgver = toPacmanPkgver(version); + const pacmanArch = mapPacmanArch(options.arch); + const packageSize = installedSize(join(stageRoot, "opt")) + installedSize(join(stageRoot, "usr")); + const pkgInfo = [ + "pkgname = cafe-code", + "pkgbase = cafe-code", + "xdata = pkgtype=pkg", + `pkgver = ${pkgver}-1`, + "pkgdesc = Minimal desktop GUI for coding agents", + "url = https://github.com/cafeai/cafe-code", + `builddate = ${String(unixNowSeconds())}`, + "packager = Cafe Code local package builder", + `size = ${String(packageSize)}`, + `arch = ${pacmanArch}`, + "license = AGPL-3.0-or-later", + "depend = fuse2", + "depend = hicolor-icon-theme", + "", + ].join("\n"); + writePackageFile(join(stageRoot, ".PKGINFO"), pkgInfo, 0o644); + + if (options.keepStage) { + const linkPath = join(resolve(repoRoot, options.outputDir), "stage-latest"); + rmSync(linkPath, { recursive: true, force: true }); + ensureParent(linkPath); + symlinkSync(stageRoot, linkPath); + writeStdout(`[arch-package] Kept stage at ${stageRoot}`); + } + + return stageRoot; +} + +function createPacmanPackage(stageRoot: string, options: CliOptions, version: string): string { + const outputDir = resolve(repoRoot, options.outputDir); + const pkgver = toPacmanPkgver(version); + const pacmanArch = mapPacmanArch(options.arch); + const packagePath = join(outputDir, `cafe-code-${pkgver}-1-${pacmanArch}.pkg.tar.zst`); + + mkdirSync(outputDir, { recursive: true }); + rmSync(packagePath, { force: true }); + run("bsdtar", [ + "--zstd", + "--uid", + "0", + "--gid", + "0", + "--uname", + "root", + "--gname", + "root", + "-C", + stageRoot, + "-cf", + packagePath, + ".PKGINFO", + "opt", + "usr", + ]); + + return packagePath; +} + +function installPackage(packagePath: string) { + const pacmanArgs = ["pacman", "-U", "--needed", packagePath]; + if (process.getuid?.() === 0) { + run("pacman", ["-U", "--needed", packagePath]); + return; + } + run("sudo", pacmanArgs); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (!commandExists("bsdtar")) { + fail("bsdtar is required to create pacman packages"); + } + + const version = options.version ?? readDefaultVersion(); + const appImagePath = ensureAppImage(options, version); + const stageRoot = createStage(options, version, appImagePath); + + try { + const packagePath = createPacmanPackage(stageRoot, options, version); + writeStdout(`[arch-package] Wrote ${packagePath}`); + writeStdout(`[arch-package] Install with: sudo pacman -U ${packagePath}`); + + if (options.install) { + installPackage(packagePath); + } + } finally { + if (!options.keepStage) { + rmSync(stageRoot, { recursive: true, force: true }); + } + } +} + +main(); From 8c92e8afb8aa808f4569d0068eeba9048b53dbb0 Mon Sep 17 00:00:00 2001 From: saltacc Date: Sat, 23 May 2026 14:34:02 -0700 Subject: [PATCH 2/5] Add project additional directories --- apps/server/src/checkpointing/Utils.test.ts | 27 ++ apps/server/src/checkpointing/Utils.ts | 41 +++ .../Layers/ProjectionPipeline.test.ts | 5 + .../Layers/ProjectionPipeline.ts | 10 +- .../Layers/ProjectionSnapshotQuery.test.ts | 2 + .../Layers/ProjectionSnapshotQuery.ts | 8 + .../Layers/ProviderCommandReactor.ts | 33 ++- apps/server/src/orchestration/Normalizer.ts | 87 ++++++- apps/server/src/orchestration/decider.ts | 4 + apps/server/src/orchestration/projector.ts | 4 + .../persistence/Layers/ProjectionProjects.ts | 8 +- .../Layers/ProjectionRepositories.test.ts | 1 + apps/server/src/persistence/Migrations.ts | 2 + .../persistence/Migrations/005_Projections.ts | 1 + ...onProjectsAdditionalWorkspaceRoots.test.ts | 51 ++++ ...jectionProjectsAdditionalWorkspaceRoots.ts | 18 ++ .../Services/ProjectionProjects.ts | 9 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 25 ++ .../src/provider/Layers/ClaudeAdapter.ts | 14 +- .../src/provider/Layers/CodexAdapter.ts | 3 + .../Layers/CodexSessionRuntime.test.ts | 16 ++ .../provider/Layers/CodexSessionRuntime.ts | 33 ++- .../src/provider/Layers/ProviderService.ts | 36 +++ apps/web/src/components/ChatMarkdown.tsx | 6 +- apps/web/src/components/ChatView.browser.tsx | 2 + apps/web/src/components/ChatView.tsx | 1 + .../components/KeybindingsToast.browser.tsx | 1 + apps/web/src/components/Sidebar.tsx | 240 +++++++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 6 + apps/web/src/localApi.test.ts | 1 + apps/web/src/markdown-links.test.ts | 15 ++ apps/web/src/markdown-links.ts | 27 +- apps/web/src/store.test.ts | 3 +- apps/web/src/store.ts | 5 + apps/web/src/types.ts | 1 + packages/contracts/src/orchestration.test.ts | 21 ++ packages/contracts/src/orchestration.ts | 11 + packages/contracts/src/provider.ts | 2 + 38 files changed, 752 insertions(+), 28 deletions(-) create mode 100644 apps/server/src/checkpointing/Utils.test.ts create mode 100644 apps/server/src/persistence/Migrations/034_ProjectionProjectsAdditionalWorkspaceRoots.test.ts create mode 100644 apps/server/src/persistence/Migrations/034_ProjectionProjectsAdditionalWorkspaceRoots.ts 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..ebfa4597 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} +
+ + + + +
+
+ { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index afb95095..b4ef49a6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -67,6 +67,7 @@ import { useServerAvailableEditors } from "../../rpc/serverState"; interface TimelineRowSharedState { timestampFormat: TimestampFormat; markdownCwd: string | undefined; + additionalWorkspaceRoots: ReadonlyArray; workspaceRoot: string | undefined; skills: ReadonlyArray>; activeThreadEnvironmentId: EnvironmentId; @@ -131,6 +132,7 @@ interface MessagesTimelineProps { onImageExpand: (preview: ExpandedImagePreview) => void; activeThreadEnvironmentId: EnvironmentId; markdownCwd: string | undefined; + additionalWorkspaceRoots?: ReadonlyArray; timestampFormat: TimestampFormat; workspaceRoot: string | undefined; skills?: ReadonlyArray>; @@ -156,6 +158,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, activeThreadEnvironmentId, markdownCwd, + additionalWorkspaceRoots = [], timestampFormat, workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, @@ -228,6 +231,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ () => ({ timestampFormat, markdownCwd, + additionalWorkspaceRoots, workspaceRoot, skills, activeThreadEnvironmentId, @@ -237,6 +241,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [ timestampFormat, markdownCwd, + additionalWorkspaceRoots, workspaceRoot, skills, activeThreadEnvironmentId, @@ -425,6 +430,7 @@ function AssistantTimelineRow({ row }: { row: Extract diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 9bd06084..73e7b4e7 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -346,6 +346,7 @@ describe("wsApi", () => { id: ProjectId.make("project-1"), title: "Project", workspaceRoot: "/tmp/workspace", + additionalWorkspaceRoots: [], defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index bb5c957f..58af096d 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -213,4 +213,19 @@ describe("markdown file link workspace policy", () => { isPathInsideWorkspace("/Users/julius/project/file.ts:4:2", "/Users/julius/project"), ).toBe(true); }); + + it("treats configured additional directories as direct-open workspace paths", () => { + expect( + isPathInsideWorkspace("/Users/julius/docs/README.md", "/Users/julius/project", [ + "/Users/julius/docs", + ]), + ).toBe(true); + expect( + resolveMarkdownFileLinkMeta("file:///Users/julius/docs/README.md", "/Users/julius/project", [ + "/Users/julius/docs", + ]), + ).toMatchObject({ + openPolicy: "direct", + }); + }); }); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 668abb89..fad57e16 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -136,18 +136,30 @@ function normalizePathForWorkspaceComparison(path: string): string { : normalized; } -export function isPathInsideWorkspace(targetPath: string, cwd: string | undefined): boolean { - if (!cwd) { +export function isPathInsideWorkspace( + targetPath: string, + cwd: string | undefined, + additionalWorkspaceRoots: ReadonlyArray = [], +): boolean { + const workspaceRoots = [cwd, ...additionalWorkspaceRoots].filter( + (root): root is string => typeof root === "string" && root.trim().length > 0, + ); + if (workspaceRoots.length === 0) { return false; } const target = normalizePathForWorkspaceComparison(splitPathAndPosition(targetPath).path); - const workspace = normalizePathForWorkspaceComparison(splitPathAndPosition(cwd).path); - if (target.length === 0 || workspace.length === 0) { + if (target.length === 0) { return false; } - return target === workspace || target.startsWith(`${workspace}/`); + return workspaceRoots.some((root) => { + const workspace = normalizePathForWorkspaceComparison(splitPathAndPosition(root).path); + if (workspace.length === 0) { + return false; + } + return target === workspace || target.startsWith(`${workspace}/`); + }); } function hasExternalScheme(path: string): boolean { @@ -203,6 +215,7 @@ function basenameOfPath(path: string): string { export function resolveMarkdownFileLinkMeta( href: string | undefined, cwd?: string, + additionalWorkspaceRoots: ReadonlyArray = [], ): MarkdownFileLinkMeta | null { const targetPath = resolveMarkdownFileLinkTarget(href, cwd); if (!targetPath) return null; @@ -218,7 +231,9 @@ export function resolveMarkdownFileLinkMeta( targetPath, displayPath: formatWorkspaceRelativePath(targetPath, cwd), basename: basenameOfPath(path), - openPolicy: isPathInsideWorkspace(targetPath, cwd) ? "direct" : "confirm", + openPolicy: isPathInsideWorkspace(targetPath, cwd, additionalWorkspaceRoots) + ? "direct" + : "confirm", ...(lineNumber !== undefined ? { line: lineNumber } : {}), ...(columnNumber !== undefined ? { column: columnNumber } : {}), }; diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 3c4324a5..9a71020f 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -583,6 +583,7 @@ describe("incremental orchestration updates", () => { projectId: recreatedProjectId, title: "Project Recreated", workspaceRoot: "/tmp/project", + additionalWorkspaceRoots: [], defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, @@ -846,7 +847,7 @@ describe("incremental orchestration updates", () => { ); expect(threadsOf(next)[0]?.session?.status).toBe("running"); - expect(threadsOf(next)[0]?.latestTurn?.state).toBe("completed"); + expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running"); expect(threadsOf(next)[0]?.messages).toHaveLength(1); }); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 5d3eec93..c0505023 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -220,6 +220,7 @@ function mapProject( environmentId, name: project.title, cwd: project.workspaceRoot, + additionalWorkspaceRoots: [...(project.additionalWorkspaceRoots ?? [])], repositoryIdentity: project.repositoryIdentity ?? null, defaultModelSelection: project.defaultModelSelection ? normalizeModelSelection(project.defaultModelSelection) @@ -1157,6 +1158,7 @@ function applyEnvironmentOrchestrationEvent( id: event.payload.projectId, title: event.payload.title, workspaceRoot: event.payload.workspaceRoot, + additionalWorkspaceRoots: event.payload.additionalWorkspaceRoots ?? [], repositoryIdentity: event.payload.repositoryIdentity ?? null, defaultModelSelection: event.payload.defaultModelSelection, scripts: event.payload.scripts, @@ -1211,6 +1213,9 @@ function applyEnvironmentOrchestrationEvent( ...project, ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), + ...(event.payload.additionalWorkspaceRoots !== undefined + ? { additionalWorkspaceRoots: [...event.payload.additionalWorkspaceRoots] } + : {}), ...(event.payload.repositoryIdentity !== undefined ? { repositoryIdentity: event.payload.repositoryIdentity ?? null } : {}), diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 2ca96cc3..4625c82a 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -78,6 +78,7 @@ export interface Project { environmentId: EnvironmentId; name: string; cwd: string; + additionalWorkspaceRoots?: string[]; repositoryIdentity?: RepositoryIdentity | null; defaultModelSelection: ModelSelection | null; createdAt?: string | undefined; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 8524aaec..cb520cb5 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -55,6 +55,7 @@ it.effect("trims branded ids and command string fields at decode boundaries", () projectId: " project-1 ", title: " Project Title ", workspaceRoot: " /tmp/workspace ", + additionalWorkspaceRoots: [" /tmp/docs ", " /tmp/tools "], defaultModelSelection: { provider: "codex", model: " gpt-5.2 ", @@ -65,6 +66,7 @@ it.effect("trims branded ids and command string fields at decode boundaries", () assert.strictEqual(parsed.projectId, "project-1"); assert.strictEqual(parsed.title, "Project Title"); assert.strictEqual(parsed.workspaceRoot, "/tmp/workspace"); + assert.deepStrictEqual(parsed.additionalWorkspaceRoots, ["/tmp/docs", "/tmp/tools"]); assert.strictEqual(parsed.createWorkspaceRootIfMissing, undefined); assert.deepStrictEqual(parsed.defaultModelSelection, { instanceId: ProviderInstanceId.make("codex"), @@ -104,6 +106,23 @@ it.effect("decodes historical project.created payloads with a default provider", updatedAt: "2026-01-01T00:00:00.000Z", }); assert.strictEqual(parsed.defaultModelSelection?.instanceId, "codex"); + assert.strictEqual(parsed.additionalWorkspaceRoots, undefined); + }), +); + +it.effect("decodes populated project additional workspace roots", () => + Effect.gen(function* () { + const parsed = yield* decodeProjectCreatedPayload({ + projectId: "project-1", + title: "Project Title", + workspaceRoot: "/tmp/workspace", + additionalWorkspaceRoots: ["/tmp/docs", " /tmp/tools "], + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + assert.deepStrictEqual(parsed.additionalWorkspaceRoots, ["/tmp/docs", "/tmp/tools"]); }), ); @@ -111,6 +130,7 @@ it.effect("decodes project.meta-updated payloads with explicit default provider" Effect.gen(function* () { const parsed = yield* decodeProjectMetaUpdatedPayload({ projectId: "project-1", + additionalWorkspaceRoots: [" /tmp/docs "], defaultModelSelection: { provider: "claudeAgent", model: "claude-opus-4-6", @@ -118,6 +138,7 @@ it.effect("decodes project.meta-updated payloads with explicit default provider" updatedAt: "2026-01-01T00:00:00.000Z", }); assert.strictEqual(parsed.defaultModelSelection?.instanceId, "claudeAgent"); + assert.deepStrictEqual(parsed.additionalWorkspaceRoots, ["/tmp/docs"]); }), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 3c8dd29b..3047af94 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -194,10 +194,16 @@ export const ProjectScript = Schema.Struct({ }); export type ProjectScript = typeof ProjectScript.Type; +export const AdditionalWorkspaceRoots = Schema.Array(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed([])), +); +export type AdditionalWorkspaceRoots = typeof AdditionalWorkspaceRoots.Type; + export const OrchestrationProject = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + additionalWorkspaceRoots: Schema.optional(AdditionalWorkspaceRoots), repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), @@ -365,6 +371,7 @@ export const OrchestrationProjectShell = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + additionalWorkspaceRoots: Schema.optional(AdditionalWorkspaceRoots), repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), @@ -455,6 +462,7 @@ export const ProjectCreateCommand = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + additionalWorkspaceRoots: Schema.optional(Schema.Array(TrimmedNonEmptyString)), createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), createdAt: IsoDateTime, @@ -466,6 +474,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({ projectId: ProjectId, title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), + additionalWorkspaceRoots: Schema.optional(Schema.Array(TrimmedNonEmptyString)), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), scripts: Schema.optional(Schema.Array(ProjectScript)), }); @@ -839,6 +848,7 @@ export const ProjectCreatedPayload = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + additionalWorkspaceRoots: Schema.optional(AdditionalWorkspaceRoots), repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), @@ -850,6 +860,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ projectId: ProjectId, title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), + additionalWorkspaceRoots: Schema.optional(Schema.Array(TrimmedNonEmptyString)), repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), scripts: Schema.optional(Schema.Array(ProjectScript)), diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 94fb007a..9cf79d76 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -40,6 +40,7 @@ export const ProviderSession = Schema.Struct({ status: ProviderSessionStatus, runtimeMode: RuntimeMode, cwd: Schema.optional(TrimmedNonEmptyString), + additionalDirectories: Schema.optional(Schema.Array(TrimmedNonEmptyString)), model: Schema.optional(TrimmedNonEmptyString), threadId: ThreadId, resumeCursor: Schema.optional(Schema.Unknown), @@ -56,6 +57,7 @@ export const ProviderSessionStartInput = Schema.Struct({ // See ProviderSession for the migration story. providerInstanceId: Schema.optional(ProviderInstanceId), cwd: Schema.optional(TrimmedNonEmptyString), + additionalDirectories: Schema.optional(Schema.Array(TrimmedNonEmptyString)), modelSelection: Schema.optional(ModelSelection), resumeCursor: Schema.optional(Schema.Unknown), approvalPolicy: Schema.optional(ProviderApprovalPolicy), From 95d91ceffb7d837054f0f2c37ac5da0c6e23ca80 Mon Sep 17 00:00:00 2001 From: saltacc Date: Sat, 23 May 2026 15:56:50 -0700 Subject: [PATCH 3/5] Add appearance controls --- .../settings/DesktopClientSettings.test.ts | 3 + apps/web/src/components/Sidebar.tsx | 42 +++-- .../settings/SettingsPanels.browser.tsx | 49 ++++++ .../components/settings/SettingsPanels.tsx | 154 +++++++++++++++++- apps/web/src/documentVisibility.test.ts | 22 +++ apps/web/src/documentVisibility.ts | 17 ++ apps/web/src/index.css | 95 ++++++++--- apps/web/src/localApi.test.ts | 6 + apps/web/src/routes/__root.tsx | 31 +++- apps/web/src/themeAccent.test.ts | 39 +++++ apps/web/src/themeAccent.ts | 23 +++ packages/contracts/src/settings.test.ts | 28 ++++ packages/contracts/src/settings.ts | 16 ++ 13 files changed, 484 insertions(+), 41 deletions(-) create mode 100644 apps/web/src/themeAccent.test.ts create mode 100644 apps/web/src/themeAccent.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index a8a3cc86..ed67690f 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -18,6 +18,9 @@ const clientSettings: ClientSettings = { dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, + continueBackgroundAnimations: false, + showSidebarMascot: true, + themeAccentColor: "", defaultEditor: "system-default", favorites: [], providerModelPreferences: {}, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ebfa4597..69998939 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2931,6 +2931,7 @@ interface SidebarProjectsContentProps { suppressProjectClickForContextMenuRef: React.RefObject; attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; projectsLength: number; + showSidebarMascot: boolean; } const SidebarProjectsContent = memo(function SidebarProjectsContent( @@ -2972,6 +2973,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( suppressProjectClickForContextMenuRef, attachProjectListAutoAnimateRef, projectsLength, + showSidebarMascot, } = props; const handleProjectSortOrderChange = useCallback( @@ -3162,24 +3164,26 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( )}