diff --git a/CHANGELOG.md b/CHANGELOG.md index 14eb2b8b59..b5551e71b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -264,6 +264,16 @@ already in use` and no way forward short of finding the file in a temporary ### Desktop / Web UI +- **The Work tabs stay out of the way until you use sandboxes** — every desktop + install showed a **Code** / **Work** switch above the projects list, even + though Work was empty for anyone who had never made a sandbox: switching to it + offered to build one and otherwise had nothing to show. The switch now appears + once you actually have a sandbox — created from Settings → Servers → **+** → + **Add sandbox** — and Work behaves exactly as before from then on. Delete your + last sandbox and it steps back out of the way again. If you want Work visible + before creating anything, Settings → General → Advanced has a **Show Work** + switch. + ([#292](https://github.com/quickbeard/codev-code/pull/292)) - **Files first in the `@` mention menu** — the composer's `@` popup listed references, agents, and MCP resources ahead of files, so the top match Enter would accept was rarely the file being typed. File results (recent open tabs diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 4d9aa7f973..27144ac401 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -219,7 +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. | | 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 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,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 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 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; 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. | | New-session project picker on Work: `packages/app/src/pages/session/composer/session-composer-controls.ts`, `packages/app/src/components/{prompt-project-selector,app-sidebar}.tsx` | Extends the Code/Work split row above to the composer's project picker. `createPromptProjectControls` indexed `server.list` in four places — the grouped project list, the two server lookups behind select/add, and the `server` field the controls hand back — so a draft on the Work side listed the local machine's projects under a "Local Server" heading beside the sandbox's own, and picking one moved the session onto that server. All four read a `sideServers()` memo instead: `serversForSide(server.list, sideForServer(projectServer()))`, from the fork-only `src/workspace/side.ts`. The side comes from the draft's **own** server (`serverSDK().server`) rather than `useWorkspaceSide()`, so a draft always offers what it can actually run on whichever side happens to be showing. Nothing else changes: with one server on the side the picker takes the same single-server branch Code has always taken (`layout.projects.list()`, or the draft's server ctx for a draft route), which drops the group headings and the "Add project" submenu the way it does for a lone local server. **Add project on Work is a mount**: `addProject` opens the fork-only `DialogSandboxMounts` for that sandbox instead of `pickDirectory`, through the fork-only `src/sandbox/mount-dialog.tsx` — upstream's picker browses the server it is pointed at, which for a sandbox is the guest's own filesystem. `PromptProjectControls` gains an optional `mount` flag and `prompt-project-selector.tsx` reads it in two label getters (`labels.add`, `labels.new`) so the item says "Mount project" (`sandbox.server.mounts`, the string the Work sidebar's button already uses); nothing else in that component changes. `app-sidebar.tsx`'s Work branch and the fork-only `mount-prompt.tsx` both route through the same hook rather than repeating the lazy import. | The two sides exist so that "am I on my machine or in a sandbox?" is a property of where you are. A picker that offers both breaks that in the one place it matters most — the moment a session is created — and the failure is silent: the session opens on the local machine with no sandbox isolation, from a screen the user reached through the Work tab. | Keep the filter and take upstream's surrounding changes. If upstream adds a fifth `server.list` read here, filter it too. Read the side from `sideForServer(projectServer())`, not `useWorkspaceSide()` — a draft route mounts its own server (`ResolvedDraftRoute` in `app.tsx`), and the sidebar's side is not guaranteed to agree with it. The `sideServers().length <= 1` branch must stay a length test on the *side* list, not on `server.list`: on a machine with one sandbox and one local server the old test sent Work down the multi-server path. | | `packages/app/src/context/server.tsx` | `createServerProjects.close()` wraps its two store writes — `remove(directory)` on `projects`, then the `recentlyClosed` record — in `batch()` (already imported). The recently-closed list is computed before the batch so the pair is one update. | The two writes are separate store keys and Solid flushes effects after each one, so between them a project is in neither list. Nothing upstream observes that gap, but the fork's `src/sandbox/projects.ts` seeding effect reconciles against **both** lists, saw a mounted folder that was neither open nor closed, and opened it straight back up — closing a Work folder was undone the instant it happened. | Keep the batch. On conflict take upstream's `close()` and re-wrap the two writes. Any future write pair that spans `projects` and `recentlyClosed` needs the same treatment; regression cover is `packages/app/test-browser/sandbox-projects.test.ts` ("closing a mounted folder sticks when the guest knows the project"). | @@ -283,6 +283,8 @@ from the squash message first, or use a plain merge. | `packages/session-ui/src/v2/components/prompt-input/interaction.ts` | The `@` mention popup's `contextList` grouping delegates to the fork-only `at-options.ts` beside it (`atSuggestionCategory` + `compareAtCategories`, unit-tested in the fork-only `at-options.test.ts`): suggestion groups rank recent → file → reference → agent → resource instead of upstream's reference → agent → resource → recent → file, so file results lead in the V2 composer — the prompt the desktop and web apps render now that the legacy layout is retired. The grouped menu scrolls, so no row-reserve logic is needed (unlike the TUI/run-footer variants), and default selection stays index 0, so Enter accepts the top file match. | Files are the dominant `@` use case; the earlier files-first reorder (see the `@` autocomplete row above) covered the TUI, the legacy web prompt, and the `codev run` footer but missed the V2 composer. | Keep the two-line delegation to `at-options.ts`; take upstream's surrounding changes. If upstream restructures the suggestion list, re-apply the ranking wherever context suggestions are grouped. | | Legacy prompt input removed: deleted `packages/app/src/components/prompt-input.tsx` and its now-orphaned submodules `packages/app/src/components/prompt-input/{attachments,files,paste,transient-state}.ts`, `{context-items,drag-overlay,image-attachments,slash-popover}.tsx`, `image-attachments.css`, `attachments.test.ts`, `packages/app/test-browser/{prompt-attachments,prompt-transient-state,prompt-at-options}.test.ts` (plus the fork-only `at-options{,.test}.ts`); edits in `packages/app/src/pages/session.tsx`, `packages/app/src/pages/session/composer/{session-composer-region-controller.ts,todo-panel-motion.stories.tsx}` | The fork hardcodes `newLayoutDesigns` to `true` (see the settings.tsx row), so the legacy `PromptInput` — the `fallback` of session.tsx's `` — could never render. session.tsx now creates the V2 controller unconditionally and always mounts `PromptInputV2Composer` (no `Show`); `session-composer-region-controller.ts` imports `PromptInputState` from `prompt-input/contracts` (where it is defined) instead of the deleted barrel; the todo-panel-motion story fills its composer-region `promptInput` slot with a static styled placeholder instead of the legacy component (its unused `controls` mock removed). Kept: `prompt-input/{contracts,history,history-store,placeholder,submit,editor-dom,build-request-parts,submission-state}.ts` and their tests — the V2 composer path still uses them. | Dead code: retired with the legacy layout, kept compiling for months, and PR #56-style fixes had to be applied to it for nothing. | Keep deleted — `git rm` the upstream side of modify/delete conflicts on these files, and delete any new module upstream adds under `prompt-input/` that only the legacy `PromptInput` imports. Take upstream changes to the kept shared modules. If upstream restructures session.tsx's composer block, re-apply "always render the V2 composer" and keep the legacy fallback out; if upstream rewires the story's input slot, keep our placeholder. If the fork ever wants the legacy layout back, restore from upstream, not from history. | +| Work side is opt-in: `packages/app/src/context/settings.tsx`, `packages/app/src/components/settings-v2/general.tsx`, `packages/app/src/i18n/{en,es}.ts` | The Work side and the Code/Work tab strip render only when `workSideAvailable()` (fork-only `packages/app/src/workspace/side.ts`) says so: sandbox-capable platform AND (a sandbox exists OR the new `general.showWork` preference is on). settings.tsx gains `showWork` (default `false`) with its accessor/setter beside `showCustomAgents`; settings-v2's Advanced section gains a "Show Work" switch, wrapped in `` so non-sandbox builds never offer it; i18n gains `settings.general.row.showWork.{title,description}`. The fork-only wiring is in `workspace/tabs.tsx`: a `useWorkAvailable()` hook feeds both `useWorkspaceSide()`'s `effectiveSide` call and `WorkspaceTabs`'s `` (which previously gated on `platform.sandboxes`), so the side and its strip can never disagree — a hidden strip with Work selected would strand the user on sandbox servers with no way back. An undefined sandbox list means "not loaded", not "none", and keeps Work (the same race the Code/Work split row calls out); the main process answers `sandbox-get-state` synchronously, so that window is sub-frame. Tests: `packages/app/test-browser/workspace-side.test.ts` covers the four `workSideAvailable` branches. | Sandboxes are a heavyweight opt-in feature (a VM per sandbox), but every desktop profile paid for them in the sidebar: a Code/Work strip with a Work side that had nothing in it and offered to build one. Hiding both until a sandbox exists makes the strip appear exactly when there are two sides worth picking between; the preference is the discoverable way in for someone who wants Work before creating anything, and Settings → Servers → + → Add sandbox is the other. Owning a sandbox shows Work whatever the preference says. | Keep the `workSideAvailable` gate on both `useWorkspaceSide` and `WorkspaceTabs` — gating only one reintroduces the stranded-side bug. Take upstream's surrounding settings/i18n changes. If upstream adds its own sandbox-visibility preference, prefer theirs and drop `showWork`. Never collapse on an unloaded sandbox list. | + ## Known rebrand gaps - **UI branding**: TUI banner, default-theme accent, desktop/web app names, favicon/PWA diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 928e3a6dff..c40f6d2f7f 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -367,6 +367,25 @@ export const SettingsGeneralV2: Component<{ /> + + {/* Fork: sandboxes are opt-in, so this reveals the Work side before the + first one exists. Owning a sandbox shows Work regardless, which is + why the row says "even with no sandboxes" rather than promising to + hide it. Sandbox-capable builds only — elsewhere there is no Work + side to reveal. */} + + +
+ settings.general.setShowWork(checked)} + /> +
+
+
) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index f8d3fc4dad..f5a3a9c18f 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -33,6 +33,7 @@ export interface Settings { shellToolPartsExpanded: boolean editToolPartsExpanded: boolean showCustomAgents: boolean + showWork: boolean mobileTitlebarPosition: "top" | "bottom" newLayoutDesigns?: boolean layoutTransitionEligible?: boolean @@ -147,6 +148,9 @@ const defaultSettings: Settings = { shellToolPartsExpanded: false, editToolPartsExpanded: false, showCustomAgents: false, + // Sandboxes are opt-in: a profile with none has no Work side and no + // Code/Work strip until it makes one, or turns this on to go looking. + showWork: false, mobileTitlebarPosition: "top", }, appearance: { @@ -308,6 +312,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setShowCustomAgents(value: boolean) { setStore("general", "showCustomAgents", value) }, + showWork: withFallback(() => store.general?.showWork, defaultSettings.general.showWork), + setShowWork(value: boolean) { + setStore("general", "showWork", value) + }, mobileTitlebarPosition: withFallback( () => store.general?.mobileTitlebarPosition, defaultSettings.general.mobileTitlebarPosition, diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 2dd2e3bb3b..7553a329aa 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -1036,6 +1036,9 @@ export const dict = { "settings.general.row.showCustomAgents.title": "Show agent", "settings.general.row.showCustomAgents.description": "Switch between agents in the composer. When hidden, defaults to Build agent.", + "settings.general.row.showWork.title": "Show Work", + "settings.general.row.showWork.description": + "Keep the Code/Work tabs visible even with no sandboxes. Creating a sandbox shows Work either way.", "settings.general.row.reasoningSummaries.title": "Show reasoning summaries", "settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline", "settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 3d805ba27a..86856713d7 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -951,6 +951,9 @@ export const dict = { "Colocar la barra de título y las pestañas de sesión en la parte inferior de la pantalla en dispositivos móviles", "settings.general.row.showCustomAgents.title": "Agentes personalizados", "settings.general.row.showCustomAgents.description": "Mostrar el selector de agentes en el editor", + "settings.general.row.showWork.title": "Mostrar Trabajo", + "settings.general.row.showWork.description": + "Mantener visibles las pestañas Código/Trabajo aunque no haya entornos aislados. Crear uno muestra Trabajo de todas formas.", "settings.general.row.reasoningSummaries.title": "Mostrar resúmenes de razonamiento", "settings.general.row.reasoningSummaries.description": "Mostrar resúmenes del razonamiento del modelo en la línea de tiempo", diff --git a/packages/app/src/workspace/side.ts b/packages/app/src/workspace/side.ts index 3e28c1a591..046352def1 100644 --- a/packages/app/src/workspace/side.ts +++ b/packages/app/src/workspace/side.ts @@ -12,17 +12,37 @@ 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. +// A persisted "work" from a build or a profile that has no Work side must not +// strand the sidebar there. Pass workSideAvailable()'s answer, not raw platform +// support: with Work hidden the tab strip is gone, so a stranded side would +// leave the user on sandbox servers with no way back to Code. +export function effectiveSide(side: WorkspaceSide, workAvailable: boolean): WorkspaceSide { + return workAvailable ? side : "code" +} + +// Whether the Work side exists for this user at all — it gates both the side +// itself and the Code/Work tab strip, which is furniture with only one side to +// pick. // -// 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" +// Sandboxes are opt-in: a profile that has never made one sees no Work side and +// no strip, and reaches the feature through Settings → Servers → + → Add +// sandbox (or the preference below). Owning a sandbox shows Work whatever the +// preference says — hiding the side its servers live on would strand them. +// +// `sandboxes` is undefined until the list arrives over IPC, which reads the +// same as "none" but must not be treated as one: collapsing on unknown bounced +// the sidebar to Code on every launch and in the beat after the first sandbox +// was created. Unknown keeps Work, and the real answer lands a round trip later +// — the main process answers getState() synchronously, so that window is +// sub-frame in practice. +export function workSideAvailable(input: { + sandboxesSupported: boolean + preference: boolean + sandboxes: { config: { id: string } }[] | undefined +}) { + if (!input.sandboxesSupported) return false + if (input.preference) return true + return !input.sandboxes || input.sandboxes.length > 0 } // 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 4110f8daed..a28cb9d75c 100644 --- a/packages/app/src/workspace/tabs.tsx +++ b/packages/app/src/workspace/tabs.tsx @@ -13,6 +13,7 @@ import { showToast } from "@/utils/toast" import { useSandboxes } from "@/sandbox/context" import { isPlaceholderSandbox } from "@/sandbox/projects" import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" import { effectiveSide, folderEntry, @@ -22,6 +23,7 @@ import { selectionForSide, sideForServer, WORKSPACE_SIDES, + workSideAvailable, workspaceEmptyState, type TabScope, type WorkspaceSide, @@ -58,12 +60,27 @@ function defaultFolder( return folderForDirectory(ctx.projects.last(), folders) ?? folders[0] } +// Whether this profile has a Work side at all — see workSideAvailable. Gates +// both the side and the Code/Work strip, so the two can never disagree. +export function useWorkAvailable() { + const platform = usePlatform() + const settings = useSettings() + const sandboxes = useSandboxes() + return createMemo(() => + workSideAvailable({ + sandboxesSupported: !!platform.sandboxes, + preference: settings.general.showWork(), + sandboxes: sandboxes.state.data?.sandboxes, + }), + ) +} + // 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)) + const available = useWorkAvailable() + return createMemo(() => effectiveSide(layout.home.workspace(), available())) } // Where selecting a mounted folder sends you. Its own hook rather than part of @@ -375,15 +392,16 @@ export function WorkspaceEmpty(props: { side: WorkspaceSide }) { export function WorkspaceTabs(props: { class?: string }) { const language = useLanguage() - const platform = usePlatform() const workspace = useWorkspaceSwitch() + const available = useWorkAvailable() 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. + // No Work side — web, or a desktop profile that has never made a sandbox and + // has not opted in — leaves one side to pick, and a lone "Code" tab is + // furniture. return ( - +
{ 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", () => { +// A "work" side persisted where Work is not available must not strand the +// sidebar on a side whose tab strip is hidden — there would be no way back. +test("collapses to Code where Work is unavailable", () => { expect(effectiveSide("work", true)).toBe("work") expect(effectiveSide("work", false)).toBe("code") expect(effectiveSide("code", false)).toBe("code") }) -// 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") +// Sandboxes are opt-in: a profile that has never made one gets no Work side and +// no Code/Work strip until it does, or turns the preference on. +test("hides Work for a sandbox-less profile that has not opted in", () => { + expect(workSideAvailable({ sandboxesSupported: true, preference: false, sandboxes: [] })).toBe(false) + expect(workSideAvailable({ sandboxesSupported: true, preference: true, sandboxes: [] })).toBe(true) +}) + +// Owning a sandbox shows Work whatever the preference says: hiding the side its +// servers live on would strand them. +test("shows Work once a sandbox exists, preference or not", () => { + expect( + workSideAvailable({ sandboxesSupported: true, preference: false, sandboxes: [{ config: { id: "sandbox:a" } }] }), + ).toBe(true) + expect( + workSideAvailable({ sandboxesSupported: true, preference: true, sandboxes: [{ config: { id: "sandbox:a" } }] }), + ).toBe(true) +}) + +// The web build has no sandbox platform at all, so neither the preference nor a +// stale list can conjure a Work side. +test("never shows Work where sandboxes are unsupported", () => { + expect( + workSideAvailable({ sandboxesSupported: false, preference: true, sandboxes: [{ config: { id: "sandbox:a" } }] }), + ).toBe(false) + expect(workSideAvailable({ sandboxesSupported: false, preference: false, sandboxes: undefined })).toBe(false) +}) + +// An undefined list is "not loaded yet", not "none": collapsing on it bounced +// the sidebar to Code on every launch and in the beat after the first sandbox +// was created. +test("keeps Work while the sandbox list has not arrived", () => { + expect(workSideAvailable({ sandboxesSupported: true, preference: false, sandboxes: undefined })).toBe(true) }) // Answered from the list as it stands before the removal: the sandbox list