diff --git a/CHANGELOG.md b/CHANGELOG.md index 230eeb8bdd..2d00bacf73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -174,6 +174,18 @@ already in use` and no way forward short of finding the file in a temporary ### Desktop / Web UI +- **Code's tab strip shows one project at a time** — the per-folder tab lists + that **Work** already had now apply to **Code** too: the strip across the top + shows the tabs of the project you are in, keyed by server and folder, instead + of every Code tab from every server in one long row. Clicking a project in + the sidebar goes to the session you were last in there — else its open draft, + an open tab, the project's most recent session, and finally a new one — and + the **+** button, `Ctrl/Cmd+1…9`, next/previous-tab and close-tab all act + within the project on screen. A deep link or a jump from the command palette + pulls the strip to its own project, so the tab you land on is always visible. + Switching to Code now also lands on its last-used project rather than + whichever one happened to be listed first. Only the desktop app changes; the + web UI has no Code/Work split and keeps its single tab list. - **Switching to Code brings Code's tabs back** — once you had visited **Work**, the tab strip stayed pinned to the sandbox folder you left: switching to **Code** kept Work's tabs across the top of a Code session, diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 43f15bb166..da295e8bf5 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -215,7 +215,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 Work 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 the Work side keeps one tab strip per **mounted folder** of a sandbox rather than one for the whole side. A sandbox exposes its project folder plus every extra mount as its own project (fork-only `src/sandbox/projects.ts`), so the strip is keyed by (sandbox, folder). 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 Work and calls `enterFolder`. The scope is **derived**, never stored: the folder on screen wins, then the sidebar's selected project, then the sandbox's primary folder from `sync.data.path.directory` — 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 sandbox 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. | +| 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. | | 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. | diff --git a/packages/app/src/pages/home-model.tsx b/packages/app/src/pages/home-model.tsx index 8dfaecf33b..3beae5f32d 100644 --- a/packages/app/src/pages/home-model.tsx +++ b/packages/app/src/pages/home-model.tsx @@ -28,6 +28,7 @@ import { toggleHomeProjectSelection, } from "@/pages/layout/helpers" import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" +import { usePlatform } from "@/context/platform" import { pathKey } from "@/utils/path-key" import { showToast } from "@/utils/toast" import { sideForServerKey } from "@/workspace/side" @@ -109,6 +110,7 @@ export function createHomeModel() { const notification = useNotification() const pickDirectory = useDirectoryPicker() const pinned = usePinnedSessions() + const platform = usePlatform() const folder = useWorkspaceFolder() const side = useWorkspaceSide() @@ -276,11 +278,14 @@ export function createHomeModel() { .some((project) => project.worktree === directory) ) return - // Fork: on Work a project is a mounted folder of a sandbox, and it owns a - // tab strip of its own, so selecting it is a move rather than a highlight: + // Fork: with the desktop's Code/Work split every project owns a tab strip + // of its own — a mounted folder of a sandbox on Work, a project of a + // server on Code — so selecting it is a move rather than a highlight: // select instead of toggling (a cleared directory would silently send the - // strip back to the primary folder) and go where that folder was left. - if (sideForServerKey(key) === "work") { + // strip to the side's default folder) and go where that folder was left. + // On web the strip is upstream's one undivided list, so a click keeps + // upstream's toggle. + if (platform.sandboxes) { setSelection({ server: key, directory }) // The selection is what the sidebar renders, so it moves now; where the // folder sends you is settled a beat later, once its candidates have been diff --git a/packages/app/src/workspace/side.ts b/packages/app/src/workspace/side.ts index ffea5c3a96..3e28c1a591 100644 --- a/packages/app/src/workspace/side.ts +++ b/packages/app/src/workspace/side.ts @@ -77,39 +77,48 @@ export function tabsForScope(tabs: Tab[], scope: TabScope) { // cannot be driven from the web e2e harness, and this decision chain is the // part worth pinning down. // -// The previous scope matters in exactly one case: on Work, with the selected -// sandbox's connection missing — a restart to apply a mounted folder — the -// strip holds the folder scope it had rather than widening to every folder of -// the sandbox and narrowing again. The hold is gated on the side still being -// Work: the sandbox also resolves to nothing once the user has switched to -// Code, and holding there pinned the sandbox's folder scope under Code — the -// strip went on showing Work's tabs while every Code tab was filtered out, and -// the + button opened drafts in the sandbox instead of the project on screen. +// Both sides scope the strip to one folder of the sidebar's selected server — +// a mounted folder of one sandbox on Work, a project of one server on Code. +// The caller passes no `selected` on the web build, whose strip stays +// upstream's single list. +// +// The previous scope matters in exactly one case: the selected server's +// connection is missing — a sandbox restarting to apply a mounted folder — +// and the strip holds the folder scope it had rather than widening to the +// whole side and narrowing again. The hold is gated on the previous scope's +// server belonging to the side on screen: the selection also resolves to +// nothing right after a side switch, and every existing sandbox is retained, +// so an ungated hold pinned the sandbox's folder scope under Code — the strip +// went on showing Work's tabs while every Code tab was filtered out. export function nextTabScope(input: { side: WorkspaceSide previous: TabScope | undefined - // Sandboxes that exist whether or not they are connected right now; the - // hold is only for a server that is coming back, never one that was removed. + // Servers that exist whether or not they are connected right now; the hold + // is only for a server that is coming back, never one that was removed. retained: string[] - // The selected sandbox with its folder list and the folder to scope to, - // resolved by the caller; absent while its connection is away or before one - // is selected. - sandbox?: { server: ServerConnection.Key; directory?: string; folders: string[] } + // The selected server with its folder list and the folder to scope to, + // resolved by the caller; absent while its connection is away, before one + // is selected, and always on web. + selected?: { server: ServerConnection.Key; directory?: string; folders: string[] } }): TabScope { - if (input.side !== "work") return { type: "side", side: input.side } - if (!input.sandbox) { - if (input.previous?.type === "folder" && input.retained.includes(input.previous.server)) return input.previous - // No sandbox selected yet — one is still starting, or the last was + if (!input.selected) { + if ( + input.previous?.type === "folder" && + sideForServerKey(input.previous.server) === input.side && + input.retained.includes(input.previous.server) + ) + return input.previous + // Nothing selected yet — a sandbox still starting, or the last one // removed. The side scope keeps the old behaviour for a state the sidebar // already reports through WorkspaceEmpty. return { type: "side", side: input.side } } - if (!input.sandbox.directory) return { type: "side", side: input.side } + if (!input.selected.directory) return { type: "side", side: input.side } return { type: "folder", - server: input.sandbox.server, - directory: input.sandbox.directory, - folders: input.sandbox.folders, + server: input.selected.server, + directory: input.selected.directory, + folders: input.selected.folders, } } @@ -148,10 +157,9 @@ export async function folderEntry(input: { // A folder can hold open session tabs and still have no recorded history — // every folder does the first time it is opened after per-folder strips // shipped. Landing on a tab that is already there beats minting an empty - // draft beside it. Only offered for Work: on Code this step would change the - // side switch's long-standing behaviour. The whole list rather than the first - // match, because a folder left over from a reset sandbox holds nothing but - // dead tabs and the live one can be anywhere in it. + // draft beside it. The whole list rather than the first match, because a + // folder left over from a reset sandbox holds nothing but dead tabs and the + // live one can be anywhere in it. for (const tab of input.openSessions ?? []) { if (tab.type !== "session") continue if (await input.exists(tab.sessionId)) return { type: "tab" as const, tab } diff --git a/packages/app/src/workspace/tabs.tsx b/packages/app/src/workspace/tabs.tsx index b59f901a1d..4110f8daed 100644 --- a/packages/app/src/workspace/tabs.tsx +++ b/packages/app/src/workspace/tabs.tsx @@ -44,6 +44,20 @@ export function primaryFolder(ctx: FolderServerCtx) { return ctx.sync.data.path.directory || ctx.sync.data.path.worktree || undefined } +// The folder a server's strip scopes to when neither the screen nor the +// sidebar picks one. A sandbox owns the folder it was created with; a Code +// server has no such folder, so its last-used project stands in, then its +// first. The last-used worktree is resolved through the folder list because +// it can name a project that has since been closed. +function defaultFolder( + side: WorkspaceSide, + ctx: FolderServerCtx & { projects: { last: () => string | undefined } }, + folders: string[], +) { + if (side === "work") return primaryFolder(ctx) + return folderForDirectory(ctx.projects.last(), folders) ?? folders[0] +} + // 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() { @@ -77,7 +91,7 @@ export function useWorkspaceFolder() { lastSession: layout.home.folderSession(server, directory), serverExists: !!conn, openDraft: findOpenDraft(tabs.store, { server, directory }), - openSessions: conn && sideForServer(conn) === "work" ? openSessionsIn(conn, server, directory) : undefined, + openSessions: conn ? openSessionsIn(conn, server, directory) : undefined, exists: (sessionId) => (conn ? sessionAlive(conn, sessionId) : Promise.resolve(false)), recentSession: () => (conn ? recentFolderSession(conn, directory) : Promise.resolve(undefined)), }) @@ -124,7 +138,7 @@ export function useWorkspaceFolder() { // The session tabs that resolve to exactly this folder, in strip order. // Resolved through the same folders list the strip uses, so the tab landed on // is always one the strip will be showing. Deliberately skips tabs whose - // folder is unknown — those are visible in every folder of the sandbox, so + // folder is unknown — those are visible in every folder of their server, so // picking one could send you to a tab that belongs elsewhere the moment it // resolves. const openSessionsIn = (conn: ServerConnection.Any, server: ServerConnection.Key, directory: string) => { @@ -141,45 +155,50 @@ export function useWorkspaceFolder() { return { enterFolder, sessionAlive } } -// What the tab strip shows. Code is the whole side, unchanged. Work narrows to -// one mounted folder of one sandbox, resolved from the folder on screen first, -// so the strip always contains the session you are looking at: a deep link, a +// What the tab strip shows. Each side narrows to one folder of the sidebar's +// selected server — a mounted folder of one sandbox on Work, a project of one +// server on Code — resolved from the folder on screen first, so the strip +// always contains the session you are looking at: a deep link, a // command-palette jump or a reopened tab pulls the strip to its own folder // instead of leaving the active tab out of view. Only when nothing is on screen -// does the sidebar's selected project decide, and failing that the sandbox's -// primary folder — the strip is never unscoped. +// does the sidebar's selected project decide, and failing that the side's +// default folder — the strip is never unscoped. On web, which has no Code/Work +// split, the strip stays upstream's one list for everything. export function useWorkspaceScope(routeDirectory: () => string | undefined): Accessor { const layout = useLayout() const global = useGlobal() const server = useServer() + const platform = usePlatform() const side = useWorkspaceSide() - const sandbox = createMemo(() => { - if (side() !== "work") return + const selected = createMemo(() => { + // Sandboxes are desktop-only; without them there is no Code/Work split and + // the strip keeps upstream's behaviour, one list for every server. + if (!platform.sandboxes) return const key = layout.home.selection().server const conn = global.servers.list().find((item) => ServerConnection.key(item) === key) - if (!conn || sideForServer(conn) !== "work") return + if (!conn || sideForServer(conn) !== side()) return return { key: ServerConnection.key(conn), ctx: global.ensureServerCtx(conn) } }) // The decision lives in nextTabScope (side.ts) with the tests: it holds a - // restarting sandbox's folder scope still, but only while the side is still - // Work — sandbox() is also empty after a switch to Code, and holding there - // kept Work's tabs on the strip while every Code tab was filtered out. + // restarting sandbox's folder scope still, but only while its side is still + // showing — selected() is also empty right after a side switch, and holding + // there kept Work's tabs on the strip while every Code tab was filtered out. const scope = createMemo((previous) => { - const current = sandbox() + const current = selected() const folders = current ? current.ctx.projects.list().map((project) => project.worktree) : [] return nextTabScope({ side: side(), previous, retained: server.retained, - sandbox: current && { + selected: current && { server: current.key, folders, directory: folderForDirectory(routeDirectory(), folders) ?? folderForDirectory(layout.home.selection().directory, folders) ?? - primaryFolder(current.ctx), + defaultFolder(side(), current.ctx, folders), }, }) }) @@ -246,16 +265,16 @@ export function useWorkspaceSwitch() { // neither, the home screen is the only honest destination — that side has // nothing to open yet. // - // Work resolves the sandbox's own project folder, so switching to Work - // lands on the same folder its strip will be scoped to. Code keeps its - // first-project fallback. - // Unless that folder is a placeholder the app made up, which is hidden - // everywhere else and would be a strange place to land: fall back to a - // mounted folder, and to the home screen — where the prompt to mount one - // lives — when the user has not mounted any yet. + // Work resolves the sandbox's own project folder and Code its last-used + // project, so switching sides lands on the same folder the strip will be + // scoped to (defaultFolder, one level up the same chain). + // Unless Work's own folder is a placeholder the app made up, which is + // hidden everywhere else and would be a strange place to land: fall back + // to a mounted folder, and to the home screen — where the prompt to mount + // one lives — when the user has not mounted any yet. const ctx = global.ensureServerCtx(target) - const own = side === "work" && !isPlaceholderSandbox(target) - const directory = own ? primaryFolder(ctx) : ctx.projects.list()[0]?.worktree + const folders = ctx.projects.list().map((project) => project.worktree) + const directory = side === "work" && isPlaceholderSandbox(target) ? folders[0] : defaultFolder(side, ctx, folders) if (!directory) { navigate("/") return diff --git a/packages/app/test-browser/workspace-side.test.ts b/packages/app/test-browser/workspace-side.test.ts index 2cf4c456ff..a282649ca9 100644 --- a/packages/app/test-browser/workspace-side.test.ts +++ b/packages/app/test-browser/workspace-side.test.ts @@ -252,19 +252,26 @@ test("a folder holding an unresolved tab still reorders", () => { ]) }) -const selectedSandbox = (server: string, directory?: string, folders = FOLDERS) => - ({ server, directory, folders }) as Parameters[0]["sandbox"] - -// The regression: every sandbox that exists is retained, and the sandbox -// resolver is empty off Work — so the restart hold below also fired on a plain -// side switch, pinning the Work folder scope under Code. The strip went on -// showing the sandbox's tabs, every Code tab was filtered out, and + opened -// drafts in the sandbox. -test("switching to Code scopes the strip to Code, whatever Work left behind", () => { +const selectedServer = (server: string, directory?: string, folders = FOLDERS) => + ({ server, directory, folders }) as Parameters[0]["selected"] + +// The regression: every sandbox that exists is retained, and the selection +// resolver is empty right after a side switch — so the restart hold below also +// fired on a plain switch to Code, pinning the Work folder scope there. The +// strip went on showing the sandbox's tabs, every Code tab was filtered out, +// and + opened drafts in the sandbox. +test("switching to Code never keeps a Work folder scope", () => { const previous = folderScope("sandbox:a", "/workspace") expect(nextTabScope({ side: "code", previous, retained: ["sandbox:a"] })).toEqual({ type: "side", side: "code" }) }) +// The same gate from the other direction: a hold is only ever for a scope +// whose server belongs to the side on screen. +test("switching to Work never keeps a Code folder scope", () => { + const previous = folderScope("sidecar", "/home/dev/api") + expect(nextTabScope({ side: "work", previous, retained: ["sidecar"] })).toEqual({ type: "side", side: "work" }) +}) + // The case the hold is for: a sandbox restarting to apply a mounted folder // leaves the connection list for a second or two while the user stays on Work. test("on Work a restarting sandbox keeps the folder scope it had", () => { @@ -284,22 +291,41 @@ test("a selected sandbox scopes the strip to its folder", () => { side: "work", previous, retained: ["sandbox:a", "sandbox:b"], - sandbox: selectedSandbox("sandbox:b", "/mnt/shared"), + selected: selectedServer("sandbox:b", "/mnt/shared"), }), ).toEqual(folderScope("sandbox:b", "/mnt/shared")) }) -test("a sandbox with no resolvable folder falls back to the whole side", () => { +// Code scopes the same way Work does: one project of the selected server. +test("a selected Code server scopes the strip to one of its projects", () => { + const folders = ["/home/dev/api", "/home/dev/web"] + expect( + nextTabScope({ + side: "code", + previous: undefined, + retained: [], + selected: selectedServer("sidecar", "/home/dev/api", folders), + }), + ).toEqual(folderScope("sidecar", "/home/dev/api", folders)) +}) + +test("a server with no resolvable folder falls back to the whole side", () => { expect( nextTabScope({ side: "work", previous: undefined, retained: ["sandbox:a"], - sandbox: selectedSandbox("sandbox:a", undefined, []), + selected: selectedServer("sandbox:a", undefined, []), }), ).toEqual({ type: "side", side: "work" }) }) +// The web build resolves no selection at all, so its strip stays upstream's +// single list whatever server is retained or was shown before. +test("with nothing selected and nothing retained the side is the scope", () => { + expect(nextTabScope({ side: "code", previous: undefined, retained: [] })).toEqual({ type: "side", side: "code" }) +}) + // Every session the chain can reach still exists unless a test says otherwise, // and the folder has no other session to fall back to — the arrangement every // case below varies one piece of.