Skip to content

Commit f4a6694

Browse files
Quickbeardclaude
andcommitted
fix(app): switch to Work as soon as the sandbox is created
Creating the first sandbox from the Work gate left the app on Code until the tabs were toggled by hand. Three things had to line up and none of them did. The switch checked the sandbox list after the dialog closed, but that list arrives on its own IPC event and is still empty for a beat after add() resolves. DialogAddSandbox now reports the sandbox it created and the gate switches on that. effectiveSide collapsed Work to Code whenever the sandbox list was empty, which is the same stale read from the other direction — and also fires on every launch before the list loads. An empty Work side is legible on its own; WorkspaceEmpty says so and offers to create one. Selection was corrected only at the moment of a switch, but a sandbox joins the server list only once it has started, so a sandbox created seconds earlier was never selected: the projects list showed the sandbox while Pinned and Recents still read the local server. Correcting it from an effect covers the sandbox arriving late, which is what toggling the tabs by hand was really doing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8eaf92a commit f4a6694

6 files changed

Lines changed: 49 additions & 38 deletions

File tree

‎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 when the platform has no sandbox support or the last sandbox has been deleted, treating an unloaded sandbox list as unknown rather than empty; 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; 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/context/layout.tsx‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
1818
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
1919
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
2020
import { requireServerKey } from "@/utils/session-route"
21-
import { sideForServerKey, type WorkspaceSide } from "@/workspace/side"
21+
import { selectionForSide, serversForSide, sideForServerKey, type WorkspaceSide } from "@/workspace/side"
2222
import { type DraftTab, useTabs } from "./tabs"
2323
import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs"
2424

@@ -316,6 +316,22 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
316316
sessionTabPreview: {} as Record<string, string | undefined>,
317317
})
318318

319+
// Fork: keep the sidebar's selection on the side that is showing. Switching
320+
// sides corrects it once, but a sandbox joins the server list only when it
321+
// finishes starting — so right after one is created there is nothing on the
322+
// Work side to select yet, and without this the projects list would show
323+
// the sandbox while Pinned and Recents still read the local server.
324+
createEffect(() => {
325+
const side = store.home.workspace ?? "code"
326+
const available = serversForSide(server.list, side)
327+
if (!available.length) return
328+
const target = selectionForSide(server.list, side, store.home.selection.server)
329+
if (!target) return
330+
const key = ServerConnection.key(target)
331+
if (store.home.selection.server === key) return
332+
setStore("home", "selection", reconcile({ server: key }))
333+
})
334+
319335
// Fork: remember where each side was, so switching back to Code or Work
320336
// returns you to the session you were reading rather than a blank draft.
321337
// Recorded here rather than in the sidebar because it has to keep working

‎packages/app/src/sandbox/dialog-add.tsx‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,19 @@ import { Show } from "solid-js"
99
import { createStore } from "solid-js/store"
1010
import { useLanguage } from "@/context/language"
1111
import { usePlatform } from "@/context/platform"
12+
import type { SandboxConfig } from "./types"
1213
import { useSandboxes } from "./context"
1314
import { sandboxResourcesValid } from "./resources"
1415

1516
// Distribution and directory names are derived from the sandbox name across
1617
// every backend, so keep it to characters that are safe in all of them.
1718
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
1819

