From c2b2fb7a630e49c4b2e5b1bdf208ca4b77b900fc Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 16 Aug 2026 12:40:58 +0700 Subject: [PATCH 1/5] fix(app): keep the sandbox resource fields inside the dialog, and explain them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The memory box ran past the right edge of the Add sandbox dialog. TextInputV2 sets its own `width: 280px` and `flex: none`, so two of them in a flex row do not share the space — they take 560px of a 448px row and the second one hangs off the end. Neither flex-1 nor min-w-0 helps against a fixed width, and w-full loses to the component's rule; `!w-full` is what the rest of the app uses, and grid tracks size the pair. Measured in the running app: the memory field ended at x=976 against a dialog edge of 880, and now ends at 864 with the row. Both fields were also unexplained. They are optional and both have real defaults, which an empty box cannot convey, so the dialog now shows the default in each placeholder and describes what the numbers buy — including that the memory is held for as long as the sandbox runs, since a sandbox is a VM. The defaults were literals in the vz backend; they are now named constants there and mirrored in the app, so the dialog cannot quote a number the VM does not actually use. The range message is interpolated from the same constants for the same reason. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 ++++ DIVERGENCES.md | 2 +- packages/app/src/i18n/en.ts | 8 +++-- packages/app/src/i18n/es.ts | 8 +++-- packages/app/src/sandbox/dialog-add.tsx | 42 +++++++++++++++++++---- packages/app/src/sandbox/resources.ts | 2 ++ packages/codev-sandbox/src/provider.ts | 6 ++++ packages/codev-sandbox/src/vz/provider.ts | 5 +-- 8 files changed, 64 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc4dcd425e..f73f099cca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI +- **The CPU and memory fields explain themselves** — the Add sandbox dialog + offered two unlabelled optional boxes with no hint of what a sandbox gets if + you leave them empty, and the memory box ran past the edge of the dialog. + They now sit side by side within the dialog, show their defaults (4 CPUs and + 4 GB), and say what they control and how high you can go. The fields appear + only on macOS, where each sandbox is its own virtual machine and the limits + actually apply. - **Deleting a sandbox no longer breaks the app** — deleting a sandbox while one of its sessions was open could take the whole window down to the "Something went wrong" screen, and because the app reopens whatever you were diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 41ed592ec9..7a370ab5ba 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -198,7 +198,7 @@ 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` 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; and the `sandbox.add.image*` / `sandbox.add.name*` / `sandbox.arch.*` families for the optional custom-image field and the sandbox-name checks); 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` 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. | | 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. | diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index f3546cb3d9..18d86eb22b 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -418,9 +418,11 @@ export const dict = { "Choose the folder you want to work on. The sandbox can only reach folders you mount from this computer — you can mount more later — and the agent edits files in them without asking.", "sandbox.add.choose": "Choose...", "sandbox.add.create": "Create", - "sandbox.add.cpus": "CPUs (optional)", - "sandbox.add.memory": "Memory GB (optional)", - "sandbox.add.resourcesInvalid": "CPUs must be 1-32 and memory 1-64 GB.", + "sandbox.add.cpus": "CPUs (default {{cpus}})", + "sandbox.add.memory": "Memory GB (default {{memory}})", + "sandbox.add.resourcesHint": + "How much of this Mac the sandbox may use. Each sandbox is a virtual machine, so its memory is held for as long as it runs. Leave both empty for the defaults \u2014 {{cpus}} CPUs and {{memory}} GB \u2014 or set up to {{maxCpus}} CPUs and {{maxMemory}} GB.", + "sandbox.add.resourcesInvalid": "CPUs must be {{minCpus}}\u2013{{maxCpus}} and memory {{minMemory}}\u2013{{maxMemory}} GB.", "sandbox.add.downloadHint": "The first sandbox downloads its image; on a slow connection this can take a while.", "sandbox.add.downloadProgress": "Downloading image… {{done}}/{{total}} MB", "sandbox.add.creating": "Creating the sandbox\u2026 this takes a few minutes.", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 1afb02c99b..d514883a55 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -409,9 +409,11 @@ export const dict = { "Elige la carpeta en la que quieres trabajar. El sandbox solo puede acceder a las carpetas de este equipo que montes — puedes montar más después — y el agente edita archivos en ellas sin preguntar.", "sandbox.add.choose": "Elegir...", "sandbox.add.create": "Crear", - "sandbox.add.cpus": "CPU (opcional)", - "sandbox.add.memory": "Memoria GB (opcional)", - "sandbox.add.resourcesInvalid": "Las CPU deben ser 1-32 y la memoria 1-64 GB.", + "sandbox.add.cpus": "CPU (por defecto {{cpus}})", + "sandbox.add.memory": "Memoria GB (por defecto {{memory}})", + "sandbox.add.resourcesHint": + "Cu\u00e1nto de este Mac puede usar el sandbox. Cada sandbox es una m\u00e1quina virtual, as\u00ed que su memoria queda reservada mientras est\u00e9 en marcha. D\u00e9jalos vac\u00edos para los valores por defecto ({{cpus}} CPU y {{memory}} GB) o indica hasta {{maxCpus}} CPU y {{maxMemory}} GB.", + "sandbox.add.resourcesInvalid": "Las CPU deben ser {{minCpus}}\u2013{{maxCpus}} y la memoria {{minMemory}}\u2013{{maxMemory}} GB.", "sandbox.add.downloadHint": "El primer sandbox descarga su imagen; con una conexión lenta puede tardar un poco.", "sandbox.add.downloadProgress": "Descargando imagen… {{done}}/{{total}} MB", "sandbox.add.creating": "Creando el sandbox\u2026 esto tarda unos minutos.", diff --git a/packages/app/src/sandbox/dialog-add.tsx b/packages/app/src/sandbox/dialog-add.tsx index d1f9e31013..3487ada234 100644 --- a/packages/app/src/sandbox/dialog-add.tsx +++ b/packages/app/src/sandbox/dialog-add.tsx @@ -13,7 +13,13 @@ import type { SandboxConfig, SandboxImageProblem } from "./types" import { useSandboxes } from "./context" import { sandboxCreatePhase } from "./create-phase" import { sandboxNameUsable, validateSandboxName } from "./name-model" -import { sandboxResourcesValid } from "./resources" +import { + SANDBOX_CPUS_RANGE, + SANDBOX_DEFAULT_CPUS, + SANDBOX_DEFAULT_MEMORY_GIB, + SANDBOX_MEMORY_MIB_RANGE, + sandboxResourcesValid, +} from "./resources" // onCreated fires with the new sandbox before the dialog closes. Callers that // need to react to a creation must use it rather than watching the sandbox @@ -261,26 +267,48 @@ export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) =>

{language.t("sandbox.add.imageOk")}

-
+ {/* Grid, not flex: the input sets its own `width: 280px` and + `flex: none`, so two of them side by side overflowed the dialog + rather than sharing the row. Grid tracks size them, and + `!w-full` is how the rest of the app overrides that width. */} +
setStore("cpus", event.currentTarget.value)} /> setStore("memoryGiB", event.currentTarget.value)} />
+ {/* Both are optional and both have real defaults, which the user + cannot otherwise discover — an empty box says nothing about + what the sandbox will actually get. */} +

+ {language.t("sandbox.add.resourcesHint", { + cpus: SANDBOX_DEFAULT_CPUS, + memory: SANDBOX_DEFAULT_MEMORY_GIB, + maxCpus: SANDBOX_CPUS_RANGE.max, + maxMemory: SANDBOX_MEMORY_MIB_RANGE.max / 1024, + })} +

-

{language.t("sandbox.add.resourcesInvalid")}

+

+ {language.t("sandbox.add.resourcesInvalid", { + minCpus: SANDBOX_CPUS_RANGE.min, + maxCpus: SANDBOX_CPUS_RANGE.max, + minMemory: SANDBOX_MEMORY_MIB_RANGE.min / 1024, + maxMemory: SANDBOX_MEMORY_MIB_RANGE.max / 1024, + })} +

