From 4c8ef43ab1d46cbf42b6db5f7dac6659043a3eee Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Sat, 22 Aug 2026 17:18:09 +0700 Subject: [PATCH 1/5] feat(app): show what the Work side is waiting on while a sandbox starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Work answered a wait that can run for minutes with one static sentence — "Your sandbox is not running yet. It appears here once it is ready." — so a download, an unpack, a boot and a reset all looked identical, and identical to a side that was simply empty. Both halves of the screen now show the loading treatment the rest of the app uses. The sidebar's empty state gets placeholder rows sized to HomeProjectRow under its status line; the session list swaps its "Nothing here yet / create a session" fallback for the same HomeSessionSkeleton it already shows while a server's sessions load. Code is untouched: the wait is undefined there, so upstream's empty state still renders. The status line says what is happening rather than that something is: sandboxCreatePhase (the Add sandbox dialog's own phase logic, reused so the two can never disagree) splits the image download from the unpack behind it, so it counts MB while bytes arrive and then names the job. A destroy keeps its line but loses the placeholders — nothing arrives at the end of it — and the offer to add a sandbox is hidden while one is already being created, since that sandbox is not in the list until it finishes. The derivation moves to a fork-only workspace/empty-state.ts: side.ts imports the tabs context, which pulls in the router and cannot be loaded from a unit test, and this logic is worth pinning down. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 12 +++ DIVERGENCES.md | 1 + packages/app/src/i18n/en.ts | 2 +- packages/app/src/i18n/es.ts | 2 +- packages/app/src/pages/home.tsx | 33 ++++++- .../app/src/workspace/empty-state.test.ts | 93 +++++++++++++++++++ packages/app/src/workspace/empty-state.ts | 84 +++++++++++++++++ packages/app/src/workspace/side.ts | 26 ------ packages/app/src/workspace/tabs.tsx | 20 +++- 9 files changed, 241 insertions(+), 32 deletions(-) create mode 100644 packages/app/src/workspace/empty-state.test.ts create mode 100644 packages/app/src/workspace/empty-state.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index de7fabf0f3..bdccc2d2fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,18 @@ file is the human-readable summary of what changed and why. ### Sandbox +- **Work says what your sandbox is waiting on** — while a sandbox came up, the + Work side said "Your sandbox is not running yet. It appears here once it is + ready." and left it there, a line of static text that looked the same after + ten seconds as after ten minutes. Both halves of the screen now show the + loading treatment the rest of the app uses: placeholder rows where the + projects and sessions will appear, above a line that says what is actually + happening — the image download counted out in MB while it runs, then + creating, resetting or starting. Removing a sandbox says so too, without the + placeholders, since nothing is arriving at the end of it. The offer to add a + sandbox no longer sits under the progress line for the one already being + created. + ([#PR](https://github.com/quickbeard/codev-code/pull/PR)) - **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..0d12d99ce4 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` | `home.tsx` gains `useWorkspaceSide()` + `useSandboxes()` and a `workspaceWait()` memo over the fork-only `packages/app/src/workspace/empty-state.ts`; the session list's empty fallback renders `HomeSessionSkeleton` with that wait's label in place of `HomeSessionsEmpty` whenever it is set (Code never sets it, so upstream's empty state is untouched there). i18n: `workspace.empty.starting` is reworded from a two-sentence explanation to the label "Starting your sandbox…" — it now heads a skeleton rather than standing alone. The rest of the feature is fork-only: `empty-state.ts` holds the derivation (job before starting runtime, `sandboxCreatePhase` for the download/unpack split, no loading treatment for a destroy) and `workspace/tabs.tsx` renders the sidebar half. | The Work side answered a minutes-long wait with one static sentence: an image download, an unpack, a boot and a reset all looked identical, and identical to a side that was simply empty. The sidebar and the session list are the two places that wait, so both show what the rest of the app shows while it loads. Reusing `HomeSessionSkeleton` rather than adding a second skeleton keeps Work's wait and Code's session load visually the same event. | Keep the `workspaceWait()` memo and the extra `Show` around upstream's `HomeSessionsEmpty` fallback; take upstream's surrounding changes. If upstream restructures the empty branch, re-apply the wait as the outer fallback — `HomeSessionsEmpty` must stay reachable for Code. The i18n key keeps its name; if upstream ever ships a string there, ours wins (it is a fork-only key). | | 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..8fdcaf7c2f 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -385,7 +385,7 @@ export const dict = { "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.", + "workspace.empty.starting": "Starting your sandbox…", "workspace.empty.stopped": "Your sandbox is stopped. Start it again from Settings → Servers.", "workspace.empty.failed": "Your sandbox failed to start.", "workspace.empty.failed.retry": "Try again", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 5631fcba99..7ee01ecb59 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -376,7 +376,7 @@ export const dict = { "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.", + "workspace.empty.starting": "Iniciando tu sandbox…", "workspace.empty.stopped": "Tu sandbox está detenido. Vuelve a iniciarlo desde Ajustes → Servidores.", "workspace.empty.failed": "Tu sandbox no se pudo iniciar.", "workspace.empty.failed.retry": "Reintentar", diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 914133327d..5c97ef5ab2 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 { workspaceEmptyState } from "@/workspace/empty-state" 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,14 @@ export function NewHome() { const records = model.records const sessionLoad = model.sessionLoad + // Fork: what the Work side is waiting on, undefined whenever nothing is on + // its way — including all of Code, where this page is never empty for a + // reason the user has to wait out. + const workspaceWait = createMemo(() => { + const empty = workspaceEmptyState(workspaceSide(), sandboxes.state.data) + return empty.loading ? empty : undefined + }) + const search = createMemo(() => state.search.trim()) const searchPlaceholder = createMemo(() => { const project = selectedProject() @@ -471,7 +484,25 @@ export function NewHome() { > 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. Show what it + // is waiting on instead, in the same skeleton this page + // already uses while a server's sessions load. + + } + > + {(wait) => ( +
+ +
+ )} +
+ } >
{/* Both levels iterate ids, not the objects themselves. diff --git a/packages/app/src/workspace/empty-state.test.ts b/packages/app/src/workspace/empty-state.test.ts new file mode 100644 index 0000000000..059e7d5b5c --- /dev/null +++ b/packages/app/src/workspace/empty-state.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test" +import type { SandboxesState, SandboxItem, SandboxJob } from "@/sandbox/types" +import { workspaceEmptyState } from "./empty-state" + +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("what the Work side is waiting on", () => { + test("has nothing to wait for on Code, or before a sandbox exists", () => { + expect(workspaceEmptyState("code", state())).toEqual({ message: "workspace.empty.none" }) + expect(workspaceEmptyState("work", state({ sandboxes: [] }))).toEqual({ message: "workspace.empty.none" }) + expect(workspaceEmptyState("work", undefined)).toEqual({ message: "workspace.empty.none" }) + }) + + test("loads while a sandbox boots", () => { + expect(workspaceEmptyState("work", state())).toEqual({ message: "workspace.empty.starting", loading: true }) + }) + + test("counts the image download in MB", () => { + expect( + workspaceEmptyState( + "work", + state({ jobs: [job({ progress: { bytes: 120 * 1024 * 1024, totalBytes: 900 * 1024 * 1024 } })] }), + ), + ).toEqual({ message: "sandbox.add.downloadProgress", values: { done: 120, total: 900 }, loading: true }) + }) + + // Progress pins at its total for the whole unpack, which is the slow half of + // a create: claiming to still be downloading there is the bug create-phase.ts + // exists to prevent. + test("moves off the download line once every byte has arrived", () => { + expect(workspaceEmptyState("work", state({ jobs: [job({ progress: { bytes: 100, totalBytes: 100 } })] }))).toEqual({ + message: "sandbox.job.create", + loading: true, + }) + }) + + test("names the job it is waiting on", () => { + expect(workspaceEmptyState("work", state({ jobs: [job()] }))).toEqual({ + message: "sandbox.job.create", + loading: true, + }) + expect(workspaceEmptyState("work", state({ jobs: [job({ kind: "reset" })] }))).toEqual({ + message: "sandbox.job.reset", + loading: true, + }) + expect(workspaceEmptyState("work", state({ jobs: [job({ status: "queued" })] }))).toEqual({ + message: "sandbox.job.queued", + loading: true, + }) + }) + + // Nothing arrives when the last sandbox is being removed, so the placeholder + // rows would be promising a list that is on its way out. + test("does not load out a sandbox that is being removed", () => { + expect(workspaceEmptyState("work", state({ jobs: [job({ kind: "destroy" })] }))).toEqual({ + message: "sandbox.job.destroy", + }) + }) + + // A job is the more specific answer, and a sandbox being reset reads + // "stopped" while its job runs — so the job has to win either way. + test("prefers the running job to the runtime behind it", () => { + expect( + workspaceEmptyState("work", { sandboxes: [sandbox({ kind: "stopped" })], jobs: [job({ kind: "reset" })] }), + ).toEqual({ message: "sandbox.job.reset", loading: true }) + }) + + test("does not load on a wait only the user can end", () => { + expect( + workspaceEmptyState("work", state({ sandboxes: [sandbox({ kind: "failed", message: "no kernel" })] })), + ).toEqual({ message: "workspace.empty.failed", failure: { id: "sandbox:work", message: "no kernel" } }) + expect(workspaceEmptyState("work", state({ sandboxes: [sandbox({ kind: "stopped" })] }))).toEqual({ + message: "workspace.empty.stopped", + }) + }) +}) diff --git a/packages/app/src/workspace/empty-state.ts b/packages/app/src/workspace/empty-state.ts new file mode 100644 index 0000000000..41dc29549c --- /dev/null +++ b/packages/app/src/workspace/empty-state.ts @@ -0,0 +1,84 @@ +import { sandboxCreatePhase } from "@/sandbox/create-phase" +import type { SandboxesState, SandboxJob } from "@/sandbox/types" +import type { WorkspaceSide } from "./side" + +// 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 type WorkspaceEmptyState = { + message: + | "workspace.empty.none" + | "workspace.empty.starting" + | "workspace.empty.failed" + | "workspace.empty.stopped" + | "sandbox.job.queued" + | "sandbox.job.create" + | "sandbox.job.destroy" + | "sandbox.job.reset" + | "sandbox.add.downloadProgress" + // Interpolation for the download line's MB counts; absent for every other + // message, all of which are plain sentences. + values?: { done: number; total: number } + // The wait ends on its own, so the side shows a loading treatment rather than + // an explanation of why it is empty. Never set on a failed or stopped side: + // those need the user before anything else happens. + loading?: true + failure?: { id: string; message: string } +} + +// What the Work empty state says while the side has no ready sandbox. A +// sandbox still coming up wins — a starting runtime, or any create/reset job, +// whose runtime reads "stopped" while it runs — because one sandbox on its way +// is enough for the wait message to be true. Only then is a failed sandbox +// called out, with its runtime message so the user is not left waiting on a +// boot that already gave up; a side whose sandboxes are all deliberately +// stopped is the fallback. +// +// A job is read before a starting runtime because it is the more specific +// answer: creating a sandbox downloads an image for minutes, and saying so +// beats a boot message that never seems to end. +export function workspaceEmptyState(side: WorkspaceSide, state: SandboxesState | undefined): WorkspaceEmptyState { + const sandboxes = state?.sandboxes ?? [] + if (side !== "work" || !sandboxes.length) return { message: "workspace.empty.none" } + // Jobs run one at a time across every sandbox and the running one comes + // first, so the head of the list is what the side is actually waiting on. + const job = state?.jobs[0] + // Removing the last sandbox is the one job with nothing arriving at the end + // of it: it says what is happening, without the placeholders that promise + // something is on its way. + if (job?.kind === "destroy") return jobStatus(job) + if (job) return { ...jobStatus(job), loading: true } + if (sandboxes.some((item) => item.runtime.kind === "starting")) + return { message: "workspace.empty.starting", loading: true } + const failure = sandboxes.flatMap((item) => + item.runtime.kind === "failed" ? [{ id: item.config.id, message: item.runtime.message }] : [], + )[0] + if (failure) return { message: "workspace.empty.failed", failure } + return { message: "workspace.empty.stopped" } +} + +const JOB_MESSAGE = { + create: "sandbox.job.create", + destroy: "sandbox.job.destroy", + reset: "sandbox.job.reset", +} as const + +// The same phase the Add sandbox dialog reports, so a create watched from the +// sidebar and a create watched from its dialog never disagree — including the +// rule that progress pinned at its total is an unpack, not a download. +function jobStatus(job: SandboxJob): { + message: WorkspaceEmptyState["message"] + values?: { done: number; total: number } +} { + const phase = sandboxCreatePhase({ adding: true, job }) + if (phase === "queued") return { message: "sandbox.job.queued" } + if (phase === "downloading" && job.progress) + return { + message: "sandbox.add.downloadProgress", + values: { + done: Math.round(job.progress.bytes / 1024 / 1024), + total: Math.round(job.progress.totalBytes / 1024 / 1024), + }, + } + return { message: JOB_MESSAGE[job.kind] } +} diff --git a/packages/app/src/workspace/side.ts b/packages/app/src/workspace/side.ts index a99fa1b782..51c695cea4 100644 --- a/packages/app/src/workspace/side.ts +++ b/packages/app/src/workspace/side.ts @@ -1,6 +1,5 @@ import { ServerConnection } from "@/context/server" import { tabKey, type Tab } from "@/context/tabs" -import type { SandboxesState } from "@/sandbox/types" import { pathKey } from "@/utils/path-key" // The desktop app is split into two workspaces: Code, everything that runs on @@ -251,31 +250,6 @@ export function leavesWorkEmpty(sandboxes: { config: { id: string } }[], removed return sideForServerKey(removed) === "work" && !sandboxes.some((item) => item.config.id !== removed) } -// What the Work empty state says while the side has no ready sandbox. A -// sandbox still coming up wins — a starting runtime, or any create/reset job, -// whose runtime reads "stopped" while it runs — because one sandbox on its way -// is enough for the wait message to be true. Only then is a failed sandbox -// called out, with its runtime message so the user is not left waiting on a -// boot that already gave up; a side whose sandboxes are all deliberately -// stopped is the fallback. -export function workspaceEmptyState( - side: WorkspaceSide, - state: SandboxesState | undefined, -): { - message: "workspace.empty.none" | "workspace.empty.starting" | "workspace.empty.failed" | "workspace.empty.stopped" - failure?: { id: string; message: string } -} { - 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" } - const failure = sandboxes.flatMap((item) => - item.runtime.kind === "failed" ? [{ id: item.config.id, message: item.runtime.message }] : [], - )[0] - if (failure) return { message: "workspace.empty.failed", failure } - return { message: "workspace.empty.stopped" } -} - // 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 170476f819..ce32e5d364 100644 --- a/packages/app/src/workspace/tabs.tsx +++ b/packages/app/src/workspace/tabs.tsx @@ -25,10 +25,10 @@ import { sideForServer, WORKSPACE_SIDES, workSideAvailable, - workspaceEmptyState, type TabScope, type WorkspaceSide, } from "./side" +import { workspaceEmptyState } from "./empty-state" // The slice of a server ctx these read, structural for the same reason // src/sandbox/projects.ts is: it keeps the folder logic testable without @@ -374,7 +374,18 @@ export function WorkspaceEmpty(props: { side: WorkspaceSide }) { return (
-

{language.t(empty().message)}

+

{language.t(empty().message, empty().values)}

+ {/* Placeholder rows for the projects that are on their way, so a sandbox + coming up reads like the rest of the app loading rather than like a + side with nothing in it. Sized to HomeProjectRow, which is what + replaces them. */} + +