fix(app): speed up cold home navigation (#43738)

This commit is contained in:
Luke Parker 2026-08-21 10:56:34 +10:00 committed by GitHub
parent 2938ac3298
commit 67e87f534e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 120 additions and 54 deletions

View file

@ -20,7 +20,7 @@ const session = (id: string, input: Partial<SessionInfo> = {}) =>
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", () => {

View file

@ -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<SessionsResponse>,
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")
}

View file

@ -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) }
})
}

View file

@ -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"])
})
})

View file

@ -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<string, LocalProject>
}) {
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,

View file

@ -7,27 +7,33 @@ export function useSessionTabAvatarState(
server: Accessor<ServerConnection.Key>,
directory: Accessor<string>,
sessionId: Accessor<string>,
root?: Accessor<boolean>,
) {
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(

View file

@ -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<LocationRef>({ directory: config.directory })
const sessions = createMemo(() =>
Object.values(store.session.info).toSorted((a, b) => b.time.updated - a.time.updated),
)
const messageIndex = new Map<string, Map<string, number>>()
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]