diff --git a/CHANGELOG.md b/CHANGELOG.md
index c7c6c290db..0f7f751e09 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,27 @@ file is the human-readable summary of what changed and why.
### Sandbox
+- **Unmounting a folder takes its sessions with it** — a session you had
+ started in a mounted folder stayed open after you unmounted that folder, and
+ followed you into whatever folder you mounted next: the conversation was on
+ screen while Recents showed nothing, because the folder it belonged to was no
+ longer part of the sandbox. Unmounting a folder now closes its sessions and
+ drafts along with its project row, whether you unmount from the folder's own
+ menu or by removing it in the Mount project dialog. The sessions themselves
+ are still inside the sandbox; mounting the folder again brings them back.
+- **Work sets its sandbox up for you** — going to Work without a sandbox used
+ to open the full Add sandbox form: a name, a folder, an image, and on a Mac a
+ CPU count and a memory size, all before anything could happen. The folder
+ question was the worst of them, because at that point you have not decided
+ what you want to work on. Work now asks for none of it. It creates a sandbox
+ called `default-sandbox` with the standard image and the standard limits, and
+ the only thing you can still choose is a guest image file of your own, for a
+ machine that cannot reach the download. Once it is ready, Work asks you to
+ mount a project — the folder on this computer you want to work on, which the
+ sandbox can then reach. That is the only folder it can reach: the sandbox's
+ own scratch folder is the app's business and is not shown to you anywhere,
+ including in the Mount project dialog. Settings → Servers keeps the full form
+ for a second sandbox, or one pointed at a particular folder from the start.
- **Sessions in a sandbox survive stopping it** — anything the sandbox had
written in the seconds before it stopped was thrown away: stop a sandbox, or
add a folder to one (which restarts it), or quit the app, and the sessions
diff --git a/DIVERGENCES.md b/DIVERGENCES.md
index bbcfb31a18..10082f5989 100644
--- a/DIVERGENCES.md
+++ b/DIVERGENCES.md
@@ -209,13 +209,14 @@ 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. |
| `packages/app/src/components/server/server-row-menu.tsx` | The trailing **Delete** item carries `style={{ "--menu-v2-fg": "var(--v2-state-fg-danger)" }}`. Nothing else changes — same `disabled={builtin}`, same `handleRemove(key)`, same position after the separator. | Delete rendered in the same colour as Edit and Set as default, so the one irreversible action in the menu carried no more weight than the rest. The session actions menu already colours its Delete this way; this brings the server menu in line. It has to be the CSS variable rather than a text utility: `menu-v2.css` colours the item **and** its `menu-v2-item-content` label span from `--menu-v2-fg`, so a class on the item never reaches the label. | Keep the style prop. On conflict take upstream's item and re-apply it. Do not swap it for a Tailwind text colour — that silently only recolours half the item. The disabled state dims by `opacity` alone, so the red survives it. |
| 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` and, on the desktop variant, `openFilePathDialog` (a path-only file picker — the existing attachment picker reads the file's bytes, which is unusable for a multi-gigabyte guest image), implemented in `renderer/index.tsx` over the existing `open-file-picker` channel and releasing the read token immediately; `server.tsx` adds the third `ServerConnection.Sidecar` variant `{ variant: "sandbox"; name; projectDir?; mounts? }` with key `sandbox:` (`projectDir` is the host folder mounted into the guest and `mounts` the extra host folders with their resolved guest paths — the guest only ever sees its own mount points, so this is the only way the app can name a sandbox's projects after the folders it works on); `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; `sandbox.add.projectDirHint`, the standing explanation above the folder field; the `sandbox.mounts.*` family for the manage-folders dialog that adds/removes extra mounts on an existing sandbox; `sandbox.job.queued`, shown while a sandbox's create/reset/destroy waits its turn behind another sandbox's job; `sandbox.server.stop` for the menu entry that shuts a sandbox down until it is explicitly started again; the `sandbox.add.image*` / `sandbox.add.name*` / `sandbox.arch.*` families for the optional custom-image field and the sandbox-name checks; and `sandbox.add.resourcesHint` plus interpolated `sandbox.add.{cpus,memory,resourcesInvalid}`, which quote the vz defaults and ranges from the constants rather than restating them in prose); 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. |
+| One-click sandbox for Work: `packages/app/src/context/server.tsx`, `packages/app/src/components/app-sidebar.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/index.ts` | Work's "you need a sandbox" gate opens the fork-only `src/sandbox/dialog-add-default.tsx` instead of `DialogAddSandbox`: it asks only for an optional custom image, and the main process decides everything else — the name (`default-sandbox`), the project folder, and the limits, which it leaves unset so the backend default applies. That folder is a placeholder the app invents (fork-only `src/main/sandbox/workspace.ts`, under `%LOCALAPPDATA%` on Windows because a redirected `%APPDATA%` can be a UNC path and `wslpath` cannot translate one), so `SandboxConfig` carries `placeholderProject` and the upstream files here keep it out of the user's way: `server.tsx` adds `placeholderProject` to the `Sidecar` sandbox variant, and `app-sidebar.tsx` renders the fork-only `` in place of upstream's `HomeProjectEmpty` for such a sandbox — upstream's picker browses the server it is pointed at, which for a sandbox is the guest's own home. The fork-only `dialog-mounts.tsx` hides its primary-folder row for the same sandbox: that path is app data the user did not choose and cannot remove, and the dialog would otherwise be the one place they meet it. `preload/index.ts` exposes `sandbox-add-default` and `sandbox-cancel-default` (its own channel because a sandbox mid-create is not in the list the renderer can see, so there is no name to cancel by). i18n gains `sandbox.addDefault.*` and `sandbox.mountPrompt.*`. Settings → Servers still opens the full `DialogAddSandbox`, unchanged. | A user reaching Work for the first time was asked for a name, a folder, an image, a CPU count and a memory size before they could do anything, and the folder question has no good answer yet — they have not decided what to work on. Deciding it for them and asking for a folder afterwards, as a mount, matches what the sandbox actually is: an environment whose reach into the machine is the folders you give it. | Keep the wiring and take upstream's surrounding changes. If upstream reworks the empty-projects fallback in `app-sidebar.tsx`, keep the placeholder branch ahead of it. Do not "simplify" the placeholder away by pointing the sandbox at the user's home or an existing project: the guest gets read-write access to its project folder. `DEFAULT_SANDBOX_NAME` is persisted as a sandbox id (`sandbox:default-sandbox`) and as a WSL distribution name — changing it strands existing sandboxes. |
| Sandbox guest durability: `packages/core/src/database/database.ts`, `packages/core/src/flag/flag.ts` | `PRAGMA synchronous` is no longer hardcoded to `NORMAL`: it reads `Flag.CODEV_SQLITE_SYNCHRONOUS`, validated against a whitelist (`OFF`/`NORMAL`/`FULL`/`EXTRA`) because the value lands in a PRAGMA, and falls back to `NORMAL` — so nothing changes for any process that does not set it. The sandbox sets it to `FULL` for guests (fork-only `packages/codev-sandbox/src/boot.ts`). | WAL + `synchronous = NORMAL` is durable across a crashed process but not across a machine that disappears, because those commits sit in the page cache until a checkpoint. A sandbox guest is exactly that machine: `VZVirtualMachine.stop()` is documented as pulling the power cable, and the guest gets no chance to sync — so stopping a sandbox, restarting one to apply a mount, or force quitting the app dropped every session written in the preceding seconds. Measured on a real vz guest: a file written and left unsynced is gone after a plain Stop/Start; with `FULL` a session created two seconds before `kill -9` of the whole app survives. The knob lives in core because the guest runs the host's own server bundle — there is no sandbox-only build to patch. | Keep the flag and the whitelist; take upstream's other pragmas. If upstream makes synchronous configurable its own way, drop ours for theirs. Never widen the whitelist to pass the value through unchecked. The default must stay `NORMAL` — raising it for every install would make the host pay for a guarantee only the sandbox needs. |
| `packages/desktop/src/renderer/index.tsx` | Desktop notification icon `https://opencode.ai/favicon-96x96-v3.png` → the relative `./favicon-96x96-v3.png`. The renderer's `publicDir` is `packages/app/public`, whose `favicon-96x96-v3.png` symlinks to the rebranded `packages/ui/src/assets/favicon/` mark, so the file is emitted next to `index.html` and resolves against the renderer origin (`oc://renderer/` packaged, the vite dev server otherwise) — the same asset and the same relative form `src/renderer/index.html` already uses for ``. | A desktop app should not reach out to a remote host to draw its own notification icon (it fails offline and leaks a request per notification), and the remote file is upstream-branded while the bundled one is the codev mark. | Keep our relative path. If upstream changes the URL, re-point it at the bundled asset rather than taking a new remote host. `packages/app/src/entry.tsx` and `pages/layout/helpers.ts` keep their remote `opencode.ai/favicon*` sources — same bug class, not yet fixed. |
| Desktop identity: `packages/desktop/src/main/{index,store}.ts`, `packages/desktop/electron-builder.config{,.test}.ts`, `packages/desktop/scripts/copy-metainfo.ts`, `packages/desktop/resources/linux/opencode-desktop.desktop` (deleted), `packages/app/src/pages/layout/{deep-links.ts,helpers.test.ts}`, `packages/codev-brand/test/brand.test.ts` | appId `ai.opencode.desktop{,.beta,.dev}` → `ai.codev.desktop{,.beta,.dev}` (both copies: `APP_IDS` in main and in the builder config, plus the unpackaged fallback); deep-link scheme `opencode://` → `codev://` in all three places that must agree — `protocols.schemes`, `app.setAsDefaultProtocolClient`, and the renderer's `parseUrl` — plus the `argv.startsWith` filter the second-instance handler uses; `rpm.packageName` `opencode`/`opencode-beta`/`opencode-dev` → `codev`/`codev-beta`/`codev-dev`; the hidden `opencode-desktop.desktop` legacy launcher and its `deb`/`rpm` fpm wiring deleted. `src/main/migrate.ts` keeps the old ids — they name the *Tauri* app's directories, not ours. | The appId is the whole desktop identity: `app.setPath("userData", join(appData, appId))`, so it also names the data directory, and it drives the macOS bundle id, the Windows AppUserModelId and NSIS keys, the Linux `.desktop` filename, `executableName` and `StartupWMClass`, and the AppStream metainfo id. Leaving it as opencode meant the fork's app identified itself as upstream to the OS. Renamed as a clean cut with no data migration and no back-compat shim, on the explicit basis that there is no installed base: existing profiles under `appData/ai.opencode.desktop*` are simply orphaned (settings, session history, sandboxes and the downloaded guest image), and a packaged update across the rename would install beside the old app rather than replace it. The legacy launcher went with it — it existed to rescue GNOME/KDE pins from a superseded packaging id, and its `Exec=/opt/OpenCode/...` no longer matched any install layout. | Keep our ids and scheme; take upstream's other config. Never rename the `TAURI_APP_IDS` in `migrate.ts` to match — a blind find-and-replace there points the Tauri migration at a directory that never existed. All three deep-link sites have to move together or links open nothing; `electron-builder.config.test.ts` covers the ids, the package names and the scheme per channel. |
| Code/Work workspace split: `packages/app/src/context/layout.tsx`, `packages/app/src/components/{app-sidebar,titlebar}.tsx`, `packages/app/src/i18n/{en,es}.ts` | The sidebar gains two tabs above Projects — **Code** (every non-sandbox server: the local sidecar, WSL, remote, SSH) and **Work** (the sandboxes) — backed by the fork-only `packages/app/src/workspace/{side.ts,tabs.tsx}`. `layout.tsx` stores `home.workspace` and `home.lastSession` (per side) and records the latter from an effect on `route()`; `app-sidebar.tsx` renders ``, swaps `global.servers.list()` for a side-filtered memo in all six places it indexed it, and adds an empty-state branch because a side can now have no servers at all; `titlebar.tsx` loses the Home button and its `home.toggle` command (`mod+b`) and filters the tab strip, its reorder and `tab.prev`/`tab.next` to the visible side; `effectiveSide` falls back to Code only where the platform has no sandbox support — deliberately not on an empty sandbox list, which is indistinguishable from one that has not loaded; `layout.tsx` also holds an effect keeping `home.selection.server` on the showing side, because a sandbox joins the server list only once it has finished starting, so the one-shot correction inside a switch cannot cover a sandbox created moments earlier; the fork-only `DialogAddSandbox` gains an `onCreated` callback so the Work gate switches on the create's own result rather than on the asynchronous sandbox list; `dialog-select-server.tsx` `handleRemove` calls `workspace.removedServer(key)` beside the tab cleanup, which steps back to Code when the sandbox being deleted is the last one; i18n gains `workspace.{code,work,empty.none,empty.starting}`. | Sandboxes were just another row in a list of servers, so the isolated environment and the local machine were one undifferentiated pile. Splitting them makes "am I running on my machine or in a sandbox?" a property of where you are rather than something to infer from a server name. The Home button went because it opened a draft rather than a home screen, which is the behaviour the tabs replace: a tab returns you to that side's last session, falling back to a draft and then to the home screen. All the logic lives in the fork-only module; the upstream files hold wiring only, and every section below the tabs is the same component reading a filtered list. | Keep the wiring and take upstream's surrounding changes. The six `sideServers()` call sites in `app-sidebar.tsx` were `global.servers.list()` — if upstream adds a seventh, filter it too or that branch will show sandboxes under Code. Read `useWorkspaceSide()` rather than `layout.home.workspace()`: it collapses to Code where the platform has no sandbox support, which is what keeps the web build coherent. The tab strip filter and `reorderWithinSide` go together — `tabs.reorder()` rejects a partial key list, so a filtered strip without the merge silently ignores drags. |
-| Per-folder Work tab strip: `packages/app/src/context/{layout,tabs}.tsx`, `packages/app/src/components/titlebar.tsx`, `packages/app/src/pages/home.tsx` | Extends the Code/Work split row above so the Work side keeps one tab strip per **mounted folder** of a sandbox rather than one for the whole side. A sandbox exposes its project folder plus every extra mount as its own project (fork-only `src/sandbox/projects.ts`), so the strip is keyed by (sandbox, folder). All of the logic is in the fork-only `src/workspace/side.ts` (`TabScope`, `folderForDirectory`, `tabsForScope`, `reorderWithinScope` — `tabsForSide`/`reorderWithinSide` become thin wrappers over the same primitives so the Code path is provably unchanged) and `src/workspace/tabs.tsx` (`useWorkspaceScope`, `useWorkspaceFolder`). Upstream files hold wiring only: `tabs.tsx` adds `SessionTab.directory` — deliberately **not** part of `tabKey()`, which is what keeps every persisted `info`/`memory`/`closed` key valid as the field appears — plus `backfillTabDirectories` and the effect that applies it, `promoteDraft` inheriting the draft's directory, `addSessionTab` filling a missing directory on an already-open tab, and an optional `within` on `closeTab`/`removeTab` so the successor after a close is picked from the visible list; `layout.tsx` adds `home.lastFolderSession` (keyed `` `${server}\n${pathKey(directory)}` ``) with `folderSession`/`recordFolderSession`; `titlebar.tsx` swaps the side filter for the scope in the strip, reorder, `tab.prev`/`tab.next` and `tab.close`, passes `session.directory` to `addSessionTab`, and gains a leading scope branch in `openNewTab`; `home.tsx` passes `directory` to `addSessionTab`. `pages/home-model.tsx` (fork-only) selects instead of toggling on Work and calls `enterFolder`. The scope is **derived**, never stored: the folder on screen wins, then the sidebar's selected project, then the sandbox's primary folder from `sync.data.path.directory` — so deep links, the command palette and a reopened tab all pull the strip to their own folder instead of leaving the active tab out of view. `pages/session.tsx` and `components/command-palette.ts` are deliberately left untouched; their tabs pick up a folder through the back-fill instead, which keeps two more upstream files out of this table. | Sandboxes gained extra folder mounts, so one sandbox can expose several unrelated folders while their sessions shared a single strip — working across two folders meant constantly reading past the other one's tabs. Deriving the scope rather than persisting a "current folder" makes "the strip always contains the session you are looking at" true by construction instead of by an effect racing the two `home` effects in `layout.tsx`, and it keeps the sidebar's Recents unnarrowed: writing the route's folder into `home.selection.directory` would feed `projectDirectories()` and silently filter that list too. | Keep the wiring and take upstream's surrounding changes. `tabsForScope` must return the store's own tab objects — the strip compares by reference for the active highlight, the close index and the `` key, so never map to copies. Never add `directory` to `tabKey()`. A tab whose folder cannot be resolved shows in **every** folder of its own sandbox on purpose: it is exactly the tab that has never rendered and so never had a chance to learn where it belongs, and being invisible everywhere is unrecoverable. `folderForDirectory`'s prefix test must stay path-boundary aware — mount guest names dedup with a `-2` suffix, so `/mnt/shared` must not swallow `/mnt/shared-2`. Resolve the primary folder from `sync.data.path`, never `projects.list()[0]`: `projects.open()` prepends, so that is the most recently added mount. |
+| Per-folder Work tab strip: `packages/app/src/context/{layout,tabs}.tsx`, `packages/app/src/components/titlebar.tsx`, `packages/app/src/pages/home.tsx` | Extends the Code/Work split row above so the Work side keeps one tab strip per **mounted folder** of a sandbox rather than one for the whole side. A sandbox exposes its project folder plus every extra mount as its own project (fork-only `src/sandbox/projects.ts`), so the strip is keyed by (sandbox, folder). All of the logic is in the fork-only `src/workspace/side.ts` (`TabScope`, `folderForDirectory`, `tabsForScope`, `reorderWithinScope` — `tabsForSide`/`reorderWithinSide` become thin wrappers over the same primitives so the Code path is provably unchanged) and `src/workspace/tabs.tsx` (`useWorkspaceScope`, `useWorkspaceFolder`). Upstream files hold wiring only: `tabs.tsx` adds `SessionTab.directory` — deliberately **not** part of `tabKey()`, which is what keeps every persisted `info`/`memory`/`closed` key valid as the field appears — plus `backfillTabDirectories` and the effect that applies it, `promoteDraft` inheriting the draft's directory, `addSessionTab` filling a missing directory on an already-open tab, and an optional `within` on `closeTab`/`removeTab` so the successor after a close is picked from the visible list, plus `removeDirectory({server, directory})` — every tab of one mounted folder, sessions and drafts, for when that folder is unmounted (matching lives in the fork-only `src/workspace/tab-directory.ts`, its own module because both `context/tabs.tsx` and `workspace/side.ts` reach the router at import time and so cannot be unit-tested). Modelled on `removeServer` rather than `closeTab`: the folder left the sandbox, so the tabs are not a close the user can undo, and leaving them behind is what made an unmounted folder's session reappear over the next folder the user mounted — `inScope()` shows a tab whose folder the sandbox no longer lists in *every* folder, so it followed the user around while appearing in no folder's Recents; `layout.tsx` adds `home.lastFolderSession` (keyed `` `${server}\n${pathKey(directory)}` ``) with `folderSession`/`recordFolderSession`; `titlebar.tsx` swaps the side filter for the scope in the strip, reorder, `tab.prev`/`tab.next` and `tab.close`, passes `session.directory` to `addSessionTab`, and gains a leading scope branch in `openNewTab`; `home.tsx` passes `directory` to `addSessionTab`. `pages/home-model.tsx` (fork-only) selects instead of toggling on Work and calls `enterFolder`. The scope is **derived**, never stored: the folder on screen wins, then the sidebar's selected project, then the sandbox's primary folder from `sync.data.path.directory` — so deep links, the command palette and a reopened tab all pull the strip to their own folder instead of leaving the active tab out of view. `pages/session.tsx` and `components/command-palette.ts` are deliberately left untouched; their tabs pick up a folder through the back-fill instead, which keeps two more upstream files out of this table. | Sandboxes gained extra folder mounts, so one sandbox can expose several unrelated folders while their sessions shared a single strip — working across two folders meant constantly reading past the other one's tabs. Deriving the scope rather than persisting a "current folder" makes "the strip always contains the session you are looking at" true by construction instead of by an effect racing the two `home` effects in `layout.tsx`, and it keeps the sidebar's Recents unnarrowed: writing the route's folder into `home.selection.directory` would feed `projectDirectories()` and silently filter that list too. | Keep the wiring and take upstream's surrounding changes. `tabsForScope` must return the store's own tab objects — the strip compares by reference for the active highlight, the close index and the `` key, so never map to copies. Never add `directory` to `tabKey()`. A tab whose folder cannot be resolved shows in **every** folder of its own sandbox on purpose: it is exactly the tab that has never rendered and so never had a chance to learn where it belongs, and being invisible everywhere is unrecoverable. `folderForDirectory`'s prefix test must stay path-boundary aware — mount guest names dedup with a `-2` suffix, so `/mnt/shared` must not swallow `/mnt/shared-2`. Resolve the primary folder from `sync.data.path`, never `projects.list()[0]`: `projects.open()` prepends, so that is the most recently added mount. |
| Code/Work workspace split: `packages/app/src/context/layout.tsx`, `packages/app/src/components/{app-sidebar,titlebar}.tsx`, `packages/app/src/i18n/{en,es}.ts` | The sidebar gains two tabs above Projects — **Code** (every non-sandbox server: the local sidecar, WSL, remote, SSH) and **Work** (the sandboxes) — backed by the fork-only `packages/app/src/workspace/{side.ts,tabs.tsx}`. `layout.tsx` stores `home.workspace` and `home.lastSession` (per side) and records the latter from an effect on `route()`; `app-sidebar.tsx` renders ``, swaps `global.servers.list()` for a side-filtered memo in all six places it indexed it, and adds an empty-state branch because a side can now have no servers at all; `titlebar.tsx` loses the Home button and its `home.toggle` command (`mod+b`) and filters the tab strip, its reorder and `tab.prev`/`tab.next` to the visible side; `effectiveSide` falls back to Code only where the platform has no sandbox support — deliberately not on an empty sandbox list, which is indistinguishable from one that has not loaded; `layout.tsx` also holds an effect keeping `home.selection.server` on the showing side, because a sandbox joins the server list only once it has finished starting, so the one-shot correction inside a switch cannot cover a sandbox created moments earlier; the fork-only `DialogAddSandbox` gains an `onCreated` callback so the Work gate switches on the create's own result rather than on the asynchronous sandbox list; `dialog-select-server.tsx` `handleRemove` calls `workspace.removedServer(key)` beside the tab cleanup, which steps back to Code when the sandbox being deleted is the last one; i18n gains `workspace.{code,work,empty.none,empty.starting,empty.stopped}` (the last for a Work side whose sandboxes are all stopped, which unlike starting or failed will never come up on its own). | Sandboxes were just another row in a list of servers, so the isolated environment and the local machine were one undifferentiated pile. Splitting them makes "am I running on my machine or in a sandbox?" a property of where you are rather than something to infer from a server name. The Home button went because it opened a draft rather than a home screen, which is the behaviour the tabs replace: a tab returns you to that side's last session, falling back to a draft and then to the home screen. All the logic lives in the fork-only module; the upstream files hold wiring only, and every section below the tabs is the same component reading a filtered list. | Keep the wiring and take upstream's surrounding changes. The six `sideServers()` call sites in `app-sidebar.tsx` were `global.servers.list()` — if upstream adds a seventh, filter it too or that branch will show sandboxes under Code. Read `useWorkspaceSide()` rather than `layout.home.workspace()`: it collapses to Code where the platform has no sandbox support, which is what keeps the web build coherent. The tab strip filter and `reorderWithinSide` go together — `tabs.reorder()` rejects a partial key list, so a filtered strip without the merge silently ignores drags. |
| `packages/app/src/context/server.tsx` | `createServerProjects.close()` wraps its two store writes — `remove(directory)` on `projects`, then the `recentlyClosed` record — in `batch()` (already imported). The recently-closed list is computed before the batch so the pair is one update. | The two writes are separate store keys and Solid flushes effects after each one, so between them a project is in neither list. Nothing upstream observes that gap, but the fork's `src/sandbox/projects.ts` seeding effect reconciles against **both** lists, saw a mounted folder that was neither open nor closed, and opened it straight back up — closing a Work folder was undone the instant it happened. | Keep the batch. On conflict take upstream's `close()` and re-wrap the two writes. Any future write pair that spans `projects` and `recentlyClosed` needs the same treatment; regression cover is `packages/app/test-browser/sandbox-projects.test.ts` ("closing a mounted folder sticks when the guest knows the project"). |
diff --git a/packages/app/src/components/app-sidebar.tsx b/packages/app/src/components/app-sidebar.tsx
index cf87ac375b..dfeeb5c4f5 100644
--- a/packages/app/src/components/app-sidebar.tsx
+++ b/packages/app/src/components/app-sidebar.tsx
@@ -36,6 +36,8 @@ import {
HomeServerRow,
HomeUtilityNav,
} from "@/pages/home"
+import { SandboxMountPrompt } from "@/sandbox/mount-prompt"
+import { isPlaceholderSandbox } from "@/sandbox/projects"
import { SidebarSection } from "@/components/sidebar-section"
import { createInlineEditorController } from "@/pages/layout/inline-editor"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
@@ -115,7 +117,16 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool
data-action={side() === "work" ? "work-mount-project" : "home-add-project"}
variant="ghost-muted"
size="large"
- class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
+ class={`titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted${
+ // Fork: icon-button-v2.css sets cursor: default on every icon
+ // button, and Tailwind utilities sit in @layer utilities, so
+ // the unlayered component rule wins without the important
+ // modifier. `enabled:` leaves the disabled state on the
+ // not-allowed cursor the variant already gives it. Work only:
+ // this is the button that mounts a folder into the sandbox,
+ // and Code's Add project is upstream's, untouched.
+ side() === "work" ? " enabled:cursor-pointer!" : ""
+ }`}
icon={}
disabled={global.servers.health[ServerConnection.key(sideServers()[0]!)]?.healthy === false}
onClick={() => {
@@ -144,14 +155,24 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool
0}
fallback={
- model.addProjects(conn, [directory])}
- language={language}
- />
+ // Fork: a sandbox whose only folder is a placeholder has
+ // nothing to list, and nothing upstream's picker could add —
+ // that picker browses the guest. Ask for a mount instead.
+ model.addProjects(conn, [directory])}
+ language={language}
+ />
+ }
+ >
+
+
}
>
(tab.type === "draft" ? [tab.draftID] : []))
+ const active = removed.includes(recentKey() ?? "") && location.pathname !== "/"
+ updateClosed((stack) => stack.filter((entry) => !removed.includes(tabKey(entry.tab))))
+ void startTransition(() => {
+ setStore((tabs) => tabs.filter((tab) => !removed.includes(tabKey(tab))))
+ // Home rather than a neighbouring tab: the folder that was on screen
+ // is gone, and home is where the sandbox's remaining folders are.
+ if (active) {
+ setRecentKey(undefined)
+ navigate("/")
+ }
+ })
+ for (const key of removed) memory.remove(key)
+ for (const key of removed) removeInfo(key)
+ for (const draftID of drafts) removeDraftPersisted(draftID)
+ },
removeSessions: (input: SessionTabsRemovedDetail) => {
const targetServer = input.server ?? server.key
updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs))
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index a153e2b98c..d1fbdab015 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -386,6 +386,12 @@ export const dict = {
"workspace.empty.starting": "Your sandbox is not running yet. It appears here once it is ready.",
"workspace.empty.stopped": "Your sandbox is stopped. Start it again from Settings → Servers.",
"sandbox.add.workIntro": "Work requires a sandbox environment to run sessions.",
+ "sandbox.addDefault.title": "Set up your sandbox",
+ "sandbox.addDefault.intro":
+ "Work runs your sessions in a sandbox: an isolated environment on this computer. Setting one up downloads its image, which takes a few minutes. You choose which of your folders it can reach once it is ready.",
+ "sandbox.addDefault.create": "Set up",
+ "sandbox.mountPrompt.body": "Your sandbox is ready. Mount a project to start working in it.",
+ "sandbox.mountPrompt.action": "Mount a project",
"sandbox.server.add": "Add sandbox",
"sandbox.server.label": "Sandbox",
"sandbox.server.menu.label": "Sandbox",
@@ -438,11 +444,11 @@ export const dict = {
"sandbox.add.downloadProgress": "Downloading image… {{done}}/{{total}} MB",
"sandbox.add.creating": "Creating the sandbox\u2026 this takes a few minutes.",
"sandbox.mounts.title": "Mount project",
- "sandbox.mounts.hint": "Folders mounted here are the only ones on this computer the sandbox can reach.",
+ "sandbox.mounts.hint": "Projects mounted here are the only folders on this computer the sandbox can reach.",
"sandbox.mounts.primary": "Project folder",
- "sandbox.mounts.empty": "No extra folders are mounted.",
- "sandbox.mounts.add": "Add folder...",
- "sandbox.mounts.remove": "Remove folder",
+ "sandbox.mounts.empty": "No projects are mounted yet.",
+ "sandbox.mounts.add": "Add project...",
+ "sandbox.mounts.remove": "Remove project",
"sandbox.mounts.unmount": "Unmount",
"sandbox.mounts.save": "Save",
"sandbox.mounts.restartWarning":
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index e821374099..2e4a66dfa6 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -378,6 +378,12 @@ export const dict = {
"workspace.empty.starting": "Tu sandbox todavía no está en marcha. Aparecerá aquí cuando esté listo.",
"workspace.empty.stopped": "Tu sandbox está detenido. Vuelve a iniciarlo desde Ajustes → Servidores.",
"sandbox.add.workIntro": "Trabajo requiere un entorno de sandbox para ejecutar sesiones.",
+ "sandbox.addDefault.title": "Configura tu sandbox",
+ "sandbox.addDefault.intro":
+ "Trabajo ejecuta tus sesiones en un sandbox: un entorno aislado en este equipo. Configurarlo descarga su imagen, lo que tarda unos minutos. Tú eliges a qué carpetas tuyas puede acceder cuando esté listo.",
+ "sandbox.addDefault.create": "Configurar",
+ "sandbox.mountPrompt.body": "Tu sandbox está listo. Monta un proyecto para empezar a trabajar en él.",
+ "sandbox.mountPrompt.action": "Montar un proyecto",
"sandbox.server.add": "Añadir sandbox",
"sandbox.server.label": "Sandbox",
"sandbox.server.menu.label": "Sandbox",
@@ -431,11 +437,12 @@ export const dict = {
"sandbox.add.downloadProgress": "Descargando imagen… {{done}}/{{total}} MB",
"sandbox.add.creating": "Creando el sandbox\u2026 esto tarda unos minutos.",
"sandbox.mounts.title": "Montar proyecto",
- "sandbox.mounts.hint": "Las carpetas montadas aquí son las únicas de este equipo a las que el sandbox puede acceder.",
+ "sandbox.mounts.hint":
+ "Los proyectos montados aquí son las únicas carpetas de este equipo a las que el sandbox puede acceder.",
"sandbox.mounts.primary": "Carpeta del proyecto",
- "sandbox.mounts.empty": "No hay carpetas adicionales montadas.",
- "sandbox.mounts.add": "Añadir carpeta...",
- "sandbox.mounts.remove": "Quitar carpeta",
+ "sandbox.mounts.empty": "Aún no hay proyectos montados.",
+ "sandbox.mounts.add": "Añadir proyecto...",
+ "sandbox.mounts.remove": "Quitar proyecto",
"sandbox.mounts.unmount": "Desmontar",
"sandbox.mounts.save": "Guardar",
"sandbox.mounts.restartWarning":
diff --git a/packages/app/src/sandbox/dialog-add-default.tsx b/packages/app/src/sandbox/dialog-add-default.tsx
new file mode 100644
index 0000000000..247f0296a1
--- /dev/null
+++ b/packages/app/src/sandbox/dialog-add-default.tsx
@@ -0,0 +1,145 @@
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { showToast } from "@opencode-ai/ui/toast"
+import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
+import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
+import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2"
+import { Show } from "solid-js"
+import { createStore } from "solid-js/store"
+import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
+import type { SandboxConfig } from "./types"
+import { useSandboxes } from "./context"
+import { sandboxCreatePhase } from "./create-phase"
+import { createSandboxImageField } from "./image-field"
+
+// The sandbox Work offers when there is none: everything DialogAddSandbox asks
+// for has an answer the app can give, so it asks for none of it. The name, the
+// folder and the limits are the main process's business (main/sandbox/ipc.ts);
+// the image stays because supplying one is a real choice on a machine that
+// cannot reach the storage host.
+//
+// DialogAddSandbox is untouched and still reachable from Settings → Servers —
+// that is where a second sandbox, or one pointed at a particular folder, gets
+// made.
+export function DialogAddDefaultSandbox(props: { onCreated?: (config: SandboxConfig) => void }) {
+ const language = useLanguage()
+ const platform = usePlatform()
+ const dialog = useDialog()
+ const sandboxes = useSandboxes()
+ const api = platform.sandboxes!
+ const [store, setStore] = createStore({ adding: false })
+ const image = createSandboxImageField()
+
+ const availability = () => sandboxes.availability.data
+ const unsupportedMessage = () => {
+ const value = availability()
+ return value && !value.supported ? value.message : null
+ }
+ const warning = () => {
+ const value = availability()
+ return value?.supported ? value.warning : null
+ }
+
+ const createJob = () => sandboxes.state.data?.jobs.find((entry) => entry.kind === "create")
+ const createPhase = () => sandboxCreatePhase({ adding: store.adding, job: createJob() })
+ const downloadProgress = () => (createPhase() === "downloading" ? createJob()?.progress : undefined)
+ const statusMessage = () => {
+ const phase = createPhase()
+ if (phase === "queued") return language.t("sandbox.job.queued")
+ if (phase === "creating") return language.t("sandbox.add.creating")
+ return null
+ }
+
+ const create = async () => {
+ if (store.adding || availability()?.supported === false || !image.usable()) return
+ setStore("adding", true)
+ try {
+ // Resolves undefined when the download was cancelled: keep the dialog
+ // open so the user can try again rather than dropping them back on Code
+ // with nothing to show for it.
+ const created = await api.addDefault(image.path())
+ if (created) {
+ props.onCreated?.(created)
+ dialog.close()
+ }
+ } catch (error) {
+ showToast({
+ variant: "error",
+ title: language.t("common.requestFailed"),
+ description: error instanceof Error ? error.message : String(error),
+ })
+ } finally {
+ setStore("adding", false)
+ }
+ }
+
+ // Its own channel rather than api.cancel(name): the name belongs to the main
+ // process, and a sandbox being created is not in the sandbox list yet — the
+ // controller only publishes it once the create has finished — so there is
+ // nothing here to read it off.
+ const cancel = () => {
+ if (!store.adding) {
+ dialog.close()
+ return
+ }
+ void api.cancelDefault()
+ }
+
+ return (
+
}
+ >
+ {/* warn-never-block: the warning is shown, creation stays enabled */}
+ {(text) =>
}
+
+
+
+
+ {language.t("common.cancel")}
+
+
+ void create()}
+ >
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/app/src/sandbox/dialog-add.tsx b/packages/app/src/sandbox/dialog-add.tsx
index 9017cc7a6a..209213f4ff 100644
--- a/packages/app/src/sandbox/dialog-add.tsx
+++ b/packages/app/src/sandbox/dialog-add.tsx
@@ -16,9 +16,10 @@ import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
-import type { SandboxConfig, SandboxImageProblem } from "./types"
+import type { SandboxConfig } from "./types"
import { useSandboxes } from "./context"
import { sandboxCreatePhase } from "./create-phase"
+import { createSandboxImageField } from "./image-field"
import { sandboxNameUsable, validateSandboxName } from "./name-model"
import {
SANDBOX_CPUS_RANGE,
@@ -50,14 +51,8 @@ export function DialogAddSandbox(props: {
cpus: "",
memoryGiB: "",
adding: false,
- imagePath: "",
- imageChecking: false,
- imageProblem: null as SandboxImageProblem | null,
- // The path the verdict in imageProblem belongs to. A field edited since
- // then is unchecked, not approved — without this a typed path would show
- // as fine until the create failed on it.
- imageCheckedPath: null as string | null,
})
+ const image = createSandboxImageField()
// Only the macOS VM backend applies per-sandbox limits; WSL's are VM-global
// and bubblewrap has none, so the fields appear only where they take effect.
@@ -93,61 +88,12 @@ export function DialogAddSandbox(props: {
if (error.code === "duplicate") return language.t("sandbox.add.nameDuplicate")
return language.t("sandbox.add.nameInvalid")
}
- const imageMessage = () => {
- const problem = store.imageProblem
- if (!problem) return null
- if (problem.code === "wrong-arch")
- return language.t("sandbox.add.imageWrongArch", {
- found: language.t(problem.found === "x64" ? "sandbox.arch.x64" : "sandbox.arch.arm64"),
- })
- if (problem.code === "wrong-os")
- return language.t(problem.found === "vz" ? "sandbox.add.imageWrongOsMac" : "sandbox.add.imageWrongOsOther")
- if (problem.code === "not-gzip") return language.t("sandbox.add.imageNotArchive")
- if (problem.code === "unreadable") return language.t("sandbox.add.imageUnreadable")
- return language.t("sandbox.add.imageNotSandbox")
- }
const canCreate = () =>
availability()?.supported === true &&
sandboxNameUsable(store.name, takenNames()) &&
store.projectDir.length > 0 &&
resourcesValid() &&
- // An unchecked image is not an approved one.
- (!store.imagePath || (store.imageCheckedPath === store.imagePath && !store.imageProblem))
-
- // The main process is the authority on what counts as usable here; the
- // dialog only renders its verdict. Runs when a file is picked and when a
- // hand-typed path is committed, never per keystroke.
- const checkImage = async (path: string) => {
- if (!path || store.imageCheckedPath === path) return
- setStore({ imageChecking: true, imageProblem: null })
- // A failed round-trip is reported as unreadable rather than as "fine":
- // null is the answer for a usable image, so it must not double as an error.
- const problem = await api.inspectImage(path).catch((): SandboxImageProblem => ({ code: "unreadable" }))
- // The field can move on during the round-trip; a stale verdict must not
- // be applied to a path the user has since changed.
- if (store.imagePath !== path) return
- setStore({ imageChecking: false, imageProblem: problem, imageCheckedPath: path })
- }
-
- const pickImage = () => {
- if (platform.platform !== "desktop") return
- void platform
- .openFilePathDialog({ title: language.t("sandbox.add.image"), extensions: ["gz", "tgz"] })
- .then((path) => {
- if (!path) return
- setStore({ imagePath: path, imageProblem: null, imageCheckedPath: null })
- return checkImage(path)
- })
- // A picker that fails has to say so: swallowing it leaves the field
- // empty after the user chose a file, which reads as the app ignoring them.
- .catch((error) =>
- showToast({
- variant: "error",
- title: language.t("common.requestFailed"),
- description: error instanceof Error ? error.message : String(error),
- }),
- )
- }
+ image.usable()
const pickProjectDir = () => {
if (platform.platform !== "desktop") return
@@ -178,7 +124,7 @@ export function DialogAddSandbox(props: {
store.name,
store.projectDir,
resourcesApply() ? { cpus: cpus(), memoryMiB: memoryMiB() } : undefined,
- store.imagePath || undefined,
+ image.path(),
)
if (created) {
props.onCreated?.(created)
@@ -253,43 +199,7 @@ export function DialogAddSandbox(props: {
{language.t("sandbox.add.choose")}
- {/* Optional: most people want the published image downloaded for
- them, so the hint leads with that and explains what the field is
- for before offering the picker. */}
-
-
+
{/* Says what the numbers buy. The defaults live in the
placeholders and the ceilings in the out-of-range message, so
diff --git a/packages/app/src/sandbox/dialog-mounts.tsx b/packages/app/src/sandbox/dialog-mounts.tsx
index 60487a6f4e..4c53f17f7b 100644
--- a/packages/app/src/sandbox/dialog-mounts.tsx
+++ b/packages/app/src/sandbox/dialog-mounts.tsx
@@ -8,10 +8,14 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2"
import { For, Show } from "solid-js"
import { createStore } from "solid-js/store"
+import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
+import { ServerConnection } from "@/context/server"
+import { useTabs } from "@/context/tabs"
import { useSandboxes } from "./context"
-import { validateSandboxMounts } from "./mounts-model"
+import { forgetSandboxFolder } from "./forget-folder"
+import { unmountedGuestPaths, validateSandboxMounts } from "./mounts-model"
// Manages the extra host folders mounted into one sandbox. The original
// project folder stays fixed (it anchors the sandbox's identity); everything
@@ -50,6 +54,8 @@ function SandboxMountsEditor(props: { id: string }) {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
+ const global = useGlobal()
+ const tabs = useTabs()
const sandboxes = useSandboxes()
const api = platform.sandboxes!
@@ -99,11 +105,26 @@ function SandboxMountsEditor(props: { id: string }) {
const save = async () => {
if (!canSave()) return
setStore("saving", true)
+ // Read before the save: a folder's guest path only exists while the
+ // sandbox has it mounted, and this is the last moment it can be looked up.
+ const runtime = item()?.runtime
+ const unmounted = unmountedGuestPaths(runtime?.kind === "ready" ? runtime.mounts : [], store.paths)
try {
await api.updateMounts(
props.id,
store.paths.map((path) => ({ path })),
)
+ // Same as the project row's Unmount: a folder that has left the sandbox
+ // takes its project row and its session tabs with it.
+ // The connection carries the branded server key the tab store is keyed
+ // by; props.id is the same string untyped, and is missing from the list
+ // only while the sandbox is not ready — when it has no tabs to lose.
+ const conn = global.servers.list().find((entry) => ServerConnection.key(entry) === props.id)
+ if (conn) {
+ const server = ServerConnection.key(conn)
+ const projects = global.ensureServerCtx(conn).projects
+ for (const directory of unmounted) forgetSandboxFolder({ server, directory, projects, tabs })
+ }
dialog.close()
} catch (error) {
showToast({
@@ -124,10 +145,15 @@ function SandboxMountsEditor(props: { id: string }) {
+ {/* A placeholder project folder is the app's own scratch directory in
+ app data, hidden everywhere else — listing it here would be the one
+ place the user meets a path they did not choose and cannot remove. */}
+
+
+ {language.t("sandbox.mounts.empty")}}
diff --git a/packages/app/src/sandbox/forget-folder.test.ts b/packages/app/src/sandbox/forget-folder.test.ts
new file mode 100644
index 0000000000..1c673ef122
--- /dev/null
+++ b/packages/app/src/sandbox/forget-folder.test.ts
@@ -0,0 +1,28 @@
+import { expect, test } from "bun:test"
+import type { ServerConnection } from "@/context/server"
+import { forgetSandboxFolder } from "./forget-folder"
+
+const server = "sandbox:default-sandbox" as unknown as ServerConnection.Key
+
+function recorder() {
+ const removedProjects: string[] = []
+ const removedTabs: { server: ServerConnection.Key; directory: string }[] = []
+ return {
+ removedProjects,
+ removedTabs,
+ projects: { remove: (directory: string) => void removedProjects.push(directory) },
+ tabs: {
+ removeDirectory: (input: { server: ServerConnection.Key; directory: string }) => void removedTabs.push(input),
+ },
+ }
+}
+
+test("a folder leaving a sandbox takes its project row and its tabs with it", () => {
+ const seen = recorder()
+ forgetSandboxFolder({ server, directory: "/mnt/api", projects: seen.projects, tabs: seen.tabs })
+ expect(seen.removedProjects).toEqual(["/mnt/api"])
+ // Without this the session tabs survive: their sessions are still in the
+ // guest, so they reopen against a folder the sandbox no longer has, showing
+ // in every remaining folder and in no folder's Recents.
+ expect(seen.removedTabs).toEqual([{ server, directory: "/mnt/api" }])
+})
diff --git a/packages/app/src/sandbox/forget-folder.ts b/packages/app/src/sandbox/forget-folder.ts
new file mode 100644
index 0000000000..d242c98c3e
--- /dev/null
+++ b/packages/app/src/sandbox/forget-folder.ts
@@ -0,0 +1,31 @@
+import type { ServerConnection } from "@/context/server"
+
+// What has to happen when a folder leaves a sandbox, wherever the user did it
+// from — the project row's Unmount, or removing it in the Mount project dialog.
+//
+// The row is only half of it. Sessions that ran in the folder stay in the
+// guest's database, but the folder is no longer part of the sandbox, so a tab
+// left open on one is a conversation that cannot be continued in a folder that
+// no longer exists. Worse, it is a tab whose directory the sandbox no longer
+// lists, and workspace/side.ts shows those in *every* folder — so it follows
+// the user into whatever they mount next while appearing in no folder's
+// Recents, which is exactly how it looks like a session that came back from
+// nowhere.
+//
+// Structural parameters rather than the contexts themselves, so the rule is
+// testable without standing up a ctx (same reason src/sandbox/projects.ts
+// takes its ctx slice structurally).
+export function forgetSandboxFolder(input: {
+ server: ServerConnection.Key
+ directory: string
+ projects: { remove: (directory: string) => void }
+ tabs: { removeDirectory: (input: { server: ServerConnection.Key; directory: string }) => void }
+}) {
+ // remove(), not close(): the row is going because its folder left the
+ // sandbox, which is the "internal, non-user removal" the project store
+ // documents. close() would record it as closed, and the seeding effect
+ // honours that — mounting the folder again would leave the user with no
+ // project for it.
+ input.projects.remove(input.directory)
+ input.tabs.removeDirectory({ server: input.server, directory: input.directory })
+}
diff --git a/packages/app/src/sandbox/image-field.tsx b/packages/app/src/sandbox/image-field.tsx
new file mode 100644
index 0000000000..e9608885fd
--- /dev/null
+++ b/packages/app/src/sandbox/image-field.tsx
@@ -0,0 +1,131 @@
+import { showToast } from "@opencode-ai/ui/toast"
+import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
+import { Show } from "solid-js"
+import { createStore } from "solid-js/store"
+import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
+import type { SandboxImageProblem } from "./types"
+
+// The optional custom-image field, shared by both create dialogs: the full one
+// in Settings and the one Work opens when there is no sandbox yet. It is the
+// only field the two have in common, and the only one with behaviour worth
+// getting wrong twice — the verdict has to belong to the path it was made
+// about, and a picker failure has to be visible.
+export function createSandboxImageField() {
+ const language = useLanguage()
+ const platform = usePlatform()
+ const api = platform.sandboxes!
+ const [store, setStore] = createStore({
+ path: "",
+ checking: false,
+ problem: null as SandboxImageProblem | null,
+ // The path the verdict in problem belongs to. A field edited since then is
+ // unchecked, not approved — without this a typed path would show as fine
+ // until the create failed on it.
+ checkedPath: null as string | null,
+ })
+
+ // The main process is the authority on what counts as usable here; the
+ // dialog only renders its verdict. Runs when a file is picked and when a
+ // hand-typed path is committed, never per keystroke.
+ const check = async (path: string) => {
+ if (!path || store.checkedPath === path) return
+ setStore({ checking: true, problem: null })
+ // A failed round-trip is reported as unreadable rather than as "fine":
+ // null is the answer for a usable image, so it must not double as an error.
+ const problem = await api.inspectImage(path).catch((): SandboxImageProblem => ({ code: "unreadable" }))
+ // The field can move on during the round-trip; a stale verdict must not be
+ // applied to a path the user has since changed.
+ if (store.path !== path) return
+ setStore({ checking: false, problem, checkedPath: path })
+ }
+
+ const pick = () => {
+ if (platform.platform !== "desktop") return
+ void platform
+ .openFilePathDialog({ title: language.t("sandbox.add.image"), extensions: ["gz", "tgz"] })
+ .then((path) => {
+ if (!path) return
+ setStore({ path, problem: null, checkedPath: null })
+ return check(path)
+ })
+ // A picker that fails has to say so: swallowing it leaves the field empty
+ // after the user chose a file, which reads as the app ignoring them.
+ .catch((error) =>
+ showToast({
+ variant: "error",
+ title: language.t("common.requestFailed"),
+ description: error instanceof Error ? error.message : String(error),
+ }),
+ )
+ }
+
+ const message = () => {
+ const problem = store.problem
+ if (!problem) return null
+ if (problem.code === "wrong-arch")
+ return language.t("sandbox.add.imageWrongArch", {
+ found: language.t(problem.found === "x64" ? "sandbox.arch.x64" : "sandbox.arch.arm64"),
+ })
+ if (problem.code === "wrong-os")
+ return language.t(problem.found === "vz" ? "sandbox.add.imageWrongOsMac" : "sandbox.add.imageWrongOsOther")
+ if (problem.code === "not-gzip") return language.t("sandbox.add.imageNotArchive")
+ if (problem.code === "unreadable") return language.t("sandbox.add.imageUnreadable")
+ return language.t("sandbox.add.imageNotSandbox")
+ }
+
+ return {
+ // Empty means "download the published image", which is the normal path.
+ path: () => store.path || undefined,
+ // An unchecked image is not an approved one.
+ usable: () => !store.path || (store.checkedPath === store.path && !store.problem),
+ Field: (props: { disabled: boolean }) => (
+ <>
+ {/* Optional: most people want the published image downloaded for them,
+ so the hint leads with that and explains what the field is for
+ before offering the picker. */}
+
+
+ {/* The only line here that reports a problem, so it is the only one
+ in the danger colour — "checking" and "this image can be used" sit
+ beside it in the muted one. role="alert" matches how the other v2
+ dialogs announce an inline validation failure. */}
+
+ {(text) => (
+
+ {text()}
+
+ )}
+
+
+
{language.t("sandbox.add.imageOk")}
+
+ >
+ ),
+ }
+}
diff --git a/packages/app/src/sandbox/mount-prompt.tsx b/packages/app/src/sandbox/mount-prompt.tsx
new file mode 100644
index 0000000000..39764e4579
--- /dev/null
+++ b/packages/app/src/sandbox/mount-prompt.tsx
@@ -0,0 +1,34 @@
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { ServerConnection } from "@/context/server"
+import { useLanguage } from "@/context/language"
+
+// What the Work sidebar shows for a sandbox with no folders in it. Upstream's
+// HomeProjectEmpty offers to open a project folder, but its picker runs on the
+// server it is pointed at — inside the guest, where the only thing to browse is
+// the guest's own home. Mounting a host folder is the action that belongs here,
+// and for the sandbox Work creates it is the first thing the user has to do:
+// its project folder is a placeholder they never see.
+export function SandboxMountPrompt(props: { server: ServerConnection.Any }) {
+ const language = useLanguage()
+ const dialog = useDialog()
+ return (
+
+
{language.t("sandbox.mountPrompt.body")}
+
+
+ )
+}
diff --git a/packages/app/src/sandbox/mounts-model.test.ts b/packages/app/src/sandbox/mounts-model.test.ts
index c540ba3ef7..229ebbe6a5 100644
--- a/packages/app/src/sandbox/mounts-model.test.ts
+++ b/packages/app/src/sandbox/mounts-model.test.ts
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import type { ServerConnection } from "@/context/server"
-import { projectRemoval, validateSandboxMounts } from "./mounts-model"
+import { projectRemoval, unmountedGuestPaths, validateSandboxMounts } from "./mounts-model"
test("accepts distinct sibling folders", () => {
expect(validateSandboxMounts("/home/user/project", ["/home/user/docs", "/srv/data"])).toBeNull()
@@ -92,3 +92,21 @@ test("with no primary known yet, a mounted folder still resolves by its mount",
})
expect(projectRemoval({ server, worktree: "/workspace", primary: undefined })).toEqual({ kind: "close" })
})
+
+test("names the guest paths a save is about to unmount", () => {
+ const mounted = [
+ { path: "/Users/ana/work/api", guestPath: "/mnt/api" },
+ { path: "/Users/ana/work/web", guestPath: "/mnt/web" },
+ ]
+ expect(unmountedGuestPaths(mounted, ["/Users/ana/work/api"])).toEqual(["/mnt/web"])
+ expect(unmountedGuestPaths(mounted, [])).toEqual(["/mnt/api", "/mnt/web"])
+ // Adding a folder unmounts nothing.
+ expect(unmountedGuestPaths(mounted, [...mounted.map((m) => m.path), "/Users/ana/notes"])).toEqual([])
+ // A trailing separator or a different case is the same folder — the host
+ // paths here come from a picker on one side and a persisted config on the
+ // other, so they are not guaranteed to match literally.
+ expect(unmountedGuestPaths(mounted, ["/Users/ana/work/api/", "/Users/ana/work/web"])).toEqual([])
+ expect(unmountedGuestPaths([{ path: "C:\\Work\\API", guestPath: "/mnt/c/Work/API" }], ["c:\\work\\api"])).toEqual([])
+ // Nothing mounted yet: a sandbox saving its first folder has none to lose.
+ expect(unmountedGuestPaths([], ["/Users/ana/work/api"])).toEqual([])
+})
diff --git a/packages/app/src/sandbox/mounts-model.ts b/packages/app/src/sandbox/mounts-model.ts
index dba83229b6..1bfd49bb51 100644
--- a/packages/app/src/sandbox/mounts-model.ts
+++ b/packages/app/src/sandbox/mounts-model.ts
@@ -54,6 +54,15 @@ export function projectRemoval(input: {
return { kind: "unmount", path: mount.path }
}
+// The guest paths of folders a save is about to unmount: everything the guest
+// currently has mounted that the new list no longer names. Guest paths because
+// that is what a project row and a tab are keyed by, and they can only be read
+// from the running sandbox — once the mount is gone, so is the mapping.
+export function unmountedGuestPaths(mounted: { path: string; guestPath: string }[], kept: string[]) {
+ const keeping = new Set(kept.map((path) => comparableMountPath(path)))
+ return mounted.filter((mount) => !keeping.has(comparableMountPath(mount.path))).map((mount) => mount.guestPath)
+}
+
// Windows paths compare case-insensitively; detection is by shape (drive
// letter or backslash) so the model needs no platform flag.
function comparableMountPath(path: string) {
diff --git a/packages/app/src/sandbox/project-menu.tsx b/packages/app/src/sandbox/project-menu.tsx
index 55e8074fb1..2373e5fd40 100644
--- a/packages/app/src/sandbox/project-menu.tsx
+++ b/packages/app/src/sandbox/project-menu.tsx
@@ -3,11 +3,13 @@ import { createMemo, Show } from "solid-js"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
+import { useTabs } from "@/context/tabs"
import { ServerConnection } from "@/context/server"
import { showToast } from "@/utils/toast"
import { pathKey } from "@/utils/path-key"
import { primaryFolder } from "@/workspace/tabs"
import { useSandboxes } from "./context"
+import { forgetSandboxFolder } from "./forget-folder"
import { projectRemoval } from "./mounts-model"
// The tail of a project row's menu (packages/app/src/pages/home.tsx): its
@@ -22,6 +24,7 @@ export function ProjectRemovalMenuItems(props: {
const language = useLanguage()
const platform = usePlatform()
const global = useGlobal()
+ const tabs = useTabs()
const sandboxes = useSandboxes()
const removal = createMemo(() =>
@@ -63,13 +66,12 @@ export function ProjectRemovalMenuItems(props: {
}
// Only once the folder has actually left the sandbox: a failed unmount
// must not hide a row whose folder is still mounted.
- //
- // remove(), not the menu's close(): the row is going because its folder
- // left the sandbox, which is the "internal, non-user removal" that store
- // documents. close() would also record it as closed, and the seeding
- // effect honours that — mounting the folder again would leave the user
- // with no project for it.
- global.ensureServerCtx(props.server).projects.remove(props.worktree)
+ forgetSandboxFolder({
+ server: id,
+ directory: props.worktree,
+ projects: global.ensureServerCtx(props.server).projects,
+ tabs,
+ })
}
return (
diff --git a/packages/app/src/sandbox/projects.test.ts b/packages/app/src/sandbox/projects.test.ts
index 17d306d7af..ffdf60b730 100644
--- a/packages/app/src/sandbox/projects.test.ts
+++ b/packages/app/src/sandbox/projects.test.ts
@@ -1,8 +1,10 @@
import { expect, test } from "bun:test"
import type { ServerConnection } from "@/context/server"
-import { sandboxProjectHostName } from "./projects"
+import { isPlaceholderSandbox, sandboxProjectHostName } from "./projects"
-const sandbox = (input?: Partial<{ projectDir: string; mounts: { path: string; guestPath: string }[] }>) =>
+const sandbox = (
+ input?: Partial<{ projectDir: string; mounts: { path: string; guestPath: string }[]; placeholderProject: boolean }>,
+) =>
({
type: "sidecar",
variant: "sandbox",
@@ -36,3 +38,11 @@ test("returns nothing for unknown guest paths and non-sandbox servers", () => {
const http: ServerConnection.Any = { type: "http", http: { url: "http://example.com" } }
expect(sandboxProjectHostName(http, "/workspace", "/workspace")).toBeUndefined()
})
+
+test("recognises the sandbox whose project folder the app made up", () => {
+ expect(isPlaceholderSandbox(sandbox())).toBe(false)
+ expect(isPlaceholderSandbox(sandbox({ placeholderProject: true }))).toBe(true)
+ // Every other kind of server has a folder the user chose, by definition.
+ const http: ServerConnection.Any = { type: "http", http: { url: "http://example.com" } }
+ expect(isPlaceholderSandbox(http)).toBe(false)
+})
diff --git a/packages/app/src/sandbox/projects.ts b/packages/app/src/sandbox/projects.ts
index 34ad809d56..7f0947cf8e 100644
--- a/packages/app/src/sandbox/projects.ts
+++ b/packages/app/src/sandbox/projects.ts
@@ -26,6 +26,14 @@ type SandboxServerCtx = {
}
}
+// True for a sandbox whose project folder the app made up rather than the user
+// choosing it. Such a folder is never seeded as a project, never landed on when
+// switching to Work, and never named in the UI — the sandbox's real folders are
+// the ones the user mounts.
+export function isPlaceholderSandbox(conn: ServerConnection.Any) {
+ return conn.type === "sidecar" && conn.variant === "sandbox" && conn.placeholderProject === true
+}
+
// The guest only ever sees its mount points, so until adoptFolderName's
// rename (below) lands, an unnamed sandbox project's label falls back to the
// guest path's basename — "workspace" for every primary folder. The app
@@ -82,7 +90,11 @@ export function seedSandboxProjects(
const primary = ctx.sync.data.path.directory || ctx.sync.data.path.worktree
if (!primary) continue
const mounted = [
- { directory: primary, hostPath: conn.projectDir },
+ // A placeholder primary is a folder the app made up so the guest had
+ // somewhere to boot. Seeding it would put a project in the sidebar
+ // that means nothing to the user and cannot be unmounted; this
+ // sandbox's folders are its mounts and nothing else.
+ ...(conn.placeholderProject ? [] : [{ directory: primary, hostPath: conn.projectDir }]),
...(conn.mounts ?? []).map((mount) => ({ directory: mount.guestPath, hostPath: mount.path })),
]
// Unmounting a folder forgets that it was seeded, so mounting it again
diff --git a/packages/app/src/sandbox/types.ts b/packages/app/src/sandbox/types.ts
index b6b3b9110b..392272d318 100644
--- a/packages/app/src/sandbox/types.ts
+++ b/packages/app/src/sandbox/types.ts
@@ -18,6 +18,10 @@ export type SandboxConfig = {
// A guest image file the user supplied instead of the published one; reset
// re-provisions from it, so it is kept for the sandbox's life.
imagePath?: string
+ // projectDir is a folder the app made up so the sandbox had somewhere to
+ // boot, not one the user chose. It is hidden everywhere the user would see
+ // a folder; the sandbox's real folders are its mounts.
+ placeholderProject?: true
// Set when the user stopped this sandbox, so it stays down across relaunches
// instead of booting with the rest.
stopped?: boolean
@@ -98,11 +102,20 @@ export type SandboxesPlatform = {
resources?: SandboxResources,
imagePath?: string,
): Promise
+ // The one-click sandbox behind Work: the main process picks the name, makes
+ // the placeholder folder, and leaves the limits at their defaults, so the
+ // user is only ever asked for the image — and only if they want to supply
+ // one. Resolves undefined when the create was cancelled, like add.
+ addDefault(imagePath?: string): Promise
// Checks a candidate custom image against this machine before it is used.
// Null means it is usable here.
inspectImage(path: string): Promise
// Aborts the in-flight create/reset job for this sandbox name, if any.
cancel(name: string): Promise
+ // The same, for the sandbox addDefault creates: its name is the main
+ // process's, and a sandbox mid-create is not in the list yet, so there is no
+ // name here to pass.
+ cancelDefault(): Promise
remove(id: string): Promise
reset(id: string): Promise
start(id: string): Promise
diff --git a/packages/app/src/workspace/tab-directory.test.ts b/packages/app/src/workspace/tab-directory.test.ts
new file mode 100644
index 0000000000..bb39f7e2ae
--- /dev/null
+++ b/packages/app/src/workspace/tab-directory.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, test } from "bun:test"
+import type { ServerConnection } from "@/context/server"
+import type { Tab } from "@/context/tabs"
+import { tabsInDirectory } from "./tab-directory"
+
+const server = "sandbox:default-sandbox" as ServerConnection.Key
+
+describe("tabs of a mounted folder", () => {
+ const other = "sandbox:other" as ServerConnection.Key
+ const tabs: Tab[] = [
+ { type: "session", server, sessionId: "a", directory: "/mnt/api" },
+ { type: "draft", draftID: "d1", server, directory: "/mnt/api" },
+ { type: "session", server, sessionId: "b", directory: "/mnt/web" },
+ // Never learned where it belongs: it resolves later, and taking it now
+ // would close a tab the user is still working in.
+ { type: "session", server, sessionId: "c" },
+ { type: "session", server: other, sessionId: "d", directory: "/mnt/api" },
+ ]
+
+ test("matches sessions and drafts of that folder, on that server only", () => {
+ expect(tabsInDirectory(tabs, server, "/mnt/api")).toEqual([tabs[0]!, tabs[1]!])
+ expect(tabsInDirectory(tabs, server, "/mnt/web")).toEqual([tabs[2]!])
+ expect(tabsInDirectory(tabs, other, "/mnt/api")).toEqual([tabs[4]!])
+ })
+
+ test("ignores a folder the sandbox never had", () => {
+ expect(tabsInDirectory(tabs, server, "/mnt/gone")).toEqual([])
+ })
+
+ test("compares folders the way the rest of the app does", () => {
+ // A guest path reaches this from a project row on one side and a resolved
+ // mount on the other, so a trailing separator must not decide the match.
+ expect(tabsInDirectory(tabs, server, "/mnt/api/")).toEqual([tabs[0]!, tabs[1]!])
+ })
+
+ test("does not take a nested folder's tabs", () => {
+ // /mnt/api-v2 starts with /mnt/api as a string, and is a different mount.
+ expect(
+ tabsInDirectory([{ type: "session", server, sessionId: "e", directory: "/mnt/api-v2" }], server, "/mnt/api"),
+ ).toEqual([])
+ })
+})
diff --git a/packages/app/src/workspace/tab-directory.ts b/packages/app/src/workspace/tab-directory.ts
new file mode 100644
index 0000000000..0e548f323d
--- /dev/null
+++ b/packages/app/src/workspace/tab-directory.ts
@@ -0,0 +1,16 @@
+import type { ServerConnection } from "@/context/server"
+import type { Tab } from "@/context/tabs"
+import { pathKey } from "@/utils/path-key"
+
+// The tabs belonging to one mounted folder of one sandbox. Its own module,
+// importing nothing but types and pathKey, so it can be unit-tested: both
+// context/tabs.tsx and workspace/side.ts reach the router at import time.
+//
+// A tab that has not learned its directory is deliberately not matched. It
+// belongs to whichever folder it resolves to later, and guessing would take a
+// tab the user is still working in — the opposite of the mistake this exists to
+// fix.
+export function tabsInDirectory(tabs: Tab[], server: ServerConnection.Key, directory: string) {
+ const key = pathKey(directory)
+ return tabs.filter((tab) => tab.server === server && !!tab.directory && pathKey(tab.directory) === key)
+}
diff --git a/packages/app/src/workspace/tabs.tsx b/packages/app/src/workspace/tabs.tsx
index ebad254b80..deaa3bf01b 100644
--- a/packages/app/src/workspace/tabs.tsx
+++ b/packages/app/src/workspace/tabs.tsx
@@ -10,6 +10,7 @@ import { findOpenDraft } from "@/pages/home-draft-reuse"
import { isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
import { sessionHref } from "@/utils/session-route"
import { useSandboxes } from "@/sandbox/context"
+import { isPlaceholderSandbox } from "@/sandbox/projects"
import { usePlatform } from "@/context/platform"
import {
effectiveSide,
@@ -245,8 +246,13 @@ export function useWorkspaceSwitch() {
// Work resolves the sandbox's own project folder, so switching to Work
// lands on the same folder its strip will be scoped to. Code keeps its
// first-project fallback.
+ // Unless that folder is a placeholder the app made up, which is hidden
+ // everywhere else and would be a strange place to land: fall back to a
+ // mounted folder, and to the home screen — where the prompt to mount one
+ // lives — when the user has not mounted any yet.
const ctx = global.ensureServerCtx(target)
- const directory = side === "work" ? primaryFolder(ctx) : ctx.projects.list()[0]?.worktree
+ const own = side === "work" && !isPlaceholderSandbox(target)
+ const directory = own ? primaryFolder(ctx) : ctx.projects.list()[0]?.worktree
if (!directory) {
navigate("/")
return
@@ -262,12 +268,15 @@ export function useWorkspaceSwitch() {
// only switch if the user goes through with it. Cancelling leaves the
// side where it was, which is how the user stays on Code.
if (next === "work" && !hasSandbox()) {
- void import("@/sandbox/dialog-add").then((module) => {
+ // The simple dialog, not the full one from Settings: a user who has
+ // never had a sandbox is being asked to make their first, and every
+ // question but the image has an answer the app can give.
+ void import("@/sandbox/dialog-add-default").then((module) => {
// Switch off the dialog's own result, not off the sandbox list: the
// list arrives on a separate IPC event and is still empty for a beat
// after the create resolves, so checking it here left the user on
// Code until they toggled the tabs by hand.
- void dialog.show(() => void enter("work")} />)
+ void dialog.show(() => void enter("work")} />)
})
return
}
@@ -309,7 +318,11 @@ export function WorkspaceEmpty(props: { side: WorkspaceSide }) {
type="button"
class="text-12-medium text-v2-text-text-base underline"
onClick={() => {
- void import("@/sandbox/dialog-add").then((module) => dialog.show(() => ))
+ // Same simple dialog the Work tab opens; this is the same user in
+ // the same position, reaching it from the sidebar instead.
+ void import("@/sandbox/dialog-add-default").then((module) =>
+ dialog.show(() => ),
+ )
}}
>
{language.t("sandbox.server.add")}
diff --git a/packages/codev-sandbox/src/provider.ts b/packages/codev-sandbox/src/provider.ts
index fe7681aa50..ae7d398d53 100644
--- a/packages/codev-sandbox/src/provider.ts
+++ b/packages/codev-sandbox/src/provider.ts
@@ -20,6 +20,13 @@ export type SandboxConfig = {
// for the sandbox's life because reset re-provisions from it; absent means
// download the manifest's image, which is the normal path.
imagePath?: string
+ // Set when projectDir is a folder the app made up rather than one the user
+ // chose — a sandbox needs a working directory to boot, and the simple Work
+ // flow does not ask for one. Nothing here reads it; it exists so the desktop
+ // app knows not to show that folder to the user, and so the folder can be
+ // cleaned up when the sandbox goes. Persisted rather than derived from the
+ // path, so app data moving cannot make the folder reappear.
+ placeholderProject?: true
// Per-sandbox limits, applied by the vz backend at boot. Omitted means the
// provider default. WSL and bubblewrap ignore them: WSL's limits live in
// the user's VM-global .wslconfig, and bubblewrap has no cgroup plumbing
diff --git a/packages/desktop/src/main/sandbox/controller.ts b/packages/desktop/src/main/sandbox/controller.ts
index db7991d874..57b0774afc 100644
--- a/packages/desktop/src/main/sandbox/controller.ts
+++ b/packages/desktop/src/main/sandbox/controller.ts
@@ -129,6 +129,9 @@ function normalizePersistedSandbox(value: unknown): SandboxConfig[] {
...(record.stopped === true ? { stopped: true } : {}),
// Kept for the sandbox's life: reset re-provisions from the same file.
...(typeof record.imagePath === "string" && record.imagePath.length ? { imagePath: record.imagePath } : {}),
+ // Only an explicit true marks the project folder as the app's own; a
+ // sandbox persisted before this existed has a folder the user chose.
+ ...(record.placeholderProject === true ? { placeholderProject: true as const } : {}),
...(sandboxResourcesValid(resources) ? resources : {}),
},
]
diff --git a/packages/desktop/src/main/sandbox/ipc.ts b/packages/desktop/src/main/sandbox/ipc.ts
index 11e24c06c9..73ea3370a0 100644
--- a/packages/desktop/src/main/sandbox/ipc.ts
+++ b/packages/desktop/src/main/sandbox/ipc.ts
@@ -10,11 +10,18 @@ import {
} from "@codev/sandbox"
import type { MountValidationError, SandboxImageInspection } from "@codev/sandbox"
import type { DesktopSandboxController } from "./controller"
+import { DEFAULT_SANDBOX_NAME, createSandboxWorkspaces } from "./workspace"
// Unlike the WSL channels there is no unavailable-stub variant: the controller
// exists on every platform, and availability() carries the unsupported message
// (or the warn-never-block Windows warnings) to the renderer.
export function registerSandboxIpcHandlers(controller: DesktopSandboxController) {
+ const workspaces = createSandboxWorkspaces({
+ platform: process.platform,
+ localAppData: process.env.LOCALAPPDATA,
+ userData: app.getPath("userData"),
+ appName: app.getName(),
+ })
const subscriptions = new Map void>()
const unsubscribe = (id: number) => {
const off = subscriptions.get(id)
@@ -76,13 +83,45 @@ export function registerSandboxIpcHandlers(controller: DesktopSandboxController)
})
},
)
+ // The one-click sandbox behind Work. Everything add() asks the user for is
+ // decided here instead — the renderer supplies at most an image file — so
+ // the name, the placeholder folder and the id are all minted in one place
+ // and the dialog cannot invent a path of its own. Limits are left unset:
+ // whatever the backend's default is at start time is what this gets.
+ ipcMain.handle("sandbox-add-default", async (_event: IpcMainInvokeEvent, image: unknown) => {
+ const backend = controller.backend
+ if (!backend) throw new Error(`sandboxes are not supported on ${process.platform}`)
+ const imagePath = image === undefined || image === null ? undefined : requireSandboxIpcString("image path", image)
+ if (imagePath) {
+ const problem = sandboxImageProblem(controller, await inspectSandboxImage(imagePath))
+ if (problem) throw new Error(imageProblemMessage(problem))
+ }
+ return controller.addSandbox({
+ id: sandboxServerId(DEFAULT_SANDBOX_NAME),
+ name: DEFAULT_SANDBOX_NAME,
+ backend,
+ projectDir: await workspaces.ensureDefault(),
+ placeholderProject: true,
+ ...(imagePath ? { imagePath } : {}),
+ })
+ })
// Cancel takes the name (like add) so the renderer never mints sandbox ids.
ipcMain.handle("sandbox-cancel", (_event: IpcMainInvokeEvent, name: unknown) =>
controller.cancelJob(sandboxServerId(requireSandboxIpcString("sandbox name", name))),
)
- ipcMain.handle("sandbox-remove", (_event: IpcMainInvokeEvent, id: unknown) =>
- controller.removeSandbox(requireSandboxIpcString("sandbox id", id)),
- )
+ // The default sandbox's cancel needs no argument, and could not take one:
+ // its name is decided here, and a sandbox mid-create is not in the list the
+ // renderer can see — the controller publishes it only once create finishes.
+ ipcMain.handle("sandbox-cancel-default", () => controller.cancelJob(sandboxServerId(DEFAULT_SANDBOX_NAME)))
+ ipcMain.handle("sandbox-remove", async (_event: IpcMainInvokeEvent, id: unknown) => {
+ const sandboxId = requireSandboxIpcString("sandbox id", id)
+ // Read before the removal, which drops the config from state. A folder the
+ // user chose is never touched; a placeholder belongs to the sandbox and
+ // would otherwise be left behind in app data forever.
+ const config = controller.getState().sandboxes.find((item) => item.config.id === sandboxId)?.config
+ await controller.removeSandbox(sandboxId)
+ if (config?.placeholderProject) await workspaces.remove(config.projectDir)
+ })
ipcMain.handle("sandbox-reset", (_event: IpcMainInvokeEvent, id: unknown) =>
controller.resetSandbox(requireSandboxIpcString("sandbox id", id)),
)
diff --git a/packages/desktop/src/main/sandbox/workspace.test.ts b/packages/desktop/src/main/sandbox/workspace.test.ts
new file mode 100644
index 0000000000..450861748f
--- /dev/null
+++ b/packages/desktop/src/main/sandbox/workspace.test.ts
@@ -0,0 +1,86 @@
+import { expect, test } from "bun:test"
+import { mkdtemp, rm, stat, writeFile } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { createSandboxWorkspaces, DEFAULT_SANDBOX_NAME } from "./workspace"
+
+const appName = "CoDev"
+
+test("macOS and Linux keep the placeholder folders under the app's data directory", () => {
+ expect(
+ createSandboxWorkspaces({
+ platform: "darwin",
+ localAppData: undefined,
+ appName,
+ userData: "/Users/ana/Library/Application Support/CoDev",
+ }).defaultDir(),
+ ).toBe(`/Users/ana/Library/Application Support/CoDev/sandbox-workspaces/${DEFAULT_SANDBOX_NAME}`)
+ expect(
+ createSandboxWorkspaces({
+ platform: "linux",
+ localAppData: undefined,
+ appName,
+ userData: "/home/ana/.config/CoDev",
+ }).defaultDir(),
+ ).toBe(`/home/ana/.config/CoDev/sandbox-workspaces/${DEFAULT_SANDBOX_NAME}`)
+})
+
+// userData resolves under %APPDATA%, which folder redirection can put on a UNC
+// share — and the WSL backend reaches a sandbox's project folder through
+// `wslpath`, which cannot translate one, so the sandbox would never start.
+test("Windows uses LOCALAPPDATA even when userData has been redirected to a UNC share", () => {
+ expect(
+ createSandboxWorkspaces({
+ platform: "win32",
+ localAppData: "C:\\Users\\ana\\AppData\\Local",
+ appName,
+ userData: "\\\\files\\profiles\\ana\\AppData\\Roaming\\CoDev",
+ }).defaultDir(),
+ ).toBe(`C:\\Users\\ana\\AppData\\Local\\CoDev\\sandbox-workspaces\\${DEFAULT_SANDBOX_NAME}`)
+})
+
+// Cannot happen on a real install, but losing the environment variable must not
+// be what stops someone creating a sandbox.
+test("an unset LOCALAPPDATA falls back to the app data directory", () => {
+ expect(
+ createSandboxWorkspaces({
+ platform: "win32",
+ localAppData: undefined,
+ appName,
+ userData: "C:\\Users\\ana\\AppData\\Roaming\\CoDev",
+ }).defaultDir(),
+ ).toBe(`C:\\Users\\ana\\AppData\\Roaming\\CoDev\\sandbox-workspaces\\${DEFAULT_SANDBOX_NAME}`)
+})
+
+test("ensureDefault creates the folder and remove takes it away again", async () => {
+ const userData = await mkdtemp(join(tmpdir(), "codev-workspaces-"))
+ const workspaces = createSandboxWorkspaces({ platform: process.platform, localAppData: undefined, appName, userData })
+
+ const dir = await workspaces.ensureDefault()
+ expect(dir).toBe(workspaces.defaultDir())
+ expect((await stat(dir)).isDirectory()).toBe(true)
+ // Creating twice is what a reset or a re-add does; it must not throw.
+ expect(await workspaces.ensureDefault()).toBe(dir)
+
+ await workspaces.remove(dir)
+ expect(await stat(dir).catch(() => null)).toBe(null)
+ await rm(userData, { recursive: true, force: true })
+})
+
+test("remove refuses any path the app did not make up", async () => {
+ const userData = await mkdtemp(join(tmpdir(), "codev-workspaces-"))
+ const elsewhere = await mkdtemp(join(tmpdir(), "codev-real-project-"))
+ await writeFile(join(elsewhere, "notes.md"), "the user's work")
+ const workspaces = createSandboxWorkspaces({ platform: process.platform, localAppData: undefined, appName, userData })
+
+ // A sandbox pointed at a folder the user chose reaches remove only through a
+ // corrupted config, and deleting that folder would destroy their work.
+ await workspaces.remove(elsewhere)
+ expect((await stat(join(elsewhere, "notes.md"))).isFile()).toBe(true)
+ // The root itself is not one of the folders it hands out either.
+ await workspaces.remove(workspaces.root)
+ expect(await stat(userData).catch(() => null)).not.toBe(null)
+
+ await rm(userData, { recursive: true, force: true })
+ await rm(elsewhere, { recursive: true, force: true })
+})
diff --git a/packages/desktop/src/main/sandbox/workspace.ts b/packages/desktop/src/main/sandbox/workspace.ts
new file mode 100644
index 0000000000..b88fa50a30
--- /dev/null
+++ b/packages/desktop/src/main/sandbox/workspace.ts
@@ -0,0 +1,62 @@
+import { mkdir, rm } from "node:fs/promises"
+import { posix, win32 } from "node:path"
+
+// The sandbox Work creates for a user who was never asked to name one. Fixed
+// rather than generated: there is only ever one of these, the user sees it in
+// the server list, and a name it keeps across machines is easier to talk about
+// than "sandbox-3f2a".
+export const DEFAULT_SANDBOX_NAME = "default-sandbox"
+
+// The project folders the app invents for itself. A sandbox cannot boot without
+// a working directory and the simple flow does not ask for one, so this is
+// where those live. Nothing is meant to be kept here: the user mounts their
+// real folders afterwards, and this one is hidden from them everywhere.
+//
+// Takes its environment rather than reading Electron, so the Windows branch —
+// the one with a reason to exist — is checkable from a machine that is not
+// Windows.
+export function createSandboxWorkspaces(env: {
+ platform: NodeJS.Platform
+ localAppData: string | undefined
+ userData: string
+ appName: string
+}) {
+ const path = env.platform === "win32" ? win32 : posix
+ // Windows deliberately does not use the Electron userData path: that resolves
+ // under %APPDATA%, which a roaming profile or folder redirection can point at
+ // a UNC share — and the WSL backend reaches a sandbox's project folder
+ // through `wslpath`, which cannot translate one. %LOCALAPPDATA% is never
+ // redirected that way. It is a bare root, hence the app-name segment the
+ // other platforms get for free from userData. An unset LOCALAPPDATA cannot
+ // happen on a real install, and is not worth failing a sandbox over if it
+ // somehow does.
+ const base = env.platform === "win32" && env.localAppData ? path.join(env.localAppData, env.appName) : env.userData
+ const root = path.join(base, "sandbox-workspaces")
+
+ return {
+ root,
+ // Shared read-write into the guest, which is why it is a dedicated
+ // subdirectory rather than the app data root — that holds the sandbox's own
+ // boot.json, and with it the guest server's password.
+ defaultDir: () => path.join(root, DEFAULT_SANDBOX_NAME),
+
+ ensureDefault: async () => {
+ const dir = path.join(root, DEFAULT_SANDBOX_NAME)
+ await mkdir(dir, { recursive: true })
+ return dir
+ },
+
+ // Destroying a sandbox clears the provider's own directories, never its
+ // project folder — the usual project folder belongs to the user. A
+ // placeholder does not, so it goes with the sandbox instead of being left
+ // behind in app data where nobody would ever find it.
+ //
+ // Guarded here rather than at the call site: this deletes a directory tree,
+ // and the only paths it may ever delete are ones this module made up. A
+ // projectDir the user chose can never match.
+ remove: async (dir: string) => {
+ if (!dir.startsWith(`${root}${path.sep}`)) return
+ await rm(dir, { recursive: true, force: true })
+ },
+ }
+}
diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts
index c3a418dc7f..bf14e398cb 100644
--- a/packages/desktop/src/preload/index.ts
+++ b/packages/desktop/src/preload/index.ts
@@ -50,8 +50,10 @@ const api: ElectronAPI = {
availability: () => ipcRenderer.invoke("sandbox-availability"),
add: (name, projectDir, resources, imagePath) =>
ipcRenderer.invoke("sandbox-add", name, projectDir, resources, imagePath),
+ addDefault: (imagePath) => ipcRenderer.invoke("sandbox-add-default", imagePath),
inspectImage: (path) => ipcRenderer.invoke("sandbox-inspect-image", path),
cancel: (name) => ipcRenderer.invoke("sandbox-cancel", name),
+ cancelDefault: () => ipcRenderer.invoke("sandbox-cancel-default"),
remove: (id) => ipcRenderer.invoke("sandbox-remove", id),
reset: (id) => ipcRenderer.invoke("sandbox-reset", id),
start: (id) => ipcRenderer.invoke("sandbox-start", id),
diff --git a/packages/desktop/src/renderer/sandbox/connections.ts b/packages/desktop/src/renderer/sandbox/connections.ts
index e296bffd10..95cb3e5386 100644
--- a/packages/desktop/src/renderer/sandbox/connections.ts
+++ b/packages/desktop/src/renderer/sandbox/connections.ts
@@ -14,6 +14,10 @@ export function readySandboxConnections(state?: SandboxesState) {
name: item.config.name,
projectDir: item.config.projectDir,
mounts: item.runtime.mounts,
+ // Carried onto the connection because the surfaces that would show the
+ // project folder — the sidebar's project list, the Work strip — see
+ // connections, not sandbox configs.
+ placeholderProject: item.config.placeholderProject === true,
http: {
url: item.runtime.url,
username: item.runtime.username ?? undefined,