diff --git a/CHANGELOG.md b/CHANGELOG.md index de7fabf0f3..86941e35f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,14 @@ file is the human-readable summary of what changed and why. ### Sandbox +- **Work says it is loading your sandbox, instead of offering a session it + cannot run** — while the sandbox that runs your sessions was still coming up, + Work showed a search box over "Nothing here yet / Create a session to get + started": the only action on screen could not work yet, and the search had + nothing to search. It now shows a spinner and "Loading sandbox..." in the + middle of the pane, and hides the search box, until the sandbox is ready. The + sidebar is unchanged: it still says the sandbox is not running yet. + ([#299](https://github.com/quickbeard/codev-code/pull/299)) - **Creating a sandbox is near-instant and no longer costs 8 GB** — a new sandbox is meant to start as a copy-on-write clone of the shared disk image, costing no space until the guest writes something. On macOS the clone never diff --git a/DIVERGENCES.md b/DIVERGENCES.md index c91c93cd0c..fbdf6f3887 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -219,6 +219,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. | +| Work loading state: `packages/app/src/pages/home.tsx`, `packages/app/src/i18n/{en,es}.ts` | `NewHome` gains `useWorkspaceSide()` + `useSandboxes()` and a `waitingForSandbox()` memo over the fork-only `packages/app/src/workspace/waiting.ts`, which drives two things. (1) The session list's empty fallback becomes a `Show`: while Work waits it renders the fork-only `HomeSandboxLoading` — a `Spinner` at `size-8` over the `workspace.loading.sandbox` label, centred in the pane — and otherwise upstream's `HomeSessionsEmpty`, unchanged. (2) `HomeSessionSearch` is wrapped in ``. New i18n key `workspace.loading.sandbox`. `HomeSessionSkeleton` and the Code-side loading branch that uses it are untouched. `HomeSandboxLoading` carries the wrapper's own `min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]` rather than `min-h-full`: the wrapper has a min-height and an auto height, so a percentage would resolve against the content and centre nothing. | A sandbox takes minutes to create and seconds to boot, and for all of it the session list said "Nothing here yet / Create a session to get started" over a search box — the one action offered could not work, since the server that would run the session did not exist yet, and the search had nothing to search. Its own view rather than the session skeleton: that skeleton stands in for rows about to arrive, which is true of a server's session load and false here, where nothing arrives for minutes. | Keep the memo, the wrapping `Show` around the fallback, and the `Show` around `HomeSessionSearch`; take upstream's surrounding changes. `HomeSessionsEmpty` must stay reachable — it is all Code ever renders here. If upstream restructures the session pane, re-apply both `Show`s; they are independent. | | Per-folder tab strip: `packages/app/src/context/{layout,tabs}.tsx`, `packages/app/src/components/titlebar.tsx`, `packages/app/src/pages/home.tsx` | Extends the Code/Work split row above so each side keeps one tab strip per **folder** of the selected server rather than one for the whole side — shipped for Work first (a sandbox exposes its project folder plus every extra mount as its own project, fork-only `src/sandbox/projects.ts`), then extended to Code, whose folders are the selected server's projects. The strip is keyed by (server, folder); on web, which has no Code/Work split, `useWorkspaceScope` resolves no selection and the strip stays upstream's single list. All of the logic is in the fork-only `src/workspace/side.ts` (`TabScope`, `folderForDirectory`, `tabsForScope`, `reorderWithinScope` — `tabsForSide`/`reorderWithinSide` become thin wrappers over the same primitives so the Code path is provably unchanged) and `src/workspace/tabs.tsx` (`useWorkspaceScope`, `useWorkspaceFolder`). Upstream files hold wiring only: `tabs.tsx` adds `SessionTab.directory` — deliberately **not** part of `tabKey()`, which is what keeps every persisted `info`/`memory`/`closed` key valid as the field appears — plus `backfillTabDirectories` and the effect that applies it, `promoteDraft` inheriting the draft's directory, `addSessionTab` filling a missing directory on an already-open tab, and an optional `within` on `closeTab`/`removeTab` so the successor after a close is picked from the visible list, plus `removeDirectory({server, directory})` — every tab of one mounted folder, sessions and drafts, for when that folder is unmounted (matching lives in the fork-only `src/workspace/tab-directory.ts`, its own module because both `context/tabs.tsx` and `workspace/side.ts` reach the router at import time and so cannot be unit-tested). Modelled on `removeServer` rather than `closeTab`: the folder left the sandbox, so the tabs are not a close the user can undo, and leaving them behind is what made an unmounted folder's session reappear over the next folder the user mounted — `inScope()` shows a tab whose folder the sandbox no longer lists in *every* folder, so it followed the user around while appearing in no folder's Recents; `layout.tsx` adds `home.lastFolderSession` (keyed `` `${server}\n${pathKey(directory)}` ``) with `folderSession`/`recordFolderSession`; `titlebar.tsx` swaps the side filter for the scope in the strip, reorder, `tab.prev`/`tab.next` and `tab.close`, passes `session.directory` to `addSessionTab`, and gains a leading scope branch in `openNewTab`; `home.tsx` passes `directory` to `addSessionTab`. `pages/home-model.tsx` (fork-only) selects instead of toggling on desktop — both sides — and calls `enterFolder`; web keeps upstream's toggle. The scope is **derived**, never stored: the folder on screen wins, then the sidebar's selected project, then the side's default folder (`defaultFolder` in `workspace/tabs.tsx` — the sandbox's primary folder from `sync.data.path.directory` on Work, the last-used project then the first on Code) — so deep links, the command palette and a reopened tab all pull the strip to their own folder instead of leaving the active tab out of view. `pages/session.tsx` and `components/command-palette.ts` are deliberately left untouched; their tabs pick up a folder through the back-fill instead, which keeps two more upstream files out of this table. | Sandboxes gained extra folder mounts, so one sandbox can expose several unrelated folders while their sessions shared a single strip — working across two folders meant constantly reading past the other one's tabs. Deriving the scope rather than persisting a "current folder" makes "the strip always contains the session you are looking at" true by construction instead of by an effect racing the two `home` effects in `layout.tsx`, and it keeps the sidebar's Recents unnarrowed: writing the route's folder into `home.selection.directory` would feed `projectDirectories()` and silently filter that list too. | Keep the wiring and take upstream's surrounding changes. `tabsForScope` must return the store's own tab objects — the strip compares by reference for the active highlight, the close index and the `` key, so never map to copies. Never add `directory` to `tabKey()`. A tab whose folder cannot be resolved shows in **every** folder of its own server on purpose: it is exactly the tab that has never rendered and so never had a chance to learn where it belongs, and being invisible everywhere is unrecoverable. `folderForDirectory`'s prefix test must stay path-boundary aware — mount guest names dedup with a `-2` suffix, so `/mnt/shared` must not swallow `/mnt/shared-2`. Resolve the primary folder from `sync.data.path`, never `projects.list()[0]`: `projects.open()` prepends, so that is the most recently added mount. | | Code/Work workspace split: `packages/app/src/context/layout.tsx`, `packages/app/src/components/{app-sidebar,titlebar}.tsx`, `packages/app/src/i18n/{en,es}.ts` | The sidebar gains two tabs above Projects — **Code** (every non-sandbox server: the local sidecar, WSL, remote, SSH) and **Work** (the sandboxes) — backed by the fork-only `packages/app/src/workspace/{side.ts,tabs.tsx}`. `layout.tsx` stores `home.workspace` and `home.lastSession` (per side) and records the latter from an effect on `route()`; `app-sidebar.tsx` renders ``, swaps `global.servers.list()` for a side-filtered memo in all six places it indexed it, and adds an empty-state branch because a side can now have no servers at all; `titlebar.tsx` loses the Home button and its `home.toggle` command (`mod+b`) and filters the tab strip, its reorder and `tab.prev`/`tab.next` to the visible side; `effectiveSide` falls back to Code wherever the Work side is unavailable — originally only where the platform has no sandbox support, now `workSideAvailable()`'s answer (see the opt-in Work row below), which still never collapses on an unloaded sandbox list, since that is indistinguishable from an empty one; `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; resuming a side's last session also selects the project that session lives in (`sessionProjectSelection` in `workspace/side.ts`, which maps the session's directory to the owning folder of the server's project list) and touches it as that server's last project — the switch otherwise set a server with no directory and returned, leaving the sidebar on no project at all beside a restored session; i18n gains `workspace.{code,work,empty.none,empty.starting,empty.stopped,empty.failed,empty.failed.retry}` (`empty.stopped` for a Work side whose sandboxes are all stopped, which unlike starting will never come up on its own; `empty.failed`/`empty.failed.retry` for one whose sandbox failed to start — the empty state shows the runtime's failure message and a retry that calls `platform.sandboxes.start`, because the wait message otherwise left the user watching for a sandbox that already gave up). | Sandboxes were just another row in a list of servers, so the isolated environment and the local machine were one undifferentiated pile. Splitting them makes "am I running on my machine or in a sandbox?" a property of where you are rather than something to infer from a server name. The Home button went because it opened a draft rather than a home screen, which is the behaviour the tabs replace: a tab returns you to that side's last session, falling back to a draft and then to the home screen. All the logic lives in the fork-only module; the upstream files hold wiring only, and every section below the tabs is the same component reading a filtered list. | Keep the wiring and take upstream's surrounding changes. The six `sideServers()` call sites in `app-sidebar.tsx` were `global.servers.list()` — if upstream adds a seventh, filter it too or that branch will show sandboxes under Code. Read `useWorkspaceSide()` rather than `layout.home.workspace()`: it collapses to Code wherever Work is unavailable, which is what keeps the web build — and a sandbox-less desktop profile — coherent. The tab strip filter and `reorderWithinSide` go together — `tabs.reorder()` rejects a partial key list, so a filtered strip without the merge silently ignores drags. | | Workspace (worktree) bar is Code-only: `packages/app/src/pages/new-session.tsx` | `showWorkspaceBar` gains a third condition, `sideForServer(serverSDK().server) === "code"`, so the "Run session in → Local repository / New workspace / Workspace…" chip is not offered on Work. Work falls through to the same `PromptGitStatus` branch a non-git project already takes, which shows the branch on its own. Nothing else moves: `newSessionWorktree()` already returns `"main"` whenever the bar is hidden, and `"main"` in `prompt-input/submit.ts` means "leave `sessionDirectory` at `sdk().directory`" — it does not redirect a session to the project root. The bar remains gated on `VITE_CODEV_CHANNEL !== "prod"`, which is upstream's own gate. | A workspace is a git worktree created by the server that runs the session, and `Worktree.makeWorktreeInfo` roots it at `Global.Path.data/worktree/` (`packages/opencode/src/worktree/index.ts`). In a sandbox that is the guest's own data dir: it is not one of the host folders the sandbox mounts (`packages/codev-sandbox/src/mounts.ts`), so the work happens where the user's editor cannot see it, and a reset re-clones the guest disk and takes it along. Meanwhile the chip says "Local" under a monitor icon, which is the opposite of true. Three storage designs were costed before choosing to hide it — a checkout inside the mounted repo, a dedicated hidden worktree mount per sandbox, or leaving it on the guest disk — and none of them make a guest worktree usable from the host, because a worktree's `.git` file holds an absolute path back to `/.git/worktrees/` and that path is `/mnt/` in the guest and the real path on the host. Note also that `git worktree add` runs with `cwd` = the project worktree, i.e. the mounted host repo, so it registers the worktree in the user's real `.git` whatever we do with the checkout. | Keep the gate. If Work should offer workspaces later, the storage question has to be answered first — where the checkout lives, and what a reset does to it — not just the label; re-read the reasoning column before reopening it. Read the side from `sideForServer(serverSDK().server)`, matching the project picker row above, so a draft is judged by its own server rather than by whichever side the sidebar is showing. If upstream changes what `showWorkspaceBar` gates, keep the third condition rather than reverting to the two-term memo. | diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index d33ed9dd0d..21104b0c3d 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -384,6 +384,7 @@ export const dict = { "wsl.server.updating": "Updating...", "workspace.code": "Code", "workspace.work": "Work", + "workspace.loading.sandbox": "Loading sandbox…", "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.", "workspace.empty.stopped": "Your sandbox is stopped. Start it again from Settings → Servers.", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 5631fcba99..727a7c69f2 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -375,6 +375,7 @@ export const dict = { "wsl.server.updating": "Actualizando...", "workspace.code": "Código", "workspace.work": "Trabajo", + "workspace.loading.sandbox": "Cargando el sandbox…", "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.", "workspace.empty.stopped": "Tu sandbox está detenido. Vuelve a iniciarlo desde Ajustes → Servidores.", diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 914133327d..143072bd0b 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -58,6 +58,9 @@ import { useMarked } from "@opencode-ai/ui/context/marked" import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache" import { createHomeModel, isBackgroundOpen, type HomeSessionRecord, type OpenSessionOptions } from "./home-model" import { ProjectRemovalMenuItems } from "@/sandbox/project-menu" +import { useSandboxes } from "@/sandbox/context" +import { useWorkspaceSide } from "@/workspace/tabs" +import { workspaceWaiting } from "@/workspace/waiting" import { showToast } from "@/utils/toast" import { fileManagerApp } from "@/utils/file-manager" @@ -235,6 +238,8 @@ export function NewHome() { const command = useCommand() const marked = useMarked() const openSettings = useSettingsCommand() + const workspaceSide = useWorkspaceSide() + const sandboxes = useSandboxes() // The project column beside this list moved into the persistent sidebar // (components/app-sidebar.tsx). Both call createHomeModel, so the two screens // share one selection, one session index, and one set of actions. @@ -261,6 +266,10 @@ export function NewHome() { const records = model.records const sessionLoad = model.sessionLoad + // Fork: whether Work has a sandbox on its way. Always false on Code, which is + // never empty for a reason the user has to wait out. + const waitingForSandbox = createMemo(() => workspaceWaiting(workspaceSide(), sandboxes.state.data)) + const search = createMemo(() => state.search.trim()) const searchPlaceholder = createMemo(() => { const project = selectedProject() @@ -422,21 +431,25 @@ export function NewHome() { if (sessionViewport) containHomeWheel(event, sessionViewport) }} > - setFocusSessionSearch(() => focus)} - onInput={(value) => setState("search", value)} - onFocus={() => setState("searchFocused", true)} - onClose={closeSearch} - onSelect={selectSearchSession} - /> + {/* Fork: a sandbox still coming up has no sessions, so the + search would return nothing however it is used. */} + + setFocusSessionSearch(() => focus)} + onInput={(value) => setState("search", value)} + onFocus={() => setState("searchFocused", true)} + onClose={closeSearch} + onSelect={selectSearchSession} + /> + 0 && newSessionProject()}>
0} - fallback={} + fallback={ + // Fork: on Work, a sandbox still coming up has no sessions + // and cannot be given one, so "create a session to get + // started" invites something that would fail. + + } + > + + + } >
{/* Both levels iterate ids, not the objects themselves. @@ -1344,6 +1369,25 @@ function HomeSessionsEmpty(props: { onNewSession?: () => void }) { ) } +// Fork: what Work shows while its sandbox comes up. Its own view rather than +// the session skeleton above: that skeleton stands in for rows that are about +// to arrive, and here nothing arrives for minutes — so this says what is +// happening, centred in the pane, with nothing else on screen to read as +// available. +function HomeSandboxLoading(props: { label: string }) { + // The height is the same container-query expression its wrapper uses, not + // min-h-full: the wrapper has a min-height and an auto height, so a + // percentage resolves against the content and centres nothing. + return ( +
+ +
+ {props.label} +
+
+ ) +} + function HomeSessionSkeleton(props: { label: string }) { return (
diff --git a/packages/app/src/workspace/side.ts b/packages/app/src/workspace/side.ts index a99fa1b782..6fc6c50207 100644 --- a/packages/app/src/workspace/side.ts +++ b/packages/app/src/workspace/side.ts @@ -2,6 +2,7 @@ import { ServerConnection } from "@/context/server" import { tabKey, type Tab } from "@/context/tabs" import type { SandboxesState } from "@/sandbox/types" import { pathKey } from "@/utils/path-key" +import { workspaceWaiting } from "./waiting" // 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 @@ -267,8 +268,7 @@ export function workspaceEmptyState( } { const sandboxes = state?.sandboxes ?? [] if (side !== "work" || !sandboxes.length) return { message: "workspace.empty.none" } - if (sandboxes.some((item) => item.runtime.kind === "starting") || state?.jobs.length) - return { message: "workspace.empty.starting" } + if (workspaceWaiting(side, state)) return { message: "workspace.empty.starting" } const failure = sandboxes.flatMap((item) => item.runtime.kind === "failed" ? [{ id: item.config.id, message: item.runtime.message }] : [], )[0] diff --git a/packages/app/src/workspace/waiting.test.ts b/packages/app/src/workspace/waiting.test.ts new file mode 100644 index 0000000000..74eeae3289 --- /dev/null +++ b/packages/app/src/workspace/waiting.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import type { SandboxesState, SandboxItem, SandboxJob } from "@/sandbox/types" +import { workspaceWaiting } from "./waiting" + +const sandbox = (runtime: SandboxItem["runtime"]): SandboxItem => ({ + config: { id: "sandbox:work", name: "work", backend: "vz", projectDir: "/tmp/work" }, + runtime, +}) + +const job = (over: Partial = {}): SandboxJob => ({ + kind: "create", + id: "sandbox:work", + startedAt: 0, + status: "running", + ...over, +}) + +const state = (over: Partial = {}): SandboxesState => ({ + sandboxes: [sandbox({ kind: "starting" })], + jobs: [], + ...over, +}) + +describe("a sandbox on its way", () => { + test("waits while one boots", () => { + expect(workspaceWaiting("work", state())).toBe(true) + }) + + // A sandbox being created is not in the list until its job finishes, and one + // being reset reads "stopped" the whole time its job runs — so the job is the + // only thing that says either wait is happening. + test("waits on a job whatever the sandboxes say", () => { + expect(workspaceWaiting("work", state({ sandboxes: [sandbox({ kind: "stopped" })], jobs: [job()] }))).toBe(true) + expect( + workspaceWaiting("work", state({ sandboxes: [sandbox({ kind: "stopped" })], jobs: [job({ kind: "reset" })] })), + ).toBe(true) + expect( + workspaceWaiting("work", state({ sandboxes: [sandbox({ kind: "stopped" })], jobs: [job({ status: "queued" })] })), + ).toBe(true) + }) + + test("does not wait on Code, or before a sandbox exists", () => { + expect(workspaceWaiting("code", state())).toBe(false) + expect(workspaceWaiting("work", state({ sandboxes: [] }))).toBe(false) + expect(workspaceWaiting("work", undefined)).toBe(false) + }) + + test("does not wait on a sandbox only the user can bring back", () => { + expect(workspaceWaiting("work", state({ sandboxes: [sandbox({ kind: "stopped" })] }))).toBe(false) + expect(workspaceWaiting("work", state({ sandboxes: [sandbox({ kind: "failed", message: "no kernel" })] }))).toBe( + false, + ) + }) + + test("stops waiting once the sandbox is ready", () => { + expect( + workspaceWaiting( + "work", + state({ sandboxes: [sandbox({ kind: "ready", url: "http://x", username: null, password: null, mounts: [] })] }), + ), + ).toBe(false) + }) +}) diff --git a/packages/app/src/workspace/waiting.ts b/packages/app/src/workspace/waiting.ts new file mode 100644 index 0000000000..34031b859d --- /dev/null +++ b/packages/app/src/workspace/waiting.ts @@ -0,0 +1,16 @@ +import type { SandboxesState } from "@/sandbox/types" +import type { WorkspaceSide } from "./side" + +// Whether the Work side has a sandbox on its way: one still booting, or any +// create/reset/destroy job, whose sandbox reads "stopped" while its job runs. +// The wait ends on its own, which is what separates it from a side whose +// sandboxes have all failed or been stopped — those need the user first. +// +// Its own module rather than part of side.ts: that file reaches into the tabs +// context for tab keys, which pulls the router in and cannot be imported from +// a unit test. Nothing here needs more than the sandbox list. +export function workspaceWaiting(side: WorkspaceSide, state: SandboxesState | undefined) { + const sandboxes = state?.sandboxes ?? [] + if (side !== "work" || !sandboxes.length) return false + return sandboxes.some((item) => item.runtime.kind === "starting") || !!state?.jobs.length +}