diff --git a/CHANGELOG.md b/CHANGELOG.md index f5bc121289..1f4f018988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,22 @@ file is the human-readable summary of what changed and why. ### Sandbox +- **Work no longer offers to run a session in a workspace** — the new-session + screen's "Local" chip creates a git worktree on the machine the session runs + on, so in a sandbox it checked out into the sandbox's own disk: invisible + from this computer, and gone the next time the sandbox was reset. Its + "Local repository" wording said the opposite. Work now shows the branch on + its own, the way it already did for a project that is not in git. Code is + unchanged. +- **A new session in Work only offers Work's projects** — the project picker on + the new-session screen listed every project on the computer, grouped under + "Local Server" beside the sandbox's own, even though you had reached that + screen through the Work tab. Picking one started the session on the local + machine, outside the sandbox, with nothing to say so. Work now offers only + the projects inside its sandboxes, and Code only the projects outside them. + Its "Add project" entry has become "Mount project" and opens the same dialog + the Work sidebar does: adding a project to a sandbox means giving it a folder + from this computer, where the old entry browsed the sandbox's own insides. - **Work says when the sandbox failed to start** — the Work sidebar showed "Your sandbox is not running yet. It appears here once it is ready." for a sandbox that had already failed to boot, so the only way to learn something diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 55f64fc13f..772de513e8 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -217,6 +217,8 @@ 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, plus `removeDirectory({server, directory})` — every tab of one mounted folder, sessions and drafts, for when that folder is unmounted (matching lives in the fork-only `src/workspace/tab-directory.ts`, its own module because both `context/tabs.tsx` and `workspace/side.ts` reach the router at import time and so cannot be unit-tested). Modelled on `removeServer` rather than `closeTab`: the folder left the sandbox, so the tabs are not a close the user can undo, and leaving them behind is what made an unmounted folder's session reappear over the next folder the user mounted — `inScope()` shows a tab whose folder the sandbox no longer lists in *every* folder, so it followed the user around while appearing in no folder's Recents; `layout.tsx` adds `home.lastFolderSession` (keyed `` `${server}\n${pathKey(directory)}` ``) with `folderSession`/`recordFolderSession`; `titlebar.tsx` swaps the side filter for the scope in the strip, reorder, `tab.prev`/`tab.next` and `tab.close`, passes `session.directory` to `addSessionTab`, and gains a leading scope branch in `openNewTab`; `home.tsx` passes `directory` to `addSessionTab`. `pages/home-model.tsx` (fork-only) selects instead of toggling on Work and calls `enterFolder`. The scope is **derived**, never stored: the folder on screen wins, then the sidebar's selected project, then the sandbox's primary folder from `sync.data.path.directory` — so deep links, the command palette and a reopened tab all pull the strip to their own folder instead of leaving the active tab out of view. `pages/session.tsx` and `components/command-palette.ts` are deliberately left untouched; their tabs pick up a folder through the back-fill instead, which keeps two more upstream files out of this table. | Sandboxes gained extra folder mounts, so one sandbox can expose several unrelated folders while their sessions shared a single strip — working across two folders meant constantly reading past the other one's tabs. Deriving the scope rather than persisting a "current folder" makes "the strip always contains the session you are looking at" true by construction instead of by an effect racing the two `home` effects in `layout.tsx`, and it keeps the sidebar's Recents unnarrowed: writing the route's folder into `home.selection.directory` would feed `projectDirectories()` and silently filter that list too. | Keep the wiring and take upstream's surrounding changes. `tabsForScope` must return the store's own tab objects — the strip compares by reference for the active highlight, the close index and the `` key, so never map to copies. Never add `directory` to `tabKey()`. A tab whose folder cannot be resolved shows in **every** folder of its own sandbox on purpose: it is exactly the tab that has never rendered and so never had a chance to learn where it belongs, and being invisible everywhere is unrecoverable. `folderForDirectory`'s prefix test must stay path-boundary aware — mount guest names dedup with a `-2` suffix, so `/mnt/shared` must not swallow `/mnt/shared-2`. Resolve the primary folder from `sync.data.path`, never `projects.list()[0]`: `projects.open()` prepends, so that is the most recently added mount. | | Code/Work workspace split: `packages/app/src/context/layout.tsx`, `packages/app/src/components/{app-sidebar,titlebar}.tsx`, `packages/app/src/i18n/{en,es}.ts` | The sidebar gains two tabs above Projects — **Code** (every non-sandbox server: the local sidecar, WSL, remote, SSH) and **Work** (the sandboxes) — backed by the fork-only `packages/app/src/workspace/{side.ts,tabs.tsx}`. `layout.tsx` stores `home.workspace` and `home.lastSession` (per side) and records the latter from an effect on `route()`; `app-sidebar.tsx` renders ``, swaps `global.servers.list()` for a side-filtered memo in all six places it indexed it, and adds an empty-state branch because a side can now have no servers at all; `titlebar.tsx` loses the Home button and its `home.toggle` command (`mod+b`) and filters the tab strip, its reorder and `tab.prev`/`tab.next` to the visible side; `effectiveSide` falls back to Code only where the platform has no sandbox support — deliberately not on an empty sandbox list, which is indistinguishable from one that has not loaded; `layout.tsx` also holds an effect keeping `home.selection.server` on the showing side, because a sandbox joins the server list only once it has finished starting, so the one-shot correction inside a switch cannot cover a sandbox created moments earlier; the fork-only `DialogAddSandbox` gains an `onCreated` callback so the Work gate switches on the create's own result rather than on the asynchronous sandbox list; `dialog-select-server.tsx` `handleRemove` calls `workspace.removedServer(key)` beside the tab cleanup, which steps back to Code when the sandbox being deleted is the last one; i18n gains `workspace.{code,work,empty.none,empty.starting,empty.stopped,empty.failed,empty.failed.retry}` (`empty.stopped` for a Work side whose sandboxes are all stopped, which unlike starting will never come up on its own; `empty.failed`/`empty.failed.retry` for one whose sandbox failed to start — the empty state shows the runtime's failure message and a retry that calls `platform.sandboxes.start`, because the wait message otherwise left the user watching for a sandbox that already gave up). | 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. | +| Workspace (worktree) bar is Code-only: `packages/app/src/pages/new-session.tsx` | `showWorkspaceBar` gains a third condition, `sideForServer(serverSDK().server) === "code"`, so the "Run session in → Local repository / New workspace / Workspace…" chip is not offered on Work. Work falls through to the same `PromptGitStatus` branch a non-git project already takes, which shows the branch on its own. Nothing else moves: `newSessionWorktree()` already returns `"main"` whenever the bar is hidden, and `"main"` in `prompt-input/submit.ts` means "leave `sessionDirectory` at `sdk().directory`" — it does not redirect a session to the project root. The bar remains gated on `VITE_CODEV_CHANNEL !== "prod"`, which is upstream's own gate. | A workspace is a git worktree created by the server that runs the session, and `Worktree.makeWorktreeInfo` roots it at `Global.Path.data/worktree/` (`packages/opencode/src/worktree/index.ts`). In a sandbox that is the guest's own data dir: it is not one of the host folders the sandbox mounts (`packages/codev-sandbox/src/mounts.ts`), so the work happens where the user's editor cannot see it, and a reset re-clones the guest disk and takes it along. Meanwhile the chip says "Local" under a monitor icon, which is the opposite of true. Three storage designs were costed before choosing to hide it — a checkout inside the mounted repo, a dedicated hidden worktree mount per sandbox, or leaving it on the guest disk — and none of them make a guest worktree usable from the host, because a worktree's `.git` file holds an absolute path back to `/.git/worktrees/` and that path is `/mnt/` in the guest and the real path on the host. Note also that `git worktree add` runs with `cwd` = the project worktree, i.e. the mounted host repo, so it registers the worktree in the user's real `.git` whatever we do with the checkout. | Keep the gate. If Work should offer workspaces later, the storage question has to be answered first — where the checkout lives, and what a reset does to it — not just the label; re-read the reasoning column before reopening it. Read the side from `sideForServer(serverSDK().server)`, matching the project picker row above, so a draft is judged by its own server rather than by whichever side the sidebar is showing. If upstream changes what `showWorkspaceBar` gates, keep the third condition rather than reverting to the two-term memo. | +| New-session project picker on Work: `packages/app/src/pages/session/composer/session-composer-controls.ts`, `packages/app/src/components/{prompt-project-selector,app-sidebar}.tsx` | Extends the Code/Work split row above to the composer's project picker. `createPromptProjectControls` indexed `server.list` in four places — the grouped project list, the two server lookups behind select/add, and the `server` field the controls hand back — so a draft on the Work side listed the local machine's projects under a "Local Server" heading beside the sandbox's own, and picking one moved the session onto that server. All four read a `sideServers()` memo instead: `serversForSide(server.list, sideForServer(projectServer()))`, from the fork-only `src/workspace/side.ts`. The side comes from the draft's **own** server (`serverSDK().server`) rather than `useWorkspaceSide()`, so a draft always offers what it can actually run on whichever side happens to be showing. Nothing else changes: with one server on the side the picker takes the same single-server branch Code has always taken (`layout.projects.list()`, or the draft's server ctx for a draft route), which drops the group headings and the "Add project" submenu the way it does for a lone local server. **Add project on Work is a mount**: `addProject` opens the fork-only `DialogSandboxMounts` for that sandbox instead of `pickDirectory`, through the fork-only `src/sandbox/mount-dialog.tsx` — upstream's picker browses the server it is pointed at, which for a sandbox is the guest's own filesystem. `PromptProjectControls` gains an optional `mount` flag and `prompt-project-selector.tsx` reads it in two label getters (`labels.add`, `labels.new`) so the item says "Mount project" (`sandbox.server.mounts`, the string the Work sidebar's button already uses); nothing else in that component changes. `app-sidebar.tsx`'s Work branch and the fork-only `mount-prompt.tsx` both route through the same hook rather than repeating the lazy import. | The two sides exist so that "am I on my machine or in a sandbox?" is a property of where you are. A picker that offers both breaks that in the one place it matters most — the moment a session is created — and the failure is silent: the session opens on the local machine with no sandbox isolation, from a screen the user reached through the Work tab. | Keep the filter and take upstream's surrounding changes. If upstream adds a fifth `server.list` read here, filter it too. Read the side from `sideForServer(projectServer())`, not `useWorkspaceSide()` — a draft route mounts its own server (`ResolvedDraftRoute` in `app.tsx`), and the sidebar's side is not guaranteed to agree with it. The `sideServers().length <= 1` branch must stay a length test on the *side* list, not on `server.list`: on a machine with one sandbox and one local server the old test sent Work down the multi-server path. | | `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 4f9826c831..d4a7427e88 100644 --- a/packages/app/src/components/app-sidebar.tsx +++ b/packages/app/src/components/app-sidebar.tsx @@ -37,6 +37,7 @@ import { HomeUtilityNav, } from "@/pages/home" import { SandboxMountPrompt } from "@/sandbox/mount-prompt" +import { useSandboxMountDialog } from "@/sandbox/mount-dialog" import { isPlaceholderSandbox } from "@/sandbox/projects" import { SidebarSection } from "@/components/sidebar-section" import { createInlineEditorController } from "@/pages/layout/inline-editor" @@ -83,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 openMounts = useSandboxMountDialog() // 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. @@ -129,9 +131,7 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool onClick={() => { const conn = sideServers()[0]! if (side() === "work") { - void import("@/sandbox/dialog-mounts").then((module) => { - dialog.show(() => ) - }) + openMounts(conn) return } model.chooseProject(conn) diff --git a/packages/app/src/components/prompt-project-selector.tsx b/packages/app/src/components/prompt-project-selector.tsx index 1e5445517d..30f0a74910 100644 --- a/packages/app/src/components/prompt-project-selector.tsx +++ b/packages/app/src/components/prompt-project-selector.tsx @@ -34,6 +34,10 @@ export type PromptProjectControls = { server?: string select: (worktree: string, server?: string) => void add: (title: string, server?: string) => void + // Fork: on the Work side `add` mounts a host folder into the sandbox rather + // than opening one of the server's own directories, so it is labelled the way + // that action is labelled everywhere else in Work. + mount?: boolean } const actionPrefix = "action:" @@ -134,9 +138,9 @@ export function createPromptProjectController(input: { search: () => store.search, active: () => store.active, labels: { - add: () => language.t("session.new.project.add"), + add: () => language.t(input.controls().mount ? "sandbox.server.mounts" : "session.new.project.add"), clear: () => language.t("common.clear"), - new: () => language.t("session.new.project.new"), + new: () => language.t(input.controls().mount ? "sandbox.server.mounts" : "session.new.project.new"), search: () => language.t("session.new.project.search"), }, add, diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index a33ee3bdc8..8505e1b9f4 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -18,6 +18,7 @@ import { useComments } from "@/context/comments" import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" +import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" @@ -34,6 +35,7 @@ import { Persist, persisted } from "@/utils/persist" import createPresence from "solid-presence" import { useLocal } from "@/context/local" import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection" +import { sideForServer } from "@/workspace/side" const workspaceBarEnabled = import.meta.env.VITE_CODEV_CHANNEL !== "prod" const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000 @@ -48,6 +50,7 @@ export default function NewSessionPage() { const prompt = usePrompt() const sdk = useSDK() const sync = useSync() + const serverSDK = useServerSDK() const serverSync = useServerSync() const comments = useComments() const language = useLanguage() @@ -79,7 +82,15 @@ export default function NewSessionPage() { const [store, setStore] = createStore<{ worktree?: string }>({}) const rightMount = useTitlebarRightMount() - const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git") + // Fork: Code only. A workspace is a git worktree the session's server checks + // out, and inside a sandbox that lands on the guest's own disk — invisible + // from this machine and gone with the next reset — while "Local repository" + // and its monitor icon read as the exact opposite. Work shows the branch on + // its own instead, the way a non-git project already does. See the worktree + // row in DIVERGENCES.md before turning this back on for Work. + const showWorkspaceBar = createMemo( + () => workspaceBarEnabled && sync().project?.vcs === "git" && sideForServer(serverSDK().server) === "code", + ) const newSessionWorktree = createMemo(() => { if (!showWorkspaceBar()) return "main" if (store.worktree) return store.worktree diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index f52b7f4b42..b419a9e992 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -15,7 +15,9 @@ import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { useTabs } from "@/context/tabs" import { useProviders } from "@/hooks/use-providers" +import { useSandboxMountDialog } from "@/sandbox/mount-dialog" import { pathKey } from "@/utils/path-key" +import { serversForSide, sideForServer } from "@/workspace/side" export function createPromptInputController(input: { sessionKey: Accessor @@ -64,14 +66,21 @@ export function createPromptProjectControls() { const tabs = useTabs() const global = useGlobal() const pickDirectory = useDirectoryPicker() + const openMounts = useSandboxMountDialog() const [search] = useSearchParams<{ draftId?: string }>() const projectServer = () => serverSDK().server const projectServerCtx = createMemo(() => global.ensureServerCtx(projectServer())) + // Fork: the Code/Work split. The picker offers the servers on the same side + // as the draft's own server — the sandboxes for a Work draft, everything + // else for a Code one — rather than every server there is. Read from the + // draft's server instead of the sidebar's side so a draft always offers what + // it can actually run on, whichever side happens to be showing. + const sideServers = createMemo(() => serversForSide(server.list, sideForServer(projectServer()))) const projects = createMemo(() => { - if (server.list.length <= 1) { + if (sideServers().length <= 1) { return search.draftId ? projectServerCtx().projects.list() : layout.projects.list() } - return server.list.flatMap((conn) => { + return sideServers().flatMap((conn) => { const item = { key: ServerConnection.key(conn), name: serverName(conn) } return global .ensureServerCtx(conn) @@ -80,7 +89,7 @@ export function createPromptProjectControls() { }) }) const selectProject = (worktree: string, serverKey?: string) => { - const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer() + const conn = serverKey ? sideServers().find((conn) => ServerConnection.key(conn) === serverKey) : projectServer() if (search.draftId) { if (!conn) return const target = global.ensureServerCtx(conn) @@ -106,8 +115,16 @@ export function createPromptProjectControls() { } const addProject = (title: string, serverKey?: string) => { - const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer() + const conn = serverKey ? sideServers().find((conn) => ServerConnection.key(conn) === serverKey) : projectServer() if (!conn) return + // Fork: adding a project to a sandbox means mounting a folder from this + // computer into it. Upstream's picker browses the server it is pointed at, + // which for a sandbox is the guest's own filesystem — nothing the user has + // ever put a project in. Same dialog the Work sidebar's add button opens. + if (sideForServer(conn) === "work") { + openMounts(conn) + return + } pickDirectory({ server: conn, title, @@ -121,8 +138,9 @@ export function createPromptProjectControls() { return createMemo(() => ({ available: projects(), directory: sdk().directory, - server: server.list.length > 1 ? ServerConnection.key(projectServer()) : undefined, + server: sideServers().length > 1 ? ServerConnection.key(projectServer()) : undefined, select: selectProject, add: addProject, + mount: sideForServer(projectServer()) === "work", })) } diff --git a/packages/app/src/sandbox/mount-dialog.tsx b/packages/app/src/sandbox/mount-dialog.tsx new file mode 100644 index 0000000000..3940662c6b --- /dev/null +++ b/packages/app/src/sandbox/mount-dialog.tsx @@ -0,0 +1,15 @@ +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { ServerConnection } from "@/context/server" + +// The one route to a sandbox's mounts editor. Every caller opens it lazily — +// the dialog is desktop-only and pulls the whole editor in with it — so the +// import lives here rather than being repeated at each call site: the Work +// sidebar's add button, its empty-state prompt, and the new-session project +// picker, which all mean the same thing by "add a project" on Work. +export function useSandboxMountDialog() { + const dialog = useDialog() + return (server: ServerConnection.Any) => { + const id = ServerConnection.key(server) + void import("./dialog-mounts").then((module) => dialog.show(() => )) + } +} diff --git a/packages/app/src/sandbox/mount-prompt.tsx b/packages/app/src/sandbox/mount-prompt.tsx index 39764e4579..f479faa614 100644 --- a/packages/app/src/sandbox/mount-prompt.tsx +++ b/packages/app/src/sandbox/mount-prompt.tsx @@ -1,7 +1,7 @@ -import { useDialog } from "@opencode-ai/ui/context/dialog" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { ServerConnection } from "@/context/server" +import type { ServerConnection } from "@/context/server" import { useLanguage } from "@/context/language" +import { useSandboxMountDialog } from "./mount-dialog" // What the Work sidebar shows for a sandbox with no folders in it. Upstream's // HomeProjectEmpty offers to open a project folder, but its picker runs on the @@ -11,7 +11,7 @@ import { useLanguage } from "@/context/language" // its project folder is a placeholder they never see. export function SandboxMountPrompt(props: { server: ServerConnection.Any }) { const language = useLanguage() - const dialog = useDialog() + const openMounts = useSandboxMountDialog() return (

{language.t("sandbox.mountPrompt.body")}

@@ -19,12 +19,7 @@ export function SandboxMountPrompt(props: { server: ServerConnection.Any }) { type="button" data-action="sandbox-mount-prompt" class="flex cursor-pointer items-center gap-1.5 text-12-medium text-v2-text-text-base underline [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted" - onClick={() => { - // Lazy, like every other route to this dialog: it is desktop-only and - // pulls the whole mounts editor in with it. - const id = ServerConnection.key(props.server) - void import("./dialog-mounts").then((module) => dialog.show(() => )) - }} + onClick={() => openMounts(props.server)} > {language.t("sandbox.mountPrompt.action")}