Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,18 @@ already in use` and no way forward short of finding the file in a temporary

### Desktop / Web UI

- **Code's tab strip shows one project at a time** — the per-folder tab lists
that **Work** already had now apply to **Code** too: the strip across the top
shows the tabs of the project you are in, keyed by server and folder, instead
of every Code tab from every server in one long row. Clicking a project in
the sidebar goes to the session you were last in there — else its open draft,
an open tab, the project's most recent session, and finally a new one — and
the **+** button, `Ctrl/Cmd+1…9`, next/previous-tab and close-tab all act
within the project on screen. A deep link or a jump from the command palette
pulls the strip to its own project, so the tab you land on is always visible.
Switching to Code now also lands on its last-used project rather than
whichever one happened to be listed first. Only the desktop app changes; the
web UI has no Code/Work split and keeps its single tab list.
- **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,
Expand Down
2 changes: 1 addition & 1 deletion DIVERGENCES.md

Large diffs are not rendered by default.

13 changes: 9 additions & 4 deletions packages/app/src/pages/home-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
toggleHomeProjectSelection,
} from "@/pages/layout/helpers"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { usePlatform } from "@/context/platform"
import { pathKey } from "@/utils/path-key"
import { showToast } from "@/utils/toast"
import { sideForServerKey } from "@/workspace/side"
Expand Down Expand Up @@ -109,6 +110,7 @@ export function createHomeModel() {
const notification = useNotification()
const pickDirectory = useDirectoryPicker()
const pinned = usePinnedSessions()
const platform = usePlatform()
const folder = useWorkspaceFolder()
const side = useWorkspaceSide()

Expand Down Expand Up @@ -276,11 +278,14 @@ 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:
// Fork: with the desktop's Code/Work split every project owns a tab strip
// of its own — a mounted folder of a sandbox on Work, a project of a
// server on Code — 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") {
// strip to the side's default folder) and go where that folder was left.
// On web the strip is upstream's one undivided list, so a click keeps
// upstream's toggle.
if (platform.sandboxes) {
setSelection({ server: key, directory })
// The selection is what the sidebar renders, so it moves now; where the
// folder sends you is settled a beat later, once its candidates have been
Expand Down
60 changes: 34 additions & 26 deletions packages/app/src/workspace/side.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,39 +77,48 @@ export function tabsForScope(tabs: Tab[], scope: TabScope) {
// 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.
// Both sides scope the strip to one folder of the sidebar's selected server —
// a mounted folder of one sandbox on Work, a project of one server on Code.
// The caller passes no `selected` on the web build, whose strip stays
// upstream's single list.
//
// The previous scope matters in exactly one case: the selected server's
// connection is missing — a sandbox restarting to apply a mounted folder —
// and the strip holds the folder scope it had rather than widening to the
// whole side and narrowing again. The hold is gated on the previous scope's
// server belonging to the side on screen: the selection also resolves to
// nothing right after a side switch, and every existing sandbox is retained,
// so an ungated hold pinned the sandbox's folder scope under Code — the strip
// went on showing Work's tabs while every Code tab was filtered out.
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.
// Servers 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[] }
// The selected server with its folder list and the folder to scope to,
// resolved by the caller; absent while its connection is away, before one
// is selected, and always on web.
selected?: { 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
if (!input.selected) {
if (
input.previous?.type === "folder" &&
sideForServerKey(input.previous.server) === input.side &&
input.retained.includes(input.previous.server)
)
return input.previous
// Nothing selected yet — a sandbox still starting, or the last one
// 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 }
if (!input.selected.directory) return { type: "side", side: input.side }
return {
type: "folder",
server: input.sandbox.server,
directory: input.sandbox.directory,
folders: input.sandbox.folders,
server: input.selected.server,
directory: input.selected.directory,
folders: input.selected.folders,
}
}

Expand Down Expand Up @@ -148,10 +157,9 @@ export async function folderEntry(input: {
// 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. The whole list rather than the first
// match, because a folder left over from a reset sandbox holds nothing but
// dead tabs and the live one can be anywhere in it.
// draft beside it. The whole list rather than the first match, because a
// folder left over from a reset sandbox holds nothing but dead tabs and the
// live one can be anywhere in it.
for (const tab of input.openSessions ?? []) {
if (tab.type !== "session") continue
if (await input.exists(tab.sessionId)) return { type: "tab" as const, tab }
Expand Down
69 changes: 44 additions & 25 deletions packages/app/src/workspace/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ export function primaryFolder(ctx: FolderServerCtx) {
return ctx.sync.data.path.directory || ctx.sync.data.path.worktree || undefined
}

// The folder a server's strip scopes to when neither the screen nor the
// sidebar picks one. A sandbox owns the folder it was created with; a Code
// server has no such folder, so its last-used project stands in, then its
// first. The last-used worktree is resolved through the folder list because
// it can name a project that has since been closed.
function defaultFolder(
side: WorkspaceSide,
ctx: FolderServerCtx & { projects: { last: () => string | undefined } },
folders: string[],
) {
if (side === "work") return primaryFolder(ctx)
return folderForDirectory(ctx.projects.last(), folders) ?? folders[0]
}

// The side everything should actually render, as opposed to the side that
// happens to be persisted. Read this rather than layout.home.workspace().
export function useWorkspaceSide() {
Expand Down Expand Up @@ -77,7 +91,7 @@ export function useWorkspaceFolder() {
lastSession: layout.home.folderSession(server, directory),
serverExists: !!conn,
openDraft: findOpenDraft(tabs.store, { server, directory }),
openSessions: conn && sideForServer(conn) === "work" ? openSessionsIn(conn, server, directory) : undefined,
openSessions: conn ? openSessionsIn(conn, server, directory) : undefined,
exists: (sessionId) => (conn ? sessionAlive(conn, sessionId) : Promise.resolve(false)),
recentSession: () => (conn ? recentFolderSession(conn, directory) : Promise.resolve(undefined)),
})
Expand Down Expand Up @@ -124,7 +138,7 @@ export function useWorkspaceFolder() {
// The session tabs that resolve to exactly this folder, in strip order.
// 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
// folder is unknown — those are visible in every folder of their server, so
// picking one could send you to a tab that belongs elsewhere the moment it
// resolves.
const openSessionsIn = (conn: ServerConnection.Any, server: ServerConnection.Key, directory: string) => {
Expand All @@ -141,45 +155,50 @@ export function useWorkspaceFolder() {
return { enterFolder, sessionAlive }
}

// 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
// What the tab strip shows. Each side narrows to one folder of the sidebar's
// selected server — a mounted folder of one sandbox on Work, a project of one
// server on Code — 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.
// does the sidebar's selected project decide, and failing that the side's
// default folder — the strip is never unscoped. On web, which has no Code/Work
// split, the strip stays upstream's one list for everything.
export function useWorkspaceScope(routeDirectory: () => string | undefined): Accessor<TabScope> {
const layout = useLayout()
const global = useGlobal()
const server = useServer()
const platform = usePlatform()
const side = useWorkspaceSide()

const sandbox = createMemo(() => {
if (side() !== "work") return
const selected = createMemo(() => {
// Sandboxes are desktop-only; without them there is no Code/Work split and
// the strip keeps upstream's behaviour, one list for every server.
if (!platform.sandboxes) return
const key = layout.home.selection().server
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
if (!conn || sideForServer(conn) !== "work") return
if (!conn || sideForServer(conn) !== side()) return
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.
// restarting sandbox's folder scope still, but only while its side is still
// showing — selected() is also empty right after a side switch, and holding
// there kept Work's tabs on the strip while every Code tab was filtered out.
const scope = createMemo<TabScope>((previous) => {
const current = sandbox()
const current = selected()
const folders = current ? current.ctx.projects.list().map((project) => project.worktree) : []
return nextTabScope({
side: side(),
previous,
retained: server.retained,
sandbox: current && {
selected: current && {
server: current.key,
folders,
directory:
folderForDirectory(routeDirectory(), folders) ??
folderForDirectory(layout.home.selection().directory, folders) ??
primaryFolder(current.ctx),
defaultFolder(side(), current.ctx, folders),
},
})
})
Expand Down Expand Up @@ -246,16 +265,16 @@ export function useWorkspaceSwitch() {
// 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.
// Unless that folder is a placeholder the app made up, which is hidden
// everywhere else and would be a strange place to land: fall back to a
// mounted folder, and to the home screen — where the prompt to mount one
// lives — when the user has not mounted any yet.
// Work resolves the sandbox's own project folder and Code its last-used
// project, so switching sides lands on the same folder the strip will be
// scoped to (defaultFolder, one level up the same chain).
// Unless Work's own folder is a placeholder the app made up, which is
// hidden everywhere else and would be a strange place to land: fall back
// to a mounted folder, and to the home screen — where the prompt to mount
// one lives — when the user has not mounted any yet.
const ctx = global.ensureServerCtx(target)
const own = side === "work" && !isPlaceholderSandbox(target)
const directory = own ? primaryFolder(ctx) : ctx.projects.list()[0]?.worktree
const folders = ctx.projects.list().map((project) => project.worktree)
const directory = side === "work" && isPlaceholderSandbox(target) ? folders[0] : defaultFolder(side, ctx, folders)
if (!directory) {
navigate("/")
return
Expand Down
50 changes: 38 additions & 12 deletions packages/app/test-browser/workspace-side.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,19 +252,26 @@ test("a folder holding an unresolved tab still reorders", () => {
])
})

const selectedSandbox = (server: string, directory?: string, folders = FOLDERS) =>
({ server, directory, folders }) as Parameters<typeof nextTabScope>[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 selectedServer = (server: string, directory?: string, folders = FOLDERS) =>
({ server, directory, folders }) as Parameters<typeof nextTabScope>[0]["selected"]

// The regression: every sandbox that exists is retained, and the selection
// resolver is empty right after a side switch — so the restart hold below also
// fired on a plain switch to Code, pinning the Work folder scope there. 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 never keeps a Work folder scope", () => {
const previous = folderScope("sandbox:a", "/workspace")
expect(nextTabScope({ side: "code", previous, retained: ["sandbox:a"] })).toEqual({ type: "side", side: "code" })
})

// The same gate from the other direction: a hold is only ever for a scope
// whose server belongs to the side on screen.
test("switching to Work never keeps a Code folder scope", () => {
const previous = folderScope("sidecar", "/home/dev/api")
expect(nextTabScope({ side: "work", previous, retained: ["sidecar"] })).toEqual({ type: "side", side: "work" })
})

// 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", () => {
Expand All @@ -284,22 +291,41 @@ test("a selected sandbox scopes the strip to its folder", () => {
side: "work",
previous,
retained: ["sandbox:a", "sandbox:b"],
sandbox: selectedSandbox("sandbox:b", "/mnt/shared"),
selected: selectedServer("sandbox:b", "/mnt/shared"),
}),
).toEqual(folderScope("sandbox:b", "/mnt/shared"))
})

test("a sandbox with no resolvable folder falls back to the whole side", () => {
// Code scopes the same way Work does: one project of the selected server.
test("a selected Code server scopes the strip to one of its projects", () => {
const folders = ["/home/dev/api", "/home/dev/web"]
expect(
nextTabScope({
side: "code",
previous: undefined,
retained: [],
selected: selectedServer("sidecar", "/home/dev/api", folders),
}),
).toEqual(folderScope("sidecar", "/home/dev/api", folders))
})

test("a server with no resolvable folder falls back to the whole side", () => {
expect(
nextTabScope({
side: "work",
previous: undefined,
retained: ["sandbox:a"],
sandbox: selectedSandbox("sandbox:a", undefined, []),
selected: selectedServer("sandbox:a", undefined, []),
}),
).toEqual({ type: "side", side: "work" })
})

// The web build resolves no selection at all, so its strip stays upstream's
// single list whatever server is retained or was shown before.
test("with nothing selected and nothing retained the side is the scope", () => {
expect(nextTabScope({ side: "code", previous: undefined, retained: [] })).toEqual({ type: "side", side: "code" })
})

// 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.
Expand Down
Loading