diff --git a/packages/app/src/context/global-sync/home-session-index.test.ts b/packages/app/src/context/global-sync/home-session-index.test.ts index b95352eb87..7dc8483dfa 100644 --- a/packages/app/src/context/global-sync/home-session-index.test.ts +++ b/packages/app/src/context/global-sync/home-session-index.test.ts @@ -20,7 +20,7 @@ const session = (id: string, input: Partial = {}) => describe("Home V2 session index", () => { test("loads all pages", async () => { const first = Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session(`session-${index}`)) - const calls: Array<{ cursor?: string }> = [] + const calls: Array<{ cursor?: string; parentID: null }> = [] const result = await loadHomeSessionIndex(async (input) => { calls.push(input) if (!input.cursor) return { data: first, cursor: { next: "next" } } @@ -29,6 +29,7 @@ describe("Home V2 session index", () => { expect(result).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1) expect(calls.map((call) => call.cursor)).toEqual([undefined, "next"]) + expect(calls.every((call) => call.parentID === null)).toBe(true) }) test("keeps only visible roots", () => { diff --git a/packages/app/src/context/global-sync/home-session-index.ts b/packages/app/src/context/global-sync/home-session-index.ts index 3b159e9be8..b5f32722bf 100644 --- a/packages/app/src/context/global-sync/home-session-index.ts +++ b/packages/app/src/context/global-sync/home-session-index.ts @@ -6,7 +6,12 @@ export const HOME_V2_SESSION_PAGE_LIMIT = 5_000 export async function loadHomeSessionIndex( list: ( - input: { limit: number; order: "desc"; cursor?: string }, + input: { + limit: number + order: "desc" + parentID: null + cursor?: string + }, options: { signal?: AbortSignal }, ) => Promise, signal?: AbortSignal, @@ -19,6 +24,7 @@ export async function loadHomeSessionIndex( { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc", + parentID: null, ...(cursor ? { cursor } : {}), }, { signal }, @@ -29,8 +35,7 @@ export async function loadHomeSessionIndex( } } -// The V2 API cannot yet filter roots, archives, or several directories, so Home -// seeds createData from a full scan and derives its visible index there. +// Keep this filter for locally known sessions merged into the fetched index. export function parseHomeSessionIndex(sessions: SessionInfo[]) { return sessions.filter((session) => !session.parentID && typeof session.time.archived !== "number") } diff --git a/packages/app/src/pages/home/home-session-records.ts b/packages/app/src/pages/home/home-session-records.ts new file mode 100644 index 0000000000..23ad142b61 --- /dev/null +++ b/packages/app/src/pages/home/home-session-records.ts @@ -0,0 +1,38 @@ +import type { SessionInfo } from "@opencode-ai/client/promise" +import type { LocalProject } from "@/context/layout" +import { compareSessionTime, displayName } from "@/pages/layout/helpers" +import { pathKey } from "@/utils/path-key" + +export type HomeSessionRecord = { + session: SessionInfo + project: LocalProject + projectName: string +} + +export function buildHomeSessionRecords(input: { + sessions: () => SessionInfo[] + projectDirectories: () => string[] | undefined + projects: () => LocalProject[] +}) { + const selected = input.projectDirectories() + const directories = selected ? new Set(selected.map(pathKey)) : undefined + const sessions = directories + ? input.sessions().filter((session) => directories.has(pathKey(session.location.directory))) + : input.sessions() + return [...new Map(sessions.map((session) => [session.id, session] as const)).values()] + .sort(compareSessionTime) + .map((session) => { + const directory = pathKey(session.location.directory) + const project = input + .projects() + .find( + (item) => + pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory), + ) ?? { + id: session.projectID, + worktree: session.location.directory, + expanded: false, + } + return { session, project, projectName: displayName(project) } + }) +} diff --git a/packages/app/src/pages/home/home-sessions-controller.test.ts b/packages/app/src/pages/home/home-sessions-controller.test.ts new file mode 100644 index 0000000000..4dec372cea --- /dev/null +++ b/packages/app/src/pages/home/home-sessions-controller.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import type { SessionInfo } from "@opencode-ai/client/promise" +import type { LocalProject } from "@/context/layout" +import { buildHomeSessionRecords } from "./home-session-records" + +const session = (id: string, directory: string, projectID: string) => + ({ + id, + projectID, + title: id, + location: { directory }, + time: { created: 1, updated: 1 }, + }) as SessionInfo + +describe("buildHomeSessionRecords", () => { + const opened = { id: "project-a", worktree: "/repo/a", expanded: true } as LocalProject + const sessions = [session("a", "/repo/a", "project-a"), session("b", "/repo/b", "project-b")] + + test("includes sessions outside added projects when unfiltered", () => { + const records = buildHomeSessionRecords({ + sessions: () => sessions, + projectDirectories: () => undefined, + projects: () => [opened], + }) + + expect(records.map((record) => record.session.id)).toEqual(["a", "b"]) + expect(records[1]?.project).toMatchObject({ id: "project-b", worktree: "/repo/b", expanded: false }) + }) + + test("filters sessions when a project is selected", () => { + const records = buildHomeSessionRecords({ + sessions: () => sessions, + projectDirectories: () => ["/repo/a"], + projects: () => [opened], + }) + + expect(records.map((record) => record.session.id)).toEqual(["a"]) + }) +}) diff --git a/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app/src/pages/home/home-sessions-controller.tsx index fdd7ef35c2..a47f77b738 100644 --- a/packages/app/src/pages/home/home-sessions-controller.tsx +++ b/packages/app/src/pages/home/home-sessions-controller.tsx @@ -13,20 +13,19 @@ import type { LocalProject } from "@/context/layout" import { useLanguage } from "@/context/language" import { ServerConnection } from "@/context/servers" import { sessionHasOpenTab, useTabs } from "@/context/tabs" -import { compareSessionTime, displayName, errorMessage, projectForSession } from "@/pages/layout/helpers" +import { errorMessage } from "@/pages/layout/helpers" import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" import { pathKey } from "@/utils/path-key" import { showToast } from "@/utils/toast" import { archiveHomeSession } from "../home-session-archive" import type { HomeController } from "./home-controller" +import { buildHomeSessionRecords, type HomeSessionRecord } from "./home-session-records" + +export type { HomeSessionRecord } from "./home-session-records" const HOME_SESSION_LIMIT = 64 -export type HomeSessionRecord = { - session: SessionInfo - project: LocalProject - projectName: string -} - +// Keep the large immutable result opaque so Solid Query does not recursively unwrap every session on mount. +const selectSessions = (sessions: SessionInfo[]) => () => sessions export type HomeSessionGroup = { id: "today" | "yesterday" | "older" title: string @@ -41,13 +40,11 @@ export function createHomeSessionsController(home: HomeController) { const dialog = useDialog() const language = useLanguage() const projectDirectories = createMemo(() => { + const selected = home.selection.value().directory + if (!selected) return const project = home.project.selected() - if (!project) return home.project.list().flatMap(directories) - return directories(project) + return project ? directories(project) : [selected] }) - const projectByID = createMemo( - () => new Map(home.project.list().flatMap((project) => (project.id ? [[project.id, project] as const] : []))), - ) const sessionLoad = useQuery(() => { const ctx = home.server.focusedContext() const conn = home.server.focused() @@ -61,13 +58,14 @@ export function createHomeSessionsController(home: HomeController) { staleTime: 30_000, refetchOnMount: true, refetchOnReconnect: true, + select: selectSessions, } }) const indexedSessions = createMemo(() => { const ctx = home.server.focusedContext() if (!ctx) return [] return retainHomeSessions( - mergeHomeSessionIndex(sessionLoad.data ?? [], ctx.data.session.list()), + mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()), HOME_SESSION_LIMIT, Date.now(), ) @@ -77,7 +75,6 @@ export function createHomeSessionsController(home: HomeController) { sessions: indexedSessions, projectDirectories, projects: home.project.list, - projectByID, }), ) const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT)) @@ -154,7 +151,7 @@ export function createHomeSessionsController(home: HomeController) { (item) => pathKey(item.worktree) === directoryKey || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey), - ) ?? projectForSession(session, home.project.list(), projectByID()) + ) const conn = home.server.focused() if (!conn) return const connKey = ServerConnection.key(conn) @@ -202,30 +199,6 @@ function directories(project: LocalProject) { return [project.worktree, ...(project.sandboxes ?? [])] } -function buildHomeSessionRecords(input: { - sessions: () => SessionInfo[] - projectDirectories: () => string[] - projects: () => LocalProject[] - projectByID: () => Map -}) { - const directories = new Set(input.projectDirectories().map(pathKey)) - const sessions = input.sessions().filter((session) => directories.has(pathKey(session.location.directory))) - return [...new Map(sessions.map((session) => [session.id, session] as const)).values()] - .sort(compareSessionTime) - .flatMap((session) => { - const directory = pathKey(session.location.directory) - const project = - input - .projects() - .find( - (item) => - pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory), - ) ?? projectForSession(session, input.projects(), input.projectByID()) - if (!project) return [] - return { session, project, projectName: displayName(project) } - }) -} - export function homeSessionSearchKey(record: HomeSessionRecord) { return `${pathKey(record.session.location.directory)}:${record.session.id}` } @@ -266,6 +239,7 @@ export function HomeSessionStatusController(props: { () => props.server, () => props.record.session.location.directory, () => props.record.session.id, + () => true, ) return props.render({ unread: avatar.unread, diff --git a/packages/app/src/pages/layout/project-avatar-state.ts b/packages/app/src/pages/layout/project-avatar-state.ts index 9cd229c405..efbbde92ac 100644 --- a/packages/app/src/pages/layout/project-avatar-state.ts +++ b/packages/app/src/pages/layout/project-avatar-state.ts @@ -7,27 +7,33 @@ export function useSessionTabAvatarState( server: Accessor, directory: Accessor, sessionId: Accessor, + root?: Accessor, ) { const global = useGlobal() const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server())) const serverCtx = useServerCtx(connection) + const sessions = createMemo(() => { + const data = serverCtx()?.data + if (!data) return [] + if (!root?.()) return data.session.list() + const id = sessionId() + return [...new Set([id, ...data.session.family(id)])].flatMap((id) => { + const info = data.session.get(id) + return info ? [info] : [] + }) + }) const hasPermissions = createMemo(() => { const ctx = serverCtx() if (!ctx) return false const permission = ctx.permission - return !!sessionPermissionRequest( - ctx.data.session.list(), - ctx.data.session.permission.list, - sessionId(), - (item) => { - return !permission.autoResponds(item, directory()) - }, - ) + return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId(), (item) => { + return !permission.autoResponds(item, directory()) + }) }) const hasQuestions = createMemo(() => { const data = serverCtx()?.data if (!data) return false - return !!sessionQuestionForm(data.session.list(), data.session.form.list, sessionId()) + return !!sessionQuestionForm(sessions(), data.session.form.list, sessionId()) }) const needsAttention = createMemo(() => hasPermissions() || hasQuestions()) const unread = createMemo( diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 3760f5137f..c608374481 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -38,7 +38,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message" import { isPermissionNotFoundError, type SessionPromptInput } from "../promise" import { createStore, produce, reconcile } from "solid-js/store" import type { SessionInbox } from "@opencode-ai/schema/session-inbox" -import { batch, createEffect, createSignal, onCleanup } from "solid-js" +import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js" export type DataSessionStatus = "idle" | "running" @@ -172,6 +172,9 @@ export function createData(config: CreateDataInput) { }) const [defaultLocation, setDefaultLocation] = createSignal({ directory: config.directory }) + const sessions = createMemo(() => + Object.values(store.session.info).toSorted((a, b) => b.time.updated - a.time.updated), + ) const messageIndex = new Map>() const sync = createSync() @@ -1034,7 +1037,7 @@ export function createData(config: CreateDataInput) { listen: config.event.listen, session: { list() { - return Object.values(store.session.info).toSorted((a, b) => b.time.updated - a.time.updated) + return sessions() }, get(sessionID: string) { return store.session.info[sessionID]