From 3188c0ec361ddf0b3a8837a66e29ce62104375ef Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Mon, 17 Aug 2026 09:28:56 +0700 Subject: [PATCH 1/6] feat(app): offer Mount project on Work instead of Add project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the Work side the folder button opened a directory picker rooted inside the guest (/home/codev): directoryPickerKind returns "server" for a sandbox because ServerConnection.local() is false for variant "sandbox". It could only reach folders that were already mounted — and those are auto-seeded as projects by src/sandbox/projects.ts anyway — while the operation it looked like it offered, pointing the sandbox at a folder on this machine, was not reachable from it at all. The same icon now opens the sandbox mounts dialog on Work. HomeServerRow and HomeProjectEmpty each take an optional mountProject handler; the fork-only app-sidebar passes it for sandboxes only and the sidebar's own header button switches on the side. The upstream data-action hooks keep their meaning and the Work variants get fork-only names. Relabels sandbox.server.mounts "Folders..." -> "Mount project", which renames the matching Settings -> Servers menu item at the same time so both routes to the dialog use one name. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 10 +++++++ DIVERGENCES.md | 1 + packages/app/src/components/app-sidebar.tsx | 32 ++++++++++++++++---- packages/app/src/i18n/en.ts | 2 +- packages/app/src/i18n/es.ts | 2 +- packages/app/src/pages/home.tsx | 33 ++++++++++++++++----- 6 files changed, 66 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93e81f7264..fae901b952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,16 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI +- **Work now offers Mount project instead of Add project** — the folder button + above the **Work** sidebar opened a folder picker that browsed *inside* the + sandbox, starting at the guest's home directory. It could not reach anything + on your own machine, so the one thing it looked like it offered — pointing a + sandbox at a folder here — was the one thing it could not do. The same button + now reads **Mount project** and opens the sandbox's folder list, where you + pick a folder on this machine to share into the sandbox; it then appears as a + project on Work, the way the sandbox's original project folder does. The + matching **Folders...** item in **Settings → Servers** is now called **Mount + project** too, so both routes to it use the same name. - **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..17b666cf35 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -205,6 +205,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. | +| Mount project on Work: `packages/app/src/pages/home.tsx`, `packages/app/src/i18n/{en,es}.ts` | `HomeServerRow` and `HomeProjectEmpty` each gain one optional `mountProject?: (server) => void` prop. When it is set, the folder-add icon keeps its icon and position but takes that handler instead of `chooseProject`, and its label/`aria-label` switch from `home.project.add` to `sandbox.server.mounts` via the new local `addProjectLabel(props)` helper; the `data-action` hooks fork to `work-mount-project` / `work-mount-project-row` so the upstream `home-add-project` / `home-add-project-row` hooks keep meaning exactly what they meant. Nothing else in either component changes and no import is added — the handler is injected by the fork-only `src/components/app-sidebar.tsx`, which passes it only for `sideForServer(conn) === "work"` and opens the fork-only `DialogSandboxMounts` (lazily; the sandbox id and its `ServerConnection.Key` are the same `sandbox:` string). The sidebar's own header button, which is fork-only code, switches on `side() === "work"` directly. The i18n change is a value only: `sandbox.server.mounts` "Folders..." -> "Mount project" (es "Carpetas..." -> "Montar proyecto"), which relabels the existing Settings -> Servers menu item at the same time, deliberately, so one sandbox verb appears everywhere. | On Work, "Add project" opened a directory picker rooted **inside the guest** (`/home/codev`): `directoryPickerKind` returns `"server"` for a sandbox because `ServerConnection.local()` is false for `variant: "sandbox"`. So the button could only reach folders that were already mounted — and those are auto-seeded as projects by `src/sandbox/projects.ts` anyway — while the one thing it looked like it offered, pointing the sandbox at a folder on this machine, was impossible. Mounting is that operation, and the mount then becomes a project, so the button now does what its icon has always implied. | Keep the prop. On conflict take upstream's row components wholesale and re-apply the optional prop, the `addProjectLabel` helper, the two `data-action` ternaries and the `??` on the click handler. Do not push the sandbox check down into `home.tsx` — keeping it in the sidebar is what holds this to a props-only upstream diff with no new imports. Never let `home-add-project` / `home-add-project-row` drift; the Work variants are fork-only names and are free to change. | | `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/components/app-sidebar.tsx b/packages/app/src/components/app-sidebar.tsx index 062294d532..616811984c 100644 --- a/packages/app/src/components/app-sidebar.tsx +++ b/packages/app/src/components/app-sidebar.tsx @@ -18,7 +18,7 @@ import { useSettingsCommand } from "@/components/settings-dialog" import { ChannelIndicator } from "@/components/titlebar" import { useAppSidebar } from "@/context/app-sidebar" import { useGlobal } from "@/context/global" -import { serversForSide } from "@/workspace/side" +import { serversForSide, sideForServer } from "@/workspace/side" import { useWorkspaceSide, WorkspaceEmpty, WorkspaceTabs } from "@/workspace/tabs" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" @@ -82,6 +82,26 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool const side = useWorkspaceSide() const sideServers = createMemo(() => serversForSide(global.servers.list(), side())) + // Fork: on Work, "Add project" would open a picker rooted in the guest + // (/home/codev), so it can only reach folders that are already mounted and + // cannot reach the host at all. Mounting a host folder is the operation the + // user actually wants there, and seedSandboxProjects turns each mount into a + // project — so the same icon opens the mounts dialog instead. + const mountProject = (conn: ServerConnection.Any) => { + // The sandbox id and its connection key are the same string (sandbox:). + const id = ServerConnection.key(conn) + void import("@/sandbox/dialog-mounts").then((module) => + dialog.show(() => ), + ) + } + // Passed only for sandboxes, which is what switches the row components over. + const mountProjectFor = (conn: ServerConnection.Any) => + sideForServer(conn) === "work" ? mountProject : undefined + // The header button sits above the list rather than on a server row, so it + // reads the side directly — Work only ever lists sandboxes. + const headerActionLabel = () => (side() === "work" ? "sandbox.server.mounts" : "home.project.add") + const headerAction = (conn: ServerConnection.Any) => (mountProjectFor(conn) ?? model.chooseProject)(conn) + // Collapsed hides the column outright rather than narrowing it to a rail, so // the content pane takes the full width. The titlebar toggle is the way back. return ( @@ -102,16 +122,16 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool !(model.projects().length === 0 && model.recentlyClosed().length > 0) } > - + } disabled={global.servers.health[ServerConnection.key(sideServers()[0]!)]?.healthy === false} - onClick={() => model.chooseProject(sideServers()[0]!)} - aria-label={language.t("home.project.add")} + onClick={() => headerAction(sideServers()[0]!)} + aria-label={language.t(headerActionLabel())} /> @@ -132,6 +152,7 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool recentlyClosed={model.recentlyClosed()} homedir={model.homedir()} chooseProject={model.chooseProject} + mountProject={mountProjectFor(sideServers()[0]!)} openRecentProject={(conn, directory) => model.addProjects(conn, [directory])} language={language} /> @@ -171,6 +192,7 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool controller={controller} focusServer={model.focusServer} chooseProject={model.chooseProject} + mountProject={mountProjectFor(item)} openEdit={(server) => dialog.show(() => )} toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])} language={language} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index d93966e4ed..aa1eec6a3b 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -386,7 +386,7 @@ export const dict = { "sandbox.server.start": "Start", "sandbox.server.stop": "Stop", "sandbox.server.reset": "Reset", - "sandbox.server.mounts": "Folders...", + "sandbox.server.mounts": "Mount project", "sandbox.status.starting": "Starting...", "sandbox.status.ready": "Ready", "sandbox.status.stopped": "Stopped", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 9d83d1bcba..7cf48572cc 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -377,7 +377,7 @@ export const dict = { "sandbox.server.start": "Iniciar", "sandbox.server.stop": "Detener", "sandbox.server.reset": "Restablecer", - "sandbox.server.mounts": "Carpetas...", + "sandbox.server.mounts": "Montar proyecto", "sandbox.status.starting": "Iniciando...", "sandbox.status.ready": "Listo", "sandbox.status.stopped": "Detenido", diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index fcb8c5e24d..6d6e0522e3 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -568,6 +568,12 @@ export function HomeUtilityNav(props: { ) } +// Fork: the two surfaces that offer "Add project" share one label so a sandbox +// never says "Add project" in one place and "Mount project" in the other. +function addProjectLabel(props: { mountProject?: unknown }) { + return props.mountProject ? "sandbox.server.mounts" : "home.project.add" +} + export function HomeServerRow(props: { server: ServerConnection.Any selected: boolean @@ -576,6 +582,9 @@ export function HomeServerRow(props: { controller: ReturnType focusServer: (server: ServerConnection.Any) => void chooseProject: (server: ServerConnection.Any) => void + // Fork: set for a sandbox, where a project is a host folder mounted into the + // guest rather than a directory the picker can browse to. See the button. + mountProject?: (server: ServerConnection.Any) => void openEdit: (server: ServerConnection.Http) => void toggleCollapsed: () => void language: ReturnType @@ -645,15 +654,23 @@ export function HomeServerRow(props: { open={state.menuOpen} onOpenChange={(open) => setState("menuOpen", open)} /> - + {/* Fork: on a sandbox the directory picker browses the guest, so it can + only reach folders that are already mounted — "Add project" there + offers a choice the user cannot make. The sidebar passes + mountProject instead, and the same icon opens the mounts dialog. */} + } - aria-label={props.language.t("home.project.add")} + aria-label={props.language.t(addProjectLabel(props))} disabled={props.health?.healthy === false} - onClick={() => props.chooseProject(props.server)} + onClick={() => (props.mountProject ?? props.chooseProject)(props.server)} /> @@ -703,6 +720,8 @@ export function HomeProjectEmpty(props: { recentlyClosed: LocalProject[] homedir: string chooseProject: (server: ServerConnection.Any) => void + // Fork: see HomeServerRow — a sandbox mounts folders instead of adding them. + mountProject?: (server: ServerConnection.Any) => void openRecentProject: (server: ServerConnection.Any, directory: string) => void language: ReturnType }) { @@ -712,13 +731,13 @@ export function HomeProjectEmpty(props: {
0}>
From 68c8205bb1098cd2d35107a3ad26245fb9a1e71f Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Mon, 17 Aug 2026 10:00:30 +0700 Subject: [PATCH 2/6] fix(app): colour the sandbox Delete menu item as destructive Delete in a sandbox's Settings -> Servers menu rendered in the same colour as Start, Stop and Reset, so the one irreversible action in the list carried no more weight than the rest. Use the same --menu-v2-fg danger hook the session actions menu uses; a text utility on the item would not reach the label span that menu-v2.css colours. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 ++++ packages/app/src/sandbox/settings.tsx | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fae901b952..ce77abdf17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ file is the human-readable summary of what changed and why. project on Work, the way the sandbox's original project folder does. The matching **Folders...** item in **Settings → Servers** is now called **Mount project** too, so both routes to it use the same name. +- **Deleting a sandbox now looks destructive** — **Delete** in a sandbox's menu + in **Settings → Servers** sat in the same colour as Start, Stop and Reset, + giving the one irreversible action in the list 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/packages/app/src/sandbox/settings.tsx b/packages/app/src/sandbox/settings.tsx index b17e6d1eb9..46e02dcb0b 100644 --- a/packages/app/src/sandbox/settings.tsx +++ b/packages/app/src/sandbox/settings.tsx @@ -142,7 +142,14 @@ export function SandboxServerSettings(props: { - request.mutate(() => props.controller.handleRemove(key))}> + {/* Same danger hook the session menu uses: menu-v2.css + colours the label span, so a text utility on the item + would not reach it. */} + request.mutate(() => props.controller.handleRemove(key))} + > {language.t("dialog.server.menu.delete")} From 8f0fe65043d8de5dab26090c61aa487bac12059c Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Mon, 17 Aug 2026 10:03:42 +0700 Subject: [PATCH 3/6] minor changes --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index cd436e0b48..2b9b38b0b8 100644 --- a/README.md +++ b/README.md @@ -59,5 +59,4 @@ bun install bun dev # run the CLI from source bun dev:desktop # run the Electron desktop app from source bun typecheck # all packages, via turbo -cd packages/opencode && bun test # tests must run from a package dir, never the repo root ``` From 23018c4d6cb24056fb02cdb8d90f6df4e9c8e208 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Mon, 17 Aug 2026 10:12:55 +0700 Subject: [PATCH 4/6] feat(app): unmount a Work folder from its project menu A project row on Work is a host folder shared into the sandbox, but its menu offered Close, which only hid the row: the folder stayed mounted with nothing in the app left to say so. The trailing item is now a red Unmount that removes the folder from the sandbox's mount list, and the row closes with it. HomeProjectList and HomeProjectRow take an optional unmountProject resolver rather than an action, so the row can ask per directory whether unmounting applies without home.tsx learning anything about sandboxes. It resolves to undefined for the sandbox's own project folder, which is fixed for its life, and for any guest directory that was never a mount; both keep Close. The fork-only sidebar owns the action. It rebuilds the mount list from the persisted SandboxConfig.mounts, never from conn.mounts, whose runtime list omits mounts the provider skipped because their host folder is gone. It closes the row before awaiting updateMounts, which restarts the sandbox on macOS and Linux, and reopens the row plus toasts if the call rejects. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 10 +++++ DIVERGENCES.md | 2 +- packages/app/src/components/app-sidebar.tsx | 41 +++++++++++++++++++++ packages/app/src/i18n/en.ts | 2 + packages/app/src/i18n/es.ts | 2 + packages/app/src/pages/home.tsx | 31 ++++++++++++++-- 6 files changed, 84 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce77abdf17..ae5e3f913b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,16 @@ file is the human-readable summary of what changed and why. project on Work, the way the sandbox's original project folder does. The matching **Folders...** item in **Settings → Servers** is now called **Mount project** too, so both routes to it use the same name. +- **A Work folder's menu now offers Unmount instead of Close** — a project on + the **Work** side is a folder shared into the sandbox, but its menu offered + **Close**, which only hid the row: the folder stayed shared with the sandbox + with nothing left in the app to say so, and no way to take it back short of + the sandbox's folder list. That item is now **Unmount**, in red, and it + really does unmount the folder — the sandbox stops seeing it and the row + goes away. On macOS and Linux the sandbox restarts to apply it, as it does + for any folder change; your tabs and sessions sit through the restart. The + folder the sandbox was created with keeps **Close**, since it is fixed for + the sandbox's lifetime and cannot be unmounted. - **Deleting a sandbox now looks destructive** — **Delete** in a sandbox's menu in **Settings → Servers** sat in the same colour as Start, Stop and Reset, giving the one irreversible action in the list no more weight than the rest. diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 17b666cf35..17ce7e3b76 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -205,7 +205,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. | -| Mount project on Work: `packages/app/src/pages/home.tsx`, `packages/app/src/i18n/{en,es}.ts` | `HomeServerRow` and `HomeProjectEmpty` each gain one optional `mountProject?: (server) => void` prop. When it is set, the folder-add icon keeps its icon and position but takes that handler instead of `chooseProject`, and its label/`aria-label` switch from `home.project.add` to `sandbox.server.mounts` via the new local `addProjectLabel(props)` helper; the `data-action` hooks fork to `work-mount-project` / `work-mount-project-row` so the upstream `home-add-project` / `home-add-project-row` hooks keep meaning exactly what they meant. Nothing else in either component changes and no import is added — the handler is injected by the fork-only `src/components/app-sidebar.tsx`, which passes it only for `sideForServer(conn) === "work"` and opens the fork-only `DialogSandboxMounts` (lazily; the sandbox id and its `ServerConnection.Key` are the same `sandbox:` string). The sidebar's own header button, which is fork-only code, switches on `side() === "work"` directly. The i18n change is a value only: `sandbox.server.mounts` "Folders..." -> "Mount project" (es "Carpetas..." -> "Montar proyecto"), which relabels the existing Settings -> Servers menu item at the same time, deliberately, so one sandbox verb appears everywhere. | On Work, "Add project" opened a directory picker rooted **inside the guest** (`/home/codev`): `directoryPickerKind` returns `"server"` for a sandbox because `ServerConnection.local()` is false for `variant: "sandbox"`. So the button could only reach folders that were already mounted — and those are auto-seeded as projects by `src/sandbox/projects.ts` anyway — while the one thing it looked like it offered, pointing the sandbox at a folder on this machine, was impossible. Mounting is that operation, and the mount then becomes a project, so the button now does what its icon has always implied. | Keep the prop. On conflict take upstream's row components wholesale and re-apply the optional prop, the `addProjectLabel` helper, the two `data-action` ternaries and the `??` on the click handler. Do not push the sandbox check down into `home.tsx` — keeping it in the sidebar is what holds this to a props-only upstream diff with no new imports. Never let `home-add-project` / `home-add-project-row` drift; the Work variants are fork-only names and are free to change. | +| Mount / unmount on Work: `packages/app/src/pages/home.tsx`, `packages/app/src/i18n/{en,es}.ts` | `HomeServerRow` and `HomeProjectEmpty` each gain one optional `mountProject?: (server) => void` prop. When it is set, the folder-add icon keeps its icon and position but takes that handler instead of `chooseProject`, and its label/`aria-label` switch from `home.project.add` to `sandbox.server.mounts` via the new local `addProjectLabel(props)` helper; the `data-action` hooks fork to `work-mount-project` / `work-mount-project-row` so the upstream `home-add-project` / `home-add-project-row` hooks keep meaning exactly what they meant. Nothing else in either component changes and no import is added — the handler is injected by the fork-only `src/components/app-sidebar.tsx`, which passes it only for `sideForServer(conn) === "work"` and opens the fork-only `DialogSandboxMounts` (lazily; the sandbox id and its `ServerConnection.Key` are the same `sandbox:` string). The sidebar's own header button, which is fork-only code, switches on `side() === "work"` directly. `HomeProjectList` and `HomeProjectRow` likewise gain one optional `unmountProject?: (server, directory) => (() => void) | undefined` — a **resolver**, not an action, so the row can ask per directory whether unmounting is even a thing here; where it resolves, the menu's trailing item becomes a red `sidebar.project.unmount` (same `--menu-v2-fg` danger hook as the session menu's Delete) and where it does not, the existing `common.close` item renders unchanged through the ``'s fallback. The resolver returns undefined for the sandbox's own project folder (fixed for the sandbox's life) and for any guest directory that is not a mount, so those keep Close. Its body lives in the fork-only sidebar: it rebuilds the mount list from the **persisted** `SandboxConfig.mounts`, never from `conn.mounts` (the runtime list omits mounts the provider skipped because their host folder is gone, so writing it back would silently drop them), closes the project row before awaiting `updateMounts` because applying a mount change restarts the sandbox on macOS and Linux, and reopens the row plus toasts if that call rejects. The i18n change is a value only: `sandbox.server.mounts` "Folders..." -> "Mount project" (es "Carpetas..." -> "Montar proyecto"), which relabels the existing Settings -> Servers menu item at the same time, deliberately, so one sandbox verb appears everywhere. | On Work, "Add project" opened a directory picker rooted **inside the guest** (`/home/codev`): `directoryPickerKind` returns `"server"` for a sandbox because `ServerConnection.local()` is false for `variant: "sandbox"`. So the button could only reach folders that were already mounted — and those are auto-seeded as projects by `src/sandbox/projects.ts` anyway — while the one thing it looked like it offered, pointing the sandbox at a folder on this machine, was impossible. Mounting is that operation, and the mount then becomes a project, so the button now does what its icon has always implied. | Keep the prop. On conflict take upstream's row components wholesale and re-apply the optional prop, the `addProjectLabel` helper, the two `data-action` ternaries and the `??` on the click handler. Do not push the sandbox check down into `home.tsx` — keeping it in the sidebar is what holds this to a props-only upstream diff with no new imports. Never let `home-add-project` / `home-add-project-row` drift; the Work variants are fork-only names and are free to change. Keep `unmountProject` a resolver: collapsing it to a plain action would force the row to decide what is unmountable, which is exactly the sandbox knowledge `home.tsx` must not carry. Never rebuild the mount list from `conn.mounts`. | | `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/components/app-sidebar.tsx b/packages/app/src/components/app-sidebar.tsx index 616811984c..6b6a7f87ac 100644 --- a/packages/app/src/components/app-sidebar.tsx +++ b/packages/app/src/components/app-sidebar.tsx @@ -18,7 +18,10 @@ import { useSettingsCommand } from "@/components/settings-dialog" import { ChannelIndicator } from "@/components/titlebar" import { useAppSidebar } from "@/context/app-sidebar" import { useGlobal } from "@/context/global" +import { useSandboxes } from "@/sandbox/context" import { serversForSide, sideForServer } from "@/workspace/side" +import { pathKey } from "@/utils/path-key" +import { showToast } from "@/utils/toast" import { useWorkspaceSide, WorkspaceEmpty, WorkspaceTabs } from "@/workspace/tabs" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" @@ -81,6 +84,7 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool // sections below are unchanged; they just read this instead of the full list. const side = useWorkspaceSide() const sideServers = createMemo(() => serversForSide(global.servers.list(), side())) + const sandboxes = useSandboxes() // Fork: on Work, "Add project" would open a picker rooted in the guest // (/home/codev), so it can only reach folders that are already mounted and @@ -102,6 +106,41 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool const headerActionLabel = () => (side() === "work" ? "sandbox.server.mounts" : "home.project.add") const headerAction = (conn: ServerConnection.Any) => (mountProjectFor(conn) ?? model.chooseProject)(conn) + // Fork: the inverse of Mount project. A Work project row is a host folder + // shared into the sandbox, so hiding the row while the folder stays mounted + // is not what "close" should mean there. Resolves an action only for an + // extra mount — the sandbox's own project folder is fixed for its lifetime, + // and a guest folder that was never a mount has nothing to unmount, so both + // of those keep Close. + const unmountProject = (conn: ServerConnection.Any, directory: string) => { + const api = platform.sandboxes + if (!api || conn.type !== "sidecar" || conn.variant !== "sandbox") return undefined + const mounted = conn.mounts?.find((entry) => pathKey(entry.guestPath) === pathKey(directory)) + if (!mounted) return undefined + return () => { + const key = ServerConnection.key(conn) + // Rebuilt from the persisted config rather than conn.mounts: the runtime + // list omits mounts the provider skipped because their folder is gone, + // and writing that back would drop them from the sandbox for good. + const configured = sandboxes.state.data?.sandboxes.find((item) => item.config.id === key)?.config.mounts ?? [] + // Closed first so the row goes away on the click. Applying the mount + // change restarts the sandbox on macOS and Linux, which takes seconds; + // a row that lingers through it reads as the click being ignored. + model.closeProject(conn, directory) + void api.updateMounts(key, configured.filter((entry) => entry.path !== mounted.path)).catch((error) => { + // Put it back: the folder is still mounted, so the list has to say so. + global.ensureServerCtx(conn).projects.open(directory) + showToast({ + variant: "error", + title: language.t("sidebar.project.unmount.failed"), + description: error instanceof Error ? error.message : String(error), + }) + }) + } + } + const unmountProjectFor = (conn: ServerConnection.Any) => + sideForServer(conn) === "work" ? unmountProject : undefined + // Collapsed hides the column outright rather than narrowing it to a rail, so // the content pane takes the full width. The titlebar toggle is the way back. return ( @@ -166,6 +205,7 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool openNewSession={model.openProjectNewSession} editProject={model.editProject} closeProject={model.closeProject} + unmountProject={unmountProjectFor(sideServers()[0]!)} clearNotifications={model.clearNotifications} unseenCount={model.unseenCount} language={language} @@ -207,6 +247,7 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool openNewSession={model.openProjectNewSession} editProject={model.editProject} closeProject={model.closeProject} + unmountProject={unmountProjectFor(item)} clearNotifications={model.clearNotifications} unseenCount={model.unseenCount} language={language} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index aa1eec6a3b..cc2890ad1b 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -891,6 +891,8 @@ export const dict = { "sidebar.project.recentSessions": "Recent sessions", "sidebar.project.viewAllSessions": "View all sessions", "sidebar.project.clearNotifications": "Clear notifications", + "sidebar.project.unmount": "Unmount", + "sidebar.project.unmount.failed": "The folder could not be unmounted", "sidebar.empty.title": "No projects open", "sidebar.empty.description": "Open a project to get started", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 7cf48572cc..e385c47520 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -823,6 +823,8 @@ export const dict = { "sidebar.project.recentSessions": "Sesiones recientes", "sidebar.project.viewAllSessions": "Ver todas las sesiones", "sidebar.project.clearNotifications": "Borrar notificaciones", + "sidebar.project.unmount": "Desmontar", + "sidebar.project.unmount.failed": "No se pudo desmontar la carpeta", "sidebar.empty.title": "No hay proyectos abiertos", "sidebar.empty.description": "Abre un proyecto para empezar", diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 6d6e0522e3..41dbabaafd 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -686,6 +686,8 @@ export function HomeProjectList(props: { openNewSession: (server: ServerConnection.Any, directory: string) => void editProject: (server: ServerConnection.Any, project: LocalProject) => void closeProject: (server: ServerConnection.Any, directory: string) => void + // Fork: see HomeProjectRow — set for a sandbox, resolved per directory. + unmountProject?: (server: ServerConnection.Any, directory: string) => (() => void) | undefined clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void unseenCount: (server: ServerConnection.Any, project: LocalProject) => number language: ReturnType @@ -706,6 +708,7 @@ export function HomeProjectList(props: { openNewSession={props.openNewSession} editProject={props.editProject} closeProject={props.closeProject} + unmountProject={props.unmountProject} clearNotifications={props.clearNotifications} language={props.language} /> @@ -799,11 +802,18 @@ function HomeProjectRow(props: { openNewSession: (server: ServerConnection.Any, directory: string) => void editProject: (server: ServerConnection.Any, project: LocalProject) => void closeProject: (server: ServerConnection.Any, directory: string) => void + // Fork: resolves the unmount action for a directory that is an extra folder + // mounted into a sandbox, and undefined for everything else — a plain + // project, or the sandbox's own project folder, which is fixed for its life. + // Where it resolves, this row's destructive action is unmounting the folder + // rather than hiding the row, so the menu says so. + unmountProject?: (server: ServerConnection.Any, directory: string) => (() => void) | undefined clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void language: ReturnType }) { const global = useGlobal() const platform = usePlatform() + const unmount = () => props.unmountProject?.(props.server, props.project.worktree) const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false const [state, setState] = createStore({ menuOpen: false }) const canRevealInFileManager = () => @@ -872,9 +882,24 @@ function HomeProjectRow(props: { {props.language.t("sidebar.project.clearNotifications")} - props.closeProject(props.server, props.project.worktree)}> - {props.language.t("common.close")} - + {/* Fork: unmounting removes the folder from the sandbox rather + than just hiding its row, so it takes the same danger colour + as Delete — set through the item's own `--menu-v2-fg`, which + is what reaches the label span menu-v2.css colours. */} + props.closeProject(props.server, props.project.worktree)}> + {props.language.t("common.close")} + + } + > + {(run) => ( + run()()} style={{ "--menu-v2-fg": "var(--v2-state-fg-danger)" }}> + {props.language.t("sidebar.project.unmount")} + + )} + From 5ed55c430174e396fd3f224e9df263dd293f1d71 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Mon, 17 Aug 2026 10:28:27 +0700 Subject: [PATCH 5/6] 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 | 8 ++ 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, 111 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae5e3f913b..6626c7ab71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,14 @@ file is the human-readable summary of what changed and why. project on Work, the way the sandbox's original project folder does. The matching **Folders...** item in **Settings → Servers** is now called **Mount project** too, so both routes to it use the same name. +- **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. This also fixes the + **Unmount** action below, which appeared to do nothing and left the menu + showing **Close** afterwards. - **A Work folder's menu now offers Unmount instead of Close** — a project on the **Work** side is a folder shared into the sandbox, but its menu offered **Close**, which only hid the row: the folder stayed shared with the sandbox diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 17ce7e3b76..cb7d3b9402 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -206,6 +206,7 @@ from the squash message first, or use a plain merge. | 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. | | Mount / unmount on Work: `packages/app/src/pages/home.tsx`, `packages/app/src/i18n/{en,es}.ts` | `HomeServerRow` and `HomeProjectEmpty` each gain one optional `mountProject?: (server) => void` prop. When it is set, the folder-add icon keeps its icon and position but takes that handler instead of `chooseProject`, and its label/`aria-label` switch from `home.project.add` to `sandbox.server.mounts` via the new local `addProjectLabel(props)` helper; the `data-action` hooks fork to `work-mount-project` / `work-mount-project-row` so the upstream `home-add-project` / `home-add-project-row` hooks keep meaning exactly what they meant. Nothing else in either component changes and no import is added — the handler is injected by the fork-only `src/components/app-sidebar.tsx`, which passes it only for `sideForServer(conn) === "work"` and opens the fork-only `DialogSandboxMounts` (lazily; the sandbox id and its `ServerConnection.Key` are the same `sandbox:` string). The sidebar's own header button, which is fork-only code, switches on `side() === "work"` directly. `HomeProjectList` and `HomeProjectRow` likewise gain one optional `unmountProject?: (server, directory) => (() => void) | undefined` — a **resolver**, not an action, so the row can ask per directory whether unmounting is even a thing here; where it resolves, the menu's trailing item becomes a red `sidebar.project.unmount` (same `--menu-v2-fg` danger hook as the session menu's Delete) and where it does not, the existing `common.close` item renders unchanged through the ``'s fallback. The resolver returns undefined for the sandbox's own project folder (fixed for the sandbox's life) and for any guest directory that is not a mount, so those keep Close. Its body lives in the fork-only sidebar: it rebuilds the mount list from the **persisted** `SandboxConfig.mounts`, never from `conn.mounts` (the runtime list omits mounts the provider skipped because their host folder is gone, so writing it back would silently drop them), closes the project row before awaiting `updateMounts` because applying a mount change restarts the sandbox on macOS and Linux, and reopens the row plus toasts if that call rejects. The i18n change is a value only: `sandbox.server.mounts` "Folders..." -> "Mount project" (es "Carpetas..." -> "Montar proyecto"), which relabels the existing Settings -> Servers menu item at the same time, deliberately, so one sandbox verb appears everywhere. | On Work, "Add project" opened a directory picker rooted **inside the guest** (`/home/codev`): `directoryPickerKind` returns `"server"` for a sandbox because `ServerConnection.local()` is false for `variant: "sandbox"`. So the button could only reach folders that were already mounted — and those are auto-seeded as projects by `src/sandbox/projects.ts` anyway — while the one thing it looked like it offered, pointing the sandbox at a folder on this machine, was impossible. Mounting is that operation, and the mount then becomes a project, so the button now does what its icon has always implied. | Keep the prop. On conflict take upstream's row components wholesale and re-apply the optional prop, the `addProjectLabel` helper, the two `data-action` ternaries and the `??` on the click handler. Do not push the sandbox check down into `home.tsx` — keeping it in the sidebar is what holds this to a props-only upstream diff with no new imports. Never let `home-add-project` / `home-add-project-row` drift; the Work variants are fork-only names and are free to change. Keep `unmountProject` a resolver: collapsing it to a plain action would force the row to decide what is unmountable, which is exactly the sandbox knowledge `home.tsx` must not carry. Never rebuild the mount list from `conn.mounts`. | +| `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() +}) From ec26234c827827ccaf29bc783aa7fc7258c23f77 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Mon, 17 Aug 2026 11:01:48 +0700 Subject: [PATCH 6/6] fix(app): read Unmount on every Work folder, disabled where it cannot apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every folder on a sandbox got there by being mounted, but only the extra ones appear in conn.mounts: the folder the sandbox was created with is projectDir, fixed for its lifetime. The resolver returned undefined for it, so that row fell back to Close and sat beside rows reading Unmount with nothing to explain the difference. The resolver now returns a shape rather than an action — undefined for an ordinary project, {} for a sandbox folder that cannot be unmounted, and { unmount } for one that can — so the Work menu always reads Unmount and disables the entry where it cannot apply. No Work row shows Close. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 8 ++-- DIVERGENCES.md | 2 +- packages/app/src/components/app-sidebar.tsx | 45 ++++++++++++--------- packages/app/src/pages/home.tsx | 23 ++++++----- 4 files changed, 46 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6626c7ab71..72fad42e17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,9 +41,11 @@ file is the human-readable summary of what changed and why. the sandbox's folder list. That item is now **Unmount**, in red, and it really does unmount the folder — the sandbox stops seeing it and the row goes away. On macOS and Linux the sandbox restarts to apply it, as it does - for any folder change; your tabs and sessions sit through the restart. The - folder the sandbox was created with keeps **Close**, since it is fixed for - the sandbox's lifetime and cannot be unmounted. + for any folder change; your tabs and sessions sit through the restart. Every + folder on a sandbox got there by being mounted, so they all read **Unmount**; + the folder the sandbox was created with is fixed for its lifetime, so there + the entry is greyed out rather than quietly saying something different from + the folders beside it. - **Deleting a sandbox now looks destructive** — **Delete** in a sandbox's menu in **Settings → Servers** sat in the same colour as Start, Stop and Reset, giving the one irreversible action in the list no more weight than the rest. diff --git a/DIVERGENCES.md b/DIVERGENCES.md index cb7d3b9402..0376d27016 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -205,7 +205,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. | -| Mount / unmount on Work: `packages/app/src/pages/home.tsx`, `packages/app/src/i18n/{en,es}.ts` | `HomeServerRow` and `HomeProjectEmpty` each gain one optional `mountProject?: (server) => void` prop. When it is set, the folder-add icon keeps its icon and position but takes that handler instead of `chooseProject`, and its label/`aria-label` switch from `home.project.add` to `sandbox.server.mounts` via the new local `addProjectLabel(props)` helper; the `data-action` hooks fork to `work-mount-project` / `work-mount-project-row` so the upstream `home-add-project` / `home-add-project-row` hooks keep meaning exactly what they meant. Nothing else in either component changes and no import is added — the handler is injected by the fork-only `src/components/app-sidebar.tsx`, which passes it only for `sideForServer(conn) === "work"` and opens the fork-only `DialogSandboxMounts` (lazily; the sandbox id and its `ServerConnection.Key` are the same `sandbox:` string). The sidebar's own header button, which is fork-only code, switches on `side() === "work"` directly. `HomeProjectList` and `HomeProjectRow` likewise gain one optional `unmountProject?: (server, directory) => (() => void) | undefined` — a **resolver**, not an action, so the row can ask per directory whether unmounting is even a thing here; where it resolves, the menu's trailing item becomes a red `sidebar.project.unmount` (same `--menu-v2-fg` danger hook as the session menu's Delete) and where it does not, the existing `common.close` item renders unchanged through the ``'s fallback. The resolver returns undefined for the sandbox's own project folder (fixed for the sandbox's life) and for any guest directory that is not a mount, so those keep Close. Its body lives in the fork-only sidebar: it rebuilds the mount list from the **persisted** `SandboxConfig.mounts`, never from `conn.mounts` (the runtime list omits mounts the provider skipped because their host folder is gone, so writing it back would silently drop them), closes the project row before awaiting `updateMounts` because applying a mount change restarts the sandbox on macOS and Linux, and reopens the row plus toasts if that call rejects. The i18n change is a value only: `sandbox.server.mounts` "Folders..." -> "Mount project" (es "Carpetas..." -> "Montar proyecto"), which relabels the existing Settings -> Servers menu item at the same time, deliberately, so one sandbox verb appears everywhere. | On Work, "Add project" opened a directory picker rooted **inside the guest** (`/home/codev`): `directoryPickerKind` returns `"server"` for a sandbox because `ServerConnection.local()` is false for `variant: "sandbox"`. So the button could only reach folders that were already mounted — and those are auto-seeded as projects by `src/sandbox/projects.ts` anyway — while the one thing it looked like it offered, pointing the sandbox at a folder on this machine, was impossible. Mounting is that operation, and the mount then becomes a project, so the button now does what its icon has always implied. | Keep the prop. On conflict take upstream's row components wholesale and re-apply the optional prop, the `addProjectLabel` helper, the two `data-action` ternaries and the `??` on the click handler. Do not push the sandbox check down into `home.tsx` — keeping it in the sidebar is what holds this to a props-only upstream diff with no new imports. Never let `home-add-project` / `home-add-project-row` drift; the Work variants are fork-only names and are free to change. Keep `unmountProject` a resolver: collapsing it to a plain action would force the row to decide what is unmountable, which is exactly the sandbox knowledge `home.tsx` must not carry. Never rebuild the mount list from `conn.mounts`. | +| Mount / unmount on Work: `packages/app/src/pages/home.tsx`, `packages/app/src/i18n/{en,es}.ts` | `HomeServerRow` and `HomeProjectEmpty` each gain one optional `mountProject?: (server) => void` prop. When it is set, the folder-add icon keeps its icon and position but takes that handler instead of `chooseProject`, and its label/`aria-label` switch from `home.project.add` to `sandbox.server.mounts` via the new local `addProjectLabel(props)` helper; the `data-action` hooks fork to `work-mount-project` / `work-mount-project-row` so the upstream `home-add-project` / `home-add-project-row` hooks keep meaning exactly what they meant. Nothing else in either component changes and no import is added — the handler is injected by the fork-only `src/components/app-sidebar.tsx`, which passes it only for `sideForServer(conn) === "work"` and opens the fork-only `DialogSandboxMounts` (lazily; the sandbox id and its `ServerConnection.Key` are the same `sandbox:` string). The sidebar's own header button, which is fork-only code, switches on `side() === "work"` directly. `HomeProjectList` and `HomeProjectRow` likewise gain one optional `unmountProject?: (server, directory) => { unmount?: () => void } | undefined` — a **resolver**, not an action, so the row can ask per directory how its destructive action should read. Undefined is an ordinary project and keeps `common.close` through the ``'s fallback. An object means the directory is a folder mounted into a sandbox, so the item becomes a red `sidebar.project.unmount` (same `--menu-v2-fg` danger hook as the session menu's Delete); `unmount` is absent when the folder cannot be unmounted — the one the sandbox was created with is `projectDir`, fixed for its lifetime, and never appears in `conn.mounts` — and the item renders disabled. Every folder on a sandbox got there by being mounted, so **no Work row shows Close**: a row that did would be reading differently from the rows beside it for a reason the user cannot see. Its body lives in the fork-only sidebar: it rebuilds the mount list from the **persisted** `SandboxConfig.mounts`, never from `conn.mounts` (the runtime list omits mounts the provider skipped because their host folder is gone, so writing it back would silently drop them), closes the project row before awaiting `updateMounts` because applying a mount change restarts the sandbox on macOS and Linux, and reopens the row plus toasts if that call rejects. The i18n change is a value only: `sandbox.server.mounts` "Folders..." -> "Mount project" (es "Carpetas..." -> "Montar proyecto"), which relabels the existing Settings -> Servers menu item at the same time, deliberately, so one sandbox verb appears everywhere. | On Work, "Add project" opened a directory picker rooted **inside the guest** (`/home/codev`): `directoryPickerKind` returns `"server"` for a sandbox because `ServerConnection.local()` is false for `variant: "sandbox"`. So the button could only reach folders that were already mounted — and those are auto-seeded as projects by `src/sandbox/projects.ts` anyway — while the one thing it looked like it offered, pointing the sandbox at a folder on this machine, was impossible. Mounting is that operation, and the mount then becomes a project, so the button now does what its icon has always implied. | Keep the prop. On conflict take upstream's row components wholesale and re-apply the optional prop, the `addProjectLabel` helper, the two `data-action` ternaries and the `??` on the click handler. Do not push the sandbox check down into `home.tsx` — keeping it in the sidebar is what holds this to a props-only upstream diff with no new imports. Never let `home-add-project` / `home-add-project-row` drift; the Work variants are fork-only names and are free to change. Keep `unmountProject` a resolver: collapsing it to a plain action would force the row to decide what is unmountable, which is exactly the sandbox knowledge `home.tsx` must not carry. Never rebuild the mount list from `conn.mounts`. | | `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/components/app-sidebar.tsx b/packages/app/src/components/app-sidebar.tsx index 6b6a7f87ac..2d459f8e95 100644 --- a/packages/app/src/components/app-sidebar.tsx +++ b/packages/app/src/components/app-sidebar.tsx @@ -116,26 +116,33 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool const api = platform.sandboxes if (!api || conn.type !== "sidecar" || conn.variant !== "sandbox") return undefined const mounted = conn.mounts?.find((entry) => pathKey(entry.guestPath) === pathKey(directory)) - if (!mounted) return undefined - return () => { - const key = ServerConnection.key(conn) - // Rebuilt from the persisted config rather than conn.mounts: the runtime - // list omits mounts the provider skipped because their folder is gone, - // and writing that back would drop them from the sandbox for good. - const configured = sandboxes.state.data?.sandboxes.find((item) => item.config.id === key)?.config.mounts ?? [] - // Closed first so the row goes away on the click. Applying the mount - // change restarts the sandbox on macOS and Linux, which takes seconds; - // a row that lingers through it reads as the click being ignored. - model.closeProject(conn, directory) - void api.updateMounts(key, configured.filter((entry) => entry.path !== mounted.path)).catch((error) => { - // Put it back: the folder is still mounted, so the list has to say so. - global.ensureServerCtx(conn).projects.open(directory) - showToast({ - variant: "error", - title: language.t("sidebar.project.unmount.failed"), - description: error instanceof Error ? error.message : String(error), + // Every folder on a sandbox got there by being mounted, so the menu says + // Unmount for all of them. `conn.mounts` holds only the extra folders, + // though — the folder the sandbox was created with is `projectDir`, fixed + // for the sandbox's lifetime — so that one is offered disabled rather than + // given a different verb from the rows beside it. + if (!mounted) return {} + return { + unmount: () => { + const key = ServerConnection.key(conn) + // Rebuilt from the persisted config rather than conn.mounts: the + // runtime list omits mounts the provider skipped because their folder + // is gone, and writing that back would drop them for good. + const configured = sandboxes.state.data?.sandboxes.find((item) => item.config.id === key)?.config.mounts ?? [] + // Closed first so the row goes away on the click. Applying the mount + // change restarts the sandbox on macOS and Linux, which takes seconds; + // a row that lingers through it reads as the click being ignored. + model.closeProject(conn, directory) + void api.updateMounts(key, configured.filter((entry) => entry.path !== mounted.path)).catch((error) => { + // Put it back: the folder is still mounted, so the list must say so. + global.ensureServerCtx(conn).projects.open(directory) + showToast({ + variant: "error", + title: language.t("sidebar.project.unmount.failed"), + description: error instanceof Error ? error.message : String(error), + }) }) - }) + }, } } const unmountProjectFor = (conn: ServerConnection.Any) => diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 41dbabaafd..b860753a3e 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -687,7 +687,7 @@ export function HomeProjectList(props: { editProject: (server: ServerConnection.Any, project: LocalProject) => void closeProject: (server: ServerConnection.Any, directory: string) => void // Fork: see HomeProjectRow — set for a sandbox, resolved per directory. - unmountProject?: (server: ServerConnection.Any, directory: string) => (() => void) | undefined + unmountProject?: (server: ServerConnection.Any, directory: string) => { unmount?: () => void } | undefined clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void unseenCount: (server: ServerConnection.Any, project: LocalProject) => number language: ReturnType @@ -802,12 +802,13 @@ function HomeProjectRow(props: { openNewSession: (server: ServerConnection.Any, directory: string) => void editProject: (server: ServerConnection.Any, project: LocalProject) => void closeProject: (server: ServerConnection.Any, directory: string) => void - // Fork: resolves the unmount action for a directory that is an extra folder - // mounted into a sandbox, and undefined for everything else — a plain - // project, or the sandbox's own project folder, which is fixed for its life. - // Where it resolves, this row's destructive action is unmounting the folder - // rather than hiding the row, so the menu says so. - unmountProject?: (server: ServerConnection.Any, directory: string) => (() => void) | undefined + // Fork: resolves how this row's destructive action should read. Undefined is + // an ordinary project, which keeps Close. An object means the directory is a + // folder mounted into a sandbox, so the action is Unmount; `unmount` is + // absent when the folder cannot be unmounted — the one the sandbox was + // created with is fixed for its lifetime — and the item is shown disabled + // rather than silently reading differently from the rows beside it. + unmountProject?: (server: ServerConnection.Any, directory: string) => { unmount?: () => void } | undefined clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void language: ReturnType }) { @@ -894,8 +895,12 @@ function HomeProjectRow(props: { } > - {(run) => ( - run()()} style={{ "--menu-v2-fg": "var(--v2-state-fg-danger)" }}> + {(state) => ( + state().unmount?.()} + > {props.language.t("sidebar.project.unmount")} )}