Skip to content

Commit 3ecaa99

Browse files
Quickbeardclaude
andcommitted
fix(app): return to Code when the last sandbox is deleted
Deleting the only sandbox left the user on a Work side with nothing in it, still looking at a session belonging to the server that had just gone away. Driven from the removal itself rather than from watching the sandbox list. That list updates over IPC a beat after the delete resolves, so a reactive "no sandboxes means go to Code" rule reads as true both before the list has loaded and in the moment after the first sandbox is created — which is what made the earlier attempt at this bounce the user off Work twice over. Asking "does removing this key empty the side?" against the list as it stands before the removal has no such window. It runs beside the tab cleanup, ahead of the awaited platform removal, so the switch lands immediately rather than after a delete that takes as long as erasing a disk. Reuses the same enter() as selecting the tab, so Code lands on its last session rather than wherever the deleted sandbox left the route. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f4a6694 commit 3ecaa99

6 files changed

Lines changed: 39 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ file is the human-readable summary of what changed and why.
1717
returns you to the session you were last in on that side rather than opening
1818
a blank new session; with no history it falls back to a new session, and to
1919
the home screen when that side has nothing to open yet. Switching to Work
20-
with no sandbox yet offers to create one — cancelling leaves you on Code.
20+
with no sandbox yet offers to create one — cancelling leaves you on Code —
21+
and deleting your last sandbox returns you to Code rather than leaving you
22+
on an empty Work side.
2123
The **Home** button has been removed from the titlebar: it opened a new
2224
session rather than a home screen, which is what the tabs now decide. The
2325
recent-sessions screen is still one click away behind the search button and

‎DIVERGENCES.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ from the squash message first, or use a plain merge.
194194

195195
| Desktop identity: `packages/desktop/src/main/{index,store}.ts`, `packages/desktop/electron-builder.config{,.test}.ts`, `packages/desktop/scripts/copy-metainfo.ts`, `packages/desktop/resources/linux/opencode-desktop.desktop` (deleted), `packages/app/src/pages/layout/{deep-links.ts,helpers.test.ts}`, `packages/codev-brand/test/brand.test.ts` | appId `ai.opencode.desktop{,.beta,.dev}` → `ai.codev.desktop{,.beta,.dev}` (both copies: `APP_IDS` in main and in the builder config, plus the unpackaged fallback); deep-link scheme `opencode://` → `codev://` in all three places that must agree — `protocols.schemes`, `app.setAsDefaultProtocolClient`, and the renderer's `parseUrl` — plus the `argv.startsWith` filter the second-instance handler uses; `rpm.packageName` `opencode`/`opencode-beta`/`opencode-dev` → `codev`/`codev-beta`/`codev-dev`; the hidden `opencode-desktop.desktop` legacy launcher and its `deb`/`rpm` fpm wiring deleted. `src/main/migrate.ts` keeps the old ids — they name the *Tauri* app's directories, not ours. | The appId is the whole desktop identity: `app.setPath("userData", join(appData, appId))`, so it also names the data directory, and it drives the macOS bundle id, the Windows AppUserModelId and NSIS keys, the Linux `.desktop` filename, `executableName` and `StartupWMClass`, and the AppStream metainfo id. Leaving it as opencode meant the fork's app identified itself as upstream to the OS. Renamed as a clean cut with no data migration and no back-compat shim, on the explicit basis that there is no installed base: existing profiles under `appData/ai.opencode.desktop*` are simply orphaned (settings, session history, sandboxes and the downloaded guest image), and a packaged update across the rename would install beside the old app rather than replace it. The legacy launcher went with it — it existed to rescue GNOME/KDE pins from a superseded packaging id, and its `Exec=/opt/OpenCode/...` no longer matched any install layout. | Keep our ids and scheme; take upstream's other config. Never rename the `TAURI_APP_IDS` in `migrate.ts` to match — a blind find-and-replace there points the Tauri migration at a directory that never existed. All three deep-link sites have to move together or links open nothing; `electron-builder.config.test.ts` covers the ids, the package names and the scheme per channel. |
196196

197-
| 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 `<WorkspaceTabs/>`, 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; 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. |
197+
| 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 `<WorkspaceTabs/>`, 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. |
198198

