Skip to content
Merged
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
6 changes: 2 additions & 4 deletions packages/tui/src/routes/session/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ export function createHistoryPrepend(input: {
sessionID: () => string
more: (sessionID: string) => boolean
loadMore: (sessionID: string) => Promise<void>
height: () => number
afterLayout: (continuation: () => void) => void
active: (sessionID: string) => boolean
scrollBy: (amount: number) => void
Expand All @@ -23,15 +22,14 @@ export function createHistoryPrepend(input: {
if (!input.more(sessionID)) return false
const current = { scrollBy, continuation }
pending = current
const before = input.height()
void input.loadMore(sessionID).then(
() =>
input.afterLayout(() => {
if (pending !== current) return
const after = pending.after
pending = undefined
if (!input.active(sessionID)) return
input.scrollBy(input.height() - before + current.scrollBy)
input.scrollBy(current.scrollBy)
current.continuation?.()
after?.()
}),
Expand All @@ -52,7 +50,7 @@ export function createHistoryPrepend(input: {
},
after(continuation: () => void) {
if (!pending) return continuation()
// A jump supersedes deferred scrolling, but must wait for anchor compensation.
// A jump supersedes deferred scrolling, but must wait until the prepended page is laid out.
pending.scrollBy = 0
pending.after = continuation
},
Expand Down
23 changes: 18 additions & 5 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,8 @@ export function Session(props: {
(id) => {
if (id === sessionID) setRowsSynced(true)
},
// A scroll-triggered history load joins this boundary load instead of publishing its own page.
() => pinAbove(),
)
const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
const anchors = createTimelineAnchors()
Expand Down Expand Up @@ -404,6 +406,15 @@ export function Session(props: {
})
}

// Pin an existing row before publication. Total height also counts streamed output below the
// reader, which must not move their viewport when it arrives alongside the older rows.
const pinAbove = () => {
if (!scroll || scroll.isDestroyed) return
if (scroll.stickyScroll && scroll.scrollTop >= scroll.scrollHeight - scroll.viewport.height) return
const node = scroll.getChildren().find((node) => node.height > 0 && node.y + node.height > scroll.viewport.y)
if (node) afterLayout(scroll.anchor(node))
}

// Tail-first transcript mounting: only the newest rows mount when the session opens. Older rows
// mount on demand near the top, keeping inactive tabs cheap to tear down. While the reader stays
// at the bottom the hidden span follows appends; leaving the bottom pins it to preserve the viewport.
Expand All @@ -426,16 +437,18 @@ export function Session(props: {
sessionID: () => route.sessionID,
more: (id) => data.session.message.more(id),
loadMore: async (id) => {
await data.session.message.loadMore(id)
const beforePublish = () => {
if (route.sessionID === id) pinAbove()
}
await data.session.message.loadMore(id, { beforePublish })
await completeGroupBoundary({
rows,
messages: () => data.session.message.list(id).length,
more: () => data.session.message.more(id),
loadMore: () => data.session.message.loadMore(id),
loadMore: () => data.session.message.loadMore(id, { beforePublish }),
active: () => route.sessionID === id,
})
},
height: () => scroll.scrollHeight,
afterLayout,
active: (id) => route.sessionID === id && Boolean(scroll && !scroll.isDestroyed),
scrollBy: (amount) => {
Expand All @@ -449,11 +462,11 @@ export function Session(props: {
if (revealingOlderRows || !scroll || scroll.isDestroyed || scroll.scrollTop > scroll.viewport.height) return false
if (current === 0) return prependHistory(scrollBy)
revealingOlderRows = true
const before = scroll.scrollHeight
scroll.stickyScroll = false
pinAbove()
setHiddenRows(rowsBefore(weights(), current, TRANSCRIPT_BACKFILL_CHUNK))
afterLayout(() => {
scroll.scrollBy(scroll.scrollHeight - before + scrollBy)
scroll.scrollBy(scrollBy)
scroll.stickyScroll = !navigationMessage()
revealingOlderRows = false
})
Expand Down
8 changes: 6 additions & 2 deletions packages/tui/src/routes/session/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ export async function completeGroupBoundary(input: {
}
}

export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessionID: string) => void) {
export function createSessionRows(
sessionID: Accessor<string>,
onSynced?: (sessionID: string) => void,
beforePublish?: () => void,
) {
const data = useData()
const client = useClient()
const config = useConfig()
Expand Down Expand Up @@ -131,7 +135,7 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
rows,
messages: () => data.session.message.list(id).length,
more: () => data.session.message.more(id),
loadMore: () => data.session.message.loadMore(id),
loadMore: () => data.session.message.loadMore(id, { beforePublish }),
active: () => sessionID() === id,
}).catch(() => undefined)
if (sessionID() === id) onSynced?.(id)
Expand Down
27 changes: 7 additions & 20 deletions packages/tui/test/cli/tui/session-history.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,16 @@
import { expect, test } from "bun:test"
import { createHistoryPrepend } from "../../../src/routes/session/history"

test("loads older history and preserves the visible scroll anchor", async () => {
let height = 100
test("loads older history and applies the deferred scroll after layout", async () => {
let resolveLoad: (() => void) | undefined
const scrolled: number[] = []
const prepend = createHistoryPrepend({
sessionID: () => "session-1",
more: () => true,
loadMore: () =>
new Promise<void>((resolve) => {
resolveLoad = () => {
height = 160
resolve()
}
resolveLoad = resolve
}),
height: () => height,
afterLayout: (continuation) => continuation(),
active: (sessionID) => sessionID === "session-1",
scrollBy: (amount) => scrolled.push(amount),
Expand All @@ -27,7 +22,7 @@ test("loads older history and preserves the visible scroll anchor", async () =>
await Promise.resolve()
await Promise.resolve()

expect(scrolled).toEqual([56])
expect(scrolled).toEqual([-4])
})

test("releases the history load after a failed request", async () => {
Expand All @@ -39,7 +34,6 @@ test("releases the history load after a failed request", async () => {
attempts++
return Promise.reject(new Error("offline"))
},
height: () => 100,
afterLayout: (continuation) => continuation(),
active: () => true,
scrollBy: () => undefined,
Expand All @@ -63,7 +57,6 @@ test("does not move a different session after history loads", async () => {
new Promise<void>((resolve) => {
resolveLoad = resolve
}),
height: () => 160,
afterLayout: (continuation) => continuation(),
active: (sessionID) => current === sessionID,
scrollBy: (amount) => scrolled.push(amount),
Expand All @@ -86,32 +79,29 @@ test("continues navigation after the prepended page is laid out", async () => {
loadMore: async () => {
events.push("loaded")
},
height: () => 100,
afterLayout: (continuation) => {
events.push("layout")
continuation()
},
active: () => true,
scrollBy: () => events.push("anchored"),
scrollBy: () => events.push("scrolled"),
})

expect(prepend(0, () => events.push("continued"))).toBe(true)
await Promise.resolve()
await Promise.resolve()

expect(events).toEqual(["loaded", "layout", "anchored", "continued"])
expect(events).toEqual(["loaded", "layout", "scrolled", "continued"])
})

test.each(["success", "failure", "cancel", "takeover"])("settles superseded prepend navigation (%s)", async (mode) => {
const load = Promise.withResolvers<void>()
const layout: (() => void)[] = []
const events: (number | string)[] = []
let height = 100
const prepend = createHistoryPrepend({
sessionID: () => "session-1",
more: () => true,
loadMore: () => load.promise,
height: () => height,
afterLayout: (continuation) => layout.push(continuation),
active: () => true,
scrollBy: (amount) => events.push(amount),
Expand All @@ -122,15 +112,12 @@ test.each(["success", "failure", "cancel", "takeover"])("settles superseded prep
if (mode === "cancel" || mode === "takeover") prepend.cancel()
if (mode === "takeover") expect(prepend(-8)).toBe(true)
if (mode === "failure") load.reject(new Error("offline"))
if (mode !== "failure") {
height = 160
load.resolve()
}
if (mode !== "failure") load.resolve()
await Promise.resolve()
expect(events).toEqual(mode === "failure" ? ["jump"] : [])
layout.shift()?.()
expect(events).toEqual(
mode === "failure" ? ["jump"] : mode === "success" ? [60, "jump"] : mode === "takeover" ? [52] : [60],
mode === "failure" ? ["jump"] : mode === "success" ? [0, "jump"] : mode === "takeover" ? [-8] : [0],
)
prepend.after(() => events.push("idle"))
expect(events.at(-1)).toBe("idle")
Expand Down
Loading
Loading