fix(tui): load transcript history on demand (#42388)

This commit is contained in:
Kit Langton
2026-08-13 15:52:39 -04:00
committed by GitHub
Unverified
parent b66f01b37c
commit e5d79ae187
+47 -65
View File
@@ -115,12 +115,9 @@ addDefaultParsers(parsers.parsers)
const NAVIGATION_SLACK_ID = "session-navigation-slack"
const BACKGROUND_TOOL_HINT_DELAY = 1_000
// Tail-first transcript mounting: rows mounted with the session, then backfill cadence.
// The tail comfortably overfills a tall viewport; backfill drains a 200-message transcript
// in a few hundred milliseconds without a perceptible pause.
// The tail comfortably overfills a tall viewport; older rows mount as the reader approaches them.
const TRANSCRIPT_TAIL_ROWS = 40
const TRANSCRIPT_BACKFILL_CHUNK = 60
const TRANSCRIPT_BACKFILL_DELAY = 120
type PendingAction = "steer" | "queue" | "cancel"
const context = createContext<{
@@ -362,32 +359,33 @@ export function Session() {
})
}
// Tail-first transcript mounting: only the newest rows mount when the session opens, and the
// rest backfill in chunks shortly after, so switching to a long session costs the visible tail
// instead of the whole transcript. Until backfill pins the count, the hidden span derives from
// the row count, so it needs no effect ordering; the clamp keeps at least a tail visible when a
// re-reduce shrinks the transcript. Streaming appends land at the end of the visible slice.
// 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. Until the first chunk
// pins the count, the hidden span derives from the row count, so streaming appends remain visible.
const [hiddenRows, setHiddenRows] = createSignal<number>()
const hidden = createMemo(() => Math.max(0, Math.min(hiddenRows() ?? Infinity, rows.length - TRANSCRIPT_TAIL_ROWS)))
const visibleRows = createMemo(() => (hidden() === 0 ? rows : rows.slice(hidden())))
createEffect(() => {
let revealingOlderRows = false
const revealOlderRows = (scrollBy = 0) => {
const current = hidden()
if (current === 0) return
// Until the first chunk pins hiddenRows, appends change hidden() and reset this timer, so
// backfill waits for a pause in streaming before starting. Once pinned, it drains on a fixed
// cadence undisturbed by appends.
const timer = setTimeout(() => {
const before = scroll && !scroll.isDestroyed ? scroll.scrollHeight : undefined
const viewportBottom = before === undefined ? 0 : scroll.scrollTop + scroll.viewport.height
setHiddenRows(Math.max(0, current - TRANSCRIPT_BACKFILL_CHUNK))
if (before === undefined) return
// Sticky scroll holds bottom-anchored readers through the mount; compensation is only for
// readers who have scrolled up.
if (viewportBottom >= before - 1) return
afterLayout(() => scroll.scrollBy(scroll.scrollHeight - before))
}, TRANSCRIPT_BACKFILL_DELAY)
onCleanup(() => clearTimeout(timer))
})
if (
revealingOlderRows ||
current === 0 ||
!scroll ||
scroll.isDestroyed ||
scroll.scrollTop > scroll.viewport.height
)
return false
revealingOlderRows = true
const before = scroll.scrollHeight
setHiddenRows(Math.max(0, current - TRANSCRIPT_BACKFILL_CHUNK))
afterLayout(() => {
revealingOlderRows = false
scroll.scrollBy(scroll.scrollHeight - before + scrollBy)
updateAwayFromBottom()
})
return true
}
/** Message navigation needs the full transcript mounted before walking or jumping. */
const ensureAllRows = (continuation: () => void) => {
if (hidden() === 0) return continuation()
@@ -536,78 +534,57 @@ export function Session() {
}, 50)
}
function moveTranscript(delta: number) {
clearMessageNavigation()
if (delta >= 0 || !revealOlderRows(delta)) {
scroll.scrollBy(delta)
updateAwayFromBottom()
}
dialog.clear()
}
const globalCommands = [
{
id: "session.page.up",
title: "Page up",
group: "Session",
palette: undefined,
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 2)
updateAwayFromBottom()
dialog.clear()
},
run: () => moveTranscript(-scroll.height / 2),
},
{
id: "session.page.down",
title: "Page down",
group: "Session",
palette: undefined,
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 2)
updateAwayFromBottom()
dialog.clear()
},
run: () => moveTranscript(scroll.height / 2),
},
{
id: "session.line.up",
title: "Line up",
group: "Session",
palette: undefined,
run: () => {
clearMessageNavigation()
scroll.scrollBy(-1)
updateAwayFromBottom()
dialog.clear()
},
run: () => moveTranscript(-1),
},
{
id: "session.line.down",
title: "Line down",
group: "Session",
palette: undefined,
run: () => {
clearMessageNavigation()
scroll.scrollBy(1)
updateAwayFromBottom()
dialog.clear()
},
run: () => moveTranscript(1),
},
{
id: "session.half.page.up",
title: "Half page up",
group: "Session",
palette: undefined,
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 4)
updateAwayFromBottom()
dialog.clear()
},
run: () => moveTranscript(-scroll.height / 4),
},
{
id: "session.half.page.down",
title: "Half page down",
group: "Session",
palette: undefined,
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 4)
updateAwayFromBottom()
dialog.clear()
},
run: () => moveTranscript(scroll.height / 4),
},
]
@@ -619,8 +596,10 @@ export function Session() {
palette: undefined,
run: () => {
clearMessageNavigation()
scroll.scrollTo(0)
updateAwayFromBottom()
ensureAllRows(() => {
scroll.scrollTo(0)
updateAwayFromBottom()
})
dialog.clear()
},
},
@@ -1102,7 +1081,10 @@ export function Session() {
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
onMouseScroll={updateAwayFromBottom}
onMouseScroll={(event) => {
if (event.scroll?.direction === "up" && revealOlderRows()) return
updateAwayFromBottom()
}}
>
<For each={visibleRows()}>
{(row, index) => (