From a4e12c93ac405e501823674b6c3fe4e131e3e554 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Mon, 17 Aug 2026 11:10:15 +0700 Subject: [PATCH 1/3] chore: rebase main --- CHANGELOG.md | 4 ++++ DIVERGENCES.md | 1 + .../app/src/components/server/server-row-menu.tsx | 11 ++++++++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da64a69a5b..b176e69426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,10 @@ file is the human-readable summary of what changed and why. between **Code** and **Work** goes through the same check. A session that is merely unreachable for a moment still keeps its place; only one the server says is gone is passed over. +- **Deleting a server now looks destructive** — **Delete** in a server's menu + sat in the same colour as Edit and Set as default, giving the one + irreversible action in the menu no more weight than the rest. It is now red, + matching Delete in a session's menu. - **Each folder in a sandbox now has its own session tabs** — a sandbox can mount several folders, but all of their sessions shared one tab strip on the **Work** side, so working in two folders meant reading past the other one's diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 038e8c8c63..c05829b203 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -198,6 +198,7 @@ from the squash message first, or use a plain merge. | Dual-path WSL install: `packages/desktop/src/main/wsl/{runtime,servers}.ts` | `runtime.ts`: three internals gain `export` (`runPowerShell`, `runInteractiveCommand`, `resolveSystem32Command`) — no behavior change. `servers.ts`: the `install-wsl` job calls `installWslRuntimeDualPath` and the `install-distro` job calls `installWslDistroDualPath` (both from the fork-only `install-bundle.ts`, which holds all new logic) instead of `installWslRuntimeElevated` / `installWslDistro` directly; both return the same `WslCommandResult` shape, so the surrounding job/error handling is untouched. | The customer fleet sits behind whitelist-only proxies that cannot reach Microsoft's servers. The dual path downloads the pinned bundle (`wsl.zip`: runtime MSI + Ubuntu-24.04 image) from the codev storage host, enables VirtualMachinePlatform via offline dism + installs the MSI behind one UAC prompt, and falls back to upstream's `wsl --install` / `--web-download` paths only when the bundle cannot be *obtained* — a completed-but-failed elevated install is returned as-is, never retried behind a second UAC prompt. | Keep the two call swaps and the three `export` keywords; take upstream's surrounding changes. If upstream reworks `installWslRuntimeElevated` / `installWslDistro`, keep them exported from `runtime.ts` — `install-bundle.ts` calls them as the fallback path. | | Async settings stores: `packages/desktop/src/main/{store,index}.ts`, `packages/desktop/package.json` | `store.ts` no longer uses electron-store: `getStore` now returns the fork-only `store-file.ts` store — in-memory state, flushed write-behind through `store-writer.ts`'s serialized latest-wins async atomic writer (temp → fsync → rename via `fs.promises`, so the fsync runs on the libuv threadpool). Adds `flushStoreWrites(timeoutMs)`; `removeStoreFile`/`removeStoreFileIfEmpty` discard/flush the writer around the delete so a late flush cannot resurrect the file. `index.ts`: `stopSidecars` awaits `flushStoreWrites(1500)`. `package.json`: the `electron-store` dependency is removed. On-disk format is unchanged (flat keys, tab-indented JSON, no extension) — existing files load as-is; a corrupt file now starts empty with a logged error instead of throwing from every accessor. | conf v15's `store.set` is a synchronous fsync on the Electron main thread, and one of those parked the whole app in uninterruptible disk sleep (kernel journal stall + lost ext4 fast-commit wakeup, observed live during a sandbox mount-apply restart): event loop dead, IPC dead, quit impossible. conf also rereads the file on every access, which makes write-behind unsound with the stock class (a set on one key reads a stale file and drops a still-queued update), so the storage layer had to be replaced rather than patched. | Keep the fork store; take upstream's surrounding changes. If upstream adds electron-store options (schema, migrations, dot-notation) or new store call sites, extend `store-file.ts` to match — its API is the superset the desktop actually uses (`get/set/has/delete/clear/store/path`). Do not reintroduce a synchronous write path on the main thread. | | Desktop sandbox wiring: `packages/desktop/src/main/{index,store-keys}.ts`, `packages/desktop/package.json` | Hooks the fork-only `src/main/sandbox/{bundle,controller,ipc}.ts` modules into startup: `index.ts` constructs the sandbox controller beside the WSL servers controller, adds `sandboxes.stopAll()` to `stopSidecars`, registers the `sandbox-*` IPC channels next to the WSL ones, and calls `sandboxes.initialize()` (all platforms) once the local server is ready; `store-keys.ts` gains `SANDBOXES_KEY = "sandboxes"`; `package.json` gains the `@codev/sandbox` and `@codev/gateway` workspace devDependencies (bundled into the main process by electron-vite, so not runtime dependencies; gateway supplies the sandbox egress broker's credentials and config fragment). | The desktop app is where sandboxes are created and run; `createSandboxController` generalizes the proven WSL-servers controller pattern to the per-platform `@codev/sandbox` providers (macOS vz / Windows wsl / Linux bwrap). | Keep the wiring lines and take upstream's surrounding changes; they are additive and order-insensitive apart from `initialize()` needing the local server ready first. Do not rename the `sandbox-*` channels or the `sandboxes` store key — preload/renderer and persisted sandbox configs depend on them. | +| `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. | | Desktop identity: `packages/desktop/src/main/{index,store}.ts`, `packages/desktop/electron-builder.config{,.test}.ts`, `packages/desktop/scripts/copy-metainfo.ts`, `packages/desktop/resources/linux/opencode-desktop.desktop` (deleted), `packages/app/src/pages/layout/{deep-links.ts,helpers.test.ts}`, `packages/codev-brand/test/brand.test.ts` | appId `ai.opencode.desktop{,.beta,.dev}` → `ai.codev.desktop{,.beta,.dev}` (both copies: `APP_IDS` in main and in the builder config, plus the unpackaged fallback); deep-link scheme `opencode://` → `codev://` in all three places that must agree — `protocols.schemes`, `app.setAsDefaultProtocolClient`, and the renderer's `parseUrl` — plus the `argv.startsWith` filter the second-instance handler uses; `rpm.packageName` `opencode`/`opencode-beta`/`opencode-dev` → `codev`/`codev-beta`/`codev-dev`; the hidden `opencode-desktop.desktop` legacy launcher and its `deb`/`rpm` fpm wiring deleted. `src/main/migrate.ts` keeps the old ids — they name the *Tauri* app's directories, not ours. | The appId is the whole desktop identity: `app.setPath("userData", join(appData, appId))`, so it also names the data directory, and it drives the macOS bundle id, the Windows AppUserModelId and NSIS keys, the Linux `.desktop` filename, `executableName` and `StartupWMClass`, and the AppStream metainfo id. Leaving it as opencode meant the fork's app identified itself as upstream to the OS. Renamed as a clean cut with no data migration and no back-compat shim, on the explicit basis that there is no installed base: existing profiles under `appData/ai.opencode.desktop*` are simply orphaned (settings, session history, sandboxes and the downloaded guest image), and a packaged update across the rename would install beside the old app rather than replace it. The legacy launcher went with it — it existed to rescue GNOME/KDE pins from a superseded packaging id, and its `Exec=/opt/OpenCode/...` no longer matched any install layout. | Keep our ids and scheme; take upstream's other config. Never rename the `TAURI_APP_IDS` in `migrate.ts` to match — a blind find-and-replace there points the Tauri migration at a directory that never existed. All three deep-link sites have to move together or links open nothing; `electron-builder.config.test.ts` covers the ids, the package names and the scheme per channel. | diff --git a/packages/app/src/components/server/server-row-menu.tsx b/packages/app/src/components/server/server-row-menu.tsx index 9d5f5e5a32..3dc4bb90b5 100644 --- a/packages/app/src/components/server/server-row-menu.tsx +++ b/packages/app/src/components/server/server-row-menu.tsx @@ -48,7 +48,16 @@ export const ServerRowMenu: Component<{ - props.controller.handleRemove(key)}> + {/* Fork: danger colour comes from the item's own `--menu-v2-fg` + rather than a text utility — menu-v2.css colours the label span + directly, so a class on the item would not reach it. Setting the + variable recolours both, and the highlighted state only swaps + the background, so it survives hover. */} + props.controller.handleRemove(key)} + > {language.t("dialog.server.menu.delete")} From 467924543a758069b878c33667e0336a1f40b133 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Mon, 17 Aug 2026 10:33:49 +0700 Subject: [PATCH 2/3] fix(app): colour the WSL server Delete menu item as destructive Matches the generic server menu in the previous commit. The WSL rows sit in the same Settings -> Servers list, so leaving theirs uncoloured would have made the list inconsistent with itself. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- packages/app/src/wsl/settings.tsx | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b176e69426..50a3cccebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ file is the human-readable summary of what changed and why. - **Deleting a server now looks destructive** — **Delete** in a server's menu sat in the same colour as Edit and Set as default, giving the one irreversible action in the menu no more weight than the rest. It is now red, - matching Delete in a session's menu. + matching Delete in a session's menu, for both ordinary servers and WSL ones. - **Each folder in a sandbox now has its own session tabs** — a sandbox can mount several folders, but all of their sessions shared one tab strip on the **Work** side, so working in two folders meant reading past the other one's diff --git a/packages/app/src/wsl/settings.tsx b/packages/app/src/wsl/settings.tsx index 803dc5f34b..d169a87930 100644 --- a/packages/app/src/wsl/settings.tsx +++ b/packages/app/src/wsl/settings.tsx @@ -165,7 +165,13 @@ export function WslServerSettings(props: { - remove(key)}> + {/* Same danger hook the session and server menus use: + menu-v2.css colours the label span, so a text + utility on the item would not reach it. */} + remove(key)} + > {language.t("dialog.server.menu.delete")} From 078c551d9f6fbd9a47a5e29777120e8abb70bacf Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Mon, 17 Aug 2026 10:28:27 +0700 Subject: [PATCH 3/3] fix(app): stop the sandbox seeding effect from reopening a closed folder Closing a mounted folder on Work removed the row and then put it straight back. With Unmount that read as the action doing nothing: the row returned, then the mount change landed, the folder left conn.mounts, and the menu fell back to Close. Two independent causes, both needed: createServerProjects.close() writes the open list and the recently-closed list as two store writes. Solid flushes effects between them, so seedSandboxProjects saw a project that was in neither list and seeded it again. The pair is now one batch. That alone only covers folders the guest server has a project record for. The ctx's recentlyClosed is filtered down to directories in sync.data.project, and a folder that has never held a session has no record there, so `closed` could never carry the user's decision for a freshly mounted folder. The effect now remembers what it has seeded and will not seed the same folder twice, and forgets a folder once it is unmounted so remounting seeds it again. Regression cover drives the real project store and the real recentlyClosed shape; the existing fakes in that file were friendly enough to hide both. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 ++ DIVERGENCES.md | 1 + packages/app/src/context/server.tsx | 12 ++- packages/app/src/sandbox/projects.ts | 21 +++++- .../app/test-browser/sandbox-projects.test.ts | 74 ++++++++++++++++++- 5 files changed, 109 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50a3cccebb..365c2def6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,12 @@ file is the human-readable summary of what changed and why. sat in the same colour as Edit and Set as default, giving the one irreversible action in the menu no more weight than the rest. It is now red, matching Delete in a session's menu, for both ordinary servers and WSL ones. +- **Closing a folder on Work now actually closes it** — closing a mounted + folder from a **Work** project's menu removed it from the list and then put + it straight back, so there was no way to get a folder off the list at all. + Two separate causes, both fixed: the close was recorded in two steps and the + app reacted to the moment in between, and a folder you had never started a + session in was not recognised as one you had closed. - **Each folder in a sandbox now has its own session tabs** — a sandbox can mount several folders, but all of their sessions shared one tab strip on the **Work** side, so working in two folders meant reading past the other one's diff --git a/DIVERGENCES.md b/DIVERGENCES.md index c05829b203..9598357143 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -206,6 +206,7 @@ from the squash message first, or use a plain merge. | 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. | | 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"). | | `packages/app/src/context/global.tsx` | Two lines: imports `seedSandboxProjects` from the fork-only `packages/app/src/sandbox/projects.ts` and calls it with `server.list` + `ensureServerCtx`, beside the memo that already creates a ctx per connection. All the logic lives in the fork-only module. | A sandbox exposes exactly one directory (`/workspace` on vz/bwrap, the wslpath translation on WSL) and the guest registers it as its own project, but the app's per-server project list starts empty and nothing ever fills it. With no project, every "New session" entry point — `home-model.openNewSession`, the titlebar's `openNewTab` fallback chain — finds nothing for that server and falls back to another one, so the sandbox looks selected while the session runs on the local server. The seed reads `sync.data.path.directory`, which every server ctx already fetches, so it stays backend-agnostic. The same module also names that project after the sandbox's host folder (via `client.project.update`, the call behind rename-project), because the guest sees only its mount point and every sandbox would otherwise read "workspace" in the sidebar, tabs and palette. | Keep both lines; take upstream's other changes to the file. The call must stay inside the Global context init (it needs `ensureServerCtx`) — `SandboxesProvider` is mounted above `GlobalProvider` in `app.tsx`, so it cannot live there. | diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 5319f3971a..4d83dc9569 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -111,14 +111,22 @@ export function createServerProjects(input: { }, // User-initiated close: removes the project and records it in recently closed. // Internal, non-user removals (e.g. sandbox/worktree normalization) should use remove(). + // + // Fork: batched. The open list and the recently-closed list are two store + // keys, and Solid flushes effects after every write, so between the two a + // project is momentarily in neither list. An effect that reconciles + // against both — the sandbox project seeding in src/sandbox/projects.ts — + // sees that gap and opens the project straight back up. close(directory: string) { - remove(directory) const key = pathKey(directory) const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice( 0, RECENTLY_CLOSED_HISTORY_LIMIT, ) - setStore("recentlyClosed", input.scope(), closed) + batch(() => { + remove(directory) + setStore("recentlyClosed", input.scope(), closed) + }) }, expand(directory: string) { const index = current().findIndex((project) => project.worktree === directory) diff --git a/packages/app/src/sandbox/projects.ts b/packages/app/src/sandbox/projects.ts index 41c5d26ed0..983ac8544e 100644 --- a/packages/app/src/sandbox/projects.ts +++ b/packages/app/src/sandbox/projects.ts @@ -45,6 +45,13 @@ export function seedSandboxProjects( // A project is named once per app run. The rename lands back as a // project.updated event, but the effect can re-run before it arrives. const named = new Set() + // Folders this run has already opened. The `closed` check below cannot carry + // that on its own: the ctx's recentlyClosed is filtered down to directories + // the guest server reports as projects, and a folder that has never held a + // session has no such record — so closing one reads as "never opened" on the + // very next run of this effect, and it would be opened straight back up. + // Seeding a folder at most once is what lets the user close it. + const seeded = new Set() createEffect(() => { for (const conn of connections()) { @@ -56,14 +63,24 @@ export function seedSandboxProjects( { 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 + // seeds it again rather than leaving the user with no project for it. + const live = new Set(mounted.map((entry) => `${conn.name}\n${entry.directory}`)) + for (const key of seeded) { + if (key.startsWith(`${conn.name}\n`) && !live.has(key)) seeded.delete(key) + } for (const entry of mounted) { + const key = `${conn.name}\n${entry.directory}` const open = ctx.projects.list().some((project) => project.worktree === entry.directory) // Reopening a project the user closed on purpose would make it // impossible to remove. A mount removed from the sandbox keeps its // project open until the user closes it — sessions may reference it. const closed = ctx.projects.recentlyClosed().some((project) => project.worktree === entry.directory) - if (!open && !closed) ctx.projects.open(entry.directory) - void adoptFolderName(ctx, entry.hostPath, entry.directory, named, `${conn.name}\n${entry.directory}`) + if (!open && !closed && !seeded.has(key)) { + seeded.add(key) + ctx.projects.open(entry.directory) + } + void adoptFolderName(ctx, entry.hostPath, entry.directory, named, key) } } }) diff --git a/packages/app/test-browser/sandbox-projects.test.ts b/packages/app/test-browser/sandbox-projects.test.ts index 60c721bfd9..2138c9b854 100644 --- a/packages/app/test-browser/sandbox-projects.test.ts +++ b/packages/app/test-browser/sandbox-projects.test.ts @@ -1,6 +1,9 @@ import { expect, test } from "bun:test" import { createRoot, createSignal } from "solid-js" -import { ServerConnection } from "@/context/server" +import { createStore } from "solid-js/store" +import { createServerProjects, ServerConnection } from "@/context/server" +import { pathKey } from "@/utils/path-key" +import { ServerScope } from "@/utils/server-scope" import { seedSandboxProjects } from "@/sandbox/projects" function sandboxConnection(name: string, projectDir?: string): ServerConnection.Any { @@ -194,3 +197,72 @@ test("seeds a sandbox that becomes ready later", () => { expect(sandbox.opened).toEqual(["/workspace"]) dispose() }) + +// The fakes above hand seedSandboxProjects a friendly ctx. These two drive the +// real project store and the real recentlyClosed shape instead, because that is +// where closing a mounted folder actually broke: the row came back on its own, +// and once the unmount that followed it landed, the menu had fallen back to +// Close. Two independent causes, so two tests. +function realStore(serverProjects: string[]) { + const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} }) + const projects = createServerProjects({ scope: () => ServerScope.local, store, setStore }) + // context/global.tsx enriches recentlyClosed by filtering it down to the + // directories the guest server reports as projects. A mounted folder that has + // never held a session has no such record. + const known = new Set(serverProjects.map((worktree) => pathKey(worktree))) + const ctx = { + sdk: { client: { project: { update: async () => {} } } }, + projects: { + ...projects, + recentlyClosed: () => + projects + .recentlyClosed() + .filter((worktree) => known.has(pathKey(worktree))) + .map((worktree) => ({ worktree })), + }, + sync: { + data: { + path: { directory: "/workspace", worktree: "/workspace" }, + project: serverProjects.map((worktree, index) => ({ id: `p${index}`, worktree })), + }, + }, + } + const conn = { + displayName: "work", + type: "sidecar", + variant: "sandbox", + name: "work", + projectDir: "/home/dev/proj", + mounts: [{ path: "/home/dev/shared", guestPath: "/mnt/shared" }], + http: { url: "http://127.0.0.1:1234" }, + } as ServerConnection.Any + const dispose = createRoot((d) => { + seedSandboxProjects( + () => [conn], + () => ctx as never, + ) + return d + }) + return { projects, dispose, open: () => projects.list().map((project) => project.worktree) } +} + +// close() writes the open list and the recently-closed list separately, and +// Solid flushes effects between them: without a batch this effect saw a project +// that was in neither list and seeded it again. +test("closing a mounted folder sticks when the guest knows the project", () => { + const harness = realStore(["/workspace", "/mnt/shared"]) + expect(harness.open()).toContain("/mnt/shared") + harness.projects.close("/mnt/shared") + expect(harness.open()).not.toContain("/mnt/shared") + harness.dispose() +}) + +// The harder half: no server-side project record means the folder never appears +// in recentlyClosed at all, so `closed` can never carry the user's decision. +test("closing a mounted folder sticks when the guest has no project for it", () => { + const harness = realStore(["/workspace"]) + expect(harness.open()).toContain("/mnt/shared") + harness.projects.close("/mnt/shared") + expect(harness.open()).not.toContain("/mnt/shared") + harness.dispose() +})