19-
export function DialogAddSandbox() {
20+
// onCreated fires with the new sandbox before the dialog closes. Callers that
21+
// need to react to a creation must use it rather than watching the sandbox
22+
// list: that list arrives over a separate IPC event, so it is still empty for
23+
// a beat after add() resolves.
24+
export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) => void }) {
2025
const language = useLanguage()
2126
const platform = usePlatform()
2227
const dialog = useDialog()
@@ -79,7 +84,10 @@ export function DialogAddSandbox() {
7984
store.projectDir,
8085
resourcesApply() ? { cpus: cpus(), memoryMiB: memoryMiB() } : undefined,
8186
)
82-
if (created) dialog.close()
87+
if (created) {
88+
props.onCreated?.(created)
89+
dialog.close()
90+
}
8391
} catch (error) {
8492
showToast({
8593
variant: "error",

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

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,17 @@ export type WorkspaceSide = "code" | "work"
1010

1111
export const WORKSPACE_SIDES = ["code", "work"] as const
1212

13-
// Sandboxes are a desktop feature, so the web build has no Work side at all,
14-
// and deleting the last sandbox leaves Work with nothing to show. Either way
15-
// the sidebar must not sit on a side it cannot populate.
13+
// Sandboxes are a desktop feature, so the web build has no Work side at all.
14+
// A persisted "work" from a desktop session must not strand the sidebar on a
15+
// side that platform can never populate.
1616
//
17-
// `sandboxCount` is undefined until the sandbox list has loaded: treating that
18-
// as zero would bounce every launch back to Code before the answer arrives.
19-
export function effectiveSide(
20-
side: WorkspaceSide,
21-
sandboxesSupported: boolean,
22-
sandboxCount?: number,
23-
): WorkspaceSide {
24-
if (!sandboxesSupported) return "code"
25-
if (side === "work" && sandboxCount === 0) return "code"
26-
return side
17+
// Deliberately not also collapsing when there are zero sandboxes: the sandbox
18+
// list arrives asynchronously, so "none" and "not loaded yet" look identical
19+
// here, and treating them alike bounced the user back to Code both on every
20+
// launch and in the beat after creating their first sandbox. An empty Work
21+
// side is a legible state — WorkspaceEmpty says so and offers to create one.
22+
export function effectiveSide(side: WorkspaceSide, sandboxesSupported: boolean): WorkspaceSide {
23+
return sandboxesSupported ? side : "code"
2724
}
2825

2926
// The sandbox key format (sandbox:<name>) is persisted in the main-process

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

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,7 @@ import { effectiveSide, selectionForSide, WORKSPACE_SIDES, type WorkspaceSide }
1717
export function useWorkspaceSide() {
1818
const layout = useLayout()
1919
const platform = usePlatform()
20-
const sandboxes = useSandboxes()
21-
return createMemo(() =>
22-
effectiveSide(layout.home.workspace(), !!platform.sandboxes, sandboxes.state.data?.sandboxes.length),
23-
)
20+
return createMemo(() => effectiveSide(layout.home.workspace(), !!platform.sandboxes))
2421
}
2522

2623
// Code and Work are the same UI pointed at different servers, so switching one
@@ -81,12 +78,11 @@ export function useWorkspaceSwitch() {
8178
// side where it was, which is how the user stays on Code.
8279
if (next === "work" && !hasSandbox()) {
8380
void import("@/sandbox/dialog-add").then((module) => {
84-
void dialog.show(
85-
() => <module.DialogAddSandbox />,
86-
() => {
87-
if (hasSandbox()) enter("work")
88-
},
89-
)
81+
// Switch off the dialog's own result, not off the sandbox list: the
82+
// list arrives on a separate IPC event and is still empty for a beat
83+
// after the create resolves, so checking it here left the user on
84+
// Code until they toggled the tabs by hand.
85+
void dialog.show(() => <module.DialogAddSandbox onCreated={() => enter("work")} />)
9086
})
9187
return
9288
}

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

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,9 @@ test("collapses to Code where sandboxes are unsupported", () => {
106106
expect(effectiveSide("code", false)).toBe("code")
107107
})
108108

109-
// Deleting the last sandbox from Settings while Work is open would otherwise
110-
// leave the sidebar on a side with nothing in it.
111-
test("falls back to Code once the last sandbox is gone", () => {
112-
expect(effectiveSide("work", true, 1)).toBe("work")
113-
expect(effectiveSide("work", true, 0)).toBe("code")
114-
})
115-
116-
// undefined is "not loaded yet", not "none": treating it as zero would bounce
117-
// every launch back to Code before the sandbox list arrives.
118-
test("keeps the persisted side while the sandbox list is still loading", () => {
119-
expect(effectiveSide("work", true, undefined)).toBe("work")
109+
// An empty Work side stays selected: the sandbox list loads asynchronously, so
110+
// collapsing on "no sandboxes" also fires before the list arrives and in the
111+
// beat after the first one is created.
112+
test("keeps Work selected even with no sandboxes", () => {
113+
expect(effectiveSide("work", true)).toBe("work")
120114
})

0 commit comments

Comments
 (0)