From da5f5ffb58b8517dd42bb32f09df6007270e6572 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Fri, 14 Aug 2026 17:57:31 +0700 Subject: [PATCH 1/2] feat(app): isolate Work-side tab lists per mounted folder A sandbox can mount several folders, but every folder's sessions shared one tab strip on the Work side, so working across two of them meant reading past the other one's tabs. Mounted folders are already ordinary projects on the sandbox server (src/sandbox/projects.ts seeds one per guest path plus the primary), so the strip is now keyed by (sandbox, folder), with the sandbox's own project folder on equal footing with the extra mounts. The scope is derived, never stored: the folder on screen wins, then the sidebar's selected project, then the sandbox's primary folder. That makes "the strip always contains the session you are looking at" true by construction rather than by an effect racing the two home effects in context/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. Selecting a folder navigates to where it was left, reusing the chain the Code/Work switch already had one level down: last session, else that folder's open draft, else a new one. Closing a tab now picks its successor from the visible list, so the last tab of a folder no longer drops you into an unrelated one -- a bug that already existed across Code and Work. Also fixes a latent bug in enter(side): it read projects.list()[0] as the sandbox's folder, but projects.open() prepends, so that was whichever mount was added most recently rather than the folder the sandbox was created with. Code keeps its first-project fallback. Logic lives in the fork-only src/workspace/{side,tabs}.ts; the upstream files hold wiring only. SessionTab gains an optional directory that is deliberately absent from tabKey(), which keeps every persisted info/memory/closed key valid, and tabs that predate it recover from the info record the strip already writes. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 15 ++ DIVERGENCES.md | 2 + packages/app/src/components/titlebar.tsx | 59 ++++--- packages/app/src/context/layout.tsx | 18 ++ packages/app/src/context/tabs.tsx | 85 +++++++++- packages/app/src/pages/home-model.tsx | 16 +- packages/app/src/pages/home.tsx | 2 +- packages/app/src/workspace/side.ts | 78 ++++++++- packages/app/src/workspace/tabs.tsx | 133 +++++++++++++-- .../app/test-browser/tab-directories.test.ts | 51 ++++++ .../app/test-browser/workspace-side.test.ts | 154 +++++++++++++++++- 11 files changed, 565 insertions(+), 48 deletions(-) create mode 100644 packages/app/test-browser/tab-directories.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 015b1fd605..21293e3587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI +- **Each folder in a sandbox now has its own session tabs** — a sandbox can + mount several folders, but all of their sessions shared one tab strip on the + **Work** side, so working in two folders meant reading past the other one's + tabs. Every folder of every sandbox now keeps its own tabs, including the + project folder the sandbox was created with. Clicking a folder in the sidebar + takes you back to the session you were last in there — or to that folder's + draft, or a new one if it has no history. Tab shortcuts (`Ctrl`/`Cmd` + + number, next/previous tab, close tab) act within the folder you are looking + at, so closing the last tab in a folder no longer drops you into an unrelated + one. Two folders that happen to have the same name stay separate, and so does + the same folder mounted into two different sandboxes. **The Work strip now + shows one sandbox at a time**: while you are in one sandbox's folder, another + sandbox's tabs are not in the strip — selecting that sandbox brings them back. + Existing tabs keep their place; anything the app cannot immediately match to a + folder stays visible in that sandbox until it can. Code is unchanged. - **Adding a folder to a sandbox no longer freezes the app** — mounting a new folder on a sandbox you had a session open on locked the window up entirely: no clicks, no typing, force quit the only way out. Applying a mount restarts diff --git a/DIVERGENCES.md b/DIVERGENCES.md index f6e50021d8..a50097e21c 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -202,6 +202,8 @@ from the squash message first, or use a plain merge. | Desktop identity: `packages/desktop/src/main/{index,store}.ts`, `packages/desktop/electron-builder.config{,.test}.ts`, `packages/desktop/scripts/copy-metainfo.ts`, `packages/desktop/resources/linux/opencode-desktop.desktop` (deleted), `packages/app/src/pages/layout/{deep-links.ts,helpers.test.ts}`, `packages/codev-brand/test/brand.test.ts` | appId `ai.opencode.desktop{,.beta,.dev}` → `ai.codev.desktop{,.beta,.dev}` (both copies: `APP_IDS` in main and in the builder config, plus the unpackaged fallback); deep-link scheme `opencode://` → `codev://` in all three places that must agree — `protocols.schemes`, `app.setAsDefaultProtocolClient`, and the renderer's `parseUrl` — plus the `argv.startsWith` filter the second-instance handler uses; `rpm.packageName` `opencode`/`opencode-beta`/`opencode-dev` → `codev`/`codev-beta`/`codev-dev`; the hidden `opencode-desktop.desktop` legacy launcher and its `deb`/`rpm` fpm wiring deleted. `src/main/migrate.ts` keeps the old ids — they name the *Tauri* app's directories, not ours. | The appId is the whole desktop identity: `app.setPath("userData", join(appData, appId))`, so it also names the data directory, and it drives the macOS bundle id, the Windows AppUserModelId and NSIS keys, the Linux `.desktop` filename, `executableName` and `StartupWMClass`, and the AppStream metainfo id. Leaving it as opencode meant the fork's app identified itself as upstream to the OS. Renamed as a clean cut with no data migration and no back-compat shim, on the explicit basis that there is no installed base: existing profiles under `appData/ai.opencode.desktop*` are simply orphaned (settings, session history, sandboxes and the downloaded guest image), and a packaged update across the rename would install beside the old app rather than replace it. The legacy launcher went with it — it existed to rescue GNOME/KDE pins from a superseded packaging id, and its `Exec=/opt/OpenCode/...` no longer matched any install layout. | Keep our ids and scheme; take upstream's other config. Never rename the `TAURI_APP_IDS` in `migrate.ts` to match — a blind find-and-replace there points the Tauri migration at a directory that never existed. All three deep-link sites have to move together or links open nothing; `electron-builder.config.test.ts` covers the ids, the package names and the scheme per channel. | +| Code/Work workspace split: `packages/app/src/context/layout.tsx`, `packages/app/src/components/{app-sidebar,titlebar}.tsx`, `packages/app/src/i18n/{en,es}.ts` | The sidebar gains two tabs above Projects — **Code** (every non-sandbox server: the local sidecar, WSL, remote, SSH) and **Work** (the sandboxes) — backed by the fork-only `packages/app/src/workspace/{side.ts,tabs.tsx}`. `layout.tsx` stores `home.workspace` and `home.lastSession` (per side) and records the latter from an effect on `route()`; `app-sidebar.tsx` renders ``, swaps `global.servers.list()` for a side-filtered memo in all six places it indexed it, and adds an empty-state branch because a side can now have no servers at all; `titlebar.tsx` loses the Home button and its `home.toggle` command (`mod+b`) and filters the tab strip, its reorder and `tab.prev`/`tab.next` to the visible side; `effectiveSide` falls back to Code only where the platform has no sandbox support — deliberately not on an empty sandbox list, which is indistinguishable from one that has not loaded; `layout.tsx` also holds an effect keeping `home.selection.server` on the showing side, because a sandbox joins the server list only once it has finished starting, so the one-shot correction inside a switch cannot cover a sandbox created moments earlier; the fork-only `DialogAddSandbox` gains an `onCreated` callback so the Work gate switches on the create's own result rather than on the asynchronous sandbox list; `dialog-select-server.tsx` `handleRemove` calls `workspace.removedServer(key)` beside the tab cleanup, which steps back to Code when the sandbox being deleted is the last one; i18n gains `workspace.{code,work,empty.none,empty.starting}`. | Sandboxes were just another row in a list of servers, so the isolated environment and the local machine were one undifferentiated pile. Splitting them makes "am I running on my machine or in a sandbox?" a property of where you are rather than something to infer from a server name. The Home button went because it opened a draft rather than a home screen, which is the behaviour the tabs replace: a tab returns you to that side's last session, falling back to a draft and then to the home screen. All the logic lives in the fork-only module; the upstream files hold wiring only, and every section below the tabs is the same component reading a filtered list. | Keep the wiring and take upstream's surrounding changes. The six `sideServers()` call sites in `app-sidebar.tsx` were `global.servers.list()` — if upstream adds a seventh, filter it too or that branch will show sandboxes under Code. Read `useWorkspaceSide()` rather than `layout.home.workspace()`: it collapses to Code where the platform has no sandbox support, which is what keeps the web build coherent. The tab strip filter and `reorderWithinSide` go together — `tabs.reorder()` rejects a partial key list, so a filtered strip without the merge silently ignores drags. | +| 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; `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. | | 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}` (the last for a Work side whose sandboxes are all stopped, which unlike starting or failed will never come up on its own). | Sandboxes were just another row in a list of servers, so the isolated environment and the local machine were one undifferentiated pile. Splitting them makes "am I running on my machine or in a sandbox?" a property of where you are rather than something to infer from a server name. The Home button went because it opened a draft rather than a home screen, which is the behaviour the tabs replace: a tab returns you to that side's last session, falling back to a draft and then to the home screen. All the logic lives in the fork-only module; the upstream files hold wiring only, and every section below the tabs is the same component reading a filtered list. | Keep the wiring and take upstream's surrounding changes. The six `sideServers()` call sites in `app-sidebar.tsx` were `global.servers.list()` — if upstream adds a seventh, filter it too or that branch will show sandboxes under Code. Read `useWorkspaceSide()` rather than `layout.home.workspace()`: it collapses to Code where the platform has no sandbox support, which is what keeps the web build coherent. The tab strip filter and `reorderWithinSide` go together — `tabs.reorder()` rejects a partial key list, so a filtered strip without the merge silently ignores drags. | | `packages/app/src/context/global.tsx` | Two lines: imports `seedSandboxProjects` from the fork-only `packages/app/src/sandbox/projects.ts` and calls it with `server.list` + `ensureServerCtx`, beside the memo that already creates a ctx per connection. All the logic lives in the fork-only module. | A sandbox exposes exactly one directory (`/workspace` on vz/bwrap, the wslpath translation on WSL) and the guest registers it as its own project, but the app's per-server project list starts empty and nothing ever fills it. With no project, every "New session" entry point — `home-model.openNewSession`, the titlebar's `openNewTab` fallback chain — finds nothing for that server and falls back to another one, so the sandbox looks selected while the session runs on the local server. The seed reads `sync.data.path.directory`, which every server ctx already fetches, so it stays backend-agnostic. The same module also names that project after the sandbox's host folder (via `client.project.update`, the call behind rename-project), because the guest sees only its mount point and every sandbox would otherwise read "workspace" in the sidebar, tabs and palette. | Keep both lines; take upstream's other changes to the file. The call must stay inside the Global context init (it needs `ensureServerCtx`) — `SandboxesProvider` is mounted above `GlobalProvider` in `app.tsx`, so it cannot live there. | diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 97c70a56c0..547d0dd18b 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -31,8 +31,8 @@ import type { PromptSession } from "@/context/prompt" import type { PromptModel } from "@/context/prompt-state" import "./titlebar.css" import { newTabTooltipKeybind } from "./command-tooltip-keybind" -import { reorderWithinSide, tabsForSide } from "@/workspace/side" -import { useWorkspaceSide } from "@/workspace/tabs" +import { reorderWithinScope, tabsForScope } from "@/workspace/side" +import { useWorkspaceScope } from "@/workspace/tabs" type TauriDesktopWindow = { startDragging?: () => Promise @@ -265,11 +265,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl const tabs = useTabs() const tabsStore = tabs.store const tabsStoreActions = tabs - // Fork: Code and Work each keep their own session tabs, so the strip - // shows one side at a time. Everything else — close, reopen, the - // tab commands — still works against the whole store. - const side = useWorkspaceSide() - const sideTabs = createMemo(() => tabsForSide(tabsStore, side())) const [session] = createResource( () => { const route = layout.route() @@ -310,6 +305,18 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl const currentTab = () => matchRoute(layout.route()) + // Fork: Code and Work each keep their own session tabs, and on Work + // each mounted folder of a sandbox keeps its own, so the strip shows + // one scope at a time. Reopen and the rest of the tab commands still + // work against the whole store. + // + // routeDirectory is the folder on screen. It falls back to the + // resolving session so a tab added from a route (the effect below) + // is never briefly left out of its own strip. + const routeDirectory = () => currentTab()?.directory ?? session()?.directory + const scope = useWorkspaceScope(routeDirectory) + const scopedTabs = createMemo(() => tabsForScope(tabsStore, scope())) + createEffect(() => { const route = layout.route() if (!tabs.ready()) return @@ -323,7 +330,10 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl const s = session() if (!s) return const sessionId = s.parentID ?? s.id - const next = { server: route.server ?? server.key, sessionId } + // Fork: the directory places the tab in its folder's strip on + // Work. A child session shares its parent's directory, so this + // is right even when the tab is the parent. + const next = { server: route.server ?? server.key, sessionId, directory: s.directory } // A tab for a server that is not connected is dropped again the // moment it lands (context/tabs.tsx prunes tabs whose server // left the list), and this effect would add it straight back. @@ -377,6 +387,16 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl return } + // Fork: with a folder scope the strip already names the server and + // folder a new tab belongs to. Without this the chain below falls + // through to the local server and its first project, which on Work + // creates a tab the visible strip cannot show. + const folderScope = scope() + if (folderScope.type === "folder") { + openDraftFor({ server: folderScope.server, directory: folderScope.directory }) + return + } + if (route.type === "home") { const selection = layout.home.selection() const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server) @@ -425,7 +445,10 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl keybind: "mod+w", hidden: true, onSelect: () => { - tabsStoreActions.closeTab(tabsStore.findIndex((tab) => current === tab)) + tabsStoreActions.closeTab( + tabsStore.findIndex((tab) => current === tab), + scopedTabs(), + ) }, }, { @@ -442,10 +465,10 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl keybind: `mod+option+ArrowLeft,ctrl+shift+tab`, hidden: true, onSelect: () => { - // Cycles the visible side only: the other side's tabs are - // not on screen, so stepping onto one would look like a jump - // to nowhere. - const visible = sideTabs() + // Cycles the visible scope only — the side, or on Work the + // one mounted folder: tabs outside it are not on screen, so + // stepping onto one would look like a jump to nowhere. + const visible = scopedTabs() let index = visible.findIndex((tab) => tab === currentTab()) if (index === -1) return @@ -463,7 +486,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl keybind: `mod+option+ArrowRight,ctrl+tab`, hidden: true, onSelect: () => { - const visible = sideTabs() + const visible = scopedTabs() let index = visible.findIndex((tab) => tab === currentTab()) if (index === -1) return @@ -534,7 +557,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl { const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab)) - if (index !== -1) tabsStoreActions.closeTab(index) + if (index !== -1) tabsStoreActions.closeTab(index, scopedTabs()) }} - onReorder={(keys) => - tabsStoreActions.reorder(reorderWithinSide(tabsStore, side(), keys)) - } + onReorder={(keys) => tabsStoreActions.reorder(reorderWithinScope(tabsStore, scope(), keys))} /> & { worktree: string; expanded: boolean } export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string } +// Fork: identifies one mounted folder of one sandbox. Normalized so a guest +// path that differs only in a trailing slash resolves to the same folder. +const folderSessionKey = (server: ServerConnection.Key, directory: string) => `${server}\n${pathKey(directory)}` + export type ReviewDiffStyle = "unified" | "split" export type ReviewChangeMode = "git" | "branch" | "turn" export type ReviewPanelSource = "context-button" | "other" @@ -308,6 +312,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( // you when you switch back to it. workspace: "code" as WorkspaceSide, lastSession: {} as Partial>, + // Fork: and where each mounted folder of a sandbox sends you, keyed + // `${server}\n${pathKey(directory)}`. Separate from `lastSession` + // rather than widening its key space, so the side switch keeps + // reading exactly what it always did. + lastFolderSession: {} as Record, }, }), ) @@ -653,6 +662,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( recordLastSession(side: WorkspaceSide, entry: { server: ServerConnection.Key; sessionId: string }) { setStore("home", "lastSession", side, entry) }, + // Fork: per mounted folder. Optional chaining because makePersisted + // replaces `home` wholesale on load, so a store written before this + // field existed has no key here — the same reason `lastSession` and + // `workspace` above are read defensively. + folderSession: (server: ServerConnection.Key, directory: string) => + store.home.lastFolderSession?.[folderSessionKey(server, directory)], + recordFolderSession(server: ServerConnection.Key, directory: string, sessionId: string) { + setStore("home", "lastFolderSession", folderSessionKey(server, directory), sessionId) + }, }, handoff: { tabs: createMemo(() => store.handoff?.tabs), diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 4dd191f6c2..00a4c9766a 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -17,6 +17,13 @@ export type SessionTab = { type: "session" server: ServerConnection.Key sessionId: string + // Fork: which folder this session belongs to, so the Work side can keep one + // tab strip per mounted folder. Deliberately not part of tabKey(), which is + // what keeps every persisted info/memory/closed key valid as this field + // appears. Optional because a tab persisted before it existed, or added from + // a route before its session resolved, has not learned it yet — see + // backfillTabDirectories. + directory?: string } export type DraftTab = { @@ -49,6 +56,19 @@ export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, ses return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id) } +// Fork: recover the directory of session tabs that predate the field, or that +// were added from a route before their session resolved. The strip already +// records one per tab it renders (rememberSessionInfo), and `info` is a sibling +// key in the same per-window store, so this reads back what is usually already +// on disk instead of waiting for every tab to resolve again. +export function backfillTabDirectories(tabs: Tab[], info: Record) { + return tabs.flatMap((tab, index) => { + if (tab.type !== "session" || tab.directory) return [] + const directory = info[tabKey(tab)]?.directory + return directory ? [{ index, directory }] : [] + }) +} + export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ name: "Tabs", gate: false, @@ -70,7 +90,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ createStore([]), ) const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore({})) - const [info, setInfo] = persisted(Persist.window("tabs.info"), createStore>({})) + const [info, setInfo, , infoReady] = persisted( + Persist.window("tabs.info"), + createStore>({}), + ) const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore([])) const params = useParams() @@ -155,18 +178,46 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ if (next.length !== closed.length) setClosed(() => next) }) + // Fork: fill in missing tab directories. Runs after the reconcile effects + // above so pruned tabs are never touched, and re-runs as the strip records + // new info, which is what covers a tab added from a route before its + // session resolved. + createEffect(() => { + if (!ready() || !infoReady()) return + const pending = backfillTabDirectories(store, info) + if (!pending.length) return + setStore( + produce((tabs) => { + for (const entry of pending) { + const tab = tabs[entry.index] + if (tab?.type === "session" && !tab.directory) tab.directory = entry.directory + } + }), + ) + }) + const navigateTab = (tab: Tab) => { const href = tabHref(tab) setRecentKey(tabKey(tab)) navigate(href) } - const removeTab = (index: number) => { + // Fork: `within` is the list the strip is showing. The neighbour to land on + // after closing the active tab is picked from it rather than from the flat + // store, so closing the last tab of a folder does not jump to a tab in + // another folder — or, from Work, to a Code tab. The splice still uses the + // store index; only the choice of successor is scoped. + const removeTab = (index: number, within?: Tab[]) => { const tab = store[index] if (!tab) return const key = tabKey(tab) const draftID = tab.type === "draft" ? tab.draftID : undefined - const nextTab = nextTabAfterClose(store, index, recentKey() === key && location.pathname !== "/") + const active = recentKey() === key && location.pathname !== "/" + const scopedIndex = within ? within.indexOf(tab) : -1 + const nextTab = + within && scopedIndex !== -1 + ? nextTabAfterClose(within, scopedIndex, active) + : nextTabAfterClose(store, index, active) closing.add(key) void startTransition(() => { setStore( @@ -189,7 +240,20 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ addSessionTab: (tab: Omit) => { const next = { type: "session" as const, ...tab } const existing = store.find((item) => tabKey(item) === tabKey(next)) - if (existing) return existing + if (existing) { + // Fork: only ever fills a gap. A session's directory never moves, so + // this is the tab learning where it belongs rather than changing + // folder, and re-opening an already-open session stays a no-op. + if (existing.type === "session" && !existing.directory && next.directory) { + setStore( + (item) => item.type === "session" && tabKey(item) === tabKey(next), + produce((item) => { + if (item.type === "session") item.directory = next.directory + }), + ) + } + return existing + } void startTransition(() => { setStore( produce((tabs) => { @@ -249,7 +313,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ setStore( produce((tabs) => { const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID) - if (index !== -1) tabs[index] = next + if (index === -1) return + // Fork: a draft always knows its directory, so inheriting it here + // means a promoted draft is never born without a folder. + const draft = tabs[index] + tabs[index] = { + ...next, + directory: next.directory ?? (draft?.type === "draft" ? draft.directory : undefined), + } }), ) if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next)) @@ -262,11 +333,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ // User-initiated close: records the tab so it can be reopened. // Cleanup paths (missing sessions, archive, server removal) go through // removeTab and friends directly and are not recorded. - closeTab(index: number) { + closeTab(index: number, within?: Tab[]) { const tab = store[index] if (!tab) return if (tab.type === "session") updateClosed((stack) => pushClosedTab(stack, tab, index)) - removeTab(index) + removeTab(index, within) }, reopenClosedTab() { if (!closedReady()) { diff --git a/packages/app/src/pages/home-model.tsx b/packages/app/src/pages/home-model.tsx index 6a47a3d310..5f76ed897a 100644 --- a/packages/app/src/pages/home-model.tsx +++ b/packages/app/src/pages/home-model.tsx @@ -30,6 +30,8 @@ import { import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" import { pathKey } from "@/utils/path-key" import { showToast } from "@/utils/toast" +import { sideForServerKey } from "@/workspace/side" +import { useWorkspaceFolder } from "@/workspace/tabs" import { archiveHomeSession } from "./home-session-archive" import { findOpenDraft } from "./home-draft-reuse" import { shouldOpenSessionInBackground } from "./home-session-open" @@ -107,6 +109,7 @@ export function createHomeModel() { const notification = useNotification() const pickDirectory = useDirectoryPicker() const pinned = usePinnedSessions() + const folder = useWorkspaceFolder() const selection = layout.home.selection @@ -235,6 +238,15 @@ 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: + // 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") { + setSelection({ server: key, directory }) + folder.enterFolder(key, directory) + return + } setSelection(toggleHomeProjectSelection(selection(), key, directory)) } @@ -312,12 +324,12 @@ export function createHomeModel() { const ctx = global.ensureServerCtx(conn) ctx.projects.open(directory) if (options?.background) { - tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) + tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id, directory }) return } ctx.projects.touch(directory) startTransition(() => { - const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) + const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id, directory }) tabs.select(tab) }) } diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 04c0a2f339..fcb8c5e24d 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -349,7 +349,7 @@ export function NewHome() { ctx.projects.open(directory) ctx.projects.touch(directory) void startTransition(() => { - const tab = tabs.addSessionTab({ server, sessionId: sessionID }) + const tab = tabs.addSessionTab({ server, sessionId: sessionID, directory }) tabs.select(tab) }) }} diff --git a/packages/app/src/workspace/side.ts b/packages/app/src/workspace/side.ts index 324b0a7ebd..066807fd5e 100644 --- a/packages/app/src/workspace/side.ts +++ b/packages/app/src/workspace/side.ts @@ -1,5 +1,6 @@ import { ServerConnection } from "@/context/server" import { tabKey, type Tab } from "@/context/tabs" +import { pathKey } from "@/utils/path-key" // The desktop app is split into two workspaces: Code, everything that runs on // a normal server (the local sidecar, WSL servers, remote and SSH servers), and @@ -38,7 +39,72 @@ export function serversForSide(servers: ServerConnection.Any[], side: WorkspaceS } export function tabsForSide(tabs: Tab[], side: WorkspaceSide) { - return tabs.filter((tab) => sideForServerKey(tab.server) === side) + return tabs.filter(inSide(side)) +} + +// What the tab strip is showing. Code is one list for the whole side, the way +// it has always been. Work is one list per (sandbox, mounted folder): a sandbox +// exposes its project folder plus every extra mount as its own project, and a +// session belongs to exactly one of them, so a shared strip made two unrelated +// folders read as one workspace. +// +// `folders` is the sandbox's open project list, needed because a session can +// sit in a subdirectory of a folder rather than at its root. +export type TabScope = + | { type: "side"; side: WorkspaceSide } + | { type: "folder"; server: ServerConnection.Key; directory: string; folders: string[] } + +// Which of `folders` owns `directory`: itself, or the deepest one it sits under. +// The prefix test stops at a path boundary so /mnt/foo does not swallow +// /mnt/foo-2 — a real pair, since mount guest names dedup with a -2 suffix. +export function folderForDirectory(directory: string | undefined, folders: string[]) { + if (!directory) return + const key = pathKey(directory) + const exact = folders.find((folder) => pathKey(folder) === key) + if (exact) return exact + return folders + .filter((folder) => key.startsWith(`${pathKey(folder)}/`)) + .sort((a, b) => pathKey(b).length - pathKey(a).length)[0] +} + +export function tabsForScope(tabs: Tab[], scope: TabScope) { + return tabs.filter(inScope(scope)) +} + +// Where selecting a mounted folder lands. Split out of the hook because the +// Work side cannot be driven from the web e2e harness — that build has no +// sandbox platform at all, so effectiveSide collapses it to Code — and this +// chain is the part worth pinning down. +export function folderEntry(input: { lastSession?: string; serverExists: boolean; openDraft?: Tab }) { + // A recorded session whose server has gone away falls through rather than + // navigating to a dead route. + if (input.lastSession && input.serverExists) return { type: "session" as const, sessionId: input.lastSession } + if (input.openDraft) return { type: "draft" as const, tab: input.openDraft } + return { type: "new" as const } +} + +export function reorderWithinScope(tabs: Tab[], scope: TabScope, keys: string[]) { + return reorderWithin(tabs, keys, inScope(scope)) +} + +function inSide(side: WorkspaceSide) { + return (tab: Tab) => sideForServerKey(tab.server) === side +} + +function inScope(scope: TabScope) { + if (scope.type === "side") return inSide(scope.side) + return (tab: Tab) => { + if (tab.server !== scope.server) return false + // A tab whose folder cannot be resolved — a session tab that has not + // learned its directory yet, or one pointing at a folder the sandbox no + // longer lists — shows in every folder of its own sandbox. Being visible + // in the wrong folder for a moment is recoverable; being invisible in all + // of them is not, and an unresolved tab is exactly the one that has never + // rendered and so has had no chance to learn where it belongs. + const folder = folderForDirectory(tab.directory, scope.folders) + if (!folder) return true + return pathKey(folder) === pathKey(scope.directory) + } } // Which server the sidebar should focus after a switch. Keeping the current @@ -67,11 +133,15 @@ export function leavesWorkEmpty(sandboxes: { config: { id: string } }[], removed // the full set, so without this a drag inside a filtered strip is a silent // no-op. export function reorderWithinSide(tabs: Tab[], side: WorkspaceSide, keys: string[]) { + return reorderWithin(tabs, keys, inSide(side)) +} + +function reorderWithin(tabs: Tab[], keys: string[], scoped: (tab: Tab) => boolean) { const moved = keys.flatMap((key) => { const tab = tabs.find((item) => tabKey(item) === key) - return tab && sideForServerKey(tab.server) === side ? [tab] : [] + return tab && scoped(tab) ? [tab] : [] }) - if (moved.length !== tabsForSide(tabs, side).length) return tabs.map(tabKey) + if (moved.length !== tabs.filter(scoped).length) return tabs.map(tabKey) const queue = [...moved] - return tabs.map((tab) => tabKey(sideForServerKey(tab.server) === side ? queue.shift()! : tab)) + return tabs.map((tab) => tabKey(scoped(tab) ? queue.shift()! : tab)) } diff --git a/packages/app/src/workspace/tabs.tsx b/packages/app/src/workspace/tabs.tsx index dbd3b926e6..e1f2abf16b 100644 --- a/packages/app/src/workspace/tabs.tsx +++ b/packages/app/src/workspace/tabs.tsx @@ -1,5 +1,5 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" -import { createMemo, For, Show } from "solid-js" +import { createEffect, createMemo, For, Show, type Accessor } from "solid-js" import { useNavigate } from "@solidjs/router" import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" @@ -10,7 +10,33 @@ import { findOpenDraft } from "@/pages/home-draft-reuse" import { sessionHref } from "@/utils/session-route" import { useSandboxes } from "@/sandbox/context" import { usePlatform } from "@/context/platform" -import { effectiveSide, leavesWorkEmpty, selectionForSide, WORKSPACE_SIDES, type WorkspaceSide } from "./side" +import { + effectiveSide, + folderEntry, + folderForDirectory, + leavesWorkEmpty, + selectionForSide, + sideForServer, + WORKSPACE_SIDES, + type TabScope, + type WorkspaceSide, +} from "./side" + +// 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 +// standing up a whole ctx. +type FolderServerCtx = { + projects: { list: () => { worktree: string }[] } + sync: { data: { path: { directory: string; worktree: string } } } +} + +// A sandbox's own project folder, as the guest reports its working directory. +// Deliberately not projects.list()[0]: projects.open() prepends, so that is +// whichever mount was added most recently, not the folder the sandbox was +// created with. +function primaryFolder(ctx: FolderServerCtx) { + return ctx.sync.data.path.directory || ctx.sync.data.path.worktree || undefined +} // The side everything should actually render, as opposed to the side that // happens to be persisted. Read this rather than layout.home.workspace(). @@ -20,6 +46,87 @@ export function useWorkspaceSide() { return createMemo(() => effectiveSide(layout.home.workspace(), !!platform.sandboxes)) } +// Where selecting a mounted folder sends you. Its own hook rather than part of +// useWorkspaceSwitch because home-model calls it, and it should not have to +// satisfy that hook's dialog and sandbox-list dependencies. +export function useWorkspaceFolder() { + const layout = useLayout() + const global = useGlobal() + const tabs = useTabs() + const navigate = useNavigate() + + // Same chain enter() uses for a side, one folder down: the session you were + // last in there, else the draft that folder already has open, else a new one. + const enterFolder = (server: ServerConnection.Key, directory: string) => { + const entry = folderEntry({ + lastSession: layout.home.folderSession(server, directory), + serverExists: global.servers.list().some((conn) => ServerConnection.key(conn) === server), + openDraft: findOpenDraft(tabs.store, { server, directory }), + }) + if (entry.type === "session") { + navigate(sessionHref(server, entry.sessionId)) + return + } + if (entry.type === "draft") { + tabs.select(entry.tab) + return + } + void tabs.newDraft({ server, directory }) + } + + return { enterFolder } +} + +// 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 +// 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. +export function useWorkspaceScope(routeDirectory: () => string | undefined): Accessor { + const layout = useLayout() + const global = useGlobal() + const side = useWorkspaceSide() + + const sandbox = createMemo(() => { + if (side() !== "work") return + const key = layout.home.selection().server + const conn = global.servers.list().find((item) => ServerConnection.key(item) === key) + if (!conn || sideForServer(conn) !== "work") return + return { key: ServerConnection.key(conn), ctx: global.ensureServerCtx(conn) } + }) + + const scope = createMemo(() => { + const current = sandbox() + // No sandbox selected yet — one is still starting, or the last was removed. + // Falling back to the side keeps today's behaviour for a state the sidebar + // already reports through WorkspaceEmpty. + if (!current) return { type: "side", side: side() } + const folders = current.ctx.projects.list().map((project) => project.worktree) + const directory = + folderForDirectory(routeDirectory(), folders) ?? + folderForDirectory(layout.home.selection().directory, folders) ?? + primaryFolder(current.ctx) + if (!directory) return { type: "side", side: side() } + return { type: "folder", server: current.key, directory, folders } + }) + + // Remember where each folder was left. Recorded here rather than beside the + // per-side recorder in context/layout.tsx because that effect cannot see a + // session's folder, while this one has already resolved it. + createEffect(() => { + const current = scope() + if (current.type !== "folder") return + const route = layout.route() + if (route.type !== "session" || route.server !== current.server) return + if (layout.home.folderSession(current.server, current.directory) === route.sessionId) return + layout.home.recordFolderSession(current.server, current.directory, route.sessionId) + }) + + return scope +} + // Code and Work are the same UI pointed at different servers, so switching one // is: move the sidebar's selection to a server on that side, then go wherever // that side was last. Everything below the tabs — projects, pinned, recents, @@ -27,11 +134,11 @@ export function useWorkspaceSide() { export function useWorkspaceSwitch() { const layout = useLayout() const global = useGlobal() - const tabs = useTabs() const navigate = useNavigate() const dialog = useDialog() const sandboxes = useSandboxes() const side = useWorkspaceSide() + const folder = useWorkspaceFolder() // A sandbox that exists but is still starting counts: the Work side should // show it coming up, not offer to create a second one. @@ -53,20 +160,20 @@ export function useWorkspaceSwitch() { return } - // No history for this side: land on its open draft, or a new one for the - // first project it has. With neither, the home screen is the only honest - // destination — that side has nothing to open yet. - const directory = global.ensureServerCtx(target).projects.list()[0]?.worktree + // No history for this side: land on its open draft, or a new one. With + // 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. + const ctx = global.ensureServerCtx(target) + const directory = side === "work" ? primaryFolder(ctx) : ctx.projects.list()[0]?.worktree if (!directory) { navigate("/") return } - const existing = findOpenDraft(tabs.store, { server, directory }) - if (existing) { - tabs.select(existing) - return - } - void tabs.newDraft({ server, directory }) + folder.enterFolder(server, directory) } return { diff --git a/packages/app/test-browser/tab-directories.test.ts b/packages/app/test-browser/tab-directories.test.ts new file mode 100644 index 0000000000..7edc4cb84b --- /dev/null +++ b/packages/app/test-browser/tab-directories.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test" +import { backfillTabDirectories, tabKey, type Tab, type TabInfo } from "@/context/tabs" + +const sessionTab = (server: string, sessionId: string, directory?: string): Tab => + ({ type: "session", server, sessionId, directory }) as unknown as Tab + +const draftTab = (server: string, draftID: string, directory: string): Tab => + ({ type: "draft", server, draftID, directory }) as unknown as Tab + +const info = (entries: [Tab, TabInfo][]) => Object.fromEntries(entries.map(([tab, value]) => [tabKey(tab), value])) + +// Session tabs persisted before the field existed, and tabs added from a route +// before their session resolved, recover their folder from the info record the +// strip already writes for every tab it renders. +test("fills in the directory of session tabs that lack one", () => { + const tabs = [sessionTab("sandbox:a", "a1"), sessionTab("sandbox:a", "a2")] + const records = info([ + [tabs[0], { title: "One", directory: "/workspace" }], + [tabs[1], { title: "Two", directory: "/mnt/shared" }], + ]) + expect(backfillTabDirectories(tabs, records)).toEqual([ + { index: 0, directory: "/workspace" }, + { index: 1, directory: "/mnt/shared" }, + ]) +}) + +test("leaves a tab that already knows its directory alone", () => { + const tabs = [sessionTab("sandbox:a", "a1", "/workspace")] + expect(backfillTabDirectories(tabs, info([[tabs[0], { directory: "/mnt/shared" }]]))).toEqual([]) +}) + +// A draft's directory is required and set at creation, so there is nothing to +// recover and nothing to overwrite. +test("ignores draft tabs", () => { + const tabs = [draftTab("sandbox:a", "d1", "/workspace")] + expect(backfillTabDirectories(tabs, info([[tabs[0], { directory: "/mnt/shared" }]]))).toEqual([]) +}) + +test("ignores info records that carry no directory", () => { + const tabs = [sessionTab("sandbox:a", "a1")] + expect(backfillTabDirectories(tabs, info([[tabs[0], { title: "Untitled" }]]))).toEqual([]) + expect(backfillTabDirectories(tabs, {})).toEqual([]) +}) + +// Indexes address the live store, so they have to survive gaps. +test("reports the store index, not the position among the unresolved", () => { + const tabs = [sessionTab("sandbox:a", "a1", "/workspace"), sessionTab("sandbox:a", "a2")] + expect(backfillTabDirectories(tabs, info([[tabs[1], { directory: "/mnt/shared" }]]))).toEqual([ + { index: 1, directory: "/mnt/shared" }, + ]) +}) diff --git a/packages/app/test-browser/workspace-side.test.ts b/packages/app/test-browser/workspace-side.test.ts index 5e623a90e4..c820259dd0 100644 --- a/packages/app/test-browser/workspace-side.test.ts +++ b/packages/app/test-browser/workspace-side.test.ts @@ -3,12 +3,17 @@ import { ServerConnection } from "@/context/server" import { tabKey, type Tab } from "@/context/tabs" import { effectiveSide, + folderEntry, + folderForDirectory, leavesWorkEmpty, + reorderWithinScope, reorderWithinSide, selectionForSide, serversForSide, sideForServer, + tabsForScope, tabsForSide, + type TabScope, } from "@/workspace/side" const local: ServerConnection.Any = { @@ -40,8 +45,16 @@ const sandbox = (name: string): ServerConnection.Any => ({ http: { url: `http://127.0.0.1:9${name.length}` }, }) -const sessionTab = (server: string, sessionId: string): Tab => - ({ type: "session", server, sessionId }) as unknown as Tab +const sessionTab = (server: string, sessionId: string, directory?: string): Tab => + ({ type: "session", server, sessionId, directory }) as unknown as Tab + +const draftTab = (server: string, draftID: string, directory: string): Tab => + ({ type: "draft", server, draftID, directory }) as unknown as Tab + +const FOLDERS = ["/workspace", "/mnt/shared", "/mnt/shared-2"] + +const folderScope = (server: string, directory: string, folders = FOLDERS): TabScope => + ({ type: "folder", server, directory, folders }) as TabScope test("only sandboxes belong to Work", () => { expect(sideForServer(local)).toBe("code") @@ -126,3 +139,140 @@ test("removing a non-sandbox server never empties Work", () => { expect(leavesWorkEmpty([], "wsl:Ubuntu-24.04")).toBe(false) expect(leavesWorkEmpty([], "sidecar")).toBe(false) }) + +// A folder owns a directory that is itself or that sits under it. The prefix +// test has to stop at a path boundary, because mount guest names dedup with a +// -2 suffix and /mnt/shared must not swallow /mnt/shared-2. +test("resolves a directory to the folder that owns it", () => { + expect(folderForDirectory("/workspace", FOLDERS)).toBe("/workspace") + expect(folderForDirectory("/workspace/packages/app", FOLDERS)).toBe("/workspace") + expect(folderForDirectory("/mnt/shared-2", FOLDERS)).toBe("/mnt/shared-2") + expect(folderForDirectory("/mnt/shared-2/src", FOLDERS)).toBe("/mnt/shared-2") + expect(folderForDirectory("/mnt/other", FOLDERS)).toBeUndefined() + expect(folderForDirectory(undefined, FOLDERS)).toBeUndefined() +}) + +test("the deepest folder wins when folders nest", () => { + const nested = ["/workspace", "/workspace/packages"] + expect(folderForDirectory("/workspace/packages/app", nested)).toBe("/workspace/packages") + expect(folderForDirectory("/workspace/script", nested)).toBe("/workspace") +}) + +test("normalizes separators and trailing slashes", () => { + expect(folderForDirectory("/workspace/", FOLDERS)).toBe("/workspace") + expect(folderForDirectory("C:\\repos\\api\\src", ["C:/repos/api"])).toBe("C:/repos/api") +}) + +// The regression lock for the Code side: a side scope must resolve to exactly +// what the side filter alone did before folders existed. +test("a side scope is the plain side filter", () => { + const tabs = [sessionTab("sidecar", "c1"), sessionTab("sandbox:a", "w1", "/workspace")] + expect(tabsForScope(tabs, { type: "side", side: "code" })).toEqual(tabsForSide(tabs, "code")) + expect(tabsForScope(tabs, { type: "side", side: "work" })).toEqual(tabsForSide(tabs, "work")) +}) + +test("a folder scope keeps only that sandbox's tabs in that folder", () => { + const tabs = [ + sessionTab("sidecar", "c1", "/home/dev/api"), + sessionTab("sandbox:a", "a-primary", "/workspace"), + sessionTab("sandbox:a", "a-shared", "/mnt/shared"), + sessionTab("sandbox:b", "b-primary", "/workspace"), + ] + expect(tabsForScope(tabs, folderScope("sandbox:a", "/workspace")).map((tab) => tab.sessionId)).toEqual(["a-primary"]) + expect(tabsForScope(tabs, folderScope("sandbox:a", "/mnt/shared")).map((tab) => tab.sessionId)).toEqual(["a-shared"]) +}) + +// Both sandboxes mount their project folder at the same guest path, which is +// why the server has to be part of the key. +test("the same guest path in two sandboxes gives two lists", () => { + const tabs = [sessionTab("sandbox:a", "a1", "/workspace"), sessionTab("sandbox:b", "b1", "/workspace")] + expect(tabsForScope(tabs, folderScope("sandbox:a", "/workspace")).map((tab) => tab.sessionId)).toEqual(["a1"]) + expect(tabsForScope(tabs, folderScope("sandbox:b", "/workspace")).map((tab) => tab.sessionId)).toEqual(["b1"]) +}) + +// The project folder is not privileged: it is one folder among the mounts. +test("the primary folder is scoped like any other", () => { + const tabs = [sessionTab("sandbox:a", "primary", "/workspace"), sessionTab("sandbox:a", "extra", "/mnt/shared")] + expect(tabsForScope(tabs, folderScope("sandbox:a", "/mnt/shared")).map((tab) => tab.sessionId)).toEqual(["extra"]) +}) + +// A tab that has not learned its directory, or points at a folder the sandbox +// no longer lists, must never be invisible in every folder — it is exactly the +// tab that has never rendered and so never had a chance to learn where it goes. +test("an unresolved tab shows in every folder of its own sandbox and no other", () => { + const tabs = [sessionTab("sandbox:a", "unknown"), sessionTab("sandbox:a", "stale", "/mnt/gone")] + for (const directory of FOLDERS) { + expect(tabsForScope(tabs, folderScope("sandbox:a", directory)).map((tab) => tab.sessionId)).toEqual([ + "unknown", + "stale", + ]) + } + expect(tabsForScope(tabs, folderScope("sandbox:b", "/workspace"))).toEqual([]) +}) + +test("draft tabs scope by their own directory", () => { + const tabs = [draftTab("sandbox:a", "d1", "/mnt/shared/src"), draftTab("sandbox:a", "d2", "/workspace")] + expect(tabsForScope(tabs, folderScope("sandbox:a", "/mnt/shared"))).toEqual([tabs[0]]) +}) + +// The strip compares tabs by reference (the active highlight, the close index, +// the For key), so the filter must hand back the store's own objects. +test("keeps tab identity", () => { + const tabs = [sessionTab("sandbox:a", "a1", "/workspace")] + expect(tabsForScope(tabs, folderScope("sandbox:a", "/workspace"))[0]).toBe(tabs[0]) +}) + +test("reorders one folder in place and leaves the rest untouched", () => { + const tabs = [ + sessionTab("sidecar", "c1"), + sessionTab("sandbox:a", "w1", "/workspace"), + sessionTab("sandbox:a", "s1", "/mnt/shared"), + sessionTab("sandbox:a", "w2", "/workspace"), + ] + const scope = folderScope("sandbox:a", "/workspace") + const reordered = reorderWithinScope(tabs, scope, [tabKey(tabs[3]), tabKey(tabs[1])]) + expect(reordered).toEqual([tabKey(tabs[0]), tabKey(tabs[3]), tabKey(tabs[2]), tabKey(tabs[1])]) +}) + +test("ignores a reorder that does not cover the whole folder", () => { + const tabs = [sessionTab("sandbox:a", "w1", "/workspace"), sessionTab("sandbox:a", "w2", "/workspace")] + const scope = folderScope("sandbox:a", "/workspace") + expect(reorderWithinScope(tabs, scope, [tabKey(tabs[0])])).toEqual(tabs.map(tabKey)) +}) + +// An unresolved tab is in the visible list, so it must be in the key list the +// strip hands back — otherwise reorder() sees a short list and silently drops +// the drag. +test("a folder holding an unresolved tab still reorders", () => { + const tabs = [sessionTab("sandbox:a", "w1", "/workspace"), sessionTab("sandbox:a", "unknown")] + const scope = folderScope("sandbox:a", "/workspace") + expect(reorderWithinScope(tabs, scope, [tabKey(tabs[1]), tabKey(tabs[0])])).toEqual([ + tabKey(tabs[1]), + tabKey(tabs[0]), + ]) +}) + +test("selecting a folder returns to the session it was left on", () => { + const draft = draftTab("sandbox:a", "d1", "/workspace") + expect(folderEntry({ lastSession: "ses_1", serverExists: true, openDraft: draft })).toEqual({ + type: "session", + sessionId: "ses_1", + }) +}) + +// A removed sandbox leaves its recorded session behind; navigating to it would +// be a dead route. +test("a recorded session on a vanished server falls through", () => { + const draft = draftTab("sandbox:a", "d1", "/workspace") + expect(folderEntry({ lastSession: "ses_1", serverExists: false, openDraft: draft })).toEqual({ + type: "draft", + tab: draft, + }) + expect(folderEntry({ lastSession: "ses_1", serverExists: false })).toEqual({ type: "new" }) +}) + +test("with no history a folder lands on its open draft, then on a new one", () => { + const draft = draftTab("sandbox:a", "d1", "/workspace") + expect(folderEntry({ serverExists: true, openDraft: draft })).toEqual({ type: "draft", tab: draft }) + expect(folderEntry({ serverExists: true })).toEqual({ type: "new" }) +}) From a9436b7b93fde148c420f97d568721996ac7e17d Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Fri, 14 Aug 2026 18:21:14 +0700 Subject: [PATCH 2/2] fix(app): land on an open tab when a Work folder has no recorded history Found by driving the real desktop app against a sandbox with four mounted folders. Selecting a folder that had open session tabs but no recorded last-session minted an empty draft beside them instead of landing on a tab that was already there. Every folder is in that state the first time it is opened after per-folder strips ship, since lastFolderSession starts empty -- so this was the common case, not an edge one. folderEntry gains an openSession step between the folder's open draft and creating a new one, and enterFolder resolves the candidate through the same folders list the strip uses, so the tab landed on is always one the strip will be showing. Tabs whose folder is still unknown are skipped: they show in every folder of the sandbox, so picking one could jump to a tab that belongs elsewhere the moment it resolves. Offered for Work only. On Code the chain is reached from enter(side), where adding this step would change the side switch's behaviour. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 3 ++- packages/app/src/workspace/side.ts | 13 ++++++++++- packages/app/src/workspace/tabs.tsx | 22 +++++++++++++++++-- .../app/test-browser/workspace-side.test.ts | 21 ++++++++++++++++++ 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21293e3587..8f77cb3856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,8 @@ file is the human-readable summary of what changed and why. tabs. Every folder of every sandbox now keeps its own tabs, including the project folder the sandbox was created with. Clicking a folder in the sidebar takes you back to the session you were last in there — or to that folder's - draft, or a new one if it has no history. Tab shortcuts (`Ctrl`/`Cmd` + + draft, or to one of its open tabs, and only to a fresh new session when the + folder has nothing open at all. Tab shortcuts (`Ctrl`/`Cmd` + number, next/previous tab, close tab) act within the folder you are looking at, so closing the last tab in a folder no longer drops you into an unrelated one. Two folders that happen to have the same name stay separate, and so does diff --git a/packages/app/src/workspace/side.ts b/packages/app/src/workspace/side.ts index 066807fd5e..5a4e8b620d 100644 --- a/packages/app/src/workspace/side.ts +++ b/packages/app/src/workspace/side.ts @@ -75,11 +75,22 @@ export function tabsForScope(tabs: Tab[], scope: TabScope) { // Work side cannot be driven from the web e2e harness — that build has no // sandbox platform at all, so effectiveSide collapses it to Code — and this // chain is the part worth pinning down. -export function folderEntry(input: { lastSession?: string; serverExists: boolean; openDraft?: Tab }) { +export function folderEntry(input: { + lastSession?: string + serverExists: boolean + openDraft?: Tab + openSession?: Tab +}) { // A recorded session whose server has gone away falls through rather than // navigating to a dead route. if (input.lastSession && input.serverExists) return { type: "session" as const, sessionId: input.lastSession } if (input.openDraft) return { type: "draft" as const, tab: input.openDraft } + // 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. + if (input.openSession) return { type: "tab" as const, tab: input.openSession } return { type: "new" as const } } diff --git a/packages/app/src/workspace/tabs.tsx b/packages/app/src/workspace/tabs.tsx index e1f2abf16b..92b7c88916 100644 --- a/packages/app/src/workspace/tabs.tsx +++ b/packages/app/src/workspace/tabs.tsx @@ -58,22 +58,40 @@ export function useWorkspaceFolder() { // Same chain enter() uses for a side, one folder down: the session you were // last in there, else the draft that folder already has open, else a new one. const enterFolder = (server: ServerConnection.Key, directory: string) => { + const conn = global.servers.list().find((item) => ServerConnection.key(item) === server) const entry = folderEntry({ lastSession: layout.home.folderSession(server, directory), - serverExists: global.servers.list().some((conn) => ServerConnection.key(conn) === server), + serverExists: !!conn, openDraft: findOpenDraft(tabs.store, { server, directory }), + openSession: conn && sideForServer(conn) === "work" ? openSessionIn(conn, server, directory) : undefined, }) if (entry.type === "session") { navigate(sessionHref(server, entry.sessionId)) return } - if (entry.type === "draft") { + if (entry.type === "draft" || entry.type === "tab") { tabs.select(entry.tab) return } void tabs.newDraft({ server, directory }) } + // A session tab that resolves to exactly this folder. 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 picking one could send + // you to a tab that belongs elsewhere the moment it resolves. + const openSessionIn = (conn: ServerConnection.Any, server: ServerConnection.Key, directory: string) => { + const folders = global + .ensureServerCtx(conn) + .projects.list() + .map((project) => project.worktree) + return tabs.store.find( + (tab) => + tab.type === "session" && tab.server === server && folderForDirectory(tab.directory, folders) === directory, + ) + } + return { enterFolder } } diff --git a/packages/app/test-browser/workspace-side.test.ts b/packages/app/test-browser/workspace-side.test.ts index c820259dd0..1f28c56895 100644 --- a/packages/app/test-browser/workspace-side.test.ts +++ b/packages/app/test-browser/workspace-side.test.ts @@ -276,3 +276,24 @@ test("with no history a folder lands on its open draft, then on a new one", () = expect(folderEntry({ serverExists: true, openDraft: draft })).toEqual({ type: "draft", tab: draft }) expect(folderEntry({ serverExists: true })).toEqual({ type: "new" }) }) + +// Every folder is in this state the first time it is opened after per-folder +// strips shipped: it has tabs, but nothing has recorded where it was left. +// Minting an empty draft beside tabs that are already there is the wrong move. +test("a folder with tabs but no recorded history lands on one of them", () => { + const open = sessionTab("sandbox:a", "a1", "/workspace") + expect(folderEntry({ serverExists: true, openSession: open })).toEqual({ type: "tab", tab: open }) +}) + +test("a recorded session and an open draft both outrank an open tab", () => { + const open = sessionTab("sandbox:a", "a1", "/workspace") + const draft = draftTab("sandbox:a", "d1", "/workspace") + expect(folderEntry({ lastSession: "ses_1", serverExists: true, openSession: open })).toEqual({ + type: "session", + sessionId: "ses_1", + }) + expect(folderEntry({ serverExists: true, openDraft: draft, openSession: open })).toEqual({ + type: "draft", + tab: draft, + }) +})