From cc7b63fc8821e346387fb689f2e98442ddc04426 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sat, 22 Aug 2026 17:20:10 +0700 Subject: [PATCH 1/2] fix(app): scope the sidebar to one project and open on the last one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh window's selection carries a server but no directory, and projectDirectories fell back to the union of every project's directories — so Recents listed sessions from every project, and rows in two different projects could look open at once (a row is marked open when its session has a tab anywhere, while tab strips are per-project). Return no directories when nothing is selected, and seed the selection once per model from the server's last project so a fresh window is not blank. A deliberate deselection still shows nothing. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 11 ++ DIVERGENCES.md | 2 + .../regression/sidebar-project-scope.spec.ts | 122 ++++++++++++++++++ packages/app/e2e/utils/mock-server.ts | 5 +- packages/app/src/pages/home-model.tsx | 40 +++++- .../test-browser/home-session-records.test.ts | 21 ++- 6 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 packages/app/e2e/regression/sidebar-project-scope.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 94df0cd7e7..3fd2ea3358 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -284,6 +284,17 @@ already in use` and no way forward short of finding the file in a temporary ### Desktop / Web UI +- **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 + alongside the folder you were. Worse, two of them could look open at once, + since a row is marked open when its session has a tab anywhere while the tab + strip only shows the current project's. A new window now opens on the project + you used last and lists just that project's sessions; if no project is + selected at all — you can clear it by clicking the selected one — the list is + empty rather than everything at once. + ([#299](https://github.com/quickbeard/codev-code/pull/299)) + - **Typing `@` lists your files again** — the `@` menu was meant to offer files first, but on a bare `@` it showed only agents and references: it filled the menu without ever searching, so unless you already had editor tabs open there diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 7bef921757..e30206afb0 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -292,6 +292,8 @@ from the squash message first, or use a plain merge. | `packages/core/src/filesystem/search.ts`, `packages/app/e2e/utils/mock-server.ts` | `fffLayer`'s `find` drops the worktree-root entry: fff ranks the root itself into an empty-query result, where it leads every list as a bare `/` — a mention naming no file — while the ripgrep fallback never emits it. The e2e mock server gains an `agents` config field (defaulting to the previous single primary agent) so a spec can supply subagents; the `@` menu hides primary agents, so the default list left nothing to rank files against. | Empty-query `@` menus (see the `@` autocomplete rows) made the root visible for the first time, at the top of the list. Both search layers should answer the same way. | Keep the root filter and the `agents` default; take upstream's surrounding changes. | +| Sidebar scoping: `packages/app/src/pages/home-model.tsx`, `packages/app/e2e/utils/mock-server.ts` | `projectDirectories` returns `[]` when no project is selected instead of the union of every project's directories, so Pinned and Recents list one project's sessions or none; and a `homeSelectionSeed` helper plus a once-per-model effect opens a window with no stored selection on the server's last project (`projects.last()`), but only while that server still lists it. The seeding is guarded so a deliberate deselection — clicking the selected project — keeps showing nothing. The e2e mock server gains a `projects` config field (defaulting to `[config.project]`) for specs that need more than one. | A fresh window has a selection with a server but no directory, so the union listed every project on the server at once: sessions from folders you were not in, and — because a row is marked open when its session has a tab anywhere while tab strips are per-project — rows in two different projects looking active at the same time. | Keep the empty return and the seeding guard together: seeding without the empty return changes nothing, and the empty return without seeding opens every fresh window on a blank sidebar. Take upstream's surrounding changes. | + ## Known rebrand gaps - **UI branding**: TUI banner, default-theme accent, desktop/web app names, favicon/PWA diff --git a/packages/app/e2e/regression/sidebar-project-scope.spec.ts b/packages/app/e2e/regression/sidebar-project-scope.spec.ts new file mode 100644 index 0000000000..3c059bdce7 --- /dev/null +++ b/packages/app/e2e/regression/sidebar-project-scope.spec.ts @@ -0,0 +1,122 @@ +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +const sidebar = (page: Page) => page.locator('[data-component="app-sidebar"]') +const rows = (page: Page) => sidebar(page).locator('[data-component="sidebar-session-row"]') + +const alpha = "C:/OpenCode/Alpha" +const beta = "C:/OpenCode/Beta" + +const project = (id: string, worktree: string, name: string) => ({ + id, + worktree, + vcs: "git", + name, + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], +}) + +const session = (id: string, directory: string, title: string, updated: number) => ({ + id, + slug: id, + projectID: directory === alpha ? "proj_alpha" : "proj_beta", + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: updated }, +}) + +// The sidebar listed every project's sessions whenever no project was selected, +// which is how a fresh window starts: Recents became a pile of two projects, and +// because a row is marked open when its session has a tab anywhere while tab +// strips are per-project, rows in two different projects looked active at once. +test("lists only the last project's sessions on a fresh window", async ({ page }) => { + await mockOpenCodeServer(page, { + directory: alpha, + project: project("proj_alpha", alpha, "alpha"), + projects: [project("proj_alpha", alpha, "alpha"), project("proj_beta", beta, "beta")], + provider: { all: [], connected: [], default: {} }, + sessions: [ + session("ses_beta_one", beta, "Beta session", 1700000002000), + session("ses_alpha_one", alpha, "Alpha session", 1700000001000), + ], + pageMessages: () => ({ items: [] }), + }) + // Both projects open, alpha last — and no stored home selection, so the + // sidebar has to decide for itself. + await page.addInitScript( + ([a, b]) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { + local: [ + { worktree: a, expanded: true }, + { worktree: b, expanded: true }, + ], + }, + lastProject: { local: a }, + }), + ) + }, + [alpha, beta], + ) + + await page.goto("/") + await expectAppVisible(rows(page).first()) + + // Alpha is the last project, so only its session is listed. + await expect(rows(page)).toHaveCount(1) + await expect(sidebar(page)).toContainText("Alpha session") + await expect(sidebar(page)).not.toContainText("Beta session") +}) + +// Deselecting a project (clicking the selected one) has to show nothing rather +// than falling back to every project's sessions — the pile the union produced. +// Selecting beta first is what proves the session index actually loaded, so the +// empty assertion afterwards means "scoped out", not "not fetched yet". +test("shows no sessions once the selected project is deselected", async ({ page }) => { + await mockOpenCodeServer(page, { + directory: alpha, + project: project("proj_alpha", alpha, "alpha"), + projects: [project("proj_alpha", alpha, "alpha"), project("proj_beta", beta, "beta")], + provider: { all: [], connected: [], default: {} }, + sessions: [ + session("ses_beta_one", beta, "Beta session", 1700000002000), + session("ses_alpha_one", alpha, "Alpha session", 1700000001000), + ], + pageMessages: () => ({ items: [] }), + }) + await page.addInitScript( + ([a, b]) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { + local: [ + { worktree: a, expanded: true }, + { worktree: b, expanded: true }, + ], + }, + lastProject: { local: a }, + }), + ) + }, + [alpha, beta], + ) + + await page.goto("/") + await expectAppVisible(rows(page).first()) + + const betaRow = sidebar(page).locator('[data-component="home-project-row"]', { hasText: "beta" }).first() + await betaRow.click() + await expect(sidebar(page)).toContainText("Beta session") + await expect(sidebar(page)).not.toContainText("Alpha session") + + // Clicking the selected project clears the selection. + await betaRow.click() + await expect(rows(page)).toHaveCount(0) + await expect(sidebar(page)).not.toContainText("Beta session") + await expect(sidebar(page)).not.toContainText("Alpha session") +}) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index e8b2bf1270..964e1937c2 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -25,6 +25,9 @@ export interface MockServerConfig { findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown sessionStatus?: unknown agents?: unknown[] + // Extra projects beyond `project`, for specs about how the sidebar scopes + // itself to one of them. + projects?: unknown[] } export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { @@ -39,7 +42,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { directory: config.directory, home: "C:/OpenCode", }, - "/project": [config.project], + "/project": config.projects ?? [config.project], "/project/current": config.project, "/agent": config.agents ?? [{ name: "build", mode: "primary" }], "/vcs": { branch: "main", default_branch: "main" }, diff --git a/packages/app/src/pages/home-model.tsx b/packages/app/src/pages/home-model.tsx index c69ae20c2e..93f909ce10 100644 --- a/packages/app/src/pages/home-model.tsx +++ b/packages/app/src/pages/home-model.tsx @@ -60,6 +60,15 @@ export function isBackgroundOpen(event: MouseEvent) { }) } +// Which project a window with no selection should open on: the one this server +// was last in, and only while it still lists it. A folder can be closed or +// removed between runs, and selecting a directory that is gone shows nothing at +// all — which is indistinguishable from the bug this seeding exists to fix. +export function homeSelectionSeed(input: { last?: string; projects: string[] }) { + if (!input.last) return undefined + return input.projects.some((worktree) => pathKey(worktree) === pathKey(input.last!)) ? input.last : undefined +} + export function buildHomeSessionRecords(input: { sessions: () => Session[] projectDirectories: () => string[] @@ -176,7 +185,14 @@ export function createHomeModel() { const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])] const projectDirectories = createMemo(() => { const project = selectedProject() - if (!project) return projects().flatMap(directories) + // Fork: no project selected means no sessions, not every project's. The + // union made Pinned and Recents a pile of every project on the server, so + // sessions from a folder you were not in showed up — and, since a row is + // marked open when its session has a tab anywhere and tab strips are + // per-project, two rows in two different projects could look active at + // once. Deselecting a project (clicking the selected one) is a real state, + // and this is what it should show: nothing. + if (!project) return [] return directories(project) }) @@ -252,6 +268,28 @@ export function createHomeModel() { layout.home.setSelection(next) } + // Fork: open on the project you were last in. The stored selection carries a + // server but no directory until something picks one, and with the union above + // gone that would leave a fresh window's sidebar empty. Seeded once per model + // so a later deselection stays deselected — the guard is what separates "not + // chosen yet" from "deliberately cleared", which the selection itself cannot + // express. + let seededSelection = false + createEffect(() => { + if (seededSelection) return + if (selection().directory) { + seededSelection = true + return + } + const seed = homeSelectionSeed({ + last: focusedServerCtx()?.projects.last(), + projects: projects().map((project) => project.worktree), + }) + if (!seed) return + seededSelection = true + setSelection({ server: selection().server, directory: seed }) + }) + // Keeps the selection pointing at a server that still exists. Idempotent, so // it is safe with both the sidebar and the home screen holding a model. // diff --git a/packages/app/test-browser/home-session-records.test.ts b/packages/app/test-browser/home-session-records.test.ts index 1f6e01fe14..0da1c58c7a 100644 --- a/packages/app/test-browser/home-session-records.test.ts +++ b/packages/app/test-browser/home-session-records.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import type { Session } from "@opencode-ai/sdk/v2/client" import type { LocalProject } from "@/context/layout" -import { buildHomeSessionRecords, descendantIDs, sessionAfterRemoval } from "@/pages/home-model" +import { buildHomeSessionRecords, descendantIDs, homeSelectionSeed, sessionAfterRemoval } from "@/pages/home-model" // The recents list the persistent sidebar and the home screen both render comes // out of buildHomeSessionRecords, and the subtree a delete evicts comes out of @@ -257,3 +257,22 @@ describe("sessionAfterRemoval", () => { expect(sessionAfterRemoval(reversed, "recent_c", new Set(["recent_c"]))?.id).toBe("recent_b") }) }) + +describe("homeSelectionSeed", () => { + // A window with no selection opens on the project it was last in; without + // this the sidebar starts empty, since an unselected project now lists no + // sessions rather than every project's. + test("opens on the server's last project", () => { + expect(homeSelectionSeed({ last: "/w/b", projects: ["/w/a", "/w/b"] })).toBe("/w/b") + }) + + // A folder closed or removed between runs would select a directory that is + // gone, which shows nothing — the very state the seeding exists to avoid. + test("ignores a last project the server no longer lists", () => { + expect(homeSelectionSeed({ last: "/w/gone", projects: ["/w/a"] })).toBeUndefined() + }) + + test("has nothing to open on without a last project", () => { + expect(homeSelectionSeed({ last: undefined, projects: ["/w/a"] })).toBeUndefined() + }) +}) From a0364b4a82ebcffd00462f5fb32063497d1d1c10 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sat, 22 Aug 2026 17:20:37 +0700 Subject: [PATCH 2/2] docs: correct the PR link for the sidebar scoping 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 3fd2ea3358..c62f9cb470 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -293,7 +293,7 @@ already in use` and no way forward short of finding the file in a temporary you used last and lists just that project's sessions; if no project is selected at all — you can clear it by clicking the selected one — the list is empty rather than everything at once. - ([#299](https://github.com/quickbeard/codev-code/pull/299)) + ([#300](https://github.com/quickbeard/codev-code/pull/300)) - **Typing `@` lists your files again** — the `@` menu was meant to offer files first, but on a bare `@` it showed only agents and references: it filled the