From d138e53dfcc17e867405ba747b617374a7118fc1 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Thu, 13 Aug 2026 14:27:13 +0700 Subject: [PATCH] fix(app): give a sandbox its project so New session lands in it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New session on a sandbox silently started the session on the local server: the sandbox looked selected while the agent ran on the host. A sandbox exposes exactly one directory — the project folder it was created with, at a fixed guest path (/workspace on vz and bwrap, the wslpath translation on WSL) — and the guest registers it as its own project at boot. The app's per-server project list, though, starts empty and nothing ever fills it: projects are only ever added by a user opening a folder. With no project for that server, home-model.openNewSession returns early and the titlebar's openNewTab fallback chain walks on to another server. Seed it from the server itself, reading sync.data.path.directory, which every server ctx already fetches, so it stays backend-agnostic. An explicit close is respected — re-opening it would make the sandbox's only project impossible to remove. The logic is fork-only (src/sandbox/projects.ts); global.tsx gains an import and one call, since SandboxesProvider is mounted above GlobalProvider and cannot reach ensureServerCtx. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 8 ++ DIVERGENCES.md | 2 + packages/app/src/context/global.tsx | 3 + packages/app/src/sandbox/projects.ts | 43 +++++++++ .../app/test-browser/sandbox-projects.test.ts | 92 +++++++++++++++++++ 5 files changed, 148 insertions(+) create mode 100644 packages/app/src/sandbox/projects.ts create mode 100644 packages/app/test-browser/sandbox-projects.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 435f57b302..09dc4a8883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI +- **New session inside a sandbox now works** — a sandbox had no project of its + own in the app, so **New session** had nothing to open there and quietly + started the session on the local server instead: the sandbox looked selected + while the agent ran on the host. Each sandbox now picks up its project folder + automatically, as the guest sees it, so a new session on a sandbox runs + inside the sandbox. Nothing to configure — existing sandboxes pick it up on + next launch. (You no longer have to find the folder in a directory picker + that opened on the sandbox's home directory.) - **Removing a sandbox no longer crashes the window** — deleting a sandbox that had a session tab open took the whole window down with `Notification server not found`. Tabs belonging to a removed server are now closed before its diff --git a/DIVERGENCES.md b/DIVERGENCES.md index a19ea7ddf0..a8a34e0eff 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -192,6 +192,8 @@ from the squash message first, or use a plain merge. | Desktop sandbox wiring: `packages/desktop/src/main/{index,store-keys}.ts`, `packages/desktop/package.json` | Hooks the fork-only `src/main/sandbox/{bundle,controller,ipc}.ts` modules into startup: `index.ts` constructs the sandbox controller beside the WSL servers controller, adds `sandboxes.stopAll()` to `stopSidecars`, registers the `sandbox-*` IPC channels next to the WSL ones, and calls `sandboxes.initialize()` (all platforms) once the local server is ready; `store-keys.ts` gains `SANDBOXES_KEY = "sandboxes"`; `package.json` gains the `@codev/sandbox` and `@codev/gateway` workspace devDependencies (bundled into the main process by electron-vite, so not runtime dependencies; gateway supplies the sandbox egress broker's credentials and config fragment). | The desktop app is where sandboxes are created and run; `createSandboxController` generalizes the proven WSL-servers controller pattern to the per-platform `@codev/sandbox` providers (macOS vz / Windows wsl / Linux bwrap). | Keep the wiring lines and take upstream's surrounding changes; they are additive and order-insensitive apart from `initialize()` needing the local server ready first. Do not rename the `sandbox-*` channels or the `sandboxes` store key — preload/renderer and persisted sandbox configs depend on them. | | Desktop sandbox renderer slice: `packages/app/package.json`, `packages/app/src/{index.ts,app.tsx}`, `packages/app/src/context/{platform,server}.tsx`, `packages/app/src/components/{settings-v2/servers.tsx,dialog-select-server.tsx}`, `packages/app/src/wsl/settings.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | Surfaces the fork-only sandbox modules (`packages/app/src/sandbox/*`, `packages/desktop/src/renderer/sandbox/*`) in the UI: app `package.json` exports `./sandbox/types` (mirror of `./wsl/types`); `index.ts` re-exports `useSandboxes` + the sandbox types; `app.tsx` mounts `SandboxesProvider` inside `WslServersProvider`; `platform.tsx` gains optional `sandboxes?: SandboxesPlatform`; `server.tsx` adds the third `ServerConnection.Sidecar` variant `{ variant: "sandbox"; name }` with key `sandbox:`; `settings-v2/servers.tsx` filters sandbox rows out of the generic list and renders `SandboxServerSettings` beside the WSL section; `dialog-select-server.tsx` `handleRemove` routes `sandbox:` keys to `platform.sandboxes.remove` (like `wsl:` keys) and moves the `tabs.removeServer(key)` + `server.remove(key)` pair *above* both awaited platform removals; `wsl/settings.tsx` `AddServerMenu` shows the menu when either wslServers or sandboxes exist and gains an "Add sandbox" item; i18n gains the `sandbox.*` key family (including the optional per-sandbox CPU/memory fields, macOS-only since only the vz backend applies them); preload exposes `api.sandboxes` over the `sandbox-*` channels and `renderer/index.tsx` wires the platform api, loading gate, ready-connection list, and the `startupServer` fallback (fork-only wrapper that validates `sandbox:` default keys the way `availableStartupServer` validates `wsl:` ones). | Ready sandboxes must appear as first-class servers and be manageable from settings; the slice mirrors the WSL vertical exactly so upstream conflicts resolve the same way in both. Removing a server destroys a disk and takes seconds, and the connection leaves the list the moment it finishes, so the tab cleanup has to happen before the await rather than after it. | Keep every listed edit; take upstream's surrounding changes. If upstream adds a `Sidecar` variant or reworks `AddServerMenu`, re-apply the sandbox variant/menu item on top. The `sandbox:` key format is persisted as the default-server key and as `config.id` in the main-process store — never change one side alone. Keep the tab cleanup ahead of the platform removals in `handleRemove`; if upstream reorders it back, re-apply. | +| `packages/app/src/context/global.tsx` | Two lines: imports `seedSandboxProjects` from the fork-only `packages/app/src/sandbox/projects.ts` and calls it with `server.list` + `ensureServerCtx`, beside the memo that already creates a ctx per connection. All the logic lives in the fork-only module. | A sandbox exposes exactly one directory (`/workspace` on vz/bwrap, the wslpath translation on WSL) and the guest registers it as its own project, but the app's per-server project list starts empty and nothing ever fills it. With no project, every "New session" entry point — `home-model.openNewSession`, the titlebar's `openNewTab` fallback chain — finds nothing for that server and falls back to another one, so the sandbox looks selected while the session runs on the local server. The seed reads `sync.data.path.directory`, which every server ctx already fetches, so it stays backend-agnostic. | Keep both lines; take upstream's other changes to the file. The call must stay inside the Global context init (it needs `ensureServerCtx`) — `SandboxesProvider` is mounted above `GlobalProvider` in `app.tsx`, so it cannot live there. | + | `packages/app/src/pages/layout/project-avatar-state.ts` | `unread` gains a `connection()` guard before `notification.ensureServerState(server())`, matching the guard `hasPermissions`/`hasQuestions`/`loading` already apply to `sync()`. | `ensureServerState` throws on a key absent from the connection list, and the throw propagates out of a render-time memo, killing the whole renderer. Sidecar connections are listed only while healthy — `readySandboxConnections`/`readyWslConnections` publish a sandbox or WSL server only in the `ready` state — so any removal, restart, or crash of one drops its key while its session tabs are still mounted. Reproduced by removing a sandbox that had an open session tab: `Error: Notification server not found: sandbox:`. | Keep the guard; take upstream's other changes to the hook. This is an upstream bug (WSL servers hit the identical path), so if upstream fixes it their way, drop ours in favour of theirs. | | `script/publish.ts` | The `if (Script.release)` block calling `packages/desktop/scripts/finalize-latest-{json,yml}.ts` removed, replaced by a comment. | Both finalizers existed to assemble update metadata into a GitHub release, which the fork no longer publishes to. electron-builder already writes a correct per-platform `latest*.yml` next to each installer, and the fork builds one arch per platform (macOS is universal — see the universal-builds row), so there is nothing left to merge. | Keep removed. If upstream adds further desktop release steps here, drop them unless they target the bucket. | | `packages/desktop/scripts/finalize-latest-json.ts`, `packages/desktop/scripts/finalize-latest-yml.ts` | Deleted. | Both `gh release upload` into a GitHub release the fork does not use. `finalize-latest-yml.ts` merged per-arch `latest*.yml` from an upstream build matrix the fork does not run; `finalize-latest-json.ts` additionally built a Tauri-style `latest.json` signed with `@tauri-apps/cli signer` that nothing in the Electron updater path ever reads, and had gone stale besides — it hardcoded `opencode-desktop-mac-{x64,arm64}.app.tar.gz`, an artifact name and format this fork never produces. Leaving them in place is a trap: they look like the release path and upload to the wrong place. | Keep deleted (`git rm` the upstream side of modify/delete conflicts). If a future build matrix produces two `latest.yml` files for one platform (Windows x64 + arm64), write a bucket-targeted merge step rather than restoring these. | diff --git a/packages/app/src/context/global.tsx b/packages/app/src/context/global.tsx index 83438898b5..d7861bd78e 100644 --- a/packages/app/src/context/global.tsx +++ b/packages/app/src/context/global.tsx @@ -6,6 +6,7 @@ import { pathKey } from "@/utils/path-key" import { useServerHealth } from "@/utils/server-health" import { createServerSdkContext } from "./server-sdk" import { createServerSyncContext } from "./server-sync" +import { seedSandboxProjects } from "@/sandbox/projects" import { getOwner } from "solid-js/web" import { QueryClient } from "@tanstack/solid-query" import type { ServerScope } from "@/utils/server-scope" @@ -60,6 +61,8 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext( } }) + seedSandboxProjects(() => server.list, ensureServerCtx) + createEffect(() => { for (const [key] of serverCtxs) { if (!server.list.find((conn) => ServerConnection.key(conn) === key)) { diff --git a/packages/app/src/sandbox/projects.ts b/packages/app/src/sandbox/projects.ts new file mode 100644 index 0000000000..7122189c0c --- /dev/null +++ b/packages/app/src/sandbox/projects.ts @@ -0,0 +1,43 @@ +import { createEffect } from "solid-js" +import type { ServerConnection } from "@/context/server" + +// The slice of a server ctx (context/global.tsx) the seed reads. Structural on +// purpose: it keeps this testable without standing up a whole ctx, and a shape +// change upstream surfaces as a type error at the call site. +type SandboxServerCtx = { + projects: { + list: () => { worktree: string }[] + recentlyClosed: () => { worktree: string }[] + open: (directory: string) => void + } + sync: { data: { path: { directory: string; worktree: string } } } +} + +// A sandbox exposes exactly one directory: the project folder it was created +// with, mounted at a fixed guest path — /workspace on macOS and Linux, the +// translated Windows path on WSL. The guest server reports it as its own +// directory, but the app's per-server project list starts empty and nothing +// ever fills it, so every "New session" entry point finds no project for the +// sandbox and falls back to another server: the sandbox looks selected while +// the session runs on the local one. +// +// Seed it from the server itself rather than making the user find the path in +// a directory picker that opens on the guest home (/home/codev) instead. +export function seedSandboxProjects( + connections: () => ServerConnection.Any[], + ensure: (conn: ServerConnection.Any) => SandboxServerCtx, +) { + createEffect(() => { + for (const conn of connections()) { + if (conn.type !== "sidecar" || conn.variant !== "sandbox") continue + const ctx = ensure(conn) + if (ctx.projects.list().length) continue + const directory = ctx.sync.data.path.directory || ctx.sync.data.path.worktree + if (!directory) continue + // Reopening a project the user closed on purpose would make it + // impossible to remove. + if (ctx.projects.recentlyClosed().some((project) => project.worktree === directory)) continue + ctx.projects.open(directory) + } + }) +} diff --git a/packages/app/test-browser/sandbox-projects.test.ts b/packages/app/test-browser/sandbox-projects.test.ts new file mode 100644 index 0000000000..8a9df74f69 --- /dev/null +++ b/packages/app/test-browser/sandbox-projects.test.ts @@ -0,0 +1,92 @@ +import { expect, test } from "bun:test" +import { createRoot, createSignal } from "solid-js" +import { ServerConnection } from "@/context/server" +import { seedSandboxProjects } from "@/sandbox/projects" + +function sandboxConnection(name: string): ServerConnection.Any { + return { + displayName: name, + type: "sidecar", + variant: "sandbox", + name, + http: { url: "http://127.0.0.1:1234" }, + } +} + +const localConnection: ServerConnection.Any = { + displayName: "Local Server", + type: "sidecar", + variant: "base", + http: { url: "http://127.0.0.1:4321" }, +} + +function serverCtx(input: { directory?: string; worktree?: string; open?: string[]; closed?: string[] }) { + const opened: string[] = [] + return { + opened, + ctx: { + projects: { + list: () => (input.open ?? []).map((worktree) => ({ worktree })), + recentlyClosed: () => (input.closed ?? []).map((worktree) => ({ worktree })), + open: (directory: string) => opened.push(directory), + }, + sync: { data: { path: { directory: input.directory ?? "", worktree: input.worktree ?? "" } } }, + }, + } +} + +// Effects flush when the root's update cycle completes, so every assertion +// belongs outside createRoot rather than inside its callback. +function seed(connections: () => ServerConnection.Any[], ctx: ReturnType) { + return createRoot((dispose) => { + seedSandboxProjects(connections, () => ctx.ctx) + return dispose + }) +} + +test("seeds a sandbox with the directory the guest server reports", () => { + const sandbox = serverCtx({ directory: "/workspace" }) + const dispose = seed(() => [sandboxConnection("work")], sandbox) + expect(sandbox.opened).toEqual(["/workspace"]) + dispose() +}) + +test("falls back to the worktree when the server reports no directory", () => { + const sandbox = serverCtx({ worktree: "/mnt/c/Users/dev/project" }) + const dispose = seed(() => [sandboxConnection("work")], sandbox) + expect(sandbox.opened).toEqual(["/mnt/c/Users/dev/project"]) + dispose() +}) + +test("leaves non-sandbox servers alone", () => { + const local = serverCtx({ directory: "/home/dev/project" }) + const dispose = seed(() => [localConnection], local) + expect(local.opened).toEqual([]) + dispose() +}) + +test("does not seed a sandbox that already has a project", () => { + const sandbox = serverCtx({ directory: "/workspace", open: ["/workspace"] }) + const dispose = seed(() => [sandboxConnection("work")], sandbox) + expect(sandbox.opened).toEqual([]) + dispose() +}) + +// Without this the only project in a sandbox could never be removed: closing it +// empties the list, which is exactly the condition the seed fires on. +test("respects a project the user closed", () => { + const sandbox = serverCtx({ directory: "/workspace", closed: ["/workspace"] }) + const dispose = seed(() => [sandboxConnection("work")], sandbox) + expect(sandbox.opened).toEqual([]) + dispose() +}) + +test("seeds a sandbox that becomes ready later", () => { + const sandbox = serverCtx({ directory: "/workspace" }) + const [connections, setConnections] = createSignal([localConnection]) + const dispose = seed(connections, sandbox) + expect(sandbox.opened).toEqual([]) + setConnections([localConnection, sandboxConnection("work")]) + expect(sandbox.opened).toEqual(["/workspace"]) + dispose() +})