Skip to content

Commit 6c83006

Browse files
Quickbeardclaude
andcommitted
feat(app): isolate Code-side tab lists per project
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: Claude Fable 5 <noreply@anthropic.com>
1 parent 97c4412 commit 6c83006

6 files changed

Lines changed: 138 additions & 68 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,18 @@ already in use` and no way forward short of finding the file in a temporary
174174

175175
### Desktop / Web UI
176176

177+
- **Code's tab strip shows one project at a time** — the per-folder tab lists
178+
that **Work** already had now apply to **Code** too: the strip across the top
179+
shows the tabs of the project you are in, keyed by server and folder, instead
180+
of every Code tab from every server in one long row. Clicking a project in
181+
the sidebar goes to the session you were last in there — else its open draft,
182+
an open tab, the project's most recent session, and finally a new one — and
183+
the **+** button, `Ctrl/Cmd+1…9`, next/previous-tab and close-tab all act
184+
within the project on screen. A deep link or a jump from the command palette
185+
pulls the strip to its own project, so the tab you land on is always visible.
186+
Switching to Code now also lands on its last-used project rather than
187+
whichever one happened to be listed first. Only the desktop app changes; the
188+
web UI has no Code/Work split and keeps its single tab list.
177189
- **Switching to Code brings Code's tabs back** — once you had visited
178190
**Work**, the tab strip stayed pinned to the sandbox folder you left:
179191
switching to **Code** kept Work's tabs across the top of a Code session,

‎DIVERGENCES.md‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎packages/app/src/pages/home-model.tsx‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
toggleHomeProjectSelection,
2929
} from "@/pages/layout/helpers"
3030
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
31+
import { usePlatform } from "@/context/platform"
3132
import { pathKey } from "@/utils/path-key"
3233
import { showToast } from "@/utils/toast"
3334
import { sideForServerKey } from "@/workspace/side"
@@ -109,6 +110,7 @@ export function createHomeModel() {
109110
const notification = useNotification()
110111
const pickDirectory = useDirectoryPicker()
111112
const pinned = usePinnedSessions()
113+
const platform = usePlatform()
112114
const folder = useWorkspaceFolder()
113115
const side = useWorkspaceSide()
114116

@@ -276,11 +278,14 @@ export function createHomeModel() {
276278
.some((project) => project.worktree === directory)
277279
)
278280
return
279-
// Fork: on Work a project is a mounted folder of a sandbox, and it owns a
280-
// tab strip of its own, so selecting it is a move rather than a highlight:
281+
// Fork: with the desktop's Code/Work split every project owns a tab strip
282+
// of its own — a mounted folder of a sandbox on Work, a project of a
283+
// server on Code — so selecting it is a move rather than a highlight:
281284
// select instead of toggling (a cleared directory would silently send the
282-
// strip back to the primary folder) and go where that folder was left.
283-
if (sideForServerKey(key) === "work") {
285+
// strip to the side's default folder) and go where that folder was left.
286+
// On web the strip is upstream's one undivided list, so a click keeps
287+
// upstream's toggle.
288+
if (platform.sandboxes) {
284289
setSelection({ server: key, directory })
285290
// The selection is what the sidebar renders, so it moves now; where the
286291
// folder sends you is settled a beat later, once its candidates have been

‎packages/app/src/workspace/side.ts‎

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -77,39 +77,48 @@ export function tabsForScope(tabs: Tab[], scope: TabScope) {
7777
// cannot be driven from the web e2e harness, and this decision chain is the
7878
// part worth pinning down.
7979
//
80-
// The previous scope matters in exactly one case: on Work, with the selected
81-
// sandbox's connection missing — a restart to apply a mounted folder — the
82-
// strip holds the folder scope it had rather than widening to every folder of
83-
// the sandbox and narrowing again. The hold is gated on the side still being
84-
// Work: the sandbox also resolves to nothing once the user has switched to
85-
// Code, and holding there pinned the sandbox's folder scope under Code — the
86-
// strip went on showing Work's tabs while every Code tab was filtered out, and
87-
// the + button opened drafts in the sandbox instead of the project on screen.
80+
// Both sides scope the strip to one folder of the sidebar's selected server —
81+
// a mounted folder of one sandbox on Work, a project of one server on Code.
82+
// The caller passes no `selected` on the web build, whose strip stays
83+
// upstream's single list.
84+
//
85+
// The previous scope matters in exactly one case: the selected server's
86+
// connection is missing — a sandbox restarting to apply a mounted folder —
87+
// and the strip holds the folder scope it had rather than widening to the
88+
// whole side and narrowing again. The hold is gated on the previous scope's
89+
// server belonging to the side on screen: the selection also resolves to
90+
// nothing right after a side switch, and every existing sandbox is retained,
91+
// so an ungated hold pinned the sandbox's folder scope under Code — the strip
92+
// went on showing Work's tabs while every Code tab was filtered out.
8893
export function nextTabScope(input: {
8994
side: WorkspaceSide
9095
previous: TabScope | undefined
91-
// Sandboxes that exist whether or not they are connected right now; the
92-
// hold is only for a server that is coming back, never one that was removed.
96+
// Servers that exist whether or not they are connected right now; the hold
97+
// is only for a server that is coming back, never one that was removed.
9398
retained: string[]
94-
// The selected sandbox with its folder list and the folder to scope to,
95-
// resolved by the caller; absent while its connection is away or before one
96-
// is selected.
97-
sandbox?: { server: ServerConnection.Key; directory?: string; folders: string[] }
99+
// The selected server with its folder list and the folder to scope to,
100+
// resolved by the caller; absent while its connection is away, before one
101+
// is selected, and always on web.
102+
selected?: { server: ServerConnection.Key; directory?: string; folders: string[] }
98103
}): TabScope {
99-
if (input.side !== "work") return { type: "side", side: input.side }
100-
if (!input.sandbox) {
101-
if (input.previous?.type === "folder" && input.retained.includes(input.previous.server)) return input.previous
102-
// No sandbox selected yet — one is still starting, or the last was
104+
if (!input.selected) {
105+
if (
106+
input.previous?.type === "folder" &&
107+
sideForServerKey(input.previous.server) === input.side &&
108+
input.retained.includes(input.previous.server)
109+
)
110+
return input.previous
111+
// Nothing selected yet — a sandbox still starting, or the last one
103112
// removed. The side scope keeps the old behaviour for a state the sidebar
104113
// already reports through WorkspaceEmpty.
105114
return { type: "side", side: input.side }
106115
}
107-
if (!input.sandbox.directory) return { type: "side", side: input.side }
116+
if (!input.selected.directory) return { type: "side", side: input.side }
108117
return {
109118
type: "folder",
110-
server: input.sandbox.server,
111-
directory: input.sandbox.directory,
112-
folders: input.sandbox.folders,
119+
server: input.selected.server,
120+
directory: input.selected.directory,
121+
folders: input.selected.folders,
113122
}
114123
}
115124

@@ -148,10 +157,9 @@ export async function folderEntry(input: {
148157
// A folder can hold open session tabs and still have no recorded history —
149158
// every folder does the first time it is opened after per-folder strips
150159
// shipped. Landing on a tab that is already there beats minting an empty
151-
// draft beside it. Only offered for Work: on Code this step would change the
152-
// side switch's long-standing behaviour. The whole list rather than the first
153-
// match, because a folder left over from a reset sandbox holds nothing but
154-
// dead tabs and the live one can be anywhere in it.
160+
// draft beside it. The whole list rather than the first match, because a
161+
// folder left over from a reset sandbox holds nothing but dead tabs and the
162+
// live one can be anywhere in it.
155163
for (const tab of input.openSessions ?? []) {
156164
if (tab.type !== "session") continue
157165
if (await input.exists(tab.sessionId)) return { type: "tab" as const, tab }

‎packages/app/src/workspace/tabs.tsx‎

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,20 @@ export function primaryFolder(ctx: FolderServerCtx) {
4444
return ctx.sync.data.path.directory || ctx.sync.data.path.worktree || undefined
4545
}
4646

47+
// The folder a server's strip scopes to when neither the screen nor the
48+
// sidebar picks one. A sandbox owns the folder it was created with; a Code
49+
// server has no such folder, so its last-used project stands in, then its
50+
// first. The last-used worktree is resolved through the folder list because
51+
// it can name a project that has since been closed.
52+
function defaultFolder(
53+
side: WorkspaceSide,
54+
ctx: FolderServerCtx & { projects: { last: () => string | undefined } },
55+
folders: string[],
56+
) {
57+
if (side === "work") return primaryFolder(ctx)
58+
return folderForDirectory(ctx.projects.last(), folders) ?? folders[0]
59+
}
60+
4761
// The side everything should actually render, as opposed to the side that
4862
// happens to be persisted. Read this rather than layout.home.workspace().
4963
export function useWorkspaceSide() {
@@ -77,7 +91,7 @@ export function useWorkspaceFolder() {
7791
lastSession: layout.home.folderSession(server, directory),
7892
serverExists: !!conn,
7993
openDraft: findOpenDraft(tabs.store, { server, directory }),
80-
openSessions: conn && sideForServer(conn) === "work" ? openSessionsIn(conn, server, directory) : undefined,
94+
openSessions: conn ? openSessionsIn(conn, server, directory) : undefined,
8195
exists: (sessionId) => (conn ? sessionAlive(conn, sessionId) : Promise.resolve(false)),
8296
recentSession: () => (conn ? recentFolderSession(conn, directory) : Promise.resolve(undefined)),
8397
})
@@ -124,7 +138,7 @@ export function useWorkspaceFolder() {
124138
// The session tabs that resolve to exactly this folder, in strip order.
125139
// Resolved through the same folders list the strip uses, so the tab landed on
126140
// is always one the strip will be showing. Deliberately skips tabs whose
127-
// folder is unknown — those are visible in every folder of the sandbox, so
141+
// folder is unknown — those are visible in every folder of their server, so
128142
// picking one could send you to a tab that belongs elsewhere the moment it
129143
// resolves.
130144
const openSessionsIn = (conn: ServerConnection.Any, server: ServerConnection.Key, directory: string) => {
@@ -141,45 +155,50 @@ export function useWorkspaceFolder() {
141155
return { enterFolder, sessionAlive }
142156
}
143157

144-
// What the tab strip shows. Code is the whole side, unchanged. Work narrows to
145-
// one mounted folder of one sandbox, resolved from the folder on screen first,
146-
// so the strip always contains the session you are looking at: a deep link, a
158+
// What the tab strip shows. Each side narrows to one folder of the sidebar's
159+
// selected server — a mounted folder of one sandbox on Work, a project of one
160+
// server on Code — resolved from the folder on screen first, so the strip
161+
// always contains the session you are looking at: a deep link, a
147162
// command-palette jump or a reopened tab pulls the strip to its own folder
148163
// instead of leaving the active tab out of view. Only when nothing is on screen
149-
// does the sidebar's selected project decide, and failing that the sandbox's
150-
// primary folder — the strip is never unscoped.
164+
// does the sidebar's selected project decide, and failing that the side's
165+
// default folder — the strip is never unscoped. On web, which has no Code/Work
166+
// split, the strip stays upstream's one list for everything.
151167
export function useWorkspaceScope(routeDirectory: () => string | undefined): Accessor<TabScope> {
152168
const layout = useLayout()
153169
const global = useGlobal()
154170
const server = useServer()
171+
const platform = usePlatform()
155172
const side = useWorkspaceSide()
156173

157-
const sandbox = createMemo(() => {
158-
if (side() !== "work") return
174+
const selected = createMemo(() => {
175+
// Sandboxes are desktop-only; without them there is no Code/Work split and
176+
// the strip keeps upstream's behaviour, one list for every server.
177+
if (!platform.sandboxes) return
159178
const key = layout.home.selection().server
160179
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
161-
if (!conn || sideForServer(conn) !== "work") return
180+
if (!conn || sideForServer(conn) !== side()) return
162181
return { key: ServerConnection.key(conn), ctx: global.ensureServerCtx(conn) }
163182
})
164183

165184
// The decision lives in nextTabScope (side.ts) with the tests: it holds a
166-
// restarting sandbox's folder scope still, but only while the side is still
167-
// Work — sandbox() is also empty after a switch to Code, and holding there
168-
// kept Work's tabs on the strip while every Code tab was filtered out.
185+
// restarting sandbox's folder scope still, but only while its side is still
186+
// showing — selected() is also empty right after a side switch, and holding
187+
// there kept Work's tabs on the strip while every Code tab was filtered out.
169188
const scope = createMemo<TabScope>((previous) => {
170-
const current = sandbox()
189+
const current = selected()
171190
const folders = current ? current.ctx.projects.list().map((project) => project.worktree) : []
172191
return nextTabScope({
173192
side: side(),
174193
previous,
175194
retained: server.retained,
176-
sandbox: current && {
195+
selected: current && {
177196
server: current.key,
178197
folders,
179198
directory:
180199
folderForDirectory(routeDirectory(), folders) ??
181200
folderForDirectory(layout.home.selection().directory, folders) ??
182-
primaryFolder(current.ctx),
201+
defaultFolder(side(), current.ctx, folders),
183202
},
184203
})
185204
})
@@ -246,16 +265,16 @@ export function useWorkspaceSwitch() {
246265
// neither, the home screen is the only honest destination — that side has
247266
// nothing to open yet.
248267
//
249-
// Work resolves the sandbox's own project folder, so switching to Work
250-
// lands on the same folder its strip will be scoped to. Code keeps its
251-
// first-project fallback.
252-
// Unless that folder is a placeholder the app made up, which is hidden
253-
// everywhere else and would be a strange place to land: fall back to a
254-
// mounted folder, and to the home screen — where the prompt to mount one
255-
// lives — when the user has not mounted any yet.
268+
// Work resolves the sandbox's own project folder and Code its last-used
269+
// project, so switching sides lands on the same folder the strip will be
270+
// scoped to (defaultFolder, one level up the same chain).
271+
// Unless Work's own folder is a placeholder the app made up, which is
272+
// hidden everywhere else and would be a strange place to land: fall back
273+
// to a mounted folder, and to the home screen — where the prompt to mount
274+
// one lives — when the user has not mounted any yet.
256275
const ctx = global.ensureServerCtx(target)
257-
const own = side === "work" && !isPlaceholderSandbox(target)
258-
const directory = own ? primaryFolder(ctx) : ctx.projects.list()[0]?.worktree
276+
const folders = ctx.projects.list().map((project) => project.worktree)
277+
const directory = side === "work" && isPlaceholderSandbox(target) ? folders[0] : defaultFolder(side, ctx, folders)
259278
if (!directory) {
260279
navigate("/")
261280
return

‎packages/app/test-browser/workspace-side.test.ts‎

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -252,19 +252,26 @@ test("a folder holding an unresolved tab still reorders", () => {
252252
])
253253
})
254254

255-
const selectedSandbox = (server: string, directory?: string, folders = FOLDERS) =>
256-
({ server, directory, folders }) as Parameters<typeof nextTabScope>[0]["sandbox"]
257-
258-
// The regression: every sandbox that exists is retained, and the sandbox
259-
// resolver is empty off Work — so the restart hold below also fired on a plain
260-
// side switch, pinning the Work folder scope under Code. The strip went on
261-
// showing the sandbox's tabs, every Code tab was filtered out, and + opened
262-
// drafts in the sandbox.
263-
test("switching to Code scopes the strip to Code, whatever Work left behind", () => {
255+
const selectedServer = (server: string, directory?: string, folders = FOLDERS) =>
256+
({ server, directory, folders }) as Parameters<typeof nextTabScope>[0]["selected"]
257+
258+
// The regression: every sandbox that exists is retained, and the selection
259+
// resolver is empty right after a side switch — so the restart hold below also
260+
// fired on a plain switch to Code, pinning the Work folder scope there. The
261+
// strip went on showing the sandbox's tabs, every Code tab was filtered out,
262+
// and + opened drafts in the sandbox.
263+
test("switching to Code never keeps a Work folder scope", () => {
264264
const previous = folderScope("sandbox:a", "/workspace")
265265
expect(nextTabScope({ side: "code", previous, retained: ["sandbox:a"] })).toEqual({ type: "side", side: "code" })
266266
})
267267

268+
// The same gate from the other direction: a hold is only ever for a scope
269+
// whose server belongs to the side on screen.
270+
test("switching to Work never keeps a Code folder scope", () => {
271+
const previous = folderScope("sidecar", "/home/dev/api")
272+
expect(nextTabScope({ side: "work", previous, retained: ["sidecar"] })).toEqual({ type: "side", side: "work" })
273+
})
274+
268275
// The case the hold is for: a sandbox restarting to apply a mounted folder
269276
// leaves the connection list for a second or two while the user stays on Work.
270277
test("on Work a restarting sandbox keeps the folder scope it had", () => {
@@ -284,22 +291,41 @@ test("a selected sandbox scopes the strip to its folder", () => {
284291
side: "work",
285292
previous,
286293
retained: ["sandbox:a", "sandbox:b"],
287-
sandbox: selectedSandbox("sandbox:b", "/mnt/shared"),
294+
selected: selectedServer("sandbox:b", "/mnt/shared"),
288295
}),
289296
).toEqual(folderScope("sandbox:b", "/mnt/shared"))
290297
})
291298

292-
test("a sandbox with no resolvable folder falls back to the whole side", () => {
299+
// Code scopes the same way Work does: one project of the selected server.
300+
test("a selected Code server scopes the strip to one of its projects", () => {
301+
const folders = ["/home/dev/api", "/home/dev/web"]
302+
expect(
303+
nextTabScope({
304+
side: "code",
305+
previous: undefined,
306+
retained: [],
307+
selected: selectedServer("sidecar", "/home/dev/api", folders),
308+
}),
309+
).toEqual(folderScope("sidecar", "/home/dev/api", folders))
310+
})
311+
312+
test("a server with no resolvable folder falls back to the whole side", () => {
293313
expect(
294314
nextTabScope({
295315
side: "work",
296316
previous: undefined,
297317
retained: ["sandbox:a"],
298-
sandbox: selectedSandbox("sandbox:a", undefined, []),
318+
selected: selectedServer("sandbox:a", undefined, []),
299319
}),
300320
).toEqual({ type: "side", side: "work" })
301321
})
302322

323+
// The web build resolves no selection at all, so its strip stays upstream's
324+
// single list whatever server is retained or was shown before.
325+
test("with nothing selected and nothing retained the side is the scope", () => {
326+
expect(nextTabScope({ side: "code", previous: undefined, retained: [] })).toEqual({ type: "side", side: "code" })
327+
})
328+
303329
// Every session the chain can reach still exists unless a test says otherwise,
304330
// and the folder has no other session to fall back to — the arrangement every
305331
// case below varies one piece of.

0 commit comments

Comments
 (0)