From 9fc5fd623ed293d2e7be07eae3923990253c44d0 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sat, 22 Aug 2026 17:49:06 +0700 Subject: [PATCH 1/2] fix(app): select the session's project when switching sides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enter() set a selection carrying a server only, then returned as soon as it resumed the side's last session — so switching Code/Work restored the session and its tabs while the sidebar sat on no project, with Projects unhighlighted and Recents empty. Select the project that owns the resumed session's directory, and record it as that server's last. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 ++++ DIVERGENCES.md | 2 +- packages/app/src/workspace/side.ts | 18 +++++++++ packages/app/src/workspace/tabs.tsx | 28 +++++++++++++ .../app/test-browser/workspace-side.test.ts | 40 +++++++++++++++++++ 5 files changed, 94 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e51e8eb60..e86b53b646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -284,6 +284,13 @@ already in use` and no way forward short of finding the file in a temporary ### Desktop / Web UI +- **Switching between Code and Work brings the project back too** — the switch + restored the session you were last in, and its tabs, but left the sidebar on + no project at all: nothing highlighted under Projects, and Pinned and Recents + empty beside an open session. It now selects the project that session lives + in, so both sides come back to where you left them. + ([#302](https://github.com/quickbeard/codev-code/pull/302)) + - **The sidebar opens on the project you were last in, and shows only its sessions** — starting the app with no project picked listed every project's sessions together in Recents, so work from a folder you were not in sat diff --git a/DIVERGENCES.md b/DIVERGENCES.md index c7e98444db..e73b6b6d3f 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -219,7 +219,7 @@ from the squash message first, or use a plain merge. | Desktop identity: `packages/desktop/src/main/{index,store}.ts`, `packages/desktop/electron-builder.config{,.test}.ts`, `packages/desktop/scripts/copy-metainfo.ts`, `packages/desktop/resources/linux/opencode-desktop.desktop` (deleted), `packages/app/src/pages/layout/{deep-links.ts,helpers.test.ts}`, `packages/codev-brand/test/brand.test.ts` | appId `ai.opencode.desktop{,.beta,.dev}` → `ai.codev.desktop{,.beta,.dev}` (both copies: `APP_IDS` in main and in the builder config, plus the unpackaged fallback); deep-link scheme `opencode://` → `codev://` in all three places that must agree — `protocols.schemes`, `app.setAsDefaultProtocolClient`, and the renderer's `parseUrl` — plus the `argv.startsWith` filter the second-instance handler uses; `rpm.packageName` `opencode`/`opencode-beta`/`opencode-dev` → `codev`/`codev-beta`/`codev-dev`; the hidden `opencode-desktop.desktop` legacy launcher and its `deb`/`rpm` fpm wiring deleted. `src/main/migrate.ts` keeps the old ids — they name the *Tauri* app's directories, not ours. | The appId is the whole desktop identity: `app.setPath("userData", join(appData, appId))`, so it also names the data directory, and it drives the macOS bundle id, the Windows AppUserModelId and NSIS keys, the Linux `.desktop` filename, `executableName` and `StartupWMClass`, and the AppStream metainfo id. Leaving it as opencode meant the fork's app identified itself as upstream to the OS. Renamed as a clean cut with no data migration and no back-compat shim, on the explicit basis that there is no installed base: existing profiles under `appData/ai.opencode.desktop*` are simply orphaned (settings, session history, sandboxes and the downloaded guest image), and a packaged update across the rename would install beside the old app rather than replace it. The legacy launcher went with it — it existed to rescue GNOME/KDE pins from a superseded packaging id, and its `Exec=/opt/OpenCode/...` no longer matched any install layout. | Keep our ids and scheme; take upstream's other config. Never rename the `TAURI_APP_IDS` in `migrate.ts` to match — a blind find-and-replace there points the Tauri migration at a directory that never existed. All three deep-link sites have to move together or links open nothing; `electron-builder.config.test.ts` covers the ids, the package names and the scheme per channel. | | Per-folder tab strip: `packages/app/src/context/{layout,tabs}.tsx`, `packages/app/src/components/titlebar.tsx`, `packages/app/src/pages/home.tsx` | Extends the Code/Work split row above so each side keeps one tab strip per **folder** of the selected server rather than one for the whole side — shipped for Work first (a sandbox exposes its project folder plus every extra mount as its own project, fork-only `src/sandbox/projects.ts`), then extended to Code, whose folders are the selected server's projects. The strip is keyed by (server, folder); on web, which has no Code/Work split, `useWorkspaceScope` resolves no selection and the strip stays upstream's single list. All of the logic is in the fork-only `src/workspace/side.ts` (`TabScope`, `folderForDirectory`, `tabsForScope`, `reorderWithinScope` — `tabsForSide`/`reorderWithinSide` become thin wrappers over the same primitives so the Code path is provably unchanged) and `src/workspace/tabs.tsx` (`useWorkspaceScope`, `useWorkspaceFolder`). Upstream files hold wiring only: `tabs.tsx` adds `SessionTab.directory` — deliberately **not** part of `tabKey()`, which is what keeps every persisted `info`/`memory`/`closed` key valid as the field appears — plus `backfillTabDirectories` and the effect that applies it, `promoteDraft` inheriting the draft's directory, `addSessionTab` filling a missing directory on an already-open tab, and an optional `within` on `closeTab`/`removeTab` so the successor after a close is picked from the visible list, plus `removeDirectory({server, directory})` — every tab of one mounted folder, sessions and drafts, for when that folder is unmounted (matching lives in the fork-only `src/workspace/tab-directory.ts`, its own module because both `context/tabs.tsx` and `workspace/side.ts` reach the router at import time and so cannot be unit-tested). Modelled on `removeServer` rather than `closeTab`: the folder left the sandbox, so the tabs are not a close the user can undo, and leaving them behind is what made an unmounted folder's session reappear over the next folder the user mounted — `inScope()` shows a tab whose folder the sandbox no longer lists in *every* folder, so it followed the user around while appearing in no folder's Recents; `layout.tsx` adds `home.lastFolderSession` (keyed `` `${server}\n${pathKey(directory)}` ``) with `folderSession`/`recordFolderSession`; `titlebar.tsx` swaps the side filter for the scope in the strip, reorder, `tab.prev`/`tab.next` and `tab.close`, passes `session.directory` to `addSessionTab`, and gains a leading scope branch in `openNewTab`; `home.tsx` passes `directory` to `addSessionTab`. `pages/home-model.tsx` (fork-only) selects instead of toggling on desktop — both sides — and calls `enterFolder`; web keeps upstream's toggle. The scope is **derived**, never stored: the folder on screen wins, then the sidebar's selected project, then the side's default folder (`defaultFolder` in `workspace/tabs.tsx` — the sandbox's primary folder from `sync.data.path.directory` on Work, the last-used project then the first on Code) — so deep links, the command palette and a reopened tab all pull the strip to their own folder instead of leaving the active tab out of view. `pages/session.tsx` and `components/command-palette.ts` are deliberately left untouched; their tabs pick up a folder through the back-fill instead, which keeps two more upstream files out of this table. | Sandboxes gained extra folder mounts, so one sandbox can expose several unrelated folders while their sessions shared a single strip — working across two folders meant constantly reading past the other one's tabs. Deriving the scope rather than persisting a "current folder" makes "the strip always contains the session you are looking at" true by construction instead of by an effect racing the two `home` effects in `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. | Keep the wiring and take upstream's surrounding changes. `tabsForScope` must return the store's own tab objects — the strip compares by reference for the active highlight, the close index and the `` key, so never map to copies. Never add `directory` to `tabKey()`. A tab whose folder cannot be resolved shows in **every** folder of its own server on purpose: it is exactly the tab that has never rendered and so never had a chance to learn where it belongs, and being invisible everywhere is unrecoverable. `folderForDirectory`'s prefix test must stay path-boundary aware — mount guest names dedup with a `-2` suffix, so `/mnt/shared` must not swallow `/mnt/shared-2`. Resolve the primary folder from `sync.data.path`, never `projects.list()[0]`: `projects.open()` prepends, so that is the most recently added mount. | -| Code/Work workspace split: `packages/app/src/context/layout.tsx`, `packages/app/src/components/{app-sidebar,titlebar}.tsx`, `packages/app/src/i18n/{en,es}.ts` | The sidebar gains two tabs above Projects — **Code** (every non-sandbox server: the local sidecar, WSL, remote, SSH) and **Work** (the sandboxes) — backed by the fork-only `packages/app/src/workspace/{side.ts,tabs.tsx}`. `layout.tsx` stores `home.workspace` and `home.lastSession` (per side) and records the latter from an effect on `route()`; `app-sidebar.tsx` renders ``, swaps `global.servers.list()` for a side-filtered memo in all six places it indexed it, and adds an empty-state branch because a side can now have no servers at all; `titlebar.tsx` loses the Home button and its `home.toggle` command (`mod+b`) and filters the tab strip, its reorder and `tab.prev`/`tab.next` to the visible side; `effectiveSide` falls back to Code wherever the Work side is unavailable — originally only where the platform has no sandbox support, now `workSideAvailable()`'s answer (see the opt-in Work row below), which still never collapses on an unloaded sandbox list, since that is indistinguishable from an empty one; `layout.tsx` also holds an effect keeping `home.selection.server` on the showing side, because a sandbox joins the server list only once it has finished starting, so the one-shot correction inside a switch cannot cover a sandbox created moments earlier; the fork-only `DialogAddSandbox` gains an `onCreated` callback so the Work gate switches on the create's own result rather than on the asynchronous sandbox list; `dialog-select-server.tsx` `handleRemove` calls `workspace.removedServer(key)` beside the tab cleanup, which steps back to Code when the sandbox being deleted is the last one; i18n gains `workspace.{code,work,empty.none,empty.starting,empty.stopped,empty.failed,empty.failed.retry}` (`empty.stopped` for a Work side whose sandboxes are all stopped, which unlike starting will never come up on its own; `empty.failed`/`empty.failed.retry` for one whose sandbox failed to start — the empty state shows the runtime's failure message and a retry that calls `platform.sandboxes.start`, because the wait message otherwise left the user watching for a sandbox that already gave up). | Sandboxes were just another row in a list of servers, so the isolated environment and the local machine were one undifferentiated pile. Splitting them makes "am I running on my machine or in a sandbox?" a property of where you are rather than something to infer from a server name. The Home button went because it opened a draft rather than a home screen, which is the behaviour the tabs replace: a tab returns you to that side's last session, falling back to a draft and then to the home screen. All the logic lives in the fork-only module; the upstream files hold wiring only, and every section below the tabs is the same component reading a filtered list. | Keep the wiring and take upstream's surrounding changes. The six `sideServers()` call sites in `app-sidebar.tsx` were `global.servers.list()` — if upstream adds a seventh, filter it too or that branch will show sandboxes under Code. Read `useWorkspaceSide()` rather than `layout.home.workspace()`: it collapses to Code wherever Work is unavailable, which is what keeps the web build — and a sandbox-less desktop profile — coherent. The tab strip filter and `reorderWithinSide` go together — `tabs.reorder()` rejects a partial key list, so a filtered strip without the merge silently ignores drags. | +| Code/Work workspace split: `packages/app/src/context/layout.tsx`, `packages/app/src/components/{app-sidebar,titlebar}.tsx`, `packages/app/src/i18n/{en,es}.ts` | The sidebar gains two tabs above Projects — **Code** (every non-sandbox server: the local sidecar, WSL, remote, SSH) and **Work** (the sandboxes) — backed by the fork-only `packages/app/src/workspace/{side.ts,tabs.tsx}`. `layout.tsx` stores `home.workspace` and `home.lastSession` (per side) and records the latter from an effect on `route()`; `app-sidebar.tsx` renders ``, swaps `global.servers.list()` for a side-filtered memo in all six places it indexed it, and adds an empty-state branch because a side can now have no servers at all; `titlebar.tsx` loses the Home button and its `home.toggle` command (`mod+b`) and filters the tab strip, its reorder and `tab.prev`/`tab.next` to the visible side; `effectiveSide` falls back to Code wherever the Work side is unavailable — originally only where the platform has no sandbox support, now `workSideAvailable()`'s answer (see the opt-in Work row below), which still never collapses on an unloaded sandbox list, since that is indistinguishable from an empty one; `layout.tsx` also holds an effect keeping `home.selection.server` on the showing side, because a sandbox joins the server list only once it has finished starting, so the one-shot correction inside a switch cannot cover a sandbox created moments earlier; the fork-only `DialogAddSandbox` gains an `onCreated` callback so the Work gate switches on the create's own result rather than on the asynchronous sandbox list; `dialog-select-server.tsx` `handleRemove` calls `workspace.removedServer(key)` beside the tab cleanup, which steps back to Code when the sandbox being deleted is the last one; resuming a side's last session also selects the project that session lives in (`sessionProjectSelection` in `workspace/side.ts`, which maps the session's directory to the owning folder of the server's project list) and touches it as that server's last project — the switch otherwise set a server with no directory and returned, leaving the sidebar on no project at all beside a restored session; i18n gains `workspace.{code,work,empty.none,empty.starting,empty.stopped,empty.failed,empty.failed.retry}` (`empty.stopped` for a Work side whose sandboxes are all stopped, which unlike starting will never come up on its own; `empty.failed`/`empty.failed.retry` for one whose sandbox failed to start — the empty state shows the runtime's failure message and a retry that calls `platform.sandboxes.start`, because the wait message otherwise left the user watching for a sandbox that already gave up). | Sandboxes were just another row in a list of servers, so the isolated environment and the local machine were one undifferentiated pile. Splitting them makes "am I running on my machine or in a sandbox?" a property of where you are rather than something to infer from a server name. The Home button went because it opened a draft rather than a home screen, which is the behaviour the tabs replace: a tab returns you to that side's last session, falling back to a draft and then to the home screen. All the logic lives in the fork-only module; the upstream files hold wiring only, and every section below the tabs is the same component reading a filtered list. | Keep the wiring and take upstream's surrounding changes. The six `sideServers()` call sites in `app-sidebar.tsx` were `global.servers.list()` — if upstream adds a seventh, filter it too or that branch will show sandboxes under Code. Read `useWorkspaceSide()` rather than `layout.home.workspace()`: it collapses to Code wherever Work is unavailable, which is what keeps the web build — and a sandbox-less desktop profile — coherent. The tab strip filter and `reorderWithinSide` go together — `tabs.reorder()` rejects a partial key list, so a filtered strip without the merge silently ignores drags. | | Workspace (worktree) bar is Code-only: `packages/app/src/pages/new-session.tsx` | `showWorkspaceBar` gains a third condition, `sideForServer(serverSDK().server) === "code"`, so the "Run session in → Local repository / New workspace / Workspace…" chip is not offered on Work. Work falls through to the same `PromptGitStatus` branch a non-git project already takes, which shows the branch on its own. Nothing else moves: `newSessionWorktree()` already returns `"main"` whenever the bar is hidden, and `"main"` in `prompt-input/submit.ts` means "leave `sessionDirectory` at `sdk().directory`" — it does not redirect a session to the project root. The bar remains gated on `VITE_CODEV_CHANNEL !== "prod"`, which is upstream's own gate. | A workspace is a git worktree created by the server that runs the session, and `Worktree.makeWorktreeInfo` roots it at `Global.Path.data/worktree/` (`packages/opencode/src/worktree/index.ts`). In a sandbox that is the guest's own data dir: it is not one of the host folders the sandbox mounts (`packages/codev-sandbox/src/mounts.ts`), so the work happens where the user's editor cannot see it, and a reset re-clones the guest disk and takes it along. Meanwhile the chip says "Local" under a monitor icon, which is the opposite of true. Three storage designs were costed before choosing to hide it — a checkout inside the mounted repo, a dedicated hidden worktree mount per sandbox, or leaving it on the guest disk — and none of them make a guest worktree usable from the host, because a worktree's `.git` file holds an absolute path back to `/.git/worktrees/` and that path is `/mnt/` in the guest and the real path on the host. Note also that `git worktree add` runs with `cwd` = the project worktree, i.e. the mounted host repo, so it registers the worktree in the user's real `.git` whatever we do with the checkout. | Keep the gate. If Work should offer workspaces later, the storage question has to be answered first — where the checkout lives, and what a reset does to it — not just the label; re-read the reasoning column before reopening it. Read the side from `sideForServer(serverSDK().server)`, matching the project picker row above, so a draft is judged by its own server rather than by whichever side the sidebar is showing. If upstream changes what `showWorkspaceBar` gates, keep the third condition rather than reverting to the two-term memo. | | New-session project picker on Work: `packages/app/src/pages/session/composer/session-composer-controls.ts`, `packages/app/src/components/{prompt-project-selector,app-sidebar}.tsx` | Extends the Code/Work split row above to the composer's project picker. `createPromptProjectControls` indexed `server.list` in four places — the grouped project list, the two server lookups behind select/add, and the `server` field the controls hand back — so a draft on the Work side listed the local machine's projects under a "Local Server" heading beside the sandbox's own, and picking one moved the session onto that server. All four read a `sideServers()` memo instead: `serversForSide(server.list, sideForServer(projectServer()))`, from the fork-only `src/workspace/side.ts`. The side comes from the draft's **own** server (`serverSDK().server`) rather than `useWorkspaceSide()`, so a draft always offers what it can actually run on whichever side happens to be showing. Nothing else changes: with one server on the side the picker takes the same single-server branch Code has always taken (`layout.projects.list()`, or the draft's server ctx for a draft route), which drops the group headings and the "Add project" submenu the way it does for a lone local server. **Add project on Work is a mount**: `addProject` opens the fork-only `DialogSandboxMounts` for that sandbox instead of `pickDirectory`, through the fork-only `src/sandbox/mount-dialog.tsx` — upstream's picker browses the server it is pointed at, which for a sandbox is the guest's own filesystem. `PromptProjectControls` gains an optional `mount` flag and `prompt-project-selector.tsx` reads it in two label getters (`labels.add`, `labels.new`) so the item says "Mount project" (`sandbox.server.mounts`, the string the Work sidebar's button already uses); nothing else in that component changes. `app-sidebar.tsx`'s Work branch and the fork-only `mount-prompt.tsx` both route through the same hook rather than repeating the lazy import. | The two sides exist so that "am I on my machine or in a sandbox?" is a property of where you are. A picker that offers both breaks that in the one place it matters most — the moment a session is created — and the failure is silent: the session opens on the local machine with no sandbox isolation, from a screen the user reached through the Work tab. | Keep the filter and take upstream's surrounding changes. If upstream adds a fifth `server.list` read here, filter it too. Read the side from `sideForServer(projectServer())`, not `useWorkspaceSide()` — a draft route mounts its own server (`ResolvedDraftRoute` in `app.tsx`), and the sidebar's side is not guaranteed to agree with it. The `sideServers().length <= 1` branch must stay a length test on the *side* list, not on `server.list`: on a machine with one sandbox and one local server the old test sent Work down the multi-server path. | | `packages/app/src/context/server.tsx` | `createServerProjects.close()` wraps its two store writes — `remove(directory)` on `projects`, then the `recentlyClosed` record — in `batch()` (already imported). The recently-closed list is computed before the batch so the pair is one update. | The two writes are separate store keys and Solid flushes effects after each one, so between them a project is in neither list. Nothing upstream observes that gap, but the fork's `src/sandbox/projects.ts` seeding effect reconciles against **both** lists, saw a mounted folder that was neither open nor closed, and opened it straight back up — closing a Work folder was undone the instant it happened. | Keep the batch. On conflict take upstream's `close()` and re-wrap the two writes. Any future write pair that spans `projects` and `recentlyClosed` needs the same treatment; regression cover is `packages/app/test-browser/sandbox-projects.test.ts` ("closing a mounted folder sticks when the guest knows the project"). | diff --git a/packages/app/src/workspace/side.ts b/packages/app/src/workspace/side.ts index 046352def1..a99fa1b782 100644 --- a/packages/app/src/workspace/side.ts +++ b/packages/app/src/workspace/side.ts @@ -88,6 +88,24 @@ export function folderForDirectory(directory: string | undefined, folders: strin .sort((a, b) => pathKey(b).length - pathKey(a).length)[0] } +// The project a session belongs to, as the sidebar reckons it: the folder of the +// server's project list that owns the session's directory, which is not always +// the directory itself — a session can sit in a subdirectory of one. +// +// Pure and separate from useWorkspaceSwitch for the same reason folderEntry is: +// the Work side has no sandbox platform in the web e2e harness, so this decision +// cannot be driven from a browser and is the part worth pinning down. +export function sessionProjectSelection(input: { + server: ServerConnection.Key + directory: string | undefined + folders: string[] +}) { + if (!input.directory) return undefined + const owner = folderForDirectory(input.directory, input.folders) + if (!owner) return undefined + return { server: input.server, directory: owner } +} + export function tabsForScope(tabs: Tab[], scope: TabScope) { return tabs.filter(inScope(scope)) } diff --git a/packages/app/src/workspace/tabs.tsx b/packages/app/src/workspace/tabs.tsx index a28cb9d75c..170476f819 100644 --- a/packages/app/src/workspace/tabs.tsx +++ b/packages/app/src/workspace/tabs.tsx @@ -21,6 +21,7 @@ import { leavesWorkEmpty, nextTabScope, selectionForSide, + sessionProjectSelection, sideForServer, WORKSPACE_SIDES, workSideAvailable, @@ -274,6 +275,13 @@ export function useWorkspaceSwitch() { const last = layout.home.lastSession(side) const lastServer = last && global.servers.list().find((conn) => ServerConnection.key(conn) === last.server) if (last && lastServer && (await folder.sessionAlive(lastServer, last.sessionId))) { + // Select the project that session lives in, not just its server. The + // selection above carries a server only, and this branch returns without + // ever choosing a folder — so switching sides restored the session and + // its tabs while the sidebar sat on no project at all, with Projects + // unhighlighted and Pinned and Recents empty beside an open session. + // (The folder branch below already selects one, through enterFolder.) + selectSessionProject(lastServer, last.sessionId) navigate(sessionHref(last.server, last.sessionId)) return } @@ -299,6 +307,26 @@ export function useWorkspaceSwitch() { await folder.enterFolder(server, directory) } + // The project a session belongs to, as the sidebar reckons it: the folder of + // the server's project list that owns the session's directory, which is not + // always the directory itself — a session can sit in a subdirectory of one. + // sessionAlive resolved it a moment ago, so the store already has it. + const selectSessionProject = (conn: ServerConnection.Any, sessionId: string) => { + const ctx = global.ensureServerCtx(conn) + const directory = ctx.sync.session.get(sessionId)?.directory + if (!directory) return + const selection = sessionProjectSelection({ + server: ServerConnection.key(conn), + directory, + folders: ctx.projects.list().map((project) => project.worktree), + }) + if (!selection) return + layout.home.setSelection(selection) + // Keep the side's last project pointing where it just landed, so a later + // launch seeds the same folder this switch chose. + ctx.projects.touch(selection.directory) + } + return { side, select(next: WorkspaceSide) { diff --git a/packages/app/test-browser/workspace-side.test.ts b/packages/app/test-browser/workspace-side.test.ts index c4d884d9e1..2c2ed1aaa6 100644 --- a/packages/app/test-browser/workspace-side.test.ts +++ b/packages/app/test-browser/workspace-side.test.ts @@ -12,6 +12,7 @@ import { reorderWithinSide, selectionForSide, serversForSide, + sessionProjectSelection, sideForServer, tabsForScope, tabsForSide, @@ -510,3 +511,42 @@ test("all sandboxes stopped is its own message", () => { message: "workspace.empty.stopped", }) }) + +// Switching sides restores that side's last session; the sidebar has to land on +// the project that session lives in, or Projects sits unhighlighted and Pinned +// and Recents come up empty beside an open session. +test("selects the project a resumed session belongs to", () => { + expect( + sessionProjectSelection({ + server: "local" as ServerConnection.Key, + directory: "/w/alpha", + folders: ["/w/alpha", "/w/beta"], + }), + ).toEqual({ server: "local", directory: "/w/alpha" }) +}) + +// A session can sit in a subdirectory of the project that owns it. +test("selects the folder that owns a nested session directory", () => { + expect( + sessionProjectSelection({ + server: "local" as ServerConnection.Key, + directory: "/w/alpha/packages/core", + folders: ["/w/alpha", "/w/beta"], + }), + ).toEqual({ server: "local", directory: "/w/alpha" }) +}) + +// Nothing to select rather than a wrong guess: the session's directory has not +// loaded yet, or belongs to no project this server lists. +test("selects nothing when no project owns the session", () => { + expect( + sessionProjectSelection({ server: "local" as ServerConnection.Key, directory: undefined, folders: ["/w/alpha"] }), + ).toBeUndefined() + expect( + sessionProjectSelection({ + server: "local" as ServerConnection.Key, + directory: "/elsewhere/gone", + folders: ["/w/alpha"], + }), + ).toBeUndefined() +}) From 9567022c37b761e6f6ea5894936e6b7f9ebbc75d Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sat, 22 Aug 2026 17:49:33 +0700 Subject: [PATCH 2/2] docs: correct the PR link for the side-switch project fix Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e86b53b646..de7fabf0f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -289,7 +289,7 @@ already in use` and no way forward short of finding the file in a temporary no project at all: nothing highlighted under Projects, and Pinned and Recents empty beside an open session. It now selects the project that session lives in, so both sides come back to where you left them. - ([#302](https://github.com/quickbeard/codev-code/pull/302)) + ([#303](https://github.com/quickbeard/codev-code/pull/303)) - **The sidebar opens on the project you were last in, and shows only its sessions** — starting the app with no project picked listed every project's