Compare commits
1 Commits
dev
...
perf/tui-s
| Author | SHA1 | Date |
|---|---|---|
|
|
5b1cbda323 |
|
|
@ -30,6 +30,26 @@ import { Log } from "@/util/log"
|
||||||
import type { Path } from "@opencode-ai/sdk"
|
import type { Path } from "@opencode-ai/sdk"
|
||||||
import type { Workspace } from "@opencode-ai/sdk/v2"
|
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({
|
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
name: "Sync",
|
name: "Sync",
|
||||||
init: () => {
|
init: () => {
|
||||||
|
|
@ -106,6 +126,31 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
})
|
})
|
||||||
|
|
||||||
const sdk = useSDK()
|
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() {
|
async function syncWorkspaces() {
|
||||||
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
||||||
|
|
@ -203,6 +248,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
break
|
break
|
||||||
|
|
||||||
case "session.deleted": {
|
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)
|
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
||||||
if (result.found) {
|
if (result.found) {
|
||||||
setStore(
|
setStore(
|
||||||
|
|
@ -252,25 +305,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
draft.splice(result.index, 0, event.properties.info)
|
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
|
break
|
||||||
}
|
}
|
||||||
case "message.removed": {
|
case "message.removed": {
|
||||||
|
|
@ -441,7 +475,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
bootstrap()
|
bootstrap()
|
||||||
})
|
})
|
||||||
|
|
||||||
const fullSyncedSessions = new Set<string>()
|
|
||||||
const result = {
|
const result = {
|
||||||
data: store,
|
data: store,
|
||||||
set: setStore,
|
set: setStore,
|
||||||
|
|
@ -468,27 +501,73 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
return last.time.completed ? "idle" : "working"
|
return last.time.completed ? "idle" : "working"
|
||||||
},
|
},
|
||||||
async sync(sessionID: string) {
|
async sync(sessionID: string) {
|
||||||
if (fullSyncedSessions.has(sessionID)) return
|
if (meta.synced[sessionID]) return
|
||||||
const [session, messages, todo, diff] = await Promise.all([
|
return runInflight(inflight, sessionID, async () => {
|
||||||
sdk.client.session.get({ sessionID }, { throwOnError: true }),
|
if (meta.synced[sessionID]) return
|
||||||
sdk.client.session.messages({ sessionID, limit: 100 }),
|
setMeta("loading", sessionID, true)
|
||||||
sdk.client.session.todo({ sessionID }),
|
const [session, messages, todo, diff] = await Promise.all([
|
||||||
sdk.client.session.diff({ sessionID }),
|
sdk.client.session.get({ sessionID }, { throwOnError: true }),
|
||||||
])
|
fetchMessages({ sessionID, limit: pageSize }),
|
||||||
setStore(
|
sdk.client.session.todo({ sessionID }),
|
||||||
produce((draft) => {
|
sdk.client.session.diff({ sessionID }),
|
||||||
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
])
|
||||||
if (match.found) draft.session[match.index] = session.data!
|
batch(() => {
|
||||||
if (!match.found) draft.session.splice(match.index, 0, session.data!)
|
setStore(
|
||||||
draft.todo[sessionID] = todo.data ?? []
|
produce((draft) => {
|
||||||
draft.message[sessionID] = messages.data!.map((x) => x.info)
|
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
||||||
for (const message of messages.data!) {
|
if (match.found) draft.session[match.index] = session.data!
|
||||||
draft.part[message.info.id] = message.parts
|
if (!match.found) draft.session.splice(match.index, 0, session.data!)
|
||||||
}
|
draft.todo[sessionID] = todo.data ?? []
|
||||||
draft.session_diff[sessionID] = diff.data ?? []
|
draft.message[sessionID] = messages.message
|
||||||
}),
|
for (const message of messages.part) {
|
||||||
)
|
draft.part[message.id] = message.part
|
||||||
fullSyncedSessions.add(sessionID)
|
}
|
||||||
|
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: {
|
workspace: {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
createSignal,
|
createSignal,
|
||||||
For,
|
For,
|
||||||
Match,
|
Match,
|
||||||
|
onCleanup,
|
||||||
on,
|
on,
|
||||||
onMount,
|
onMount,
|
||||||
Show,
|
Show,
|
||||||
|
|
@ -233,6 +234,7 @@ export function Session() {
|
||||||
|
|
||||||
let scroll: ScrollBoxRenderable
|
let scroll: ScrollBoxRenderable
|
||||||
let prompt: PromptRef
|
let prompt: PromptRef
|
||||||
|
let historyAdjust = false
|
||||||
const keybind = useKeybind()
|
const keybind = useKeybind()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
|
|
@ -319,6 +321,38 @@ export function Session() {
|
||||||
}, 50)
|
}, 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()
|
const local = useLocal()
|
||||||
|
|
||||||
function moveFirstChild() {
|
function moveFirstChild() {
|
||||||
|
|
@ -1069,6 +1103,15 @@ export function Session() {
|
||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
scrollAcceleration={scrollAcceleration()}
|
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()}>
|
<For each={messages()}>
|
||||||
{(message, index) => (
|
{(message, index) => (
|
||||||
<Switch>
|
<Switch>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue