Repository navigation
feat(app): set the sandbox up for the user when Work needs one - #260
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
codev-sandbox, decided in mainSettings → Servers keeps
DialogAddSandboxexactly 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 ElectronuserDatapath: 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 throughwslpath, which cannot translate one — the sandbox would simply fail to start.validateMountPathsalready 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.SandboxConfiggainsplaceholderProject, 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:seedSandboxProjectsno longer seeds a project for it, so no sidebar row is created.tabs.tsxfell back toprimaryFolder, which is exactly the folder being hidden).sandbox-removedeletes 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.removeSandboxWorkspacerefuses 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.tsxnow 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-defaultis its own channel rather thancancel(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/desktop113 pass,packages/app696 pass,packages/codev-sandbox149 pass / 2 skip. All three typecheck clean.LOCALAPPDATAeven whenuserDatais a UNC path, and that an unsetLOCALAPPDATAfalls back rather than leaving the sandbox with nowhere to boot;ensureDefaultbeing idempotent; andremoverefusing both a folder the user chose (asserted by the file still being there) and the root itself. PlusisPlaceholderSandboxagainst a normal sandbox, a placeholder one, and a non-sandbox server.Note on formatting
prettier --checkflags 8 of the files this branch touches — every one of them already fails onmain, 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--writewas tried and reverted: it rewrapped unrelated lines (app-sidebar.tsxalone 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 coveringcontext/server.tsx,app-sidebar.tsx,i18n/{en,es}.tsandpreload/index.ts, including the warning not to "simplify" the placeholder away by pointing the sandbox at the user's home, and thatDEFAULT_SANDBOX_NAMEis persisted as a sandbox id and a WSL distribution name.🤖 Generated with Claude Code