199199
| `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. |
200200

‎packages/app/src/components/dialog-select-server.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { normalizeServerUrl, ServerConnection, useServer } from "@/context/serve
1919
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
2020
import { useSettings } from "@/context/settings"
2121
import { useTabs } from "@/context/tabs"
22+
import { useWorkspaceSwitch } from "@/workspace/tabs"
2223

2324
const DEFAULT_USERNAME = "opencode"
2425

@@ -193,6 +194,7 @@ export function useServerManagementController(options: { onSelect?: () => void;
193194
const navigate = useNavigate()
194195
const server = useServer()
195196
const tabs = useTabs()
197+
const workspace = useWorkspaceSwitch()
196198
const global = useGlobal()
197199
const platform = usePlatform()
198200
const language = useLanguage()
@@ -517,6 +519,9 @@ export function useServerManagementController(options: { onSelect?: () => void;
517519
// server that is already gone, and the tab-pruning effect that catches
518520
// them does not discard their draft state the way removeServer does.
519521
tabs.removeServer(key)
522+
// Fork: before the awaits, so the switch lands with the tab cleanup
523+
// rather than after a delete that takes as long as erasing a disk.
524+
workspace.removedServer(key)
520525
server.remove(key)
521526
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
522527
if (key.startsWith("sandbox:")) await platform.sandboxes?.remove(key)

‎packages/app/src/workspace/side.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ export function selectionForSide(
5454
return kept ?? available[0]
5555
}
5656

57+
// Whether removing this server empties the Work side. Answered from the list
58+
// as it stands *before* the removal lands — the sandbox list updates over IPC a
59+
// beat later, so reading it afterwards is a race.
60+
export function leavesWorkEmpty(sandboxes: { config: { id: string } }[], removed: string) {
61+
return sideForServerKey(removed) === "work" && !sandboxes.some((item) => item.config.id !== removed)
62+
}
63+
5764
// The tab strip only renders one side, so it can only ever reorder that side's
5865
// keys. Splice the new order back into the slots those tabs already occupy and
5966
// leave the other side untouched — tabs.reorder() rejects a list that is not

‎packages/app/src/workspace/tabs.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { findOpenDraft } from "@/pages/home-draft-reuse"
1010
import { sessionHref } from "@/utils/session-route"
1111
import { useSandboxes } from "@/sandbox/context"
1212
import { usePlatform } from "@/context/platform"
13-
import { effectiveSide, selectionForSide, WORKSPACE_SIDES, type WorkspaceSide } from "./side"
13+
import { effectiveSide, leavesWorkEmpty, selectionForSide, WORKSPACE_SIDES, type WorkspaceSide } from "./side"
1414

1515
// The side everything should actually render, as opposed to the side that
1616
// happens to be persisted. Read this rather than layout.home.workspace().
@@ -88,6 +88,14 @@ export function useWorkspaceSwitch() {
8888
}
8989
enter(next)
9090
},
91+
// Deleting the last sandbox leaves Work with nothing to show, and the
92+
// session on screen belonged to the server that just went away. Step back
93+
// to Code and navigate there, the same way selecting the tab would.
94+
removedServer(key: string) {
95+
if (side() !== "work") return
96+
if (!leavesWorkEmpty(sandboxes.state.data?.sandboxes ?? [], key)) return
97+
enter("code")
98+
},
9199
}
92100
}
93101

‎packages/app/test-browser/workspace-side.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ServerConnection } from "@/context/server"
33
import { tabKey, type Tab } from "@/context/tabs"
44
import {
55
effectiveSide,
6+
leavesWorkEmpty,
67
reorderWithinSide,
78
selectionForSide,
89
serversForSide,
@@ -112,3 +113,16 @@ test("collapses to Code where sandboxes are unsupported", () => {
112113
test("keeps Work selected even with no sandboxes", () => {
113114
expect(effectiveSide("work", true)).toBe("work")
114115
})
116+
117+
// Answered from the list as it stands before the removal: the sandbox list
118+
// updates over IPC a beat later, so reading it afterwards is a race.
119+
test("knows when removing a server empties the Work side", () => {
120+
const configured = [{ config: { id: "sandbox:a" } }, { config: { id: "sandbox:b" } }]
121+
expect(leavesWorkEmpty(configured, "sandbox:a")).toBe(false)
122+
expect(leavesWorkEmpty([{ config: { id: "sandbox:a" } }], "sandbox:a")).toBe(true)
123+
})
124+
125+
test("removing a non-sandbox server never empties Work", () => {
126+
expect(leavesWorkEmpty([], "wsl:Ubuntu-24.04")).toBe(false)
127+
expect(leavesWorkEmpty([], "sidecar")).toBe(false)
128+
})

0 commit comments

Comments
 (0)