Skip to content

feat(app): set the sandbox up for the user when Work needs one - #260

Merged
quickbeard merged 6 commits into
mainfrom
feat/work-default-sandbox
Aug 18, 2026
Merged

quickbeard merged 6 commits into
mainfrom
feat/work-default-sandbox

Conversation

@quickbeard

@quickbeard quickbeard commented Aug 17, 2026 •

Copy link
Copy Markdown
Owner

What

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. It now opens a dialog that asks for none of it.

Before Now
Name typed codev-sandbox, decided in main
Folder picked a placeholder the app makes; never shown
Image optional unchanged — still the one question
CPUs / memory (macOS) typed unset, so the backend default applies

Settings → Servers keeps DialogAddSandbox exactly as it is, for a second sandbox or one pointed at a particular folder from the start.

The folder question was the worst of the five: at that moment the user has not decided what they want to work on, and getting it wrong is permanent — a sandbox's project folder is fixed for its life. Deciding it for them and asking for a folder afterwards, as a mount, matches what a sandbox actually is: an environment whose reach into the machine is the folders you give it.

The placeholder folder

A sandbox cannot boot without a working directory, so the main process makes one at <app data>/sandbox-workspaces/codev-sandbox. On Windows that root is %LOCALAPPDATA%\CoDev\…, deliberately not 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 project folder through wslpath, which cannot translate one — the sandbox would simply fail to start. validateMountPaths already rejects UNC for the same reason.

It is a dedicated subdirectory rather than the app data root because the guest gets read-write access to whatever it is given, and that root holds the sandbox's own boot.json — and with it the guest server's password.

SandboxConfig gains placeholderProject, persisted rather than derived from the path so that app data moving cannot make the folder reappear. The app uses it in three places, because the folder reaches the UI three ways:

  • seedSandboxProjects no longer seeds a project for it, so no sidebar row is created.
  • Switching to Work no longer lands on it (tabs.tsx fell back to primaryFolder, which is exactly the folder being hidden).
  • sandbox-remove deletes it. destroy() clears the provider's own directories but never a project folder, since the usual one belongs to the user — so without this it would be orphaned in app data on every delete.

removeSandboxWorkspace refuses any path outside the root it owns. It deletes a directory tree, and a folder the user chose must never match.

Asking for the first project

With nothing mounted there is nothing to list, and upstream's empty-projects fallback offers a picker that browses the server it is pointed at — for a sandbox, the guest's own home. The Work sidebar shows a prompt instead, with a button that opens the existing Mount project dialog. Creating a sandbox does not open that dialog by itself: two dialogs in a row is the app deciding what the user does next, and the prompt is there whenever they want it.

That dialog also hides its primary-folder row for a placeholder sandbox — the path is app data the user did not choose and cannot remove, so it would have been the one place they ever met it. A sandbox pointed at a folder the user picked still shows it.

The wording is now "Mount a project" throughout, matching the dialog title and sidebar button, which already said project.

Shared image field

src/sandbox/image-field.tsx now owns the picker, the inspection round-trip and the verdict rendering, used by both dialogs. 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 (a field edited since is unchecked, not approved), and a picker failure has to be visible rather than leaving an empty field.

sandbox-cancel-default is its own channel rather than cancel(name): the name belongs to the main process, and a sandbox mid-create is not in the list the renderer can see — the controller publishes it only once the create has finished — so there is no name there to cancel by.

Verification

  • packages/desktop 113 pass, packages/app 696 pass, packages/codev-sandbox 149 pass / 2 skip. All three typecheck clean.
  • New coverage: the workspace root on each platform, including that Windows uses LOCALAPPDATA even when userData is a UNC path, and that an unset LOCALAPPDATA falls back rather than leaving the sandbox with nowhere to boot; ensureDefault being idempotent; and remove refusing both a folder the user chose (asserted by the file still being there) and the root itself. Plus isPlaceholderSandbox against a normal sandbox, a placeholder one, and a non-sandbox server.
  • Not exercised: the flow in the running app. Worth watching on the first real run — applying the first mount restarts the sandbox on macOS and Linux.

Note on formatting

prettier --check flags 8 of the files this branch touches — every one of them already fails on main, which has no format script and no CI format check. None of the failures come from this branch, and every file it adds is prettier-clean. Running --write was tried and reverted: it rewrapped unrelated lines (app-sidebar.tsx alone went to 155 changed lines instead of 28), which buries the change and, on upstream-tracked files, adds merge-conflict surface for lines that are not ours. Normalising the repo is worth doing as its own commit.

Docs

  • CHANGELOG.md — Unreleased ▸ Sandbox.
  • DIVERGENCES.md — a new row covering context/server.tsx, app-sidebar.tsx, i18n/{en,es}.ts and preload/index.ts, including the warning not to "simplify" the placeholder away by pointing the sandbox at the user's home, and that DEFAULT_SANDBOX_NAME is persisted as a sandbox id and a WSL distribution name.

🤖 Generated with Claude Code

quickbeard and others added 6 commits August 17, 2026 23:15
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) <noreply@anthropic.com>
…x exists

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
"That image is for macOS sandboxes. This computer needs the Windows/Linux
image." was rendered in the muted colour, the same as the line above it that
says the image is being checked and the one below that says it is fine. The
one message that means the create cannot go ahead read as an aside.

It now uses the v2 danger token with role="alert", matching how the other v2
dialogs report an inline validation failure. The whole message() family goes
with it — wrong architecture, not an archive, not a sandbox image, unreadable —
since each of them blocks the create for the same reason.

One change covers both dialogs: the field is shared by DialogAddSandbox in
Settings and DialogAddDefaultSandbox in Work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Renamed from codev-sandbox. It says what the sandbox is — the one the app made
without asking — rather than repeating the product name, which every sandbox on
the machine shares.

The name is not only a label: it becomes the sandbox id (sandbox:default-sandbox),
its data directory, its placeholder project folder, and on Windows the WSL
distribution name. Nothing migrates, because nothing has shipped: a sandbox
created from a build before this keeps the old name and goes on working, and
only deleting and re-adding it picks the new one up.

Left alone: the bubblewrap guest hostname and the codev-sandbox package,
docker image tags and os-release ID, none of which name this sandbox.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quickbeard
quickbeard merged commit 50d375a into main Aug 18, 2026
0 of 5 checks passed
@quickbeard
quickbeard deleted the feat/work-default-sandbox branch August 18, 2026 02:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant