From 2b6bc9dc1544e29d717970705ba757c72e90bee0 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 16 Aug 2026 21:46:05 +0700 Subject: [PATCH 1/2] fix(app): stop a sandbox restart from locking the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounting a new folder on a sandbox that had one of its sessions open froze the whole renderer: no clicks, no typing, force quit the only way out. Applying a mount has no hot path on macOS, so the sandbox restarts, and a sandbox is published as a connection only while it is ready — it leaves server.list for the second or two it takes. Two effects then disagree: the tab store prunes tabs whose server is not in the list, and the titlebar adds a tab back for whatever session the route names. Prune, re-add, prune, about a thousand rounds a second. The loop is synchronous, so the renderer never returns to the event loop and never processes the IPC event that would put the sandbox back and end it. The titlebar effect now skips servers that are not connected. The route is left alone, so the tab comes back on its own once the sandbox does. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 +++++++ DIVERGENCES.md | 1 + packages/app/src/components/titlebar.tsx | 7 +++++++ 3 files changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00bc45ceb8..9d14cf0750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI +- **Adding a folder to a sandbox no longer freezes the app** — mounting a new + folder on a sandbox you had a session open on locked the window up entirely: + no clicks, no typing, force quit the only way out. Applying a mount restarts + the sandbox, and while it was down the app kept closing the session's tab and + immediately reopening it, thousands of times a second, which left it too busy + to notice the sandbox had come back. Any sandbox restart was affected, not + only mounting a folder. - **Switching to Work with no sandbox explains itself** — clicking **Work** before you have a sandbox opens the Add sandbox dialog, which was a dialog you did not ask for with nothing to say about why it appeared. It now leads diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 7a370ab5ba..d1277549b1 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -207,6 +207,7 @@ from the squash message first, or use a plain merge. | `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. | | `packages/app/src/pages/layout/project-avatar-state.ts` | Both `ensureServerState` lookups are gated on `connection()`: `unread` before `notification.ensureServerState`, and the `permissionState` memo before `permission.ensureServerState` (which also moves below `connection`, and its consumer in `hasPermissions` handles the undefined state). Matches the guard `hasPermissions`/`hasQuestions`/`loading` already apply to `sync()`. | `ensureServerState` throws on a key absent from the connection list, and the throw propagates out of a render-time memo, killing the whole renderer. Sidecar connections are listed only while healthy — `readySandboxConnections`/`readyWslConnections` publish a sandbox or WSL server only in the `ready` state — so any removal, restart, or crash of one drops its key while its session tabs are still mounted. Reproduced by removing a sandbox that had an open session tab: `Error: Notification server not found: sandbox:`. The permission lookup is the sharper of the two because `createMemo` computes eagerly: for a tab mounted after its server disappeared it throws during construction, before any downstream guard runs — `Error: Permission server not found: sandbox:`, reproduced by deleting a sandbox from Settings while the Work side was open. | Keep the guard; take upstream's other changes to the hook. This is an upstream bug (WSL servers hit the identical path), so if upstream fixes it their way, drop ours in favour of theirs. | +| `packages/app/src/components/titlebar.tsx` | The effect that opens a tab for the session the route points at now skips servers that are not in `global.servers.list()`. | The tab store prunes tabs whose server left the list (`context/tabs.tsx`), and this effect adds one back for whatever the route names — so a route pointing at a server that is momentarily absent puts the two in a loop: prune, re-add, prune, at about a thousand rounds a second. The renderer never yields, so it also never processes the IPC event that would bring the server back, and the window stays wedged until it is killed. Sidecar connections are published only while healthy (`readySandboxConnections`/`readyWslConnections`), so any restart drops one: reproduced by adding a folder to a sandbox while one of its sessions was open, which restarts the guest to apply the mount. The route is deliberately left alone — the tab returns on its own when the server comes back. This is an upstream bug (WSL servers hit the identical path). | Keep the guard; take upstream's other changes to the effect. If upstream fixes it their way, drop ours in favour of theirs. Do not "simplify" the guard away — an unguarded add against the pruner is the hang. | | `script/publish.ts` | The `if (Script.release)` block calling `packages/desktop/scripts/finalize-latest-{json,yml}.ts` removed, replaced by a comment. | Both finalizers existed to assemble update metadata into a GitHub release, which the fork no longer publishes to. electron-builder already writes a correct per-platform `latest*.yml` next to each installer, and the fork builds one arch per platform (macOS is universal — see the universal-builds row), so there is nothing left to merge. | Keep removed. If upstream adds further desktop release steps here, drop them unless they target the bucket. | | `packages/desktop/scripts/finalize-latest-json.ts`, `packages/desktop/scripts/finalize-latest-yml.ts` | Deleted. | Both `gh release upload` into a GitHub release the fork does not use. `finalize-latest-yml.ts` merged per-arch `latest*.yml` from an upstream build matrix the fork does not run; `finalize-latest-json.ts` additionally built a Tauri-style `latest.json` signed with `@tauri-apps/cli signer` that nothing in the Electron updater path ever reads, and had gone stale besides — it hardcoded `opencode-desktop-mac-{x64,arm64}.app.tar.gz`, an artifact name and format this fork never produces. Leaving them in place is a trap: they look like the release path and upload to the wrong place. | Keep deleted (`git rm` the upstream side of modify/delete conflicts). If a future build matrix produces two `latest.yml` files for one platform (Windows x64 + arm64), write a bucket-targeted merge step rather than restoring these. | | `packages/desktop/src/renderer/index.tsx` | Desktop notification icon `https://opencode.ai/favicon-96x96-v3.png` → the relative `./favicon-96x96-v3.png`. The renderer's `publicDir` is `packages/app/public`, whose `favicon-96x96-v3.png` symlinks to the rebranded `packages/ui/src/assets/favicon/` mark, so the file is emitted next to `index.html` and resolves against the renderer origin (`oc://renderer/` packaged, the vite dev server otherwise) — the same asset and the same relative form `src/renderer/index.html` already uses for ``. | A desktop app should not reach out to a remote host to draw its own notification icon (it fails offline and leaks a request per notification), and the remote file is upstream-branded while the bundled one is the codev mark. | Keep our relative path. If upstream changes the URL, re-point it at the bundled asset rather than taking a new remote host. `packages/app/src/entry.tsx` and `pages/layout/helpers.ts` keep their remote `opencode.ai/favicon*` sources — same bug class, not yet fixed. | diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index c5d3fe3f39..97c70a56c0 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -324,6 +324,13 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl if (!s) return const sessionId = s.parentID ?? s.id const next = { server: route.server ?? server.key, sessionId } + // A tab for a server that is not connected is dropped again the + // moment it lands (context/tabs.tsx prunes tabs whose server + // left the list), and this effect would add it straight back. + // Sidecar servers leave the list whenever they restart — a + // sandbox applying new mounts does exactly that — so without + // this the two spin against each other and lock the renderer. + if (!global.servers.list().some((conn) => ServerConnection.key(conn) === next.server)) return tabsStoreActions.addSessionTab(next) } }) From 8198f41b39afe7b208364e5fda88870807a86d78 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 16 Aug 2026 21:46:46 +0700 Subject: [PATCH 2/2] feat(app): keep session tabs through a sandbox restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sandbox is a connection only while it is ready, so restarting one — to apply a folder you just mounted, or because you stopped and started it — takes it out of server.list for a second or two. The tab store reconciles against that list, read every restart as "this server is gone", and deleted every tab belonging to the sandbox along with each tab's memory: the typed prompt, the terminal, where you were scrolled. Tabs now belong to a sandbox for as long as the sandbox exists rather than as long as it answers. The desktop renderer passes every configured sandbox as retainedServers, the server context exposes it as `retained`, and both of the tab store's prune effects reconcile against list plus retained. Deleting a sandbox is unaffected: dialog-select-server already removes its tabs explicitly, and the sandbox then leaves retained as well. The route is the other half. A retained server has no connection, and mounting the SDK and sync providers with undefined sends the session's requests to whichever server happens to be selected — which answered "this session cannot be found" about a perfectly healthy session, and bounced the route home through a burst of ~250 navigations when a tab was opened while its sandbox was stopped. TargetServerRoute now renders a "Waiting for this server" panel instead, and swaps back to the session by itself the moment the sandbox answers again. Verified end to end against a real vz sandbox: mounting a folder with a session open keeps the tab, shows the waiting panel for ~1.2s, and returns to the session with no interaction. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 11 ++++++++ DIVERGENCES.md | 1 + packages/app/src/app.tsx | 23 ++++++++++++++--- packages/app/src/context/server.tsx | 10 ++++++++ packages/app/src/context/tabs.tsx | 11 ++++++-- packages/app/src/i18n/en.ts | 6 +++++ packages/app/src/i18n/es.ts | 3 +++ packages/app/src/sandbox/server-away.tsx | 25 +++++++++++++++++++ packages/desktop/src/renderer/index.tsx | 8 +++++- .../src/renderer/sandbox/connections.test.ts | 13 +++++++++- .../src/renderer/sandbox/connections.ts | 9 +++++++ 11 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 packages/app/src/sandbox/server-away.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d14cf0750..015b1fd605 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,17 @@ file is the human-readable summary of what changed and why. immediately reopening it, thousands of times a second, which left it too busy to notice the sandbox had come back. Any sandbox restart was affected, not only mounting a folder. +- **Your sandbox tabs survive a sandbox restart** — a sandbox that restarts — + to apply a folder you just mounted, or because you stopped and started it — + is away for a second or two, and every open tab belonging to it was closed + and its state thrown out: the prompt you had typed, your terminal, where you + were scrolled. Tabs now belong to a sandbox for as long as the sandbox + exists, not for as long as it is running, so they sit through the restart + with everything you had in them and pick up where they were. Only deleting + the sandbox closes its tabs. The session itself says **Waiting for this + server** while the sandbox is away — including when you open one of its tabs + with the sandbox stopped, which used to claim the session could not be found + — and returns on its own, with no click needed, once the sandbox is back. - **Switching to Work with no sandbox explains itself** — clicking **Work** before you have a sandbox opens the Add sandbox dialog, which was a dialog you did not ask for with nothing to say about why it appeared. It now leads diff --git a/DIVERGENCES.md b/DIVERGENCES.md index d1277549b1..f6e50021d8 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -208,6 +208,7 @@ from the squash message first, or use a plain merge. | `packages/app/src/pages/layout/project-avatar-state.ts` | Both `ensureServerState` lookups are gated on `connection()`: `unread` before `notification.ensureServerState`, and the `permissionState` memo before `permission.ensureServerState` (which also moves below `connection`, and its consumer in `hasPermissions` handles the undefined state). Matches the guard `hasPermissions`/`hasQuestions`/`loading` already apply to `sync()`. | `ensureServerState` throws on a key absent from the connection list, and the throw propagates out of a render-time memo, killing the whole renderer. Sidecar connections are listed only while healthy — `readySandboxConnections`/`readyWslConnections` publish a sandbox or WSL server only in the `ready` state — so any removal, restart, or crash of one drops its key while its session tabs are still mounted. Reproduced by removing a sandbox that had an open session tab: `Error: Notification server not found: sandbox:`. The permission lookup is the sharper of the two because `createMemo` computes eagerly: for a tab mounted after its server disappeared it throws during construction, before any downstream guard runs — `Error: Permission server not found: sandbox:`, reproduced by deleting a sandbox from Settings while the Work side was open. | Keep the guard; take upstream's other changes to the hook. This is an upstream bug (WSL servers hit the identical path), so if upstream fixes it their way, drop ours in favour of theirs. | | `packages/app/src/components/titlebar.tsx` | The effect that opens a tab for the session the route points at now skips servers that are not in `global.servers.list()`. | The tab store prunes tabs whose server left the list (`context/tabs.tsx`), and this effect adds one back for whatever the route names — so a route pointing at a server that is momentarily absent puts the two in a loop: prune, re-add, prune, at about a thousand rounds a second. The renderer never yields, so it also never processes the IPC event that would bring the server back, and the window stays wedged until it is killed. Sidecar connections are published only while healthy (`readySandboxConnections`/`readyWslConnections`), so any restart drops one: reproduced by adding a folder to a sandbox while one of its sessions was open, which restarts the guest to apply the mount. The route is deliberately left alone — the tab returns on its own when the server comes back. This is an upstream bug (WSL servers hit the identical path). | Keep the guard; take upstream's other changes to the effect. If upstream fixes it their way, drop ours in favour of theirs. Do not "simplify" the guard away — an unguarded add against the pruner is the hang. | +| Tabs that outlive a sandbox restart: `packages/app/src/context/{server,tabs}.tsx`, `packages/app/src/app.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/renderer/index.tsx` | `server.tsx` takes a `retainedServers` prop and exposes it as `retained` — servers that exist but are not connected; `tabs.tsx` reconciles both of its prune effects (open tabs and the closed-tab stack) against `list` **plus** `retained` through a shared `knownServers()`; `app.tsx` threads `retainedServers` from `AppInterface` into `ServerProvider`, and `TargetServerRoute` renders the fork-only `packages/app/src/sandbox/server-away.tsx` instead of mounting its providers when the route's server is retained but absent; i18n gains `session.error.disconnected{,.description}`; `renderer/index.tsx` passes `retainedSandboxServers(...)` (fork-only, in `renderer/sandbox/connections.ts`) — every sandbox that exists, whatever its runtime state. | A sandbox is published as a connection only while it is ready, so applying a mount — which restarts the guest — takes it out of `server.list` for a second or two. The tab pruner read that as "this server is gone" and deleted every tab belonging to it, discarding each tab's memory: the typed prompt, terminal and scroll state. Retention draws the line where the user does — the sandbox still exists, so its tabs do — and explicit removal is unaffected because `dialog-select-server` already calls `tabs.removeServer(key)` and the sandbox then leaves `retained` as well. The route guard is the other half: a retained server has no connection, and mounting `ServerSDKProvider`/`ServerSyncProvider` with `undefined` sends the session's requests to whichever server is selected instead, which answered "this session cannot be found" about a healthy session and bounced the route home through a burst of ~250 navigations. WSL servers are deliberately not retained — same latent problem, but the WSL vertical has no equivalent restart in normal use. | Keep all five edits together — retention without the route guard just moves the confusing state, and the guard without retention is unreachable. Take upstream's other changes to these files. `retained` must stay a superset relationship with `list` (a server can be in both mid-transition); if upstream reworks the pruner, re-apply the union. The web build passes no `retainedServers`, so `retained` is empty there and every one of these paths collapses to upstream behaviour. | | `script/publish.ts` | The `if (Script.release)` block calling `packages/desktop/scripts/finalize-latest-{json,yml}.ts` removed, replaced by a comment. | Both finalizers existed to assemble update metadata into a GitHub release, which the fork no longer publishes to. electron-builder already writes a correct per-platform `latest*.yml` next to each installer, and the fork builds one arch per platform (macOS is universal — see the universal-builds row), so there is nothing left to merge. | Keep removed. If upstream adds further desktop release steps here, drop them unless they target the bucket. | | `packages/desktop/scripts/finalize-latest-json.ts`, `packages/desktop/scripts/finalize-latest-yml.ts` | Deleted. | Both `gh release upload` into a GitHub release the fork does not use. `finalize-latest-yml.ts` merged per-arch `latest*.yml` from an upstream build matrix the fork does not run; `finalize-latest-json.ts` additionally built a Tauri-style `latest.json` signed with `@tauri-apps/cli signer` that nothing in the Electron updater path ever reads, and had gone stale besides — it hardcoded `opencode-desktop-mac-{x64,arm64}.app.tar.gz`, an artifact name and format this fork never produces. Leaving them in place is a trap: they look like the release path and upload to the wrong place. | Keep deleted (`git rm` the upstream side of modify/delete conflicts). If a future build matrix produces two `latest.yml` files for one platform (Windows x64 + arm64), write a bucket-targeted merge step rather than restoring these. | | `packages/desktop/src/renderer/index.tsx` | Desktop notification icon `https://opencode.ai/favicon-96x96-v3.png` → the relative `./favicon-96x96-v3.png`. The renderer's `publicDir` is `packages/app/public`, whose `favicon-96x96-v3.png` symlinks to the rebranded `packages/ui/src/assets/favicon/` mark, so the file is emitted next to `index.html` and resolves against the renderer origin (`oc://renderer/` packaged, the vite dev server otherwise) — the same asset and the same relative form `src/renderer/index.html` already uses for ``. | A desktop app should not reach out to a remote host to draw its own notification icon (it fails offline and leaks a request per notification), and the remote file is upstream-branded while the bundled one is the codev mark. | Keep our relative path. If upstream changes the URL, re-point it at the bundled asset rather than taking a new remote host. `packages/app/src/entry.tsx` and `pages/layout/helpers.ts` keep their remote `opencode.ai/favicon*` sources — same bug class, not yet fixed. | diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 670e3944f9..3852120092 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -58,6 +58,7 @@ import { SettingsProvider, useSettings } from "@/context/settings" import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" import { SDKProvider, useSDK } from "@/context/sdk" import { WslServersProvider } from "@/wsl/context" +import { ServerAway } from "@/sandbox/server-away" import { SandboxesProvider } from "@/sandbox/context" import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout" import LegacyLayout from "@/pages/layout" @@ -111,6 +112,7 @@ const SessionRoute = () => { function TargetServerRoute(props: ParentProps) { const params = useParams<{ serverKey: string; id: string }>() const global = useGlobal() + const server = useServer() // Absent while this route is being torn down: removing a server navigates // away, and the param clears before this subtree unmounts. Rendering nothing // is the right answer — throwing from a memo kills the whole renderer. @@ -129,9 +131,18 @@ function TargetServerRoute(props: ParentProps) { // sit on a route whose providers cannot mount, which leaves the shell in a // half-built state that fails further down. }> - - {props.children} - + {(current) => ( + // Fork: a retained server is one that exists but is away — a sandbox + // restarting to apply a mount. Its tabs stay open, so this route stays + // reachable, and mounting the providers with no connection would send + // this session's requests to whatever server is selected instead. Wait + // for it: the route swaps back the moment it is connected again. + }> + + {props.children} + + + )} ) } @@ -542,6 +553,11 @@ export function AppInterface(props: { defaultServer: ServerConnection.Key canonicalLocalServer?: ServerConnection.Key servers?: Array + // Fork: servers that exist but are not connected right now (a sandbox + // restarting to apply new mounts). They are not in `servers` — nothing can + // talk to them — but their tabs and tab state are kept until they are gone + // for good. See context/server.tsx `retained`. + retainedServers?: Array router?: Component disableHealthCheck?: boolean startup?: Promise @@ -570,6 +586,7 @@ export function AppInterface(props: { defaultServer={props.defaultServer} canonicalLocalServer={props.canonicalLocalServer} servers={props.servers} + retainedServers={props.retainedServers} > diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index efc2b0f099..5319f3971a 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -272,6 +272,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( defaultServer: ServerConnection.Key canonicalLocalServer?: ServerConnection.Key servers?: Array + retainedServers?: Array }) => { const [store, setStore, _, ready] = persisted( { @@ -357,6 +358,15 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( get list() { return allServers() }, + // Servers that exist but are not connected at the moment. Sidecar + // servers are published only while they are healthy, so a sandbox + // restarting to apply new mounts leaves `list` for a second or two — + // long enough for anything reconciling against `list` to decide it is + // gone. Everything keyed by server that should outlive a restart (tabs + // and their state) reconciles against `list` plus these. + get retained() { + return props.retainedServers ?? [] + }, get current() { return current() }, diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 302ecf285f..4dd191f6c2 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -120,9 +120,16 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ onCleanup(memory.dispose) + // Tabs belong to servers that exist, not to servers that answer right now: + // a sandbox restarting to apply new mounts drops out of `server.list` for + // a second or two, and pruning against that alone closed the user's + // session tabs — and discarded their prompt and terminal state — on every + // restart. `server.retained` carries the ones that are merely away. + const knownServers = () => new Set([...server.list.map(ServerConnection.key), ...server.retained]) + createEffect(() => { if (!ready() || !recentReady()) return - const servers = new Set(server.list.map(ServerConnection.key)) + const servers = knownServers() const next = store.filter((tab) => servers.has(tab.server)) if (next.length !== store.length) { for (const tab of store) { @@ -143,7 +150,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ createEffect(() => { if (!closedReady()) return - const servers = new Set(server.list.map(ServerConnection.key)) + const servers = knownServers() const next = closed.filter((entry) => servers.has(entry.tab.server)) if (next.length !== closed.length) setClosed(() => next) }) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index fa0b567b0d..d93966e4ed 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -707,6 +707,12 @@ export const dict = { "session.error.notFound": "This session cannot be found", "session.error.notFound.description": "This tab points to a session that no longer exists on this server.", "session.error.notFound.closeTab": "Close Tab", + // Fork: a sandbox is a server that comes and goes — it leaves while it + // restarts to apply a new mounted folder — and its tabs stay open across + // that gap, so its sessions need something truer to say than "not found". + "session.error.disconnected": "Waiting for this server", + "session.error.disconnected.description": + "This session is on a server that is not running right now. It comes back on its own once the server does.", "session.error.serverConnection": "Can't connect to this server", "session.review.filesChanged": "Files Changed {{count}}", "session.review.change.one": "Change", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index ad1b43daa1..9d83d1bcba 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -688,6 +688,9 @@ export const dict = { "session.error.notFound": "No se encuentra esta sesión", "session.error.notFound.description": "Esta pestaña apunta a una sesión que ya no existe en este servidor.", "session.error.notFound.closeTab": "Cerrar pestaña", + "session.error.disconnected": "Esperando a este servidor", + "session.error.disconnected.description": + "Esta sesión está en un servidor que no está en ejecución ahora mismo. Vuelve por sí sola cuando el servidor lo haga.", "session.error.serverConnection": "No se puede conectar a este servidor", "session.review.filesChanged": "{{count}} Archivos Cambiados", "session.review.change.one": "Cambio", diff --git a/packages/app/src/sandbox/server-away.tsx b/packages/app/src/sandbox/server-away.tsx new file mode 100644 index 0000000000..23fe4072ae --- /dev/null +++ b/packages/app/src/sandbox/server-away.tsx @@ -0,0 +1,25 @@ +import { useLanguage } from "@/context/language" + +// Stands in for a server-scoped route whose server exists but is not connected +// right now — a sandbox restarting to apply a new mounted folder is gone for a +// second or two, and its tabs stay open across that gap (context/tabs.tsx). +// +// The alternative is what the route did before: mount its providers with no +// connection, which sends the session's requests to whichever server is +// selected instead, and answers "this session cannot be found" about a session +// that is fine. This says what is actually happening, and the route swaps back +// to the real thing on its own the moment the server answers again. +export function ServerAway(props: { name: string }) { + const language = useLanguage() + return ( +
+
+
{language.t("session.error.disconnected")}
+
+ {language.t("session.error.disconnected.description")} +
+
{props.name}
+
+
+ ) +} diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 1716de3e2e..fea4557c25 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -28,7 +28,7 @@ import { initializationData, initializationReady } from "./initialization" import { DesktopFirstLaunchOnboarding } from "./onboarding" import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom" import { readyWslConnections } from "./wsl/connections" -import { readySandboxConnections, startupServer } from "./sandbox/connections" +import { readySandboxConnections, retainedSandboxServers, startupServer } from "./sandbox/connections" import "./styles.css" import { Splash } from "@opencode-ai/ui/logo" import { useTheme } from "@opencode-ai/ui/theme/context" @@ -429,6 +429,11 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { list.push(...readySandboxConnections(sandboxes.state.data)) return list }) + // A sandbox leaves `servers` whenever it restarts (applying a mount does + // exactly that), which is not the same as being deleted: its tabs stay. + const retained = createMemo(() => + retainedSandboxServers(sandboxes.state.data).map((id) => ServerConnection.Key.make(id)), + ) const effectiveDefaultServer = createMemo(() => ServerConnection.Key.make(startupServer(defaultServer.latest, wslServers.data, sandboxes.state.data)), ) @@ -439,6 +444,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { ({ job: null, @@ -28,6 +28,17 @@ describe("sandbox desktop connections", () => { ]) }) + test("retains a sandbox whatever it is doing, so a restart keeps its tabs", () => { + const key = "sandbox:skills" + expect(retainedSandboxServers(state("ready"))).toEqual([key]) + expect(retainedSandboxServers(state("starting"))).toEqual([key]) + expect(retainedSandboxServers(state("failed"))).toEqual([key]) + expect(retainedSandboxServers(state("stopped"))).toEqual([key]) + // A sandbox that is gone is gone: nothing keeps its tabs alive. + expect(retainedSandboxServers({ job: null, sandboxes: [] })).toEqual([]) + expect(retainedSandboxServers(undefined)).toEqual([]) + }) + test("does not block desktop startup on a configured sandbox default", () => { const key = "sandbox:skills" expect(startupServer(key, undefined, undefined)).toBe("sidecar") diff --git a/packages/desktop/src/renderer/sandbox/connections.ts b/packages/desktop/src/renderer/sandbox/connections.ts index 90f74d633a..e296bffd10 100644 --- a/packages/desktop/src/renderer/sandbox/connections.ts +++ b/packages/desktop/src/renderer/sandbox/connections.ts @@ -24,6 +24,15 @@ export function readySandboxConnections(state?: SandboxesState) { }) } +// Every sandbox that exists, whatever it is doing. A sandbox is a connection +// only while it is ready, so restarting one — which is how a new mount is +// applied — takes it out of the server list for a second or two; the app keeps +// the tabs and tab state of the servers listed here through that gap, and +// drops them only once the sandbox itself is gone. +export function retainedSandboxServers(state?: SandboxesState) { + return (state?.sandboxes ?? []).map((item) => item.config.id) +} + // The upstream availableStartupServer only validates wsl: keys; a persisted // sandbox: default must equally fall back to the local sidecar until its // sandbox is ready, or startup would hang on a dead connection.