Skip to content
Merged
87 changes: 87 additions & 0 deletions docs/lab-notes/2026-03-30-terminal-replay-scroll-investigation.md
Original file line number Diff line number Diff line change
@@ -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.
74 changes: 74 additions & 0 deletions docs/plans/2026-03-30-paginated-terminal-replay.md
Original file line number Diff line number Diff line change
@@ -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` |
1 change: 1 addition & 0 deletions server/perf-logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
44 changes: 37 additions & 7 deletions server/terminal-stream/broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -170,15 +200,15 @@ export class TerminalStreamBroker {
this.perfEventLogger('terminal_stream_gap', {
terminalId,
connectionId: ws.connectionId,
fromSeq: replay.missedFromSeq,
fromSeq: effectiveMissedFromSeq,
toSeq: missedToSeq,
reason: 'replay_window_exceeded',
}, 'warn')

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 } : {}),
Expand Down
1 change: 1 addition & 0 deletions server/ws-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
1 change: 1 addition & 0 deletions shared/ws-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading