From 0e077f748352df6d44c811829baff3c26b3436ac Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:31:52 -0500 Subject: [PATCH] feat: session load perf (#17186) --- packages/app/src/context/global-sync.tsx | 2 + .../global-sync/session-prefetch.test.ts | 63 ++++++ .../context/global-sync/session-prefetch.ts | 85 ++++++++ packages/app/src/context/sync.tsx | 109 +++++++--- packages/app/src/pages/layout.tsx | 204 +++++++++++------- .../app/src/pages/layout/sidebar-items.tsx | 53 ++++- .../app/src/pages/layout/sidebar-project.tsx | 12 +- .../src/pages/layout/sidebar-workspace.tsx | 3 + packages/app/src/pages/session.tsx | 71 +++++- 9 files changed, 468 insertions(+), 134 deletions(-) create mode 100644 packages/app/src/context/global-sync/session-prefetch.test.ts create mode 100644 packages/app/src/context/global-sync/session-prefetch.ts diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 645bd678b..1b6cdf530 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -29,6 +29,7 @@ import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer" import { createRefreshQueue } from "./global-sync/queue" +import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch" import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" @@ -161,6 +162,7 @@ function createGlobalSync() { queue.clear(directory) sessionMeta.delete(directory) sdkCache.delete(directory) + clearSessionPrefetchDirectory(directory) }, }) diff --git a/packages/app/src/context/global-sync/session-prefetch.test.ts b/packages/app/src/context/global-sync/session-prefetch.test.ts new file mode 100644 index 000000000..f039b02ca --- /dev/null +++ b/packages/app/src/context/global-sync/session-prefetch.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { + clearSessionPrefetch, + clearSessionPrefetchDirectory, + getSessionPrefetch, + runSessionPrefetch, + setSessionPrefetch, +} from "./session-prefetch" + +describe("session prefetch", () => { + test("stores and clears message metadata by directory", () => { + clearSessionPrefetch("/tmp/a", ["ses_1"]) + clearSessionPrefetch("/tmp/b", ["ses_1"]) + + setSessionPrefetch({ + directory: "/tmp/a", + sessionID: "ses_1", + limit: 200, + complete: false, + at: 123, + }) + + expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, complete: false, at: 123 }) + expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined() + + clearSessionPrefetch("/tmp/a", ["ses_1"]) + + expect(getSessionPrefetch("/tmp/a", "ses_1")).toBeUndefined() + }) + + test("dedupes inflight work", async () => { + clearSessionPrefetch("/tmp/c", ["ses_2"]) + + let calls = 0 + const run = () => + runSessionPrefetch({ + directory: "/tmp/c", + sessionID: "ses_2", + task: async () => { + calls += 1 + return { limit: 100, complete: true, at: 456 } + }, + }) + + const [a, b] = await Promise.all([run(), run()]) + + expect(calls).toBe(1) + expect(a).toEqual({ limit: 100, complete: true, at: 456 }) + expect(b).toEqual({ limit: 100, complete: true, at: 456 }) + }) + + test("clears a whole directory", () => { + setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, complete: true, at: 1 }) + setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, complete: false, at: 2 }) + setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, complete: true, at: 3 }) + + clearSessionPrefetchDirectory("/tmp/d") + + expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined() + expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined() + expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, complete: true, at: 3 }) + }) +}) diff --git a/packages/app/src/context/global-sync/session-prefetch.ts b/packages/app/src/context/global-sync/session-prefetch.ts new file mode 100644 index 000000000..10877b063 --- /dev/null +++ b/packages/app/src/context/global-sync/session-prefetch.ts @@ -0,0 +1,85 @@ +const key = (directory: string, sessionID: string) => `${directory}\n${sessionID}` + +export const SESSION_PREFETCH_TTL = 15_000 + +type Meta = { + limit: number + complete: boolean + at: number +} + +const cache = new Map() +const inflight = new Map>() +const rev = new Map() + +const version = (id: string) => rev.get(id) ?? 0 + +export function getSessionPrefetch(directory: string, sessionID: string) { + return cache.get(key(directory, sessionID)) +} + +export function getSessionPrefetchPromise(directory: string, sessionID: string) { + return inflight.get(key(directory, sessionID)) +} + +export function clearSessionPrefetchInflight() { + inflight.clear() +} + +export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) { + return version(key(directory, sessionID)) === value +} + +export function runSessionPrefetch(input: { + directory: string + sessionID: string + task: (value: number) => Promise +}) { + const id = key(input.directory, input.sessionID) + const pending = inflight.get(id) + if (pending) return pending + + const value = version(id) + + const promise = input.task(value).finally(() => { + if (inflight.get(id) === promise) inflight.delete(id) + }) + + inflight.set(id, promise) + return promise +} + +export function setSessionPrefetch(input: { + directory: string + sessionID: string + limit: number + complete: boolean + at?: number +}) { + cache.set(key(input.directory, input.sessionID), { + limit: input.limit, + complete: input.complete, + at: input.at ?? Date.now(), + }) +} + +export function clearSessionPrefetch(directory: string, sessionIDs: Iterable) { + for (const sessionID of sessionIDs) { + if (!sessionID) continue + const id = key(directory, sessionID) + rev.set(id, version(id) + 1) + cache.delete(id) + inflight.delete(id) + } +} + +export function clearSessionPrefetchDirectory(directory: string) { + const prefix = `${directory}\n` + const keys = new Set([...cache.keys(), ...inflight.keys()]) + for (const id of keys) { + if (!id.startsWith(prefix)) continue + rev.set(id, version(id) + 1) + cache.delete(id) + inflight.delete(id) + } +} diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 5623a2c7c..db7b06388 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -3,6 +3,12 @@ import { createStore, produce, reconcile } from "solid-js/store" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" import { createSimpleContext } from "@opencode-ai/ui/context" +import { + clearSessionPrefetch, + getSessionPrefetch, + getSessionPrefetchPromise, + setSessionPrefetch, +} from "./global-sync/session-prefetch" import { useGlobalSync } from "./global-sync" import { useSDK } from "./sdk" import type { Message, Part } from "@opencode-ai/sdk/v2/client" @@ -160,6 +166,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => { if (sessionIDs.length === 0) return + clearSessionPrefetch(directory, sessionIDs) for (const sessionID of sessionIDs) { globalSync.todo.set(sessionID, undefined) } @@ -217,6 +224,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } setMeta("limit", key, input.limit) setMeta("complete", key, next.complete) + setSessionPrefetch({ + directory: input.directory, + sessionID: input.sessionID, + limit: input.limit, + complete: next.complete, + }) }) }) .finally(() => { @@ -280,54 +293,82 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ parts: input.parts, }) }, - async sync(sessionID: string) { + async sync(sessionID: string, opts?: { force?: boolean }) { const directory = sdk.directory const client = sdk.client const [store, setStore] = globalSync.child(directory) const key = keyFor(directory, sessionID) - const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found touch(directory, setStore, sessionID) - if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return + const seeded = getSessionPrefetch(directory, sessionID) + if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) { + batch(() => { + setMeta("limit", key, seeded.limit) + setMeta("complete", key, seeded.complete) + setMeta("loading", key, false) + }) + } - const limit = meta.limit[key] ?? messagePageSize + return runInflight(inflight, key, async () => { + const pending = getSessionPrefetchPromise(directory, sessionID) + if (pending) { + await pending + const seeded = getSessionPrefetch(directory, sessionID) + if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) { + batch(() => { + setMeta("limit", key, seeded.limit) + setMeta("complete", key, seeded.complete) + setMeta("loading", key, false) + }) + } + } - const sessionReq = hasSession - ? Promise.resolve() - : retry(() => client.session.get({ sessionID })).then((session) => { - if (!tracked(directory, sessionID)) return - const data = session.data - if (!data) return - setStore( - "session", - produce((draft) => { - const match = Binary.search(draft, sessionID, (s) => s.id) - if (match.found) { - draft[match.index] = data - return - } - draft.splice(match.index, 0, data) - }), - ) - }) + const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found + const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined + if (cached && hasSession && !opts?.force) return - const messagesReq = loadMessages({ - directory, - client, - setStore, - sessionID, - limit, + const limit = meta.limit[key] ?? messagePageSize + const sessionReq = + hasSession && !opts?.force + ? Promise.resolve() + : retry(() => client.session.get({ sessionID })).then((session) => { + if (!tracked(directory, sessionID)) return + const data = session.data + if (!data) return + setStore( + "session", + produce((draft) => { + const match = Binary.search(draft, sessionID, (s) => s.id) + if (match.found) { + draft[match.index] = data + return + } + draft.splice(match.index, 0, data) + }), + ) + }) + + const messagesReq = + cached && !opts?.force + ? Promise.resolve() + : loadMessages({ + directory, + client, + setStore, + sessionID, + limit, + }) + + await Promise.all([sessionReq, messagesReq]) }) - - return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {})) }, - async diff(sessionID: string) { + async diff(sessionID: string, opts?: { force?: boolean }) { const directory = sdk.directory const client = sdk.client const [store, setStore] = globalSync.child(directory) touch(directory, setStore, sessionID) - if (store.session_diff[sessionID] !== undefined) return + if (store.session_diff[sessionID] !== undefined && !opts?.force) return const key = keyFor(directory, sessionID) return runInflight(inflightDiff, key, () => @@ -337,7 +378,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }), ) }, - async todo(sessionID: string) { + async todo(sessionID: string, opts?: { force?: boolean }) { const directory = sdk.directory const client = sdk.client const [store, setStore] = globalSync.child(directory) @@ -348,7 +389,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (cached === undefined) { globalSync.todo.set(sessionID, existing) } - return + if (!opts?.force) return } if (cached !== undefined) { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index eb3028101..da857a603 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -35,6 +35,15 @@ import { showToast, Toast, toaster } from "@opencode-ai/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" import { clearWorkspaceTerminals } from "@/context/terminal" import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache" +import { + clearSessionPrefetchInflight, + clearSessionPrefetch, + getSessionPrefetch, + isSessionPrefetchCurrent, + runSessionPrefetch, + SESSION_PREFETCH_TTL, + setSessionPrefetch, +} from "@/context/global-sync/session-prefetch" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { Binary } from "@opencode-ai/util/binary" @@ -662,8 +671,9 @@ export default function Layout(props: ParentProps) { } const prefetchChunk = 200 - const prefetchConcurrency = 1 - const prefetchPendingLimit = 6 + const prefetchConcurrency = 2 + const prefetchPendingLimit = 10 + const span = 4 const prefetchToken = { value: 0 } const prefetchQueues = new Map() @@ -688,14 +698,30 @@ export default function Layout(props: ParentProps) { }) } + createEffect(() => { + const active = new Set(visibleSessionDirs()) + for (const directory of [...prefetchedByDir.keys()]) { + if (active.has(directory)) continue + prefetchedByDir.delete(directory) + } + }) + createEffect(() => { params.dir globalSDK.url prefetchToken.value += 1 - for (const q of prefetchQueues.values()) { + clearSessionPrefetchInflight() + prefetchQueues.clear() + }) + + createEffect(() => { + const visible = new Set(visibleSessionDirs()) + for (const [directory, q] of prefetchQueues) { + if (visible.has(directory)) continue q.pending.length = 0 q.pendingSet.clear() + if (q.running === 0) prefetchQueues.delete(directory) } }) @@ -731,36 +757,67 @@ export default function Layout(props: ParentProps) { async function prefetchMessages(directory: string, sessionID: string, token: number) { const [store, setStore] = globalSync.child(directory, { bootstrap: false }) - return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) - .then((messages) => { - if (prefetchToken.value !== token) return - if (!lruFor(directory).has(sessionID)) return + return runSessionPrefetch({ + directory, + sessionID, + task: (rev) => + retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) + .then((messages) => { + if (prefetchToken.value !== token) return + if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return - const items = (messages.data ?? []).filter((x) => !!x?.info?.id) - const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id) - const sorted = mergeByID([], next) + const items = (messages.data ?? []).filter((x) => !!x?.info?.id) + const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id) + const sorted = mergeByID([], next) + const stale = markPrefetched(directory, sessionID) + const meta = { + limit: prefetchChunk, + complete: sorted.length < prefetchChunk, + at: Date.now(), + } - const current = store.message[sessionID] ?? [] - const merged = mergeByID( - current.filter((item): item is Message => !!item?.id), - sorted, - ) + if (stale.length > 0) { + clearSessionPrefetch(directory, stale) + for (const id of stale) { + globalSync.todo.set(id, undefined) + } + } - batch(() => { - setStore("message", sessionID, reconcile(merged, { key: "id" })) - - for (const message of items) { - const currentParts = store.part[message.info.id] ?? [] - const mergedParts = mergeByID( - currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id), - message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id), + const current = store.message[sessionID] ?? [] + const merged = mergeByID( + current.filter((item): item is Message => !!item?.id), + sorted, ) - setStore("part", message.info.id, reconcile(mergedParts, { key: "id" })) - } - }) - }) - .catch(() => undefined) + if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return + + batch(() => { + if (stale.length > 0) { + setStore( + produce((draft) => { + dropSessionCaches(draft, stale) + }), + ) + } + + setStore("message", sessionID, reconcile(merged, { key: "id" })) + setSessionPrefetch({ directory, sessionID, ...meta }) + + for (const message of items) { + const currentParts = store.part[message.info.id] ?? [] + const mergedParts = mergeByID( + currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id), + message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id), + ) + + setStore("part", message.info.id, reconcile(mergedParts, { key: "id" })) + } + }) + + return meta + }) + .catch(() => undefined), + }) } const pumpPrefetch = (directory: string) => { @@ -788,28 +845,29 @@ export default function Layout(props: ParentProps) { if (!directory) return const [store] = globalSync.child(directory, { bootstrap: false }) - const cached = untrack(() => store.message[session.id] !== undefined) + const cached = untrack(() => { + if (store.message[session.id] === undefined) return false + const info = getSessionPrefetch(directory, session.id) + if (!info) return false + return Date.now() - info.at < SESSION_PREFETCH_TTL + }) if (cached) return const q = queueFor(directory) if (q.inflight.has(session.id)) return - if (q.pendingSet.has(session.id)) return + if (q.pendingSet.has(session.id)) { + if (priority !== "high") return + const index = q.pending.indexOf(session.id) + if (index > 0) { + q.pending.splice(index, 1) + q.pending.unshift(session.id) + } + return + } const lru = lruFor(directory) const known = lru.has(session.id) if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return - const stale = markPrefetched(directory, session.id) - if (stale.length > 0) { - const [, setStore] = globalSync.child(directory, { bootstrap: false }) - for (const id of stale) { - globalSync.todo.set(id, undefined) - } - setStore( - produce((draft) => { - dropSessionCaches(draft, stale) - }), - ) - } if (priority === "high") q.pending.unshift(session.id) if (priority !== "high") q.pending.push(session.id) @@ -824,27 +882,29 @@ export default function Layout(props: ParentProps) { pumpPrefetch(directory) } + const warm = (sessions: Session[], index: number) => { + for (let offset = 1; offset <= span; offset++) { + const next = sessions[index + offset] + if (next) prefetchSession(next, offset === 1 ? "high" : "low") + + const prev = sessions[index - offset] + if (prev) prefetchSession(prev, offset === 1 ? "high" : "low") + } + } + createEffect(() => { const sessions = currentSessions() - const id = params.id + if (sessions.length === 0) return - if (!id) { - const first = sessions[0] - if (first) prefetchSession(first) - - const second = sessions[1] - if (second) prefetchSession(second) - return - } - - const index = sessions.findIndex((s) => s.id === id) + const index = params.id ? sessions.findIndex((s) => s.id === params.id) : 0 if (index === -1) return - const next = sessions[index + 1] - if (next) prefetchSession(next) + if (!params.id) { + const first = sessions[index] + if (first) prefetchSession(first, "high") + } - const prev = sessions[index - 1] - if (prev) prefetchSession(prev) + warm(sessions, index) }) function navigateSessionByOffset(offset: number) { @@ -863,18 +923,8 @@ export default function Layout(props: ParentProps) { const session = sessions[targetIndex] if (!session) return - const next = sessions[(targetIndex + 1) % sessions.length] - const prev = sessions[(targetIndex - 1 + sessions.length) % sessions.length] - - if (offset > 0) { - if (next) prefetchSession(next, "high") - if (prev) prefetchSession(prev) - } - - if (offset < 0) { - if (prev) prefetchSession(prev, "high") - if (next) prefetchSession(next) - } + prefetchSession(session, "high") + warm(sessions, targetIndex) navigateToSession(session) } @@ -896,19 +946,7 @@ export default function Layout(props: ParentProps) { if (notification.session.unseenCount(session.id) === 0) continue prefetchSession(session, "high") - - const next = sessions[(index + 1) % sessions.length] - const prev = sessions[(index - 1 + sessions.length) % sessions.length] - - if (offset > 0) { - if (next) prefetchSession(next, "high") - if (prev) prefetchSession(prev) - } - - if (offset < 0) { - if (prev) prefetchSession(prev, "high") - if (next) prefetchSession(next) - } + warm(sessions, index) navigateToSession(session) return @@ -1842,6 +1880,7 @@ export default function Layout(props: ParentProps) { const workspaceSidebarCtx: WorkspaceSidebarContext = { currentDir, + navList: currentSessions, sidebarExpanded, sidebarHovering, nav: () => state.nav, @@ -1887,6 +1926,7 @@ export default function Layout(props: ParentProps) { workspaceIds, workspaceLabel, sessionProps: { + navList: currentSessions, sidebarExpanded, sidebarHovering, nav: () => state.nav, diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 8dc03755e..b6c8fedb1 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -10,6 +10,7 @@ import { base64Encode } from "@opencode-ai/util/encode" import { getFilename } from "@opencode-ai/util/path" import { A, useNavigate, useParams } from "@solidjs/router" import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js" +import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" @@ -67,6 +68,8 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti export type SessionItemProps = { session: Session + list: Session[] + navList?: Accessor slug: string mobile?: boolean dense?: boolean @@ -95,18 +98,18 @@ const SessionRow = (props: { setHoverSession: (id: string | undefined) => void clearHoverProjectSoon: () => void sidebarOpened: Accessor - prefetchSession: (session: Session, priority?: "high" | "low") => void - scheduleHoverPrefetch: () => void + warmHover: () => void + warmPress: () => void + warmFocus: () => void cancelHoverPrefetch: () => void }): JSX.Element => ( props.prefetchSession(props.session, "high")} + onFocus={props.warmFocus} onClick={() => { props.setHoverSession(undefined) if (props.sidebarOpened()) return @@ -225,11 +228,37 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { const hoverMessages = createMemo(() => sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"), ) - const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined) + const hoverReady = createMemo(() => { + if (sessionStore.message[props.session.id] === undefined) return false + if (props.session.id === params.id) return true + const info = getSessionPrefetch(props.session.directory, props.session.id) + if (!info) return false + return Date.now() - info.at < SESSION_PREFETCH_TTL + }) const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded()) const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) const isActive = createMemo(() => props.session.id === params.id) + const warm = (span: number, priority: "high" | "low") => { + const nav = props.navList?.() + const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory) + ? nav + : props.list + + props.prefetchSession(props.session, priority) + + const idx = list.findIndex((item) => item.id === props.session.id && item.directory === props.session.directory) + if (idx === -1) return + + for (let step = 1; step <= span; step++) { + const next = list[idx + step] + if (next) props.prefetchSession(next, step === 1 ? "high" : priority) + + const prev = list[idx - step] + if (prev) props.prefetchSession(prev, step === 1 ? "high" : priority) + } + } + const hoverPrefetch = { current: undefined as ReturnType | undefined, } @@ -239,11 +268,12 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { hoverPrefetch.current = undefined } const scheduleHoverPrefetch = () => { + warm(1, "high") if (hoverPrefetch.current !== undefined) return hoverPrefetch.current = setTimeout(() => { hoverPrefetch.current = undefined - props.prefetchSession(props.session) - }, 200) + warm(2, "low") + }, 80) } onCleanup(cancelHoverPrefetch) @@ -267,8 +297,9 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { setHoverSession={props.setHoverSession} clearHoverProjectSoon={props.clearHoverProjectSoon} sidebarOpened={layout.sidebar.opened} - prefetchSession={props.prefetchSession} - scheduleHoverPrefetch={scheduleHoverPrefetch} + warmHover={scheduleHoverPrefetch} + warmPress={() => warm(2, "high")} + warmFocus={() => warm(2, "high")} cancelHoverPrefetch={cancelHoverPrefetch} /> ) diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 551090fd5..a26bc1831 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -30,7 +30,7 @@ export type ProjectSidebarContext = { workspacesEnabled: (project: LocalProject) => boolean workspaceIds: (project: LocalProject) => string[] workspaceLabel: (directory: string, branch?: string, projectId?: string) => string - sessionProps: Omit + sessionProps: Omit setHoverSession: (id: string | undefined) => void } @@ -204,11 +204,12 @@ const ProjectPreviewPanel = (props: { + {(session) => ( {props.label(directory)} - + {(session) => ( globalSync.child(props.project.worktree, { bootstrap: false })[0]) - const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()).slice(0, 2)) + const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow())) const projectChildren = createMemo(() => childMapByParent(projectStore().session)) const workspaceSessions = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) - return sortedRootSessions(data, props.sortNow()).slice(0, 2) + return sortedRootSessions(data, props.sortNow()) } const workspaceChildren = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 5eb5e71cd..48c63e547 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -32,6 +32,7 @@ type InlineEditorComponent = (props: { export type WorkspaceSidebarContext = { currentDir: Accessor + navList: Accessor sidebarExpanded: Accessor sidebarHovering: Accessor nav: Accessor @@ -265,6 +266,8 @@ const WorkspaceSessionList = (props: { {(session) => ( { if (!prev) return if (next.dir === prev.dir && next.id === prev.id) return - if (prev.id) sync.session.evict(prev.id, prev.dir) if (!next.id) resetSessionModel(local) }, { defer: true }, @@ -464,6 +464,10 @@ export default function Page() { }, sessionKey()) let reviewFrame: number | undefined + let refreshFrame: number | undefined + let refreshTimer: number | undefined + let diffFrame: number | undefined + let diffTimer: number | undefined createComputed((prev) => { const open = desktopReviewOpen() @@ -623,10 +627,36 @@ export default function Page() { createEffect( on([() => sdk.directory, () => params.id] as const, ([, id]) => { + if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) + if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) + refreshFrame = undefined + refreshTimer = undefined if (!id) return + + const cached = untrack(() => sync.data.message[id] !== undefined) + const stale = !cached + ? false + : (() => { + const info = getSessionPrefetch(sdk.directory, id) + if (!info) return true + return Date.now() - info.at > SESSION_PREFETCH_TTL + })() + const todos = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined) + untrack(() => { void sync.session.sync(id) - void sync.session.todo(id) + }) + + refreshFrame = requestAnimationFrame(() => { + refreshFrame = undefined + refreshTimer = window.setTimeout(() => { + refreshTimer = undefined + if (params.id !== id) return + untrack(() => { + if (stale) void sync.session.sync(id, { force: true }) + void sync.session.todo(id, todos ? { force: true } : undefined) + }) + }, 0) }) }), ) @@ -1064,6 +1094,39 @@ export default function Page() { void sync.session.diff(id) }) + createEffect( + on( + () => + [ + sessionKey(), + isDesktop() + ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") + : store.mobileTab === "changes", + ] as const, + ([key, wants]) => { + if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) + if (diffTimer !== undefined) window.clearTimeout(diffTimer) + diffFrame = undefined + diffTimer = undefined + if (!wants) return + + const id = params.id + if (!id) return + if (!untrack(() => sync.data.session_diff[id] !== undefined)) return + + diffFrame = requestAnimationFrame(() => { + diffFrame = undefined + diffTimer = window.setTimeout(() => { + diffTimer = undefined + if (sessionKey() !== key) return + void sync.session.diff(id, { force: true }) + }, 0) + }) + }, + { defer: true }, + ), + ) + let treeDir: string | undefined createEffect(() => { const dir = sdk.directory @@ -1326,6 +1389,10 @@ export default function Page() { onCleanup(() => { document.removeEventListener("keydown", handleKeyDown) if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) + if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) + if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) + if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) + if (diffTimer !== undefined) window.clearTimeout(diffTimer) if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) })