diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa1baab8ab..ac6fd259a3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,24 @@ file is the human-readable summary of what changed and why.
### Desktop / Web UI
+- **Sandboxes can be stopped** — a sandbox ran from the moment the app started
+ until it was deleted, and every sandbox on the machine booted at launch, so
+ three sandboxes meant three virtual machines holding memory and CPU whether
+ or not any work was happening in them. Each sandbox now has a **Stop** entry
+ in its menu (Settings → Servers), which shuts its environment down; files on
+ its disk are kept, and running terminals and in-flight work stop. A stopped
+ sandbox stays stopped when the app is relaunched, so a machine only runs the
+ sandboxes you actually want running. **Start** in the same menu brings it
+ back, and resetting a stopped sandbox rebuilds it without starting it.
+- **Working on one sandbox no longer cancels another one's work** — creating,
+ resetting, and deleting a sandbox all ran as a single operation with no
+ regard for which sandbox they belonged to, so starting one cancelled
+ whichever was already running. Deleting a sandbox part-way through another
+ sandbox's image download silently abandoned that download, and the half-built
+ sandbox was never created — with no error to say why. These operations now
+ queue: each one runs in turn and finishes. A sandbox waiting its turn says
+ so, and cancelling it (or the **Cancel** button in the Add sandbox dialog)
+ still takes effect immediately without disturbing whatever is running.
- **A stalling disk can no longer freeze the app** — saving any setting
(window layout, server list, sandbox folders) used to write to disk
synchronously on the app's main thread. If the operating system's disk
diff --git a/DIVERGENCES.md b/DIVERGENCES.md
index e3731211fc..a065f83149 100644
--- a/DIVERGENCES.md
+++ b/DIVERGENCES.md
@@ -197,11 +197,11 @@ from the squash message first, or use a plain merge.
| Dual-path WSL install: `packages/desktop/src/main/wsl/{runtime,servers}.ts` | `runtime.ts`: three internals gain `export` (`runPowerShell`, `runInteractiveCommand`, `resolveSystem32Command`) — no behavior change. `servers.ts`: the `install-wsl` job calls `installWslRuntimeDualPath` and the `install-distro` job calls `installWslDistroDualPath` (both from the fork-only `install-bundle.ts`, which holds all new logic) instead of `installWslRuntimeElevated` / `installWslDistro` directly; both return the same `WslCommandResult` shape, so the surrounding job/error handling is untouched. | The customer fleet sits behind whitelist-only proxies that cannot reach Microsoft's servers. The dual path downloads the pinned bundle (`wsl.zip`: runtime MSI + Ubuntu-24.04 image) from the codev storage host, enables VirtualMachinePlatform via offline dism + installs the MSI behind one UAC prompt, and falls back to upstream's `wsl --install` / `--web-download` paths only when the bundle cannot be *obtained* — a completed-but-failed elevated install is returned as-is, never retried behind a second UAC prompt. | Keep the two call swaps and the three `export` keywords; take upstream's surrounding changes. If upstream reworks `installWslRuntimeElevated` / `installWslDistro`, keep them exported from `runtime.ts` — `install-bundle.ts` calls them as the fallback path. |
| Async settings stores: `packages/desktop/src/main/{store,index}.ts`, `packages/desktop/package.json` | `store.ts` no longer uses electron-store: `getStore` now returns the fork-only `store-file.ts` store — in-memory state, flushed write-behind through `store-writer.ts`'s serialized latest-wins async atomic writer (temp → fsync → rename via `fs.promises`, so the fsync runs on the libuv threadpool). Adds `flushStoreWrites(timeoutMs)`; `removeStoreFile`/`removeStoreFileIfEmpty` discard/flush the writer around the delete so a late flush cannot resurrect the file. `index.ts`: `stopSidecars` awaits `flushStoreWrites(1500)`. `package.json`: the `electron-store` dependency is removed. On-disk format is unchanged (flat keys, tab-indented JSON, no extension) — existing files load as-is; a corrupt file now starts empty with a logged error instead of throwing from every accessor. | conf v15's `store.set` is a synchronous fsync on the Electron main thread, and one of those parked the whole app in uninterruptible disk sleep (kernel journal stall + lost ext4 fast-commit wakeup, observed live during a sandbox mount-apply restart): event loop dead, IPC dead, quit impossible. conf also rereads the file on every access, which makes write-behind unsound with the stock class (a set on one key reads a stale file and drops a still-queued update), so the storage layer had to be replaced rather than patched. | Keep the fork store; take upstream's surrounding changes. If upstream adds electron-store options (schema, migrations, dot-notation) or new store call sites, extend `store-file.ts` to match — its API is the superset the desktop actually uses (`get/set/has/delete/clear/store/path`). Do not reintroduce a synchronous write path on the main thread. |
| 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; 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; and the `sandbox.mounts.*` family for the manage-folders dialog that adds/removes extra mounts on an existing sandbox); 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. |
+| 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; 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; and `sandbox.server.stop` for the menu entry that shuts a sandbox down until it is explicitly started again); 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. |
| 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. |
+| 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/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. The same module also names that project after the sandbox's host folder (via `client.project.update`, the call behind rename-project), because the guest sees only its mount point and every sandbox would otherwise read "workspace" in the sidebar, tabs and palette. | 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. |
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index cadc0904a0..13cf45d6f8 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -378,10 +378,12 @@ export const dict = {
"workspace.work": "Work",
"workspace.empty.none": "No sandbox yet. Create one to work in an isolated environment.",
"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.server.add": "Add sandbox",
"sandbox.server.label": "Sandbox",
"sandbox.server.menu.label": "Sandbox",
"sandbox.server.start": "Start",
+ "sandbox.server.stop": "Stop",
"sandbox.server.reset": "Reset",
"sandbox.server.mounts": "Folders...",
"sandbox.status.starting": "Starting...",
@@ -391,6 +393,7 @@ export const dict = {
"sandbox.job.create": "Creating...",
"sandbox.job.destroy": "Removing...",
"sandbox.job.reset": "Resetting...",
+ "sandbox.job.queued": "Waiting for another sandbox...",
"sandbox.add.namePlaceholder": "Sandbox name",
"sandbox.add.nameInvalid": "Use letters, numbers, dots, dashes, or underscores.",
"sandbox.add.projectDir": "Project folder",
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index 58b6b7cb37..4850bcaa41 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -369,10 +369,12 @@ export const dict = {
"workspace.work": "Trabajo",
"workspace.empty.none": "Aún no hay ningún sandbox. Crea uno para trabajar en un entorno aislado.",
"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.server.add": "Añadir sandbox",
"sandbox.server.label": "Sandbox",
"sandbox.server.menu.label": "Sandbox",
"sandbox.server.start": "Iniciar",
+ "sandbox.server.stop": "Detener",
"sandbox.server.reset": "Restablecer",
"sandbox.server.mounts": "Carpetas...",
"sandbox.status.starting": "Iniciando...",
@@ -382,6 +384,7 @@ export const dict = {
"sandbox.job.create": "Creando...",
"sandbox.job.destroy": "Eliminando...",
"sandbox.job.reset": "Restableciendo...",
+ "sandbox.job.queued": "Esperando a otro sandbox...",
"sandbox.add.namePlaceholder": "Nombre del sandbox",
"sandbox.add.nameInvalid": "Usa letras, números, puntos, guiones o guiones bajos.",
"sandbox.add.projectDir": "Carpeta del proyecto",
diff --git a/packages/app/src/sandbox/dialog-add.tsx b/packages/app/src/sandbox/dialog-add.tsx
index 0e3ff43eac..2f2f503f35 100644
--- a/packages/app/src/sandbox/dialog-add.tsx
+++ b/packages/app/src/sandbox/dialog-add.tsx
@@ -65,13 +65,15 @@ export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) =>
})
}
- // The create job's image-download progress, pushed by the main process
- // while this dialog's add is in flight.
+ // This create's image-download progress, pushed by the main process while
+ // the add is in flight. Jobs are queued, so the create may still be waiting
+ // behind another sandbox's job: no progress until it starts running.
const progress = () => {
if (!store.adding) return undefined
- const job = sandboxes.state.data?.job
- return job?.kind === "create" ? job.progress : undefined
+ return sandboxes.state.data?.jobs.find((job) => job.kind === "create" && job.status === "running")?.progress
}
+ const queued = () =>
+ store.adding && sandboxes.state.data?.jobs.some((job) => job.kind === "create" && job.status === "queued")
const create = async () => {
if (!canCreate() || store.adding) return
@@ -172,7 +174,11 @@ export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) =>
{language.t("sandbox.add.downloadHint")}
diff --git a/packages/app/src/sandbox/dialog-mounts.tsx b/packages/app/src/sandbox/dialog-mounts.tsx
index 3fa14a63a7..a3622b75af 100644
--- a/packages/app/src/sandbox/dialog-mounts.tsx
+++ b/packages/app/src/sandbox/dialog-mounts.tsx
@@ -30,7 +30,7 @@ export function DialogSandboxMounts(props: { id: string }) {
saving: false,
})
- const busy = () => sandboxes.state.data?.job?.id === props.id
+ const busy = () => !!sandboxes.state.data?.jobs.some((job) => job.id === props.id)
const dirty = () => {
const current = (item()?.config.mounts ?? []).map((mount) => mount.path)
return current.length !== store.paths.length || current.some((path, index) => path !== store.paths[index])
diff --git a/packages/app/src/sandbox/settings.tsx b/packages/app/src/sandbox/settings.tsx
index 15a162a1d8..b17e6d1eb9 100644
--- a/packages/app/src/sandbox/settings.tsx
+++ b/packages/app/src/sandbox/settings.tsx
@@ -26,6 +26,12 @@ export function sandboxRuntimeRetryable(runtime: SandboxRuntime) {
return runtime.kind === "failed" || runtime.kind === "stopped"
}
+// Stopping is offered while it is still coming up too: a start that hangs on a
+// slow image or a wedged VM is exactly when the user wants out of it.
+export function sandboxRuntimeStoppable(runtime: SandboxRuntime) {
+ return runtime.kind === "ready" || runtime.kind === "starting"
+}
+
export function useFilteredSandboxes(filter: Accessor) {
const sandboxes = useSandboxes()
return createMemo(() => {
@@ -63,11 +69,16 @@ export function SandboxServerSettings(props: {
{(item) => {
const key = ServerConnection.Key.make(item.config.id)
- // Create/reset/destroy jobs are single-flight in the controller, so a
- // job on this sandbox means its lifecycle actions must wait.
- const busy = () => sandboxes.state.data?.job?.id === item.config.id
+ // A sandbox has at most one job, running or queued behind another
+ // sandbox's; either way its lifecycle actions must wait.
+ const job = () => sandboxes.state.data?.jobs.find((entry) => entry.id === item.config.id)
+ const busy = () => !!job()
const meta = () => {
- if (busy()) return language.t(`sandbox.job.${sandboxes.state.data!.job!.kind}`)
+ const current = job()
+ if (current)
+ return current.status === "queued"
+ ? language.t("sandbox.job.queued")
+ : language.t(`sandbox.job.${current.kind}`)
if (item.runtime.kind === "failed") return item.runtime.message
return `${item.config.backend} · ${language.t(`sandbox.status.${item.runtime.kind}`)}`
}
@@ -106,6 +117,11 @@ export function SandboxServerSettings(props: {
{language.t("sandbox.server.start")}
+
+ api && request.mutate(() => api.stop(key))}>
+ {language.t("sandbox.server.stop")}
+
+ api && request.mutate(() => api.reset(key))}>
{language.t("sandbox.server.reset")}
diff --git a/packages/app/src/sandbox/types.ts b/packages/app/src/sandbox/types.ts
index fd052cc960..0ed6370be9 100644
--- a/packages/app/src/sandbox/types.ts
+++ b/packages/app/src/sandbox/types.ts
@@ -13,6 +13,9 @@ export type SandboxConfig = {
// Extra host folders shared into the guest alongside projectDir; absent
// means none. Objects so a readOnly flag can be added without migration.
mounts?: SandboxMount[]
+ // Set when the user stopped this sandbox, so it stays down across relaunches
+ // instead of booting with the rest.
+ stopped?: boolean
} & SandboxResources
export type SandboxMount = {
@@ -48,13 +51,19 @@ export type SandboxProgress = { bytes: number; totalBytes: number }
export type SandboxJob = {
kind: "create" | "destroy" | "reset"
id: string
+ // When the job was admitted to the queue, which is not when it started
+ // running — status says which of those has happened.
startedAt: number
+ status: "running" | "queued"
progress?: SandboxProgress
}
export type SandboxesState = {
sandboxes: SandboxItem[]
- job: SandboxJob | null
+ // Create/reset/destroy run one at a time across all sandboxes (they share
+ // one image cache), so a job can be waiting behind another sandbox's. The
+ // running job comes first; at most one job per sandbox.
+ jobs: SandboxJob[]
}
export type SandboxesEvent = { type: "state"; state: SandboxesState }
@@ -74,6 +83,8 @@ export type SandboxesPlatform = {
remove(id: string): Promise
reset(id: string): Promise
start(id: string): Promise
+ // Shuts the sandbox down until the user starts it again, across relaunches.
+ stop(id: string): Promise
// Replaces the extra-mount list. Applies hot on Windows (WSL); on macOS and
// Linux a running sandbox restarts to pick the new list up.
updateMounts(id: string, mounts: SandboxMount[]): Promise
diff --git a/packages/app/src/workspace/tabs.tsx b/packages/app/src/workspace/tabs.tsx
index 82e4b6588a..446db5a5dc 100644
--- a/packages/app/src/workspace/tabs.tsx
+++ b/packages/app/src/workspace/tabs.tsx
@@ -99,22 +99,27 @@ export function useWorkspaceSwitch() {
}
}
-// Reachable on Work while its only sandbox is still starting or has failed —
-// a sandbox joins the server list only once it is ready — and after the last
-// sandbox is removed. Code always has the local server, so it only shows here
-// if every server is somehow gone.
+// Reachable on Work while its only sandbox is still starting, has failed, or
+// was stopped — a sandbox joins the server list only once it is ready — and
+// after the last sandbox is removed. Code always has the local server, so it
+// only shows here if every server is somehow gone.
export function WorkspaceEmpty(props: { side: WorkspaceSide }) {
const language = useLanguage()
const dialog = useDialog()
const sandboxes = useSandboxes()
- const configured = () => sandboxes.state.data?.sandboxes.length ?? 0
+ const configured = () => sandboxes.state.data?.sandboxes ?? []
+ // Nothing here is coming up on its own once every sandbox is stopped, so
+ // saying "it appears here once it is ready" would be a promise we do not
+ // keep. One sandbox still starting is enough for the wait to be true.
+ const message = () => {
+ if (props.side !== "work" || !configured().length) return "workspace.empty.none"
+ return configured().every((item) => item.config.stopped) ? "workspace.empty.stopped" : "workspace.empty.starting"
+ }
return (