OpenProgram / Web / Transcript

Chat transcript follow

Author: engineering Status: implemented on the web renderer Scope: web renderer (App and browser)

Sending a message does not reliably take the reader to the latest turn. Four overlapping scroll writers disagree about what “bottom” is and who may move the viewport (legacy scrollToBottom, unused useStickToBottom, live useChatAreaStick, and loadSessionHistoryWindow). This page collapses them into one React-owned follow policy: one attach/detach state, one at-bottom predicate, one follow-trigger table, and one padding-edge definition of latest. Chat handlers and the history loader do not pin latest onto an arbitrary viewport.

Interactive preview of the adopted contract

This viewport is local and makes no network requests. It illustrates the basic follow interactions, not the current product or the full acceptance matrix. Seed a long history, scroll up, send, watch a stream, and use Jump to latest. On the latest window, send snaps instantly and attaches. While an older window is showing, send stays pending, keeps Jump, and snaps after latest loads. Stream growth follows only while attached. Jump re-attaches with native smooth scroll (instant when reduced motion is requested).

attached latest window send = snap
Ask anything… then Send, or press Enter in the transcript
Policy log: ready. Seed history, scroll up, then send.

Keyboard: Tab to the scroller or buttons; Enter in the scroller sends; the Jump control is a real button. The breathing pad is max(25vh, composer + 24). Latest is the padding edge, so the last bubble sits in the visible band above the composer, not under it. Jump stays visible until the smooth ride settles; pointer or wheel on the scroller cancels the ride. Send still hides Jump instantly. This preview does not yet implement the gesture-lifetime, asynchronous cancellation, or dynamic Jump completion requirements below and is not evidence for those cases. This viewport does not simulate welcome overflow, peer overlay slack, a failed latest-window load, or a real older history page (the older-page control is a flag).

Current implementation and verified gaps

useChatAreaStick owns main and peer transcript placement, send following and Jump. chat-scroll.ts provides geometry and generation-stamped notes. useHistoryWindow and the history loader own bounded pagination and per-viewport anchor restoration. Legacy follow writers are absent.

Send identity and admission

Accepted normal, direct-function and running queue/steer submissions publish a note for their captured session and scroller. Loading or failed attachments reject admission before queue creation, draft clearing or note publication. Completed notes remain readable by design; a rejected later submit must preserve the existing note and its generation.

First activation and delayed layout

Without saved pixels, first activation creates its own generation and follows latest through delayed layout. Recent returns restore detached reading positions; latest-following and expired views open at latest. A user gesture cancels pending following. A pending accepted welcome send survives activation until its view is ready.

Bottom-aware conversation restoration

Each viewport stores both its scroll offset and whether it was following the latest message. Returning to a viewport that followed latest follows the new bottom through delayed loading and layout. A viewport explicitly scrolled into history restores its saved offset and history anchor. Legacy entries without freshness evidence open at latest. User interaction cancels pending following; main and peer keys remain separate. The asynchronous conversation loader must not overwrite latest placement with stale saved pixels. Reload persistence retains an unchanged position's follow state.

Latest intent also survives an initially empty transcript. When asynchronous rows commit before ResizeObserver delivery, layout preserves the existing follow state and places the new bottom. Height growth is not a reading gesture; the 600 ms growth suppression starts only after actual pointer, wheel or scroll-key input.

Restoration follows one lifecycle policy. A new App or Web document always opens at latest; saved state from another runtime is ignored. Within the same runtime, a conversation revisited less than one hour after leaving restores its reading state. At one hour or later it opens at latest. Returning after the application has lost focus for an hour also follows latest. There is no timer that moves an actively viewed transcript. Leaving, hiding, or unmounting records the departure time even if the reader has not scrolled recently. Latest restoration clears the obsolete history anchor and uses the existing cancellable latest-window loader. Storage records contain offset, bottom intent, timestamp and runtime identity; legacy entries lacking freshness evidence open at latest. The one-hour threshold is defined once.

Older history and first activation

First activation of an older loaded window requests latest and stays detached until a verified latest response is published. It does not snap the stale window while waiting. A browser fixture exercising this activation must provide that response before expecting latest placement. A fixture exercising manual reading of an older window must instead establish an explicit saved viewport; otherwise it unintentionally starts an unrelated activation request.

History restoration ownership

The loader restores each registered viewport using its own captured anchor and key. It does not perform latest placement. The hook preserves manual reading and cancellation while history pages change. Session reloads do not schedule a second pixel restore: a saved offset from the cached window may exceed the shorter latest page and incorrectly clamp reading to its bottom.

Welcome first send

Main retries the current accepted send after the welcome overflow lock clears. Peer sends do not use this path.

Separate peer viewports

Main and peer operations use separate scroller keys. Accepted queue/steer follows only the submitting pane; attachment rejection leaves both viewports unchanged.

