diff --git a/packages/app/src/context/global.tsx b/packages/app/src/context/global.tsx index 83438898b54d..cfaf85d7d946 100644 --- a/packages/app/src/context/global.tsx +++ b/packages/app/src/context/global.tsx @@ -1,7 +1,13 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { createEffect, createMemo, createRoot } from "solid-js" import { createStore } from "solid-js/store" -import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server" +import { + createServerProjects, + RECENTLY_CLOSED_DISPLAY_LIMIT, + ServerConnection, + useServer, + visibleProjectEntries, +} from "./server" import { pathKey } from "@/utils/path-key" import { useServerHealth } from "@/utils/server-health" import { createServerSdkContext } from "./server-sdk" @@ -127,7 +133,9 @@ function createServerCtx( return base } - const projectsList = createMemo(() => projects.list().map(enrich)) + const projectsList = createMemo(() => + visibleProjectEntries(projects.list(), sync.data.project, projects.recentlyClosed()).map(enrich), + ) const recentlyClosedList = createMemo(() => { const known = new Set(sync.data.project.map((project) => pathKey(project.worktree))) return projects diff --git a/packages/app/src/context/server.test.ts b/packages/app/src/context/server.test.ts index d78880ecf104..2f51891e9d32 100644 --- a/packages/app/src/context/server.test.ts +++ b/packages/app/src/context/server.test.ts @@ -7,6 +7,7 @@ import { nextServerAfterRemoval, resolveServerList, ServerConnection, + visibleProjectEntries, } from "./server" import { ServerScope } from "@/utils/server-scope" @@ -243,3 +244,33 @@ describe("migrateCanonicalLocalServerState", () => { }) }) }) + +describe("visibleProjectEntries", () => { + test("shows server projects until the first bookmark exists", () => { + expect(visibleProjectEntries([], [{ worktree: "/repo/a" }, { worktree: "/repo/b" }])).toEqual([ + { worktree: "/repo/a", expanded: false }, + { worktree: "/repo/b", expanded: false }, + ]) + }) + + test("keeps server projects after the first bookmark appears", () => { + const bookmarked = [{ worktree: "/repo/a", expanded: true }] + expect(visibleProjectEntries(bookmarked, [{ worktree: "/repo/a" }, { worktree: "/repo/b" }])).toEqual([ + { worktree: "/repo/a", expanded: true }, + { worktree: "/repo/b", expanded: false }, + ]) + }) + + test("deduplicates server projects already bookmarked by normalized path", () => { + const bookmarked = [{ worktree: "/repo/a/", expanded: true }] + expect(visibleProjectEntries(bookmarked, [{ worktree: "/repo/a" }])).toEqual([ + { worktree: "/repo/a/", expanded: true }, + ]) + }) + + test("hides server projects the user closed", () => { + expect(visibleProjectEntries([], [{ worktree: "/repo/a" }, { worktree: "/repo/b" }], ["/repo/a"])).toEqual([ + { worktree: "/repo/b", expanded: false }, + ]) + }) +}) diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 450129f45884..76823f3811bb 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -145,6 +145,26 @@ export function createServerProjects(input: { } } +// Bookmarks are authoritative and keep their order. Server projects stay discoverable alongside +// them so a fresh web client (which never seeds bookmarks) still shows the `/project` list, and so +// bookmarks added later do not evict the remaining server projects. `hidden` is the per-server +// recently-closed set: server projects the user explicitly closed stay out until reopened. +export function visibleProjectEntries( + bookmarked: ReadonlyArray, + server: ReadonlyArray<{ worktree: string }>, + hidden: ReadonlyArray = [], +): StoredProject[] { + const seen = new Set(bookmarked.map((project) => pathKey(project.worktree))) + const hiddenKeys = new Set(hidden.map((project) => pathKey(project))) + const discovered = server.flatMap((project) => { + const key = pathKey(project.worktree) + if (hiddenKeys.has(key) || seen.has(key)) return [] + seen.add(key) + return [{ worktree: project.worktree, expanded: false }] + }) + return [...bookmarked, ...discovered] +} + export function resolveServerList(input: { props?: Array stored: StoredServer[]