Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
464 changes: 454 additions & 10 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx

Large diffs are not rendered by default.

120 changes: 113 additions & 7 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export function Session() {
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
})
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const paging = createMemo(() => sync.data.message_page[route.sessionID])
const permissions = createMemo(() => {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
Expand All @@ -128,6 +129,53 @@ export function Session() {
return children().flatMap((x) => sync.data.question[x.id] ?? [])
})

const LOAD_MORE_THRESHOLD = 5

const loadOlder = () => {
const page = paging()
if (!page?.hasOlder || page.loading || !scroll) return
if (scroll.scrollTop > LOAD_MORE_THRESHOLD) return

const anchor = (() => {
const scrollTop = scroll.scrollTop
const children = scroll.getChildren()
for (const child of children) {
if (!child.id) continue
if (child.y + child.height > scrollTop) {
return { id: child.id, offset: scrollTop - child.y }
}
}
return undefined
})()

const height = scroll.scrollHeight
const scrollTop = scroll.scrollTop
sync.session.loadOlder(route.sessionID).then(() => {
queueMicrotask(() => {
requestAnimationFrame(() => {
if (anchor) {
const child = scroll.getChildren().find((item) => item.id === anchor.id)
if (child) {
scroll.scrollTo(child.y + anchor.offset)
return
}
}

const delta = scroll.scrollHeight - height
if (delta > 0) scroll.scrollTo(scrollTop + delta)
})
})
})
}

const loadNewer = () => {
const page = paging()
if (!page?.hasNewer || page.loading || !scroll) return
const bottomDistance = scroll.scrollHeight - scroll.scrollTop - scroll.viewport.height
if (bottomDistance > LOAD_MORE_THRESHOLD) return
sync.session.loadNewer(route.sessionID)
}

const pending = createMemo(() => {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
})
Expand Down Expand Up @@ -230,7 +278,7 @@ export function Session() {
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
const children = scroll.getChildren()
const messagesList = messages()
const scrollTop = scroll.y
const scrollTop = scroll.scrollTop

// Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
const visibleMessages = children
Expand Down Expand Up @@ -268,7 +316,7 @@ export function Session() {
}

const child = scroll.getChildren().find((c) => c.id === targetID)
if (child) scroll.scrollBy(child.y - scroll.y - 1)
if (child) scroll.scrollBy(child.y - scroll.scrollTop - 1)
dialog.clear()
}

Expand Down Expand Up @@ -347,7 +395,7 @@ export function Session() {
const child = scroll.getChildren().find((child) => {
return child.id === messageID
})
if (child) scroll.scrollBy(child.y - scroll.y - 1)
if (child) scroll.scrollBy(child.y - scroll.scrollTop - 1)
}}
sessionID={route.sessionID}
setPrompt={(promptInfo) => prompt.set(promptInfo)}
Expand All @@ -370,7 +418,7 @@ export function Session() {
const child = scroll.getChildren().find((child) => {
return child.id === messageID
})
if (child) scroll.scrollBy(child.y - scroll.y - 1)
if (child) scroll.scrollBy(child.y - scroll.scrollTop - 1)
}}
sessionID={route.sessionID}
/>
Expand Down Expand Up @@ -643,7 +691,16 @@ export function Session() {
category: "Session",
hidden: true,
onSelect: (dialog) => {
scroll.scrollTo(0)
const page = paging()
if (page?.hasOlder && !page.loading) {
sync.session.jumpToOldest(route.sessionID).then(() => {
requestAnimationFrame(() => {
if (scroll) scroll.scrollTo(0)
})
})
} else {
scroll.scrollTo(0)
}
dialog.clear()
},
},
Expand All @@ -654,7 +711,16 @@ export function Session() {
category: "Session",
hidden: true,
onSelect: (dialog) => {
scroll.scrollTo(scroll.scrollHeight)
const page = paging()
if (page?.hasNewer && !page.loading) {
sync.session.jumpToLatest(route.sessionID).then(() => {
requestAnimationFrame(() => {
if (scroll) scroll.scrollTo(scroll.scrollHeight)
})
})
} else {
scroll.scrollTo(scroll.scrollHeight)
}
dialog.clear()
},
},
Expand Down Expand Up @@ -684,7 +750,7 @@ export function Session() {
const child = scroll.getChildren().find((child) => {
return child.id === message.id
})
if (child) scroll.scrollBy(child.y - scroll.y - 1)
if (child) scroll.scrollBy(child.y - scroll.scrollTop - 1)
break
}
}
Expand Down Expand Up @@ -954,8 +1020,38 @@ export function Session() {
<Show when={!sidebarVisible() || !wide()}>
<Header />
</Show>
<Show when={paging()?.loading && paging()?.loadingDirection === "older"}>
<box flexShrink={0} paddingLeft={1}>
<text fg={theme.textMuted}>Loading older messages...</text>
</box>
</Show>
<Show when={!paging()?.loading && paging()?.hasOlder}>
<box flexShrink={0} paddingLeft={1}>
<text fg={theme.textMuted}>(scroll up for more)</text>
</box>
</Show>
<Show when={paging()?.error}>
<box flexShrink={0} paddingLeft={1}>
<text fg={theme.error}>Failed to load: {paging()?.error}</text>
<text fg={theme.textMuted}> (scroll to retry)</text>
</box>
</Show>
<scrollbox
ref={(r) => (scroll = r)}
onMouseScroll={() => {
loadOlder()
loadNewer()
}}
onKeyDown={(e) => {
// Standard scroll triggers incremental load
if (["up", "pageup", "home"].includes(e.name)) {
setTimeout(loadOlder, 0)
}
if (["down", "pagedown", "end"].includes(e.name)) {
setTimeout(loadNewer, 0)
}
}}
viewportCulling={true}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}
Expand Down Expand Up @@ -1068,6 +1164,16 @@ export function Session() {
)}
</For>
</scrollbox>
<Show when={paging()?.loading && paging()?.loadingDirection === "newer"}>
<box flexShrink={0} paddingLeft={1}>
<text fg={theme.textMuted}>Loading newer messages...</text>
</box>
</Show>
<Show when={!paging()?.loading && paging()?.hasNewer}>
<box flexShrink={0} paddingLeft={1}>
<text fg={theme.textMuted}>(scroll down for more)</text>
</box>
</Show>
<box flexShrink={0}>
<Show when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} />
Expand Down
92 changes: 87 additions & 5 deletions packages/opencode/src/server/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { Session } from "../../session"
import { MessageV2 } from "../../session/message-v2"
import { Identifier } from "../../id/id"
import { SessionPrompt } from "../../session/prompt"
import { SessionCompaction } from "../../session/compaction"
import { SessionRevert } from "../../session/revert"
Expand Down Expand Up @@ -570,16 +571,97 @@ export const SessionRoutes = lazy(() =>
validator(
"query",
z.object({
limit: z.coerce.number().optional(),
limit: z.coerce.number().int().min(0).optional(),
before: Identifier.schema("message").optional(),
after: Identifier.schema("message").optional(),
oldest: z.coerce.boolean().optional(),
}),
),
async (c) => {
const query = c.req.valid("query")
const messages = await Session.messages({
sessionID: c.req.valid("param").sessionID,
limit: query.limit,
if (query.before && query.after) {
return c.json({ error: "Cannot specify both 'before' and 'after'" }, 400)
}
if (query.oldest && (query.before || query.after)) {
return c.json({ error: "Cannot use 'oldest' with 'before' or 'after'" }, 400)
}

const rawLimit = query.limit === 0 ? undefined : query.limit
const sessionID = c.req.valid("param").sessionID

const usesCursor = !!(query.oldest || query.after || query.before)

if (!usesCursor && rawLimit === undefined) {
const messages = await Session.messages({ sessionID })
return c.json(messages)
}

const pageLimit = usesCursor ? (rawLimit ? Math.min(rawLimit, 100) : 100) : (rawLimit ?? 100)

if (query.oldest) {
const page = await Session.messages({
sessionID,
limit: pageLimit + 1,
oldest: true,
})

if (page.length > pageLimit) {
const messages = page.slice(0, -1)
const last = messages.at(-1)
if (last) {
const url = new URL(c.req.url)
url.searchParams.delete("oldest")
url.searchParams.set("limit", pageLimit.toString())
url.searchParams.set("after", last.info.id)
c.header("Link", `<${url.toString()}>; rel=\"next\"`)
}
return c.json(messages)
}

return c.json(page)
}

if (query.after) {
const page = await Session.messages({
sessionID,
limit: pageLimit + 1,
after: query.after,
})

if (page.length > pageLimit) {
const messages = page.slice(0, -1)
const last = messages.at(-1)
if (last) {
const url = new URL(c.req.url)
url.searchParams.set("limit", pageLimit.toString())
url.searchParams.set("after", last.info.id)
c.header("Link", `<${url.toString()}>; rel=\"next\"`)
}
return c.json(messages)
}

return c.json(page)
}

const page = await Session.messages({
sessionID,
limit: pageLimit + 1,
before: query.before,
})
return c.json(messages)

if (page.length > pageLimit) {
const messages = page.slice(1)
const first = messages.at(0)
if (first) {
const url = new URL(c.req.url)
url.searchParams.set("limit", pageLimit.toString())
url.searchParams.set("before", first.info.id)
c.header("Link", `<${url.toString()}>; rel=\"prev\"`)
}
return c.json(messages)
}

return c.json(page)
},
)
.get(
Expand Down
Loading