summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-03-12 11:31:52 -0500
committerGitHub <[email protected]>2026-03-12 11:31:52 -0500
commit0e077f748352df6d44c811829baff3c26b3436ac (patch)
tree1239dd7f492a528037c92ef695b31ef90b5c86cc /packages
parent776e7a9c15f3e352c5abf0b0949a5d7b767adfa3 (diff)
downloadopencode-0e077f748352df6d44c811829baff3c26b3436ac.tar.gz
opencode-0e077f748352df6d44c811829baff3c26b3436ac.zip
feat: session load perf (#17186)
Diffstat (limited to 'packages')
-rw-r--r--packages/app/src/context/global-sync.tsx2
-rw-r--r--packages/app/src/context/global-sync/session-prefetch.test.ts63
-rw-r--r--packages/app/src/context/global-sync/session-prefetch.ts85
-rw-r--r--packages/app/src/context/sync.tsx115
-rw-r--r--packages/app/src/pages/layout.tsx210
-rw-r--r--packages/app/src/pages/layout/sidebar-items.tsx53
-rw-r--r--packages/app/src/pages/layout/sidebar-project.tsx12
-rw-r--r--packages/app/src/pages/layout/sidebar-workspace.tsx3
-rw-r--r--packages/app/src/pages/session.tsx71
9 files changed, 474 insertions, 140 deletions
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<string, Meta>()
+const inflight = new Map<string, Promise<Meta | undefined>>()
+const rev = new Map<string, number>()
+
+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<Meta | undefined>
+}) {
+ 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<string>) {
+ 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 limit = meta.limit[key] ?? messagePageSize
-
- 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 messagesReq = loadMessages({
- directory,
- client,
- setStore,
- sessionID,
- limit,
- })
+ 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)
+ })
+ }
- return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
+ 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 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 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])
+ })
},
- 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<string, PrefetchQueue>()
@@ -689,13 +699,29 @@ 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
-
- 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 current = store.message[sessionID] ?? []
- const merged = mergeByID(
- current.filter((item): item is Message => !!item?.id),
- sorted,
- )
-
- 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),
+ 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 stale = markPrefetched(directory, sessionID)
+ const meta = {
+ limit: prefetchChunk,
+ complete: sorted.length < prefetchChunk,
+ at: Date.now(),
+ }
+
+ if (stale.length > 0) {
+ clearSessionPrefetch(directory, stale)
+ for (const id of stale) {
+ globalSync.todo.set(id, undefined)
+ }
+ }
+
+ 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)
}
- createEffect(() => {
- const sessions = currentSessions()
- const id = params.id
-
- if (!id) {
- const first = sessions[0]
- if (first) prefetchSession(first)
+ 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 second = sessions[1]
- if (second) prefetchSession(second)
- return
+ const prev = sessions[index - offset]
+ if (prev) prefetchSession(prev, offset === 1 ? "high" : "low")
}
+ }
- const index = sessions.findIndex((s) => s.id === id)
+ createEffect(() => {
+ const sessions = currentSessions()
+ if (sessions.length === 0) return
+
+ 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<Session[]>
slug: string
mobile?: boolean
dense?: boolean
@@ -95,18 +98,18 @@ const SessionRow = (props: {
setHoverSession: (id: string | undefined) => void
clearHoverProjectSoon: () => void
sidebarOpened: Accessor<boolean>
- prefetchSession: (session: Session, priority?: "high" | "low") => void
- scheduleHoverPrefetch: () => void
+ warmHover: () => void
+ warmPress: () => void
+ warmFocus: () => void
cancelHoverPrefetch: () => void
}): JSX.Element => (
<A
href={`/${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
- onPointerEnter={props.scheduleHoverPrefetch}
+ onPointerDown={props.warmPress}
+ onPointerEnter={props.warmHover}
onPointerLeave={props.cancelHoverPrefetch}
- onMouseEnter={props.scheduleHoverPrefetch}
- onMouseLeave={props.cancelHoverPrefetch}
- onFocus={() => 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<typeof setTimeout> | 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<SessionItemProps, "session" | "slug" | "children" | "mobile" | "dense" | "popover">
+ sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "children" | "mobile" | "dense" | "popover">
setHoverSession: (id: string | undefined) => void
}
@@ -204,11 +204,12 @@ const ProjectPreviewPanel = (props: {
<Show
when={props.workspaceEnabled()}
fallback={
- <For each={props.projectSessions()}>
+ <For each={props.projectSessions().slice(0, 2)}>
{(session) => (
<SessionItem
{...props.ctx.sessionProps}
session={session}
+ list={props.projectSessions()}
slug={base64Encode(props.project.worktree)}
dense
mobile={props.mobile}
@@ -231,11 +232,12 @@ const ProjectPreviewPanel = (props: {
</div>
<span class="truncate text-14-medium text-text-base">{props.label(directory)}</span>
</div>
- <For each={sessions()}>
+ <For each={sessions().slice(0, 2)}>
{(session) => (
<SessionItem
{...props.ctx.sessionProps}
session={session}
+ list={sessions()}
slug={base64Encode(directory)}
dense
mobile={props.mobile}
@@ -317,11 +319,11 @@ export const SortableProject = (props: {
}
const projectStore = createMemo(() => 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<string>
+ navList: Accessor<Session[]>
sidebarExpanded: Accessor<boolean>
sidebarHovering: Accessor<boolean>
nav: Accessor<HTMLElement | undefined>
@@ -265,6 +266,8 @@ const WorkspaceSessionList = (props: {
{(session) => (
<SessionItem
session={session}
+ list={props.sessions()}
+ navList={props.ctx.navList}
slug={props.slug()}
mobile={props.mobile}
popover={props.popover}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index c25463d75..2454acf4d 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -27,6 +27,7 @@ import { base64Encode, checksum } from "@opencode-ai/util/encode"
import { useNavigate, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
+import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
@@ -437,7 +438,6 @@ export default function Page() {
(next, prev) => {
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)
})