diff --git a/packages/app/src/sandbox/resources.ts b/packages/app/src/sandbox/resources.ts index 55125845a6..2aea456431 100644 --- a/packages/app/src/sandbox/resources.ts +++ b/packages/app/src/sandbox/resources.ts @@ -3,6 +3,8 @@ import type { SandboxResources } from "./types" // Mirrors SANDBOX_*_RANGE in @codev/sandbox: the app package must not import // the node-flavored package, and the main process re-validates on the way in, // so this copy only decides when the dialog's Create button is enabled. +export const SANDBOX_DEFAULT_CPUS = 4 +export const SANDBOX_DEFAULT_MEMORY_GIB = 4 export const SANDBOX_CPUS_RANGE = { min: 1, max: 32 } as const export const SANDBOX_MEMORY_MIB_RANGE = { min: 1024, max: 65536 } as const diff --git a/packages/codev-sandbox/src/provider.ts b/packages/codev-sandbox/src/provider.ts index 993567eb2a..fe7681aa50 100644 --- a/packages/codev-sandbox/src/provider.ts +++ b/packages/codev-sandbox/src/provider.ts @@ -40,6 +40,12 @@ export type ResolvedSandboxMount = { guestPath: string } +// What a sandbox gets when it sets no limits of its own. Named here rather +// than left as literals in the vz backend because the Add sandbox dialog +// quotes these numbers to the user; they must not drift apart. +export const SANDBOX_DEFAULT_CPUS = 4 +export const SANDBOX_DEFAULT_MEMORY_MIB = 4096 + // Ceilings keep an unusable VM (0 cpus, 64MiB) or an oversubscribed host out // of the persisted config; the guest needs real headroom for the office // toolchain. Both ends inclusive. diff --git a/packages/codev-sandbox/src/vz/provider.ts b/packages/codev-sandbox/src/vz/provider.ts index 33f34c8a4f..1d3f73f587 100644 --- a/packages/codev-sandbox/src/vz/provider.ts +++ b/packages/codev-sandbox/src/vz/provider.ts @@ -10,6 +10,7 @@ import type { SandboxAsset } from "../manifest" import { resolveGuestMounts } from "../mounts" import { macosSandboxAvailability } from "../policy" import { createSandboxSocketDir } from "../socket" +import { SANDBOX_DEFAULT_CPUS, SANDBOX_DEFAULT_MEMORY_MIB } from "../provider" import type { SandboxAvailability, SandboxConfig, SandboxJobOptions, SandboxProvider } from "../provider" import { createVzHelperEventReader, type VzHelperEvent, type VzVmConfig } from "./protocol" @@ -182,8 +183,8 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider { disk: dirs.disk, cmdline: VZ_CMDLINE, // Per-sandbox limits win over the provider-wide defaults. - cpus: config.cpus ?? options.cpus ?? 4, - memoryMiB: config.memoryMiB ?? options.memoryMiB ?? 4096, + cpus: config.cpus ?? options.cpus ?? SANDBOX_DEFAULT_CPUS, + memoryMiB: config.memoryMiB ?? options.memoryMiB ?? SANDBOX_DEFAULT_MEMORY_MIB, shares: [ { tag: "workspace", path: config.projectDir, readOnly: false }, { tag: "bundle", path: options.bundleDir, readOnly: true }, From 54e72a219463319db4138c5c0d5f212cbd44f178 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 16 Aug 2026 18:39:14 +0700 Subject: [PATCH 2/5] fix(app): drop two lines of filler from the Add sandbox dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image hint spent a sentence explaining when to pick a file, which the field's own "(optional)" placeholder and the first sentence already convey, and the idle form carried a standing warning that the first sandbox downloads its image — advice that arrives before the user has done anything and stays up while they fill the form in. The status line now speaks only while something is happening: queued, or creating. A download says what it is doing with the byte counter under the progress bar, so nothing is lost. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 3 ++- packages/app/src/i18n/en.ts | 4 +--- packages/app/src/i18n/es.ts | 4 +--- packages/app/src/sandbox/dialog-add.tsx | 7 +++---- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f73f099cca..0195b7a7d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ file is the human-readable summary of what changed and why. offered two unlabelled optional boxes with no hint of what a sandbox gets if you leave them empty, and the memory box ran past the edge of the dialog. They now sit side by side within the dialog, show their defaults (4 CPUs and - 4 GB), and say what they control and how high you can go. The fields appear + 4 GB), and say what they control and how high you can go. The dialog also + drops two lines that were not earning their space. The fields appear only on macOS, where each sandbox is its own virtual machine and the limits actually apply. - **Deleting a sandbox no longer breaks the app** — deleting a sandbox while diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 18d86eb22b..469530e8b6 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -402,8 +402,7 @@ export const dict = { "sandbox.arch.arm64": "ARM64 (Apple silicon)", "sandbox.add.image": "Sandbox image", "sandbox.add.imagePlaceholder": "Sandbox image file (optional)", - "sandbox.add.imageHint": - "Leave this empty and the right image is downloaded for you. Choose a file only if you have one already \u2014 an image built for this computer, or one someone shared with you.", + "sandbox.add.imageHint": "Leave this empty and the right image is downloaded for you.", "sandbox.add.imageClear": "Clear", "sandbox.add.imageChecking": "Checking the image\u2026", "sandbox.add.imageOk": "This image can be used on this computer.", @@ -423,7 +422,6 @@ export const dict = { "sandbox.add.resourcesHint": "How much of this Mac the sandbox may use. Each sandbox is a virtual machine, so its memory is held for as long as it runs. Leave both empty for the defaults \u2014 {{cpus}} CPUs and {{memory}} GB \u2014 or set up to {{maxCpus}} CPUs and {{maxMemory}} GB.", "sandbox.add.resourcesInvalid": "CPUs must be {{minCpus}}\u2013{{maxCpus}} and memory {{minMemory}}\u2013{{maxMemory}} GB.", - "sandbox.add.downloadHint": "The first sandbox downloads its image; on a slow connection this can take a while.", "sandbox.add.downloadProgress": "Downloading image… {{done}}/{{total}} MB", "sandbox.add.creating": "Creating the sandbox\u2026 this takes a few minutes.", "sandbox.mounts.title": "Sandbox folders", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index d514883a55..70c3cbdf91 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -393,8 +393,7 @@ export const dict = { "sandbox.arch.arm64": "ARM64 (Apple silicon)", "sandbox.add.image": "Imagen del sandbox", "sandbox.add.imagePlaceholder": "Archivo de imagen del sandbox (opcional)", - "sandbox.add.imageHint": - "D\u00e9jalo vac\u00edo y se descargar\u00e1 la imagen adecuada. Elige un archivo solo si ya tienes uno: una imagen creada para este equipo o una que te hayan compartido.", + "sandbox.add.imageHint": "D\u00e9jalo vac\u00edo y se descargar\u00e1 la imagen adecuada.", "sandbox.add.imageClear": "Quitar", "sandbox.add.imageChecking": "Comprobando la imagen\u2026", "sandbox.add.imageOk": "Esta imagen se puede usar en este equipo.", @@ -414,7 +413,6 @@ export const dict = { "sandbox.add.resourcesHint": "Cu\u00e1nto de este Mac puede usar el sandbox. Cada sandbox es una m\u00e1quina virtual, as\u00ed que su memoria queda reservada mientras est\u00e9 en marcha. D\u00e9jalos vac\u00edos para los valores por defecto ({{cpus}} CPU y {{memory}} GB) o indica hasta {{maxCpus}} CPU y {{maxMemory}} GB.", "sandbox.add.resourcesInvalid": "Las CPU deben ser {{minCpus}}\u2013{{maxCpus}} y la memoria {{minMemory}}\u2013{{maxMemory}} GB.", - "sandbox.add.downloadHint": "El primer sandbox descarga su imagen; con una conexión lenta puede tardar un poco.", "sandbox.add.downloadProgress": "Descargando imagen… {{done}}/{{total}} MB", "sandbox.add.creating": "Creando el sandbox\u2026 esto tarda unos minutos.", "sandbox.mounts.title": "Carpetas del sandbox", diff --git a/packages/app/src/sandbox/dialog-add.tsx b/packages/app/src/sandbox/dialog-add.tsx index 3487ada234..d59b602b7d 100644 --- a/packages/app/src/sandbox/dialog-add.tsx +++ b/packages/app/src/sandbox/dialog-add.tsx @@ -146,14 +146,13 @@ export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) => 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) - // Null while the bar is up: the byte counter under it already says what is - // happening, and a custom image needs no download warning at all. + // Only speaks while something is happening: the byte counter under the bar + // already says what a download is doing, and an idle form needs no commentary. const statusMessage = () => { const phase = createPhase() if (phase === "queued") return language.t("sandbox.job.queued") if (phase === "creating") return language.t("sandbox.add.creating") - if (phase === "downloading") return null - return store.imagePath ? null : language.t("sandbox.add.downloadHint") + return null } const create = async () => { From 4cedca7256f41ea420b9c2f44a7643e1ea2890f9 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 16 Aug 2026 18:47:02 +0700 Subject: [PATCH 3/5] fix(app): trim the folder hint and label the memory field by its unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The folder hint ended on "and the agent edits files in them without asking", which reads as a warning at the moment someone is picking a folder — and the permission model is a property of the sandbox, not of this field. Dropped, and the surrounding em-dash aside folded into the sentence so it does not read as though something was cut out of it. "Memory GB (default 4)" put the unit on the label and left the default looking unitless; "Memory (default 4 GB)" reads as the quantity it is. Co-Authored-By: Claude Opus 5 (1M context) --- packages/app/src/i18n/en.ts | 4 ++-- packages/app/src/i18n/es.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 469530e8b6..f64ffc5563 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -414,11 +414,11 @@ export const dict = { "sandbox.add.imageWrongArch": "That image is built for {{found}} machines, which this computer is not.", "sandbox.add.projectDir": "Project folder", "sandbox.add.projectDirHint": - "Choose the folder you want to work on. The sandbox can only reach folders you mount from this computer — you can mount more later — and the agent edits files in them without asking.", + "Choose the folder you want to work on. The sandbox can only reach folders you mount from this computer, and you can mount more later.", "sandbox.add.choose": "Choose...", "sandbox.add.create": "Create", "sandbox.add.cpus": "CPUs (default {{cpus}})", - "sandbox.add.memory": "Memory GB (default {{memory}})", + "sandbox.add.memory": "Memory (default {{memory}} GB)", "sandbox.add.resourcesHint": "How much of this Mac the sandbox may use. Each sandbox is a virtual machine, so its memory is held for as long as it runs. Leave both empty for the defaults \u2014 {{cpus}} CPUs and {{memory}} GB \u2014 or set up to {{maxCpus}} CPUs and {{maxMemory}} GB.", "sandbox.add.resourcesInvalid": "CPUs must be {{minCpus}}\u2013{{maxCpus}} and memory {{minMemory}}\u2013{{maxMemory}} GB.", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 70c3cbdf91..b56c6c3592 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -405,11 +405,11 @@ export const dict = { "sandbox.add.imageWrongArch": "Esa imagen est\u00e1 hecha para equipos {{found}}, y este no lo es.", "sandbox.add.projectDir": "Carpeta del proyecto", "sandbox.add.projectDirHint": - "Elige la carpeta en la que quieres trabajar. El sandbox solo puede acceder a las carpetas de este equipo que montes — puedes montar más después — y el agente edita archivos en ellas sin preguntar.", + "Elige la carpeta en la que quieres trabajar. El sandbox solo puede acceder a las carpetas de este equipo que montes, y puedes montar más después.", "sandbox.add.choose": "Elegir...", "sandbox.add.create": "Crear", "sandbox.add.cpus": "CPU (por defecto {{cpus}})", - "sandbox.add.memory": "Memoria GB (por defecto {{memory}})", + "sandbox.add.memory": "Memoria (por defecto {{memory}} GB)", "sandbox.add.resourcesHint": "Cu\u00e1nto de este Mac puede usar el sandbox. Cada sandbox es una m\u00e1quina virtual, as\u00ed que su memoria queda reservada mientras est\u00e9 en marcha. D\u00e9jalos vac\u00edos para los valores por defecto ({{cpus}} CPU y {{memory}} GB) o indica hasta {{maxCpus}} CPU y {{maxMemory}} GB.", "sandbox.add.resourcesInvalid": "Las CPU deben ser {{minCpus}}\u2013{{maxCpus}} y la memoria {{minMemory}}\u2013{{maxMemory}} GB.", From 3d06f8ac616208d7227da9db620a05abe4dae82a Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 16 Aug 2026 19:50:25 +0700 Subject: [PATCH 4/5] fix(app): explain the resource fields before showing them Every other field in Add sandbox is introduced by the line above it; the CPU and memory description sat underneath, so it read as a footnote to boxes the user had already had to guess at. Moved above the pair, and the dialog now reads the same way throughout: what the field is for, then the field. Co-Authored-By: Claude Opus 5 (1M context) --- packages/app/src/sandbox/dialog-add.tsx | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/app/src/sandbox/dialog-add.tsx b/packages/app/src/sandbox/dialog-add.tsx index d59b602b7d..022d7fc4b0 100644 --- a/packages/app/src/sandbox/dialog-add.tsx +++ b/packages/app/src/sandbox/dialog-add.tsx @@ -266,6 +266,17 @@ export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) =>

