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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
([#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
menu without ever searching, so unless you already had editor tabs open there
Expand Down
2 changes: 2 additions & 0 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 122 additions & 0 deletions packages/app/e2e/regression/sidebar-project-scope.spec.ts
Original file line number Diff line number Diff line change
@@ -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")
})
5 changes: 4 additions & 1 deletion packages/app/e2e/utils/mock-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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" },
Expand Down
40 changes: 39 additions & 1 deletion packages/app/src/pages/home-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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)
})

Expand Down Expand Up @@ -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.
//
Expand Down
21 changes: 20 additions & 1 deletion packages/app/test-browser/home-session-records.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
})
})
Loading