From dbd3174ab6a69babb6ede71379020d3794d2967d Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Wed, 19 Aug 2026 17:45:48 +0700 Subject: [PATCH] fix(app): scope the tab strip to Code after a side switch Every sandbox that exists sits in server.retained, and useWorkspaceScope's sandbox() resolves to nothing both while a sandbox restarts and after the user switches to Code. The restart hold (36411be) only checked the second half, so switching Work -> Code kept the previous folder scope alive for as long as the sandbox existed: the strip went on showing Work's tabs over a Code session, every Code tab was filtered out, and the + button opened new drafts in the sandbox instead of the project on screen. The decision moves into the pure nextTabScope in side.ts (the folderEntry pattern -- Work cannot be driven from the web e2e harness), gated on the side still being Work before the retained hold applies, with regression tests either side of the gate. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 +++ packages/app/src/workspace/side.ts | 41 ++++++++++++++++ packages/app/src/workspace/tabs.tsx | 36 +++++++------- .../app/test-browser/workspace-side.test.ts | 49 +++++++++++++++++++ 4 files changed, 116 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b50ebeafd4..230eeb8bdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -174,6 +174,13 @@ already in use` and no way forward short of finding the file in a temporary ### Desktop / Web UI +- **Switching to Code brings Code's tabs back** — once you had visited + **Work**, the tab strip stayed pinned to the sandbox folder you left: + switching to **Code** kept Work's tabs across the top of a Code session, + Code's own tabs appeared nowhere, and the **+** button opened new sessions in + the sandbox rather than in the project on screen. The hold that keeps the + strip still while a sandbox restarts no longer applies after you switch + sides. - **A folder on Work is unmounted from its own menu** — the menu on a **Work** project offered **Close**, which only took the row out of the sidebar while the sandbox went on holding the folder: the folder was still mounted, still diff --git a/packages/app/src/workspace/side.ts b/packages/app/src/workspace/side.ts index 9042257c7e..ffea5c3a96 100644 --- a/packages/app/src/workspace/side.ts +++ b/packages/app/src/workspace/side.ts @@ -72,6 +72,47 @@ export function tabsForScope(tabs: Tab[], scope: TabScope) { return tabs.filter(inScope(scope)) } +// What the strip shows next, given what it showed before. Pure and separate +// from useWorkspaceScope for the same reason folderEntry is: the Work side +// cannot be driven from the web e2e harness, and this decision chain is the +// part worth pinning down. +// +// The previous scope matters in exactly one case: on Work, with the selected +// sandbox's connection missing — a restart to apply a mounted folder — the +// strip holds the folder scope it had rather than widening to every folder of +// the sandbox and narrowing again. The hold is gated on the side still being +// Work: the sandbox also resolves to nothing once the user has switched to +// Code, and holding there pinned the sandbox's folder scope under Code — the +// strip went on showing Work's tabs while every Code tab was filtered out, and +// the + button opened drafts in the sandbox instead of the project on screen. +export function nextTabScope(input: { + side: WorkspaceSide + previous: TabScope | undefined + // Sandboxes that exist whether or not they are connected right now; the + // hold is only for a server that is coming back, never one that was removed. + retained: string[] + // The selected sandbox with its folder list and the folder to scope to, + // resolved by the caller; absent while its connection is away or before one + // is selected. + sandbox?: { server: ServerConnection.Key; directory?: string; folders: string[] } +}): TabScope { + if (input.side !== "work") return { type: "side", side: input.side } + if (!input.sandbox) { + if (input.previous?.type === "folder" && input.retained.includes(input.previous.server)) return input.previous + // No sandbox selected yet — one is still starting, or the last was + // removed. The side scope keeps the old behaviour for a state the sidebar + // already reports through WorkspaceEmpty. + return { type: "side", side: input.side } + } + if (!input.sandbox.directory) return { type: "side", side: input.side } + return { + type: "folder", + server: input.sandbox.server, + directory: input.sandbox.directory, + folders: input.sandbox.folders, + } +} + // 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 diff --git a/packages/app/src/workspace/tabs.tsx b/packages/app/src/workspace/tabs.tsx index 569b928aa3..b59f901a1d 100644 --- a/packages/app/src/workspace/tabs.tsx +++ b/packages/app/src/workspace/tabs.tsx @@ -18,6 +18,7 @@ import { folderEntry, folderForDirectory, leavesWorkEmpty, + nextTabScope, selectionForSide, sideForServer, WORKSPACE_SIDES, @@ -161,25 +162,26 @@ export function useWorkspaceScope(routeDirectory: () => string | undefined): Acc return { key: ServerConnection.key(conn), ctx: global.ensureServerCtx(conn) } }) + // The decision lives in nextTabScope (side.ts) with the tests: it holds a + // restarting sandbox's folder scope still, but only while the side is still + // Work — sandbox() is also empty after a switch to Code, and holding there + // kept Work's tabs on the strip while every Code tab was filtered out. const scope = createMemo((previous) => { const current = sandbox() - // A sandbox that is merely away — restarting to apply a mounted folder — - // keeps the scope it had. Its folder list lives on the connection, which is - // gone for those seconds, and falling through would widen the strip to - // every folder of the sandbox and then narrow it again: a reshuffle in the - // middle of an operation the user is waiting on. - if (!current && previous?.type === "folder" && server.retained.includes(previous.server)) return previous - // 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 } + const folders = current ? current.ctx.projects.list().map((project) => project.worktree) : [] + return nextTabScope({ + side: side(), + previous, + retained: server.retained, + sandbox: current && { + server: current.key, + folders, + directory: + folderForDirectory(routeDirectory(), folders) ?? + folderForDirectory(layout.home.selection().directory, folders) ?? + primaryFolder(current.ctx), + }, + }) }) // Remember where each folder was left. Recorded here rather than beside the diff --git a/packages/app/test-browser/workspace-side.test.ts b/packages/app/test-browser/workspace-side.test.ts index d1b13e08ea..2cf4c456ff 100644 --- a/packages/app/test-browser/workspace-side.test.ts +++ b/packages/app/test-browser/workspace-side.test.ts @@ -7,6 +7,7 @@ import { folderEntry, folderForDirectory, leavesWorkEmpty, + nextTabScope, reorderWithinScope, reorderWithinSide, selectionForSide, @@ -251,6 +252,54 @@ test("a folder holding an unresolved tab still reorders", () => { ]) }) +const selectedSandbox = (server: string, directory?: string, folders = FOLDERS) => + ({ server, directory, folders }) as Parameters[0]["sandbox"] + +// The regression: every sandbox that exists is retained, and the sandbox +// resolver is empty off Work — so the restart hold below also fired on a plain +// side switch, pinning the Work folder scope under Code. The strip went on +// showing the sandbox's tabs, every Code tab was filtered out, and + opened +// drafts in the sandbox. +test("switching to Code scopes the strip to Code, whatever Work left behind", () => { + const previous = folderScope("sandbox:a", "/workspace") + expect(nextTabScope({ side: "code", previous, retained: ["sandbox:a"] })).toEqual({ type: "side", side: "code" }) +}) + +// The case the hold is for: a sandbox restarting to apply a mounted folder +// leaves the connection list for a second or two while the user stays on Work. +test("on Work a restarting sandbox keeps the folder scope it had", () => { + const previous = folderScope("sandbox:a", "/workspace") + expect(nextTabScope({ side: "work", previous, retained: ["sandbox:a"] })).toBe(previous) +}) + +test("a removed sandbox does not hold its folder scope", () => { + const previous = folderScope("sandbox:a", "/workspace") + expect(nextTabScope({ side: "work", previous, retained: [] })).toEqual({ type: "side", side: "work" }) +}) + +test("a selected sandbox scopes the strip to its folder", () => { + const previous = folderScope("sandbox:a", "/workspace") + expect( + nextTabScope({ + side: "work", + previous, + retained: ["sandbox:a", "sandbox:b"], + sandbox: selectedSandbox("sandbox:b", "/mnt/shared"), + }), + ).toEqual(folderScope("sandbox:b", "/mnt/shared")) +}) + +test("a sandbox with no resolvable folder falls back to the whole side", () => { + expect( + nextTabScope({ + side: "work", + previous: undefined, + retained: ["sandbox:a"], + sandbox: selectedSandbox("sandbox:a", undefined, []), + }), + ).toEqual({ type: "side", side: "work" }) +}) + // Every session the chain can reach still exists unless a test says otherwise, // and the folder has no other session to fall back to — the arrangement every // case below varies one piece of.