diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e51e8eb60..de7fabf0f3 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. + ([#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 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() +})