Verified browser fixture gaps

Two historical-window browser fixtures wait for a nonzero initial position while withholding the automatic first-activation latest response. Four queue/steer browser cases assume no note exists after rejecting a loading attachment, although initial activation has already created a completed note. These expectations conflict with the activation and note-lifetime contracts. Fix the specific fixtures by establishing saved historical reading positions and comparing the exact note before and after rejected admission. Keep first-activation latest requests, delayed-growth placement, manual cancellation and send semantics unchanged. Existing Node activation regressions retain their acceptance meaning.

Controlled browser tests exercise production hooks with isolated backend responses. They do not establish installed-App or live-provider acceptance. Fixture repair and independent review evidence are recorded separately.

Official reference corpus and framework comparison

Comparison is of product rules, not pixel clones. Absent capabilities stay absent.

SurfaceSend while scrolled upStream / agent growthRe-attach controlSession / historyAbsent here (keep absent unless noted)
ChatGPT web Send is a take-me-to-latest gesture. New user turn appears in the live band. Follows while the reader is at the live edge. Scroll up detaches. Scroll back near the edge re-attaches. Down-arrow when detached. Stream can show a live variant of that control. Opening a chat aims at latest; restore bugs are reported when this fails. No virtual-list library in OpenProgram. No auto-follow of every DAG node expansion.
Claude.ai / Claude Code web Submit lands the user turn in view. Reading older content is interrupted by send, not by the model continuing. Streaming follows at the live edge. Manual scroll up holds position. Jump/latest affordance when not at the live edge. Conversation switch restores that conversation, not a global stick flag. No global window stick flag. OpenProgram already killed window.__stickListenerInstalled in name only; the module flag remains.
Cursor chat / Composer Send keeps the new prompt in the composer-adjacent band. Follows generation while pinned; inspector-style scroll up detaches. Explicit jump when behind the tail. Per-thread. Not used as a protocol source. No Cursor-style message-scroller library. No turn-anchor-to-top of the previous reply as a second mode in this slice.
Codex / ChatGPT Atlas notes in this repo No in-repo Atlas transcript-follow spec. Codex material here covers queue/steer (send-queue-reliability.html), not scroll physics. — — — Do not invent an Atlas scroll contract. Queue/steer remains a separate topic.
OpenProgram CLI ScrollBox scrollToBottom() sets stickyScroll = true (gesture-owned flag), then the renderer pins. Positional follow only while sticky. scrollTo / scrollBy clear sticky. Sticky does not depend on layout epsilon. Imperative scrollToBottom re-sets sticky. TUI only. Out of implementation scope. Adopt the named attached flag idea, not Yoga/Ink code.
shadcn MessageScroller (prior art, not a product we ship) New user turn can use a turn-anchor near the top with a peek of the previous exchange. autoScroll only while at bottom. MessageScrollerButton when behind. — Rejected as a library and as a second “anchor previous turn at top” mode. OpenProgram pad already leaves breathing room above the composer.

Shared rule across ChatGPT, Claude, and Cursor: send is an implicit jump to latest; model tokens are not. OpenProgram’s resolveChatScrollTop already states that rule. The web view does not enforce it with one owner.

Adopted, modified, and rejected choices

Adopted

One policy module in apps/web/lib/chat/chat-scroll.ts. One attached boolean per scroller. Send / queue-drain / first welcome send = take-latest, instant snap. Stream pins only while attached and on the latest history window. Jump to latest remains the explicit smooth re-attach. First activation follows latest; subsequent activation restores the saved position without creating a message. Desktop App uses this same web renderer.

Modified

Take-latest is a generation-stamped note keyed by session id plus the submitting scroller’s chatKey, not a consume-once boolean and not lastRole alone. lastRole === "user" remains a fallback until the send-path note lands in the same merge train. Pointer 600ms suppression applies only to in-transcript clicks that resize content, never to take-latest or Jump. Peer overlay and pad are separate inputs; do not treat --peer-composer-h as --main-composer-height. resolveChatScrollTop keeps the whether-to-move decision; callers then assign latestScrollTop (the named clamped padding edge Jump already uses). Send while history.after is pending until latest loads; attached stays false on the stale page. The history loader never places latest; window anchor restoration remains in history-viewport helpers as specified below.

Rejected

A fourth stick file. Last-message + 16px as the follow target. Send as a second smooth animation. WS handlers calling a scroller. Global window / module stickToBottom. Persisting stickiness to disk. A virtual-list library. Auto-follow of every DAG expand. Rewriting the send/ack protocol. CLI ScrollBox in the web bundle. Turn-anchor-previous-reply-at-top as a parallel mode.

What “bottom” means