{language.t("sandbox.add.imageOk")}

+ {/* Both are optional and both have real defaults, which the user + cannot otherwise discover — an empty box says nothing about + what the sandbox will actually get. */} +

+ {language.t("sandbox.add.resourcesHint", { + cpus: SANDBOX_DEFAULT_CPUS, + memory: SANDBOX_DEFAULT_MEMORY_GIB, + maxCpus: SANDBOX_CPUS_RANGE.max, + maxMemory: SANDBOX_MEMORY_MIB_RANGE.max / 1024, + })} +

{/* Grid, not flex: the input sets its own `width: 280px` and `flex: none`, so two of them side by side overflowed the dialog rather than sharing the row. Grid tracks size them, and @@ -288,17 +299,6 @@ export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) => onInput={(event) => setStore("memoryGiB", event.currentTarget.value)} />
- {/* Both are optional and both have real defaults, which the user - cannot otherwise discover — an empty box says nothing about - what the sandbox will actually get. */} -

- {language.t("sandbox.add.resourcesHint", { - cpus: SANDBOX_DEFAULT_CPUS, - memory: SANDBOX_DEFAULT_MEMORY_GIB, - maxCpus: SANDBOX_CPUS_RANGE.max, - maxMemory: SANDBOX_MEMORY_MIB_RANGE.max / 1024, - })} -

