diff --git a/docs/lab-notes/2026-03-30-terminal-replay-scroll-investigation.md b/docs/lab-notes/2026-03-30-terminal-replay-scroll-investigation.md new file mode 100644 index 00000000..c40d1e54 --- /dev/null +++ b/docs/lab-notes/2026-03-30-terminal-replay-scroll-investigation.md @@ -0,0 +1,87 @@ +# Terminal Replay Scroll Investigation + +**Date:** 2026-03-30 +**Issue:** When a tab is visited after idle time or on first hydration (page refresh), the entire conversation rapidly replays — scrolling from top to bottom over ~1-2 seconds — instead of loading silently. + +## Reproduction + +1. Open Freshell at `localhost:3347` +2. Navigate to a tab with substantial terminal history (e.g., "Reload Me" tab with a Claude Code session) +3. Refresh the page (Cmd+R) +4. Observe: terminal content visibly scrolls from top to bottom rapidly + +## Root Cause Analysis + +The issue is in the interaction between the **server-side replay frame delivery**, the **client-side write queue**, and **xterm.js auto-scroll behavior**. + +### The Flow + +1. **Page loads** → terminal pane mounts with empty xterm.js instance +2. **WebSocket connects** → client sends `terminal.attach` with `sinceSeq: 0` (intent: `viewport_hydrate`) +3. **Server sends all replay frames synchronously** — `broker.ts:192-195` iterates through all frames in a tight loop: + ``` + for (const frame of replayFrames) { + sendFrame(ws, terminalId, frame, attachRequestId) + } + ``` +4. **Client receives frames** → each triggers `handleTerminalOutput()` → `enqueueTerminalWrite()` → the write queue +5. **Write queue processes frames** using `requestAnimationFrame` with an 8ms budget per frame (`terminal-write-queue.ts:26-27`). Within each animation frame, it flushes as many `term.write()` calls as fit within 8ms. +6. **xterm.js auto-scrolls on each write** — this is built-in xterm behavior. When data is written that moves the cursor past the visible viewport, xterm adjusts `ydisp` to follow the cursor. There is no explicit `scrollToBottom` call in the replay path — confirmed by grep. +7. **Over many animation frames** (1-2 seconds total), the user sees content appearing and the viewport rapidly tracking the cursor from top to bottom. + +### Why It's Visible + +- The terminal container is **immediately visible** during replay — no CSS hiding +- `isAttaching` is true during replay, which shows "Recovering terminal output..." text, but the xterm canvas is fully rendered and visible beneath it +- The write queue's rAF-based batching causes the replay to span **many visual frames**, each showing intermediate states + +### Key Code Locations + +| Component | File | Lines | Role | +|-----------|------|-------|------| +| Server replay delivery | `server/terminal-stream/broker.ts` | 192-195 | Sends all frames synchronously | +| Client attach entry | `src/components/TerminalView.tsx` | 1395-1462 | `attachTerminal()` sends attach request with sinceSeq=0 | +| Output frame handler | `src/components/TerminalView.tsx` | 1624-1692 | Receives frames, calls `handleTerminalOutput()` | +| Terminal output processing | `src/components/TerminalView.tsx` | 813-851 | Cleans data, calls `enqueueTerminalWrite()` | +| Write queue | `src/components/terminal/terminal-write-queue.ts` | 16-67 | rAF-based batching with 8ms budget | +| Viewport hydrate trigger | `src/components/TerminalView.tsx` | 1417, 1486 | Sets sinceSeq=0, clears viewport | + +### What Doesn't Cause It + +- **No explicit scrollToBottom in the replay path** — confirmed. `scrollToBottom` is only called via user actions (Cmd+End) or scheduled layout, never during replay frames. +- **Not a DOM scroll issue** — xterm viewports show `scrollHeight === clientHeight` (456px). xterm uses canvas-based virtual scrolling internally. +- **Not the layout scheduler** — `requestTerminalLayout({ scrollToBottom: true })` is never invoked during replay. + +## Potential Fix Approaches + +### A. Hide terminal canvas during replay (simplest) +Add CSS `visibility: hidden` or `opacity: 0` to the xterm container while `isAttaching` is true. After replay completes (when `pendingReplay` clears at line 1686-1690), remove the hiding. This is cheap and doesn't change the data flow. + +**Trade-off:** Terminal appears to "pop in" rather than gradually fill. User sees a blank area during the 1-2 second replay period. + +### B. Batch all replay data into a single write +Accumulate all frames while `pendingReplay` is true in `seqState`, then write them all in one `term.write()` call when replay completes. xterm would only render the final state. + +**Trade-off:** Requires changes to the frame handler and seq state tracking. More complex. May cause a noticeable pause on very large buffers since xterm processes the whole batch in one go. + +### C. Suppress xterm viewport following during replay +Use xterm's internal API or a wrapper to freeze the viewport position during replay writes. After replay completes, jump to bottom. + +**Trade-off:** Relies on xterm internals (`_core._bufferService.buffer.ydisp`). Fragile across xterm versions. + +### D. Write all replay data outside the write queue +Bypass the rAF-based write queue during replay. Write all replay data synchronously to xterm in a single call stack, which would complete before the browser gets a chance to render. + +**Trade-off:** May cause a brief UI freeze on very large buffers. Simpler than B since it doesn't change the frame accumulation logic. + +## Related Prior Work + +- `docs/plans/2026-02-21-console-violations-four-issue-fix.md` line 387 mentions "snapshot replay scroll work" should be coalesced — this appears to be a known/planned item. +- `docs/plans/2026-02-21-terminal-stream-v2-responsiveness.md` — foundational v2 protocol with bounded streaming. +- `docs/plans/2026-02-23-attach-generation-reconnect-hydration.md` — attach request ID tagging. + +## Recommendation + +**Approach A (CSS hiding)** is the quickest and least risky fix. The `isAttaching` state already exists and tracks exactly the right lifecycle. Adding `visibility: hidden` to the xterm container during that state would eliminate the visual replay entirely with minimal code change. + +For a more polished UX, **Approach B (batch writes)** would be better long-term since it avoids the "pop in" effect and reduces unnecessary intermediate rendering work. diff --git a/docs/plans/2026-03-30-paginated-terminal-replay.md b/docs/plans/2026-03-30-paginated-terminal-replay.md new file mode 100644 index 00000000..48d642e2 --- /dev/null +++ b/docs/plans/2026-03-30-paginated-terminal-replay.md @@ -0,0 +1,74 @@ +# Paginated Terminal Replay & Progressive Background Hydration + +## Goal + +Eliminate the visible fast-scroll replay when switching to a terminal tab that hasn't been visited since page load. Tabs should appear instantly with recent content, and older history should be available on demand. + +## Context + +On page load, only the active tab attaches to its server-side terminal. All other tabs defer with `viewport_hydrate` intent (`TerminalView.tsx:1992-1996`). When the user first visits a deferred tab, the client sends `terminal.attach` with `sinceSeq: 0`, and the server sends the entire replay ring (up to 8 MB for Claude Code sessions). The client's write queue processes this across many animation frames, causing a visible 1-2 second fast-scroll replay through the xterm canvas. + +### Key architectural facts + +- Single WebSocket per browser tab; all Freshell panes share it +- Replay ring: 256 KB default, 8 MB for coding CLI terminals +- Replay ring is in-memory only (no disk persistence) +- `ReplayRing.replaySince(seq)` already supports partial replay from any sequence +- `terminal.output.gap` already signals when replay data was skipped +- `terminal.attach.ready` already reports `replayFromSeq`/`replayToSeq` bounds +- xterm.js auto-scrolls to cursor on every `write()` — this is the source of the visual jank +- xterm is append-only — you can't prepend to scrollback +- WS catastrophic backpressure kills the connection at 16 MB sustained for 10s + +### Related investigation + +See `docs/lab-notes/2026-03-30-terminal-replay-scroll-investigation.md` for the full root cause analysis. + +## Design + +### 1. Server-side truncated replay + +Add an optional `maxReplayBytes` field to the `terminal.attach` Zod schema. When present, `broker.attach()` takes only the **tail** frames from the replay ring that fit within the byte budget. Frames before the budget cutoff are reported as a gap via the existing `terminal.output.gap` message (reason: `replay_window_exceeded`). + +No new message types. The gap mechanism already handles this — the client just needs to recognize that an attach-time gap means "there's more history above." + +### 2. Client-side "Load more history" affordance + +When TerminalView receives a `terminal.output.gap` during an attach with `maxReplayBytes`, it stores the gap range. The UI shows a clickable element at the top of the terminal viewport (above the xterm canvas or as a banner). Clicking it triggers a full `viewport_hydrate` with no `maxReplayBytes` — the existing full-replay behavior, which the user has opted into. + +### 3. Progressive background hydration + +After the active tab's initial attach completes, a coordinator progressively hydrates background tabs: + +- One tab at a time, to avoid WS backpressure +- **Neighbor-first ordering**: tabs adjacent to the active tab hydrate first, expanding outward +- Background tabs do full hydration (no `maxReplayBytes`) since they're CSS-hidden and the replay is invisible +- Each background tab attaches, receives full replay, writes to its xterm canvas (invisible), and becomes ready +- When the user switches to an already-hydrated tab, it appears instantly with full history +- If the user switches to a tab the queue hasn't reached yet, that tab does a truncated attach (with `maxReplayBytes`) for instant display, and gets the "load more" option + +### 4. Queue interruption on tab switch + +When the user switches to an un-hydrated tab, the progressive queue pauses, the newly active tab gets priority (truncated attach), and the queue resumes after. Already-in-progress background hydrations complete normally — they don't need to be interrupted since they're just writing to a hidden canvas. + +## Verification Criteria + +1. **No visible scroll replay on tab switch**: switching to any tab should show content instantly — either from progressive hydration (full history) or truncated attach (recent history) +2. **"Load more history" appears and works**: when a tab was loaded with truncated history, scrolling to the top shows the affordance. Clicking it loads the full history (visible replay is acceptable here — user opted in) +3. **Progressive hydration completes without backpressure issues**: background tabs hydrate one at a time without triggering WS catastrophic backpressure (16 MB threshold) +4. **Active tab is unaffected**: the currently active tab's terminal responsiveness is not degraded by background hydration +5. **Page refresh still works**: full page refresh hydrates active tab immediately, queues the rest progressively +6. **Non-coding-CLI terminals work correctly**: regular shell tabs (256 KB replay rings) also benefit from progressive hydration and truncated attach +7. **All existing terminal attach/detach/reconnect tests pass** + +## Files involved + +| Area | Files | +|------|-------| +| Protocol schema | `shared/ws-protocol.ts` | +| Server attach handler | `server/ws-handler.ts` | +| Replay ring truncation | `server/terminal-stream/broker.ts`, `server/terminal-stream/replay-ring.ts` | +| Client attach flow | `src/components/TerminalView.tsx` | +| Client seq state | `src/lib/terminal-attach-seq-state.ts` | +| Progressive hydration coordinator | New: likely `src/lib/hydration-queue.ts` or similar | +| "Load more" UI | Within `src/components/TerminalView.tsx` | diff --git a/server/perf-logger.ts b/server/perf-logger.ts index 23dfd413..aad77214 100644 --- a/server/perf-logger.ts +++ b/server/perf-logger.ts @@ -106,6 +106,7 @@ type PerfSeverity = 'debug' | 'info' | 'warn' | 'error' export type TerminalStreamPerfEvent = | 'terminal_stream_replay_hit' | 'terminal_stream_replay_miss' + | 'terminal_stream_replay_truncated' | 'terminal_stream_gap' | 'terminal_stream_queue_pressure' | 'terminal_stream_catastrophic_close' diff --git a/server/terminal-stream/broker.ts b/server/terminal-stream/broker.ts index 88d1728a..f20fcbc8 100644 --- a/server/terminal-stream/broker.ts +++ b/server/terminal-stream/broker.ts @@ -81,6 +81,7 @@ export class TerminalStreamBroker { rows: number, sinceSeq: number | undefined, attachRequestId?: string, + maxReplayBytes?: number, ): Promise<'attached' | 'duplicate' | 'missing'> { const normalizedSinceSeq = sinceSeq === undefined || sinceSeq === 0 ? 0 : sinceSeq let result: 'attached' | 'duplicate' | 'missing' = 'attached' @@ -127,12 +128,43 @@ export class TerminalStreamBroker { } const replay = terminalState.replayRing.replaySince(normalizedSinceSeq) - const replayFrames = replay.frames + let replayFrames = replay.frames + let effectiveMissedFromSeq = replay.missedFromSeq + let budgetTruncated = false const headSeq = terminalState.replayRing.headSeq() + + // Truncate replay to tail frames within byte budget + if (maxReplayBytes !== undefined && maxReplayBytes > 0 && replayFrames.length > 0) { + let budgetRemaining = maxReplayBytes + let keepFromIndex = replayFrames.length + for (let i = replayFrames.length - 1; i >= 0; i--) { + if (replayFrames[i].bytes > budgetRemaining) break + budgetRemaining -= replayFrames[i].bytes + keepFromIndex = i + } + if (keepFromIndex > 0) { + const truncatedFromSeq = replayFrames[0].seqStart + const truncatedToSeq = replayFrames[keepFromIndex - 1].seqEnd + effectiveMissedFromSeq = effectiveMissedFromSeq ?? truncatedFromSeq + budgetTruncated = true + replayFrames = replayFrames.slice(keepFromIndex) + + this.perfEventLogger('terminal_stream_replay_truncated', { + terminalId, + connectionId: ws.connectionId, + maxReplayBytes, + droppedFrames: keepFromIndex, + droppedFromSeq: truncatedFromSeq, + droppedToSeq: truncatedToSeq, + keptFrames: replayFrames.length, + }) + } + } + const replayFromSeq = replayFrames.length > 0 ? replayFrames[0].seqStart : headSeq + 1 const replayToSeq = replayFrames.length > 0 ? replayFrames[replayFrames.length - 1].seqEnd : headSeq - if (replayFrames.length > 0 && replay.missedFromSeq === undefined) { + if (replayFrames.length > 0 && effectiveMissedFromSeq === undefined) { this.perfEventLogger('terminal_stream_replay_hit', { terminalId, connectionId: ws.connectionId, @@ -154,14 +186,15 @@ export class TerminalStreamBroker { return } - if (replay.missedFromSeq !== undefined) { + if (effectiveMissedFromSeq !== undefined) { const missedToSeq = replayFromSeq - 1 - if (missedToSeq >= replay.missedFromSeq) { + const gapReason = budgetTruncated ? 'replay_budget_exceeded' as const : 'replay_window_exceeded' as const + if (missedToSeq >= effectiveMissedFromSeq) { this.perfEventLogger('terminal_stream_replay_miss', { terminalId, connectionId: ws.connectionId, sinceSeq: normalizedSinceSeq, - missedFromSeq: replay.missedFromSeq, + missedFromSeq: effectiveMissedFromSeq, missedToSeq, replayFromSeq, replayToSeq, @@ -170,17 +203,17 @@ export class TerminalStreamBroker { this.perfEventLogger('terminal_stream_gap', { terminalId, connectionId: ws.connectionId, - fromSeq: replay.missedFromSeq, + fromSeq: effectiveMissedFromSeq, toSeq: missedToSeq, - reason: 'replay_window_exceeded', + reason: gapReason, }, 'warn') if (!this.safeSend(ws, { type: 'terminal.output.gap', terminalId, - fromSeq: replay.missedFromSeq, + fromSeq: effectiveMissedFromSeq, toSeq: missedToSeq, - reason: 'replay_window_exceeded', + reason: gapReason, ...(attachment.activeAttachRequestId ? { attachRequestId: attachment.activeAttachRequestId } : {}), })) { return diff --git a/server/ws-handler.ts b/server/ws-handler.ts index 7531c096..ffd891fc 100644 --- a/server/ws-handler.ts +++ b/server/ws-handler.ts @@ -1428,6 +1428,7 @@ export class WsHandler { m.rows, m.sinceSeq, m.attachRequestId, + m.maxReplayBytes, ) if (attachResult === 'missing') { this.sendError(ws, { code: 'INVALID_TERMINAL_ID', message: 'Unknown terminalId', terminalId: m.terminalId }) diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index 69078dd3..3bb59b08 100644 --- a/shared/ws-protocol.ts +++ b/shared/ws-protocol.ts @@ -196,6 +196,7 @@ export const TerminalAttachSchema = z.object({ type: z.literal('terminal.attach'), terminalId: z.string().min(1), sinceSeq: z.number().int().nonnegative().optional(), + maxReplayBytes: z.number().int().positive().optional(), attachRequestId: z.string().min(1).optional(), cols: z.number().int().min(2).max(1000), rows: z.number().int().min(2).max(500), @@ -463,7 +464,7 @@ export type TerminalOutputGapMessage = { terminalId: string fromSeq: number toSeq: number - reason: 'queue_overflow' | 'replay_window_exceeded' + reason: 'queue_overflow' | 'replay_window_exceeded' | 'replay_budget_exceeded' attachRequestId?: string } diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index 914bfd04..fe2b0956 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -8,6 +8,7 @@ import { type PointerEvent as ReactPointerEvent, type TouchEvent as ReactTouchEvent, } from 'react' +import { shallowEqual } from 'react-redux' import { useAppDispatch, useAppSelector } from '@/store/hooks' import { updateTab, switchToNextTab, switchToPrevTab } from '@/store/tabsSlice' import { consumePaneRefreshRequest, splitPane, updatePaneContent, updatePaneTitle } from '@/store/panesSlice' @@ -70,6 +71,7 @@ import { Loader2 } from 'lucide-react' import { ConfirmModal } from '@/components/ui/confirm-modal' import type { PaneContent, PaneContentInput, PaneRefreshRequest, TerminalPaneContent } from '@/store/paneTypes' import '@xterm/xterm/css/xterm.css' +import { getHydrationQueue } from '@/lib/hydration-queue' import { createLogger } from '@/lib/client-logger' const log = createLogger('TerminalView') @@ -92,6 +94,7 @@ const TOUCH_SCROLL_PIXELS_PER_LINE = 18 const LIGHT_THEME_MIN_CONTRAST_RATIO = 4.5 const DEFAULT_MIN_CONTRAST_RATIO = 1 const MAX_LAST_SENT_VIEWPORT_CACHE_ENTRIES = 200 +const TRUNCATED_REPLAY_BYTES = 128 * 1024 function resolveMinimumContrastRatio(theme?: { isDark?: boolean } | null): number { return theme?.isDark === false ? LIGHT_THEME_MIN_CONTRAST_RATIO : DEFAULT_MIN_CONTRAST_RATIO @@ -210,6 +213,7 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter const connectionStatus = useAppSelector((s) => s.connection.status) const tab = useAppSelector((s) => s.tabs.tabs.find((t) => t.id === tabId)) const activeTabId = useAppSelector((s) => s.tabs.activeTabId) + const tabOrder = useAppSelector((s) => s.tabs.tabs.map((t) => t.id), shallowEqual) const activePaneId = useAppSelector((s) => s.panes.activePane[tabId]) const refreshRequest = useAppSelector((s) => s.panes.refreshRequestsByPane?.[tabId]?.[paneId] ?? null) const localServerInstanceId = useAppSelector((s) => s.connection.serverInstanceId) @@ -227,6 +231,8 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter const suppressNetworkEffects = typeof window !== 'undefined' && window.__FRESHELL_TEST_HARNESS__?.isTerminalNetworkEffectsSuppressed?.(paneId) === true const [isAttaching, setIsAttaching] = useState(false) + const [truncatedHistoryGap, setTruncatedHistoryGap] = useState<{ fromSeq: number; toSeq: number } | null>(null) + const [backgroundHydrationTriggered, setBackgroundHydrationTriggered] = useState(false) const wasCreatedFreshRef = useRef(paneContent.kind === 'terminal' && paneContent.status === 'creating') const [pendingLinkUri, setPendingLinkUri] = useState(null) const [pendingOsc52Event, setPendingOsc52Event] = useState(null) @@ -251,6 +257,7 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter }) const mountedRef = useRef(false) const hiddenRef = useRef(hidden) + const hydrationRegisteredRef = useRef(false) const lastSessionActivityAtRef = useRef(0) const rateLimitRetryRef = useRef<{ count: number; timer: ReturnType | null }>({ count: 0, timer: null }) const restoreRequestIdRef = useRef(null) @@ -1362,6 +1369,8 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter return () => disposable.dispose() }, [isTerminal, dispatch, tabId]) + const tabOrderRef = useRef(tabOrder) + tabOrderRef.current = tabOrder const markAttachComplete = useCallback(() => { wasCreatedFreshRef.current = false deferredAttachStateRef.current = { @@ -1369,7 +1378,13 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter pendingIntent: null, pendingSinceSeq: 0, } - }, []) + const queue = getHydrationQueue() + if (hiddenRef.current) { + queue.onHydrationComplete(paneId) + } else { + queue.onActiveTabReady(tabId, tabOrderRef.current) + } + }, [paneId, tabId]) const isCurrentAttachMessage = useCallback((msg: { type: string @@ -1395,7 +1410,12 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter const attachTerminal = useCallback(( tid: string, intent: AttachIntent, - opts?: { clearViewportFirst?: boolean; suppressNextMatchingResize?: boolean; skipPreAttachFit?: boolean }, + opts?: { + clearViewportFirst?: boolean + suppressNextMatchingResize?: boolean + skipPreAttachFit?: boolean + maxReplayBytes?: number + }, ) => { if (suppressNetworkEffects) return const term = termRef.current @@ -1411,6 +1431,7 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter const cols = Math.max(2, term.cols || 80) const rows = Math.max(2, term.rows || 24) setIsAttaching(true) + setTruncatedHistoryGap(null) const persistedSeq = loadTerminalCursor(tid) const deltaSeq = Math.max(seqStateRef.current.lastSeq, persistedSeq) @@ -1456,6 +1477,7 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter rows, sinceSeq, attachRequestId, + ...(opts?.maxReplayBytes ? { maxReplayBytes: opts.maxReplayBytes } : {}), }) rememberSentViewport(tid, cols, rows) lastSentViewportRef.current = { terminalId: tid, cols, rows } @@ -1483,7 +1505,7 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter } setIsAttaching(false) } else { - attachTerminal(tid, 'viewport_hydrate', { clearViewportFirst: true }) + attachTerminal(tid, 'viewport_hydrate', { clearViewportFirst: true, maxReplayBytes: TRUNCATED_REPLAY_BYTES }) } dispatch(consumePaneRefreshRequest({ tabId, paneId, requestId: request.requestId })) @@ -1521,10 +1543,17 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter const tid = terminalIdRef.current const deferred = deferredAttachStateRef.current if (tid && deferred.mode === 'waiting_for_geometry' && deferred.pendingIntent) { + // Unregister from background queue — this tab is now being directly hydrated + if (hydrationRegisteredRef.current) { + getHydrationQueue().unregister(paneId) + hydrationRegisteredRef.current = false + } + getHydrationQueue().onActiveTabChanged(tabId, tabOrderRef.current) attachTerminal(tid, deferred.pendingIntent, { clearViewportFirst: deferred.pendingIntent === 'viewport_hydrate', suppressNextMatchingResize: true, skipPreAttachFit: true, + ...(deferred.pendingIntent === 'viewport_hydrate' ? { maxReplayBytes: TRUNCATED_REPLAY_BYTES } : {}), }) return } @@ -1532,6 +1561,15 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter } }, [hidden, isTerminal, requestTerminalLayout, attachTerminal]) + // Background hydration: triggered by the hydration queue for hidden tabs + useEffect(() => { + if (!backgroundHydrationTriggered) return + setBackgroundHydrationTriggered(false) + const tid = terminalIdRef.current + if (!tid || !hiddenRef.current) return + attachTerminal(tid, 'viewport_hydrate', { clearViewportFirst: true }) + }, [backgroundHydrationTriggered, attachTerminal]) + // Create or attach to backend terminal useEffect(() => { if (suppressNetworkEffects) return @@ -1705,13 +1743,21 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter return } - const reason = msg.reason === 'replay_window_exceeded' - ? 'reconnect window exceeded' - : 'slow link backlog' - try { - term.writeln(`\r\n[Output gap ${msg.fromSeq}-${msg.toSeq}: ${reason}]\r\n`) - } catch { - // disposed + // Only show "load more" when the server confirms the gap is from + // byte-budget truncation (recoverable), not ring overflow (data gone). + const isTruncatedReplay = msg.reason === 'replay_budget_exceeded' + && seqStateRef.current.pendingReplay + if (isTruncatedReplay) { + setTruncatedHistoryGap({ fromSeq: msg.fromSeq, toSeq: msg.toSeq }) + } else { + const reason = msg.reason === 'replay_window_exceeded' + ? 'reconnect window exceeded' + : 'slow link backlog' + try { + term.writeln(`\r\n[Output gap ${msg.fromSeq}-${msg.toSeq}: ${reason}]\r\n`) + } catch { + // disposed + } } const previousSeqState = seqStateRef.current const nextSeqState = onOutputGap(previousSeqState, { fromSeq: msg.fromSeq, toSeq: msg.toSeq }) @@ -1995,11 +2041,22 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter ? { mode: 'waiting_for_geometry', pendingIntent: 'transport_reconnect', pendingSinceSeq: seqStateRef.current.lastSeq } : { mode: 'waiting_for_geometry', pendingIntent: 'viewport_hydrate', pendingSinceSeq: 0 } setIsAttaching(false) + + // Register with hydration queue for progressive background hydration + if (!hydrationRegisteredRef.current && deferredAttachStateRef.current.pendingIntent === 'viewport_hydrate') { + hydrationRegisteredRef.current = true + const setBgTriggered = setBackgroundHydrationTriggered + getHydrationQueue().register({ + tabId, + paneId: paneIdRef.current, + trigger: () => setBgTriggered(true), + }) + } } else { const intent: AttachIntent = deferredAttachStateRef.current.mode === 'live' ? 'keepalive_delta' : 'viewport_hydrate' - attachTerminal(currentTerminalId, intent) + attachTerminal(currentTerminalId, intent, intent === 'viewport_hydrate' ? { maxReplayBytes: TRUNCATED_REPLAY_BYTES } : undefined) } } else { deferredAttachStateRef.current = { @@ -2017,6 +2074,10 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter clearRateLimitRetry() unsub() unsubReconnect() + if (hydrationRegisteredRef.current) { + getHydrationQueue().unregister(paneIdRef.current) + hydrationRegisteredRef.current = false + } } // Dependencies explanation: // - isTerminal: skip effect for non-terminal panes @@ -2069,6 +2130,13 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter } }, [isMobile, mobileBottomInsetPx]) + const handleLoadMoreHistory = useCallback(() => { + const tid = terminalIdRef.current + if (!tid) return + setTruncatedHistoryGap(null) + attachTerminal(tid, 'viewport_hydrate', { clearViewportFirst: true }) + }, [attachTerminal]) + // NOW we can do the conditional return - after all hooks if (!isTerminal || !terminalContent) { return null @@ -2150,6 +2218,18 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter )} + {truncatedHistoryGap && ( +
+ +
+ )} {searchOpen && ( void +} + +type HydrationQueue = { + /** Register a tab that needs background hydration. */ + register: (entry: HydrationEntry) => void + /** Unregister a tab (e.g., on unmount). */ + unregister: (paneId: string) => void + /** Signal that the active tab's initial hydration is complete. Starts the queue. */ + onActiveTabReady: (activeTabId: string, tabOrder: string[]) => void + /** Signal that a background tab's hydration completed. Advances the queue. */ + onHydrationComplete: (paneId: string) => void + /** Notify the queue that the active tab changed. Reprioritizes if needed. */ + onActiveTabChanged: (activeTabId: string, tabOrder: string[]) => void + /** Destroy the queue. */ + dispose: () => void +} + +function neighborFirstOrder(activeTabId: string, tabOrder: string[], pendingTabIds: Set): string[] { + const activeIndex = tabOrder.indexOf(activeTabId) + if (activeIndex === -1) return [...pendingTabIds] + + const result: string[] = [] + const maxDistance = Math.max(activeIndex, tabOrder.length - 1 - activeIndex) + + for (let d = 1; d <= maxDistance; d++) { + const leftIdx = activeIndex - d + const rightIdx = activeIndex + d + if (leftIdx >= 0 && pendingTabIds.has(tabOrder[leftIdx])) { + result.push(tabOrder[leftIdx]) + } + if (rightIdx < tabOrder.length && pendingTabIds.has(tabOrder[rightIdx])) { + result.push(tabOrder[rightIdx]) + } + } + return result +} + +export function createHydrationQueue(): HydrationQueue { + const entries = new Map() + let queue: string[] = [] + let activePane: string | null = null + let started = false + let disposed = false + + function advance() { + if (disposed || activePane) return + while (queue.length > 0) { + const nextPaneId = queue.shift()! + const entry = entries.get(nextPaneId) + if (entry) { + activePane = nextPaneId + entry.trigger() + return + } + } + } + + return { + register(entry) { + if (disposed) return + entries.set(entry.paneId, entry) + }, + + unregister(paneId) { + entries.delete(paneId) + queue = queue.filter((id) => id !== paneId) + if (activePane === paneId) { + activePane = null + advance() + } + }, + + onActiveTabReady(activeTabId, tabOrder) { + if (disposed || started) return + started = true + + const pendingTabIds = new Set() + for (const entry of entries.values()) { + pendingTabIds.add(entry.tabId) + } + pendingTabIds.delete(activeTabId) + + const orderedTabIds = neighborFirstOrder(activeTabId, tabOrder, pendingTabIds) + queue = [] + for (const tabId of orderedTabIds) { + for (const entry of entries.values()) { + if (entry.tabId === tabId) { + queue.push(entry.paneId) + } + } + } + + advance() + }, + + onHydrationComplete(paneId) { + if (disposed) return + entries.delete(paneId) + if (activePane === paneId) { + activePane = null + advance() + } + }, + + onActiveTabChanged(activeTabId, tabOrder) { + if (disposed) return + const pendingTabIds = new Set() + for (const paneId of queue) { + const entry = entries.get(paneId) + if (entry) pendingTabIds.add(entry.tabId) + } + pendingTabIds.delete(activeTabId) + + const orderedTabIds = neighborFirstOrder(activeTabId, tabOrder, pendingTabIds) + const newQueue: string[] = [] + for (const tabId of orderedTabIds) { + for (const entry of entries.values()) { + if (entry.tabId === tabId && queue.includes(entry.paneId)) { + newQueue.push(entry.paneId) + } + } + } + queue = newQueue + }, + + dispose() { + disposed = true + entries.clear() + queue = [] + activePane = null + }, + } +} + +let globalQueue: HydrationQueue | null = null + +export function getHydrationQueue(): HydrationQueue { + if (!globalQueue) { + globalQueue = createHydrationQueue() + } + return globalQueue +} + +export function resetHydrationQueueForTests(): void { + globalQueue?.dispose() + globalQueue = null +}