From 5bcdf84effd744b4735470419eb7dfad505b6917 Mon Sep 17 00:00:00 2001
From: minhn4
Date: Mon, 17 Aug 2026 23:15:43 +0700
Subject: [PATCH 1/6] feat(app): set the sandbox up for the user when Work
needs one
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reaching Work without a sandbox opened the full Add sandbox form: a name, a
folder, an image, and on a Mac a CPU count and a memory size, before anything
could happen. The folder question is the worst of them — at that moment the
user has not decided what they want to work on, and answering it wrong is
permanent, since a sandbox's project folder is fixed for its life.
Work now opens a dialog that asks for none of it. The main process picks the
name (codev-sandbox), makes the project folder, and leaves the limits unset so
the backend default applies; the only thing left to choose is a custom guest
image, which stays because supplying one is a real answer on a machine that
cannot reach the storage host. Settings → Servers keeps the full form, for a
second sandbox or one pointed at a particular folder from the start.
The folder it makes is a placeholder the user never sees. It lives beside the
app's other sandbox data, under %LOCALAPPDATA% on Windows rather than the
Electron userData path: that resolves under %APPDATA%, which a roaming profile
can point at a UNC share, and the WSL backend reaches a project folder through
wslpath, which cannot translate one. SandboxConfig carries placeholderProject
so the app knows to keep that folder out of the sidebar, out of the folder Work
lands on when switching to it, and to delete it with the sandbox — destroy()
clears the provider's directories but never a project folder, since the usual
one belongs to the user.
With no folder to show, the sidebar asks for one: upstream's empty-projects
fallback offers a picker that browses the server it is pointed at, which for a
sandbox is the guest's own home. The Folders dialog also opens by itself once
the sandbox is created, so the common path is create then mount, and the prompt
is what remains for a user who cancels it or comes back later.
The image field moves to src/sandbox/image-field.tsx so both dialogs share it.
It is the only field they have in common and the only one with behaviour worth
getting wrong twice: a verdict has to belong to the path it was made about, and
a picker failure has to be visible.
Co-Authored-By: Claude Opus 5 (1M context)
---
CHANGELOG.md | 12 ++
DIVERGENCES.md | 1 +
packages/app/src/components/app-sidebar.tsx | 28 +++-
packages/app/src/context/server.tsx | 4 +
packages/app/src/i18n/en.ts | 6 +
packages/app/src/i18n/es.ts | 6 +
.../app/src/sandbox/dialog-add-default.tsx | 145 ++++++++++++++++++
packages/app/src/sandbox/dialog-add.tsx | 102 +-----------
packages/app/src/sandbox/image-field.tsx | 121 +++++++++++++++
packages/app/src/sandbox/mount-prompt.tsx | 30 ++++
packages/app/src/sandbox/open-mounts.tsx | 10 ++
packages/app/src/sandbox/projects.test.ts | 14 +-
packages/app/src/sandbox/projects.ts | 14 +-
packages/app/src/sandbox/types.ts | 13 ++
packages/app/src/workspace/tabs.tsx | 31 +++-
packages/codev-sandbox/src/provider.ts | 7 +
.../desktop/src/main/sandbox/controller.ts | 3 +
packages/desktop/src/main/sandbox/ipc.ts | 45 +++++-
.../src/main/sandbox/workspace.test.ts | 86 +++++++++++
.../desktop/src/main/sandbox/workspace.ts | 62 ++++++++
packages/desktop/src/preload/index.ts | 2 +
.../src/renderer/sandbox/connections.ts | 4 +
22 files changed, 632 insertions(+), 114 deletions(-)
create mode 100644 packages/app/src/sandbox/dialog-add-default.tsx
create mode 100644 packages/app/src/sandbox/image-field.tsx
create mode 100644 packages/app/src/sandbox/mount-prompt.tsx
create mode 100644 packages/app/src/sandbox/open-mounts.tsx
create mode 100644 packages/desktop/src/main/sandbox/workspace.test.ts
create mode 100644 packages/desktop/src/main/sandbox/workspace.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c7c6c290db..9835df1b06 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,18 @@ file is the human-readable summary of what changed and why.
### Sandbox
+- **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 `codev-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, it asks which of
+ your folders it may use, and that is the folder you work in — the sandbox's
+ own scratch folder is the app's business and is not shown to you anywhere.
+ 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..be8318f00b 100644
--- a/DIVERGENCES.md
+++ b/DIVERGENCES.md
@@ -209,6 +209,7 @@ 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 (`codev-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. `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:codev-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. |
diff --git a/packages/app/src/components/app-sidebar.tsx b/packages/app/src/components/app-sidebar.tsx
index cf87ac375b..1d23779575 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"
@@ -144,14 +146,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}
+ />
+ }
+ >
+
+
}
>
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/image-field.tsx b/packages/app/src/sandbox/image-field.tsx
new file mode 100644
index 0000000000..11fb0dc442
--- /dev/null
+++ b/packages/app/src/sandbox/image-field.tsx
@@ -0,0 +1,121 @@
+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. */}
+
+
+ >
+ ),
+ }
+}
diff --git a/packages/app/src/sandbox/mount-prompt.tsx b/packages/app/src/sandbox/mount-prompt.tsx
new file mode 100644
index 0000000000..0cb9750dfe
--- /dev/null
+++ b/packages/app/src/sandbox/mount-prompt.tsx
@@ -0,0 +1,30 @@
+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"
+import { openSandboxMounts } from "./open-mounts"
+
+// 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/open-mounts.tsx b/packages/app/src/sandbox/open-mounts.tsx
new file mode 100644
index 0000000000..7632db818e
--- /dev/null
+++ b/packages/app/src/sandbox/open-mounts.tsx
@@ -0,0 +1,10 @@
+import type { useDialog } from "@opencode-ai/ui/context/dialog"
+
+// Opens the Folders dialog for one sandbox. Three callers reach it the same
+// lazy way — the Work tab right after a sandbox is created, the sidebar's
+// mount button, and the prompt shown while a sandbox has no folders yet — and
+// the import has to stay lazy: this dialog is desktop-only and pulls the whole
+// mounts editor in with it.
+export function openSandboxMounts(dialog: ReturnType, id: string) {
+ void import("./dialog-mounts").then((module) => dialog.show(() => ))
+}
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/tabs.tsx b/packages/app/src/workspace/tabs.tsx
index ebad254b80..58c5c33849 100644
--- a/packages/app/src/workspace/tabs.tsx
+++ b/packages/app/src/workspace/tabs.tsx
@@ -10,6 +10,8 @@ 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 { openSandboxMounts } from "@/sandbox/open-mounts"
+import { isPlaceholderSandbox } from "@/sandbox/projects"
import { usePlatform } from "@/context/platform"
import {
effectiveSide,
@@ -245,8 +247,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 +269,22 @@ 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")
+ openSandboxMounts(dialog, config.id)
+ }}
+ />
+ ))
})
return
}
@@ -309,7 +326,13 @@ 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(() => (
+ openSandboxMounts(dialog, config.id)} />
+ )),
+ )
}}
>
{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..bb5dd85156
--- /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 = "codev-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,
From 7ff9ebb53270f5dc8456a2c345501e981f7412f6 Mon Sep 17 00:00:00 2001
From: minhn4
Date: Tue, 18 Aug 2026 08:38:56 +0700
Subject: [PATCH 2/6] fix(app): mount a project when the user asks, not the
moment a sandbox exists
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three corrections to the one-click sandbox.
The Mount project dialog listed the sandbox's project folder as a fixed row.
For the sandbox Work creates that folder is app data — a path in Application
Support the user did not choose and cannot remove — so the one dialog that
still showed it was the one place they would ever meet it. It is hidden there
now, like everywhere else; a sandbox pointed at a folder the user picked still
shows it, since that is the folder it works on.
Creating a sandbox no longer opens the Mount project dialog by itself. Two
dialogs in a row is the app deciding what the user does next, and the sidebar
prompt is already there when they want it. That leaves the prompt as the only
route, so its lazy import moves back inline and open-mounts.tsx goes.
"Mount a folder" is now "Mount a project", matching the dialog's own title and
the sidebar button, which already said project. The strings inside the dialog
follow: Add project, Remove project, and an empty state that talks about
projects rather than extra folders. What is on disk is still described as a
folder, because that is what the user picks.
Co-Authored-By: Claude Opus 5 (1M context)
---
CHANGELOG.md | 11 ++++++-----
DIVERGENCES.md | 2 +-
packages/app/src/i18n/en.ts | 12 ++++++------
packages/app/src/i18n/es.ts | 13 +++++++------
packages/app/src/sandbox/dialog-mounts.tsx | 13 +++++++++----
packages/app/src/sandbox/mount-prompt.tsx | 8 ++++++--
packages/app/src/sandbox/open-mounts.tsx | 10 ----------
packages/app/src/workspace/tabs.tsx | 14 ++------------
8 files changed, 37 insertions(+), 46 deletions(-)
delete mode 100644 packages/app/src/sandbox/open-mounts.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9835df1b06..cfa9ae0477 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,11 +23,12 @@ file is the human-readable summary of what changed and why.
what you want to work on. Work now asks for none of it. It creates a sandbox
called `codev-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, it asks which of
- your folders it may use, and that is the folder you work in — the sandbox's
- own scratch folder is the app's business and is not shown to you anywhere.
- Settings → Servers keeps the full form for a second sandbox, or one pointed
- at a particular folder from the start.
+ 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 be8318f00b..a6e938c18c 100644
--- a/DIVERGENCES.md
+++ b/DIVERGENCES.md
@@ -209,7 +209,7 @@ 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 (`codev-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. `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:codev-sandbox`) and as a WSL distribution name — changing it strands existing sandboxes. |
+| 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 (`codev-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:codev-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. |
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index a87d59d842..d1fbdab015 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -390,8 +390,8 @@ export const dict = {
"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 folder to start working in it.",
- "sandbox.mountPrompt.action": "Mount a folder",
+ "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",
@@ -444,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 fb37cf828e..2e4a66dfa6 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -382,8 +382,8 @@ export const dict = {
"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 una carpeta para empezar a trabajar en él.",
- "sandbox.mountPrompt.action": "Montar una carpeta",
+ "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",
@@ -437,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-mounts.tsx b/packages/app/src/sandbox/dialog-mounts.tsx
index 60487a6f4e..1cd36871bb 100644
--- a/packages/app/src/sandbox/dialog-mounts.tsx
+++ b/packages/app/src/sandbox/dialog-mounts.tsx
@@ -124,10 +124,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/mount-prompt.tsx b/packages/app/src/sandbox/mount-prompt.tsx
index 0cb9750dfe..b1d450dd25 100644
--- a/packages/app/src/sandbox/mount-prompt.tsx
+++ b/packages/app/src/sandbox/mount-prompt.tsx
@@ -2,7 +2,6 @@ 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"
-import { openSandboxMounts } from "./open-mounts"
// 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
@@ -20,7 +19,12 @@ export function SandboxMountPrompt(props: { server: ServerConnection.Any }) {
type="button"
data-action="sandbox-mount-prompt"
class="flex items-center gap-1.5 text-12-medium text-v2-text-text-base underline [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
- onClick={() => openSandboxMounts(dialog, ServerConnection.key(props.server))}
+ onClick={() => {
+ // Lazy, like every other route to this dialog: it is desktop-only and
+ // pulls the whole mounts editor in with it.
+ const id = ServerConnection.key(props.server)
+ void import("./dialog-mounts").then((module) => dialog.show(() => ))
+ }}
>
{language.t("sandbox.mountPrompt.action")}
diff --git a/packages/app/src/sandbox/open-mounts.tsx b/packages/app/src/sandbox/open-mounts.tsx
deleted file mode 100644
index 7632db818e..0000000000
--- a/packages/app/src/sandbox/open-mounts.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import type { useDialog } from "@opencode-ai/ui/context/dialog"
-
-// Opens the Folders dialog for one sandbox. Three callers reach it the same
-// lazy way — the Work tab right after a sandbox is created, the sidebar's
-// mount button, and the prompt shown while a sandbox has no folders yet — and
-// the import has to stay lazy: this dialog is desktop-only and pulls the whole
-// mounts editor in with it.
-export function openSandboxMounts(dialog: ReturnType, id: string) {
- void import("./dialog-mounts").then((module) => dialog.show(() => ))
-}
diff --git a/packages/app/src/workspace/tabs.tsx b/packages/app/src/workspace/tabs.tsx
index 58c5c33849..deaa3bf01b 100644
--- a/packages/app/src/workspace/tabs.tsx
+++ b/packages/app/src/workspace/tabs.tsx
@@ -10,7 +10,6 @@ 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 { openSandboxMounts } from "@/sandbox/open-mounts"
import { isPlaceholderSandbox } from "@/sandbox/projects"
import { usePlatform } from "@/context/platform"
import {
@@ -277,14 +276,7 @@ export function useWorkspaceSwitch() {
// 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")
- openSandboxMounts(dialog, config.id)
- }}
- />
- ))
+ void dialog.show(() => void enter("work")} />)
})
return
}
@@ -329,9 +321,7 @@ export function WorkspaceEmpty(props: { side: WorkspaceSide }) {
// 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(() => (
- openSandboxMounts(dialog, config.id)} />
- )),
+ dialog.show(() => ),
)
}}
>
From 629a245bc87af50ce1c6b4b4b48ac7ab6ec862e2 Mon Sep 17 00:00:00 2001
From: minhn4
Date: Tue, 18 Aug 2026 09:13:31 +0700
Subject: [PATCH 3/6] fix(app): close a folder's sessions when it is unmounted
from a sandbox
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Start a session in a mounted folder, unmount that folder, mount another, and
the first session was still there — the conversation on screen, its tab in the
strip, and nothing in Recents. Its folder had left the sandbox, so no folder
listed it, and workspace/side.ts deliberately shows a tab whose directory the
sandbox no longer lists in *every* folder: the fallback that keeps a tab which
has not learned its directory from becoming invisible also kept an orphaned one
permanently visible. It followed the user into whatever they mounted next.
Unmounting now takes the folder's tabs with it. `tabs.removeDirectory` removes
every session and draft tab of one (server, folder), modelled on removeServer
rather than closeTab — the folder left the sandbox, so this is not a close the
user can undo, and a reopened tab would point at a folder that is gone. The
sessions stay in the guest's database, so mounting the folder again brings them
back.
Both routes go through it. The project row's Unmount already removed the row;
the Mount project dialog removed neither, so a folder unmounted there left both
its row and its tabs behind. They now share forgetSandboxFolder, and the dialog
reads each folder's guest path before saving — once the mount is gone, so is
the mapping from host path to guest path.
The matching is its own module (workspace/tab-directory.ts) because both
context/tabs.tsx and workspace/side.ts reach the router at import time and
cannot be imported by a test. A tab that has not learned its directory is not
matched: it belongs to whichever folder it resolves to later, and guessing
would close a tab the user is still working in.
Co-Authored-By: Claude Opus 5 (1M context)
---
CHANGELOG.md | 8 ++++
DIVERGENCES.md | 2 +-
packages/app/src/context/tabs.tsx | 30 +++++++++++++
packages/app/src/sandbox/dialog-mounts.tsx | 23 +++++++++-
.../app/src/sandbox/forget-folder.test.ts | 28 +++++++++++++
packages/app/src/sandbox/forget-folder.ts | 31 ++++++++++++++
packages/app/src/sandbox/mounts-model.test.ts | 20 ++++++++-
packages/app/src/sandbox/mounts-model.ts | 9 ++++
packages/app/src/sandbox/project-menu.tsx | 16 +++----
.../app/src/workspace/tab-directory.test.ts | 42 +++++++++++++++++++
packages/app/src/workspace/tab-directory.ts | 16 +++++++
11 files changed, 215 insertions(+), 10 deletions(-)
create mode 100644 packages/app/src/sandbox/forget-folder.test.ts
create mode 100644 packages/app/src/sandbox/forget-folder.ts
create mode 100644 packages/app/src/workspace/tab-directory.test.ts
create mode 100644 packages/app/src/workspace/tab-directory.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cfa9ae0477..775809407e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,14 @@ 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
diff --git a/DIVERGENCES.md b/DIVERGENCES.md
index a6e938c18c..197666d07d 100644
--- a/DIVERGENCES.md
+++ b/DIVERGENCES.md
@@ -216,7 +216,7 @@ from the squash message first, or use a plain merge.
| 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/context/tabs.tsx b/packages/app/src/context/tabs.tsx
index 00a4c9766a..58e4754b4f 100644
--- a/packages/app/src/context/tabs.tsx
+++ b/packages/app/src/context/tabs.tsx
@@ -9,6 +9,7 @@ import { usePlatform } from "./platform"
import { uuid } from "@/utils/uuid"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { sessionHref } from "@/utils/session-route"
+import { tabsInDirectory } from "@/workspace/tab-directory"
import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
@@ -378,6 +379,35 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const draftID of drafts) removeDraftPersisted(draftID)
if (server.key === key) navigate("/")
},
+ // Fork: every tab belonging to one mounted folder of one sandbox, for
+ // when that folder is unmounted. Its sessions stay in the guest, but the
+ // folder they ran in is no longer part of the sandbox, so a tab left
+ // behind is a conversation the user cannot continue and the sidebar does
+ // not list — inScope() shows a tab whose folder the sandbox no longer
+ // lists in *every* folder, which is right for a tab that has not learned
+ // its directory yet and wrong for one whose directory is gone for good.
+ // Modelled on removeServer: the container went away, so this is not a
+ // close the user can undo.
+ removeDirectory(input: { server: ServerConnection.Key; directory: string }) {
+ const going = tabsInDirectory(store, input.server, input.directory)
+ if (!going.length) return
+ const removed = going.map(tabKey)
+ const drafts = going.flatMap((tab) => (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/sandbox/dialog-mounts.tsx b/packages/app/src/sandbox/dialog-mounts.tsx
index 1cd36871bb..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({
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..30e03dfdb5
--- /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:codev-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/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/workspace/tab-directory.test.ts b/packages/app/src/workspace/tab-directory.test.ts
new file mode 100644
index 0000000000..e65505b974
--- /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:codev-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)
+}
From 88a7ab22d2f5efe0e7f90bf988dce14feb87ac2f Mon Sep 17 00:00:00 2001
From: minhn4
Date: Tue, 18 Aug 2026 09:28:20 +0700
Subject: [PATCH 4/6] fix(app): show the pointer cursor on Work's mount buttons
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Both ways into the Mount project dialog from the sidebar looked unclickable:
the icon button beside Projects and the prompt shown while a sandbox has no
folders both kept the arrow cursor on hover.
The icon button needs the important modifier. icon-button-v2.css sets
`cursor: default` on every icon button, and that file is unlayered while
Tailwind's utilities live in @layer utilities — unlayered CSS wins over any
layered rule regardless of specificity, so a plain `cursor-pointer` would have
been silently dropped. `enabled:` keeps the disabled state on the not-allowed
cursor the variant already gives it, which is what the button shows while its
sandbox is unhealthy. The prompt is a plain button with no component CSS
competing, so it needs no modifier.
Work only: this is the button that mounts a host folder into the sandbox. The
same element on Code is upstream's Add project, which is left alone.
Co-Authored-By: Claude Opus 5 (1M context)
---
packages/app/src/components/app-sidebar.tsx | 11 ++++++++++-
packages/app/src/sandbox/mount-prompt.tsx | 2 +-
2 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/packages/app/src/components/app-sidebar.tsx b/packages/app/src/components/app-sidebar.tsx
index 1d23779575..dfeeb5c4f5 100644
--- a/packages/app/src/components/app-sidebar.tsx
+++ b/packages/app/src/components/app-sidebar.tsx
@@ -117,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={() => {
diff --git a/packages/app/src/sandbox/mount-prompt.tsx b/packages/app/src/sandbox/mount-prompt.tsx
index b1d450dd25..39764e4579 100644
--- a/packages/app/src/sandbox/mount-prompt.tsx
+++ b/packages/app/src/sandbox/mount-prompt.tsx
@@ -18,7 +18,7 @@ export function SandboxMountPrompt(props: { server: ServerConnection.Any }) {