diff --git a/CHANGELOG.md b/CHANGELOG.md
index 015b1fd605..8f77cb3856 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,22 @@ 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 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
+ 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..5a4e8b620d 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,83 @@ 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
+ 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 }
+}
+
+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 +144,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..92b7c88916 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,105 @@ 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 conn = global.servers.list().find((item) => ServerConnection.key(item) === server)
+ const entry = folderEntry({
+ lastSession: layout.home.folderSession(server, directory),
+ 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" || 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 }
+}
+
+// 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 +152,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 +178,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..1f28c56895 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,161 @@ 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" })
+})
+
+// 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,
+ })
+})