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
20 changes: 17 additions & 3 deletions packages/app/src/components/session/session-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { statusTriggerVisibility } from "../status-popover-model"
import { useServerSync } from "@/context/server-sync"
import { useGlobal } from "@/context/global"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { sortedRootSessions } from "@/pages/layout/helpers"
import { sessionListDirectories, sortedRootSessions } from "@/pages/layout/helpers"
import { useNavigate } from "@solidjs/router"
import type { Session } from "@opencode-ai/sdk/v2/client"

Expand Down Expand Up @@ -727,8 +727,7 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {})
if (!conn) return []
const ctx = globalCtx.ensureServerCtx(conn)
if (!ctx) return []
const projects = ctx.projects.list()
const directories = projects.flatMap((p) => [p.worktree, ...(p.sandboxes ?? [])])
const directories = sessionListDirectories(ctx.projects.list(), ctx.sync.data?.project ?? [])
const seen = new Set<string>()
const sessions: Session[] = []
for (const dir of directories) {
Expand All @@ -745,6 +744,21 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {})
}
})

// Fresh clients have no bootstrapped child stores for the fallback
// directories (the dropdown reads with bootstrap: false) — kick the loads
// once per open. Converges: re-runs find the stores populated and skip.
createEffect(() => {
if (!open()) return
const conn = server.current
if (!conn) return
const ctx = globalCtx.ensureServerCtx(conn)
if (!ctx) return
for (const dir of sessionListDirectories(ctx.projects.list(), ctx.sync.data?.project ?? [])) {
const [store] = ctx.sync.child(dir, { bootstrap: false })
if ((store.session?.length ?? 0) === 0) ctx.sync.project.loadSessions(dir, { limit: 50 })
}
})

// Sort: open-tab sessions first
const sortedActiveSessions = createMemo(() => {
if (!open()) return []
Expand Down
10 changes: 7 additions & 3 deletions packages/app/src/context/server-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -427,10 +427,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const limit = Math.max(store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0)
// amicode#288: the fetch limit must be the floor here. Re-deriving
// it from a fresh store (limit 0) trimmed everything older than
// SESSION_RECENT_WINDOW out of the store — the dropdown and home
// list then showed only the last few hours of history.
const retained = Math.max(limit, store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0)
const childSessions = store.session.filter((s) => !!s.parentID)
const next = trimSessions([...nonArchived, ...childSessions], {
limit,
limit: retained,
permission: session.data.permission,
})
batch(() => {
Expand All @@ -445,7 +449,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
)
setStore("session", reconcile(next, { key: "id" }))
})
sessionMeta.set(key, { limit })
sessionMeta.set(key, { limit: retained })
})
.catch((err) => {
console.error("Failed to load sessions", err)
Expand Down
7 changes: 6 additions & 1 deletion packages/app/src/pages/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
homeProjectDirectories,
homeProjectNavigation,
projectForSession,
sessionListDirectories,
sortedRootSessions,
toggleHomeProjectSelection,
} from "@/pages/layout/helpers"
Expand Down Expand Up @@ -287,7 +288,11 @@ function HomeDesign() {
// lists, so it must load EVERY project's sessions regardless of selection.
// Scoping to the selected project (the old behavior) made the flat list flip
// empty/populated with selection and hid non-selected projects' sessions.
const projectDirectories = createMemo(() => projects().flatMap(directories))
// amicode#288: a fresh client has an empty opened-projects registry — fall
// back to the server's registered projects so history is still listed.
const projectDirectories = createMemo(() =>
sessionListDirectories(projects(), focusedSync().data.project ?? []),
)
const search = createMemo(() => state.search.trim())
const sessionLoad = useQuery(() => ({
queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const,
Expand Down
27 changes: 27 additions & 0 deletions packages/app/src/pages/layout/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
homeProjectDirectories,
homeSessionServerStatus,
latestRootSession,
sessionListDirectories,
toggleHomeProjectSelection,
} from "./helpers"
import { pathKey } from "@/utils/path-key"
Expand Down Expand Up @@ -318,4 +319,30 @@ describe("layout workspace helpers", () => {
expect(errorMessage(new Error("broken"), "fallback")).toBe("broken")
expect(errorMessage("unknown", "fallback")).toBe("fallback")
})

describe("sessionListDirectories", () => {
test("uses opened projects when present", () => {
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"])
})

test("falls back to server projects when nothing is opened (fresh client, amicode#288)", () => {
const server = [
{ worktree: "/", sandboxes: ["/staging"] },
{ worktree: "/amicode" },
{ worktree: "/opencode" },
]
expect(sessionListDirectories([], server)).toEqual(["/", "/staging", "/amicode", "/opencode"])
})

test("fallback dedupes and drops empty entries", () => {
const server = [
{ worktree: "/a", sandboxes: ["/a"] },
{ worktree: "/a" },
{ worktree: "", sandboxes: [""] },
]
expect(sessionListDirectories([], server)).toEqual(["/a"])
})
})
})
22 changes: 22 additions & 0 deletions packages/app/src/pages/layout/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,28 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri
export const displayName = (project: { name?: string; worktree: string }) =>
project.name || getFilename(project.worktree) || project.worktree

/**
* 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).
*/
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<string>()
return serverProjects.flatMap((p) => [p.worktree, ...(p.sandboxes ?? [])]).filter((d) => {
if (!d || seen.has(d)) return false
seen.add(d)
return true
})
}

export function toggleHomeProjectSelection(
current: HomeProjectSelection | undefined,
server: ServerConnection.Key,
Expand Down
Loading