perf: page tui session history

pull/17338/head
Shoubhit Dash 2026-03-13 17:31:22 +05:30
parent 270cb0b8b4
commit 5b1cbda323
2 changed files with 163 additions and 41 deletions

View File

@ -30,6 +30,26 @@ import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
import type { Workspace } from "@opencode-ai/sdk/v2"
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
const pending = map.get(key)
if (pending) return pending
const promise = task().finally(() => {
map.delete(key)
})
map.set(key, promise)
return promise
}
function cmp(a: string, b: string) {
return a < b ? -1 : a > b ? 1 : 0
}
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
const map = new Map(a.map((item) => [item.id, item] as const))
for (const item of b) map.set(item.id, item)
return [...map.values()].sort((x, y) => cmp(x.id, y.id))
}
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
@ -106,6 +126,31 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
const sdk = useSDK()
const pageSize = 100
const inflight = new Map<string, Promise<void>>()
const [meta, setMeta] = createStore({
cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean>,
loading: {} as Record<string, boolean>,
synced: {} as Record<string, boolean>,
})
const fetchMessages = async (input: { sessionID: string; limit: number; before?: string }) => {
const messages = await sdk.client.session.messages(
{ sessionID: input.sessionID, limit: input.limit, before: input.before },
{ throwOnError: true },
)
const items = (messages.data ?? []).filter((item) => !!item?.info?.id)
const next = items.map((item) => item.info).sort((a, b) => cmp(a.id, b.id))
const part = items.map((item) => ({ id: item.info.id, part: item.parts.toSorted((a, b) => cmp(a.id, b.id)) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return {
message: next,
part,
cursor,
complete: !cursor,
}
}
async function syncWorkspaces() {
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
@ -203,6 +248,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
case "session.deleted": {
setMeta(
produce((draft) => {
delete draft.cursor[event.properties.info.id]
delete draft.complete[event.properties.info.id]
delete draft.loading[event.properties.info.id]
delete draft.synced[event.properties.info.id]
}),
)
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore(
@ -252,25 +305,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
draft.splice(result.index, 0, event.properties.info)
}),
)
const updated = store.message[event.properties.info.sessionID]
if (updated.length > 100) {
const oldest = updated[0]
batch(() => {
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.shift()
}),
)
setStore(
"part",
produce((draft) => {
delete draft[oldest.id]
}),
)
})
}
break
}
case "message.removed": {
@ -441,7 +475,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
bootstrap()
})
const fullSyncedSessions = new Set<string>()
const result = {
data: store,
set: setStore,
@ -468,27 +501,73 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return last.time.completed ? "idle" : "working"
},
async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
])
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session[match.index] = session.data!
if (!match.found) draft.session.splice(match.index, 0, session.data!)
draft.todo[sessionID] = todo.data ?? []
draft.message[sessionID] = messages.data!.map((x) => x.info)
for (const message of messages.data!) {
draft.part[message.info.id] = message.parts
}
draft.session_diff[sessionID] = diff.data ?? []
}),
)
fullSyncedSessions.add(sessionID)
if (meta.synced[sessionID]) return
return runInflight(inflight, sessionID, async () => {
if (meta.synced[sessionID]) return
setMeta("loading", sessionID, true)
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
fetchMessages({ sessionID, limit: pageSize }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
])
batch(() => {
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session[match.index] = session.data!
if (!match.found) draft.session.splice(match.index, 0, session.data!)
draft.todo[sessionID] = todo.data ?? []
draft.message[sessionID] = messages.message
for (const message of messages.part) {
draft.part[message.id] = message.part
}
draft.session_diff[sessionID] = diff.data ?? []
}),
)
setMeta("cursor", sessionID, messages.cursor)
setMeta("complete", sessionID, messages.complete)
setMeta("synced", sessionID, true)
setMeta("loading", sessionID, false)
})
}).finally(() => {
setMeta("loading", sessionID, false)
})
},
history: {
more(sessionID: string) {
return !meta.complete[sessionID] && !!meta.cursor[sessionID]
},
loading(sessionID: string) {
return meta.loading[sessionID] ?? false
},
async loadMore(sessionID: string, count = pageSize) {
const before = meta.cursor[sessionID]
if (!before) return
if (meta.loading[sessionID]) return
return runInflight(inflight, `${sessionID}:history`, async () => {
const cursor = meta.cursor[sessionID]
if (!cursor) return
setMeta("loading", sessionID, true)
const next = await fetchMessages({ sessionID, limit: count, before: cursor })
batch(() => {
setStore(
"message",
sessionID,
reconcile(merge(store.message[sessionID] ?? [], next.message), { key: "id" }),
)
for (const item of next.part) {
setStore("part", item.id, reconcile(merge(store.part[item.id] ?? [], item.part), { key: "id" }))
}
setMeta("cursor", sessionID, next.cursor)
setMeta("complete", sessionID, next.complete)
setMeta("synced", sessionID, true)
setMeta("loading", sessionID, false)
})
}).finally(() => {
setMeta("loading", sessionID, false)
})
},
},
},
workspace: {

View File

@ -6,6 +6,7 @@ import {
createSignal,
For,
Match,
onCleanup,
on,
onMount,
Show,
@ -233,6 +234,7 @@ export function Session() {
let scroll: ScrollBoxRenderable
let prompt: PromptRef
let historyAdjust = false
const keybind = useKeybind()
const dialog = useDialog()
const renderer = useRenderer()
@ -319,6 +321,38 @@ export function Session() {
}, 50)
}
async function loadOlder() {
if (!scroll || scroll.isDestroyed) return
if (!sync.session.history.more(route.sessionID)) return
if (sync.session.history.loading(route.sessionID)) return
historyAdjust = true
const sessionID = route.sessionID
const height = scroll.scrollHeight
const y = scroll.y
try {
await sync.session.history.loadMore(sessionID)
await new Promise((resolve) => setTimeout(resolve, 0))
if (!scroll || scroll.isDestroyed) return
if (route.sessionID !== sessionID) return
const next = scroll.scrollHeight - height
if (next > 0) scroll.scrollBy(next + (y === 0 ? -1 : 0))
} finally {
historyAdjust = false
}
}
onMount(() => {
const id = setInterval(() => {
if (!scroll || scroll.isDestroyed) return
if (historyAdjust) return
if (scroll.y > 2) return
if (!sync.session.history.more(route.sessionID)) return
if (sync.session.history.loading(route.sessionID)) return
void loadOlder()
}, 120)
onCleanup(() => clearInterval(id))
})
const local = useLocal()
function moveFirstChild() {
@ -1069,6 +1103,15 @@ export function Session() {
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<Show when={sync.session.history.loading(route.sessionID) || sync.session.history.more(route.sessionID)}>
<box paddingLeft={3} paddingBottom={1} flexShrink={0}>
<text fg={theme.textMuted}>
{sync.session.history.loading(route.sessionID)
? "Loading older messages..."
: "Scroll up for older messages"}
</text>
</box>
</Show>
<For each={messages()}>
{(message, index) => (
<Switch>