Skip to content

feat(app): isolate Work-side tab lists per mounted folder - #239

Merged
quickbeard merged 2 commits into
mainfrom
work-folder-tabs
Aug 17, 2026
Merged

quickbeard merged 2 commits into
mainfrom
work-folder-tabs

Conversation

@quickbeard

Copy link
Copy Markdown
Owner

A sandbox can mount several folders, but all of their sessions shared one tab strip on the Work side, so working across two folders meant constantly reading past the other one's tabs.

Mounted folders are already ordinary projects on the sandbox server — packages/app/src/sandbox/projects.ts:41 seeds one per mounts[].guestPath 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. Code is unchanged.

Behaviour

  • Every folder of every sandbox keeps its own tabs.
  • Clicking a folder in the sidebar goes to the session you were last in there, else that folder's open draft, else a new one — the chain the Code/Work switch already had, one level down.
  • Tab shortcuts (mod+1…9, next/previous, mod+w) act within the folder on screen. Closing the last tab of a folder no longer drops you into an unrelated one.
  • The same folder mounted into two sandboxes stays separate, and so do two sandboxes that both mount their project folder at /workspace.
  • The Work strip now shows one sandbox at a time — a consequence of including the sandbox in the key, and a visible change from today.
  • Existing tabs keep their place; anything not immediately matchable to a folder stays visible in its sandbox until it is.

Design

The scope is derived, never stored: folder on screen → sidebar's selected project → the sandbox's primary folder. This makes "the strip always contains the session you are looking at" true by construction instead of via an effect racing the two home effects in context/layout.tsx, so deep links, command-palette jumps and reopened tabs all pull the strip to their own folder. It also keeps the change to tabs only — writing the route's folder into home.selection.directory would feed projectDirectories() and silently narrow the sidebar's Recents.

Logic lives in the fork-only src/workspace/{side.ts,tabs.tsx}; tabsForSide/reorderWithinSide became thin wrappers over the same primitives so the Code path is provably the old one. Upstream files hold wiring only, recorded as one new row in DIVERGENCES.md.

SessionTab gains an optional directory, deliberately absent from tabKey() — that is what keeps every persisted info/memory/closed key valid as the field appears. Tabs that predate it recover from the info record the strip already writes for every tab it renders.

Drive-by fix

enter(side) 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. It now reads the guest cwd, matching sandbox/projects.ts:53.

Testing

  • 663 unit + 90 browser tests pass, 21 new: folder resolution (/mnt/shared must not swallow /mnt/shared-2; deepest folder wins when folders nest), a regression lock proving a side scope equals the old side filter, two sandboxes both at /workspace staying separate, the primary folder not being privileged, unresolved tabs, tab identity preserved by reference (the strip compares by reference), reorder merges, the directory back-fill, and the folder-entry chain.
  • 139 smoke + regression e2e tests pass in a real browser, including cross-server-tab-close, new-session-reuses-draft, sidebar persistence and terminal tab switching.
  • bun typecheck clean in packages/app and packages/desktop.

Not verified end-to-end: the Work side running for real. The web e2e harness has no sandbox platform, so effectiveSide collapses it to Code — there is no way to drive Work there, which is why the repo has no Work-side e2e tests. folderEntry was extracted as a pure function to cover that decision chain directly. Still worth a manual pass before merge: create a sandbox, add a second folder via Settings → Servers → Folders…, open sessions in each, and check the strip, the folder switch, and the tab shortcuts.

🤖 Generated with Claude Code

@quickbeard

Copy link
Copy Markdown
Owner Author

Manual pass done — one real bug found and fixed

Drove the running desktop app (Electron dev build, page-level CDP on the remote-debugging port — Playwright's connectOverCDP hangs against Electron's browser-level endpoint) against an existing sandbox with four mounted folders: /workspace (primary, repos/opencode), /mnt/codev-hub, /mnt/codev-office, /mnt/vibe-coding, with three pre-existing Work session tabs spread across three of them.

Bug found: an empty draft was minted beside tabs that were already there

Selecting a folder that had open session tabs but no recorded last-session created a new draft instead of landing on one of its tabs. Every folder is in that state the first time it is opened after this ships, because lastFolderSession starts empty — so this was the common path, not an edge case. Observed directly: clicking codev-hub gave strip=[draft:b4509529*, ses_00032035…].

Fixed in a49a83b: folderEntry gains an openSession step between the folder's open draft and creating a new one, resolved through the same folders list the strip uses so the tab landed on is always one the strip will show. Tabs whose folder is still unknown are skipped — they appear in every folder of the sandbox, so picking one could jump elsewhere the moment it resolves. Work only; on Code the chain is reached from enter(side), where this step would change the side switch's behaviour.

Verified against the real app after the fix — 19/19