{language.t("sandbox.add.resourcesInvalid", { From afcdcca8135e044063ea53254b1fd8fc5cc7161a Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 16 Aug 2026 19:57:57 +0700 Subject: [PATCH 5/5] fix(app): stop the resource hint repeating what the fields already say MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hint restated the defaults that each placeholder already carries and the ceilings that the out-of-range message states when they matter, so two thirds of it was there to be skipped. What is left is the part nothing else says: the numbers are a claim on this machine, and a VM holds its memory the whole time it runs. The interpolation goes with it — the string no longer quotes any number, so it no longer needs the constants passed in. Co-Authored-By: Claude Opus 5 (1M context) --- packages/app/src/i18n/en.ts | 2 +- packages/app/src/i18n/es.ts | 2 +- packages/app/src/sandbox/dialog-add.tsx | 15 ++++----------- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index f64ffc5563..49ea12586b 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -420,7 +420,7 @@ export const dict = { "sandbox.add.cpus": "CPUs (default {{cpus}})", "sandbox.add.memory": "Memory (default {{memory}} GB)", "sandbox.add.resourcesHint": - "How much of this Mac the sandbox may use. Each sandbox is a virtual machine, so its memory is held for as long as it runs. Leave both empty for the defaults \u2014 {{cpus}} CPUs and {{memory}} GB \u2014 or set up to {{maxCpus}} CPUs and {{maxMemory}} GB.", + "How much of this Mac the sandbox may use. Each sandbox is a virtual machine, so its memory is held for as long as it runs.", "sandbox.add.resourcesInvalid": "CPUs must be {{minCpus}}\u2013{{maxCpus}} and memory {{minMemory}}\u2013{{maxMemory}} GB.", "sandbox.add.downloadProgress": "Downloading image… {{done}}/{{total}} MB", "sandbox.add.creating": "Creating the sandbox\u2026 this takes a few minutes.", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index b56c6c3592..d064e3f931 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -411,7 +411,7 @@ export const dict = { "sandbox.add.cpus": "CPU (por defecto {{cpus}})", "sandbox.add.memory": "Memoria (por defecto {{memory}} GB)", "sandbox.add.resourcesHint": - "Cu\u00e1nto de este Mac puede usar el sandbox. Cada sandbox es una m\u00e1quina virtual, as\u00ed que su memoria queda reservada mientras est\u00e9 en marcha. D\u00e9jalos vac\u00edos para los valores por defecto ({{cpus}} CPU y {{memory}} GB) o indica hasta {{maxCpus}} CPU y {{maxMemory}} GB.", + "Cu\u00e1nto de este Mac puede usar el sandbox. Cada sandbox es una m\u00e1quina virtual, as\u00ed que su memoria queda reservada mientras est\u00e9 en marcha.", "sandbox.add.resourcesInvalid": "Las CPU deben ser {{minCpus}}\u2013{{maxCpus}} y la memoria {{minMemory}}\u2013{{maxMemory}} GB.", "sandbox.add.downloadProgress": "Descargando imagen… {{done}}/{{total}} MB", "sandbox.add.creating": "Creando el sandbox\u2026 esto tarda unos minutos.", diff --git a/packages/app/src/sandbox/dialog-add.tsx b/packages/app/src/sandbox/dialog-add.tsx index 022d7fc4b0..80ea641738 100644 --- a/packages/app/src/sandbox/dialog-add.tsx +++ b/packages/app/src/sandbox/dialog-add.tsx @@ -266,17 +266,10 @@ export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) =>

{language.t("sandbox.add.imageOk")}

- {/* Both are optional and both have real defaults, which the user - cannot otherwise discover — an empty box says nothing about - what the sandbox will actually get. */} -

- {language.t("sandbox.add.resourcesHint", { - cpus: SANDBOX_DEFAULT_CPUS, - memory: SANDBOX_DEFAULT_MEMORY_GIB, - maxCpus: SANDBOX_CPUS_RANGE.max, - maxMemory: SANDBOX_MEMORY_MIB_RANGE.max / 1024, - })} -

+ {/* Says what the numbers buy. The defaults live in the + placeholders and the ceilings in the out-of-range message, so + neither needs restating here. */} +

{language.t("sandbox.add.resourcesHint")}

{/* Grid, not flex: the input sets its own `width: 280px` and `flex: none`, so two of them side by side overflowed the dialog rather than sharing the row. Grid tracks size them, and