From 93d3915cf6ec405f201d94da473404fef6ff5e4d Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Thu, 13 Aug 2026 17:14:28 +0700 Subject: [PATCH 1/4] feat(app): split the sidebar into Code and Work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sandboxes were just another row in a list of servers, so the isolated environment and the local machine were one undifferentiated pile. The sidebar now opens on two tabs: Code, every non-sandbox server (local sidecar, WSL, remote, SSH), and Work, the sandboxes. Each side keeps its own projects, pinned and recent sessions, and its own session tabs — the titlebar strip, its reorder and tab.prev/tab.next all follow the visible side. Clicking a tab returns you to that side's last session rather than opening a draft, falling back to a draft and then to the home screen when a side has no history and nothing to open. The Home button goes with it: it opened a draft, not a home screen, which is exactly the behaviour the tabs replace. Its mod+b command goes too. Switching to Work with no sandbox reuses DialogAddSandbox rather than growing a second one, and only switches if a sandbox actually gets created — cancel leaves you on Code. A side can now be empty (a sandbox joins the server list only once it is ready), so the sidebar gains an empty state; every branch under it indexes servers[0]. Logic lives in the fork-only workspace module and is unit tested; the upstream files carry wiring only, and every section below the tabs is the same component reading a filtered list. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 13 ++ DIVERGENCES.md | 2 + packages/app/src/components/app-sidebar.tsx | 28 ++- packages/app/src/components/titlebar.tsx | 80 +++------ packages/app/src/context/layout.tsx | 30 ++++ packages/app/src/i18n/en.ts | 4 + packages/app/src/i18n/es.ts | 4 + packages/app/src/workspace/side.ts | 64 +++++++ packages/app/src/workspace/tabs.tsx | 159 ++++++++++++++++++ .../app/test-browser/workspace-side.test.ts | 107 ++++++++++++ 10 files changed, 429 insertions(+), 62 deletions(-) create mode 100644 packages/app/src/workspace/side.ts create mode 100644 packages/app/src/workspace/tabs.tsx create mode 100644 packages/app/test-browser/workspace-side.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2068338f0c..50a4699c5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI +- **Code and Work** — the sidebar now opens on two tabs. **Code** is your + machine: the local server, plus any WSL, remote or SSH servers you have + added. **Work** is your sandboxes. Each side keeps its own projects, pinned + and recent sessions, and its own open session tabs, so a sandbox session + never appears while you are working locally and vice versa. Clicking a tab + returns you to the session you were last in on that side rather than opening + a blank new session; with no history it falls back to a new session, and to + the home screen when that side has nothing to open yet. Switching to Work + with no sandbox yet offers to create one — cancelling leaves you on Code. + The **Home** button has been removed from the titlebar: it opened a new + session rather than a home screen, which is what the tabs now decide. The + recent-sessions screen is still one click away behind the search button and + the sidebar's **View all**. - **The desktop app now identifies itself as CoDev to your operating system** — its application id changed from `ai.opencode.desktop` to `ai.codev.desktop`, its deep links from `opencode://` to `codev://`, and its Linux packages from diff --git a/DIVERGENCES.md b/DIVERGENCES.md index f86ad8f40e..84632b2248 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -194,6 +194,8 @@ from the squash message first, or use a plain merge. | 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. | +| 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; 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. | + | `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` | `unread` gains a `connection()` guard before `notification.ensureServerState(server())`, matching 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:`. | 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. | diff --git a/packages/app/src/components/app-sidebar.tsx b/packages/app/src/components/app-sidebar.tsx index 58a1cf4e90..062294d532 100644 --- a/packages/app/src/components/app-sidebar.tsx +++ b/packages/app/src/components/app-sidebar.tsx @@ -18,6 +18,8 @@ import { useSettingsCommand } from "@/components/settings-dialog" import { ChannelIndicator } from "@/components/titlebar" import { useAppSidebar } from "@/context/app-sidebar" import { useGlobal } from "@/context/global" +import { serversForSide } from "@/workspace/side" +import { useWorkspaceSide, WorkspaceEmpty, WorkspaceTabs } from "@/workspace/tabs" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { ServerConnection } from "@/context/server" @@ -75,6 +77,10 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool Persist.global("sidebar.sections"), createStore({ collapsed: {} as Record }), ) + // Fork: Code shows every non-sandbox server, Work shows the sandboxes. The + // sections below are unchanged; they just read this instead of the full list. + const side = useWorkspaceSide() + const sideServers = createMemo(() => serversForSide(global.servers.list(), side())) // 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. @@ -85,11 +91,14 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool class={`${props.class ?? ""} w-[280px] min-h-0 min-w-0 shrink-0 flex-col gap-4 overflow-hidden px-3 pb-4 pt-6`} aria-label={language.t("home.projects")} > + {/* Fork: the Code/Work split. Everything below is unchanged — it just + sees the servers of the selected side. */} +
{language.t("home.projects")}
0) } > @@ -100,22 +109,26 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool size="large" class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted" icon={} - disabled={global.servers.health[ServerConnection.key(global.servers.list()[0]!)]?.healthy === false} - onClick={() => model.chooseProject(global.servers.list()[0]!)} + disabled={global.servers.health[ServerConnection.key(sideServers()[0]!)]?.healthy === false} + onClick={() => model.chooseProject(sideServers()[0]!)} aria-label={language.t("home.project.add")} />
+ {/* Fork: a side can be empty — Work while its only sandbox is still + starting, or after the last one was removed. Every branch below + indexes servers[0], so the empty case has to be caught first. */} + 0} fallback={}> 1} + when={sideServers().length > 1} fallback={ 0} fallback={
- + {(item) => { const key = ServerConnection.key(item) const healthy = () => !!global.servers.health[key]?.healthy @@ -183,6 +196,7 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool
+
Promise @@ -263,6 +265,11 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl const tabs = useTabs() const tabsStore = tabs.store const tabsStoreActions = tabs + // Fork: Code and Work each keep their own session tabs, so the strip + // shows one side at a time. Everything else — close, reopen, the + // tab commands — still works against the whole store. + const side = useWorkspaceSide() + const sideTabs = createMemo(() => tabsForSide(tabsStore, side())) const [session] = createResource( () => { const route = layout.route() @@ -392,31 +399,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl openDraftFor({ server: fallback.server, directory: fallback.project.worktree }) } - // The home screen is a new session, not the recent-sessions list, so - // this goes there rather than toggling between "/" and the last tab. - // An already-open empty draft is reused so repeated presses do not - // stack up tabs; the recents list lives behind the sidebar's - // "View all". - const goHome = () => { - const draft = tabsStore.find((tab) => tab.type === "draft") - if (draft) { - tabs.select(draft) - return - } - openNewTab() - } - - command.register("titlebar-home", () => [ - { - id: "home.toggle", - title: language.t("home.title"), - category: language.t("command.category.view"), - keybind: "mod+b", - hidden: true, - onSelect: goHome, - }, - ]) - command.register("tabs", () => { const current = currentTab() @@ -453,13 +435,17 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl keybind: `mod+option+ArrowLeft,ctrl+shift+tab`, hidden: true, onSelect: () => { - let index = tabsStore.findIndex((tab) => tab === currentTab()) + // Cycles the visible side only: the other side's tabs are + // not on screen, so stepping onto one would look like a jump + // to nowhere. + const visible = sideTabs() + let index = visible.findIndex((tab) => tab === currentTab()) if (index === -1) return index -= 1 - if (index === -1) index = tabsStore.length - 1 + if (index === -1) index = visible.length - 1 - const next = tabsStore[index] + const next = visible[index] if (next) tabs.select(next) }, }, @@ -470,13 +456,14 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl keybind: `mod+option+ArrowRight,ctrl+tab`, hidden: true, onSelect: () => { - let index = tabsStore.findIndex((tab) => tab === currentTab()) + const visible = sideTabs() + let index = visible.findIndex((tab) => tab === currentTab()) if (index === -1) return index += 1 - if (index === tabsStore.length) index = 0 + if (index === visible.length) index = 0 - const next = tabsStore[index] + const next = visible[index] if (next) tabs.select(next) }, }, @@ -520,28 +507,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl - - {language.t("home.title")} - - - } - class="shrink-0" - > - } - state={layout.route().type === "draft" ? "pressed" : undefined} - onClick={goHome} - aria-label={language.t("home.title")} - aria-pressed={layout.route().type === "draft"} - /> - + {/* Fork: the Home button that sat here opened a draft, which is + now what the sidebar's Code/Work tabs decide — they return + you to the session you were last in instead. */} {/* Fork: opens the recent-sessions list, like the sidebar's "View all", and focuses its search field on arrival. */} @@ -559,7 +527,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl tabKey(item) === tabKey(tab)) if (index !== -1) tabsStoreActions.closeTab(index) }} - onReorder={(keys) => tabsStoreActions.reorder(keys)} + onReorder={(keys) => + tabsStoreActions.reorder(reorderWithinSide(tabsStore, side(), keys)) + } /> >, }, }), ) @@ -310,6 +316,19 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( sessionTabPreview: {} as Record, }) + // Fork: remember where each side was, so switching back to Code or Work + // returns you to the session you were reading rather than a blank draft. + // Recorded here rather than in the sidebar because it has to keep working + // while the sidebar is collapsed. + createEffect(() => { + const value = route() + if (value.type !== "session" || !value.server) return + const side = sideForServerKey(value.server) + const previous = store.home.lastSession?.[side] + if (previous?.server === value.server && previous.sessionId === value.sessionId) return + setStore("home", "lastSession", side, { server: value.server, sessionId: value.sessionId }) + }) + const MAX_SESSION_KEYS = 50 const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000 const usage = { @@ -607,6 +626,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setSelection(selection: HomeProjectSelection) { setStore("home", "selection", reconcile(selection)) }, + // Fork: Code/Work. Reading `workspace` before the store has loaded + // gives "code", which is also the default side, so the first paint is + // never wrong. + workspace: createMemo(() => store.home.workspace ?? "code"), + setWorkspace(side: WorkspaceSide) { + setStore("home", "workspace", side) + }, + lastSession: (side: WorkspaceSide) => store.home.lastSession?.[side], + recordLastSession(side: WorkspaceSide, entry: { server: ServerConnection.Key; sessionId: string }) { + setStore("home", "lastSession", side, entry) + }, }, handoff: { tabs: createMemo(() => store.handoff?.tabs), diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 273d73da1b..9e818589fa 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -374,6 +374,10 @@ export const dict = { "wsl.server.menu.label": "WSL server", "wsl.server.retryStart": "Retry start", "wsl.server.updating": "Updating...", + "workspace.code": "Code", + "workspace.work": "Work", + "workspace.empty.none": "No sandbox yet. Create one to work in an isolated environment.", + "workspace.empty.starting": "Your sandbox is not running yet. It appears here once it is ready.", "sandbox.server.add": "Add sandbox", "sandbox.server.label": "Sandbox", "sandbox.server.menu.label": "Sandbox", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 6641d214b3..eb041cd1d1 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -365,6 +365,10 @@ export const dict = { "wsl.server.menu.label": "Servidor WSL", "wsl.server.retryStart": "Reintentar el inicio", "wsl.server.updating": "Actualizando...", + "workspace.code": "Código", + "workspace.work": "Trabajo", + "workspace.empty.none": "Aún no hay ningún sandbox. Crea uno para trabajar en un entorno aislado.", + "workspace.empty.starting": "Tu sandbox todavía no está en marcha. Aparecerá aquí cuando esté listo.", "sandbox.server.add": "Añadir sandbox", "sandbox.server.label": "Sandbox", "sandbox.server.menu.label": "Sandbox", diff --git a/packages/app/src/workspace/side.ts b/packages/app/src/workspace/side.ts new file mode 100644 index 0000000000..b76ad033b9 --- /dev/null +++ b/packages/app/src/workspace/side.ts @@ -0,0 +1,64 @@ +import { ServerConnection } from "@/context/server" +import { tabKey, type Tab } from "@/context/tabs" + +// The desktop app is split into two workspaces: Code, everything that runs on +// a normal server (the local sidecar, WSL servers, remote and SSH servers), and +// Work, the sandboxes. Each side owns its own sidebar, its own session tabs and +// its own last-visited session; a sandbox never shows up under Code and a plain +// server never under Work. +export type WorkspaceSide = "code" | "work" + +export const WORKSPACE_SIDES = ["code", "work"] as const + +// Sandboxes are a desktop feature, so the web build has no Work side at all. +// A persisted "work" from a desktop session must not strand the sidebar on a +// side that platform can never populate. +export function effectiveSide(side: WorkspaceSide, sandboxesSupported: boolean): WorkspaceSide { + return sandboxesSupported ? side : "code" +} + +// The sandbox key format (sandbox:) is persisted in the main-process +// store and as the default-server key, so it is a stable thing to test. +export function sideForServerKey(key: string): WorkspaceSide { + return key.startsWith("sandbox:") ? "work" : "code" +} + +export function sideForServer(conn: ServerConnection.Any): WorkspaceSide { + return conn.type === "sidecar" && conn.variant === "sandbox" ? "work" : "code" +} + +export function serversForSide(servers: ServerConnection.Any[], side: WorkspaceSide) { + return servers.filter((conn) => sideForServer(conn) === side) +} + +export function tabsForSide(tabs: Tab[], side: WorkspaceSide) { + return tabs.filter((tab) => sideForServerKey(tab.server) === side) +} + +// Which server the sidebar should focus after a switch. Keeping the current +// selection when it already belongs to the side matters: switching away and +// back should land where you left, not reset to the first server. +export function selectionForSide( + servers: ServerConnection.Any[], + side: WorkspaceSide, + current: ServerConnection.Key | undefined, +) { + const available = serversForSide(servers, side) + const kept = available.find((conn) => ServerConnection.key(conn) === current) + return kept ?? available[0] +} + +// The tab strip only renders one side, so it can only ever reorder that side's +// keys. Splice the new order back into the slots those tabs already occupy and +// leave the other side untouched — tabs.reorder() rejects a list that is not +// the full set, so without this a drag inside a filtered strip is a silent +// no-op. +export function reorderWithinSide(tabs: Tab[], side: WorkspaceSide, keys: string[]) { + const moved = keys.flatMap((key) => { + const tab = tabs.find((item) => tabKey(item) === key) + return tab && sideForServerKey(tab.server) === side ? [tab] : [] + }) + if (moved.length !== tabsForSide(tabs, side).length) return tabs.map(tabKey) + const queue = [...moved] + return tabs.map((tab) => tabKey(sideForServerKey(tab.server) === side ? queue.shift()! : tab)) +} diff --git a/packages/app/src/workspace/tabs.tsx b/packages/app/src/workspace/tabs.tsx new file mode 100644 index 0000000000..3ad314dad7 --- /dev/null +++ b/packages/app/src/workspace/tabs.tsx @@ -0,0 +1,159 @@ +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createMemo, For, Show } from "solid-js" +import { useNavigate } from "@solidjs/router" +import { useGlobal } from "@/context/global" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { ServerConnection } from "@/context/server" +import { useTabs } from "@/context/tabs" +import { findOpenDraft } from "@/pages/home-draft-reuse" +import { sessionHref } from "@/utils/session-route" +import { useSandboxes } from "@/sandbox/context" +import { usePlatform } from "@/context/platform" +import { effectiveSide, selectionForSide, WORKSPACE_SIDES, type WorkspaceSide } from "./side" + +// The side everything should actually render, as opposed to the side that +// happens to be persisted. Read this rather than layout.home.workspace(). +export function useWorkspaceSide() { + const layout = useLayout() + const platform = usePlatform() + return createMemo(() => effectiveSide(layout.home.workspace(), !!platform.sandboxes)) +} + +// Code and Work are the same UI pointed at different servers, so switching one +// is: move the sidebar's selection to a server on that side, then go wherever +// that side was last. Everything below the tabs — projects, pinned, recents, +// the home page — already derives from that selection. +export function useWorkspaceSwitch() { + const layout = useLayout() + const global = useGlobal() + const tabs = useTabs() + const navigate = useNavigate() + const dialog = useDialog() + const sandboxes = useSandboxes() + const side = useWorkspaceSide() + + // A sandbox that exists but is still starting counts: the Work side should + // show it coming up, not offer to create a second one. + const hasSandbox = () => (sandboxes.state.data?.sandboxes.length ?? 0) > 0 + + const enter = (side: WorkspaceSide) => { + layout.home.setWorkspace(side) + const target = selectionForSide(global.servers.list(), side, layout.home.selection().server) + if (!target) return + const server = ServerConnection.key(target) + if (layout.home.selection().server !== server) layout.home.setSelection({ server }) + + // Back to the session this side was last on. A recorded session whose + // server has since gone away (a removed sandbox) falls through to the + // draft path rather than navigating to a dead route. + const last = layout.home.lastSession(side) + if (last && global.servers.list().some((conn) => ServerConnection.key(conn) === last.server)) { + navigate(sessionHref(last.server, last.sessionId)) + return + } + + // No history for this side: land on its open draft, or a new one for the + // first project it has. With neither, the home screen is the only honest + // destination — that side has nothing to open yet. + const directory = global.ensureServerCtx(target).projects.list()[0]?.worktree + if (!directory) { + navigate("/") + return + } + const existing = findOpenDraft(tabs.store, { server, directory }) + if (existing) { + tabs.select(existing) + return + } + void tabs.newDraft({ server, directory }) + } + + return { + side, + select(next: WorkspaceSide) { + if (next === side()) return + // Work with no sandbox at all is a dead end, so ask for one first and + // only switch if the user goes through with it. Cancelling leaves the + // side where it was, which is how the user stays on Code. + if (next === "work" && !hasSandbox()) { + void import("@/sandbox/dialog-add").then((module) => { + void dialog.show( + () => , + () => { + if (hasSandbox()) enter("work") + }, + ) + }) + return + } + enter(next) + }, + } +} + +// Reachable on Work while its only sandbox is still starting or has failed — +// a sandbox joins the server list only once it is ready — and after the last +// sandbox is removed. Code always has the local server, so it only shows here +// if every server is somehow gone. +export function WorkspaceEmpty(props: { side: WorkspaceSide }) { + const language = useLanguage() + const dialog = useDialog() + const sandboxes = useSandboxes() + const configured = () => sandboxes.state.data?.sandboxes.length ?? 0 + + return ( +
+

+ {language.t(props.side === "work" && configured() > 0 ? "workspace.empty.starting" : "workspace.empty.none")} +

+ + + +
+ ) +} + +export function WorkspaceTabs(props: { class?: string }) { + const language = useLanguage() + const platform = usePlatform() + const workspace = useWorkspaceSwitch() + + const label = (side: WorkspaceSide) => language.t(side === "code" ? "workspace.code" : "workspace.work") + + // Sandboxes are desktop-only, so on web there is no second side and a lone + // "Code" tab would be furniture. + return ( + +
+ + {(side) => ( + + )} + +
+
+ ) +} diff --git a/packages/app/test-browser/workspace-side.test.ts b/packages/app/test-browser/workspace-side.test.ts new file mode 100644 index 0000000000..12e03fa7e3 --- /dev/null +++ b/packages/app/test-browser/workspace-side.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from "bun:test" +import { ServerConnection } from "@/context/server" +import { tabKey, type Tab } from "@/context/tabs" +import { + effectiveSide, + reorderWithinSide, + selectionForSide, + serversForSide, + sideForServer, + tabsForSide, +} from "@/workspace/side" + +const local: ServerConnection.Any = { + displayName: "Local Server", + type: "sidecar", + variant: "base", + http: { url: "http://127.0.0.1:1" }, +} + +const wsl: ServerConnection.Any = { + displayName: "Ubuntu", + type: "sidecar", + variant: "wsl", + distro: "Ubuntu-24.04", + http: { url: "http://127.0.0.1:2" }, +} + +const remote: ServerConnection.Any = { + displayName: "Remote", + type: "http", + http: { url: "https://example.test" }, +} + +const sandbox = (name: string): ServerConnection.Any => ({ + displayName: name, + type: "sidecar", + variant: "sandbox", + name, + http: { url: `http://127.0.0.1:9${name.length}` }, +}) + +const sessionTab = (server: string, sessionId: string): Tab => + ({ type: "session", server, sessionId }) as unknown as Tab + +test("only sandboxes belong to Work", () => { + expect(sideForServer(local)).toBe("code") + expect(sideForServer(wsl)).toBe("code") + expect(sideForServer(remote)).toBe("code") + expect(sideForServer(sandbox("work"))).toBe("work") +}) + +test("Code keeps every non-sandbox server, Work keeps the sandboxes", () => { + const all = [local, wsl, remote, sandbox("a"), sandbox("b")] + expect(serversForSide(all, "code")).toEqual([local, wsl, remote]) + expect(serversForSide(all, "work").map((conn) => ServerConnection.key(conn))).toEqual([ + "sandbox:a", + "sandbox:b", + ]) +}) + +test("splits session tabs by the side their server belongs to", () => { + const tabs = [sessionTab("sidecar", "s1"), sessionTab("sandbox:a", "s2"), sessionTab("wsl:Ubuntu", "s3")] + expect(tabsForSide(tabs, "code").map((tab) => tab.server)).toEqual(["sidecar", "wsl:Ubuntu"]) + expect(tabsForSide(tabs, "work").map((tab) => tab.server)).toEqual(["sandbox:a"]) +}) + +test("keeps the current selection when switching back to a side it belongs to", () => { + const all = [local, wsl, sandbox("a")] + expect(selectionForSide(all, "code", ServerConnection.key(wsl))).toBe(wsl) +}) + +test("falls back to the first server of a side the selection does not belong to", () => { + const all = [local, wsl, sandbox("a")] + expect(selectionForSide(all, "work", ServerConnection.key(wsl))?.displayName).toBe("a") + expect(selectionForSide(all, "code", "sandbox:a")).toBe(local) +}) + +test("a side with no servers has nothing to select", () => { + expect(selectionForSide([local], "work", undefined)).toBeUndefined() +}) + +// The strip only ever hands back the keys it is showing, and tabs.reorder() +// rejects anything that is not the complete set, so a filtered drag would +// otherwise be a silent no-op. +test("reorders one side in place and leaves the other side untouched", () => { + const tabs = [ + sessionTab("sidecar", "c1"), + sessionTab("sandbox:a", "w1"), + sessionTab("sidecar", "c2"), + sessionTab("sandbox:a", "w2"), + ] + const reordered = reorderWithinSide(tabs, "code", [tabKey(tabs[2]), tabKey(tabs[0])]) + expect(reordered).toEqual([tabKey(tabs[2]), tabKey(tabs[1]), tabKey(tabs[0]), tabKey(tabs[3])]) +}) + +test("ignores a reorder that does not cover the whole side", () => { + const tabs = [sessionTab("sidecar", "c1"), sessionTab("sidecar", "c2"), sessionTab("sandbox:a", "w1")] + expect(reorderWithinSide(tabs, "code", [tabKey(tabs[0])])).toEqual(tabs.map(tabKey)) +}) + +// Sandboxes are desktop-only, so a "work" side persisted on the desktop must +// not strand the web build's sidebar on a side it can never populate. +test("collapses to Code where sandboxes are unsupported", () => { + expect(effectiveSide("work", true)).toBe("work") + expect(effectiveSide("work", false)).toBe("code") + expect(effectiveSide("code", false)).toBe("code") +}) From 8eaf92a6d2731a1ab93799eac9f1ea4ebb26fea6 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Thu, 13 Aug 2026 17:54:29 +0700 Subject: [PATCH 2/4] fix(app): survive a tab whose server is gone at construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a sandbox from Settings while the Work side was open killed the renderer with "Permission server not found: sandbox:". createMemo computes eagerly, so permissionState ran the moment useSessionTabAvatarState did. For a tab drawn after its server had already disappeared that is during construction — before the sync()/connection() guards downstream get a chance — and ensureServerState throws on a key it cannot find. #219 guarded the notification lookup on the same hook but left this one, which is reached earlier. Gate it on connection() too, and let effectiveSide fall back to Code once the last sandbox is deleted so the sidebar is not left sitting on a side with nothing in it. An unloaded sandbox list stays "unknown" rather than "none", or every launch would bounce off Work before the list arrives. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 +++- DIVERGENCES.md | 4 ++-- .../src/pages/layout/project-avatar-state.ts | 10 ++++++++-- packages/app/src/workspace/side.ts | 19 ++++++++++++++----- packages/app/src/workspace/tabs.tsx | 5 ++++- .../app/test-browser/workspace-side.test.ts | 13 +++++++++++++ 6 files changed, 44 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50a4699c5d..b47f89d982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,7 +61,9 @@ file is the human-readable summary of what changed and why. had a session tab open took the whole window down with `Notification server not found`. Tabs belonging to a removed server are now closed before its disk is deleted, and a tab whose server has gone away (removed, restarting, - or crashed) simply stops showing an unread marker instead of failing. + or crashed) stops showing its unread and attention markers instead of + failing — including a tab that is drawn for the first time after its server + is already gone. - **Sandboxes run the agent as an unprivileged user** — the server inside a sandbox no longer runs as root: it drops to the image's ordinary `codev` account once the guest has finished mounting, so a sandbox escape has one diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 84632b2248..7ec2c9c8c9 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -194,11 +194,11 @@ from the squash message first, or use a plain merge. | 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. | -| 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; 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. | +| 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 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. | | `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` | `unread` gains a `connection()` guard before `notification.ensureServerState(server())`, matching 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:`. | 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/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. | | `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/pages/layout/project-avatar-state.ts b/packages/app/src/pages/layout/project-avatar-state.ts index 30e97d10c3..8be98292ad 100644 --- a/packages/app/src/pages/layout/project-avatar-state.ts +++ b/packages/app/src/pages/layout/project-avatar-state.ts @@ -13,8 +13,13 @@ export function useSessionTabAvatarState( const global = useGlobal() const notification = useNotification() const permission = usePermission() - const permissionState = createMemo(() => permission.ensureServerState(server())) const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server())) + // createMemo computes eagerly, so this runs the moment the hook does — for a + // tab whose server is already gone (a sandbox deleted from Settings) that is + // during construction, before any guard downstream can help. ensureServerState + // throws on an unknown key and the throw escapes the render, taking the whole + // renderer with it, so the lookup has to be gated here. + const permissionState = createMemo(() => (connection() ? permission.ensureServerState(server()) : undefined)) const sync = createMemo(() => { const conn = connection() if (conn) return global.ensureServerCtx(conn).sync @@ -24,7 +29,8 @@ export function useSessionTabAvatarState( if (!serverSync) return false const [store] = serverSync.child(directory(), { bootstrap: false }) return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => { - return !permissionState().autoResponds(item, directory()) + const state = permissionState() + return !!state && !state.autoResponds(item, directory()) }) }) const hasQuestions = createMemo(() => { diff --git a/packages/app/src/workspace/side.ts b/packages/app/src/workspace/side.ts index b76ad033b9..f4db382068 100644 --- a/packages/app/src/workspace/side.ts +++ b/packages/app/src/workspace/side.ts @@ -10,11 +10,20 @@ export type WorkspaceSide = "code" | "work" export const WORKSPACE_SIDES = ["code", "work"] as const -// Sandboxes are a desktop feature, so the web build has no Work side at all. -// A persisted "work" from a desktop session must not strand the sidebar on a -// side that platform can never populate. -export function effectiveSide(side: WorkspaceSide, sandboxesSupported: boolean): WorkspaceSide { - return sandboxesSupported ? side : "code" +// Sandboxes are a desktop feature, so the web build has no Work side at all, +// and deleting the last sandbox leaves Work with nothing to show. Either way +// the sidebar must not sit on a side it cannot populate. +// +// `sandboxCount` is undefined until the sandbox list has loaded: treating that +// as zero would bounce every launch back to Code before the answer arrives. +export function effectiveSide( + side: WorkspaceSide, + sandboxesSupported: boolean, + sandboxCount?: number, +): WorkspaceSide { + if (!sandboxesSupported) return "code" + if (side === "work" && sandboxCount === 0) return "code" + return side } // The sandbox key format (sandbox:) is persisted in the main-process diff --git a/packages/app/src/workspace/tabs.tsx b/packages/app/src/workspace/tabs.tsx index 3ad314dad7..2ffa89f7fb 100644 --- a/packages/app/src/workspace/tabs.tsx +++ b/packages/app/src/workspace/tabs.tsx @@ -17,7 +17,10 @@ import { effectiveSide, selectionForSide, WORKSPACE_SIDES, type WorkspaceSide } export function useWorkspaceSide() { const layout = useLayout() const platform = usePlatform() - return createMemo(() => effectiveSide(layout.home.workspace(), !!platform.sandboxes)) + const sandboxes = useSandboxes() + return createMemo(() => + effectiveSide(layout.home.workspace(), !!platform.sandboxes, sandboxes.state.data?.sandboxes.length), + ) } // Code and Work are the same UI pointed at different servers, so switching one diff --git a/packages/app/test-browser/workspace-side.test.ts b/packages/app/test-browser/workspace-side.test.ts index 12e03fa7e3..66cae45a5a 100644 --- a/packages/app/test-browser/workspace-side.test.ts +++ b/packages/app/test-browser/workspace-side.test.ts @@ -105,3 +105,16 @@ test("collapses to Code where sandboxes are unsupported", () => { expect(effectiveSide("work", false)).toBe("code") expect(effectiveSide("code", false)).toBe("code") }) + +// Deleting the last sandbox from Settings while Work is open would otherwise +// leave the sidebar on a side with nothing in it. +test("falls back to Code once the last sandbox is gone", () => { + expect(effectiveSide("work", true, 1)).toBe("work") + expect(effectiveSide("work", true, 0)).toBe("code") +}) + +// undefined is "not loaded yet", not "none": treating it as zero would bounce +// every launch back to Code before the sandbox list arrives. +test("keeps the persisted side while the sandbox list is still loading", () => { + expect(effectiveSide("work", true, undefined)).toBe("work") +}) From f4a66948809037d99c63951167e6a2bfdfd7ab55 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Thu, 13 Aug 2026 18:03:12 +0700 Subject: [PATCH 3/4] fix(app): switch to Work as soon as the sandbox is created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- DIVERGENCES.md | 2 +- packages/app/src/context/layout.tsx | 18 ++++++++++++++- packages/app/src/sandbox/dialog-add.tsx | 12 ++++++++-- packages/app/src/workspace/side.ts | 23 ++++++++----------- packages/app/src/workspace/tabs.tsx | 16 +++++-------- .../app/test-browser/workspace-side.test.ts | 16 ++++--------- 6 files changed, 49 insertions(+), 38 deletions(-) diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 7ec2c9c8c9..796d91f473 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -194,7 +194,7 @@ from the squash message first, or use a plain merge. | 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. | -| 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 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. | +| 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; 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. | | `packages/app/src/context/global.tsx` | Two lines: imports `seedSandboxProjects` from the fork-only `packages/app/src/sandbox/projects.ts` and calls it with `server.list` + `ensureServerCtx`, beside the memo that already creates a ctx per connection. All the logic lives in the fork-only module. | A sandbox exposes exactly one directory (`/workspace` on vz/bwrap, the wslpath translation on WSL) and the guest registers it as its own project, but the app's per-server project list starts empty and nothing ever fills it. With no project, every "New session" entry point — `home-model.openNewSession`, the titlebar's `openNewTab` fallback chain — finds nothing for that server and falls back to another one, so the sandbox looks selected while the session runs on the local server. The seed reads `sync.data.path.directory`, which every server ctx already fetches, so it stays backend-agnostic. The same module also names that project after the sandbox's host folder (via `client.project.update`, the call behind rename-project), because the guest sees only its mount point and every sandbox would otherwise read "workspace" in the sidebar, tabs and palette. | Keep both lines; take upstream's other changes to the file. The call must stay inside the Global context init (it needs `ensureServerCtx`) — `SandboxesProvider` is mounted above `GlobalProvider` in `app.tsx`, so it cannot live there. | diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 6ca81d182c..a913feaf85 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -18,7 +18,7 @@ import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2" import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope" import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" import { requireServerKey } from "@/utils/session-route" -import { sideForServerKey, type WorkspaceSide } from "@/workspace/side" +import { selectionForSide, serversForSide, sideForServerKey, type WorkspaceSide } from "@/workspace/side" import { type DraftTab, useTabs } from "./tabs" import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs" @@ -316,6 +316,22 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( sessionTabPreview: {} as Record, }) + // Fork: keep the sidebar's selection on the side that is showing. Switching + // sides corrects it once, but a sandbox joins the server list only when it + // finishes starting — so right after one is created there is nothing on the + // Work side to select yet, and without this the projects list would show + // the sandbox while Pinned and Recents still read the local server. + createEffect(() => { + const side = store.home.workspace ?? "code" + const available = serversForSide(server.list, side) + if (!available.length) return + const target = selectionForSide(server.list, side, store.home.selection.server) + if (!target) return + const key = ServerConnection.key(target) + if (store.home.selection.server === key) return + setStore("home", "selection", reconcile({ server: key })) + }) + // Fork: remember where each side was, so switching back to Code or Work // returns you to the session you were reading rather than a blank draft. // Recorded here rather than in the sidebar because it has to keep working diff --git a/packages/app/src/sandbox/dialog-add.tsx b/packages/app/src/sandbox/dialog-add.tsx index f8de22d06b..0e3ff43eac 100644 --- a/packages/app/src/sandbox/dialog-add.tsx +++ b/packages/app/src/sandbox/dialog-add.tsx @@ -9,6 +9,7 @@ import { Show } from "solid-js" import { createStore } from "solid-js/store" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" +import type { SandboxConfig } from "./types" import { useSandboxes } from "./context" import { sandboxResourcesValid } from "./resources" @@ -16,7 +17,11 @@ import { sandboxResourcesValid } from "./resources" // every backend, so keep it to characters that are safe in all of them. const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/ -export function DialogAddSandbox() { +// onCreated fires with the new sandbox before the dialog closes. Callers that +// need to react to a creation must use it rather than watching the sandbox +// list: that list arrives over a separate IPC event, so it is still empty for +// a beat after add() resolves. +export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) => void }) { const language = useLanguage() const platform = usePlatform() const dialog = useDialog() @@ -79,7 +84,10 @@ export function DialogAddSandbox() { store.projectDir, resourcesApply() ? { cpus: cpus(), memoryMiB: memoryMiB() } : undefined, ) - if (created) dialog.close() + if (created) { + props.onCreated?.(created) + dialog.close() + } } catch (error) { showToast({ variant: "error", diff --git a/packages/app/src/workspace/side.ts b/packages/app/src/workspace/side.ts index f4db382068..95b09c30d8 100644 --- a/packages/app/src/workspace/side.ts +++ b/packages/app/src/workspace/side.ts @@ -10,20 +10,17 @@ export type WorkspaceSide = "code" | "work" export const WORKSPACE_SIDES = ["code", "work"] as const -// Sandboxes are a desktop feature, so the web build has no Work side at all, -// and deleting the last sandbox leaves Work with nothing to show. Either way -// the sidebar must not sit on a side it cannot populate. +// Sandboxes are a desktop feature, so the web build has no Work side at all. +// A persisted "work" from a desktop session must not strand the sidebar on a +// side that platform can never populate. // -// `sandboxCount` is undefined until the sandbox list has loaded: treating that -// as zero would bounce every launch back to Code before the answer arrives. -export function effectiveSide( - side: WorkspaceSide, - sandboxesSupported: boolean, - sandboxCount?: number, -): WorkspaceSide { - if (!sandboxesSupported) return "code" - if (side === "work" && sandboxCount === 0) return "code" - return side +// Deliberately not also collapsing when there are zero sandboxes: the sandbox +// list arrives asynchronously, so "none" and "not loaded yet" look identical +// here, and treating them alike bounced the user back to Code both on every +// launch and in the beat after creating their first sandbox. An empty Work +// side is a legible state — WorkspaceEmpty says so and offers to create one. +export function effectiveSide(side: WorkspaceSide, sandboxesSupported: boolean): WorkspaceSide { + return sandboxesSupported ? side : "code" } // The sandbox key format (sandbox:) is persisted in the main-process diff --git a/packages/app/src/workspace/tabs.tsx b/packages/app/src/workspace/tabs.tsx index 2ffa89f7fb..6f6b91f0b3 100644 --- a/packages/app/src/workspace/tabs.tsx +++ b/packages/app/src/workspace/tabs.tsx @@ -17,10 +17,7 @@ import { effectiveSide, selectionForSide, WORKSPACE_SIDES, type WorkspaceSide } export function useWorkspaceSide() { const layout = useLayout() const platform = usePlatform() - const sandboxes = useSandboxes() - return createMemo(() => - effectiveSide(layout.home.workspace(), !!platform.sandboxes, sandboxes.state.data?.sandboxes.length), - ) + return createMemo(() => effectiveSide(layout.home.workspace(), !!platform.sandboxes)) } // Code and Work are the same UI pointed at different servers, so switching one @@ -81,12 +78,11 @@ export function useWorkspaceSwitch() { // side where it was, which is how the user stays on Code. if (next === "work" && !hasSandbox()) { void import("@/sandbox/dialog-add").then((module) => { - void dialog.show( - () => , - () => { - if (hasSandbox()) enter("work") - }, - ) + // Switch off the dialog's own result, not off the sandbox list: the + // list arrives on a separate IPC event and is still empty for a beat + // after the create resolves, so checking it here left the user on + // Code until they toggled the tabs by hand. + void dialog.show(() => enter("work")} />) }) return } diff --git a/packages/app/test-browser/workspace-side.test.ts b/packages/app/test-browser/workspace-side.test.ts index 66cae45a5a..70a6ab7f2d 100644 --- a/packages/app/test-browser/workspace-side.test.ts +++ b/packages/app/test-browser/workspace-side.test.ts @@ -106,15 +106,9 @@ test("collapses to Code where sandboxes are unsupported", () => { expect(effectiveSide("code", false)).toBe("code") }) -// Deleting the last sandbox from Settings while Work is open would otherwise -// leave the sidebar on a side with nothing in it. -test("falls back to Code once the last sandbox is gone", () => { - expect(effectiveSide("work", true, 1)).toBe("work") - expect(effectiveSide("work", true, 0)).toBe("code") -}) - -// undefined is "not loaded yet", not "none": treating it as zero would bounce -// every launch back to Code before the sandbox list arrives. -test("keeps the persisted side while the sandbox list is still loading", () => { - expect(effectiveSide("work", true, undefined)).toBe("work") +// An empty Work side stays selected: the sandbox list loads asynchronously, so +// collapsing on "no sandboxes" also fires before the list arrives and in the +// beat after the first one is created. +test("keeps Work selected even with no sandboxes", () => { + expect(effectiveSide("work", true)).toBe("work") }) From 3ecaa99ffbd261960b7512fd2539740fc143a148 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Thu, 13 Aug 2026 18:19:57 +0700 Subject: [PATCH 4/4] fix(app): return to Code when the last sandbox is deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 4 +++- DIVERGENCES.md | 2 +- .../app/src/components/dialog-select-server.tsx | 5 +++++ packages/app/src/workspace/side.ts | 7 +++++++ packages/app/src/workspace/tabs.tsx | 10 +++++++++- packages/app/test-browser/workspace-side.test.ts | 14 ++++++++++++++ 6 files changed, 39 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b47f89d982..d0094b6a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,9 @@ file is the human-readable summary of what changed and why. returns you to the session you were last in on that side rather than opening a blank new session; with no history it falls back to a new session, and to the home screen when that side has nothing to open yet. Switching to Work - with no sandbox yet offers to create one — cancelling leaves you on Code. + with no sandbox yet offers to create one — cancelling leaves you on Code — + and deleting your last sandbox returns you to Code rather than leaving you + on an empty Work side. The **Home** button has been removed from the titlebar: it opened a new session rather than a home screen, which is what the tabs now decide. The recent-sessions screen is still one click away behind the search button and diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 796d91f473..f288ee2a19 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -194,7 +194,7 @@ from the squash message first, or use a plain merge. | 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. | -| 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; 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. | +| Code/Work workspace split: `packages/app/src/context/layout.tsx`, `packages/app/src/components/{app-sidebar,titlebar}.tsx`, `packages/app/src/i18n/{en,es}.ts` | The sidebar gains two tabs above Projects — **Code** (every non-sandbox server: the local sidecar, WSL, remote, SSH) and **Work** (the sandboxes) — backed by the fork-only `packages/app/src/workspace/{side.ts,tabs.tsx}`. `layout.tsx` stores `home.workspace` and `home.lastSession` (per side) and records the latter from an effect on `route()`; `app-sidebar.tsx` renders ``, swaps `global.servers.list()` for a side-filtered memo in all six places it indexed it, and adds an empty-state branch because a side can now have no servers at all; `titlebar.tsx` loses the Home button and its `home.toggle` command (`mod+b`) and filters the tab strip, its reorder and `tab.prev`/`tab.next` to the visible side; `effectiveSide` falls back to Code only where the platform has no sandbox support — deliberately not on an empty sandbox list, which is indistinguishable from one that has not loaded; `layout.tsx` also holds an effect keeping `home.selection.server` on the showing side, because a sandbox joins the server list only once it has finished starting, so the one-shot correction inside a switch cannot cover a sandbox created moments earlier; the fork-only `DialogAddSandbox` gains an `onCreated` callback so the Work gate switches on the create's own result rather than on the asynchronous sandbox list; `dialog-select-server.tsx` `handleRemove` calls `workspace.removedServer(key)` beside the tab cleanup, which steps back to Code when the sandbox being deleted is the last one; i18n gains `workspace.{code,work,empty.none,empty.starting}`. | Sandboxes were just another row in a list of servers, so the isolated environment and the local machine were one undifferentiated pile. Splitting them makes "am I running on my machine or in a sandbox?" a property of where you are rather than something to infer from a server name. The Home button went because it opened a draft rather than a home screen, which is the behaviour the tabs replace: a tab returns you to that side's last session, falling back to a draft and then to the home screen. All the logic lives in the fork-only module; the upstream files hold wiring only, and every section below the tabs is the same component reading a filtered list. | Keep the wiring and take upstream's surrounding changes. The six `sideServers()` call sites in `app-sidebar.tsx` were `global.servers.list()` — if upstream adds a seventh, filter it too or that branch will show sandboxes under Code. Read `useWorkspaceSide()` rather than `layout.home.workspace()`: it collapses to Code where the platform has no sandbox support, which is what keeps the web build coherent. The tab strip filter and `reorderWithinSide` go together — `tabs.reorder()` rejects a partial key list, so a filtered strip without the merge silently ignores drags. | | `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/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 74818a042c..8291cf7698 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -19,6 +19,7 @@ import { normalizeServerUrl, ServerConnection, useServer } from "@/context/serve import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" import { useSettings } from "@/context/settings" import { useTabs } from "@/context/tabs" +import { useWorkspaceSwitch } from "@/workspace/tabs" const DEFAULT_USERNAME = "opencode" @@ -193,6 +194,7 @@ export function useServerManagementController(options: { onSelect?: () => void; const navigate = useNavigate() const server = useServer() const tabs = useTabs() + const workspace = useWorkspaceSwitch() const global = useGlobal() const platform = usePlatform() const language = useLanguage() @@ -517,6 +519,9 @@ export function useServerManagementController(options: { onSelect?: () => void; // server that is already gone, and the tab-pruning effect that catches // them does not discard their draft state the way removeServer does. tabs.removeServer(key) + // Fork: before the awaits, so the switch lands with the tab cleanup + // rather than after a delete that takes as long as erasing a disk. + workspace.removedServer(key) server.remove(key) if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key) if (key.startsWith("sandbox:")) await platform.sandboxes?.remove(key) diff --git a/packages/app/src/workspace/side.ts b/packages/app/src/workspace/side.ts index 95b09c30d8..324b0a7ebd 100644 --- a/packages/app/src/workspace/side.ts +++ b/packages/app/src/workspace/side.ts @@ -54,6 +54,13 @@ export function selectionForSide( return kept ?? available[0] } +// Whether removing this server empties the Work side. Answered from the list +// as it stands *before* the removal lands — the sandbox list updates over IPC a +// beat later, so reading it afterwards is a race. +export function leavesWorkEmpty(sandboxes: { config: { id: string } }[], removed: string) { + return sideForServerKey(removed) === "work" && !sandboxes.some((item) => item.config.id !== removed) +} + // The tab strip only renders one side, so it can only ever reorder that side's // keys. Splice the new order back into the slots those tabs already occupy and // leave the other side untouched — tabs.reorder() rejects a list that is not diff --git a/packages/app/src/workspace/tabs.tsx b/packages/app/src/workspace/tabs.tsx index 6f6b91f0b3..82e4b6588a 100644 --- a/packages/app/src/workspace/tabs.tsx +++ b/packages/app/src/workspace/tabs.tsx @@ -10,7 +10,7 @@ import { findOpenDraft } from "@/pages/home-draft-reuse" import { sessionHref } from "@/utils/session-route" import { useSandboxes } from "@/sandbox/context" import { usePlatform } from "@/context/platform" -import { effectiveSide, selectionForSide, WORKSPACE_SIDES, type WorkspaceSide } from "./side" +import { effectiveSide, leavesWorkEmpty, selectionForSide, WORKSPACE_SIDES, type WorkspaceSide } from "./side" // The side everything should actually render, as opposed to the side that // happens to be persisted. Read this rather than layout.home.workspace(). @@ -88,6 +88,14 @@ export function useWorkspaceSwitch() { } enter(next) }, + // Deleting the last sandbox leaves Work with nothing to show, and the + // session on screen belonged to the server that just went away. Step back + // to Code and navigate there, the same way selecting the tab would. + removedServer(key: string) { + if (side() !== "work") return + if (!leavesWorkEmpty(sandboxes.state.data?.sandboxes ?? [], key)) return + enter("code") + }, } } diff --git a/packages/app/test-browser/workspace-side.test.ts b/packages/app/test-browser/workspace-side.test.ts index 70a6ab7f2d..5e623a90e4 100644 --- a/packages/app/test-browser/workspace-side.test.ts +++ b/packages/app/test-browser/workspace-side.test.ts @@ -3,6 +3,7 @@ import { ServerConnection } from "@/context/server" import { tabKey, type Tab } from "@/context/tabs" import { effectiveSide, + leavesWorkEmpty, reorderWithinSide, selectionForSide, serversForSide, @@ -112,3 +113,16 @@ test("collapses to Code where sandboxes are unsupported", () => { test("keeps Work selected even with no sandboxes", () => { expect(effectiveSide("work", true)).toBe("work") }) + +// Answered from the list as it stands before the removal: the sandbox list +// updates over IPC a beat later, so reading it afterwards is a race. +test("knows when removing a server empties the Work side", () => { + const configured = [{ config: { id: "sandbox:a" } }, { config: { id: "sandbox:b" } }] + expect(leavesWorkEmpty(configured, "sandbox:a")).toBe(false) + expect(leavesWorkEmpty([{ config: { id: "sandbox:a" } }], "sandbox:a")).toBe(true) +}) + +test("removing a non-sandbox server never empties Work", () => { + expect(leavesWorkEmpty([], "wsl:Ubuntu-24.04")).toBe(false) + expect(leavesWorkEmpty([], "sidecar")).toBe(false) +})