Isolation and folder switching (15/15), each folder checked twice, before and after it had recorded history:

  • each of the three folders showed only its own tab, never the other folders' or Code's
  • the folder's own tab was the active one
  • no draft minted beside it
  • codev-office (nothing open) correctly got a draft of its own, alone in the strip
  • revisiting each folder returned to its remembered session
  • Code showed only its own draft; returning to Work landed back on a single folder's strip

Keyboard shortcuts, in a folder holding two tabs (4/4): mod+t added a tab to the folder on screen; ctrl+tab cycled within the folder; mod+w closed the active tab and landed on its folder's neighbour rather than another folder's tab; the side stayed on Work throughout. mod+shift+t restored the closed tab into the right folder.

Migration verified on real pre-existing data

The three Work session tabs were persisted before SessionTab.directory existed. They came back carrying /mnt/vibe-coding, /mnt/codev-hub and /workspace, recovered from the tabs.info records the strip had already written — no tab vanished. After a graceful quit, both the back-filled directories and lastFolderSession were on disk:

"sandbox:codev-work\n/mnt/vibe-coding": "ses_00031e19…"
"sandbox:codev-work\n/mnt/codev-hub":   "ses_00032035…"
"sandbox:codev-work\n/workspace":       "ses_000f10f5…"

(An earlier kill -9 did lose that write — expected, since settings now write asynchronously per #237; the graceful path flushes them.)

Still not covered

Cross-sandbox isolation needs a second sandbox; only unit tests cover that the same guest path in two sandboxes stays separate. bun test is 663 unit + 92 browser green, bun typecheck clean in packages/app and packages/desktop.

🤖 Generated with Claude Code

Quickbeard and others added 2 commits August 17, 2026 08:41
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@quickbeard
quickbeard merged commit 88af49a into main Aug 17, 2026
0 of 5 checks passed
@quickbeard
quickbeard deleted the work-folder-tabs branch August 17, 2026 02:06
quickbeard added a commit that referenced this pull request Aug 19, 2026
Extends #239 to the other side. Code kept one tab strip for the whole
side -- every project of every connected server in one list -- so a
session on the local machine sat beside one on a WSL distro and one from
an unrelated checkout. Work had already been split per (sandbox, mounted
folder); Code is now split the same way, per (server, project), and the
scope type it goes through is the one #239 introduced.

The resolution grows a server axis to make that safe. Work could read
the sidebar's selection alone because an effect in context/layout.tsx
pins it to a sandbox, but nothing syncs home.selection.server to the
route, and on Code the two come apart routinely: selecting a tab on a
WSL server leaves the sidebar on the local one, and scoping to the
sidebar would hide the tab that was just opened. serverForScope takes
the route's server first and the sidebar's second, both filtered to the
side being shown, which keeps "the strip always contains the session you
are looking at" true on a side with several servers connected at once.
Work goes through the same resolution, where it settles the same
mismatch between two sandboxes.

scopeDirectory is the chain #239 derived, with two changes. The
sidebar's directory only counts when the sidebar is on the scoped
server, since two servers can hold the same path -- a checkout the local
machine and a WSL distro both see. And the last resort is per side: the
sandbox's own folder on Work, projects.list()[0] on Code, which is the
fallback enter("code") already used. A Code server with no project open
resolves nothing and degrades to the side scope, which is what both
sides showed before.

Everything downstream was already scope-generic and narrows to Code
projects with no change: tabsForScope, reorderWithinScope, the successor
picked after a close, tab.prev/tab.next/tab.close, and openNewTab's
leading folder branch, which now starts a new tab in the Code project
the strip is showing. The Code sidebar keeps toggling Recents rather
than navigating, so folderEntry's openSessions step stays Work-only for
the reason #239 gave.

Logic stays in the fork-only src/workspace/side.ts; no upstream file is
touched, so the DIVERGENCES row is amended rather than added to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
quickbeard added a commit that referenced this pull request Aug 19, 2026
The per-folder tab strips PR #239 gave the Work side now apply to Code:
the strip is keyed by (server, folder) on both sides, where Code's
folders are the selected server's projects. nextTabScope loses its
work-only early return and takes a side-agnostic `selected` server; its
restart hold is re-gated on the previous scope's server belonging to the
side on screen (same fix as #275, now symmetric — a Work switch can no
longer inherit a Code folder scope either). useWorkspaceScope resolves
the selection on both sides, gated on platform.sandboxes so the web
build, which has no Code/Work split, keeps upstream's single strip.

Selecting a Code project in the sidebar becomes a move rather than a
highlight, through the same folderEntry chain Work uses -- last session
in that folder, else its open draft, an open tab, the server's most
recent session there, else a new draft -- and the openSessions step is
no longer gated to Work. The strip's fallback folder becomes per-side
(defaultFolder): the sandbox's primary folder on Work, the last-used
project then the first on Code; the side switch lands through the same
helper so it agrees with the strip. Web keeps the selection toggle.

Co-authored-by: Quickbeard <quickbeard@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant