From 7471ee41cd7ad02cf1c97cc95214b3c2807e3f6c Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 7 Sep 2026 16:00:27 -0700 Subject: [PATCH 1/9] docs(workspace): add agent terminal follow implementation plan --- .../plans/2026-09-07-agent-terminal-follow.md | 1006 +++++++++++++++++ 1 file changed, 1006 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-07-agent-terminal-follow.md diff --git a/docs/superpowers/plans/2026-09-07-agent-terminal-follow.md b/docs/superpowers/plans/2026-09-07-agent-terminal-follow.md new file mode 100644 index 00000000..03a7dd74 --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-agent-terminal-follow.md @@ -0,0 +1,1006 @@ +# Agent Terminal Follow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Jump to Latest and auto-follow (Tail / Tail All) work for agent sessions showing their raw terminal surface (`AgentTerminalLeaf` — OpenCode Terminal, hybrid fallback, Claude/Codex toggled to terminal view), with renderer integration tests. + +**Architecture:** The workspace already broadcasts follow intent through per-session runtime state (`runtime.scrollToLatestRequest`, `runtime.tailMode`) and app state (`tailAllMode`). Only the rendered `Feed` consumes it today. We add a co-located hook (`useAgentTerminalFollow`) that `AgentTerminalLeaf` uses to drive the xterm viewport, remove the `renderedViewPolicy` gate that hides the two commands on terminal surfaces, and prove behavior with renderer integration tests that drive PTY data through the real `sessionDataDispatcher`. + +**Tech Stack:** React 18 hooks, xterm.js (`@xterm/xterm`), Zustand app store, Vitest renderer project (`happy-dom` + `@testing-library/react`). + +**Worktree:** `.worktrees/agent-terminal-follow`, branch `feat/agent-terminal-follow` (this plan is the first commit on the branch). + +--- + +## Background an implementer needs + +- `AgentTerminalLeaf` (`src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx`) is the full-pane raw PTY view for agent sessions. Its xterm mount effect is **keyed on `[sessionId]` alone** and reads changing runtime state through `runtimeRef` — remounting xterm on every runtime change would lose scrollback and re-attach the PTY. New runtime-driven behavior must live in **separate effects** outside the mount effect and reach the terminal through `termRef` / refs. +- `TileLeaf`'s effective-tail mask (src/renderer/src/workspace/tile-tree/TileLeaf.tsx:206): `(runtime.tailMode || tailAllMode) && !workspaceHidden`. The terminal-surface analog of `!workspaceHidden` is `useAgentTerminalOwnerVisible()` (`src/renderer/src/workspace/terminal/AgentTerminalOwnership.tsx`) — it composes the Global-Editor-fullscreen and Reader/Spotlight/Settings retention shells. Folding visibility into the mask matters for the same reason it does in TileLeaf: a re-reveal must be a genuine false→true transition so follow re-engages. +- `Feed` semantics we are mirroring (src/renderer/src/features/feed/ui/Feed.tsx): + - Tail re-pins on any scroll while active and auto-scrolls on new entries. + - Tail is **non-destructive**: the pre-tail reading position survives and is restored on disengage (see the "WHY tailing deliberately does NOT persist" comment). +- PTY bytes reach the leaf through `subscribeToAgentPtyData` (src/renderer/src/workspace/terminal/sessionDataDispatcher.ts), which subscribes once to `window.api.onSessionAgentPtyData` and fans out by session id. Tests drive this channel by capturing the listener passed to a mocked `window.api.onSessionAgentPtyData` — that exercises the real dispatcher, real subscription, and real leaf write path. +- Both follow commands are currently **unreachable** on terminal surfaces, independently of the leaf gap: `toggle-tail` and `jump-latest-message` carry `renderedViewPolicy: { kind: 'requires-rendered-feed' }`, and `commandAllowedByRenderedViewPolicy` (src/renderer/src/workspace/agentDisplayMode.ts:150) returns false for any policy when `providerRuntime === 'terminal'` (OpenCode Terminal) and false for `requires-rendered-feed` whenever the effective surface is terminal. The `when` guard (`kind !== 'terminal'`) already excludes plain shell panes and stays. +- xterm APIs used (all real): `term.scrollToBottom()`, `term.onScroll(cb) → IDisposable`, `term.buffer.active.viewportY` (get/set), `term.buffer.active.length`, `term.rows`, `term.write(data, callback)` where `callback` fires after xterm has parsed the chunk. On alternate-screen TUIs there is no scrollback and all of this is a harmless no-op; it matters for normal-buffer output streams. +- Test harness pattern to copy: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.submit.renderer.test.tsx` (mocked `@xterm/xterm` class, `window.api` on `window`, `AgentTerminalOwnershipProvider` + `MountedAgentTerminalOwner`, deferred `attachAgentPty`, stubbed `requestAnimationFrame`/`ResizeObserver`). + +## File structure + +- Create: `src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts` — the follow hook (jump, tail engage/disengage/restore) + `isXtermViewportAtBottom` helper. One responsibility: translating follow intent into xterm viewport calls. +- Modify: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx` — compute `tailActive`, call the hook, wire `follow.attach` + write-callback scrolling into the existing mount effect, TAIL pill in the header. +- Create: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx` — integration tests (harness + one file, tests appended per task). +- Modify: `src/renderer/src/features/workspace/commands/paneCommands.ts` — drop `renderedViewPolicy` from the two commands, update copy and comments. +- Create: `src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts` — regression test that the two commands stay surface-agnostic and shell-excluded. + +--- + +### Task 1: Set up the worktree and verify a clean baseline + +**Files:** none (verification only) + +- [ ] **Step 1: Install dependencies (includes submodules for aliases + electron-rebuild for node-pty)** + +Run in `.worktrees/agent-terminal-follow`: +```bash +git submodule update --init --recursive && npm install +``` +Expected: exit 0. (`npm install` runs `electron-rebuild -f -w node-pty`; renderer tests do not load node-pty, so a rebuild warning is tolerable, a hard failure is not.) + +- [ ] **Step 2: Run the existing AgentTerminalLeaf renderer tests** + +```bash +npm run test:renderer -- AgentTerminalLeaf.submit +``` +Expected: PASS (3 tests). If this fails, stop and report — the plan's harness is modeled on this file. + +- [ ] **Step 3: Typecheck baseline** + +```bash +npm run typecheck +``` +Expected: exit 0. + +--- + +### Task 2: Follow hook — Jump to Latest + +**Files:** +- Create: `src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts` +- Create: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx` +- Modify: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx` + +- [ ] **Step 1: Write the failing tests (full harness + jump tests)** + +Create `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx`: + +```tsx +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { emptyRuntime } from '@renderer/session-runtime/state' +import type { SessionRuntime } from '@renderer/session-runtime/state' +import { + AgentTerminalOwnershipProvider, + AgentTerminalOwnerVisibilityProvider, + MountedAgentTerminalOwner, +} from '@renderer/workspace/terminal/AgentTerminalOwnership' +import type { Workspace } from '@renderer/workspace/workspaceStore' +import { AgentTerminalLeaf } from './AgentTerminalLeaf' + +// Integration harness for follow behavior (jump-to-latest + tail) on the raw +// agent terminal surface. Modeled on AgentTerminalLeaf.submit.renderer.test.tsx +// with three deltas: the mocked Terminal grows scroll surface area +// (scrollToBottom / onScroll / buffer viewport), the app-store mock is MUTABLE +// so tests can flip tailAllMode like the real store would, and the agent PTY +// channel listener is captured so tests push bytes through the REAL +// sessionDataDispatcher fanout instead of calling leaf internals. +type MockTerminal = { + rows: number + buffer: { active: { viewportY: number; length: number } } + scrollToBottom: ReturnType + onScrollListener: ((line: number) => void) | null +} + +const xtermHarness = vi.hoisted(() => ({ + cols: 120, + rows: 40, + instances: [] as MockTerminal[], + attachWebgl: vi.fn(), + fit: vi.fn(), +})) + +const appStore = vi.hoisted(() => ({ + settings: { + dictationEnabled: false, + dictationProvider: 'local', + dictationShortcut: 'off', + mouseModeEnabled: false, + }, + tailAllMode: false, +})) + +vi.mock('@renderer/workspace/terminal/xtermWebglRenderer', () => ({ + attachXtermWebglRenderer: xtermHarness.attachWebgl, +})) + +vi.mock('@xterm/xterm', () => ({ + Terminal: class { + cols = xtermHarness.cols + rows = xtermHarness.rows + options: Record = {} + container: HTMLElement | null = null + onDataListener: ((data: string) => void) | null = null + onScrollListener: ((line: number) => void) | null = null + buffer = { active: { viewportY: 0, length: 1 } } + // scrollToBottom intentionally does NOT emit onScroll: real xterm does, + // but our handler then sees an at-bottom viewport and no-ops, so the mock + // keeps call counts deterministic. Re-pin behavior is tested by invoking + // the registered onScroll listener directly. + scrollToBottom = vi.fn(() => { + this.buffer.active.viewportY = Math.max(0, this.buffer.active.length - this.rows) + }) + dispose = vi.fn() + inputDispose = vi.fn(() => { this.onDataListener = null }) + scrollDispose = vi.fn(() => { this.onScrollListener = null }) + constructor() { xtermHarness.instances.push(this as unknown as MockTerminal) } + loadAddon() {} + open(container: HTMLElement) { this.container = container } + onData(listener: (data: string) => void) { + this.onDataListener = listener + return { dispose: this.inputDispose } + } + onScroll(listener: (line: number) => void) { + this.onScrollListener = listener + return { dispose: this.scrollDispose } + } + write(_data: string, callback?: () => void) { callback?.() } + focus() {} + }, +})) + +vi.mock('@xterm/addon-fit', () => ({ + FitAddon: class { fit() { xtermHarness.fit() } }, +})) + +vi.mock('@renderer/app-state/hooks', () => ({ + useAppStore: (selector: (state: typeof appStore) => unknown) => selector(appStore), +})) + +vi.mock('@renderer/app-state/settings/theme', async importOriginal => ({ + ...(await importOriginal>()), + THEME_CHANGED_EVENT: 'agent-code:test-theme-change', + getActiveAppFontFamily: () => 'monospace', +})) + +vi.mock('@renderer/workspace/tile-tree/xtermTheme', () => ({ + readXtermTheme: () => ({}), + syncXtermTheme: () => {}, +})) + +vi.mock('@renderer/workspace/tile-tree/TileLeaf/useComposerDictation', () => ({ + useComposerDictation: () => {}, +})) + +type Deferred = { promise: Promise; resolve: (value: T) => void } +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise(done => { resolve = done }) + return { promise, resolve } +} + +type PtyEvent = { sessionId: string; data: string } + +describe('AgentTerminalLeaf follow (jump-to-latest + tail)', () => { + let attach: Deferred + let nextFrameId: number + let frames: Map + let ptyListener: ((event: PtyEvent) => void) | null = null + const api = { + attachAgentPty: vi.fn((_id: string) => attach.promise), + detachAgentPty: vi.fn().mockResolvedValue(undefined), + onSessionAgentPtyData: vi.fn((listener: (event: PtyEvent) => void) => { + ptyListener = listener + return () => { ptyListener = null } + }), + onSessionTerminalData: vi.fn(() => () => {}), + resize: vi.fn().mockResolvedValue(undefined), + sendInput: vi.fn().mockResolvedValue(undefined), + } + const workspace = { + acknowledgeSession: vi.fn(), + ensureSessionLive: vi.fn().mockResolvedValue(undefined), + showPaneToast: vi.fn(), + } as unknown as Workspace + + const runtimeWith = (patch: Partial): SessionRuntime => ({ + ...emptyRuntime(), + processStatus: 'started', + ...patch, + }) + + function leaf(runtime: SessionRuntime = runtimeWith({})) { + return ( + + + {}} + workspace={workspace} + runtime={runtime} + projectDir="/tmp/project" + provider="codex" + /> + + + ) + } + + function flushAnimationFrames() { + const pending = [...frames.values()] + frames.clear() + for (const callback of pending) callback(performance.now()) + } + + async function attachResolved(buffer = '') { + act(() => flushAnimationFrames()) + await act(async () => { + attach.resolve(buffer) + await attach.promise + }) + } + + function term(): MockTerminal { + return xtermHarness.instances[0] + } + + beforeEach(() => { + appStore.tailAllMode = false + attach = deferred() + nextFrameId = 0 + frames = new Map() + ptyListener = null + xtermHarness.fit.mockClear() + xtermHarness.instances.length = 0 + xtermHarness.attachWebgl.mockReset() + xtermHarness.attachWebgl.mockImplementation(() => ({ + ready: Promise.resolve(true), + dispose: vi.fn(), + })) + api.attachAgentPty.mockReset().mockImplementation(() => attach.promise) + api.detachAgentPty.mockClear() + + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = ++nextFrameId + frames.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { frames.delete(id) }) + vi.stubGlobal('ResizeObserver', class { + disconnect = vi.fn() + observe() {} + unobserve() {} + }) + Object.defineProperty(window, 'api', { configurable: true, value: api }) + }) + + afterEach(() => { + cleanup() + Reflect.deleteProperty(window, 'api') + vi.unstubAllGlobals() + }) + + it('ignores the pre-existing jump request baseline on mount', async () => { + render(leaf(runtimeWith({ scrollToLatestRequest: 3 }))) + await attachResolved() + expect(term().scrollToBottom).not.toHaveBeenCalled() + }) + + it('scrolls the xterm viewport once when a new jump-to-latest request arrives', async () => { + const view = render(leaf(runtimeWith({ scrollToLatestRequest: 3 }))) + await attachResolved() + act(() => { view.rerender(leaf(runtimeWith({ scrollToLatestRequest: 4 }))) }) + expect(term().scrollToBottom).toHaveBeenCalledTimes(1) + }) +}) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +npm run test:renderer -- AgentTerminalLeaf.follow +``` +Expected: FAIL — the rerendered request does nothing; first test may pass vacuously (it is the guard against over-triggering, the second is the red one). + +- [ ] **Step 3: Implement the hook (jump path only)** + +Create `src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts`: + +```ts +import { useEffect, useMemo, useRef } from 'react' +import type { RefObject } from 'react' +import type { Terminal } from '@xterm/xterm' + +// Follow behavior for raw agent terminal surfaces (AgentTerminalLeaf) — the +// xterm counterpart of what Feed does for the rendered surface: +// - Jump to Latest: the workspace bumps `runtime.scrollToLatestRequest` +// whenever the user asks to return to the bottom (palette command, prompt +// send). Feed scrolls its DOM scroller; a raw pane scrolls the xterm +// viewport instead. Nothing consumed this counter on the terminal surface +// before, so the command silently did nothing there. +// - Tail (auto-follow): mirrors Feed's semantics — pin to bottom while +// active, re-pin if the user scrolls away, and restore the pre-tail +// viewport line on disengage so following is non-destructive. Feed +// protects the saved position for the same reason (see Feed.tsx "WHY +// tailing deliberately does NOT persist"). +// +// WHY a hook instead of inline effects in AgentTerminalLeaf: the leaf's xterm +// mount effect is deliberately keyed on [sessionId] alone (remounting xterm on +// every runtime change would lose scrollback and re-attach the PTY), so +// runtime-driven behavior must live outside that effect and reach the terminal +// through refs. Collecting it here also gives the renderer tests one unit to +// target. The hook MUST be called before the leaf's mount effect — see the +// wiring comment in AgentTerminalLeaf. + +/** Viewport is at bottom when its top line plus rows covers the buffer. */ +export function isXtermViewportAtBottom(term: Terminal): boolean { + const buffer = term.buffer.active + return buffer.viewportY >= buffer.length - term.rows +} + +type FollowArgs = { + /** Live runtime counter; every increment is one jump-to-latest request. */ + scrollToLatestRequest: number + /** Computed tail verdict (per-session Tail OR Tail All, masked by visibility). */ + tailActive: boolean + /** The leaf's terminal ref; null until the mount effect creates xterm. */ + termRef: RefObject +} + +export type AgentTerminalFollowHandle = { + /** Tail verdict for the PTY write path inside the leaf's mount effect. */ + readonly tailActiveRef: Readonly<{ current: boolean }> + /** Wire re-pin-on-user-scroll to a freshly created Terminal instance. */ + attach: (term: Terminal) => () => void +} + +export function useAgentTerminalFollow({ + scrollToLatestRequest, + tailActive, + termRef, +}: FollowArgs): AgentTerminalFollowHandle { + // WHY render-time assignment (mirroring runtimeRef in AgentTerminalLeaf): + // the PTY subscriber in the mount effect reads this ref at IPC-event time, + // long after any effect ordering, and the mount effect itself must never + // re-run for follow-state changes. + const tailActiveRef = useRef(tailActive) + tailActiveRef.current = tailActive + + // Jump to Latest. WHY a baseline ref: the counter can already be non-zero + // from the session's rendered-surface life, and remounting the pane must + // not replay an old request against a fresh xterm — the attach replay + // already leaves a fresh terminal at the bottom. + const jumpBaselineRef = useRef(null) + useEffect(() => { + if (jumpBaselineRef.current === null) { + jumpBaselineRef.current = scrollToLatestRequest + return + } + if (scrollToLatestRequest === jumpBaselineRef.current) return + jumpBaselineRef.current = scrollToLatestRequest + termRef.current?.scrollToBottom() + }, [scrollToLatestRequest, termRef]) + + // Stable handle: the leaf's mount effect is keyed on [sessionId] and must + // not be invalidated by follow-state churn. + return useMemo(() => ({ + tailActiveRef, + attach: _term => () => {}, + }), []) +} +``` + +(`attach` is a placeholder returning a no-op disposer until Task 5 wires `onScroll`; the signature is final.) + +- [ ] **Step 4: Wire the hook into AgentTerminalLeaf** + +In `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx`: + +Add the import with the other tile-tree imports: + +```ts +import { useAgentTerminalFollow } from '@renderer/workspace/tile-tree/agentTerminalFollow' +``` + +After `const ownerVisible = useAgentTerminalOwnerVisible()` (currently line ~86) add: + +```tsx +const tailAllMode = useAppStore(state => state.tailAllMode) +// Feed-parity tail mask (TileLeaf's effectiveTailMode): per-session Tail OR +// Tail All, suppressed while this subtree is hidden (editor fullscreen / +// Reader/Spotlight/Settings takeover) — a display:none pane cannot scroll, +// and folding visibility into the mask makes re-reveal a genuine transition +// that re-engages follow. +const tailActive = (runtime.tailMode || tailAllMode) && ownerVisible +// WHY this hook must be called BEFORE the xterm mount effect below: its +// effects read termRef.current at effect time and React runs passive effects +// in declaration order — when tail is already on at mount, the terminal does +// not exist yet, which is exactly the "nothing to restore" case. +const follow = useAgentTerminalFollow({ + scrollToLatestRequest: runtime.scrollToLatestRequest, + tailActive, + termRef, +}) +``` + +In the mount effect, after `termRef.current = term` (currently line ~242) add: + +```ts +const offFollowAttach = follow.attach(term) +``` + +In the mount-effect cleanup, next to `onDataDisposable?.dispose()` add: + +```ts +offFollowAttach() +``` + +(The mount effect's dep array stays `[sessionId]` — `follow` is a stable `useMemo` handle, so closing over it does not invalidate the keying; this mirrors the existing refs-not-deps rationale in the "WHY this goes through refs" comment.) + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +npm run test:renderer -- AgentTerminalLeaf.follow +``` +Expected: PASS (2 tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx +git commit -m "feat(workspace): honor jump-to-latest on agent terminal surfaces" +``` + +--- + +### Task 3: Tail engage, disengage, and non-destructive restore + +**Files:** +- Modify: `src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts` +- Modify: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx` + +- [ ] **Step 1: Write the failing tests** — append inside the `describe` block: + +```tsx +describe('tail engage/disengage', () => { + it('pins to bottom on engage and restores the pre-tail viewport line on disengage', async () => { + const view = render(leaf()) + await attachResolved() + term().buffer.active.length = 500 + term().buffer.active.viewportY = 100 // user scrolled up + act(() => { view.rerender(leaf(runtimeWith({ tailMode: true }))) }) + expect(term().scrollToBottom).toHaveBeenCalled() + expect(term().buffer.active.viewportY).toBe(460) // 500 - rows(40) + act(() => { view.rerender(leaf(runtimeWith({ tailMode: false }))) }) + expect(term().buffer.active.viewportY).toBe(100) + }) + + it('keeps the bottom on disengage when tail engaged at the bottom', async () => { + const view = render(leaf()) + await attachResolved() + term().buffer.active.length = 500 + term().buffer.active.viewportY = 460 // at bottom + act(() => { view.rerender(leaf(runtimeWith({ tailMode: true }))) }) + act(() => { view.rerender(leaf(runtimeWith({ tailMode: false }))) }) + expect(term().buffer.active.viewportY).toBe(460) + }) + + it('does not restore when tail was already on at mount (fresh terminal)', async () => { + const view = render(leaf(runtimeWith({ tailMode: true }))) + await attachResolved() + term().buffer.active.length = 500 + term().buffer.active.viewportY = 460 // user sat at the bottom + act(() => { view.rerender(leaf(runtimeWith({ tailMode: false }))) }) + // Engage happened before xterm existed — nothing was saved, disengage + // must not invent a position and yank the user to the top. + expect(term().buffer.active.viewportY).toBe(460) + }) +}) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +npm run test:renderer -- AgentTerminalLeaf.follow +``` +Expected: FAIL — engage does nothing yet (all three: no pin, no restore). + +- [ ] **Step 3: Implement engage/disengage in the hook** + +Add after the jump effect in `agentTerminalFollow.ts`: + +```ts +// Tail engage/disengage. Non-destructive like Feed: only a viewport that was +// genuinely scrolled up has a position worth restoring; engaging while at +// bottom saves nothing and disengage leaves the bottom. On mount with tail +// already on, this effect runs before xterm exists (declaration order — see +// the leaf wiring), so nothing is saved and disengage keeps the bottom the +// attach replay left us at. +const tailEngagedRef = useRef(false) +const savedViewportYRef = useRef(null) +useEffect(() => { + const activeTerm = termRef.current + if (tailActive && !tailEngagedRef.current) { + tailEngagedRef.current = true + if (activeTerm) { + savedViewportYRef.current = isXtermViewportAtBottom(activeTerm) + ? null + : activeTerm.buffer.active.viewportY + activeTerm.scrollToBottom() + } + return + } + if (!tailActive && tailEngagedRef.current) { + tailEngagedRef.current = false + const saved = savedViewportYRef.current + savedViewportYRef.current = null + if (activeTerm && saved !== null) { + const buffer = activeTerm.buffer.active + buffer.viewportY = Math.min(saved, Math.max(0, buffer.length - activeTerm.rows)) + } + } +}, [tailActive, termRef]) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +npm run test:renderer -- AgentTerminalLeaf.follow +``` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx +git commit -m "feat(workspace): engage and restore tail follow on agent terminal surfaces" +``` + +--- + +### Task 4: Follow PTY output (write path) and pin after attach replay + +**Files:** +- Modify: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx` +- Modify: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx` + +- [ ] **Step 1: Write the failing tests** — append inside the top-level `describe` (after the jump tests): + +```tsx +it('follows PTY output through the dispatcher while per-session tail is on', async () => { + render(leaf(runtimeWith({ tailMode: true }))) + await attachResolved() + term().buffer.active.length = 500 + act(() => { ptyListener?.({ sessionId: 'session-1', data: 'stream' }) }) + expect(term().scrollToBottom).toHaveBeenCalled() + expect(term().buffer.active.viewportY).toBe(460) +}) + +it('pins after the attach replay when tail is on', async () => { + render(leaf(runtimeWith({ tailMode: true }))) + await attachResolved('backfill') + // Tail engaged before xterm existed, so the pin has to come from the + // post-replay moment in tryAttach — proving that branch ran. + expect(term().scrollToBottom).toHaveBeenCalled() +}) + +it('leaves the viewport alone on PTY output while tail is off', async () => { + render(leaf()) + await attachResolved() + term().buffer.active.viewportY = 10 + term().buffer.active.length = 500 + act(() => { ptyListener?.({ sessionId: 'session-1', data: 'stream' }) }) + expect(term().scrollToBottom).not.toHaveBeenCalled() + expect(term().buffer.active.viewportY).toBe(10) +}) +``` + +Note: `ptyListener` is captured in `beforeEach` via the `onSessionAgentPtyData` mock — these tests push bytes through the real `sessionDataDispatcher`, not a leaf callback. + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +npm run test:renderer -- AgentTerminalLeaf.follow +``` +Expected: FAIL — first two (no follow, no post-replay pin); third passes vacuously. + +- [ ] **Step 3: Implement the write path in the leaf's mount effect** + +In `AgentTerminalLeaf.tsx`, replace the live-write branch of the PTY subscriber (currently `term?.write(data)`): + +```ts +offPtyData = subscribeToAgentPtyData(sessionId, data => { + if (!attachedBackfillDone) { + backlogQueue.push(data) + if (backlogQueue.length > 256) backlogQueue.splice(0, backlogQueue.length - 256) + return + } + // Tail scrolls in the write completion callback: xterm parses chunks + // asynchronously, so scrolling synchronously would target the pre-parse + // bottom and land one chunk early. + const liveTerm = term + if (follow.tailActiveRef.current) { + liveTerm?.write(data, () => liveTerm.scrollToBottom()) + } else { + liveTerm?.write(data) + } +}) +``` + +And inside `tryAttach`, immediately after `attachedBackfillDone = true`: + +```ts +// A fresh terminal follows its replay by default, but engage-while-mounted +// (or Tail All flipping during a remount) wants the pin explicit once the +// backfill exists — the replay itself does not go through the write path. +if (follow.tailActiveRef.current) liveTerm.scrollToBottom() +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +npm run test:renderer -- AgentTerminalLeaf.follow +``` +Expected: PASS (8 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx +git commit -m "feat(workspace): tail agent terminal output during PTY writes" +``` + +--- + +### Task 5: Re-pin on user scroll + TAIL pill + +**Files:** +- Modify: `src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts` +- Modify: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx` +- Modify: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx` + +- [ ] **Step 1: Write the failing tests** — append inside the top-level `describe`: + +```tsx +it('re-pins when the user scrolls away while tailing', async () => { + render(leaf(runtimeWith({ tailMode: true }))) + await attachResolved() + term().buffer.active.length = 500 + term().buffer.active.viewportY = 200 // user wheel-scrolled up + act(() => { term().onScrollListener?.(200) }) + expect(term().scrollToBottom).toHaveBeenCalled() + expect(term().buffer.active.viewportY).toBe(460) +}) + +it('does not re-pin on scroll while tail is off', async () => { + render(leaf()) + await attachResolved() + term().buffer.active.length = 500 + term().buffer.active.viewportY = 100 + act(() => { term().onScrollListener?.(100) }) + expect(term().scrollToBottom).not.toHaveBeenCalled() + expect(term().buffer.active.viewportY).toBe(100) +}) + +it('shows the TAIL pill in the header while tail is active', async () => { + const view = render(leaf()) + await attachResolved() + expect(screen.queryByText('TAIL')).toBeNull() + act(() => { view.rerender(leaf(runtimeWith({ tailMode: true }))) }) + expect(screen.getByText('TAIL')).toBeTruthy() +}) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +npm run test:renderer -- AgentTerminalLeaf.follow +``` +Expected: FAIL — no `onScroll` wiring (first test), no pill (third). Second passes vacuously. + +- [ ] **Step 3: Implement re-pin in the hook** + +Replace the placeholder `attach` in the `useMemo` handle of `agentTerminalFollow.ts`: + +```ts +return useMemo(() => ({ + tailActiveRef, + attach: mountedTerm => { + // Feed re-pins on the scroll event itself. scrollToBottom also fires + // onScroll, but the handler then sees an at-bottom viewport and no-ops, + // so the loop self-terminates. Mouse-mode TUIs forward wheel events to + // the app instead of xterm scrollback, so this only acts on genuine + // viewport movement. + const disposable = mountedTerm.onScroll(() => { + if (!tailActiveRef.current) return + if (isXtermViewportAtBottom(mountedTerm)) return + mountedTerm.scrollToBottom() + }) + return () => disposable.dispose() + }, +}), []) +``` + +- [ ] **Step 4: Implement the TAIL pill in the leaf header** + +In `AgentTerminalLeaf.tsx`, replace the header's right-side span (currently the single `terminal view` span): + +```tsx +
+ {tailActive ? ( + + TAIL + + ) : null} + + terminal view + +
+``` + +(Styling copied from the TAIL pill in `TileLeaf/ScrollIndicator.tsx` so both surfaces read identically.) + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +npm run test:renderer -- AgentTerminalLeaf.follow +``` +Expected: PASS (11 tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx +git commit -m "feat(workspace): re-pin tailed agent terminals and show TAIL pill" +``` + +--- + +### Task 6: Tail All + visibility-mask integration tests + +Behavior should already hold (the mask `(runtime.tailMode || tailAllMode) && ownerVisible` landed in Task 2). These tests pin it. + +**Files:** +- Modify: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx` + +- [ ] **Step 1: Append the tests** — inside the top-level `describe`: + +```tsx +it('follows output when Tail All is on', async () => { + appStore.tailAllMode = true + render(leaf()) + await attachResolved() + expect(screen.getByText('TAIL')).toBeTruthy() + term().buffer.active.length = 500 + act(() => { ptyListener?.({ sessionId: 'session-1', data: 'stream' }) }) + expect(term().buffer.active.viewportY).toBe(460) +}) + +it('stays inert for Tail All while the pane subtree is hidden', async () => { + appStore.tailAllMode = true + render( + + {leaf()} + , + ) + await attachResolved() + // Masked: no pill, no forced scroll on output — mirroring TileLeaf's + // re-reveal argument for folding visibility into the tail mask. + expect(screen.queryByText('TAIL')).toBeNull() + term().buffer.active.length = 500 + term().buffer.active.viewportY = 10 + act(() => { ptyListener?.({ sessionId: 'session-1', data: 'stream' }) }) + expect(term().scrollToBottom).not.toHaveBeenCalled() + expect(term().buffer.active.viewportY).toBe(10) +}) +``` + +- [ ] **Step 2: Run the tests** + +```bash +npm run test:renderer -- AgentTerminalLeaf.follow +``` +Expected: PASS (13 tests). If either fails, the mask computation in `AgentTerminalLeaf.tsx` deviates from TileLeaf's — fix the mask, not the test. + +- [ ] **Step 3: Commit** + +```bash +git add src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx +git commit -m "test(workspace): cover Tail All and visibility masking for agent terminals" +``` + +--- + +### Task 7: Surface the follow commands on terminal views + copy updates + +**Files:** +- Modify: `src/renderer/src/features/workspace/commands/paneCommands.ts` +- Create: `src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts` + +- [ ] **Step 1: Write the failing regression test** + +Create `src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' + +import { paneCommands } from '@renderer/features/workspace/commands/paneCommands' + +// Guards the command-availability half of terminal follow: both commands +// previously carried `renderedViewPolicy: 'requires-rendered-feed'`, which +// `commandAllowedByRenderedViewPolicy` resolves to false on ANY terminal +// surface — and unconditionally for OpenCode Terminal sessions +// (providerRuntime === 'terminal'). Someone re-adding the policy "for +// consistency" would silently uninstall the commands from raw terminal views +// again while the leaf-side behavior stays green. +// +// WHY this does not exercise `when`: the kind guards route through +// `commandTargetSessionId`, which needs a much larger workspace-state shape +// (tab/dispatch focus) than a unit fixture should fake. This task does not +// touch `when`; its behavior is owned by the existing command suites. + +describe('follow command availability', () => { + const tail = paneCommands.find(command => command.id === 'toggle-tail') + const jump = paneCommands.find(command => command.id === 'jump-latest-message') + + it('exposes both follow commands without a rendered-view policy', () => { + expect(tail).toBeDefined() + expect(jump).toBeDefined() + expect(tail!.renderedViewPolicy).toBeUndefined() + expect(jump!.renderedViewPolicy).toBeUndefined() + }) + + it('keeps both commands shell-excluded through a kind guard', () => { + // The `when` guards (kind !== 'terminal') are what keep plain shells out; + // assert they exist so removing the policy cannot silently remove them. + expect(typeof tail!.when).toBe('function') + expect(typeof jump!.when).toBe('function') + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +npm run test:renderer -- paneCommands.follow +``` +Expected: FAIL on `renderedViewPolicy` being defined. + +- [ ] **Step 3: Update `paneCommands.ts`** + +`toggle-tail` (around line 486): replace the description, drop the policy line, and update the `when` comment. The block + +```ts + description: '**What it does:** Toggles feed **auto-follow** for the focused target.\n\n**Use when:** You want output to stay pinned to the bottom.\n\n**Notes:** Applies to the visible command target, including **Dispatch** selection.', + renderedViewPolicy: { kind: 'requires-rendered-feed' }, +``` + +becomes + +```ts + description: '**What it does:** Toggles **auto-follow** for the focused target.\n\n**Use when:** You want output to stay pinned to the bottom.\n\n**Notes:** Applies to the visible command target, including **Dispatch** selection. Works in both the rendered feed and raw agent terminal views — in a terminal view the TUI output stays pinned to the bottom.', + // NO `renderedViewPolicy` — deliberately: this command owns follow + // behavior on BOTH agent surfaces now (Feed's tailMode on the rendered + // surface, useAgentTerminalFollow on the raw terminal). The old + // 'requires-rendered-feed' gate hid it on terminal surfaces, where + // following is exactly as meaningful. +``` + +and the `when` comment + +```ts + // WHY tail is agent-only even though terminals are Dispatch rows: + // tailMode controls the rendered transcript/feed scroll container. + // Terminal panes delegate scrollback to xterm.js, so toggling this + // runtime flag on a terminal would present a command that appears to + // work while changing nothing visible. + return workspace.state.sessions[sessionId]?.kind !== 'terminal' +``` + +becomes + +```ts + // WHY tail is agent-only even though plain shells are Dispatch rows: + // agent sessions consume tailMode on both of their surfaces since the + // terminal-follow work (useAgentTerminalFollow). Plain shell terminals + // (kind === 'terminal') delegate entirely to xterm scrollback and have + // no tail state. + return workspace.state.sessions[sessionId]?.kind !== 'terminal' +``` + +`toggle-tail-all` (around line 541): in the description, replace + +``` +Terminals are never affected. +``` + +with + +``` +Plain shell terminals are never affected; raw agent terminal views follow too. +``` + +`jump-latest-message` (around line 556): drop the policy line and update the notes. The block + +```ts + description: '**What it does:** Scrolls to the **latest agent message**.\n\n**Use when:** You are far up in the feed and want to return to the bottom.\n\n**Notes:** Agent panes only.', + renderedViewPolicy: { kind: 'requires-rendered-feed' }, +``` + +becomes + +```ts + description: '**What it does:** Scrolls to the **latest agent message**.\n\n**Use when:** You are far up in the feed and want to return to the bottom.\n\n**Notes:** Agent panes only — in a raw terminal view this scrolls the TUI viewport to the bottom.', + // NO `renderedViewPolicy` — the xterm viewport answers jump requests too + // (useAgentTerminalFollow); gating on a rendered feed would hide this on + // the surface where returning to the bottom is most often needed. +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +npm run test:renderer -- paneCommands.follow +``` +Expected: PASS (2 tests). + +- [ ] **Step 5: Run the command-related checks (copy changed)** + +```bash +npm run check:contract && npm run check:keybindings +``` +Expected: both exit 0. If the contract checker objects to the removed fields, read its output — it is the authority on command-def invariants. + +- [ ] **Step 6: Commit** + +```bash +git add src/renderer/src/features/workspace/commands/paneCommands.ts src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts +git commit -m "feat(workspace): surface follow commands on raw agent terminal views" +``` + +--- + +### Task 8: Full verification sweep + +**Files:** none + +- [ ] **Step 1: Typecheck** + +```bash +npm run typecheck +``` +Expected: exit 0. + +- [ ] **Step 2: Full renderer suite** + +```bash +npm run test:renderer +``` +Expected: PASS, zero failures — in particular `commandState.test.ts`, `preferences.renderer.test.tsx`, and `agentDisplayMode.test.ts` (policy semantics were touched indirectly). + +- [ ] **Step 3: Unit + system suites** + +```bash +npm run test:unit && npm run test:system +``` +Expected: PASS. + +- [ ] **Step 4: Review the final diff** + +```bash +git diff main --stat && git log --oneline main.. +``` +Expected: only the files listed in this plan; conventional-commit subjects; plan file is the first commit. + +- [ ] **Step 5: Report and wait** + +Report the final state (tests run, results, remaining risks — e.g. alternate-screen TUIs make follow a visual no-op by design; behavior verified against mocked xterm scroll APIs, not a real canvas) and wait for explicit user confirmation before opening a PR. Do not merge. From 40abea15e0bacbc46ef29b68c99ef6448baf2779 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 7 Sep 2026 16:17:12 -0700 Subject: [PATCH 2/9] feat(workspace): honor jump-to-latest on agent terminal surfaces --- ...AgentTerminalLeaf.follow.renderer.test.tsx | 229 ++++++++++++++++++ .../workspace/tile-tree/AgentTerminalLeaf.tsx | 24 ++ .../tile-tree/agentTerminalFollow.ts | 81 +++++++ 3 files changed, 334 insertions(+) create mode 100644 src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx create mode 100644 src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx new file mode 100644 index 00000000..a93099e3 --- /dev/null +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx @@ -0,0 +1,229 @@ +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { emptyRuntime } from '@renderer/session-runtime/state' +import type { SessionRuntime } from '@renderer/session-runtime/state' +import { + AgentTerminalOwnershipProvider, + AgentTerminalOwnerVisibilityProvider, + MountedAgentTerminalOwner, +} from '@renderer/workspace/terminal/AgentTerminalOwnership' +import type { Workspace } from '@renderer/workspace/workspaceStore' +import { AgentTerminalLeaf } from './AgentTerminalLeaf' + +// Integration harness for follow behavior (jump-to-latest + tail) on the raw +// agent terminal surface. Modeled on AgentTerminalLeaf.submit.renderer.test.tsx +// with three deltas: the mocked Terminal grows scroll surface area +// (scrollToBottom / onScroll / buffer viewport), the app-store mock is MUTABLE +// so tests can flip tailAllMode like the real store would, and the agent PTY +// channel listener is captured so tests push bytes through the REAL +// sessionDataDispatcher fanout instead of calling leaf internals. +type MockTerminal = { + rows: number + buffer: { active: { viewportY: number; length: number } } + scrollToBottom: ReturnType + onScrollListener: ((line: number) => void) | null +} + +const xtermHarness = vi.hoisted(() => ({ + cols: 120, + rows: 40, + instances: [] as MockTerminal[], + attachWebgl: vi.fn(), + fit: vi.fn(), +})) + +const appStore = vi.hoisted(() => ({ + settings: { + dictationEnabled: false, + dictationProvider: 'local', + dictationShortcut: 'off', + mouseModeEnabled: false, + }, + tailAllMode: false, +})) + +vi.mock('@renderer/workspace/terminal/xtermWebglRenderer', () => ({ + attachXtermWebglRenderer: xtermHarness.attachWebgl, +})) + +vi.mock('@xterm/xterm', () => ({ + Terminal: class { + cols = xtermHarness.cols + rows = xtermHarness.rows + options: Record = {} + container: HTMLElement | null = null + onDataListener: ((data: string) => void) | null = null + onScrollListener: ((line: number) => void) | null = null + buffer = { active: { viewportY: 0, length: 1 } } + // scrollToBottom intentionally does NOT emit onScroll: real xterm does, + // but our handler then sees an at-bottom viewport and no-ops, so the mock + // keeps call counts deterministic. Re-pin behavior is tested by invoking + // the registered onScroll listener directly. + scrollToBottom = vi.fn(() => { + this.buffer.active.viewportY = Math.max(0, this.buffer.active.length - this.rows) + }) + dispose = vi.fn() + inputDispose = vi.fn(() => { this.onDataListener = null }) + scrollDispose = vi.fn(() => { this.onScrollListener = null }) + constructor() { xtermHarness.instances.push(this as unknown as MockTerminal) } + loadAddon() {} + open(container: HTMLElement) { this.container = container } + onData(listener: (data: string) => void) { + this.onDataListener = listener + return { dispose: this.inputDispose } + } + onScroll(listener: (line: number) => void) { + this.onScrollListener = listener + return { dispose: this.scrollDispose } + } + write(_data: string, callback?: () => void) { callback?.() } + focus() {} + }, +})) + +vi.mock('@xterm/addon-fit', () => ({ + FitAddon: class { fit() { xtermHarness.fit() } }, +})) + +vi.mock('@renderer/app-state/hooks', () => ({ + useAppStore: (selector: (state: typeof appStore) => unknown) => selector(appStore), +})) + +vi.mock('@renderer/app-state/settings/theme', async importOriginal => ({ + ...(await importOriginal>()), + THEME_CHANGED_EVENT: 'agent-code:test-theme-change', + getActiveAppFontFamily: () => 'monospace', +})) + +vi.mock('@renderer/workspace/tile-tree/xtermTheme', () => ({ + readXtermTheme: () => ({}), + syncXtermTheme: () => {}, +})) + +vi.mock('@renderer/workspace/tile-tree/TileLeaf/useComposerDictation', () => ({ + useComposerDictation: () => {}, +})) + +type Deferred = { promise: Promise; resolve: (value: T) => void } +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise(done => { resolve = done }) + return { promise, resolve } +} + +type PtyEvent = { sessionId: string; data: string } + +describe('AgentTerminalLeaf follow (jump-to-latest + tail)', () => { + let attach: Deferred + let nextFrameId: number + let frames: Map + let ptyListener: ((event: PtyEvent) => void) | null = null + const api = { + attachAgentPty: vi.fn((_id: string) => attach.promise), + detachAgentPty: vi.fn().mockResolvedValue(undefined), + onSessionAgentPtyData: vi.fn((listener: (event: PtyEvent) => void) => { + ptyListener = listener + return () => { ptyListener = null } + }), + onSessionTerminalData: vi.fn(() => () => {}), + resize: vi.fn().mockResolvedValue(undefined), + sendInput: vi.fn().mockResolvedValue(undefined), + } + const workspace = { + acknowledgeSession: vi.fn(), + ensureSessionLive: vi.fn().mockResolvedValue(undefined), + showPaneToast: vi.fn(), + } as unknown as Workspace + + const runtimeWith = (patch: Partial): SessionRuntime => ({ + ...emptyRuntime(), + processStatus: 'started', + ...patch, + }) + + function leaf(runtime: SessionRuntime = runtimeWith({})) { + return ( + + + {}} + workspace={workspace} + runtime={runtime} + projectDir="/tmp/project" + provider="codex" + /> + + + ) + } + + function flushAnimationFrames() { + const pending = [...frames.values()] + frames.clear() + for (const callback of pending) callback(performance.now()) + } + + async function attachResolved(buffer = '') { + act(() => flushAnimationFrames()) + await act(async () => { + attach.resolve(buffer) + await attach.promise + }) + } + + function term(): MockTerminal { + return xtermHarness.instances[0] + } + + beforeEach(() => { + appStore.tailAllMode = false + attach = deferred() + nextFrameId = 0 + frames = new Map() + ptyListener = null + xtermHarness.fit.mockClear() + xtermHarness.instances.length = 0 + xtermHarness.attachWebgl.mockReset() + xtermHarness.attachWebgl.mockImplementation(() => ({ + ready: Promise.resolve(true), + dispose: vi.fn(), + })) + api.attachAgentPty.mockReset().mockImplementation(() => attach.promise) + api.detachAgentPty.mockClear() + + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = ++nextFrameId + frames.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { frames.delete(id) }) + vi.stubGlobal('ResizeObserver', class { + disconnect = vi.fn() + observe() {} + unobserve() {} + }) + Object.defineProperty(window, 'api', { configurable: true, value: api }) + }) + + afterEach(() => { + cleanup() + Reflect.deleteProperty(window, 'api') + vi.unstubAllGlobals() + }) + + it('ignores the pre-existing jump request baseline on mount', async () => { + render(leaf(runtimeWith({ scrollToLatestRequest: 3 }))) + await attachResolved() + expect(term().scrollToBottom).not.toHaveBeenCalled() + }) + + it('scrolls the xterm viewport once when a new jump-to-latest request arrives', async () => { + const view = render(leaf(runtimeWith({ scrollToLatestRequest: 3 }))) + await attachResolved() + act(() => { view.rerender(leaf(runtimeWith({ scrollToLatestRequest: 4 }))) }) + expect(term().scrollToBottom).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx index 862d0599..9b0023aa 100644 --- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx @@ -22,6 +22,7 @@ import { attachXtermWebglRenderer } from '@renderer/workspace/terminal/xtermWebg import { AgentTitleHeader } from '@renderer/workspace/tile-tree/AgentTitleHeader' import { createTerminalInputForwarder } from '@renderer/workspace/tile-tree/terminalInputForwarder' import { AgentTerminalActions } from '@renderer/workspace/tile-tree/AgentTerminalActions' +import { useAgentTerminalFollow } from '@renderer/workspace/tile-tree/agentTerminalFollow' type Props = { sessionId: SessionId @@ -84,6 +85,22 @@ export function AgentTerminalLeaf({ focusedRef.current = focused const dimensionActive = useAgentTerminalDimensionActive() const ownerVisible = useAgentTerminalOwnerVisible() + const tailAllMode = useAppStore(state => state.tailAllMode) + // Feed-parity tail mask (TileLeaf's effectiveTailMode): per-session Tail OR + // Tail All, suppressed while this subtree is hidden (editor fullscreen / + // Reader/Spotlight/Settings takeover) — a display:none pane cannot scroll, + // and folding visibility into the mask makes re-reveal a genuine transition + // that re-engages follow. + const tailActive = (runtime.tailMode || tailAllMode) && ownerVisible + // WHY this hook must be called BEFORE the xterm mount effect below: its + // effects read termRef.current at effect time and React runs passive effects + // in declaration order — when tail is already on at mount, the terminal does + // not exist yet, which is exactly the "nothing to restore" case. + const follow = useAgentTerminalFollow({ + scrollToLatestRequest: runtime.scrollToLatestRequest, + tailActive, + termRef, + }) const dimensionActiveRef = useRef(false) const dimensionOwnershipEpochRef = useRef(0) const onDimensionOwnershipChangeRef = useRef<((active: boolean) => void) | null>(null) @@ -121,6 +138,9 @@ export function AgentTerminalLeaf({ let webglRenderer: ReturnType | null = null let onDataDisposable: { dispose(): void } | null = null let offPtyData: (() => void) | null = null + // Nullable like the disposables above: xterm init can throw before the + // follow wiring ever runs, and cleanup must survive that path. + let offFollowAttach: (() => void) | null = null let resizeObserver: ResizeObserver | null = null let resizeFrame: number | null = null let disposed = false @@ -240,6 +260,9 @@ export function AgentTerminalLeaf({ term.open(container) webglRenderer = attachXtermWebglRenderer(term) termRef.current = term + // Follow re-pin wiring lives in the hook; the mount effect only owns + // the terminal instance lifetime, so this attaches/detaches with it. + offFollowAttach = follow.attach(term) if (dimensionActiveRef.current) scheduleFitAndResizeBackend() resizeObserver = new ResizeObserver(scheduleFitAndResizeBackend) @@ -437,6 +460,7 @@ export function AgentTerminalLeaf({ if (resizeFrame !== null) cancelAnimationFrame(resizeFrame) resizeObserver?.disconnect() onDataDisposable?.dispose() + offFollowAttach?.() offPtyData?.() webglRenderer?.dispose() if (onThemeChangedListener) { diff --git a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts new file mode 100644 index 00000000..bf46fa4c --- /dev/null +++ b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts @@ -0,0 +1,81 @@ +import { useEffect, useMemo, useRef } from 'react' +import type { RefObject } from 'react' +import type { Terminal } from '@xterm/xterm' + +// Follow behavior for raw agent terminal surfaces (AgentTerminalLeaf) — the +// xterm counterpart of what Feed does for the rendered surface: +// - Jump to Latest: the workspace bumps `runtime.scrollToLatestRequest` +// whenever the user asks to return to the bottom (palette command, prompt +// send). Feed scrolls its DOM scroller; a raw pane scrolls the xterm +// viewport instead. Nothing consumed this counter on the terminal surface +// before, so the command silently did nothing there. +// - Tail (auto-follow): mirrors Feed's semantics — pin to bottom while +// active, re-pin if the user scrolls away, and restore the pre-tail +// viewport line on disengage so following is non-destructive. Feed +// protects the saved position for the same reason (see Feed.tsx "WHY +// tailing deliberately does NOT persist"). +// +// WHY a hook instead of inline effects in AgentTerminalLeaf: the leaf's xterm +// mount effect is deliberately keyed on [sessionId] alone (remounting xterm on +// every runtime change would lose scrollback and re-attach the PTY), so +// runtime-driven behavior must live outside that effect and reach the terminal +// through refs. Collecting it here also gives the renderer tests one unit to +// target. The hook MUST be called before the leaf's mount effect — see the +// wiring comment in AgentTerminalLeaf. + +/** Viewport is at bottom when its top line plus rows covers the buffer. */ +export function isXtermViewportAtBottom(term: Terminal): boolean { + const buffer = term.buffer.active + return buffer.viewportY >= buffer.length - term.rows +} + +type FollowArgs = { + /** Live runtime counter; every increment is one jump-to-latest request. */ + scrollToLatestRequest: number + /** Computed tail verdict (per-session Tail OR Tail All, masked by visibility). */ + tailActive: boolean + /** The leaf's terminal ref; null until the mount effect creates xterm. */ + termRef: RefObject +} + +export type AgentTerminalFollowHandle = { + /** Tail verdict for the PTY write path inside the leaf's mount effect. */ + readonly tailActiveRef: Readonly<{ current: boolean }> + /** Wire re-pin-on-user-scroll to a freshly created Terminal instance. */ + attach: (term: Terminal) => () => void +} + +export function useAgentTerminalFollow({ + scrollToLatestRequest, + tailActive, + termRef, +}: FollowArgs): AgentTerminalFollowHandle { + // WHY render-time assignment (mirroring runtimeRef in AgentTerminalLeaf): + // the PTY subscriber in the mount effect reads this ref at IPC-event time, + // long after any effect ordering, and the mount effect itself must never + // re-run for follow-state changes. + const tailActiveRef = useRef(tailActive) + tailActiveRef.current = tailActive + + // Jump to Latest. WHY a baseline ref: the counter can already be non-zero + // from the session's rendered-surface life, and remounting the pane must + // not replay an old request against a fresh xterm — the attach replay + // already leaves a fresh terminal at the bottom. + const jumpBaselineRef = useRef(null) + useEffect(() => { + if (jumpBaselineRef.current === null) { + jumpBaselineRef.current = scrollToLatestRequest + return + } + if (scrollToLatestRequest === jumpBaselineRef.current) return + jumpBaselineRef.current = scrollToLatestRequest + termRef.current?.scrollToBottom() + }, [scrollToLatestRequest, termRef]) + + // Stable handle: the leaf's mount effect is keyed on [sessionId] and must + // not be invalidated by follow-state churn. + return useMemo(() => ({ + tailActiveRef, + attach: _term => () => {}, + }), []) +} From b611035958a44bddb2ac92b0b0b67486ee57a293 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 7 Sep 2026 16:20:31 -0700 Subject: [PATCH 3/9] feat(workspace): engage and restore tail follow on agent terminal surfaces --- ...AgentTerminalLeaf.follow.renderer.test.tsx | 35 +++++++++++++++++++ .../tile-tree/agentTerminalFollow.ts | 31 ++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx index a93099e3..ca76193c 100644 --- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx @@ -226,4 +226,39 @@ describe('AgentTerminalLeaf follow (jump-to-latest + tail)', () => { act(() => { view.rerender(leaf(runtimeWith({ scrollToLatestRequest: 4 }))) }) expect(term().scrollToBottom).toHaveBeenCalledTimes(1) }) + + describe('tail engage/disengage', () => { + it('pins to bottom on engage and restores the pre-tail viewport line on disengage', async () => { + const view = render(leaf()) + await attachResolved() + term().buffer.active.length = 500 + term().buffer.active.viewportY = 100 // user scrolled up + act(() => { view.rerender(leaf(runtimeWith({ tailMode: true }))) }) + expect(term().scrollToBottom).toHaveBeenCalled() + expect(term().buffer.active.viewportY).toBe(460) // 500 - rows(40) + act(() => { view.rerender(leaf(runtimeWith({ tailMode: false }))) }) + expect(term().buffer.active.viewportY).toBe(100) + }) + + it('keeps the bottom on disengage when tail engaged at the bottom', async () => { + const view = render(leaf()) + await attachResolved() + term().buffer.active.length = 500 + term().buffer.active.viewportY = 460 // at bottom + act(() => { view.rerender(leaf(runtimeWith({ tailMode: true }))) }) + act(() => { view.rerender(leaf(runtimeWith({ tailMode: false }))) }) + expect(term().buffer.active.viewportY).toBe(460) + }) + + it('does not restore when tail was already on at mount (fresh terminal)', async () => { + const view = render(leaf(runtimeWith({ tailMode: true }))) + await attachResolved() + term().buffer.active.length = 500 + term().buffer.active.viewportY = 460 // user sat at the bottom + act(() => { view.rerender(leaf(runtimeWith({ tailMode: false }))) }) + // Engage happened before xterm existed — nothing was saved, disengage + // must not invent a position and yank the user to the top. + expect(term().buffer.active.viewportY).toBe(460) + }) + }) }) diff --git a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts index bf46fa4c..0651af42 100644 --- a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts +++ b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts @@ -72,6 +72,37 @@ export function useAgentTerminalFollow({ termRef.current?.scrollToBottom() }, [scrollToLatestRequest, termRef]) + // Tail engage/disengage. Non-destructive like Feed: only a viewport that was + // genuinely scrolled up has a position worth restoring; engaging while at + // bottom saves nothing and disengage leaves the bottom. On mount with tail + // already on, this effect runs before xterm exists (declaration order — see + // the leaf wiring), so nothing is saved and disengage keeps the bottom the + // attach replay left us at. + const tailEngagedRef = useRef(false) + const savedViewportYRef = useRef(null) + useEffect(() => { + const activeTerm = termRef.current + if (tailActive && !tailEngagedRef.current) { + tailEngagedRef.current = true + if (activeTerm) { + savedViewportYRef.current = isXtermViewportAtBottom(activeTerm) + ? null + : activeTerm.buffer.active.viewportY + activeTerm.scrollToBottom() + } + return + } + if (!tailActive && tailEngagedRef.current) { + tailEngagedRef.current = false + const saved = savedViewportYRef.current + savedViewportYRef.current = null + if (activeTerm && saved !== null) { + const buffer = activeTerm.buffer.active + buffer.viewportY = Math.min(saved, Math.max(0, buffer.length - activeTerm.rows)) + } + } + }, [tailActive, termRef]) + // Stable handle: the leaf's mount effect is keyed on [sessionId] and must // not be invalidated by follow-state churn. return useMemo(() => ({ From 91c2db70323e760d7f3afba1b239012ede3fd928 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 7 Sep 2026 16:25:59 -0700 Subject: [PATCH 4/9] feat(workspace): tail agent terminal output during PTY writes --- ...AgentTerminalLeaf.follow.renderer.test.tsx | 40 +++++++++++++++++-- .../workspace/tile-tree/AgentTerminalLeaf.tsx | 14 ++++++- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx index ca76193c..1ade0518 100644 --- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx @@ -118,13 +118,19 @@ describe('AgentTerminalLeaf follow (jump-to-latest + tail)', () => { let attach: Deferred let nextFrameId: number let frames: Map - let ptyListener: ((event: PtyEvent) => void) | null = null + // The REAL sessionDataDispatcher is a module singleton that subscribes to + // the window.api channel exactly once and keeps that subscription across + // tests (its unsubscribe only runs on dispose/HMR). So the channel listener + // captured by the FIRST mount stays valid for the whole file — later tests + // must not null it, they only replace the per-session handler by remounting. + // beforeEach therefore leaves this capture alone. + let channelListener: ((event: PtyEvent) => void) | null = null const api = { attachAgentPty: vi.fn((_id: string) => attach.promise), detachAgentPty: vi.fn().mockResolvedValue(undefined), onSessionAgentPtyData: vi.fn((listener: (event: PtyEvent) => void) => { - ptyListener = listener - return () => { ptyListener = null } + channelListener = listener + return () => {} }), onSessionTerminalData: vi.fn(() => () => {}), resize: vi.fn().mockResolvedValue(undefined), @@ -183,7 +189,6 @@ describe('AgentTerminalLeaf follow (jump-to-latest + tail)', () => { attach = deferred() nextFrameId = 0 frames = new Map() - ptyListener = null xtermHarness.fit.mockClear() xtermHarness.instances.length = 0 xtermHarness.attachWebgl.mockReset() @@ -227,6 +232,33 @@ describe('AgentTerminalLeaf follow (jump-to-latest + tail)', () => { expect(term().scrollToBottom).toHaveBeenCalledTimes(1) }) + it('follows PTY output through the dispatcher while per-session tail is on', async () => { + render(leaf(runtimeWith({ tailMode: true }))) + await attachResolved() + term().buffer.active.length = 500 + act(() => { channelListener?.({ sessionId: 'session-1', data: 'stream' }) }) + expect(term().scrollToBottom).toHaveBeenCalled() + expect(term().buffer.active.viewportY).toBe(460) + }) + + it('pins after the attach replay when tail is on', async () => { + render(leaf(runtimeWith({ tailMode: true }))) + await attachResolved('backfill') + // Tail engaged before xterm existed, so the pin has to come from the + // post-replay moment in tryAttach — proving that branch ran. + expect(term().scrollToBottom).toHaveBeenCalled() + }) + + it('leaves the viewport alone on PTY output while tail is off', async () => { + render(leaf()) + await attachResolved() + term().buffer.active.viewportY = 10 + term().buffer.active.length = 500 + act(() => { channelListener?.({ sessionId: 'session-1', data: 'stream' }) }) + expect(term().scrollToBottom).not.toHaveBeenCalled() + expect(term().buffer.active.viewportY).toBe(10) + }) + describe('tail engage/disengage', () => { it('pins to bottom on engage and restores the pre-tail viewport line on disengage', async () => { const view = render(leaf()) diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx index 9b0023aa..7df16d37 100644 --- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx @@ -319,7 +319,15 @@ export function AgentTerminalLeaf({ if (backlogQueue.length > 256) backlogQueue.splice(0, backlogQueue.length - 256) return } - term?.write(data) + // Tail scrolls in the write completion callback: xterm parses chunks + // asynchronously, so scrolling synchronously would target the pre-parse + // bottom and land one chunk early. + const liveTerm = term + if (follow.tailActiveRef.current) { + liveTerm?.write(data, () => liveTerm.scrollToBottom()) + } else { + liveTerm?.write(data) + } }) // WHY this goes through refs instead of effect deps: mounting xterm is @@ -392,6 +400,10 @@ export function AgentTerminalLeaf({ void forwarder.replay(liveTerm, [buffer, backlogQueue.join('')]) backlogQueue.length = 0 attachedBackfillDone = true + // A fresh terminal follows its replay by default, but engage-while-mounted + // (or Tail All flipping during a remount) wants the pin explicit once the + // backfill exists — the replay itself does not go through the write path. + if (follow.tailActiveRef.current) liveTerm.scrollToBottom() if (pendingResize) { const measured = pendingResize pendingResize = null From 0e77e68799b6da08d7258a825b565b84ab6c5fe5 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 7 Sep 2026 16:29:19 -0700 Subject: [PATCH 5/9] feat(workspace): re-pin tailed agent terminals and show TAIL pill --- ...AgentTerminalLeaf.follow.renderer.test.tsx | 28 +++++++++++++++++++ .../workspace/tile-tree/AgentTerminalLeaf.tsx | 17 +++++++++-- .../tile-tree/agentTerminalFollow.ts | 14 +++++++++- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx index 1ade0518..75b4f128 100644 --- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx @@ -259,6 +259,34 @@ describe('AgentTerminalLeaf follow (jump-to-latest + tail)', () => { expect(term().buffer.active.viewportY).toBe(10) }) + it('re-pins when the user scrolls away while tailing', async () => { + render(leaf(runtimeWith({ tailMode: true }))) + await attachResolved() + term().buffer.active.length = 500 + term().buffer.active.viewportY = 200 // user wheel-scrolled up + act(() => { term().onScrollListener?.(200) }) + expect(term().scrollToBottom).toHaveBeenCalled() + expect(term().buffer.active.viewportY).toBe(460) + }) + + it('does not re-pin on scroll while tail is off', async () => { + render(leaf()) + await attachResolved() + term().buffer.active.length = 500 + term().buffer.active.viewportY = 100 + act(() => { term().onScrollListener?.(100) }) + expect(term().scrollToBottom).not.toHaveBeenCalled() + expect(term().buffer.active.viewportY).toBe(100) + }) + + it('shows the TAIL pill in the header while tail is active', async () => { + const view = render(leaf()) + await attachResolved() + expect(screen.queryByText('TAIL')).toBeNull() + act(() => { view.rerender(leaf(runtimeWith({ tailMode: true }))) }) + expect(screen.getByText('TAIL')).toBeTruthy() + }) + describe('tail engage/disengage', () => { it('pins to bottom on engage and restores the pre-tail viewport line on disengage', async () => { const view = render(leaf()) diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx index 7df16d37..9eeef447 100644 --- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx @@ -537,9 +537,20 @@ export function AgentTerminalLeaf({ {shortenCwd(projectDir)} - - terminal view - + {/* TAIL pill styling copied from ScrollIndicator so both surfaces + read identically — without it the raw view silently follows + output while showing no state the palette can be checked + against. */} +
+ {tailActive ? ( + + TAIL + + ) : null} + + terminal view + +
diff --git a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts index 0651af42..1a19aaf5 100644 --- a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts +++ b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts @@ -107,6 +107,18 @@ export function useAgentTerminalFollow({ // not be invalidated by follow-state churn. return useMemo(() => ({ tailActiveRef, - attach: _term => () => {}, + attach: mountedTerm => { + // Feed re-pins on the scroll event itself. scrollToBottom also fires + // onScroll, but the handler then sees an at-bottom viewport and no-ops, + // so the loop self-terminates. Mouse-mode TUIs forward wheel events to + // the app instead of xterm scrollback, so this only acts on genuine + // viewport movement. + const disposable = mountedTerm.onScroll(() => { + if (!tailActiveRef.current) return + if (isXtermViewportAtBottom(mountedTerm)) return + mountedTerm.scrollToBottom() + }) + return () => disposable.dispose() + }, }), []) } From e98015af7e155bb8f9ad943f33c8c0e907ae01e3 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 7 Sep 2026 16:31:27 -0700 Subject: [PATCH 6/9] test(workspace): cover Tail All and visibility masking for agent terminals --- ...AgentTerminalLeaf.follow.renderer.test.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx index 75b4f128..c1c45cb7 100644 --- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx @@ -287,6 +287,34 @@ describe('AgentTerminalLeaf follow (jump-to-latest + tail)', () => { expect(screen.getByText('TAIL')).toBeTruthy() }) + it('follows output when Tail All is on', async () => { + appStore.tailAllMode = true + render(leaf()) + await attachResolved() + expect(screen.getByText('TAIL')).toBeTruthy() + term().buffer.active.length = 500 + act(() => { channelListener?.({ sessionId: 'session-1', data: 'stream' }) }) + expect(term().buffer.active.viewportY).toBe(460) + }) + + it('stays inert for Tail All while the pane subtree is hidden', async () => { + appStore.tailAllMode = true + render( + + {leaf()} + , + ) + await attachResolved() + // Masked: no pill, no forced scroll on output — mirroring TileLeaf's + // re-reveal argument for folding visibility into the tail mask. + expect(screen.queryByText('TAIL')).toBeNull() + term().buffer.active.length = 500 + term().buffer.active.viewportY = 10 + act(() => { channelListener?.({ sessionId: 'session-1', data: 'stream' }) }) + expect(term().scrollToBottom).not.toHaveBeenCalled() + expect(term().buffer.active.viewportY).toBe(10) + }) + describe('tail engage/disengage', () => { it('pins to bottom on engage and restores the pre-tail viewport line on disengage', async () => { const view = render(leaf()) From 58ef234d74ece25c155eec86d931c18f38116576 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 7 Sep 2026 16:35:33 -0700 Subject: [PATCH 7/9] feat(workspace): surface follow commands on raw agent terminal views --- .../paneCommands.follow.renderer.test.ts | 35 +++++++++++++++++++ .../workspace/commands/paneCommands.ts | 26 ++++++++------ 2 files changed, 51 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts diff --git a/src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts b/src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts new file mode 100644 index 00000000..ea0f4c1b --- /dev/null +++ b/src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { paneCommands } from '@renderer/features/workspace/commands/paneCommands' + +// Guards the command-availability half of terminal follow: both commands +// previously carried `renderedViewPolicy: 'requires-rendered-feed'`, which +// `commandAllowedByRenderedViewPolicy` resolves to false on ANY terminal +// surface — and unconditionally for OpenCode Terminal sessions +// (providerRuntime === 'terminal'). Someone re-adding the policy "for +// consistency" would silently uninstall the commands from raw terminal views +// again while the leaf-side behavior stays green. +// +// WHY this does not exercise `when`: the kind guards route through +// `commandTargetSessionId`, which needs a much larger workspace-state shape +// (tab/dispatch focus) than a unit fixture should fake. This task does not +// touch `when`; its behavior is owned by the existing command suites. + +describe('follow command availability', () => { + const tail = paneCommands.find(command => command.id === 'toggle-tail') + const jump = paneCommands.find(command => command.id === 'jump-latest-message') + + it('exposes both follow commands without a rendered-view policy', () => { + expect(tail).toBeDefined() + expect(jump).toBeDefined() + expect(tail!.renderedViewPolicy).toBeUndefined() + expect(jump!.renderedViewPolicy).toBeUndefined() + }) + + it('keeps both commands shell-excluded through a kind guard', () => { + // The `when` guards (kind !== 'terminal') are what keep plain shells out; + // assert they exist so removing the policy cannot silently remove them. + expect(typeof tail!.when).toBe('function') + expect(typeof jump!.when).toBe('function') + }) +}) diff --git a/src/renderer/src/features/workspace/commands/paneCommands.ts b/src/renderer/src/features/workspace/commands/paneCommands.ts index 652bf566..c16632d0 100644 --- a/src/renderer/src/features/workspace/commands/paneCommands.ts +++ b/src/renderer/src/features/workspace/commands/paneCommands.ts @@ -483,8 +483,12 @@ export const paneCommands: CommandDef[] = [ surface: 'session', title: 'Auto-follow Focused Agent', keywords: ['tail'], - description: '**What it does:** Toggles feed **auto-follow** for the focused target.\n\n**Use when:** You want output to stay pinned to the bottom.\n\n**Notes:** Applies to the visible command target, including **Dispatch** selection.', - renderedViewPolicy: { kind: 'requires-rendered-feed' }, + description: '**What it does:** Toggles **auto-follow** for the focused target.\n\n**Use when:** You want output to stay pinned to the bottom.\n\n**Notes:** Applies to the visible command target, including **Dispatch** selection. Works in both the rendered feed and raw agent terminal views — in a terminal view the TUI output stays pinned to the bottom.', + // NO `renderedViewPolicy` — deliberately: this command owns follow + // behavior on BOTH agent surfaces now (Feed's tailMode on the rendered + // surface, useAgentTerminalFollow on the raw terminal). The old + // 'requires-rendered-feed' gate hid it on terminal surfaces, where + // following is exactly as meaningful. getState: ({ workspace, flags }) => { const sessionId = commandTargetSessionId(workspace) const tailMode = sessionId @@ -514,11 +518,11 @@ export const paneCommands: CommandDef[] = [ when: ({ workspace }) => { const sessionId = commandTargetSessionId(workspace) if (!sessionId) return false - // WHY tail is agent-only even though terminals are Dispatch rows: - // tailMode controls the rendered transcript/feed scroll container. - // Terminal panes delegate scrollback to xterm.js, so toggling this - // runtime flag on a terminal would present a command that appears to - // work while changing nothing visible. + // WHY tail is agent-only even though plain shells are Dispatch rows: + // agent sessions consume tailMode on both of their surfaces since the + // terminal-follow work (useAgentTerminalFollow). Plain shell terminals + // (kind === 'terminal') delegate entirely to xterm scrollback and have + // no tail state. return workspace.state.sessions[sessionId]?.kind !== 'terminal' }, run: ({ workspace }) => { @@ -538,7 +542,7 @@ export const paneCommands: CommandDef[] = [ surface: 'app', title: 'Auto-follow All Visible Agents', description: - '**What it does:** Toggles feed **auto-follow for every visible agent** at once.\n\n**Use when:** You are watching several agents work and want them all pinned to the bottom.\n\n**Notes:** Scopes to what is on screen — in **single dispatch** that is the one agent, in **tiled** every lane, in the **grid** the current tab\'s panes only. Panes you open afterward tail too, until you toggle it off. Terminals are never affected.\n\n**Caution:** A tailing feed cannot be scrolled up — this takes scrollback away from every visible pane at once, and turning it off does not restore where you were reading.', + '**What it does:** Toggles feed **auto-follow for every visible agent** at once.\n\n**Use when:** You are watching several agents work and want them all pinned to the bottom.\n\n**Notes:** Scopes to what is on screen — in **single dispatch** that is the one agent, in **tiled** every lane, in the **grid** the current tab\'s panes only. Panes you open afterward tail too, until you toggle it off. Plain shell terminals are never affected; raw agent terminal views follow too.\n\n**Caution:** A tailing feed cannot be scrolled up — this takes scrollback away from every visible pane at once, and turning it off does not restore where you were reading.', keywords: ['tail', 'all', 'follow', 'auto-scroll', 'bulk', 'every', 'watch', 'tail all', 'tail'], // WHY no `renderedViewPolicy` even though per-session Tail has one: that // gate resolves ONE target session and checks whether it renders a feed. @@ -557,8 +561,10 @@ export const paneCommands: CommandDef[] = [ category: 'navigate', surface: 'session', title: 'Jump to Latest Message', - description: '**What it does:** Scrolls to the **latest agent message**.\n\n**Use when:** You are far up in the feed and want to return to the bottom.\n\n**Notes:** Agent panes only.', - renderedViewPolicy: { kind: 'requires-rendered-feed' }, + description: '**What it does:** Scrolls to the **latest agent message**.\n\n**Use when:** You are far up in the feed and want to return to the bottom.\n\n**Notes:** Agent panes only — in a raw terminal view this scrolls the TUI viewport to the bottom.', + // NO `renderedViewPolicy` — the xterm viewport answers jump requests too + // (useAgentTerminalFollow); gating on a rendered feed would hide this on + // the surface where returning to the bottom is most often needed. when: ({ workspace }) => { const sessionId = commandTargetSessionId(workspace) if (!sessionId) return false From 44c43c5b1aed6d628e4dafb0cb3205dbc96c017d Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 7 Sep 2026 17:06:53 -0700 Subject: [PATCH 8/9] fix(workspace): restore terminal tail position via scrollToLine and align harnesses @xterm/xterm v6 exposes buffer.active.viewportY as readonly (v5 allowed assignment), so the tail-restore path typechecks only through Terminal.scrollToLine with an explicit clamp. The submit and dimension-ownership harness mocks also needed the scroll surface (onScroll/scrollToBottom/scrollToLine) and a tailAllMode store slice: the follow wiring subscribes to viewport movement at mount, and a mock without onScroll aborts xterm init inside the leaf's try block, taking those suites down with a missing-method TypeError. --- ...nalLeaf.dimensionOwnership.renderer.test.tsx | 15 +++++++++++++++ .../AgentTerminalLeaf.follow.renderer.test.tsx | 5 +++++ .../AgentTerminalLeaf.submit.renderer.test.tsx | 17 ++++++++++++++++- .../workspace/tile-tree/agentTerminalFollow.ts | 6 +++++- 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.dimensionOwnership.renderer.test.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.dimensionOwnership.renderer.test.tsx index 294e0e91..f48d4de7 100644 --- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.dimensionOwnership.renderer.test.tsx +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.dimensionOwnership.renderer.test.tsx @@ -18,6 +18,7 @@ type MockTerminal = { rows: number container: HTMLElement | null onDataListener: ((data: string) => void) | null + onScrollListener: ((line: number) => void) | null writes: string[] dispose: ReturnType inputDispose: ReturnType @@ -43,8 +44,10 @@ vi.mock('@xterm/xterm', () => ({ container: HTMLElement | null = null writes: string[] = [] onDataListener: ((data: string) => void) | null = null + onScrollListener: ((line: number) => void) | null = null dispose = vi.fn() inputDispose = vi.fn(() => { this.onDataListener = null }) + scrollDispose = vi.fn(() => { this.onScrollListener = null }) constructor() { xtermHarness.instances.push(this) } loadAddon() {} open(container: HTMLElement) { this.container = container } @@ -52,6 +55,15 @@ vi.mock('@xterm/xterm', () => ({ this.onDataListener = listener return { dispose: this.inputDispose } } + // Follow wiring (agentTerminalFollow) subscribes to viewport movement on + // mount; these scroll surfaces exist so the ownership harness exercises + // the same Terminal API the real component consumes. + onScroll(listener: (line: number) => void) { + this.onScrollListener = listener + return { dispose: this.scrollDispose } + } + scrollToBottom() {} + scrollToLine(_line: number) {} // Real xterm reports each write parsed via the callback; the input // forwarder (#745) holds its replay latch until then, so a mock that // never calls back would model a pane that is deaf forever. @@ -82,6 +94,9 @@ vi.mock('@renderer/app-state/hooks', () => ({ dictationProvider: 'local', dictationShortcut: 'off', }, + // Read by the follow wiring in AgentTerminalLeaf; absent it would be + // undefined, which happens to behave as "off" but hides the contract. + tailAllMode: false, }), })) diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx index c1c45cb7..f51d8bef 100644 --- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx @@ -63,6 +63,11 @@ vi.mock('@xterm/xterm', () => ({ scrollToBottom = vi.fn(() => { this.buffer.active.viewportY = Math.max(0, this.buffer.active.length - this.rows) }) + // viewportY is readonly on xterm v6's public type; scrollToLine is the + // sanctioned writer. Mirrors scrollToBottom's clamping behavior. + scrollToLine = vi.fn((line: number) => { + this.buffer.active.viewportY = Math.max(0, Math.min(line, this.buffer.active.length - this.rows)) + }) dispose = vi.fn() inputDispose = vi.fn(() => { this.onDataListener = null }) scrollDispose = vi.fn(() => { this.onScrollListener = null }) diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.submit.renderer.test.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.submit.renderer.test.tsx index 51db5aa1..ec1377a6 100644 --- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.submit.renderer.test.tsx +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.submit.renderer.test.tsx @@ -14,6 +14,7 @@ type MockTerminal = Record & { rows: number container: HTMLElement | null onDataListener: ((data: string) => void) | null + onScrollListener: ((line: number) => void) | null } const xtermHarness = vi.hoisted(() => ({ @@ -31,6 +32,10 @@ const settings = vi.hoisted(() => ({ mouseModeEnabled: false, })) +// Read by the follow wiring in AgentTerminalLeaf; absent it would be +// undefined, which happens to behave as "off" but hides the contract. +const appStoreTail = vi.hoisted(() => ({ tailAllMode: false })) + vi.mock('@renderer/workspace/terminal/xtermWebglRenderer', () => ({ attachXtermWebglRenderer: xtermHarness.attachWebgl, })) @@ -44,6 +49,7 @@ vi.mock('@xterm/xterm', () => ({ onDataListener: ((data: string) => void) | null = null dispose = vi.fn() inputDispose = vi.fn(() => { this.onDataListener = null }) + scrollDispose = vi.fn(() => { this.onScrollListener = null }) constructor() { xtermHarness.instances.push(this as unknown as MockTerminal) } loadAddon() {} open(container: HTMLElement) { this.container = container } @@ -51,6 +57,15 @@ vi.mock('@xterm/xterm', () => ({ this.onDataListener = listener return { dispose: this.inputDispose } } + // Follow wiring (agentTerminalFollow) subscribes to viewport movement on + // mount; these scroll surfaces exist so the Submit harness exercises the + // same Terminal API the real component consumes. + onScroll(listener: (line: number) => void) { + this.onScrollListener = listener + return { dispose: this.scrollDispose } + } + scrollToBottom() {} + scrollToLine(_line: number) {} write(_data: string, callback?: () => void) { callback?.() } focus() {} }, @@ -62,7 +77,7 @@ vi.mock('@xterm/addon-fit', () => ({ vi.mock('@renderer/app-state/hooks', () => ({ useAppStore: (selector: (state: Record) => unknown) => - selector({ settings }), + selector({ settings: settings, tailAllMode: appStoreTail.tailAllMode }), })) vi.mock('@renderer/app-state/settings/theme', async importOriginal => ({ diff --git a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts index 1a19aaf5..98d57d21 100644 --- a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts +++ b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts @@ -96,9 +96,13 @@ export function useAgentTerminalFollow({ tailEngagedRef.current = false const saved = savedViewportYRef.current savedViewportYRef.current = null + // WHY scrollToLine and not a viewportY write: @xterm/xterm v6 exposes + // buffer.active.viewportY as readonly (v5 allowed assignment). The + // explicit clamp keeps the target inside a buffer that may have grown + // or shrunk since the position was saved. if (activeTerm && saved !== null) { const buffer = activeTerm.buffer.active - buffer.viewportY = Math.min(saved, Math.max(0, buffer.length - activeTerm.rows)) + activeTerm.scrollToLine(Math.min(saved, Math.max(0, buffer.length - activeTerm.rows))) } } }, [tailActive, termRef]) From 7e992ea450c4f091528092a39af9e03a8f852b4f Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 7 Sep 2026 18:55:34 -0700 Subject: [PATCH 9/9] fix(workspace): preserve terminal follow intent across asynchronous output Resolve Claude and Codex review findings with fire-time callback guards, deferred viewport re-pinning, session-local markers and real Electron/xterm coverage. Markers preserve content across scrollback trimming where numeric offsets and baseY arithmetic cannot. Update the execution plan and command contracts to match verified behavior. Refs #837 --- .../plans/2026-09-07-agent-terminal-follow.md | 1034 +---------------- src/renderer/src/app-state/uiShell/types.ts | 15 +- .../paneCommands.follow.renderer.test.ts | 40 +- .../workspace/commands/paneCommands.ts | 12 +- .../src/workspace/control/preferences.ts | 6 +- ...AgentTerminalLeaf.follow.renderer.test.tsx | 118 +- ...AgentTerminalLeaf.submit.renderer.test.tsx | 1 + .../workspace/tile-tree/AgentTerminalLeaf.tsx | 39 +- .../agentTerminalFollow.system.test.ts | 140 +++ .../tile-tree/agentTerminalFollow.ts | 167 ++- 10 files changed, 449 insertions(+), 1123 deletions(-) create mode 100644 src/renderer/src/workspace/tile-tree/agentTerminalFollow.system.test.ts diff --git a/docs/superpowers/plans/2026-09-07-agent-terminal-follow.md b/docs/superpowers/plans/2026-09-07-agent-terminal-follow.md index 03a7dd74..8a26f257 100644 --- a/docs/superpowers/plans/2026-09-07-agent-terminal-follow.md +++ b/docs/superpowers/plans/2026-09-07-agent-terminal-follow.md @@ -1,1006 +1,68 @@ # Agent Terminal Follow Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **For agentic workers:** Execute inline with executing-plans. The initial plan is preserved in first commit `7471ee41`; this revision records the implemented contracts and review corrections rather than retaining incorrect copy-paste recipes. -**Goal:** Make Jump to Latest and auto-follow (Tail / Tail All) work for agent sessions showing their raw terminal surface (`AgentTerminalLeaf` — OpenCode Terminal, hybrid fallback, Claude/Codex toggled to terminal view), with renderer integration tests. +**Goal:** Support Jump to Latest and per-agent/Tail All auto-follow on raw agent terminal surfaces, without affecting plain shell panes. -**Architecture:** The workspace already broadcasts follow intent through per-session runtime state (`runtime.scrollToLatestRequest`, `runtime.tailMode`) and app state (`tailAllMode`). Only the rendered `Feed` consumes it today. We add a co-located hook (`useAgentTerminalFollow`) that `AgentTerminalLeaf` uses to drive the xterm viewport, remove the `renderedViewPolicy` gate that hides the two commands on terminal surfaces, and prove behavior with renderer integration tests that drive PTY data through the real `sessionDataDispatcher`. +**Architecture:** Keep the existing runtime counter and follow flags as the source of truth. `AgentTerminalLeaf` owns the xterm lifetime; `useAgentTerminalFollow` translates intent into public viewport operations without reattaching the PTY. Tail All is masked by composed subtree visibility. -**Tech Stack:** React 18 hooks, xterm.js (`@xterm/xterm`), Zustand app store, Vitest renderer project (`happy-dom` + `@testing-library/react`). +**Tech Stack:** React 18, xterm 6, Vitest renderer and real Electron system tests. Use Node 24 as specified by `.nvmrc`. -**Worktree:** `.worktrees/agent-terminal-follow`, branch `feat/agent-terminal-follow` (this plan is the first commit on the branch). +**Tracking:** Issue #837, branch `feat/agent-terminal-follow`, worktree `.worktrees/agent-terminal-follow`. ---- +## Scope and Contracts -## Background an implementer needs +- Agent terminal surfaces only: OpenCode Terminal, Claude/Codex terminal views, and raw Hybrid fallback. No new provider and no shell-terminal follow. +- Commands remain agent-scoped but lose their rendered-feed-only gate. Never switch surfaces or acquire a rendered lease to jump/follow. +- A new jump counter scrolls to bottom; a mount/session change baselines the old counter rather than replaying it. +- Effective follow is `(runtime.tailMode || tailAllMode) && ownerVisible`. Hidden retained panes suspend forced scrolling and re-pin on reveal. +- Live writes and attach replay pin only after parsing completes. Each deferred callback checks current follow state and terminal lifetime. +- Re-pin after `onScroll` is deferred and coalesced: xterm's browser viewport suppresses reentrant scroll operations. +- Save the first visible normal-buffer line with `registerMarker(viewportY - baseY - cursorY)`. Markers track trimming; `baseY` is NOT a trim counter and stops growing when scrollback fills. +- Disengagement restores the retained marker via `scrollToLine`; an evicted marker falls back to the oldest surviving line. Do not apply a normal-buffer anchor to the alternate screen. +- Dispose markers and scroll listeners on session change/unmount. A new session cannot inherit the prior session's saved reading position. +- Alternate-screen TUIs often own their history internally. These commands control the xterm viewport, not provider-specific keybindings or internal transcript navigation. -- `AgentTerminalLeaf` (`src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx`) is the full-pane raw PTY view for agent sessions. Its xterm mount effect is **keyed on `[sessionId]` alone** and reads changing runtime state through `runtimeRef` — remounting xterm on every runtime change would lose scrollback and re-attach the PTY. New runtime-driven behavior must live in **separate effects** outside the mount effect and reach the terminal through `termRef` / refs. -- `TileLeaf`'s effective-tail mask (src/renderer/src/workspace/tile-tree/TileLeaf.tsx:206): `(runtime.tailMode || tailAllMode) && !workspaceHidden`. The terminal-surface analog of `!workspaceHidden` is `useAgentTerminalOwnerVisible()` (`src/renderer/src/workspace/terminal/AgentTerminalOwnership.tsx`) — it composes the Global-Editor-fullscreen and Reader/Spotlight/Settings retention shells. Folding visibility into the mask matters for the same reason it does in TileLeaf: a re-reveal must be a genuine false→true transition so follow re-engages. -- `Feed` semantics we are mirroring (src/renderer/src/features/feed/ui/Feed.tsx): - - Tail re-pins on any scroll while active and auto-scrolls on new entries. - - Tail is **non-destructive**: the pre-tail reading position survives and is restored on disengage (see the "WHY tailing deliberately does NOT persist" comment). -- PTY bytes reach the leaf through `subscribeToAgentPtyData` (src/renderer/src/workspace/terminal/sessionDataDispatcher.ts), which subscribes once to `window.api.onSessionAgentPtyData` and fans out by session id. Tests drive this channel by capturing the listener passed to a mocked `window.api.onSessionAgentPtyData` — that exercises the real dispatcher, real subscription, and real leaf write path. -- Both follow commands are currently **unreachable** on terminal surfaces, independently of the leaf gap: `toggle-tail` and `jump-latest-message` carry `renderedViewPolicy: { kind: 'requires-rendered-feed' }`, and `commandAllowedByRenderedViewPolicy` (src/renderer/src/workspace/agentDisplayMode.ts:150) returns false for any policy when `providerRuntime === 'terminal'` (OpenCode Terminal) and false for `requires-rendered-feed` whenever the effective surface is terminal. The `when` guard (`kind !== 'terminal'`) already excludes plain shell panes and stays. -- xterm APIs used (all real): `term.scrollToBottom()`, `term.onScroll(cb) → IDisposable`, `term.buffer.active.viewportY` (get/set), `term.buffer.active.length`, `term.rows`, `term.write(data, callback)` where `callback` fires after xterm has parsed the chunk. On alternate-screen TUIs there is no scrollback and all of this is a harmless no-op; it matters for normal-buffer output streams. -- Test harness pattern to copy: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.submit.renderer.test.tsx` (mocked `@xterm/xterm` class, `window.api` on `window`, `AgentTerminalOwnershipProvider` + `MountedAgentTerminalOwner`, deferred `attachAgentPty`, stubbed `requestAnimationFrame`/`ResizeObserver`). +## Implementation and Review -## File structure +- [x] Create dedicated branch and worktree; commit plan first. +- [x] Add jump/follow hook and `AgentTerminalLeaf` wiring, without broadening the mount effect dependencies. +- [x] Remove rendered-only policies from `toggle-tail` and `jump-latest-message`; keep shell exclusion. +- [x] Add TAIL indicator and align command/control descriptions with both agent surfaces. +- [x] Add renderer integration tests through the real session-data dispatcher, including deferred write/replay callbacks, session swaps and hidden-pane behavior. +- [x] Obtain initial independent Claude and Codex reviews via Agent Code MCP orchestration. +- [x] Fix unconditional write callback, pre-parse replay pin, synchronous reentrant scrolling, session-state leakage and tautological command guard test. +- [x] Replace incorrect numeric/baseY restoration with public xterm markers. +- [x] Add a colocated Electron system test using the installed xterm and production hook. Confirm red before the marker fix: after 100 trimmed lines, restoration returned `line-1191` instead of `line-1091`. Confirm green afterward. -- Create: `src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts` — the follow hook (jump, tail engage/disengage/restore) + `isXtermViewportAtBottom` helper. One responsibility: translating follow intent into xterm viewport calls. -- Modify: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx` — compute `tailActive`, call the hook, wire `follow.attach` + write-callback scrolling into the existing mount effect, TAIL pill in the header. -- Create: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx` — integration tests (harness + one file, tests appended per task). -- Modify: `src/renderer/src/features/workspace/commands/paneCommands.ts` — drop `renderedViewPolicy` from the two commands, update copy and comments. -- Create: `src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts` — regression test that the two commands stay surface-agnostic and shell-excluded. +## Files and Responsibilities ---- +- `src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts`: session-local intent, marker lifetime and deferred viewport re-pin. +- `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx`: effective follow, write/replay completion guards and header status. +- `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx`: component/dispatcher/lifecycle regression tests; fake Terminal is not authoritative about trimming. +- `src/renderer/src/workspace/tile-tree/agentTerminalFollow.system.test.ts`: real Chromium/xterm re-pin, content restoration after trimming, eviction fallback, jump and alternate-buffer coverage; isolated temporary userData and no provider processes. +- `src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts`: actual kind-guard invocation using a focused-tab fixture, plus rendered-policy regression guard. +- `src/renderer/src/features/workspace/commands/paneCommands.ts`, `workspace/control/preferences.ts`, `app-state/uiShell/types.ts`: truthful user/operator help and ownership comments. +- Existing submit and dimension-ownership test mocks: add the real public scroll API consumed by the component, without weakening production behavior. -### Task 1: Set up the worktree and verify a clean baseline +## Verification and Delivery -**Files:** none (verification only) - -- [ ] **Step 1: Install dependencies (includes submodules for aliases + electron-rebuild for node-pty)** - -Run in `.worktrees/agent-terminal-follow`: -```bash -git submodule update --init --recursive && npm install -``` -Expected: exit 0. (`npm install` runs `electron-rebuild -f -w node-pty`; renderer tests do not load node-pty, so a rebuild warning is tolerable, a hard failure is not.) - -- [ ] **Step 2: Run the existing AgentTerminalLeaf renderer tests** - -```bash -npm run test:renderer -- AgentTerminalLeaf.submit -``` -Expected: PASS (3 tests). If this fails, stop and report — the plan's harness is modeled on this file. - -- [ ] **Step 3: Typecheck baseline** +Run from this worktree with Node 24 on PATH. Do not pipe test commands through truncation filters that hide their exit status. ```bash npm run typecheck +npm run test:renderer -- AgentTerminalLeaf paneCommands.follow +npm run test:system -- agentTerminalFollow +npm run test:contract +npm run check:keybindings +npm test -- --maxWorkers=4 --testTimeout=15000 +git diff --check ``` -Expected: exit 0. - ---- - -### Task 2: Follow hook — Jump to Latest - -**Files:** -- Create: `src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts` -- Create: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx` -- Modify: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx` - -- [ ] **Step 1: Write the failing tests (full harness + jump tests)** - -Create `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx`: - -```tsx -import { act, cleanup, render, screen } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { emptyRuntime } from '@renderer/session-runtime/state' -import type { SessionRuntime } from '@renderer/session-runtime/state' -import { - AgentTerminalOwnershipProvider, - AgentTerminalOwnerVisibilityProvider, - MountedAgentTerminalOwner, -} from '@renderer/workspace/terminal/AgentTerminalOwnership' -import type { Workspace } from '@renderer/workspace/workspaceStore' -import { AgentTerminalLeaf } from './AgentTerminalLeaf' - -// Integration harness for follow behavior (jump-to-latest + tail) on the raw -// agent terminal surface. Modeled on AgentTerminalLeaf.submit.renderer.test.tsx -// with three deltas: the mocked Terminal grows scroll surface area -// (scrollToBottom / onScroll / buffer viewport), the app-store mock is MUTABLE -// so tests can flip tailAllMode like the real store would, and the agent PTY -// channel listener is captured so tests push bytes through the REAL -// sessionDataDispatcher fanout instead of calling leaf internals. -type MockTerminal = { - rows: number - buffer: { active: { viewportY: number; length: number } } - scrollToBottom: ReturnType - onScrollListener: ((line: number) => void) | null -} - -const xtermHarness = vi.hoisted(() => ({ - cols: 120, - rows: 40, - instances: [] as MockTerminal[], - attachWebgl: vi.fn(), - fit: vi.fn(), -})) - -const appStore = vi.hoisted(() => ({ - settings: { - dictationEnabled: false, - dictationProvider: 'local', - dictationShortcut: 'off', - mouseModeEnabled: false, - }, - tailAllMode: false, -})) - -vi.mock('@renderer/workspace/terminal/xtermWebglRenderer', () => ({ - attachXtermWebglRenderer: xtermHarness.attachWebgl, -})) - -vi.mock('@xterm/xterm', () => ({ - Terminal: class { - cols = xtermHarness.cols - rows = xtermHarness.rows - options: Record = {} - container: HTMLElement | null = null - onDataListener: ((data: string) => void) | null = null - onScrollListener: ((line: number) => void) | null = null - buffer = { active: { viewportY: 0, length: 1 } } - // scrollToBottom intentionally does NOT emit onScroll: real xterm does, - // but our handler then sees an at-bottom viewport and no-ops, so the mock - // keeps call counts deterministic. Re-pin behavior is tested by invoking - // the registered onScroll listener directly. - scrollToBottom = vi.fn(() => { - this.buffer.active.viewportY = Math.max(0, this.buffer.active.length - this.rows) - }) - dispose = vi.fn() - inputDispose = vi.fn(() => { this.onDataListener = null }) - scrollDispose = vi.fn(() => { this.onScrollListener = null }) - constructor() { xtermHarness.instances.push(this as unknown as MockTerminal) } - loadAddon() {} - open(container: HTMLElement) { this.container = container } - onData(listener: (data: string) => void) { - this.onDataListener = listener - return { dispose: this.inputDispose } - } - onScroll(listener: (line: number) => void) { - this.onScrollListener = listener - return { dispose: this.scrollDispose } - } - write(_data: string, callback?: () => void) { callback?.() } - focus() {} - }, -})) - -vi.mock('@xterm/addon-fit', () => ({ - FitAddon: class { fit() { xtermHarness.fit() } }, -})) - -vi.mock('@renderer/app-state/hooks', () => ({ - useAppStore: (selector: (state: typeof appStore) => unknown) => selector(appStore), -})) - -vi.mock('@renderer/app-state/settings/theme', async importOriginal => ({ - ...(await importOriginal>()), - THEME_CHANGED_EVENT: 'agent-code:test-theme-change', - getActiveAppFontFamily: () => 'monospace', -})) - -vi.mock('@renderer/workspace/tile-tree/xtermTheme', () => ({ - readXtermTheme: () => ({}), - syncXtermTheme: () => {}, -})) - -vi.mock('@renderer/workspace/tile-tree/TileLeaf/useComposerDictation', () => ({ - useComposerDictation: () => {}, -})) - -type Deferred = { promise: Promise; resolve: (value: T) => void } -function deferred(): Deferred { - let resolve!: (value: T) => void - const promise = new Promise(done => { resolve = done }) - return { promise, resolve } -} - -type PtyEvent = { sessionId: string; data: string } - -describe('AgentTerminalLeaf follow (jump-to-latest + tail)', () => { - let attach: Deferred - let nextFrameId: number - let frames: Map - let ptyListener: ((event: PtyEvent) => void) | null = null - const api = { - attachAgentPty: vi.fn((_id: string) => attach.promise), - detachAgentPty: vi.fn().mockResolvedValue(undefined), - onSessionAgentPtyData: vi.fn((listener: (event: PtyEvent) => void) => { - ptyListener = listener - return () => { ptyListener = null } - }), - onSessionTerminalData: vi.fn(() => () => {}), - resize: vi.fn().mockResolvedValue(undefined), - sendInput: vi.fn().mockResolvedValue(undefined), - } - const workspace = { - acknowledgeSession: vi.fn(), - ensureSessionLive: vi.fn().mockResolvedValue(undefined), - showPaneToast: vi.fn(), - } as unknown as Workspace - - const runtimeWith = (patch: Partial): SessionRuntime => ({ - ...emptyRuntime(), - processStatus: 'started', - ...patch, - }) - - function leaf(runtime: SessionRuntime = runtimeWith({})) { - return ( - - - {}} - workspace={workspace} - runtime={runtime} - projectDir="/tmp/project" - provider="codex" - /> - - - ) - } - - function flushAnimationFrames() { - const pending = [...frames.values()] - frames.clear() - for (const callback of pending) callback(performance.now()) - } - - async function attachResolved(buffer = '') { - act(() => flushAnimationFrames()) - await act(async () => { - attach.resolve(buffer) - await attach.promise - }) - } - - function term(): MockTerminal { - return xtermHarness.instances[0] - } - - beforeEach(() => { - appStore.tailAllMode = false - attach = deferred() - nextFrameId = 0 - frames = new Map() - ptyListener = null - xtermHarness.fit.mockClear() - xtermHarness.instances.length = 0 - xtermHarness.attachWebgl.mockReset() - xtermHarness.attachWebgl.mockImplementation(() => ({ - ready: Promise.resolve(true), - dispose: vi.fn(), - })) - api.attachAgentPty.mockReset().mockImplementation(() => attach.promise) - api.detachAgentPty.mockClear() - - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - const id = ++nextFrameId - frames.set(id, callback) - return id - }) - vi.stubGlobal('cancelAnimationFrame', (id: number) => { frames.delete(id) }) - vi.stubGlobal('ResizeObserver', class { - disconnect = vi.fn() - observe() {} - unobserve() {} - }) - Object.defineProperty(window, 'api', { configurable: true, value: api }) - }) - - afterEach(() => { - cleanup() - Reflect.deleteProperty(window, 'api') - vi.unstubAllGlobals() - }) - - it('ignores the pre-existing jump request baseline on mount', async () => { - render(leaf(runtimeWith({ scrollToLatestRequest: 3 }))) - await attachResolved() - expect(term().scrollToBottom).not.toHaveBeenCalled() - }) - - it('scrolls the xterm viewport once when a new jump-to-latest request arrives', async () => { - const view = render(leaf(runtimeWith({ scrollToLatestRequest: 3 }))) - await attachResolved() - act(() => { view.rerender(leaf(runtimeWith({ scrollToLatestRequest: 4 }))) }) - expect(term().scrollToBottom).toHaveBeenCalledTimes(1) - }) -}) -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -npm run test:renderer -- AgentTerminalLeaf.follow -``` -Expected: FAIL — the rerendered request does nothing; first test may pass vacuously (it is the guard against over-triggering, the second is the red one). - -- [ ] **Step 3: Implement the hook (jump path only)** - -Create `src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts`: - -```ts -import { useEffect, useMemo, useRef } from 'react' -import type { RefObject } from 'react' -import type { Terminal } from '@xterm/xterm' - -// Follow behavior for raw agent terminal surfaces (AgentTerminalLeaf) — the -// xterm counterpart of what Feed does for the rendered surface: -// - Jump to Latest: the workspace bumps `runtime.scrollToLatestRequest` -// whenever the user asks to return to the bottom (palette command, prompt -// send). Feed scrolls its DOM scroller; a raw pane scrolls the xterm -// viewport instead. Nothing consumed this counter on the terminal surface -// before, so the command silently did nothing there. -// - Tail (auto-follow): mirrors Feed's semantics — pin to bottom while -// active, re-pin if the user scrolls away, and restore the pre-tail -// viewport line on disengage so following is non-destructive. Feed -// protects the saved position for the same reason (see Feed.tsx "WHY -// tailing deliberately does NOT persist"). -// -// WHY a hook instead of inline effects in AgentTerminalLeaf: the leaf's xterm -// mount effect is deliberately keyed on [sessionId] alone (remounting xterm on -// every runtime change would lose scrollback and re-attach the PTY), so -// runtime-driven behavior must live outside that effect and reach the terminal -// through refs. Collecting it here also gives the renderer tests one unit to -// target. The hook MUST be called before the leaf's mount effect — see the -// wiring comment in AgentTerminalLeaf. - -/** Viewport is at bottom when its top line plus rows covers the buffer. */ -export function isXtermViewportAtBottom(term: Terminal): boolean { - const buffer = term.buffer.active - return buffer.viewportY >= buffer.length - term.rows -} - -type FollowArgs = { - /** Live runtime counter; every increment is one jump-to-latest request. */ - scrollToLatestRequest: number - /** Computed tail verdict (per-session Tail OR Tail All, masked by visibility). */ - tailActive: boolean - /** The leaf's terminal ref; null until the mount effect creates xterm. */ - termRef: RefObject -} - -export type AgentTerminalFollowHandle = { - /** Tail verdict for the PTY write path inside the leaf's mount effect. */ - readonly tailActiveRef: Readonly<{ current: boolean }> - /** Wire re-pin-on-user-scroll to a freshly created Terminal instance. */ - attach: (term: Terminal) => () => void -} - -export function useAgentTerminalFollow({ - scrollToLatestRequest, - tailActive, - termRef, -}: FollowArgs): AgentTerminalFollowHandle { - // WHY render-time assignment (mirroring runtimeRef in AgentTerminalLeaf): - // the PTY subscriber in the mount effect reads this ref at IPC-event time, - // long after any effect ordering, and the mount effect itself must never - // re-run for follow-state changes. - const tailActiveRef = useRef(tailActive) - tailActiveRef.current = tailActive - - // Jump to Latest. WHY a baseline ref: the counter can already be non-zero - // from the session's rendered-surface life, and remounting the pane must - // not replay an old request against a fresh xterm — the attach replay - // already leaves a fresh terminal at the bottom. - const jumpBaselineRef = useRef(null) - useEffect(() => { - if (jumpBaselineRef.current === null) { - jumpBaselineRef.current = scrollToLatestRequest - return - } - if (scrollToLatestRequest === jumpBaselineRef.current) return - jumpBaselineRef.current = scrollToLatestRequest - termRef.current?.scrollToBottom() - }, [scrollToLatestRequest, termRef]) - - // Stable handle: the leaf's mount effect is keyed on [sessionId] and must - // not be invalidated by follow-state churn. - return useMemo(() => ({ - tailActiveRef, - attach: _term => () => {}, - }), []) -} -``` - -(`attach` is a placeholder returning a no-op disposer until Task 5 wires `onScroll`; the signature is final.) - -- [ ] **Step 4: Wire the hook into AgentTerminalLeaf** - -In `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx`: - -Add the import with the other tile-tree imports: - -```ts -import { useAgentTerminalFollow } from '@renderer/workspace/tile-tree/agentTerminalFollow' -``` - -After `const ownerVisible = useAgentTerminalOwnerVisible()` (currently line ~86) add: - -```tsx -const tailAllMode = useAppStore(state => state.tailAllMode) -// Feed-parity tail mask (TileLeaf's effectiveTailMode): per-session Tail OR -// Tail All, suppressed while this subtree is hidden (editor fullscreen / -// Reader/Spotlight/Settings takeover) — a display:none pane cannot scroll, -// and folding visibility into the mask makes re-reveal a genuine transition -// that re-engages follow. -const tailActive = (runtime.tailMode || tailAllMode) && ownerVisible -// WHY this hook must be called BEFORE the xterm mount effect below: its -// effects read termRef.current at effect time and React runs passive effects -// in declaration order — when tail is already on at mount, the terminal does -// not exist yet, which is exactly the "nothing to restore" case. -const follow = useAgentTerminalFollow({ - scrollToLatestRequest: runtime.scrollToLatestRequest, - tailActive, - termRef, -}) -``` - -In the mount effect, after `termRef.current = term` (currently line ~242) add: - -```ts -const offFollowAttach = follow.attach(term) -``` - -In the mount-effect cleanup, next to `onDataDisposable?.dispose()` add: - -```ts -offFollowAttach() -``` - -(The mount effect's dep array stays `[sessionId]` — `follow` is a stable `useMemo` handle, so closing over it does not invalidate the keying; this mirrors the existing refs-not-deps rationale in the "WHY this goes through refs" comment.) - -- [ ] **Step 5: Run the tests to verify they pass** - -```bash -npm run test:renderer -- AgentTerminalLeaf.follow -``` -Expected: PASS (2 tests). - -- [ ] **Step 6: Commit** - -```bash -git add src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx -git commit -m "feat(workspace): honor jump-to-latest on agent terminal surfaces" -``` - ---- - -### Task 3: Tail engage, disengage, and non-destructive restore - -**Files:** -- Modify: `src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts` -- Modify: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx` - -- [ ] **Step 1: Write the failing tests** — append inside the `describe` block: - -```tsx -describe('tail engage/disengage', () => { - it('pins to bottom on engage and restores the pre-tail viewport line on disengage', async () => { - const view = render(leaf()) - await attachResolved() - term().buffer.active.length = 500 - term().buffer.active.viewportY = 100 // user scrolled up - act(() => { view.rerender(leaf(runtimeWith({ tailMode: true }))) }) - expect(term().scrollToBottom).toHaveBeenCalled() - expect(term().buffer.active.viewportY).toBe(460) // 500 - rows(40) - act(() => { view.rerender(leaf(runtimeWith({ tailMode: false }))) }) - expect(term().buffer.active.viewportY).toBe(100) - }) - - it('keeps the bottom on disengage when tail engaged at the bottom', async () => { - const view = render(leaf()) - await attachResolved() - term().buffer.active.length = 500 - term().buffer.active.viewportY = 460 // at bottom - act(() => { view.rerender(leaf(runtimeWith({ tailMode: true }))) }) - act(() => { view.rerender(leaf(runtimeWith({ tailMode: false }))) }) - expect(term().buffer.active.viewportY).toBe(460) - }) - - it('does not restore when tail was already on at mount (fresh terminal)', async () => { - const view = render(leaf(runtimeWith({ tailMode: true }))) - await attachResolved() - term().buffer.active.length = 500 - term().buffer.active.viewportY = 460 // user sat at the bottom - act(() => { view.rerender(leaf(runtimeWith({ tailMode: false }))) }) - // Engage happened before xterm existed — nothing was saved, disengage - // must not invent a position and yank the user to the top. - expect(term().buffer.active.viewportY).toBe(460) - }) -}) -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -npm run test:renderer -- AgentTerminalLeaf.follow -``` -Expected: FAIL — engage does nothing yet (all three: no pin, no restore). - -- [ ] **Step 3: Implement engage/disengage in the hook** - -Add after the jump effect in `agentTerminalFollow.ts`: - -```ts -// Tail engage/disengage. Non-destructive like Feed: only a viewport that was -// genuinely scrolled up has a position worth restoring; engaging while at -// bottom saves nothing and disengage leaves the bottom. On mount with tail -// already on, this effect runs before xterm exists (declaration order — see -// the leaf wiring), so nothing is saved and disengage keeps the bottom the -// attach replay left us at. -const tailEngagedRef = useRef(false) -const savedViewportYRef = useRef(null) -useEffect(() => { - const activeTerm = termRef.current - if (tailActive && !tailEngagedRef.current) { - tailEngagedRef.current = true - if (activeTerm) { - savedViewportYRef.current = isXtermViewportAtBottom(activeTerm) - ? null - : activeTerm.buffer.active.viewportY - activeTerm.scrollToBottom() - } - return - } - if (!tailActive && tailEngagedRef.current) { - tailEngagedRef.current = false - const saved = savedViewportYRef.current - savedViewportYRef.current = null - if (activeTerm && saved !== null) { - const buffer = activeTerm.buffer.active - buffer.viewportY = Math.min(saved, Math.max(0, buffer.length - activeTerm.rows)) - } - } -}, [tailActive, termRef]) -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -```bash -npm run test:renderer -- AgentTerminalLeaf.follow -``` -Expected: PASS (5 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx -git commit -m "feat(workspace): engage and restore tail follow on agent terminal surfaces" -``` - ---- - -### Task 4: Follow PTY output (write path) and pin after attach replay - -**Files:** -- Modify: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx` -- Modify: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx` - -- [ ] **Step 1: Write the failing tests** — append inside the top-level `describe` (after the jump tests): - -```tsx -it('follows PTY output through the dispatcher while per-session tail is on', async () => { - render(leaf(runtimeWith({ tailMode: true }))) - await attachResolved() - term().buffer.active.length = 500 - act(() => { ptyListener?.({ sessionId: 'session-1', data: 'stream' }) }) - expect(term().scrollToBottom).toHaveBeenCalled() - expect(term().buffer.active.viewportY).toBe(460) -}) - -it('pins after the attach replay when tail is on', async () => { - render(leaf(runtimeWith({ tailMode: true }))) - await attachResolved('backfill') - // Tail engaged before xterm existed, so the pin has to come from the - // post-replay moment in tryAttach — proving that branch ran. - expect(term().scrollToBottom).toHaveBeenCalled() -}) - -it('leaves the viewport alone on PTY output while tail is off', async () => { - render(leaf()) - await attachResolved() - term().buffer.active.viewportY = 10 - term().buffer.active.length = 500 - act(() => { ptyListener?.({ sessionId: 'session-1', data: 'stream' }) }) - expect(term().scrollToBottom).not.toHaveBeenCalled() - expect(term().buffer.active.viewportY).toBe(10) -}) -``` - -Note: `ptyListener` is captured in `beforeEach` via the `onSessionAgentPtyData` mock — these tests push bytes through the real `sessionDataDispatcher`, not a leaf callback. - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -npm run test:renderer -- AgentTerminalLeaf.follow -``` -Expected: FAIL — first two (no follow, no post-replay pin); third passes vacuously. - -- [ ] **Step 3: Implement the write path in the leaf's mount effect** - -In `AgentTerminalLeaf.tsx`, replace the live-write branch of the PTY subscriber (currently `term?.write(data)`): - -```ts -offPtyData = subscribeToAgentPtyData(sessionId, data => { - if (!attachedBackfillDone) { - backlogQueue.push(data) - if (backlogQueue.length > 256) backlogQueue.splice(0, backlogQueue.length - 256) - return - } - // Tail scrolls in the write completion callback: xterm parses chunks - // asynchronously, so scrolling synchronously would target the pre-parse - // bottom and land one chunk early. - const liveTerm = term - if (follow.tailActiveRef.current) { - liveTerm?.write(data, () => liveTerm.scrollToBottom()) - } else { - liveTerm?.write(data) - } -}) -``` - -And inside `tryAttach`, immediately after `attachedBackfillDone = true`: - -```ts -// A fresh terminal follows its replay by default, but engage-while-mounted -// (or Tail All flipping during a remount) wants the pin explicit once the -// backfill exists — the replay itself does not go through the write path. -if (follow.tailActiveRef.current) liveTerm.scrollToBottom() -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -```bash -npm run test:renderer -- AgentTerminalLeaf.follow -``` -Expected: PASS (8 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx -git commit -m "feat(workspace): tail agent terminal output during PTY writes" -``` - ---- - -### Task 5: Re-pin on user scroll + TAIL pill - -**Files:** -- Modify: `src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts` -- Modify: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx` -- Modify: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx` - -- [ ] **Step 1: Write the failing tests** — append inside the top-level `describe`: - -```tsx -it('re-pins when the user scrolls away while tailing', async () => { - render(leaf(runtimeWith({ tailMode: true }))) - await attachResolved() - term().buffer.active.length = 500 - term().buffer.active.viewportY = 200 // user wheel-scrolled up - act(() => { term().onScrollListener?.(200) }) - expect(term().scrollToBottom).toHaveBeenCalled() - expect(term().buffer.active.viewportY).toBe(460) -}) - -it('does not re-pin on scroll while tail is off', async () => { - render(leaf()) - await attachResolved() - term().buffer.active.length = 500 - term().buffer.active.viewportY = 100 - act(() => { term().onScrollListener?.(100) }) - expect(term().scrollToBottom).not.toHaveBeenCalled() - expect(term().buffer.active.viewportY).toBe(100) -}) - -it('shows the TAIL pill in the header while tail is active', async () => { - const view = render(leaf()) - await attachResolved() - expect(screen.queryByText('TAIL')).toBeNull() - act(() => { view.rerender(leaf(runtimeWith({ tailMode: true }))) }) - expect(screen.getByText('TAIL')).toBeTruthy() -}) -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -npm run test:renderer -- AgentTerminalLeaf.follow -``` -Expected: FAIL — no `onScroll` wiring (first test), no pill (third). Second passes vacuously. - -- [ ] **Step 3: Implement re-pin in the hook** - -Replace the placeholder `attach` in the `useMemo` handle of `agentTerminalFollow.ts`: - -```ts -return useMemo(() => ({ - tailActiveRef, - attach: mountedTerm => { - // Feed re-pins on the scroll event itself. scrollToBottom also fires - // onScroll, but the handler then sees an at-bottom viewport and no-ops, - // so the loop self-terminates. Mouse-mode TUIs forward wheel events to - // the app instead of xterm scrollback, so this only acts on genuine - // viewport movement. - const disposable = mountedTerm.onScroll(() => { - if (!tailActiveRef.current) return - if (isXtermViewportAtBottom(mountedTerm)) return - mountedTerm.scrollToBottom() - }) - return () => disposable.dispose() - }, -}), []) -``` - -- [ ] **Step 4: Implement the TAIL pill in the leaf header** - -In `AgentTerminalLeaf.tsx`, replace the header's right-side span (currently the single `terminal view` span): - -```tsx -
- {tailActive ? ( - - TAIL - - ) : null} - - terminal view - -
-``` - -(Styling copied from the TAIL pill in `TileLeaf/ScrollIndicator.tsx` so both surfaces read identically.) - -- [ ] **Step 5: Run the tests to verify they pass** - -```bash -npm run test:renderer -- AgentTerminalLeaf.follow -``` -Expected: PASS (11 tests). - -- [ ] **Step 6: Commit** - -```bash -git add src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx -git commit -m "feat(workspace): re-pin tailed agent terminals and show TAIL pill" -``` - ---- - -### Task 6: Tail All + visibility-mask integration tests - -Behavior should already hold (the mask `(runtime.tailMode || tailAllMode) && ownerVisible` landed in Task 2). These tests pin it. - -**Files:** -- Modify: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx` - -- [ ] **Step 1: Append the tests** — inside the top-level `describe`: - -```tsx -it('follows output when Tail All is on', async () => { - appStore.tailAllMode = true - render(leaf()) - await attachResolved() - expect(screen.getByText('TAIL')).toBeTruthy() - term().buffer.active.length = 500 - act(() => { ptyListener?.({ sessionId: 'session-1', data: 'stream' }) }) - expect(term().buffer.active.viewportY).toBe(460) -}) - -it('stays inert for Tail All while the pane subtree is hidden', async () => { - appStore.tailAllMode = true - render( - - {leaf()} - , - ) - await attachResolved() - // Masked: no pill, no forced scroll on output — mirroring TileLeaf's - // re-reveal argument for folding visibility into the tail mask. - expect(screen.queryByText('TAIL')).toBeNull() - term().buffer.active.length = 500 - term().buffer.active.viewportY = 10 - act(() => { ptyListener?.({ sessionId: 'session-1', data: 'stream' }) }) - expect(term().scrollToBottom).not.toHaveBeenCalled() - expect(term().buffer.active.viewportY).toBe(10) -}) -``` - -- [ ] **Step 2: Run the tests** - -```bash -npm run test:renderer -- AgentTerminalLeaf.follow -``` -Expected: PASS (13 tests). If either fails, the mask computation in `AgentTerminalLeaf.tsx` deviates from TileLeaf's — fix the mask, not the test. - -- [ ] **Step 3: Commit** - -```bash -git add src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx -git commit -m "test(workspace): cover Tail All and visibility masking for agent terminals" -``` - ---- - -### Task 7: Surface the follow commands on terminal views + copy updates - -**Files:** -- Modify: `src/renderer/src/features/workspace/commands/paneCommands.ts` -- Create: `src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts` - -- [ ] **Step 1: Write the failing regression test** - -Create `src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts`: - -```ts -import { describe, expect, it } from 'vitest' - -import { paneCommands } from '@renderer/features/workspace/commands/paneCommands' - -// Guards the command-availability half of terminal follow: both commands -// previously carried `renderedViewPolicy: 'requires-rendered-feed'`, which -// `commandAllowedByRenderedViewPolicy` resolves to false on ANY terminal -// surface — and unconditionally for OpenCode Terminal sessions -// (providerRuntime === 'terminal'). Someone re-adding the policy "for -// consistency" would silently uninstall the commands from raw terminal views -// again while the leaf-side behavior stays green. -// -// WHY this does not exercise `when`: the kind guards route through -// `commandTargetSessionId`, which needs a much larger workspace-state shape -// (tab/dispatch focus) than a unit fixture should fake. This task does not -// touch `when`; its behavior is owned by the existing command suites. - -describe('follow command availability', () => { - const tail = paneCommands.find(command => command.id === 'toggle-tail') - const jump = paneCommands.find(command => command.id === 'jump-latest-message') - - it('exposes both follow commands without a rendered-view policy', () => { - expect(tail).toBeDefined() - expect(jump).toBeDefined() - expect(tail!.renderedViewPolicy).toBeUndefined() - expect(jump!.renderedViewPolicy).toBeUndefined() - }) - - it('keeps both commands shell-excluded through a kind guard', () => { - // The `when` guards (kind !== 'terminal') are what keep plain shells out; - // assert they exist so removing the policy cannot silently remove them. - expect(typeof tail!.when).toBe('function') - expect(typeof jump!.when).toBe('function') - }) -}) -``` - -- [ ] **Step 2: Run the test to verify it fails** - -```bash -npm run test:renderer -- paneCommands.follow -``` -Expected: FAIL on `renderedViewPolicy` being defined. - -- [ ] **Step 3: Update `paneCommands.ts`** - -`toggle-tail` (around line 486): replace the description, drop the policy line, and update the `when` comment. The block - -```ts - description: '**What it does:** Toggles feed **auto-follow** for the focused target.\n\n**Use when:** You want output to stay pinned to the bottom.\n\n**Notes:** Applies to the visible command target, including **Dispatch** selection.', - renderedViewPolicy: { kind: 'requires-rendered-feed' }, -``` - -becomes - -```ts - description: '**What it does:** Toggles **auto-follow** for the focused target.\n\n**Use when:** You want output to stay pinned to the bottom.\n\n**Notes:** Applies to the visible command target, including **Dispatch** selection. Works in both the rendered feed and raw agent terminal views — in a terminal view the TUI output stays pinned to the bottom.', - // NO `renderedViewPolicy` — deliberately: this command owns follow - // behavior on BOTH agent surfaces now (Feed's tailMode on the rendered - // surface, useAgentTerminalFollow on the raw terminal). The old - // 'requires-rendered-feed' gate hid it on terminal surfaces, where - // following is exactly as meaningful. -``` - -and the `when` comment - -```ts - // WHY tail is agent-only even though terminals are Dispatch rows: - // tailMode controls the rendered transcript/feed scroll container. - // Terminal panes delegate scrollback to xterm.js, so toggling this - // runtime flag on a terminal would present a command that appears to - // work while changing nothing visible. - return workspace.state.sessions[sessionId]?.kind !== 'terminal' -``` - -becomes - -```ts - // WHY tail is agent-only even though plain shells are Dispatch rows: - // agent sessions consume tailMode on both of their surfaces since the - // terminal-follow work (useAgentTerminalFollow). Plain shell terminals - // (kind === 'terminal') delegate entirely to xterm scrollback and have - // no tail state. - return workspace.state.sessions[sessionId]?.kind !== 'terminal' -``` - -`toggle-tail-all` (around line 541): in the description, replace - -``` -Terminals are never affected. -``` - -with - -``` -Plain shell terminals are never affected; raw agent terminal views follow too. -``` - -`jump-latest-message` (around line 556): drop the policy line and update the notes. The block - -```ts - description: '**What it does:** Scrolls to the **latest agent message**.\n\n**Use when:** You are far up in the feed and want to return to the bottom.\n\n**Notes:** Agent panes only.', - renderedViewPolicy: { kind: 'requires-rendered-feed' }, -``` - -becomes - -```ts - description: '**What it does:** Scrolls to the **latest agent message**.\n\n**Use when:** You are far up in the feed and want to return to the bottom.\n\n**Notes:** Agent panes only — in a raw terminal view this scrolls the TUI viewport to the bottom.', - // NO `renderedViewPolicy` — the xterm viewport answers jump requests too - // (useAgentTerminalFollow); gating on a rendered feed would hide this on - // the surface where returning to the bottom is most often needed. -``` - -- [ ] **Step 4: Run the test to verify it passes** - -```bash -npm run test:renderer -- paneCommands.follow -``` -Expected: PASS (2 tests). - -- [ ] **Step 5: Run the command-related checks (copy changed)** - -```bash -npm run check:contract && npm run check:keybindings -``` -Expected: both exit 0. If the contract checker objects to the removed fields, read its output — it is the authority on command-def invariants. - -- [ ] **Step 6: Commit** - -```bash -git add src/renderer/src/features/workspace/commands/paneCommands.ts src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts -git commit -m "feat(workspace): surface follow commands on raw agent terminal views" -``` - ---- - -### Task 8: Full verification sweep - -**Files:** none - -- [ ] **Step 1: Typecheck** - -```bash -npm run typecheck -``` -Expected: exit 0. - -- [ ] **Step 2: Full renderer suite** - -```bash -npm run test:renderer -``` -Expected: PASS, zero failures — in particular `commandState.test.ts`, `preferences.renderer.test.tsx`, and `agentDisplayMode.test.ts` (policy semantics were touched indirectly). - -- [ ] **Step 3: Unit + system suites** - -```bash -npm run test:unit && npm run test:system -``` -Expected: PASS. - -- [ ] **Step 4: Review the final diff** - -```bash -git diff main --stat && git log --oneline main.. -``` -Expected: only the files listed in this plan; conventional-commit subjects; plan file is the first commit. -- [ ] **Step 5: Report and wait** +Earlier Node 25 full-suite runs hit `localStorage.setItem` failures also seen in the other checkout; they are not evidence of a clean Node 24 baseline. A Node 24 renderer run passed 548 tests with one unrelated cold-transform timeout. Record fresh results on the final integrated revision, not inferred counts or test-name-only comparisons. -Report the final state (tests run, results, remaining risks — e.g. alternate-screen TUIs make follow a visual no-op by design; behavior verified against mocked xterm scroll APIs, not a real canvas) and wait for explicit user confirmation before opening a PR. Do not merge. +- [ ] Commit the review corrections and integrate current `main` without overwriting concurrent work. +- [ ] Re-run targeted and full checks under the project Node version; report any genuine baseline failures separately. +- [ ] Get both existing reviewers to recheck the final revision, including the real-xterm evidence. +- [ ] Open a fully described PR linked with `Fixes #837`; keep review findings and verification synchronized there. +- [ ] Wait for CI, resolve valid findings, then merge using the user's explicit review-then-merge authorization. Never bypass failing checks or merge an unreviewed revision. diff --git a/src/renderer/src/app-state/uiShell/types.ts b/src/renderer/src/app-state/uiShell/types.ts index cfc104af..291c5053 100644 --- a/src/renderer/src/app-state/uiShell/types.ts +++ b/src/renderer/src/app-state/uiShell/types.ts @@ -198,7 +198,7 @@ export type UiShellState = { * leaving click interception active after restart would make the app appear * broken, and the captured inputs may contain developer-sensitive payloads. */ renderingDebugMode: boolean - /** Workspace-wide feed auto-follow. While true, every *visible* agent pane + /** Workspace-wide agent auto-follow. While true, every *visible* agent pane * tails, and a pane that becomes visible later (a new lane selection, a tab * switch, a fresh split) is already tailing without the user re-running the * command. This is a stance on the workspace, not a batch operation. @@ -213,13 +213,12 @@ export type UiShellState = { * WHY there is no "which panes are visible" query behind this: there is no * canonical visible-session selector in this codebase (`resolveTabSessions` * answers membership, not visibility, and says so in its own header). The OR - * is resolved inside `TileLeaf`, which with one exception mounts only for - * on-screen panes — so grid tab-scoping, dispatch lanes, duplicate tiled - * lanes, and terminal exclusion all fall out of what React already mounts. - * The exception is Global Editor fullscreen, which keeps the workspace - * mounted under `display: 'none'`; the full reasoning for why that is - * tolerable lives at the OR site in TileLeaf.tsx. Reader Mode is a takeover - * and mounts no TileLeaf at all, so Tail All is inert there. See + * is resolved inside `TileLeaf` and `AgentTerminalLeaf`, each masked by + * subtree visibility. Grid tabs and Dispatch lanes therefore follow without + * a second visibility enumeration. Plain shell TerminalLeaf never reads it. + * Global Editor fullscreen and Reader/Spotlight/Settings retain hidden + * workspace subtrees; their composed mask suspends follow until re-reveal. + * The full rationale lives at the OR site in TileLeaf.tsx. See * docs/superpowers/plans/2026-07-20-tail-all.md before replacing this with an * enumeration; the enumeration has at least four documented ways to be wrong. * diff --git a/src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts b/src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts index ea0f4c1b..2a4c2eab 100644 --- a/src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts +++ b/src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' +import type { CommandContext } from '@renderer/features/command-palette/types' import { paneCommands } from '@renderer/features/workspace/commands/paneCommands' // Guards the command-availability half of terminal follow: both commands @@ -9,11 +10,27 @@ import { paneCommands } from '@renderer/features/workspace/commands/paneCommands // (providerRuntime === 'terminal'). Someone re-adding the policy "for // consistency" would silently uninstall the commands from raw terminal views // again while the leaf-side behavior stays green. -// -// WHY this does not exercise `when`: the kind guards route through -// `commandTargetSessionId`, which needs a much larger workspace-state shape -// (tab/dispatch focus) than a unit fixture should fake. This task does not -// touch `when`; its behavior is owned by the existing command suites. + +// The state shape `commandTargetSessionIdForState` needs to resolve a target +// (mirror of contextWithAgent() in sessionCommands.renderer.test.ts — active +// tab with a focused leaf). Without it the selector returns null and `when` +// answers false for every kind, which would make this guard vacuous. +function contextWithKind(kind: string): CommandContext { + return { + workspace: { + state: { + activeTabId: 'tab', + dispatchMode: null, + sessions: { + agent: { cwd: '/projects/app', kind, providerSessionId: 'provider-abc' }, + }, + tabs: [{ id: 'tab', focusedSessionId: 'agent', root: { type: 'leaf', sessionId: 'agent' } }], + }, + }, + ui: {}, + flags: {}, + } as unknown as CommandContext +} describe('follow command availability', () => { const tail = paneCommands.find(command => command.id === 'toggle-tail') @@ -26,10 +43,13 @@ describe('follow command availability', () => { expect(jump!.renderedViewPolicy).toBeUndefined() }) - it('keeps both commands shell-excluded through a kind guard', () => { - // The `when` guards (kind !== 'terminal') are what keep plain shells out; - // assert they exist so removing the policy cannot silently remove them. - expect(typeof tail!.when).toBe('function') - expect(typeof jump!.when).toBe('function') + it('keeps both commands hidden for plain shell terminals and visible for agent kinds', () => { + for (const command of [tail!, jump!]) { + expect(command.when?.(contextWithKind('terminal'))).toBe(false) + expect(command.when?.(contextWithKind('claude'))).toBe(true) + // OpenCode covers both process runtimes: the structured HTTP session + // and OpenCode Terminal (same kind, providerRuntime 'terminal'). + expect(command.when?.(contextWithKind('opencode'))).toBe(true) + } }) }) diff --git a/src/renderer/src/features/workspace/commands/paneCommands.ts b/src/renderer/src/features/workspace/commands/paneCommands.ts index c16632d0..a453cd80 100644 --- a/src/renderer/src/features/workspace/commands/paneCommands.ts +++ b/src/renderer/src/features/workspace/commands/paneCommands.ts @@ -542,13 +542,13 @@ export const paneCommands: CommandDef[] = [ surface: 'app', title: 'Auto-follow All Visible Agents', description: - '**What it does:** Toggles feed **auto-follow for every visible agent** at once.\n\n**Use when:** You are watching several agents work and want them all pinned to the bottom.\n\n**Notes:** Scopes to what is on screen — in **single dispatch** that is the one agent, in **tiled** every lane, in the **grid** the current tab\'s panes only. Panes you open afterward tail too, until you toggle it off. Plain shell terminals are never affected; raw agent terminal views follow too.\n\n**Caution:** A tailing feed cannot be scrolled up — this takes scrollback away from every visible pane at once, and turning it off does not restore where you were reading.', + '**What it does:** Toggles **auto-follow for every visible agent** at once.\n\n**Use when:** You are watching several agents work and want them all pinned to the bottom.\n\n**Notes:** Scopes to what is on screen — in **single dispatch** that is the one agent, in **tiled** every lane, in the **grid** the current tab\'s panes only. Panes you open afterward tail too, until you toggle it off. Plain shell terminals are never affected; raw agent terminal views follow too.\n\n**Caution:** A tailing pane cannot be scrolled up. Turning this off leaves individually enabled followers on; other panes restore their earlier reading position where that content is still retained. Raw terminal follow controls xterm scrollback, not a TUI\'s internal history.', keywords: ['tail', 'all', 'follow', 'auto-scroll', 'bulk', 'every', 'watch', 'tail all', 'tail'], - // WHY no `renderedViewPolicy` even though per-session Tail has one: that - // gate resolves ONE target session and checks whether it renders a feed. - // Tail All has no single target — it is a stance that applies to whatever - // is mounted, now and later. Gating it on the currently focused pane would - // hide a workspace-level command because of one pane's view mode. + // WHY no `renderedViewPolicy` — Tail All is a stance over whatever is + // mounted, on either agent surface (rendered feed or raw terminal view, + // both of which follow now). Gating it on the currently focused pane's + // view mode would hide a workspace-level command for pane-local reasons. + // (Per-session Tail used to carry such a policy; it no longer does.) // // WHY no `when` guard: it is meaningful in every layout mode, and with zero // agent panes visible it is a harmless no-op rather than a command that diff --git a/src/renderer/src/workspace/control/preferences.ts b/src/renderer/src/workspace/control/preferences.ts index 6db211b6..828ef803 100644 --- a/src/renderer/src/workspace/control/preferences.ts +++ b/src/renderer/src/workspace/control/preferences.ts @@ -27,16 +27,16 @@ export function preferenceControlCapabilities(getWorkspace: () => Workspace) { } return [ defineCapability({ id: 'views.preferencesRead', title: 'Read agent display and follow preferences', execution: 'window', effect: 'read', target: { kind: 'session', field: 'sessionId' }, - description: 'Read an exact agent’s configured view override, global mode, effective rendered/terminal surface and auto-follow preference without focusing it. followEnabled includes Tail All; hidden panes may suspend scrolling and native terminal scrolling is separate. Hybrid leases can temporarily change effectiveSurface. Returns the revision used by the setters.', input: target, output, handler: input => read(input.sessionId) }), + description: 'Read an exact agent’s configured view override, global mode, effective rendered/terminal surface and auto-follow preference without focusing it. followEnabled includes Tail All and applies to rendered feeds and raw agent terminal viewports; hidden panes suspend forced scrolling. Hybrid leases can temporarily change effectiveSurface. Returns the revision used by the setters.', input: target, output, handler: input => read(input.sessionId) }), defineCapability({ id: 'views.modeSet', title: 'Set an agent display mode', execution: 'window', effect: 'mutation', target: { kind: 'session', field: 'sessionId' }, description: 'Set an exact agent’s durable Agent/Terminal override, or null to inherit global Agent/Terminal/Hybrid mode. Requires views.preferencesRead revision. Uses the normal provider policy: structured OpenCode cannot become a native terminal, and OpenCode Terminal cannot become a rendered agent. Does not focus, reload or rearrange panes.', input: target.extend({ revision: z.string(), mode: z.enum(['agent', 'terminal']).nullable() }), output, handler: input => { guard(input); if (!getWorkspace().setSessionAgentViewModeOverride(input.sessionId, input.mode)) throw new ControlError('unavailable', 'Provider rejected this view mode'); return read(input.sessionId) } }), defineCapability({ id: 'views.followSet', title: 'Set an agent auto-follow preference', execution: 'window', effect: 'mutation', target: { kind: 'session', field: 'sessionId' }, - description: 'Set the exact agent’s rendered-feed auto-follow preference using a fresh views.preferencesRead revision. Idempotent desired state; leaves other agents and pane layout untouched. Tail All can keep followEnabled true when this preference is false; use views.tailAllSet to change the window-wide override.', + description: 'Set the exact agent’s auto-follow preference for its rendered feed or raw terminal viewport using a fresh views.preferencesRead revision. Idempotent desired state; leaves other agents and pane layout untouched. Tail All can keep followEnabled true when this preference is false; use views.tailAllSet to change the window-wide override.', input: target.extend({ revision: z.string(), enabled: z.boolean() }), output, handler: input => { const current = guard(input); if (current.autoFollow !== input.enabled) getWorkspace().toggleTailMode(input.sessionId); return read(input.sessionId) } }), - defineCapability({ id: 'views.tailAllSet', title: 'Set window-wide feed auto-follow', execution: 'window', effect: 'mutation', + defineCapability({ id: 'views.tailAllSet', title: 'Set window-wide agent auto-follow', execution: 'window', effect: 'mutation', description: 'Set Tail All for the explicitly selected window. Requires expected current value from views.preferencesRead.tailAll. Turning it off restores each agent’s own follow preference; it does not disable individually enabled followers. Hidden panes may suspend scrolling. Does not focus or rearrange panes.', input: z.object({ expected: z.boolean(), enabled: z.boolean() }).strict(), output: z.object({ enabled: z.boolean() }), handler: input => { diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx index f51d8bef..2b02d2e8 100644 --- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx @@ -20,9 +20,10 @@ import { AgentTerminalLeaf } from './AgentTerminalLeaf' // sessionDataDispatcher fanout instead of calling leaf internals. type MockTerminal = { rows: number - buffer: { active: { viewportY: number; length: number } } + buffer: { active: { viewportY: number; length: number; baseY: number } } scrollToBottom: ReturnType onScrollListener: ((line: number) => void) | null + markers: Array<{ line: number; isDisposed: boolean }> } const xtermHarness = vi.hoisted(() => ({ @@ -31,6 +32,17 @@ const xtermHarness = vi.hoisted(() => ({ instances: [] as MockTerminal[], attachWebgl: vi.fn(), fit: vi.fn(), + // Deferred-write mode for race regression tests: real xterm parses chunks + // on a setTimeout cadence, so write completion callbacks fire macrotasks + // after the write — long enough for tail to disengage in between. Sync + // mode (the default) models the common case and keeps the bulk of the + // suite deterministic. + deferWrites: false, + pendingWriteCallbacks: [] as Array<() => void>, + flushWriteCallbacks(): void { + const pending = xtermHarness.pendingWriteCallbacks.splice(0) + for (const callback of pending) callback() + }, })) const appStore = vi.hoisted(() => ({ @@ -55,13 +67,30 @@ vi.mock('@xterm/xterm', () => ({ container: HTMLElement | null = null onDataListener: ((data: string) => void) | null = null onScrollListener: ((line: number) => void) | null = null - buffer = { active: { viewportY: 0, length: 1 } } - // scrollToBottom intentionally does NOT emit onScroll: real xterm does, - // but our handler then sees an at-bottom viewport and no-ops, so the mock - // keeps call counts deterministic. Re-pin behavior is tested by invoking - // the registered onScroll listener directly. + buffer = { active: { + type: 'normal', viewportY: 0, length: 1, + get baseY() { return Math.max(0, this.length - xtermHarness.rows) }, + cursorY: xtermHarness.rows - 1, + } } + // The real-engine system test owns trim/reflow behavior. This marker only + // supplies the public API needed by lifecycle and deferred-callback tests. + markers: Array<{ line: number; isDisposed: boolean }> = [] + registerMarker(offset: number) { + const marker = { + line: this.buffer.active.baseY + this.buffer.active.cursorY + offset, + isDisposed: false, + dispose() { this.isDisposed = true; this.line = -1 }, + } + this.markers.push(marker) + return marker + } + // scrollToBottom emits onScroll AFTER moving the viewport, like real + // xterm fires the public scroll event for programmatic moves. The re-pin + // handler then sees an at-bottom viewport and no-ops, so the harness + // exercises the self-termination instead of asserting it in a comment. scrollToBottom = vi.fn(() => { this.buffer.active.viewportY = Math.max(0, this.buffer.active.length - this.rows) + this.onScrollListener?.(this.buffer.active.viewportY) }) // viewportY is readonly on xterm v6's public type; scrollToLine is the // sanctioned writer. Mirrors scrollToBottom's clamping behavior. @@ -82,7 +111,11 @@ vi.mock('@xterm/xterm', () => ({ this.onScrollListener = listener return { dispose: this.scrollDispose } } - write(_data: string, callback?: () => void) { callback?.() } + write(_data: string, callback?: () => void) { + if (!callback) return + if (xtermHarness.deferWrites) xtermHarness.pendingWriteCallbacks.push(callback) + else callback() + } focus() {} }, })) @@ -153,12 +186,12 @@ describe('AgentTerminalLeaf follow (jump-to-latest + tail)', () => { ...patch, }) - function leaf(runtime: SessionRuntime = runtimeWith({})) { + function leaf(runtime: SessionRuntime = runtimeWith({}), leafSessionId = 'session-1') { return ( - + {}} workspace={workspace} @@ -194,6 +227,8 @@ describe('AgentTerminalLeaf follow (jump-to-latest + tail)', () => { attach = deferred() nextFrameId = 0 frames = new Map() + xtermHarness.deferWrites = false + xtermHarness.pendingWriteCallbacks.length = 0 xtermHarness.fit.mockClear() xtermHarness.instances.length = 0 xtermHarness.attachWebgl.mockReset() @@ -247,11 +282,13 @@ describe('AgentTerminalLeaf follow (jump-to-latest + tail)', () => { }) it('pins after the attach replay when tail is on', async () => { + xtermHarness.deferWrites = true render(leaf(runtimeWith({ tailMode: true }))) await attachResolved('backfill') - // Tail engaged before xterm existed, so the pin has to come from the - // post-replay moment in tryAttach — proving that branch ran. - expect(term().scrollToBottom).toHaveBeenCalled() + term().buffer.active.length = 500 + expect(term().scrollToBottom).not.toHaveBeenCalled() + await act(async () => { xtermHarness.flushWriteCallbacks() }) + expect(term().buffer.active.viewportY).toBe(460) }) it('leaves the viewport alone on PTY output while tail is off', async () => { @@ -264,16 +301,69 @@ describe('AgentTerminalLeaf follow (jump-to-latest + tail)', () => { expect(term().buffer.active.viewportY).toBe(10) }) - it('re-pins when the user scrolls away while tailing', async () => { + it('re-pins when the user scrolls away while tailing, deferred out of the scroll dispatch', async () => { render(leaf(runtimeWith({ tailMode: true }))) await attachResolved() term().buffer.active.length = 500 term().buffer.active.viewportY = 200 // user wheel-scrolled up act(() => { term().onScrollListener?.(200) }) + // Real xterm suppresses reentrant scroll handling: a synchronous pin from + // inside the onScroll dispatch does not move the viewport. The handler + // must schedule instead — nothing has moved yet. + expect(term().buffer.active.viewportY).toBe(200) + await act(async () => { await Promise.resolve() }) // flush the microtask expect(term().scrollToBottom).toHaveBeenCalled() expect(term().buffer.active.viewportY).toBe(460) }) + it('keeps the restored position when a queued write callback lands after tail-off', async () => { + xtermHarness.deferWrites = true + const view = render(leaf()) + await attachResolved() + term().buffer.active.length = 500 + term().buffer.active.viewportY = 100 + act(() => { view.rerender(leaf(runtimeWith({ tailMode: true }))) }) // engage, save line 100 + act(() => { channelListener?.({ sessionId: 'session-1', data: 'stream' }) }) // write queued, callback pending + act(() => { view.rerender(leaf(runtimeWith({ tailMode: false }))) }) // disengage, restore line 100 + expect(term().buffer.active.viewportY).toBe(100) + act(() => { xtermHarness.flushWriteCallbacks() }) // xterm finishes parsing now + expect(term().buffer.active.viewportY).toBe(100) + }) + + it('discards queued write and scroll work after unmount', async () => { + const view = render(leaf(runtimeWith({ tailMode: true }))) + await attachResolved() + xtermHarness.deferWrites = true + term().buffer.active.length = 500 + term().buffer.active.viewportY = 100 + act(() => { channelListener!({ sessionId: 'session-1', data: 'stream' }) }) + act(() => { term().onScrollListener!(100) }) + term().scrollToBottom.mockClear() + view.unmount() + await act(async () => { xtermHarness.flushWriteCallbacks() }) + expect(term().scrollToBottom).not.toHaveBeenCalled() + }) + + it('does not carry a saved position across a session swap in the same leaf', async () => { + const view = render(leaf(runtimeWith({}), 'session-1')) + await attachResolved() + term().buffer.active.length = 500 + term().buffer.active.viewportY = 100 + act(() => { view.rerender(leaf(runtimeWith({ tailMode: true }), 'session-1')) }) // session A saves line 100 + // TileTree swaps renderedSessionId under the mounted leaf; the mount + // effect re-runs and builds a fresh xterm for session B. + act(() => { view.rerender(leaf(runtimeWith({ tailMode: true }), 'session-2')) }) + expect(term().markers.every(marker => marker.isDisposed)).toBe(true) + const swapped = xtermHarness.instances.at(-1)! + expect(swapped).not.toBe(term()) + swapped.buffer.active.length = 500 + swapped.buffer.active.viewportY = 460 // session B sits at the bottom + act(() => { view.rerender(leaf(runtimeWith({ tailMode: false }), 'session-2')) }) + // Without the session-identity reset, disengage restored A's line 100 + // inside B's terminal (review reproduction). + expect(swapped.buffer.active.viewportY).toBe(460) + }) + it('does not re-pin on scroll while tail is off', async () => { render(leaf()) await attachResolved() diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.submit.renderer.test.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.submit.renderer.test.tsx index ec1377a6..d3188450 100644 --- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.submit.renderer.test.tsx +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.submit.renderer.test.tsx @@ -47,6 +47,7 @@ vi.mock('@xterm/xterm', () => ({ options: Record = {} container: HTMLElement | null = null onDataListener: ((data: string) => void) | null = null + onScrollListener: ((line: number) => void) | null = null dispose = vi.fn() inputDispose = vi.fn(() => { this.onDataListener = null }) scrollDispose = vi.fn(() => { this.onScrollListener = null }) diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx index 9eeef447..5d854a0e 100644 --- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx @@ -97,6 +97,7 @@ export function AgentTerminalLeaf({ // in declaration order — when tail is already on at mount, the terminal does // not exist yet, which is exactly the "nothing to restore" case. const follow = useAgentTerminalFollow({ + sessionId, scrollToLatestRequest: runtime.scrollToLatestRequest, tailActive, termRef, @@ -324,7 +325,17 @@ export function AgentTerminalLeaf({ // bottom and land one chunk early. const liveTerm = term if (follow.tailActiveRef.current) { - liveTerm?.write(data, () => liveTerm.scrollToBottom()) + liveTerm?.write(data, () => { + // Fire-time re-check, not just schedule-time: xterm's WriteBuffer + // schedules parsing with setTimeout and yields under load, so this + // callback can fire long after the write — potentially after tail + // disengaged and restored the reading position, or after unmount + // disposed the terminal. An unconditional scrollToBottom would + // undo the restore and clear xterm's isUserScrolling latch, + // leaving the pane following with the TAIL pill off. + if (disposed || !follow.tailActiveRef.current) return + liveTerm.scrollToBottom() + }) } else { liveTerm?.write(data) } @@ -396,14 +407,28 @@ export function AgentTerminalLeaf({ return true } // Replay, with the forwarder holding its latch until xterm has parsed - // every chunk; the backlog is strictly newer than the buffer. - void forwarder.replay(liveTerm, [buffer, backlogQueue.join('')]) + // every chunk; the backlog is strictly newer than the buffer. The pin + // chains on the replay promise — replay resolves only when xterm + // reports every chunk parsed, so the pin acts on the real backfill; an + // inline call here runs before parsing touches an empty buffer. + // Guards: the pane can unmount mid-parse (`disposed`) or tail can + // disengage before the backlog lands. + forwarder + .replay(liveTerm, [buffer, backlogQueue.join('')]) + .then(() => { + if (disposed || !follow.tailActiveRef.current) return + if (termRef.current !== liveTerm) return + liveTerm.scrollToBottom() + }) + .catch(error => { + // The detached replay promise is outside tryAttach's error path. + // Report parse failures on the surviving pane rather than silently + // swallowing them or generating an unhandled renderer rejection. + if (!disposed) showPaneToastRef.current(sessionId, + error instanceof Error ? error.message : 'Could not replay agent terminal') + }) backlogQueue.length = 0 attachedBackfillDone = true - // A fresh terminal follows its replay by default, but engage-while-mounted - // (or Tail All flipping during a remount) wants the pin explicit once the - // backfill exists — the replay itself does not go through the write path. - if (follow.tailActiveRef.current) liveTerm.scrollToBottom() if (pendingResize) { const measured = pendingResize pendingResize = null diff --git a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.system.test.ts b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.system.test.ts new file mode 100644 index 00000000..c2bf6770 --- /dev/null +++ b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.system.test.ts @@ -0,0 +1,140 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { promisify } from 'node:util' +import { build } from 'vite' +import { expect, it } from 'vitest' + +const root = resolve(import.meta.dirname, '../../../../..') +const run = promisify(execFile) + +// Review reproduced two failures our fake Terminal could not represent: +// Chromium's xterm viewport rejects reentrant scrolling, and a full buffer +// trims content without changing baseY. Run the production hook with the +// shipped xterm in an isolated Electron window. No app bootstrap, providers, +// user sessions, or credentials are involved. +it('follows and restores real xterm content across trimming and buffer switches', async () => { + const directory = await mkdtemp(join(tmpdir(), 'agent-terminal-follow-')) + try { + const renderer = join(directory, 'renderer.ts') + await writeFile(renderer, ` + import { createElement } from '${resolve(root, 'node_modules/react/index.js')}' + import { createRoot } from '${resolve(root, 'node_modules/react-dom/client.js')}' + import { flushSync } from '${resolve(root, 'node_modules/react-dom/index.js')}' + import { Terminal } from '${resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js')}' + import '${resolve(root, 'node_modules/@xterm/xterm/css/xterm.css')}' + import { useAgentTerminalFollow } from '${resolve(root, 'src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts')}' + + window.followTrial = (async () => { + const term = new Terminal({ cols: 80, rows: 10, scrollback: 2000 }) + term.open(document.getElementById('terminal')) + const termRef = { current: term } + let follow + function Harness(props) { follow = useAgentTerminalFollow({ ...props, termRef }); return null } + const reactRoot = createRoot(document.getElementById('react')) + let tailActive = false + let scrollToLatestRequest = 0 + function render() { + flushSync(() => reactRoot.render(createElement(Harness, { + sessionId: 'trial', tailActive, scrollToLatestRequest, + }))) + } + const check = (condition, message) => { if (!condition) throw new Error(message) } + const delay = () => new Promise(done => setTimeout(done, 10)) + const until = async (predicate, message) => { + const deadline = performance.now() + 5000 + while (!predicate()) { + if (performance.now() > deadline) throw new Error(message) + await delay() + } + } + const write = data => new Promise(done => term.write(data, done)) + const textAtTop = () => term.buffer.active.getLine(term.buffer.active.viewportY)?.translateToString(true) + const bottom = () => term.buffer.active.viewportY === term.buffer.active.baseY + render() + const off = follow.attach(term) + try { + await write(Array.from({length: 2100}, (_, i) => 'line-' + i + '\\r\\n').join('')) + term.scrollToLine(1000) + const before = textAtTop() + const fullBaseY = term.buffer.active.baseY + tailActive = true; render() + await until(bottom, 'Tail did not engage') + + term.scrollLines(-20) + await until(bottom, 'Tail failed to re-pin after real xterm scroll dispatch') + await write(Array.from({length: 100}, (_, i) => 'new-' + i + '\\r\\n').join('')) + check(term.buffer.active.baseY === fullBaseY, 'Trial did not exercise a full, trimming buffer') + tailActive = false; render() + check(textAtTop() === before, 'Restore drifted after trimming: expected ' + before + ', got ' + textAtTop()) + + scrollToLatestRequest++; render() + await until(bottom, 'Jump request did not reach real xterm bottom') + + term.scrollToLine(10) + tailActive = true; render() + await write(Array.from({length: 100}, () => 'evict\\r\\n').join('')) + tailActive = false; render() + check(term.buffer.active.viewportY === 0, 'Evicted anchor must fall back to oldest retained content') + + term.scrollToLine(50) + tailActive = true; render() + await write('\\x1b[?1049h') + tailActive = false; render() + check(term.buffer.active.type === 'alternate' && bottom(), 'Alternate buffer was scrolled with a normal-buffer anchor') + await write('\\x1b[?1049l') + // Marker registration/disposal is public; only this diagnostic + // enumeration requires proposed APIs. Keep them off for all behavior. + term.options.allowProposedApi = true + check(term.markers.length === 0, 'Follow leaked a saved marker after disengage') + return { repin: true, trim: true, eviction: true, jump: true, alternate: true } + } finally { + off(); reactRoot.unmount(); termRef.current = null; term.dispose() + } + })() + `) + await build({ + configFile: false, root, logLevel: 'silent', + // The temporary entry has no ancestor node_modules; keep React and the + // production hook on the same installed module, rather than two copies. + resolve: { alias: { react: resolve(root, 'node_modules/react') } }, + define: { 'process.env.NODE_ENV': JSON.stringify('production') }, + build: { + outDir: directory, emptyOutDir: false, target: 'esnext', minify: false, + lib: { entry: renderer, name: 'FollowTrial', formats: ['iife'], fileName: () => 'renderer.js', cssFileName: 'style' }, + }, + }) + await writeFile(join(directory, 'index.html'), '
') + await writeFile(join(directory, 'main.cjs'), ` + const { app, BrowserWindow } = require('electron') + app.setPath('userData', ${JSON.stringify(join(directory, 'user-data'))}) + app.disableHardwareAcceleration() + const deadline = setTimeout(() => app.exit(2), 25000) + app.whenReady().then(async () => { + app.dock?.hide() + const window = new BrowserWindow({ show: false, webPreferences: { backgroundThrottling: false } }) + try { + await window.loadFile(${JSON.stringify(join(directory, 'index.html'))}) + const result = await window.webContents.executeJavaScript('window.followTrial') + if (!result) throw new Error('Renderer trial did not start') + console.log('FOLLOW_TRIAL=' + JSON.stringify(result)) + window.destroy(); clearTimeout(deadline); app.exit(0) + } catch (error) { console.error(error); app.exit(1) } + }).catch(error => { console.error(error); app.exit(1) }) + `) + const environment = { ...process.env } + delete environment.ELECTRON_RUN_AS_NODE + const { stdout } = await run(createRequire(import.meta.url)('electron') as string, [join(directory, 'main.cjs')], { + env: environment, timeout: 35_000, maxBuffer: 2_000_000, + }) + const result = stdout.split('\n').find(line => line.startsWith('FOLLOW_TRIAL=')) + expect(result, stdout).toBeDefined() + expect(JSON.parse(result!.slice('FOLLOW_TRIAL='.length))).toEqual({ + repin: true, trim: true, eviction: true, jump: true, alternate: true, + }) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}, 120_000) diff --git a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts index 98d57d21..4b1b7cc3 100644 --- a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts +++ b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts @@ -1,68 +1,51 @@ import { useEffect, useMemo, useRef } from 'react' import type { RefObject } from 'react' -import type { Terminal } from '@xterm/xterm' - -// Follow behavior for raw agent terminal surfaces (AgentTerminalLeaf) — the -// xterm counterpart of what Feed does for the rendered surface: -// - Jump to Latest: the workspace bumps `runtime.scrollToLatestRequest` -// whenever the user asks to return to the bottom (palette command, prompt -// send). Feed scrolls its DOM scroller; a raw pane scrolls the xterm -// viewport instead. Nothing consumed this counter on the terminal surface -// before, so the command silently did nothing there. -// - Tail (auto-follow): mirrors Feed's semantics — pin to bottom while -// active, re-pin if the user scrolls away, and restore the pre-tail -// viewport line on disengage so following is non-destructive. Feed -// protects the saved position for the same reason (see Feed.tsx "WHY -// tailing deliberately does NOT persist"). -// -// WHY a hook instead of inline effects in AgentTerminalLeaf: the leaf's xterm -// mount effect is deliberately keyed on [sessionId] alone (remounting xterm on -// every runtime change would lose scrollback and re-attach the PTY), so -// runtime-driven behavior must live outside that effect and reach the terminal -// through refs. Collecting it here also gives the renderer tests one unit to -// target. The hook MUST be called before the leaf's mount effect — see the -// wiring comment in AgentTerminalLeaf. - -/** Viewport is at bottom when its top line plus rows covers the buffer. */ -export function isXtermViewportAtBottom(term: Terminal): boolean { - const buffer = term.buffer.active - return buffer.viewportY >= buffer.length - term.rows -} +import type { IMarker, Terminal } from '@xterm/xterm' +// AgentTerminalLeaf keeps its expensive PTY/xterm attachment keyed on sessionId. +// Follow intent changes independently of that lifetime: consuming it in this +// hook avoids remounting the terminal and losing scrollback on every toggle. +// The hook runs before the leaf's mount effect, so a fresh terminal has no +// pre-tail position to restore. The leaf pins once its attach replay is parsed. type FollowArgs = { - /** Live runtime counter; every increment is one jump-to-latest request. */ + sessionId: string scrollToLatestRequest: number - /** Computed tail verdict (per-session Tail OR Tail All, masked by visibility). */ tailActive: boolean - /** The leaf's terminal ref; null until the mount effect creates xterm. */ termRef: RefObject } -export type AgentTerminalFollowHandle = { - /** Tail verdict for the PTY write path inside the leaf's mount effect. */ - readonly tailActiveRef: Readonly<{ current: boolean }> - /** Wire re-pin-on-user-scroll to a freshly created Terminal instance. */ - attach: (term: Terminal) => () => void +function isAtBottom(term: Terminal): boolean { + return term.buffer.active.viewportY >= term.buffer.active.baseY } export function useAgentTerminalFollow({ - scrollToLatestRequest, - tailActive, - termRef, -}: FollowArgs): AgentTerminalFollowHandle { - // WHY render-time assignment (mirroring runtimeRef in AgentTerminalLeaf): - // the PTY subscriber in the mount effect reads this ref at IPC-event time, - // long after any effect ordering, and the mount effect itself must never - // re-run for follow-state changes. + sessionId, scrollToLatestRequest, tailActive, termRef, +}: FollowArgs) { + // The mount-owned PTY subscriber reads the latest verdict at callback time, + // not when a chunk was queued: parsing can finish after Tail was disabled. const tailActiveRef = useRef(tailActive) tailActiveRef.current = tailActive - - // Jump to Latest. WHY a baseline ref: the counter can already be non-zero - // from the session's rendered-surface life, and remounting the pane must - // not replay an old request against a fresh xterm — the attach replay - // already leaves a fresh terminal at the bottom. + const tailEngagedRef = useRef(false) + const savedLineRef = useRef(null) const jumpBaselineRef = useRef(null) + useEffect(() => { + // Related-agent tabs can change sessionId without remounting the React + // leaf. None of A's saved position or jump counter belongs to session B. + tailEngagedRef.current = false + savedLineRef.current?.dispose() + savedLineRef.current = null + jumpBaselineRef.current = null + return () => { + savedLineRef.current?.dispose() + savedLineRef.current = null + } + }, [sessionId]) + + useEffect(() => { + // The counter also survives rendered/raw surface swaps. Baseline rather + // than replay the old request; a newly attached xterm starts at its tail. + // New requests come from scrollFocusedToLatest and softReloadRuntime. if (jumpBaselineRef.current === null) { jumpBaselineRef.current = scrollToLatestRequest return @@ -70,59 +53,65 @@ export function useAgentTerminalFollow({ if (scrollToLatestRequest === jumpBaselineRef.current) return jumpBaselineRef.current = scrollToLatestRequest termRef.current?.scrollToBottom() - }, [scrollToLatestRequest, termRef]) + }, [scrollToLatestRequest, sessionId, termRef]) - // Tail engage/disengage. Non-destructive like Feed: only a viewport that was - // genuinely scrolled up has a position worth restoring; engaging while at - // bottom saves nothing and disengage leaves the bottom. On mount with tail - // already on, this effect runs before xterm exists (declaration order — see - // the leaf wiring), so nothing is saved and disengage keeps the bottom the - // attach replay left us at. - const tailEngagedRef = useRef(false) - const savedViewportYRef = useRef(null) useEffect(() => { - const activeTerm = termRef.current + const term = termRef.current if (tailActive && !tailEngagedRef.current) { tailEngagedRef.current = true - if (activeTerm) { - savedViewportYRef.current = isXtermViewportAtBottom(activeTerm) - ? null - : activeTerm.buffer.active.viewportY - activeTerm.scrollToBottom() + if (term) { + const buffer = term.buffer.active + // Numeric viewport offsets are not content anchors. Once scrollback + // fills, both baseY and length stay constant while old lines are + // evicted. xterm markers follow trims/deletions and dispose themselves + // when their line is lost. Register relative to the cursor, not baseY, + // so the anchor names exactly the first line the user was reading. + if (buffer.type === 'normal' && !isAtBottom(term)) { + savedLineRef.current = term.registerMarker(buffer.viewportY - buffer.baseY - buffer.cursorY) + } + term.scrollToBottom() } return } if (!tailActive && tailEngagedRef.current) { tailEngagedRef.current = false - const saved = savedViewportYRef.current - savedViewportYRef.current = null - // WHY scrollToLine and not a viewportY write: @xterm/xterm v6 exposes - // buffer.active.viewportY as readonly (v5 allowed assignment). The - // explicit clamp keeps the target inside a buffer that may have grown - // or shrunk since the position was saved. - if (activeTerm && saved !== null) { - const buffer = activeTerm.buffer.active - activeTerm.scrollToLine(Math.min(saved, Math.max(0, buffer.length - activeTerm.rows))) + const saved = savedLineRef.current + savedLineRef.current = null + if (term && saved && term.buffer.active.type === 'normal') { + // An evicted anchor cannot be restored; show the oldest surviving + // content instead. Never apply a normal-buffer anchor to an alternate + // screen. scrollToLine is the public viewport writer, not viewportY. + term.scrollToLine(saved.isDisposed ? 0 : saved.line) } + saved?.dispose() } - }, [tailActive, termRef]) + }, [sessionId, tailActive, termRef]) - // Stable handle: the leaf's mount effect is keyed on [sessionId] and must - // not be invalidated by follow-state churn. - return useMemo(() => ({ + return useMemo(() => ({ tailActiveRef, - attach: mountedTerm => { - // Feed re-pins on the scroll event itself. scrollToBottom also fires - // onScroll, but the handler then sees an at-bottom viewport and no-ops, - // so the loop self-terminates. Mouse-mode TUIs forward wheel events to - // the app instead of xterm scrollback, so this only acts on genuine - // viewport movement. - const disposable = mountedTerm.onScroll(() => { - if (!tailActiveRef.current) return - if (isXtermViewportAtBottom(mountedTerm)) return - mountedTerm.scrollToBottom() + attach: (term: Terminal) => { + let disposed = false + let queued = false + const subscription = term.onScroll(() => { + // onScroll also fires while parsing output, not just user scrolls. + // Coalesce dispatches; xterm's viewport rejects a synchronous re-pin + // inside its own scroll handler. A microtask exits that reentrancy + // fence. The write-completion pin in the leaf still handles the final + // post-parse bottom, after xterm has updated its scroll dimensions. + if (queued || !tailActiveRef.current || isAtBottom(term)) return + queued = true + queueMicrotask(() => { + queued = false + if (disposed || termRef.current !== term || !tailActiveRef.current || isAtBottom(term)) return + term.scrollToBottom() + }) }) - return () => disposable.dispose() + return () => { + disposed = true + subscription.dispose() + savedLineRef.current?.dispose() + savedLineRef.current = null + } }, - }), []) + }), [termRef]) }