diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 5dfa543c9..a43dd15e2 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -321,13 +321,13 @@ describe("layout workspace helpers", () => { }) describe("sessionListDirectories", () => { - test("uses opened projects when present", () => { + test("unions opened and server projects, opened first", () => { const opened = [{ worktree: "/a", sandboxes: ["/a-sbx"] }, { worktree: "/b" }] const server = [{ worktree: "/server", sandboxes: ["/server-sbx"] }] - expect(sessionListDirectories(opened, server)).toEqual(["/a", "/a-sbx", "/b"]) + expect(sessionListDirectories(opened, server)).toEqual(["/a", "/a-sbx", "/b", "/server", "/server-sbx"]) }) - test("falls back to server projects when nothing is opened (fresh client, amicode#288)", () => { + test("returns only server projects when nothing is opened (fresh client, amicode#288)", () => { const server = [ { worktree: "/", sandboxes: ["/staging"] }, { worktree: "/amicode" }, @@ -336,7 +336,13 @@ describe("layout workspace helpers", () => { expect(sessionListDirectories([], server)).toEqual(["/", "/staging", "/amicode", "/opencode"]) }) - test("fallback dedupes and drops empty entries", () => { + test("dedupes across opened and server projects", () => { + const opened = [{ worktree: "/a", sandboxes: ["/shared"] }] + const server = [{ worktree: "/a" }, { worktree: "/b", sandboxes: ["/shared"] }] + expect(sessionListDirectories(opened, server)).toEqual(["/a", "/shared", "/b"]) + }) + + test("dedupes and drops empty entries from server-only path", () => { const server = [ { worktree: "/a", sandboxes: ["/a"] }, { worktree: "/a" }, diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 4105a082c..e99324465 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -59,24 +59,33 @@ export const displayName = (project: { name?: string; worktree: string }) => /** * Directories whose sessions the list surfaces (Sessions dropdown, home list) - * aggregate over: the user's opened projects, falling back to the server's - * registered projects when nothing has been opened yet (fresh client, empty - * persisted registry — amicode#288). Session listing only; this must NOT feed - * the project switcher, or closing a server-registered project becomes - * impossible (the fallback would re-add it). + * — always the union of opened projects (first) and server-registered projects + * (deduplicated, appended). Every historical project the server knows about + * contributes sessions, not just the ones currently open. Session listing only; + * this must NOT feed the project switcher, or closing a server-registered + * project becomes impossible (amicode#839, replacing the fallback-only behavior + * from amicode#288). */ export function sessionListDirectories( opened: { worktree: string; sandboxes?: string[] }[], serverProjects: { worktree: string; sandboxes?: string[] }[], ): string[] { - const dirs = opened.flatMap((p) => [p.worktree, ...(p.sandboxes ?? [])]) - if (dirs.length > 0) return dirs const seen = new Set() - return serverProjects.flatMap((p) => [p.worktree, ...(p.sandboxes ?? [])]).filter((d) => { - if (!d || seen.has(d)) return false + const result: string[] = [] + const add = (d: string) => { + if (!d || seen.has(d)) return seen.add(d) - return true - }) + result.push(d) + } + for (const p of opened) { + add(p.worktree) + for (const s of p.sandboxes ?? []) add(s) + } + for (const p of serverProjects) { + add(p.worktree) + for (const s of p.sandboxes ?? []) add(s) + } + return result } export function toggleHomeProjectSelection( diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 2a81c8d63..2b0c887c0 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -268,11 +268,11 @@ function ResolvedTargetSessionRoute() { }) // Notify the extension which project this session is bound to so the - // sidebar highlight tracks the active tab. autoExpand=false: session - // navigation should only change the highlight, never toggle folder state. + // sidebar highlight tracks the active tab. mode="expand": opening a session + // expands its project folder if collapsed, without collapsing others. createEffect(() => { const dir = directory() - if (dir) notifyProjectSelected(dir, false) + if (dir) notifyProjectSelected(dir, "expand") }) return ( diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index 3951072d9..0a553a68d 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -109,7 +109,7 @@ export function createPromptProjectControls() { if (pathKey(worktree) === pathKey(sdk().directory)) { const fallback = hiddenProjectWorktree() if (search.draftId && fallback) { - notifyProjectSelected(fallback, true) + notifyProjectSelected(fallback, "reset") tabs.updateDraft(search.draftId, { directory: fallback }) } return diff --git a/packages/app/src/utils/amicode-workspace-projects.test.ts b/packages/app/src/utils/amicode-workspace-projects.test.ts index f207b6364..8f0614d96 100644 --- a/packages/app/src/utils/amicode-workspace-projects.test.ts +++ b/packages/app/src/utils/amicode-workspace-projects.test.ts @@ -12,34 +12,34 @@ describe("amicode workspace-projects — notifyProjectSelected (#663)", () => { notifyProjectSelected("/Users/jj/harmoniqs") expect(calls).toHaveLength(1) const [msg, origin] = calls[0] as [Record, string] - expect(msg).toEqual({ source: "amicode", kind: "project-selected", path: "/Users/jj/harmoniqs", autoExpand: true }) + expect(msg).toEqual({ source: "amicode", kind: "project-selected", path: "/Users/jj/harmoniqs", mode: "reset" }) expect(origin).toBe("*") } finally { window.parent.postMessage = orig } }) - test("explicit selection defaults to autoExpand=true", () => { + test("explicit selection defaults to mode 'reset'", () => { const calls: unknown[] = [] const orig = window.parent.postMessage window.parent.postMessage = (...args: unknown[]) => { calls.push(args) } try { notifyProjectSelected("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/projects/foo") const [msg] = calls[0] as [Record] - expect(msg.autoExpand).toBe(true) + expect(msg.mode).toBe("reset") } finally { window.parent.postMessage = orig } }) - test("session navigation passes autoExpand=false", () => { + test("session navigation passes mode 'expand'", () => { const calls: unknown[] = [] const orig = window.parent.postMessage window.parent.postMessage = (...args: unknown[]) => { calls.push(args) } try { - notifyProjectSelected("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/projects/foo", false) + notifyProjectSelected("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/projects/foo", "expand") const [msg] = calls[0] as [Record] - expect(msg.autoExpand).toBe(false) + expect(msg.mode).toBe("expand") } finally { window.parent.postMessage = orig } @@ -85,11 +85,11 @@ describe("session-composer-controls — toggle deselect (#673)", () => { expect(toggleBranch).toMatch(/notifyProjectSelected/) }) - test("deselect notification passes autoExpand true so the folder collapses", () => { + test("deselect notification passes mode 'reset' so the sidebar collapses the old folder", () => { const selectFn = ctrlSrc.slice(ctrlSrc.indexOf("const selectProject")) // The toggle branch's notifyProjectSelected call (before the main one) const toggleBranch = selectFn.slice(0, selectFn.indexOf("notifyProjectSelected(worktree)")) - // Must NOT pass false — needs true (or default) so the sidebar collapses the old folder - expect(toggleBranch).not.toMatch(/notifyProjectSelected\([^)]*,\s*false/) + // Must NOT pass "none" — needs "reset" (or default) so the sidebar collapses the old folder + expect(toggleBranch).not.toMatch(/notifyProjectSelected\([^)]*,\s*["']none["']/) }) }) diff --git a/packages/app/src/utils/amicode-workspace-projects.ts b/packages/app/src/utils/amicode-workspace-projects.ts index da20fc16b..8975e0f90 100644 --- a/packages/app/src/utils/amicode-workspace-projects.ts +++ b/packages/app/src/utils/amicode-workspace-projects.ts @@ -29,9 +29,13 @@ export function requestAddWorkspaceProject(): void { } /** Notify the extension host that the user selected a project in the dropdown. - * The extension forwards this to the sidebar (collapse others, expand selected). - * autoExpand=true (default) for explicit dropdown clicks; false for session - * navigation (highlight only, don't toggle folder state). */ -export function notifyProjectSelected(worktree: string, autoExpand = true): void { - window.parent.postMessage({ source: "amicode", kind: "project-selected", path: worktree, autoExpand }, "*") + * The extension forwards this to the sidebar with a three-valued mode: + * - "reset" (default) — expand selected, collapse others (project selector) + * - "expand" — expand selected if collapsed, leave others alone (session open / tab switch) + * - "none" — highlight only, no folder state changes (orphan fallback, replay) + */ +export type ActiveProjectMode = "none" | "expand" | "reset" + +export function notifyProjectSelected(worktree: string, mode: ActiveProjectMode = "reset"): void { + window.parent.postMessage({ source: "amicode", kind: "project-selected", path: worktree, mode }, "*") }