Latest is the padding edge of the scroller. latestScrollTop = max(0, scrollHeight - clientHeight) is the named form of the clamp Jump already uses and that assigning scrollTop = scrollHeight already produces when clientHeight > 0. Combined with pad max(25vh, overlay + 24) on main (peer pad is the published --peer-composer-h = overlay + 24), the last relevant content (user bubble, pending-reply indicator, streaming assistant, visible queued rows) sits in the visible band above the composer. Remaining slack is chatAtBottomSlack(pad, overlay) = max(0, pad - overlay) + 8. Overlay is the floating composer box, not the pad CSS variable.

Last-message + 16px is rejected because it measures against the scroller’s visible bottom, which is under the overlay, and because it ignores the pad that Jump already uses. Pinning to the padding edge without slack would hide Jump until the reader scrolled an extra ~25vh; that bug is already fixed in isChatAtBottom and is kept.

Target architecture

1
Composer submit, queue drain, slash submit, or Jump writes a follow gesture. WebSocket frames do not scroll. The history loader does not pin latest to scrollHeight on registered.keys().next(). After data publication, the matching hook applies send snap or Jump smooth. Window replacement uses per-area history restoration; see History restoration ownership.
2
useChatAreaStick is the DOM applicator and state machine (stuckRef, jumpingRef, lastPointerRef, pending latest-load). MessageList and the peer pane pass the scroller, column, overlay reader, and scroller chatKey.
3
Primitives live in chat-scroll.ts (latestScrollTop, take-latest note, overlay vs pad, snapToLatest, resolveChatScrollTop as whether-to-move). The hook applies them. There is no decideFollow action enum.
4
ResizeObserver on the column pins only while attached, on the latest window, and not during an in-flight Jump. Take-latest forces stuckRef and snaps inside the 600ms window only when already on the latest window. While after is set, take-latest is pending: stay detached, keep Jump, do not snap the stale page. 600ms still blocks unrelated in-transcript growth.
5
Legacy scrollToBottom becomes a documented no-op shim, then is removed with its callers. useStickToBottom is deleted. The loader stops pinning latest onto an arbitrary first viewport in the same unification.
Public entryGestureAttachedMotionHistory window
Composer submit (Enter / Send), including first message from welcometake-latestOn the latest window: true immediately. While after is set: pending, stay detached, keep Jump. After successful latest load: true. On load failure or !isCurrent: stay detached.See send latch. On the latest window: layout sees the note, forces stuckRef = true (ignore 600ms and current isChatAtBottom), snapToLatest now and on the next column resize this turn. While after is set: pending, stay detached, keep Jump. 600ms still blocks unrelated in-transcript growth. Instant snap, not smooth. Jump hides on snap, not while pending. Welcome: one retry when welcomeVisible / #welcome-mount child list clears, main shell only.If after is set, do not snap the stale page. Await loadSessionHistoryWindow(..., "latest") with isCurrent in a Jump-style async callback, not inside useLayoutEffect. Loader must not pin latest on registered.keys().next(); after data publication the matching hook applies the still-current send.
Queued drain that actually calls sendChatMessagetake-latest (same as submit)Same latch as submit for the submitting scroller onlyInstant snap (same latch)Same as submit. Parking a queued row is not a send; draining it is.
Composer slash / builtin submit that does not call sendChatMessage (dispatchFunction in use-chat-submit.ts)take-latest at the submit siteSame as submitSame latch. The WS local_command handler does not scroll and does not note.If a local_command row arrives with no prior submit note (another client), follow only while already attached.
Jump to latest (main: portal on #chatView; peer: existing header button, no portal, no live bars)jumpTrue after latest is on screen; button stays until settle then fades (jumpingRef / JUMP_LATEST_FADE_MS)Native smooth scroll; instant if prefers-reduced-motion: reduce. Pointer/wheel on the scroller cancels the ride.Await latest, then animateJumpToLatest. Existing isCurrent cancellation stays. Loader must not pin latest on an arbitrary first viewport; after data publication the matching hook begins the still-current smooth Jump without a preliminary snap.
#chatArea / peer scroller: wheel, pointer, keyboarduser-scrollTrue iff isChatAtBottom and not afterNoneUnchanged page. Scroll-up detaches. Scroll into slack re-attaches.
Stream / tool / KaTeX / recycle / pad / composer resizegrowthUnchangedPin to latestScrollTop only if attachedNo pin while after is set.
Session switch (chatKey change)restoreAttached after latest placementRestore saved position on return; first opening follows latestNot a send. Relocate a pending take-latest note only when the incoming session is this draft’s adopted identity; switching to another existing session creates its own activation intent. History autoload / around-anchor remains useHistoryWindow.
History window load that is not a take-latest/jumpwindowFalse if after, else geometricLoader does not pin latest to scrollHeight on registered.keys().next(). After flushSync, apply history-viewport restore on every registered area for older/around.Older/newer pages never pin as if they were latest. Restore is a policy write of scrollTop, not a loader pin of latest.
WS chat_response / compaction / status / follow-up / local_command frameNone—No scroll call and no take-latest note from the handlerRows land in the store; the view follows only via growth + attached, unless the composer submit already noted take-latest.

Send latch sequence

  1. sendChatMessage (or slash submit) calls appendLocalUserTurn when applicable, then noteTakeLatest({ sessionId, scrollerKey, turnSeed }). Default inside sendChatMessage: scrollerKey = background ? "peer:" + sessionId : sessionId. Interactive submit may pass scrollerKey explicitly. Do not read live activeChatKey at drain (that is the focused session, which may not be the draining session). Queue drain inherits because it already calls sendChatMessage with sessionId and background; send-queue.ts stays unchanged.
  2. The matching hook’s layout effect sees a note whose generation is newer than lastAppliedGeneration and whose scrollerKey equals this instance’s chatKey. Other panes do not latch. The note is not deleted on read (Strict Mode and two hook instances).
  3. If history.after is set, do not set attached, do not snap, keep Jump. Start the same async isCurrent await Jump uses. Layout effect must not await.
  4. When on the latest window (or after a successful latest load): set stuckRef = true regardless of the 600ms pointer window and regardless of current isChatAtBottom. Assign snapToLatest now.
  5. The next column ResizeObserver callback in this turn also snaps (late row height). That pin ignores 600ms because the take-latest generation is still current. Unrelated in-transcript growth still honors 600ms.
  6. Main shell only: subscribe to welcomeVisible or observe #welcome-mount children. When the overflow lock lifts, one retry snap if the same take-latest generation is still current. Peer sends use background: true and skip welcome.
  7. Ack that appends the assistant placeholder does not detach: attached is already latched, not derived from lastRole.

Gesture lifetime and cancellation

A note identifies a send, not permanent permission to scroll. Store notes independently by (sessionId, scrollerKey); peekTakeLatest(sessionId, scrollerKey) reads that pair. A main send and a peer send in the same session must not overwrite each other. Publishing a note must notify the matching view even when no transcript row changes (slash submit); reading a mutable map only during unrelated renders is insufficient. turnSeed identifies the accepted optimistic turn, not the current last row after ack.

  1. Each active follow operation captures session id, scroller key, generation, and a view interaction epoch. Every async completion, next-resize snap, welcome retry, and animation callback verifies all four before writing. A newer send replaces the older operation for that scroller. Jump replaces a pending send operation; send replaces Jump and stops its native animation before instant placement.
  2. Wheel, touch/pointer scroll intent, and scrolling keys in the transcript invalidate pending work before subsequent resize or network callbacks. They clear the forced next-resize/welcome retry. Geometry then determines attached after actual scrolling; clicking an execution control retains the existing temporary growth suppression. Programmatic scroll events alone are not cancellation. After send then user scroll-up, delayed growth never forces reattachment.
  3. Changing key or truly unmounting invalidates the outgoing operation. A recent return restores reading state; an expired return creates a new latest-placement operation without replaying a completed or cancelled send note. Keep note data readable; do not delete it on read. Maintain explicit operation completion/cancellation and per-view applied-generation bookkeeping. Strict Mode effect cleanup cancels that effect's resources; setup must resume a still-valid pending operation, without a second logical send, lost retry, or an orphaned request. Do not mark a generation completed when merely starting a latest request. Session delete and draft drop forget that session’s notes. Idle completed notes without an active viewport are capped; waiting send notes are kept. Releasing a viewport schedules that cap after the current turn so a same-tick remount can retain again.
  4. A first welcome send may publish before the transcript becomes visible. Preserve only that still-pending handoff until its matching view is ready; an initial subscription baseline must not discard it. Retry after visibility/layout readiness once, and cancel on a later user gesture or session departure. Existing provisional-to-real session binding must preserve the pending operation's identity without generating another send or replaying a completed one. Binding is the adopted session identity of that draft, not “any local_* key to any non-draft key”. Ack message-id replacement does not require turnSeed to remain the transcript's last id.
  5. While an older/around request is in flight, a send waits for a verified latest window even if the currently displayed page has no after yet. Use history.after || history.loading to enter asynchronous coordination, then verify the resulting latest page and operation identity. A cancelled/failed request leaves no forced retry and cannot hide Jump. A newer valid gesture may still succeed; an older completion must not detach or clear it.

One next-resize retry belongs to the operation, not to the entire assistant turn. After that retry, ordinary growth uses attached plus the normal interaction rules. Do not infer unlimited bypass from an unchanged generation. Each hook owns its resources and cleanup; no polling timer or persistent attached state is added.

Jump completion with changing content

On latest-load success, Jump starts smooth scrolling directly from the preserved position; no pre-animation snap is allowed. While Jump is active, resize records the changed target but does not instant-pin. At native settle, recompute latestScrollTop. If the target moved, stop the completed animation and perform one instant correction to the current padding edge, then verify placement. Hide Jump only after this verification and only if the same operation is still current. Reduced motion uses the same final verification with instant placement from the start.

The fallback timeout is a watchdog, not proof of arrival. At the timeout, a still-current operation stops native motion, corrects to the current target, verifies, and only then completes. Cancelling must stop browser motion as well as callbacks; removing a scrollend listener alone is not cancellation. No cancelled operation performs a correction. Subsequent growth resumes ordinary attached pinning. Accept a small geometry tolerance (2px); do not substitute the larger reattachment slack for exact Jump placement.

History restoration ownership

loadSessionHistoryWindow coordinates requests and publishes rows/window metadata. It never performs latest placement or examines send/jump notes. useChatAreaStick alone chooses and applies send snap or Jump smooth after a successful latest load. For older/newer/around replacement, existing history-viewport helpers own anchor restoration: capture a separate anchor, old top, and old height for each registered area before the update; restore after flushSync only when that area is still registered for the same session and captured chatKey. A previously registered area that lost registration is not restored. The unregistered #chatArea fallback applies only when nothing was registered at capture and the node is still the live main scroller. A still-connected node is not enough if it has been reused. Use each area's own chat key for height-cache maintenance. Do not reuse the first area's anchor or fallback delta.

History restoration is the explicitly retained scroll writer for window replacement, not another follow policy. It does not run a latest snap, and it must not overwrite an active, newer follow operation. Shared session data can replace rows in both main and peer panes; preserve each non-requesting pane's visible anchor when it remains available. If eviction removes that anchor, keep the existing bounded fallback and document it in acceptance evidence; scroller isolation does not promise independent history datasets.

Trust boundaries, data flow, failures

Compatibility and exclusions

API and data-model changes

Extend chat-scroll.ts. Do not add a fourth stick module. Primitives plus a generation-stamped note; useChatAreaStick remains the state machine. Illustrative surface (names may tighten in review; behavior must not):

export function latestScrollTop(area: ScrollMetrics): number;
// Named clamp Jump already uses: max(0, scrollHeight - clientHeight).

export type TakeLatestNote = {
  sessionId: string;
  scrollerKey: string; // default: background ? `peer:${sessionId}` : sessionId
  turnSeed: string;    // optimistic message id / lastId at send
  generation: number;  // monotonic; never delete-on-read
};

export function noteTakeLatest(note: Omit<TakeLatestNote, "generation">): TakeLatestNote;
export function peekTakeLatest(sessionId: string, scrollerKey: string): TakeLatestNote | null;
// No consumeTakeLatest. Each hook compares note.generation to a per-instance
// lastAppliedGeneration ref. Strict Mode re-runs are idempotent.

export function readComposerOverlay(area: HTMLElement | null): number;
// Main: --main-composer-height (overlay only).
// Peer: composer root offsetHeight, or (--peer-composer-h - 24).
// Never treat --peer-composer-h as overlay.

export function snapToLatest(area: HTMLElement): void; // instant
// animateJumpToLatest stays; respects prefers-reduced-motion

resolveChatScrollTop remains the whether-to-move function and may still return scrollHeight as a sentinel. Callers assign latestScrollTop(area) after a follow decision. FollowGesture in the trigger table is documentation, not a function argument.

Key space: send knows sessionId and background. sendChatMessage defaults scrollerKey to background ? "peer:" + sessionId : sessionId (main chatKey equals that session id when that session is shown; peer panes always use the peer: prefix). Interactive submit may pass scrollerKey explicitly. Do not read live activeChatKey at drain. A hook latches only when note.scrollerKey === chatKey. Two panes of different sessions never share a latch. Two panes of the same session currently share peer:${sessionId} (same collision as row-height cache); this slice does not add a pane instance id. Public-entry test: send from an unfocused peer composer of session Y while the other pane shows session X attaches Y only.

useChatAreaStick keeps { detached, jumpToLatest }. Until the send-path note lands, lastRole === "user" remains a documented fallback so send-follow stays green. Dropping lastRole and updating check-chat-ui.mjs happen in the same merge as noteTakeLatest.

Loader: loadSessionHistoryWindow must not pin latest to scrollHeight on registered.keys().next(). The hook owns send/Jump placement after publication. History-viewport helpers restore each area independently for window replacement, as specified in History restoration ownership.

Legacy:

/** @deprecated React MessageList owns follow. No-op. */
export function scrollToBottom(_opts?: { force?: boolean }): void {}

Then delete it and every caller. Delete useStickToBottom. Delete runtimeState._skipScrollToBottom once conversations.ts no longer force-scrolls; session restore is the React layout effect.

Do not call any scroller from chat-handlers.ts.

Acceptance criteria

Public entrySetupObservable outcome
Composer submitScrolled far up on the latest window; press Enter or SendOptimistic user bubble (and pending indicator if still last-role user) is in the visible band above the composer at 0ms after the store append. Instant snap, not smooth. Attached becomes true. Jump hides at snap, not before.
Composer submitAlready attachedSame snap-to-latest; stream that follows stays pinned.
Welcome first sendWelcome visible; overflow lockedWelcome hides at 0ms; after the lock lifts, the first user row is in the live band. Scroll is not left at 0 on an unlocked empty pad.
Queue drainA queued row actually sends via sendChatMessage (possibly while another session is focused)Same take-latest as a manual send. scrollerKey defaults from sessionId and background, not live activeChatKey. Enqueue without drain does not move the viewport. send-queue.ts stays unchanged.
Jump to latestDetached on an older windowAwaits latest page, then smooth-scrolls (instant if reduced motion). Jump stays visible until settle, then fades. Pointer/wheel on the scroller cancels the ride. Main uses the #chatView portal; peer uses the existing header button.
#chatArea scrollWheel up until last bubble tucks under the composerDetached; Jump visible; later assistant/tool deltas do not move scrollTop.
#chatArea scrollScroll back into slackRe-attaches; Jump hides; subsequent growth pins.
Stream deltasAttached; KaTeX / recycle / token growthPin to latest. Math renders through the same pin path.
Agent/system row without take-latestDetachedscrollTop unchanged. Compaction and the local_command frame must not call a scroller. Composer slash/builtin submit notes take-latest at the submit site.
Session switchSaved position, or latest for first openingRestores saved position on return. First opening follows latest without sending a message.
History windowSend while pages[sid].after is setNo snap on the stale page. Jump stays. Attached stays false until latest load succeeds, then snap+attach. Failure or !isCurrent: stay detached, Jump visible. Loader must not pin latest on registered.keys().next().
Peer session paneSend from an unfocused peer composer (session Y) while the other pane shows session XOnly Y’s scroller attaches and snaps. X does not move. Slack: overlay = composer root, pad = h+24, slack = 24+8. Peer Jump is the header button (no portal, no live bars). Focused shell welcome/run flags stay untouched (background: true).
Ack after sendAssistant placeholder becomes last rowDoes not detach. Stream continues to pin.
Scroll then send within 600msPointer on the area, then composer submitTake-latest still snaps and pins. 600ms suppression does not apply.
Legacy helpersGrep after unificationNo production caller of scrollToBottom except a no-op shim pending deletion. useStickToBottom gone. No module stickToBottom listener on #chatArea.

RED tests to add (public entries, not implementation details): take-latest note survives ack and Strict Mode double layout; send + history.after stays detached until latest; pin after late row height; 600ms does not block send; scrollToBottom is unused; loader does not pin latest on registered.keys().next(); drain uses background ? "peer:" + sessionId : sessionId and not live activeChatKey; peer overlay/pad slack 24+8; send from peer Y does not snap pane X; welcome overflow then one retry; reduced-motion Jump is instant. Keep lastRole === "user" assertions until the PR that lands noteTakeLatest. Peer pad/overlay unit: pad h+24, overlay h ⇒ slack 24+8.

Conversation switching and file summaries

Opening a conversation without a saved viewport places it at the latest message, including after asynchronous file-card growth. Recent returns restore reading state; expired or restarted views show latest. A saved pixel offset is not a latest-message anchor. First opening from an older history window requests the latest window through the existing cancellable follow operation. Manual wheel or keyboard scrolling cancels pending following; historical pagination keeps its anchor contract. Main and peer viewports retain separate keys.

Legacy file-change summaries are loaded only near the viewport, then retained in the existing session message store. Re-mounted cards render the successful summary immediately, including confirmed empty results. Errors remain retryable and aborted or stale responses never update another session. History preflight remains fresh and mutation authorization remains server-side; summaries are historical descriptions, not permission to undo.

Acceptance uses actual React components and the scroll hook: remount a legacy card with no second summary request; first open with delayed content growth and reach latest; return to an opened conversation and restore its position; cancel growth following on manual input; first open older history without a saved position and ignore stale completion. No backend protocol, message ordering, persisted history, or model behavior changes.

The hook follows latest for new, expired or previously attached viewports; recent detached views restore saved positions. Actual React regressions cover initial delayed growth, return restoration, manual cancellation, stale completion and summary retention. Review, full frontend gates and installed App evidence are recorded separately in the task report.

Implementation handoff

This section is the single implementation plan. The web renderer implements the unified contract in this page. Read this page, AGENTS.md, tests/README.md and the OpenProgram workflow before changing follow behavior. Record exact commits in the ignored reports ledger, not in this design body.

One implementation owner works sequentially through A, B, C and D. Each code stage gets a specification review by a non-author, then a fresh quality review, with confirmed fixes and scoped re-review before the next stage. Reviewers do not edit tracked files. Do not split B across independently mergeable PRs. A may merge alone; B must include legacy no-op and all B tests. C can delete the no-op later. Remote push/merge is outside this planning authorization.

StagePrimary files (repository-relative)Deliverable
Aapps/web/lib/chat/chat-scroll.ts
apps/web/tests/chat/chat-scroll-follow.test.mjs
apps/web/tests/chat/jump-latest.test.mjs
Primitives, keyed observable notes, exact placement, cancellation-capable Jump helper. Preserve current callers until B.
Bapps/web/components/chat/messages/use-chat-area-stick.ts
apps/web/components/chat/messages/message-list.tsx
apps/web/components/chat/peer-session-pane.tsx
apps/web/components/chat/composer/submit/send-chat-message.ts
apps/web/components/chat/composer/submit/use-chat-submit.ts
apps/web/lib/runtime-bridge/session-history-loader.ts
apps/web/lib/chat/history-viewport.ts
apps/web/components/chat/messages/use-history-window.ts
apps/web/lib/runtime-bridge/helpers.ts
Atomic integration: note publication/subscription, lifecycle, latest loading, per-area restoration, overlay reader, welcome handoff, Jump completion, legacy no-op and removal of its global listener. Remove lastRole inference in the same candidate.
Capps/web/lib/runtime-bridge/helpers.ts
apps/web/lib/runtime-bridge/chat-handlers.ts
apps/web/lib/runtime-bridge/conversations.ts
apps/web/lib/format-utils/scroll.ts
apps/web/lib/runtime-bridge/state.ts
Remove no-op callers/imports, dead useStickToBottom and obsolete _skipScrollToBottom declarations/assignments. Search all references before removal; preserve explicit locator helpers.
DBoth chat-transcript-follow HTML pages
apps/web/tests/chat/send-chat-message-latency.test.mjs
apps/web/tests/desktop/self-update-scroll.test.mjs
apps/web/scripts/chat/check-chat-ui.mjs
tests/e2e/web/test_chat_history_interactions.py
tests/e2e/web/test_chat_transcript_follow.py
Combined-candidate verification and documentation evidence. Tests listed here are added with A/B/C when their behavior changes, not deferred to D. D adds no missing core behavior.

At B entry, enumerate all legacy callers and the existing provisional binding path with CodeGraph; extend the brief with exact discovered files only if required for this contract. Do not redesign ack or queue admission. The default scroller key uses captured session/background, so send-queue.ts remains unchanged; an explicit custom key outside these defaults is not introduced. Peer overlay reads the composer root offsetHeight, with the existing pad reader. No new CSS variable or pane instance id.

Acceptance matrix

A tests are pure/shared-boundary tests. B lifecycle, layout and animation cases use real React hooks in a browser, with actual public submit/queue entries and controlled WebSocket responses. Extend the existing history interaction harness; do not copy the preview policy into a test implementation. Assert scrollTop, row/overlay geometry, button visibility, request ordering and cleanup. Run StrictMode explicitly; source-string assertions cannot prove timing. New proposed test files are created in their owning stage.

IDStageRequired observation
F01AEmpty/short/overflow geometry; main slack and peer h+24 versus h = 32px.
F02A/BIndependent main/peer notes in the same session; increasing generations; subscription fires without a row update.
F03BEnter and Send while far up: socket accepted, user row visible above overlay before ack; assistant placeholder then stream continue following.
F04BScroll then send inside 600ms; delayed column growth follows once. Send then scroll up before delayed growth does not snap back.
F05BDetached stream/tool/math growth preserves top; scroll into slack reattaches; execution-row expansion retains suppression.
F06Bafter send: no stale-page write, pending Jump visible, success snaps; failure and cancellation stay detached.
F07BSend during loading older/around with no current after; settle that request then fetch latest; stale responses never win over newer gestures.
F08BStrictMode initial send and pending latest cleanup/setup: no lost intent, no duplicate logical send, no stranded pending state.
F09BSend, detach, switch away and back; true unmount/remount restores saved position without replaying old send notes. Switch during request does not move incoming chat.
F10BWelcome first send before transcript readiness and provisional ack binding; one valid retry, cancelled retry never writes.
F11BPeer Y sends while pane X is detached: only Y moves. Same-session main/peer notes stay independent; shared peer key intentionally affects matching peers.
F12BEnqueue alone does not move; actual drain uses captured session/background while focus changes. Socket closed/throw or duplicate pending-first-send creates no new note.
F13BSlash/builtin submit follows without relying on an immediate new row; remote local_command/compaction/status while detached do not follow.
F14BOlder-window Jump loads then visibly smooth-scrolls, never pre-snaps; button remains until verified completion.
F15BGrowth during Jump stops before animation ends: final current latest reached within 2px, then button hides; test watchdog without scrollend.
F16BPointer/wheel/scrolling key cancels pending or native Jump; no later animation/correction; send during Jump wins instantly. Reduced motion is instant.
F17BTwo registered areas with distinct positions restore their own older/newer/around anchors and fallback deltas; latest has no arbitrary loader pin.
F18C/DNo legacy global follow listener or production scrollToBottom/useStickToBottom references; explicit locators and self-update persistence guard still work.

Commands and completion gates

Run commands from the repository root. Install locked dev/browser dependencies and Chromium as in CONTRIBUTING.md; use the installed workspace Node dependencies. A runs its two primitive test files first; B/C run the relevant focused selection below. Capture an actual failing baseline through the production boundary before the corresponding fix, then GREEN. A RED is not required for a new API that does not exist yet; the behavioral defect still needs its B baseline.

node --no-warnings --experimental-strip-types --test apps/web/tests/chat/chat-scroll-follow.test.mjs apps/web/tests/chat/jump-latest.test.mjs apps/web/tests/chat/send-chat-message-latency.test.mjs apps/web/tests/desktop/self-update-scroll.test.mjs
npm --prefix apps/web run check:chat-ui
npm --prefix apps/web run check:provisional-send
npm --prefix apps/web run check:send-queue
uv run --locked --extra dev --extra browser python -m pytest -q -m browser tests/e2e/web/test_chat_transcript_follow.py tests/e2e/web/test_chat_history_interactions.py tests/e2e/web/test_automatic_chat_history.py tests/e2e/web/test_scalable_chat_history.py

After the final code candidate passes both reviews, run this full manifest once. New tracked code/test/dependency changes require scoped re-review and another full run. Missing dependencies or verified baseline failures remain explicit failed/pending gates, never replaced with a smaller suite. CLI is unchanged; add desktop checks only if desktop source changes.

npm --prefix apps/web test
(cd apps/web && npx tsc --noEmit)
npm --prefix apps/web run check
npm --prefix apps/web run build
uv run --locked --extra dev ruff check openprogram tests scripts
uv run --locked --extra dev python -m pytest -q tests/contracts
uv run --locked --extra dev python -m pytest -q tests/unit
uv run --locked --extra dev python -m pytest -q tests/component
uv run --locked --extra dev python -m pytest -q tests/integration
uv run --locked --extra dev python -m pytest -q -m "not browser" tests/e2e
uv run --locked --extra dev --extra browser python -m pytest -q -m browser tests/e2e/web
uv run --locked --with markdown-it-py --with mdit-py-plugins --with pygments python -m scripts.docs_site.build
uv run --locked --with markdown-it-py --with mdit-py-plugins --with pygments python -m scripts.docs_site.check_landing
uv run --locked --with markdown-it-py --with mdit-py-plugins --with pygments python -m scripts.docs_site.checklinks
git diff --check

Browser automation uses isolated test state, no real user messages or external model calls. Product visual acceptance uses only /Applications/OpenProgram.app and default profile/18100/~/.openprogram. Do not launch a dev profile, second frontend or test Electron as a visible App. After the complete feature is reviewed and committed, refresh once using scripts/refresh-local-app.sh from that checkout, then verify main send-before-ack, peer isolation, older-window send, Jump with growth/cancellation, welcome and session restore. Capture actual geometry and screenshots; the HTML preview is not evidence.

Ledger: reports/chat-transcript-follow/ records each stage base/candidate, RED and GREEN commands/results, F01–F18 test names and evidence, specification/quality verdicts, fixes, final full-gate outputs, clean scoped diff and App acceptance. Keep source tests, commit, installed App and remote state separate. No implementation-complete mark until all required evidence exists. Rollback B atomically (hook + send + loader + no-op); never restore a competing writer alone.

Implementation status

AreaStateBoundary
chat-scroll.ts math (isChatAtBottom, slack, storage, animateJumpToLatest, resolveChatScrollTop)PresentTake-latest notes, latestScrollTop, overlay vs pad, and cancel-capable Jump live in this module.
useChatAreaStick + Jump portalPresentSingle React owner. Send identity is the take-latest note, not lastRole. Peer overlay is the composer root.
Optimistic user row at sendPresentappendLocalUserTurn at socket accept notes take-latest for the submitting scroller.
Legacy scrollToBottom / module stick flagRemovedNo production web callers. Handlers do not scroll.
useStickToBottomRemovedFile deleted.
History loader scrollTop writesPresentLoader does not pin latest. Per-area restore lives in history-viewport helpers and skips a locked follow.
Unified follow contract on this pagePresentWeb App and browser share this renderer. Evidence is the reviewed implementation commits on codex/chat-transcript-follow, not this preview.

Rollout: no feature flag. Do not merge the hook change without send identity (lastRole fallback or noteTakeLatest in the same train). If send-from-scrolled-up fails after a partial stack, revert that train. App and browser share the web renderer; rollback is git revert, not a protocol change.

Implementation defaults

References