fix(app): speed up cold home navigation (#43738)
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
})
|
||||
}
|
||||
@@ -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"])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user