From 6bc91b72fd4ccb96d08e047029fcdc3ad02c0873 Mon Sep 17 00:00:00 2001 From: Matt Leaverton Date: Mon, 30 Mar 2026 17:08:27 -0500 Subject: [PATCH 1/7] docs: investigation and design for paginated terminal replay Root cause analysis of the visible fast-scroll replay on tab switch, plus design for server-side truncated replay, progressive background hydration, and "load more history" UI affordance. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...30-terminal-replay-scroll-investigation.md | 87 +++++++++++++++++++ .../2026-03-30-paginated-terminal-replay.md | 74 ++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 docs/lab-notes/2026-03-30-terminal-replay-scroll-investigation.md create mode 100644 docs/plans/2026-03-30-paginated-terminal-replay.md 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` | From 55c743de4f84f1e2b64b257197ca4d871360e628 Mon Sep 17 00:00:00 2001 From: Matt Leaverton Date: Mon, 30 Mar 2026 17:15:06 -0500 Subject: [PATCH 2/7] feat: add maxReplayBytes to terminal.attach for truncated replay Server-side support for byte-budgeted replay. When maxReplayBytes is set on a terminal.attach message, the broker takes only the tail frames from the replay ring that fit within the budget. Truncated frames are reported via the existing terminal.output.gap mechanism. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/perf-logger.ts | 1 + server/terminal-stream/broker.ts | 44 +++++++++++++++++++++++++++----- server/ws-handler.ts | 1 + shared/ws-protocol.ts | 1 + 4 files changed, 40 insertions(+), 7 deletions(-) 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..cc80ff53 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,41 @@ export class TerminalStreamBroker { } const replay = terminalState.replayRing.replaySince(normalizedSinceSeq) - const replayFrames = replay.frames + let replayFrames = replay.frames + let effectiveMissedFromSeq = replay.missedFromSeq 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 + 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 +184,14 @@ export class TerminalStreamBroker { return } - if (replay.missedFromSeq !== undefined) { + if (effectiveMissedFromSeq !== undefined) { const missedToSeq = replayFromSeq - 1 - if (missedToSeq >= replay.missedFromSeq) { + if (missedToSeq >= effectiveMissedFromSeq) { this.perfEventLogger('terminal_stream_replay_miss', { terminalId, connectionId: ws.connectionId, sinceSeq: normalizedSinceSeq, - missedFromSeq: replay.missedFromSeq, + missedFromSeq: effectiveMissedFromSeq, missedToSeq, replayFromSeq, replayToSeq, @@ -170,7 +200,7 @@ export class TerminalStreamBroker { this.perfEventLogger('terminal_stream_gap', { terminalId, connectionId: ws.connectionId, - fromSeq: replay.missedFromSeq, + fromSeq: effectiveMissedFromSeq, toSeq: missedToSeq, reason: 'replay_window_exceeded', }, 'warn') @@ -178,7 +208,7 @@ export class TerminalStreamBroker { if (!this.safeSend(ws, { type: 'terminal.output.gap', terminalId, - fromSeq: replay.missedFromSeq, + fromSeq: effectiveMissedFromSeq, toSeq: missedToSeq, reason: 'replay_window_exceeded', ...(attachment.activeAttachRequestId ? { attachRequestId: attachment.activeAttachRequestId } : {}), 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..b7fb17d8 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), From 3381fab3cc90109fa78e5bf197ec67cdd8bf4b60 Mon Sep 17 00:00:00 2001 From: Matt Leaverton Date: Mon, 30 Mar 2026 17:17:50 -0500 Subject: [PATCH 3/7] feat: send maxReplayBytes on visible viewport_hydrate Visible tabs now request truncated replay (128KB tail) instead of the full replay ring. Gap from truncation is stored for the "load more history" UI rather than printed as a generic gap message. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/TerminalView.tsx | 40 ++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index 914bfd04..1130f690 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -92,6 +92,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 @@ -227,6 +228,7 @@ 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 wasCreatedFreshRef = useRef(paneContent.kind === 'terminal' && paneContent.status === 'creating') const [pendingLinkUri, setPendingLinkUri] = useState(null) const [pendingOsc52Event, setPendingOsc52Event] = useState(null) @@ -1395,7 +1397,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 +1418,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 +1464,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 +1492,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 })) @@ -1525,6 +1534,7 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter clearViewportFirst: deferred.pendingIntent === 'viewport_hydrate', suppressNextMatchingResize: true, skipPreAttachFit: true, + ...(deferred.pendingIntent === 'viewport_hydrate' ? { maxReplayBytes: TRUNCATED_REPLAY_BYTES } : {}), }) return } @@ -1705,13 +1715,23 @@ 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 + // If this gap is from a truncated replay (maxReplayBytes), store it + // for the "load more history" UI instead of printing a gap message. + const currentAttach = currentAttachRef.current + const isTruncatedReplay = currentAttach + && msg.reason === 'replay_window_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 }) @@ -1999,7 +2019,7 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter 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 = { From 9fc1366806c3a8ee37a0f13da3a28472dc921fca Mon Sep 17 00:00:00 2001 From: Matt Leaverton Date: Mon, 30 Mar 2026 17:18:31 -0500 Subject: [PATCH 4/7] feat: add "Load more history" button for truncated replay Shows a button at the top of the terminal when history was truncated due to maxReplayBytes. Clicking it triggers a full viewport_hydrate to load the complete history. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/TerminalView.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index 1130f690..91b4bd14 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -2089,6 +2089,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 @@ -2170,6 +2177,18 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter )} + {truncatedHistoryGap && ( +
+ +
+ )} {searchOpen && ( Date: Mon, 30 Mar 2026 17:21:43 -0500 Subject: [PATCH 5/7] feat: progressive background hydration of terminal tabs After the active tab completes hydration, background tabs are progressively hydrated one at a time in neighbor-first order. Hidden tabs register with a global hydration queue that triggers full replay in the background (invisible since CSS-hidden). When the user switches to an un-hydrated tab, it gets a truncated fast attach (128KB tail). Already-hydrated tabs appear instantly with full history. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/TerminalView.tsx | 44 ++++++++- src/lib/hydration-queue.ts | 154 ++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 src/lib/hydration-queue.ts diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index 91b4bd14..0fc14456 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -70,6 +70,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') @@ -211,6 +212,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)) 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) @@ -229,6 +231,7 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter && 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) @@ -253,6 +256,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) @@ -1364,6 +1368,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 = { @@ -1371,7 +1377,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 @@ -1530,6 +1542,12 @@ 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, @@ -1542,6 +1560,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 @@ -2015,6 +2042,17 @@ 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' @@ -2037,6 +2075,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 diff --git a/src/lib/hydration-queue.ts b/src/lib/hydration-queue.ts new file mode 100644 index 00000000..7a1a4ecc --- /dev/null +++ b/src/lib/hydration-queue.ts @@ -0,0 +1,154 @@ +// Coordinates progressive background hydration of terminal tabs. +// After the active tab hydrates, queues remaining tabs neighbor-first. + +type HydrationEntry = { + tabId: string + paneId: string + trigger: () => 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 +} From 9d048d9336c7d4f103d0f2872a31faf3a6120b1a Mon Sep 17 00:00:00 2001 From: Matt Leaverton Date: Mon, 30 Mar 2026 17:51:29 -0500 Subject: [PATCH 6/7] fix: only show "Load more history" for byte-budget truncation The button was incorrectly shown for replay ring overflow gaps where the data no longer exists. Now it only appears when the attach used maxReplayBytes, meaning the data is still in the ring and a full re-attach will actually load more. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/TerminalView.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index 0fc14456..7c16e88d 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -312,6 +312,7 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter sinceSeq: number cols: number rows: number + usedMaxReplayBytes: boolean } | null>(null) const suppressNextMatchingResizeRef = useRef<{ terminalId: string @@ -1464,6 +1465,7 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter sinceSeq, cols, rows, + usedMaxReplayBytes: !!opts?.maxReplayBytes, } suppressNextMatchingResizeRef.current = opts?.suppressNextMatchingResize ? { terminalId: tid, cols, rows } @@ -1742,10 +1744,10 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter return } - // If this gap is from a truncated replay (maxReplayBytes), store it - // for the "load more history" UI instead of printing a gap message. + // Only show "load more" when the gap is from our byte-budget truncation + // (usedMaxReplayBytes), not from ring overflow where the data is gone. const currentAttach = currentAttachRef.current - const isTruncatedReplay = currentAttach + const isTruncatedReplay = currentAttach?.usedMaxReplayBytes && msg.reason === 'replay_window_exceeded' && seqStateRef.current.pendingReplay if (isTruncatedReplay) { From 4c4c36614258848e61db94939cea7ae45bf50070 Mon Sep 17 00:00:00 2001 From: Matt Leaverton Date: Tue, 31 Mar 2026 10:32:53 -0500 Subject: [PATCH 7/7] fix: server-side gap reason for byte-budget truncation + memoize tabOrder - Adds 'replay_budget_exceeded' gap reason distinct from 'replay_window_exceeded' so the client can reliably distinguish recoverable truncation from ring overflow - Client checks msg.reason === 'replay_budget_exceeded' instead of inferring - Adds shallowEqual to tabOrder selector to prevent unnecessary rerenders Co-Authored-By: Claude Opus 4.6 (1M context) --- server/terminal-stream/broker.ts | 7 +++++-- shared/ws-protocol.ts | 2 +- src/components/TerminalView.tsx | 13 +++++-------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/server/terminal-stream/broker.ts b/server/terminal-stream/broker.ts index cc80ff53..f20fcbc8 100644 --- a/server/terminal-stream/broker.ts +++ b/server/terminal-stream/broker.ts @@ -130,6 +130,7 @@ export class TerminalStreamBroker { const replay = terminalState.replayRing.replaySince(normalizedSinceSeq) let replayFrames = replay.frames let effectiveMissedFromSeq = replay.missedFromSeq + let budgetTruncated = false const headSeq = terminalState.replayRing.headSeq() // Truncate replay to tail frames within byte budget @@ -145,6 +146,7 @@ export class TerminalStreamBroker { 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', { @@ -186,6 +188,7 @@ export class TerminalStreamBroker { if (effectiveMissedFromSeq !== undefined) { const missedToSeq = replayFromSeq - 1 + const gapReason = budgetTruncated ? 'replay_budget_exceeded' as const : 'replay_window_exceeded' as const if (missedToSeq >= effectiveMissedFromSeq) { this.perfEventLogger('terminal_stream_replay_miss', { terminalId, @@ -202,7 +205,7 @@ export class TerminalStreamBroker { connectionId: ws.connectionId, fromSeq: effectiveMissedFromSeq, toSeq: missedToSeq, - reason: 'replay_window_exceeded', + reason: gapReason, }, 'warn') if (!this.safeSend(ws, { @@ -210,7 +213,7 @@ export class TerminalStreamBroker { terminalId, fromSeq: effectiveMissedFromSeq, toSeq: missedToSeq, - reason: 'replay_window_exceeded', + reason: gapReason, ...(attachment.activeAttachRequestId ? { attachRequestId: attachment.activeAttachRequestId } : {}), })) { return diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index b7fb17d8..3bb59b08 100644 --- a/shared/ws-protocol.ts +++ b/shared/ws-protocol.ts @@ -464,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 7c16e88d..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' @@ -212,7 +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)) + 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) @@ -312,7 +313,6 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter sinceSeq: number cols: number rows: number - usedMaxReplayBytes: boolean } | null>(null) const suppressNextMatchingResizeRef = useRef<{ terminalId: string @@ -1465,7 +1465,6 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter sinceSeq, cols, rows, - usedMaxReplayBytes: !!opts?.maxReplayBytes, } suppressNextMatchingResizeRef.current = opts?.suppressNextMatchingResize ? { terminalId: tid, cols, rows } @@ -1744,11 +1743,9 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter return } - // Only show "load more" when the gap is from our byte-budget truncation - // (usedMaxReplayBytes), not from ring overflow where the data is gone. - const currentAttach = currentAttachRef.current - const isTruncatedReplay = currentAttach?.usedMaxReplayBytes - && msg.reason === 'replay_window_exceeded' + // 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 })