Repository navigation
feat(app): isolate Work-side tab lists per mounted folder - #239
Conversation
Manual pass done — one real bug found and fixedDrove the running desktop app (Electron dev build, page-level CDP on the remote-debugging port — Playwright's Bug found: an empty draft was minted beside tabs that were already thereSelecting 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 Fixed in a49a83b: Verified against the real app after the fix — 19/19Isolation and folder switching (15/15), each folder checked twice, before and after it had recorded history:
Keyboard shortcuts, in a folder holding two tabs (4/4): Migration verified on real pre-existing dataThe three Work session tabs were persisted before (An earlier Still not coveredCross-sandbox isolation needs a second sandbox; only unit tests cover that the same guest path in two sandboxes stays separate. 🤖 Generated with Claude Code |
a49a83b to
721dca6
Compare
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>
721dca6 to
a9436b7
Compare
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>
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>
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:41seeds one permounts[].guestPathplus 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
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./workspace.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
homeeffects incontext/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 intohome.selection.directorywould feedprojectDirectories()and silently narrow the sidebar's Recents.Logic lives in the fork-only
src/workspace/{side.ts,tabs.tsx};tabsForSide/reorderWithinSidebecame 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 inDIVERGENCES.md.SessionTabgains an optionaldirectory, deliberately absent fromtabKey()— that is what keeps every persistedinfo/memory/closedkey valid as the field appears. Tabs that predate it recover from theinforecord the strip already writes for every tab it renders.Drive-by fix
enter(side)readprojects.list()[0]as the sandbox's folder, butprojects.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, matchingsandbox/projects.ts:53.Testing
/mnt/sharedmust 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/workspacestaying 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.cross-server-tab-close,new-session-reuses-draft, sidebar persistence and terminal tab switching.bun typecheckclean inpackages/appandpackages/desktop.Not verified end-to-end: the Work side running for real. The web e2e harness has no sandbox platform, so
effectiveSidecollapses it to Code — there is no way to drive Work there, which is why the repo has no Work-side e2e tests.folderEntrywas 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