Jelajahi Sumber

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

Kit Langton 3 hari lalu
induk
melakukan
e5d79ae187
1 mengubah file dengan 47 tambahan dan 65 penghapusan
  1. 47 65
      packages/tui/src/routes/session/index.tsx

+ 47 - 65
packages/tui/src/routes/session/index.tsx

@@ -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) => (