diff --git a/CHANGELOG.md b/CHANGELOG.md
index 30a8f4aa..f5ec3946 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+- **Chat scrolling rebuilt around your message.** Sending a message now parks it near the top of the view and the reply streams into the space below it, so you read from where you asked instead of chasing the bottom. While a reply streams, text arrives a paragraph at a time (code blocks line by line) with a soft fade, and the view glides after it in one continuous motion instead of snapping per line. Scrolling up during a stream immediately hands you the wheel — nothing yanks the view back — and the scroll-to-bottom pill appears on the left, carrying the model's working status while you're away from the live edge. Sending from anywhere mid-conversation jumps you straight to your new message.
+- Chat: a new "Follow new content while streaming" checkbox (Settings → Chat → Streaming, on by default) turns the automatic following off entirely — your message still parks at the top on send, but the view never moves on its own afterwards.
+- Chat: streamed code blocks are now syntax-highlighted while they stream, and finished messages no longer jump when a code block's line numbers fill in at the end of a reply.
+- Chat: finished replies no longer flicker — tool cards stopped re-rendering (and replaying their reveal animation) when they completed, and resizing the window no longer throws the conversation up and down while you're at the bottom.
+- Chat: clicking the last item in the prompt rail now always lands on it, and rail jumps teleport instead of a long smooth scroll that could stop halfway.
+- Chat: opening a session goes straight to the newest message with no scroll animation, and the first uncached open fades the conversation in instead of popping.
+- Mobile: scrolling during a streaming reply works again — a drag immediately takes over, the scroll-to-bottom pill shows up, and the load-older button no longer throws you to the bottom of the chat.
+- UI: the chat's top and bottom scroll fades are back.
+- Fixed file links in messages being checked twice against the filesystem, and against the wrong project directory on the first pass.
- **Chat context attachments:** everything you attach to a message — diff/file/plan comments, terminal selections, browser annotations, PR comments and failed checks, linked issues and PRs — now shows up in the conversation as a compact context card: a header naming the source, the captured content behind an expander, and your comment below it. Previously most of these arrived as a wall of raw text inside your message.
- **Chat: comment on a reply.** Select text in a chat message and choose Comment to attach that quote with your note to the next message. The selection stays highlighted while you type, and the selection menu itself was restyled — Add to chat is now Add to input.
- **Diff: comment like a review.** Hovering a line shows a + button in the gutter; clicking it, clicking a line, or dragging across lines opens the comment editor for that line or range. The comment editor and saved-comment cards now match the chat's comment style.
diff --git a/bun.lock b/bun.lock
index 7405486f..3155488f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -167,6 +167,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
+ "@legendapp/list": "3.3.8",
"@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "1.18.21",
"@pierre/diffs": "1.3.0-beta.6",
@@ -918,6 +919,8 @@
"@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="],
+ "@legendapp/list": ["@legendapp/list@3.3.8", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom", "react-native"] }, "sha512-GM4Hca/6WDvcY33XXCieR9MaG9CoZmACzwqQwRhKFSaNKfQV1lTLiTOpWuA9wnE8n8+6WeA52DwNKC9yrnPPeg=="],
+
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
"@lezer/common": ["@lezer/common@1.5.1", "", {}, "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw=="],
diff --git a/packages/electron/scripts/electron-dev.mjs b/packages/electron/scripts/electron-dev.mjs
index a4ee60e4..c8ef57a6 100644
--- a/packages/electron/scripts/electron-dev.mjs
+++ b/packages/electron/scripts/electron-dev.mjs
@@ -220,7 +220,7 @@ async function main() {
});
}
- const electron = spawnProcess('npx', ['electron', './main.mjs'], {
+ const electron = spawnProcess('bun', ['x', 'electron', './main.mjs'], {
cwd: electronDir,
env: {
...process.env,
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 4d52e7cb..c9648636 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -43,6 +43,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
+ "@legendapp/list": "3.3.8",
"@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "1.18.21",
"@pierre/diffs": "1.3.0-beta.6",
diff --git a/packages/ui/src/apps/MobileChangesSurface.tsx b/packages/ui/src/apps/MobileChangesSurface.tsx
index 35e84fa6..b8e98b59 100644
--- a/packages/ui/src/apps/MobileChangesSurface.tsx
+++ b/packages/ui/src/apps/MobileChangesSurface.tsx
@@ -628,7 +628,6 @@ const MobileDiffDetail: React.FC<{
) : (
= ({ onClose
-
+
{directoryError ? (
) : query.trim() ? (
diff --git a/packages/ui/src/apps/MobileSessionsSheet.tsx b/packages/ui/src/apps/MobileSessionsSheet.tsx
index 99392c46..a64d94f0 100644
--- a/packages/ui/src/apps/MobileSessionsSheet.tsx
+++ b/packages/ui/src/apps/MobileSessionsSheet.tsx
@@ -1455,7 +1455,7 @@ export const MobileSessionsSheet: React.FC = ({ open,
// clipped overflow swallowed the footer.
const surfaceContent = (
-
+
{/* The search bar scrolls WITH the list (iOS-style): the open-time
auto-scroll to the current session naturally tucks it away, and
scrolling to the very top brings it back. */}
diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx
index e7541c2c..0873eee2 100644
--- a/packages/ui/src/components/chat/ChatContainer.tsx
+++ b/packages/ui/src/components/chat/ChatContainer.tsx
@@ -18,8 +18,8 @@ import { StatusRowContainer } from './StatusRowContainer';
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
import ScrollToBottomButton from './components/ScrollToBottomButton';
import { PromptNavigatorRail } from './components/PromptNavigatorRail';
-import { ScrollShadow } from '@/components/ui/ScrollShadow';
-import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow';
+import { useScrollShadow } from '@/components/ui/useScrollShadow';
+import { useChatTimelineScroll, type TimelineListHandle } from '@/hooks/useChatTimelineScroll';
import { useChatTimelineController } from './hooks/useChatTimelineController';
import { TimelineDialog } from './TimelineDialog';
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
@@ -151,11 +151,15 @@ type ChatViewportProps = {
currentSessionKey: string;
isDesktopExpandedInput: boolean;
isMobile: boolean;
- stickyUserHeader: boolean;
directory?: string;
scrollRef: React.RefObject;
messageListRef: React.RefObject;
- pendingRevealWork: boolean;
+ registerList: (list: TimelineListHandle | null) => void;
+ anchorMessageId: string | null;
+ onAnchorReady: (messageId: string, anchorIndex: number) => void;
+ onAnchorSizeChanged: (messageId: string) => void;
+ onIsAtEndChange: (isAtEnd: boolean) => void;
+ onTimelineDataChange: () => void;
renderedMessages: SessionMessageRecord[];
isLoadingOlder: boolean;
sessionIsWorking: boolean;
@@ -167,10 +171,11 @@ type ChatViewportProps = {
confirmedAt?: number;
fallbackTimestamp?: number;
} | null;
- handleMessageContentChange: (reason?: ContentChangeReason) => void;
- getAnimationHandlers: (messageId: string) => AnimationHandlers;
- handleHistoryScroll: () => void;
scrollToBottom: () => void;
+ endPinningReleased: boolean;
+ // One-shot fade for content that replaced the hydration skeleton;
+ // cached sessions render instantly without it.
+ revealContent: boolean;
sessionQuestions: QuestionRequest[];
sessionPermissions: PermissionRequest[];
isProgrammaticFollowActive: boolean;
@@ -190,21 +195,24 @@ const ChatViewport = React.memo(({
currentSessionKey,
isDesktopExpandedInput,
isMobile,
- stickyUserHeader,
directory,
scrollRef,
messageListRef,
- pendingRevealWork,
+ registerList,
+ anchorMessageId,
+ onAnchorReady,
+ onAnchorSizeChanged,
+ onIsAtEndChange,
+ onTimelineDataChange,
renderedMessages,
isLoadingOlder,
sessionIsWorking,
streamingMessageId,
activeStreamingPhase,
retryOverlay,
- handleMessageContentChange,
- getAnimationHandlers,
- handleHistoryScroll,
scrollToBottom,
+ endPinningReleased,
+ revealContent,
sessionQuestions,
sessionPermissions,
isProgrammaticFollowActive,
@@ -315,89 +323,96 @@ const ChatViewport = React.memo(({
scrollRef.current?.focus({ preventScroll: true });
}, [scrollRef]);
+ // Everything that used to sit beside the list inside the scroll container
+ // now renders as the list's header/footer, so it keeps scrolling with the
+ // rows exactly as before.
+ const listHeader = React.useMemo(() => (
+ showLoadOlderButton ? (
+
+ );
+};
diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx
index 456afa5f..c1a5c358 100644
--- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx
+++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx
@@ -352,6 +352,13 @@ const useFileReferenceInteractions = ({
if (!container) {
return;
}
+ // Wait for the real directory: annotating against an empty/fallback
+ // directory issues stat probes under the wrong cache key (and the wrong
+ // server directory), and the pass reruns anyway once the directory
+ // resolves — every link ended up verified twice.
+ if (enabled && !effectiveDirectory) {
+ return;
+ }
let cancelled = false;
const fileReferenceLinkLimit = getFileReferenceLinkLimit();
// On mobile surfaces, file-reference highlighting is disabled entirely — not
@@ -405,6 +412,19 @@ const useFileReferenceInteractions = ({
};
const annotateFileLinks = () => {
+ annotationWriteDepth += 1;
+ try {
+ annotateFileLinksInner();
+ } finally {
+ // Let the mutation events from our own writes flush before the
+ // observer starts listening for real content changes again.
+ queueMicrotask(() => {
+ annotationWriteDepth -= 1;
+ });
+ }
+ };
+
+ const annotateFileLinksInner = () => {
if (fileReferencesEnabled) {
wrapBlockCodePathTokens(container);
}
@@ -533,7 +553,12 @@ const useFileReferenceInteractions = ({
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
+ // Our own annotation writes (path-token wrapping, attribute updates) fire
+ // childList mutations too; observing them re-ran the whole pass — every
+ // link was scanned and verified twice per render.
+ let annotationWriteDepth = 0;
const observer = new MutationObserver(() => {
+ if (annotationWriteDepth > 0) return;
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
});
observer.observe(container, {
@@ -918,13 +943,19 @@ const useMorphdomMarkdown = ({
if (!active || renderRevisionRef.current !== renderRevision) return;
const existing = Array.from(target.children) as HTMLElement[];
+ // Reconcile per block: only re-morph blocks whose content changed, leaving
+ // stable leading blocks untouched. Keeps per-stream-step DOM work bounded
+ // to the trailing (growing) block instead of the whole message.
+ let enteredThisPass = 0;
blocks.forEach((block, index) => {
let el = existing[index];
+ let isNewBlock = false;
if (!el) {
el = document.createElement('div');
el.setAttribute('data-md-block', '');
el.style.display = 'contents';
target.appendChild(el);
+ isNewBlock = true;
}
if (el.getAttribute('data-md-id') === block.id) {
if (el.getAttribute(MARKDOWN_DECORATION_ID_ATTR) !== decorationId) {
@@ -952,6 +983,23 @@ const useMorphdomMarkdown = ({
const temp = document.createElement('div');
temp.innerHTML = block.html;
decorateMarkdown(temp, ctx);
+ if (isNewBlock && streaming && index > 0) {
+ // A freshly committed block enters with a short reveal. The class
+ // goes on the block's children — the wrapper is display:contents
+ // and cannot animate — and the transform never changes layout, so
+ // row measurement stays exact. Skipped for the first block so a
+ // full initial render does not shimmer. Several blocks committed
+ // in one tick cascade with a small stagger instead of popping in
+ // together.
+ const delayMs = Math.min(enteredThisPass, 4) * 55;
+ enteredThisPass += 1;
+ for (const child of Array.from(temp.children)) {
+ child.classList.add('oc-md-block-enter');
+ if (delayMs > 0 && child instanceof HTMLElement) {
+ child.style.setProperty('--oc-md-enter-delay', `${delayMs}ms`);
+ }
+ }
+ }
const hadMermaidBlock = shouldRefreshMermaidViewers(el);
const tempHasMermaidBlock = shouldRefreshMermaidViewers(temp);
morphdom(el, temp, {
diff --git a/packages/ui/src/components/chat/MessageList.activationOverscan.test.tsx b/packages/ui/src/components/chat/MessageList.activationOverscan.test.tsx
deleted file mode 100644
index 16b6d446..00000000
--- a/packages/ui/src/components/chat/MessageList.activationOverscan.test.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
-import { Window } from 'happy-dom';
-import React from 'react';
-import { act } from 'react';
-import { createRoot, type Root } from 'react-dom/client';
-
-import { useActivationOverscan } from './useActivationOverscan';
-
-type Frame = FrameRequestCallback;
-
-describe('MessageList activation overscan', () => {
- let windowInstance: Window;
- let host: HTMLDivElement;
- let root: Root;
- let pendingFrames: Map;
- let nextFrameId: number;
- let renderCount: number;
-
- beforeEach(() => {
- windowInstance = new Window();
- Object.assign(globalThis, {
- window: windowInstance,
- document: windowInstance.document,
- HTMLElement: windowInstance.HTMLElement,
- Element: windowInstance.Element,
- Node: windowInstance.Node,
- IS_REACT_ACT_ENVIRONMENT: true,
- });
- pendingFrames = new Map();
- nextFrameId = 1;
- renderCount = 0;
- Object.defineProperty(windowInstance, 'requestAnimationFrame', {
- configurable: true,
- value: (callback: Frame) => {
- const frameId = nextFrameId;
- nextFrameId += 1;
- pendingFrames.set(frameId, callback);
- return frameId;
- },
- });
- Object.defineProperty(windowInstance, 'cancelAnimationFrame', {
- configurable: true,
- value: (id: number) => {
- pendingFrames.delete(id);
- },
- });
- host = document.createElement('div');
- document.body.appendChild(host);
- root = createRoot(host);
- });
-
- afterEach(async () => {
- await act(async () => root.unmount());
- windowInstance.close();
- });
-
- const Harness = ({ normalOverscan }: { normalOverscan: number }) => {
- renderCount += 1;
- const overscan = useActivationOverscan(true, normalOverscan);
- return ;
- };
-
- const runNextFrame = async (timestamp: number): Promise => {
- const nextFrame = pendingFrames.entries().next();
- if (nextFrame.done) throw new Error('No animation frame is pending');
-
- const [frameId, callback] = nextFrame.value;
- pendingFrames.delete(frameId);
- await act(async () => callback(timestamp));
- };
-
- test('restores normal overscan in at most two renders after the first paint opportunity', async () => {
- await act(async () => root.render());
- expect(host.firstElementChild?.getAttribute('data-overscan')).toBe('0');
- expect(renderCount).toBe(1);
-
- await runNextFrame(0);
- expect(host.firstElementChild?.getAttribute('data-overscan')).toBe('0');
- expect(renderCount).toBe(1);
-
- await runNextFrame(16);
- expect(host.firstElementChild?.getAttribute('data-overscan')).toBe('4');
- expect(renderCount).toBe(2);
-
- await runNextFrame(32);
- expect(host.firstElementChild?.getAttribute('data-overscan')).toBe('8');
- expect(renderCount).toBe(3);
- });
-
- test('cancels every pending restoration frame when the list unmounts', async () => {
- for (const framesToRun of [0, 1, 2]) {
- await act(async () => root.render());
- for (let frame = 0; frame < framesToRun; frame += 1) {
- await runNextFrame(frame * 16);
- }
-
- expect(pendingFrames.size).toBe(1);
- await act(async () => root.render(null));
- expect(pendingFrames.size).toBe(0);
- }
- });
-});
diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx
index 74b4b6db..4197f067 100644
--- a/packages/ui/src/components/chat/MessageList.tsx
+++ b/packages/ui/src/components/chat/MessageList.tsx
@@ -1,11 +1,10 @@
import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2';
-import { elementScroll, useVirtualizer as useTanstackVirtualizer, type ReactVirtualizer, type VirtualItem } from '@tanstack/react-virtual';
+import { LegendList, type LegendListRef } from '@legendapp/list/react';
import ChatMessage from './ChatMessage';
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
import TurnItem from './components/TurnItem';
-import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types';
import { useTurnRecords } from './hooks/useTurnRecords';
import { applyRetryOverlay } from './lib/turns/applyRetryOverlay';
@@ -19,107 +18,62 @@ import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/
import { streamPerfCount, streamPerfMark, streamPerfMeasure } from '@/stores/utils/streamDebug';
import type { StreamPhase } from './message/types';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
-import { useSessionParts } from '@/sync/sync-context';
-import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
+import { useSessionPartsForMessages } from '@/sync/sync-context';
import type { ReviewTransferDirection } from '@/lib/reviewFlow';
+import { resolveChatListAnchoredEndSpace, resolveTimelineIsAtEnd } from './lib/scroll/timelineScrollAnchoring';
import {
USER_SHELL_MARKER,
isUserShellMarkerMessage,
getShellBridgeAssistantDetails,
type ShellBridgeDetails,
} from './lib/shellBridge';
-import { useActivationOverscan } from './useActivationOverscan';
-const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5;
const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = [];
const EMPTY_UNGROUPED_MESSAGE_IDS = new Set();
-const TIMELINE_CACHE_LIMIT = 16;
-const sameKeys = (a: readonly string[] | undefined, b: readonly string[] | undefined): boolean => {
- if (a === b) return true;
- if (!a || !b) return false;
- if (a.length !== b.length) return false;
- return a.every((key, index) => key === b[index]);
-};
+// --- Timeline virtualization (@legendapp/list) -----------------------------
+// The timeline is a single virtualized list on every surface: history turns
+// AND the live streaming tail are rows of the same list, so the list owns one
+// coherent scroll position instead of arbitrating between a virtualizer and a
+// separately-rendered tail.
+//
+// Scroll behavior the list owns natively, which is why none of it exists here
+// any more:
+// • `maintainScrollAtEnd` keeps the live edge pinned as rows grow.
+// • `maintainVisibleContentPosition` preserves the read position when older
+// history is prepended, replacing the manual anchor-hold and the mobile
+// quiet-window prepend deferral.
+// • `anchoredEndSpace` reserves the tail space that parks a just-sent
+// message near the top of the viewport.
+const TIMELINE_ESTIMATED_ENTRY_SIZE = 320;
-// --- History virtualization (@tanstack/react-virtual) ----------------------
-// The history list virtualizes with @tanstack/react-virtual on all surfaces:
-// its core has bottom anchoring (anchorTo: 'end'), key-stable prepend
-// preservation, and native iOS touch/momentum deferral for scroll
-// adjustments — the failure modes that historically forced virtua off on
-// mobile and required manual prepend compensation on desktop.
-type TanstackVirtualizerInstance = ReactVirtualizer;
-type HistoryEngine = 'none' | 'tanstack';
-
-const TANSTACK_ESTIMATED_ENTRY_SIZE = 320;
-const TANSTACK_OVERSCAN = 8;
-// Touch flings cover more distance between paints than desktop wheels; a
-// larger window keeps fast mobile scrolling over mounted rows.
-const TANSTACK_MOBILE_OVERSCAN = 16;
-const resolveTanstackOverscan = (): number => (
- isMobileSurfaceRuntime() ? TANSTACK_MOBILE_OVERSCAN : TANSTACK_OVERSCAN
-);
-// Post-prepend anchor hold: measurements of freshly
-// prepended rows settle over multiple frames, so a single restore can be
-// invalidated by the next measurement pass. Re-assert the anchor until it
-// holds still for STABLE_FRAMES consecutive frames, giving up at MAX_FRAMES.
+// Anchor hold for an explicit viewport restore (session re-entry): row
+// measurements settle over several frames, so a single restore can be
+// invalidated by the next measurement pass. Re-assert until it holds still for
+// STABLE_FRAMES consecutive frames, giving up at MAX_FRAMES.
const ANCHOR_HOLD_STABLE_FRAMES = 30;
const ANCHOR_HOLD_MAX_FRAMES = 180;
-// Adaptive estimate bounds: only trust the session average once a few rows
-// are measured, and keep it inside sane turn-height bounds.
-const TANSTACK_ESTIMATE_MIN_SAMPLES = 5;
-const TANSTACK_ESTIMATE_MIN = 120;
-const TANSTACK_ESTIMATE_MAX = 1200;
-// "At bottom" tolerance for resize-adjustment decisions.
-const TANSTACK_AT_END_THRESHOLD_PX = 80;
-// Quiet-window prepend on mobile: while a touch drag or momentum scroll is
-// active, iOS owns the scroll position and ANY geometry change above the
-// viewport races against the native animation — a race that compensation
-// logic can only lose sometimes. So freshly loaded older history is held
-// (data already fetched, store already updated) and inserted into the
-// rendered list only once the gesture goes quiet. Safety valves: flush when
-// the user gets close to the top (a blank top is worse than a small hop) or
-// after MAX_HOLD_MS.
-const HISTORY_PREPEND_QUIET_MS = 160;
-const HISTORY_PREPEND_MAX_HOLD_MS = 1500;
-const HISTORY_PREPEND_NEAR_TOP_VIEWPORTS = 1.5;
-const HISTORY_PREPEND_MONITOR_INTERVAL_MS = 90;
-
-// A commit is a deferable prepend when older entries were inserted strictly
-// above the known content: the previous first key still exists deeper in the
-// list and the tail is unchanged. Anything else renders immediately.
-const isPrependAboveCommit = (previous: RenderEntry[], next: RenderEntry[]): boolean => {
- if (previous.length === 0 || next.length <= previous.length) return false;
- if (previous[previous.length - 1]?.key !== next[next.length - 1]?.key) return false;
- const previousFirstKey = previous[0]?.key;
- const insertedIndex = next.findIndex((entry) => entry.key === previousFirstKey);
- return insertedIndex > 0;
+// Reserved tail space that parks an anchored row near the top of the viewport.
+// `onReady` fires once the list has measured the anchor, `onSizeChanged` when
+// the reserved size is recomputed.
+// Presentation-only props forwarded to the scroll container the list renders.
+// Deliberately narrow: the list owns scroll and layout callbacks on that
+// element, so only styling, focus and click-through are caller-controlled.
+type TimelineScrollContainerProps = {
+ className?: string;
+ style?: React.CSSProperties;
+ tabIndex?: number;
+ onClick?: React.MouseEventHandler;
+ 'data-scrollbar'?: string;
+ 'data-scroll-shadow'?: string;
};
-const tanstackTimelineCache = new Map();
-
-const readTanstackTimelineCache = (sessionKey: string, keys: readonly string[]): VirtualItem[] | undefined => {
- const entry = tanstackTimelineCache.get(sessionKey);
- if (!entry) return undefined;
- if (sameKeys(entry.keys, keys)) return entry.items;
- tanstackTimelineCache.delete(sessionKey);
- return undefined;
-};
-
-const writeTanstackTimelineCache = (
- sessionKey: string,
- keys: readonly string[],
- virtualizer: TanstackVirtualizerInstance | null | undefined,
-): void => {
- if (!virtualizer || keys.length === 0) return;
- tanstackTimelineCache.delete(sessionKey);
- tanstackTimelineCache.set(sessionKey, { keys: keys.slice(), items: virtualizer.takeSnapshot() });
- while (tanstackTimelineCache.size > TIMELINE_CACHE_LIMIT) {
- const oldest = tanstackTimelineCache.keys().next().value;
- if (typeof oldest !== 'string') break;
- tanstackTimelineCache.delete(oldest);
- }
+type TimelineAnchoredEndSpace = {
+ anchorIndex: number;
+ anchorOffset?: number;
+ onReady?: (info: { anchorIndex: number | undefined; anchorKey: string | undefined; size: number }) => void;
+ onSizeChanged?: (size: number) => void;
};
const useStableEvent = (handler: (...args: TArgs) => TResult) => {
@@ -351,7 +305,6 @@ const withShellBridgeDetails = (message: ChatMessageEntry, details: ShellBridgeD
interface MessageListProps {
sessionKey: string;
- disableStaging?: boolean;
messages: ChatMessageEntry[];
sessionIsWorking?: boolean;
activeStreamingMessageId?: string | null;
@@ -362,12 +315,29 @@ interface MessageListProps {
confirmedAt?: number;
fallbackTimestamp?: number;
} | null;
- onMessageContentChange: (reason?: ContentChangeReason) => void;
- getAnimationHandlers: (messageId: string) => AnimationHandlers;
isLoadingOlder: boolean;
scrollToBottom?: () => void;
- scrollRef?: React.RefObject;
directory?: string;
+ // The list owns its scroll container; the timeline scroll hook drives it
+ // through this ref and observes it through the callbacks below.
+ registerList?: (list: LegendListRef | null) => void;
+ // True while a real gesture owns the scroll; releases the list's own
+ // end pinning so the state machine, not the library heuristic, decides.
+ endPinningReleased?: boolean;
+ // The anchored row is identified by message id; the index it maps to is a
+ // property of the row model, which only this component knows.
+ anchorMessageId?: string | null;
+ onAnchorReady?: (messageId: string, anchorIndex: number) => void;
+ onAnchorSizeChanged?: (messageId: string) => void;
+ composerOverlayHeight?: number;
+ onIsAtEndChange?: (isAtEnd: boolean) => void;
+ onTimelineDataChange?: () => void;
+ // Content that used to sit as siblings of the list inside the scroll
+ // container. The list owns that container now, so they render as its
+ // header/footer and scroll with the rows exactly as before.
+ listHeader?: React.ReactNode;
+ listFooter?: React.ReactNode;
+ scrollContainerProps?: TimelineScrollContainerProps;
}
export interface MessageListHandle {
@@ -404,8 +374,6 @@ interface MessageRowProps {
activeStreamingPhase?: StreamPhase | null;
animateUserOnMount?: boolean;
onUserAnimationConsumed?: (messageId: string) => void;
- onContentChange: (reason?: ContentChangeReason) => void;
- animationHandlers: AnimationHandlers;
scrollToBottom?: () => void;
reviewTransferDirection?: ReviewTransferDirection | null;
}
@@ -420,8 +388,6 @@ const MessageRow = React.memo(({
activeStreamingPhase,
animateUserOnMount,
onUserAnimationConsumed,
- onContentChange,
- animationHandlers,
scrollToBottom,
reviewTransferDirection,
}) => {
@@ -432,8 +398,6 @@ const MessageRow = React.memo(({
nextMessage={nextMessage}
animateUserOnMount={animateUserOnMount}
onUserAnimationConsumed={onUserAnimationConsumed}
- onContentChange={onContentChange}
- animationHandlers={animationHandlers}
scrollToBottom={scrollToBottom}
turnGroupingContext={turnGroupingContext}
assistantHeaderMessageId={assistantHeaderMessageId}
@@ -451,20 +415,12 @@ const MessageRow = React.memo(({
&& areOptionalRenderRelevantMessagesEqual(prev.nextMessage, next.nextMessage)
&& prev.animateUserOnMount === next.animateUserOnMount
&& prev.onUserAnimationConsumed === next.onUserAnimationConsumed
- && prev.onContentChange === next.onContentChange
&& prev.scrollToBottom === next.scrollToBottom
&& areRelevantTurnGroupingContextsEqual(prevTurn, nextTurn, prev.message.info.id, resolveMessageRole(prev.message) === 'user')
&& prev.assistantHeaderMessageId === next.assistantHeaderMessageId
&& prev.isInActiveTurn === next.isInActiveTurn
&& prev.activeStreamingPhase === next.activeStreamingPhase
- && prev.reviewTransferDirection === next.reviewTransferDirection
- && prev.animationHandlers?.onChunk === next.animationHandlers?.onChunk
- && prev.animationHandlers?.onComplete === next.animationHandlers?.onComplete
- && prev.animationHandlers?.onStreamingCandidate === next.animationHandlers?.onStreamingCandidate
- && prev.animationHandlers?.onAnimationStart === next.animationHandlers?.onAnimationStart
- && prev.animationHandlers?.onReservationCancelled === next.animationHandlers?.onReservationCancelled
- && prev.animationHandlers?.onReasoningBlock === next.animationHandlers?.onReasoningBlock
- && prev.animationHandlers?.onAnimatedHeightChange === next.animationHandlers?.onAnimatedHeightChange;
+ && prev.reviewTransferDirection === next.reviewTransferDirection;
});
MessageRow.displayName = 'MessageRow';
@@ -478,8 +434,6 @@ interface TurnBlockProps {
turnUiStates: Map;
onToggleTurnGroup: (turnId: string) => void;
chatRenderMode: 'sorted' | 'live';
- onMessageContentChange: (reason?: ContentChangeReason) => void;
- getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: () => void;
stickyUserHeader?: boolean;
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
@@ -498,8 +452,6 @@ const TurnBlock = React.memo(({
turnUiStates,
onToggleTurnGroup,
chatRenderMode,
- onMessageContentChange,
- getAnimationHandlers,
scrollToBottom,
stickyUserHeader = true,
shouldAnimateUserMessage,
@@ -508,6 +460,7 @@ const TurnBlock = React.memo(({
activeStreamingPhase,
reviewTransferDirection,
}: TurnBlockProps) => {
+
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
const userMessageHidden = React.useMemo(
() => isHiddenUserMessage(turn.userMessage, { planModeEnabled }),
@@ -736,19 +689,15 @@ const TurnBlock = React.memo(({
reviewTransferDirection={reviewTransferDirection}
animateUserOnMount={shouldAnimateUserMessage(message)}
onUserAnimationConsumed={onUserAnimationConsumed}
- onContentChange={onMessageContentChange}
- animationHandlers={getAnimationHandlers(message.info.id)}
scrollToBottom={scrollToBottom}
/>
);
},
[
- getAnimationHandlers,
isLastTurn,
nextEntryFirstMessage,
messageOrder.lookup,
messageOrder.ordered,
- onMessageContentChange,
scrollToBottom,
sessionIsWorking,
chatRenderMode,
@@ -797,8 +746,6 @@ interface UngroupedMessageRowProps {
message: ChatMessageEntry;
previousMessage?: ChatMessageEntry;
nextMessage?: ChatMessageEntry;
- onMessageContentChange: (reason?: ContentChangeReason) => void;
- getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: () => void;
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
@@ -811,8 +758,6 @@ const UngroupedMessageRow = React.memo(({
message,
previousMessage,
nextMessage,
- onMessageContentChange,
- getAnimationHandlers,
scrollToBottom,
shouldAnimateUserMessage,
onUserAnimationConsumed,
@@ -827,8 +772,6 @@ const UngroupedMessageRow = React.memo(({
nextMessage={nextMessage}
animateUserOnMount={shouldAnimateUserMessage(message)}
onUserAnimationConsumed={onUserAnimationConsumed}
- onContentChange={onMessageContentChange}
- animationHandlers={getAnimationHandlers(message.info.id)}
scrollToBottom={scrollToBottom}
isInActiveTurn={Boolean(activeStreamingMessageId) && message.info.id === activeStreamingMessageId}
activeStreamingPhase={message.info.id === activeStreamingMessageId ? activeStreamingPhase : null}
@@ -841,8 +784,6 @@ UngroupedMessageRow.displayName = 'UngroupedMessageRow';
interface MessageListEntryProps {
entry: RenderEntry;
- onMessageContentChange: (reason?: ContentChangeReason) => void;
- getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: () => void;
stickyUserHeader?: boolean;
sessionIsWorking: boolean;
@@ -871,8 +812,6 @@ const turnContainsMessageId = (turn: TurnRecord, messageId: string | null | unde
const MessageListEntry = React.memo(({
entry,
- onMessageContentChange,
- getAnimationHandlers,
scrollToBottom,
stickyUserHeader,
sessionIsWorking,
@@ -893,8 +832,6 @@ const MessageListEntry = React.memo(({
message={entry.message}
previousMessage={entry.previousMessage}
nextMessage={entry.nextMessage}
- onMessageContentChange={onMessageContentChange}
- getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
@@ -920,8 +857,6 @@ const MessageListEntry = React.memo(({
activeStreamingMessageId={activeStreamingMessageId}
activeStreamingPhase={activeStreamingPhase}
reviewTransferDirection={reviewTransferDirection}
- onMessageContentChange={onMessageContentChange}
- getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
stickyUserHeader={stickyUserHeader}
/>
@@ -930,265 +865,241 @@ const MessageListEntry = React.memo(({
MessageListEntry.displayName = 'MessageListEntry';
-// Inner component that renders staged turn entries.
-type StaticHistoryListProps = {
- entries: RenderEntry[];
- engine: HistoryEngine;
- contentRef: React.RefObject;
- scrollRef?: React.RefObject;
- registerTanstackVirtualizer?: (virtualizer: TanstackVirtualizerInstance | null) => void;
- virtualizerKey: string;
- onMessageContentChange: (reason?: ContentChangeReason) => void;
- getAnimationHandlers: (messageId: string) => AnimationHandlers;
+// Shared row state. Passed through context rather than closed over by
+// `renderItem` so the render callback keeps a stable identity — a changing
+// `renderItem` makes the list re-render every mounted row on every commit.
+type TimelineRowContextValue = {
scrollToBottom?: () => void;
stickyUserHeader: boolean;
defaultActivityExpanded: boolean;
turnUiStates: Map;
onToggleTurnGroup: (turnId: string) => void;
chatRenderMode: 'sorted' | 'live';
+ showTurnChangedFiles: boolean;
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
reviewTransferDirection?: ReviewTransferDirection | null;
+ // The live tail row renders through StreamingTailContent, which subscribes
+ // to streaming parts; every other row renders statically.
+ streamingTailKey: string | null;
+ directory?: string;
+ sessionIsWorking: boolean;
+ activeStreamingMessageId: string | null;
+ activeStreamingPhase: StreamPhase | null;
};
-const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef, registerTanstackVirtualizer, virtualizerKey, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, reviewTransferDirection }: StaticHistoryListProps) => {
- const isTanstack = engine === 'tanstack';
- const overscan = useActivationOverscan(isTanstack, resolveTanstackOverscan());
+const TimelineRowContext = React.createContext(null);
- // --- Quiet-window prepend (mobile) --------------------------------------
- // Gesture tracking for the deferred-prepend decision. Refs only: reading
- // them never re-renders, and the render-phase reconcile below needs them.
- const touchActiveRef = React.useRef(false);
- const lastScrollAtRef = React.useRef(0);
- const holdSinceRef = React.useRef(null);
- const deferPrepends = isTanstack && isMobileSurfaceRuntime();
+const TimelineRow = React.memo(({ entry }: { entry: RenderEntry }) => {
+ const context = React.useContext(TimelineRowContext);
+ if (!context) return null;
- React.useEffect(() => {
- if (!deferPrepends) return;
- const element = scrollRef?.current;
- if (!element) return;
- const onTouchStart = () => { touchActiveRef.current = true; };
- const onTouchEnd = () => { touchActiveRef.current = false; };
- const onScroll = () => { lastScrollAtRef.current = performance.now(); };
- element.addEventListener('touchstart', onTouchStart, { passive: true });
- element.addEventListener('touchend', onTouchEnd, { passive: true });
- element.addEventListener('touchcancel', onTouchEnd, { passive: true });
- element.addEventListener('scroll', onScroll, { passive: true });
- return () => {
- element.removeEventListener('touchstart', onTouchStart);
- element.removeEventListener('touchend', onTouchEnd);
- element.removeEventListener('touchcancel', onTouchEnd);
- element.removeEventListener('scroll', onScroll);
- };
- }, [deferPrepends, scrollRef]);
-
- const isGestureActive = React.useCallback(() => (
- touchActiveRef.current
- || performance.now() - lastScrollAtRef.current < HISTORY_PREPEND_QUIET_MS
- ), []);
-
- const isNearTop = React.useCallback(() => {
- const element = scrollRef?.current;
- if (!element) return true;
- return element.scrollTop < element.clientHeight * HISTORY_PREPEND_NEAR_TOP_VIEWPORTS;
- }, [scrollRef]);
-
- const [displayEntries, setDisplayEntries] = React.useState(entries);
- // Render-phase reconcile (official derived-state pattern): adopt the new
- // entries immediately unless this commit is a pure prepend-above landing
- // in the middle of an active touch gesture — those wait for quiet.
- let renderEntries = displayEntries;
- if (entries !== displayEntries) {
- const shouldHold = deferPrepends
- && isPrependAboveCommit(displayEntries, entries)
- && isGestureActive()
- && !isNearTop()
- && (holdSinceRef.current === null
- || performance.now() - holdSinceRef.current < HISTORY_PREPEND_MAX_HOLD_MS);
- if (shouldHold) {
- if (holdSinceRef.current === null) holdSinceRef.current = performance.now();
- } else {
- holdSinceRef.current = null;
- setDisplayEntries(entries);
- renderEntries = entries;
- }
- } else if (holdSinceRef.current !== null) {
- holdSinceRef.current = null;
- }
-
- // While a prepend is held, poll for the quiet window (touch/momentum have
- // no completion event we can await) and flush by re-rendering.
- const [, forceFlushTick] = React.useReducer((tick: number) => tick + 1, 0);
- React.useEffect(() => {
- if (!deferPrepends) return;
- const timer = window.setInterval(() => {
- if (holdSinceRef.current === null) return;
- const expired = performance.now() - holdSinceRef.current >= HISTORY_PREPEND_MAX_HOLD_MS;
- if (!isGestureActive() || isNearTop() || expired) {
- forceFlushTick();
- }
- }, HISTORY_PREPEND_MONITOR_INTERVAL_MS);
- return () => window.clearInterval(timer);
- }, [deferPrepends, isGestureActive, isNearTop]);
-
- const entriesRef = React.useRef(renderEntries);
- entriesRef.current = renderEntries;
- // Initial-only read: measurement cache restore is a mount-time concern;
- // afterwards the live virtualizer owns measurements.
- const [initialMeasurements] = React.useState(() => (
- isTanstack
- ? readTanstackTimelineCache(virtualizerKey, entries.map((entry) => entry.key))
- : undefined
- ));
-
- const sizeContainerRef = React.useRef(null);
- // Adaptive estimate: rows this session has actually measured are a far
- // better predictor for the still-unmeasured ones than a fixed constant.
- // Smaller estimate error → smaller anchor corrections when prepended rows
- // measure in → less visible drift. The ref keeps estimateSize's identity
- // stable so updating the average never triggers a global remeasure.
- const estimatedEntrySizeRef = React.useRef(TANSTACK_ESTIMATED_ENTRY_SIZE);
- const tanstackVirtualizer = useTanstackVirtualizer({
- count: renderEntries.length,
- enabled: isTanstack,
- getScrollElement: () => scrollRef?.current ?? null,
- estimateSize: () => estimatedEntrySizeRef.current,
- overscan,
- scrollToFn: (offset, options, instance) => {
- // Expose the new total height before core writes an anchor
- // correction so the browser does not clamp the offset to the old
- // height.
- const sizeElement = sizeContainerRef.current;
- if (sizeElement) sizeElement.style.height = `${instance.getTotalSize()}px`;
- elementScroll(offset, options, instance);
- },
- getItemKey: (index) => entriesRef.current[index]?.key ?? `index:${index}`,
- // Bottom-anchored chat semantics: prepending older entries above the
- // viewport must not move what the user is reading, and iOS-specific
- // touch/momentum deferral for those adjustments lives in the core.
- anchorTo: 'end',
- initialOffset: () => Number.MAX_SAFE_INTEGER,
- initialMeasurementsCache: initialMeasurements,
- });
- // Only compensate scroll for rows growing ABOVE the viewport (history
- // remeasures, prepended pages). A row growing inside the viewport —
- // expanding a tool call or thinking block — must grow DOWNWARD naturally;
- // the end-anchored default made it expand upward. At the bottom,
- // app-level auto-follow owns pinning, so skip there too instead of
- // double-writing. (This is an instance field, not a constructor option.)
- tanstackVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
- if (instance.isAtEnd(TANSTACK_AT_END_THRESHOLD_PX)) return false;
- const firstVisibleIndex = instance.range?.startIndex;
- return firstVisibleIndex !== undefined && item.index < firstVisibleIndex;
- };
-
- React.useEffect(() => {
- if (!isTanstack) return;
- const sizes = tanstackVirtualizer.itemSizeCache;
- if (sizes.size >= TANSTACK_ESTIMATE_MIN_SAMPLES) {
- let total = 0;
- for (const size of sizes.values()) total += size;
- estimatedEntrySizeRef.current = Math.min(
- TANSTACK_ESTIMATE_MAX,
- Math.max(TANSTACK_ESTIMATE_MIN, Math.round(total / sizes.size)),
- );
- }
- });
-
- React.useEffect(() => {
- if (!isTanstack) return;
- registerTanstackVirtualizer?.(tanstackVirtualizer);
- return () => {
- writeTanstackTimelineCache(
- virtualizerKey,
- entriesRef.current.map((entry) => entry.key),
- tanstackVirtualizer,
- );
- registerTanstackVirtualizer?.(null);
- };
- }, [isTanstack, registerTanstackVirtualizer, tanstackVirtualizer, virtualizerKey]);
-
- const renderEntry = React.useCallback((entry: RenderEntry) => {
+ if (context.streamingTailKey === entry.key) {
return (
-
);
- }, [chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, reviewTransferDirection, scrollToBottom, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
-
- if (engine === 'none') {
- return (
-
- {renderEntries.map((entry) => (
-
- {renderEntry(entry)}
-
- ))}
-
- );
}
- if (engine === 'tanstack') {
- const virtualItems = tanstackVirtualizer.getVirtualItems();
- const startOffset = virtualItems[0]?.start ?? 0;
- // Rendered rows stay in normal flow inside a single offset wrapper (not
- // per-row absolute positioning) so per-turn sticky user headers keep
- // working against the scroll container. The offset MUST be padding, not
- // transform: a transformed ancestor becomes the sticky containing block,
- // so headers would stick to the wrapper's (arbitrary, overscan-dependent)
- // top edge mid-list and float over the previous turn. Padding only
- // changes when the virtual window shifts — not per scroll frame — so the
- // layout cost is negligible.
- return (
-
- );
- }
-
- return null;
+ return (
+
+ );
});
-StaticHistoryList.displayName = 'StaticHistoryList';
+TimelineRow.displayName = 'TimelineRow';
+
+const timelineKeyExtractor = (item: RenderEntry): string => item.key;
+
+// Row type drives container reuse. Turn blocks and ungrouped messages have very
+// different shapes, so keeping them in separate pools avoids re-measuring a
+// container every time one replaces the other.
+const timelineItemType = (item: RenderEntry): string => item.kind;
+
+const renderTimelineItem = ({ item }: { item: RenderEntry }) => ;
+
+type TimelineListProps = {
+ entries: RenderEntry[];
+ streamingTailKey: string | null;
+ registerList: (list: LegendListRef | null) => void;
+ endPinningReleased: boolean;
+ anchoredEndSpace?: {
+ anchorIndex: number;
+ anchorOffset?: number;
+ onReady?: (info: { anchorIndex: number | undefined; anchorKey: string | undefined; size: number }) => void;
+ onSizeChanged?: (size: number) => void;
+ };
+ composerOverlayHeight: number;
+ onIsAtEndChange: (isAtEnd: boolean) => void;
+ onTimelineDataChange: () => void;
+ listHeader?: React.ReactNode;
+ listFooter?: React.ReactNode;
+ scrollContainerProps?: TimelineScrollContainerProps;
+ rowContext: TimelineRowContextValue;
+};
+
+const TimelineList = React.memo(({
+ entries,
+ registerList,
+ endPinningReleased,
+ anchoredEndSpace,
+ composerOverlayHeight,
+ onIsAtEndChange,
+ onTimelineDataChange,
+ listHeader,
+ listFooter,
+ scrollContainerProps,
+ rowContext,
+}: TimelineListProps) => {
+ const listRef = React.useRef(null);
+ // With streaming auto-follow off, content growth must never move the
+ // viewport; explicit commands (the scroll-to-bottom pill, session open)
+ // still scroll through the imperative handle.
+ const streamingAutoFollowEnabled = useUIStore((state) => state.streamingAutoFollowEnabled);
+ const isAtEndRef = React.useRef(true);
+
+ const setListRef = React.useCallback((list: LegendListRef | null) => {
+ listRef.current = list;
+ registerList(list);
+ }, [registerList]);
+
+ // A width change re-wraps every row, so all content above the viewport
+ // changes height at once; without size compensation the accumulated delta
+ // throws the read position around. Size restoration stays off otherwise —
+ // rows growing in place (a tool result expanding) must grow downward —
+ // so compensation is enabled only while the list width is actively
+ // resizing, and released shortly after it settles.
+ const [isWidthResizing, setIsWidthResizing] = React.useState(false);
+ React.useEffect(() => {
+ const node = listRef.current?.getScrollableNode();
+ if (!node) return;
+ let lastWidth: number | null = null;
+ let quietTimer: ReturnType | null = null;
+ const observer = new ResizeObserver((observerEntries) => {
+ const width = observerEntries[observerEntries.length - 1]?.contentRect.width;
+ if (typeof width !== 'number') return;
+ if (lastWidth === null) {
+ lastWidth = width;
+ return;
+ }
+ if (Math.abs(width - lastWidth) < 1) return;
+ lastWidth = width;
+ setIsWidthResizing(true);
+ if (quietTimer !== null) clearTimeout(quietTimer);
+ quietTimer = setTimeout(() => {
+ quietTimer = null;
+ setIsWidthResizing(false);
+ }, 300);
+ });
+ observer.observe(node);
+ return () => {
+ observer.disconnect();
+ if (quietTimer !== null) clearTimeout(quietTimer);
+ };
+ }, []);
+
+ // The list reports scroll continuously; only end-crossings are interesting,
+ // so the edge is debounced to a state transition here rather than pushing a
+ // callback on every frame.
+ const handleScroll = React.useCallback(() => {
+ const state = listRef.current?.getState();
+ if (!state) return;
+ const isAtEnd = resolveTimelineIsAtEnd(state);
+ if (typeof isAtEnd !== 'boolean' || isAtEnd === isAtEndRef.current) return;
+ isAtEndRef.current = isAtEnd;
+ onIsAtEndChange(isAtEnd);
+ }, [onIsAtEndChange]);
+
+ // Data changes are the only moment an automatic correction can be needed;
+ // the owning hook decides whether one actually applies.
+ React.useEffect(() => {
+ onTimelineDataChange();
+ }, [entries, onTimelineDataChange]);
+
+ const header = React.useMemo(() => (listHeader ? <>{listHeader}> : undefined), [listHeader]);
+ const footer = React.useMemo(() => (listFooter ? <>{listFooter}> : undefined), [listFooter]);
+
+ return (
+
+
+ ref={setListRef}
+ data={entries}
+ keyExtractor={timelineKeyExtractor}
+ getItemType={timelineItemType}
+ renderItem={renderTimelineItem}
+ estimatedItemSize={TIMELINE_ESTIMATED_ENTRY_SIZE}
+ initialScrollAtEnd
+ // Chat rows own internal state (expanded tool calls, reveal
+ // animations); recycling a container into a different row would
+ // carry that state across.
+ recycleItems={false}
+ {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
+ contentInsetEndAdjustment={composerOverlayHeight}
+ // While a turn is anchored, the reserved end space — not the
+ // live edge — defines where the viewport rests.
+ // Also released while the width resizes: re-pinning against
+ // rows that are still re-measuring shakes the pinned
+ // viewport; the owning hook re-asserts the end once the
+ // resize settles.
+ maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled || isWidthResizing || endPinningReleased
+ ? false
+ // Animated only while the session actively streams: there
+ // the block-step growth turns each correction into a glide
+ // and reveal + scroll read as one motion. Outside of a live
+ // stream — opening a historical session, late measurements —
+ // corrections must be instant: an animated catch-up scrolls
+ // visibly through the whole conversation on open, and an
+ // in-flight glide can supersede explicit navigation.
+ : {
+ animated: rowContext.sessionIsWorking,
+ on: { dataChange: true, itemLayout: true, layout: true, footerLayout: true },
+ }}
+ // Prepending older history must not move what the user is
+ // reading. Size restoration applies only during a width
+ // resize — see the observer above.
+ maintainVisibleContentPosition={{ data: true, size: isWidthResizing }}
+ onScroll={handleScroll}
+ ListHeaderComponent={header}
+ ListFooterComponent={footer}
+ {...scrollContainerProps}
+ />
+
+ );
+});
+
+TimelineList.displayName = 'TimelineList';
const StreamingTailContent: React.FC<{
entry: RenderEntry;
directory?: string;
- onMessageContentChange: (reason?: ContentChangeReason) => void;
- getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: () => void;
stickyUserHeader: boolean;
sessionIsWorking: boolean;
@@ -1205,8 +1116,6 @@ const StreamingTailContent: React.FC<{
}> = ({
entry,
directory,
- onMessageContentChange,
- getAnimationHandlers,
scrollToBottom,
stickyUserHeader,
sessionIsWorking,
@@ -1221,21 +1130,26 @@ const StreamingTailContent: React.FC<{
activeStreamingPhase,
reviewTransferDirection,
}) => {
- const liveParts = useSessionParts(activeStreamingMessageId ?? '', directory);
+ // Overlay live parts on every message of the tail, not only the one
+ // currently streaming: a finished step message's base record can lag the
+ // part store, and rendering it from that stale snapshot briefly unmounts
+ // its completed tool parts when the stream hands off to the next message.
+ const tailMessageIds = React.useMemo(() => {
+ if (entry.kind === 'turn') return entry.turn.assistantMessageIds;
+ return [entry.message.info.id];
+ }, [entry]);
+ const livePartsByMessageId = useSessionPartsForMessages(tailMessageIds, directory);
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
const liveEntry = React.useMemo(() => buildLiveStreamingEntry(entry, {
- activeStreamingMessageId,
- liveParts,
+ livePartsByMessageId,
showTextJustificationActivity: chatRenderMode === 'sorted',
showTurnChangedFiles,
mergeHiddenUserTurns: { planModeEnabled },
- }), [activeStreamingMessageId, chatRenderMode, entry, liveParts, showTurnChangedFiles, planModeEnabled]);
+ }), [chatRenderMode, entry, livePartsByMessageId, showTurnChangedFiles, planModeEnabled]);
return (
(({
activeStreamingMessageId = null,
activeStreamingPhase = null,
retryOverlay = null,
- onMessageContentChange,
- getAnimationHandlers,
scrollToBottom,
- scrollRef,
directory,
+ registerList,
+ endPinningReleased = false,
+ anchorMessageId = null,
+ onAnchorReady,
+ onAnchorSizeChanged,
+ composerOverlayHeight = 0,
+ onIsAtEndChange,
+ onTimelineDataChange,
+ listHeader,
+ listFooter,
+ scrollContainerProps,
}, ref) => {
streamPerfMark('react.message_list_render');
streamPerfCount('ui.message_list.render');
@@ -1283,7 +1205,6 @@ const MessageList = React.forwardRef(({
previousOrder: string[];
animatedIds: Set;
}>({ sessionKey: undefined, previousOrder: [], animatedIds: new Set() });
- const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers);
const stableScrollToBottom = useStableEvent(() => {
scrollToBottom?.();
});
@@ -1359,16 +1280,18 @@ const MessageList = React.forwardRef(({
return output;
}), [messages]);
- const historyContentRef = React.useRef(null);
- const resolveScrollContainer = React.useCallback((): HTMLDivElement | null => {
- if (scrollRef?.current) {
- return scrollRef.current;
+ // The list owns the scroll container. The DOM fallback covers the window
+ // between mount and the list handing us its node.
+ const resolveScrollContainer = React.useCallback((): HTMLElement | null => {
+ const listNode = listRef.current?.getScrollableNode();
+ if (listNode) {
+ return listNode;
}
if (typeof document === 'undefined') {
return null;
}
return document.querySelector('[data-scrollbar="chat"]');
- }, [scrollRef]);
+ }, []);
const displayMessages = React.useMemo(() => streamPerfMeasure('ui.message_list.retry_overlay_ms', () => {
return applyRetryOverlay(baseDisplayMessages, {
@@ -1485,30 +1408,27 @@ const MessageList = React.forwardRef(({
return { ...entry, nextEntryFirstMessage };
});
}, [staticRenderEntries, trailingEntryFirstMessage]);
- // Mobile always starts with the same virtualized engine it will use after
- // pagination. Switching a short list from normal DOM to TanStack during a
- // prepend remounts the history subtree, and the newly enabled end-anchored
- // virtualizer initializes at the bottom before it has prior keyed state.
- // Desktop keeps the small-list threshold where that transition is not tied
- // to the explicit mobile load-older interaction.
- const shouldVirtualizeHistory = isMobileSurfaceRuntime()
- || historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD;
- const historyEngine: HistoryEngine = shouldVirtualizeHistory ? 'tanstack' : 'none';
- const tanstackVirtualizerRef = React.useRef(null);
- const registerTanstackVirtualizer = React.useCallback((virtualizer: TanstackVirtualizerInstance | null) => {
- tanstackVirtualizerRef.current = virtualizer;
- }, []);
+ // Every surface uses the same virtualized list for the whole timeline —
+ // there is no small-list DOM path to transition out of, which is what used
+ // to remount the history subtree mid-prepend.
+ const listRef = React.useRef(null);
+ const handleRegisterList = React.useCallback((list: LegendListRef | null) => {
+ listRef.current = list;
+ registerList?.(list);
+ }, [registerList]);
const allEntries = React.useMemo(() => {
return trailingStreamingEntry ? [...historyEntries, trailingStreamingEntry] : historyEntries;
}, [historyEntries, trailingStreamingEntry]);
- const stableHistoryContentChange = useStableEvent((reason?: ContentChangeReason) => {
- onMessageContentChange(reason);
+ // Stable identities: these reach the list, where a changing callback would
+ // re-render every mounted row.
+ const stableIsAtEndChange = useStableEvent((isAtEnd: boolean) => {
+ onIsAtEndChange?.(isAtEnd);
});
- const stableTailContentChange = useStableEvent((reason?: ContentChangeReason) => {
- onMessageContentChange(reason);
+ const stableTimelineDataChange = useStableEvent(() => {
+ onTimelineDataChange?.();
});
const currentUserOrder = React.useMemo(() => {
@@ -1593,27 +1513,26 @@ const MessageList = React.forwardRef(({
return container.querySelector(`[data-message-id="${messageId}"]`);
}, [resolveScrollContainer]);
+ // Accepts any index the list renders, the trailing streaming entry
+ // included — it lives at historyEntries.length and is a legitimate
+ // navigation target (the timeline rail's last item).
const scrollHistoryIndexIntoView = React.useCallback((index: number) => {
- if (index < 0 || index >= historyEntries.length) {
+ if (index < 0 || index >= allEntries.length) {
return false;
}
- if (!shouldVirtualizeHistory) {
+ const list = listRef.current;
+ if (!list) {
return false;
}
- const virtualizer = tanstackVirtualizerRef.current;
- if (!virtualizer) {
- return false;
- }
-
- // Smooth scrolling can stop at a stale offset while unmounted,
- // variable-height rows replace estimates with real measurements. Use
- // exact auto-reconciliation; mounted targets still take the smooth DOM
- // path below.
- virtualizer.scrollToIndex(index, { align: 'start', behavior: 'auto' });
+ // Unanimated: an unmounted target's position is still an estimate, and
+ // a smooth scroll would end at that stale offset once the real
+ // measurement replaces it. Mounted targets still take the smooth DOM
+ // path in scrollMessageElementIntoView.
+ void list.scrollToIndex({ index, animated: false, viewPosition: 0 });
return true;
- }, [historyEntries.length, shouldVirtualizeHistory]);
+ }, [allEntries.length]);
const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => {
const container = resolveScrollContainer();
@@ -1656,10 +1575,6 @@ const MessageList = React.forwardRef(({
return true;
}
- const targetIsTail = trailingStreamingEntry !== undefined && index >= historyEntries.length;
- if (targetIsTail) {
- return false;
- }
return scrollHistoryIndexIntoView(index);
},
@@ -1672,11 +1587,7 @@ const MessageList = React.forwardRef(({
}
return scrollMessageElementIntoView(messageId, behavior)
- || (
- trailingStreamingEntry !== undefined && index >= historyEntries.length
- ? false
- : scrollHistoryIndexIntoView(index)
- );
+ || scrollHistoryIndexIntoView(index);
},
holdViewportAnchor: (anchor) => {
@@ -1720,7 +1631,9 @@ const MessageList = React.forwardRef(({
window.requestAnimationFrame(step);
},
- isHistoryVirtualized: () => shouldVirtualizeHistory,
+ // The timeline is always virtualized now; the flag stays so callers
+ // that branch on it keep compiling and take the virtualized path.
+ isHistoryVirtualized: () => true,
captureViewportAnchor: () => {
const container = resolveScrollContainer();
@@ -1792,14 +1705,15 @@ const MessageList = React.forwardRef(({
},
scrollToBottom: () => {
- if (shouldVirtualizeHistory && historyEntries.length > 0 && tanstackVirtualizerRef.current) {
- tanstackVirtualizerRef.current.scrollToEnd();
+ const list = listRef.current;
+ if (list) {
+ void list.scrollToEnd({ animated: false });
return;
}
const container = resolveScrollContainer();
if (!container) return;
// Overshoot so the browser clamps to the exact fractional
- // maximum (scrollHeight is integer-rounded) — see useChatAutoFollow.
+ // maximum (scrollHeight is integer-rounded).
container.scrollTop = container.scrollHeight + 4096;
},
};
@@ -1816,65 +1730,85 @@ const MessageList = React.forwardRef(({
return () => {
objectRef.current = null;
};
- }, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, shouldVirtualizeHistory, trailingStreamingEntry, turnIndexMap, ref]);
+ }, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, turnIndexMap, ref]);
- const disableFadeIn = false;
+ const anchoredEndSpace = React.useMemo(() => {
+ const resolved = resolveChatListAnchoredEndSpace(
+ allEntries,
+ anchorMessageId,
+ (entry) => (entry.kind === 'turn' ? entry.turn.userMessage.info.id : entry.message.info.id),
+ );
+ if (!resolved || !anchorMessageId) {
+ return undefined;
+ }
+ return {
+ ...resolved,
+ onReady: (info) => {
+ if (info.anchorIndex === undefined) return;
+ onAnchorReady?.(anchorMessageId, info.anchorIndex);
+ },
+ onSizeChanged: () => {
+ onAnchorSizeChanged?.(anchorMessageId);
+ },
+ };
+ }, [allEntries, anchorMessageId, onAnchorReady, onAnchorSizeChanged]);
+
+ const rowContext = React.useMemo(() => ({
+ scrollToBottom: stableScrollToBottom,
+ stickyUserHeader,
+ defaultActivityExpanded,
+ turnUiStates,
+ onToggleTurnGroup: toggleTurnGroup,
+ chatRenderMode,
+ showTurnChangedFiles,
+ shouldAnimateUserMessage,
+ onUserAnimationConsumed,
+ reviewTransferDirection,
+ streamingTailKey: trailingStreamingEntry?.key ?? null,
+ directory,
+ sessionIsWorking,
+ activeStreamingMessageId,
+ activeStreamingPhase,
+ }), [
+ activeStreamingMessageId,
+ activeStreamingPhase,
+ chatRenderMode,
+ defaultActivityExpanded,
+ directory,
+ onUserAnimationConsumed,
+ reviewTransferDirection,
+ sessionIsWorking,
+ shouldAnimateUserMessage,
+ showTurnChangedFiles,
+ stableScrollToBottom,
+ stickyUserHeader,
+ toggleTurnGroup,
+ trailingStreamingEntry?.key,
+ turnUiStates,
+ ]);
return (
-
-
-
- {/* Virtualized history rows unmount/remount during scroll;
- re-running the reveal fade on every remount reads as
- blinking. History content is never "new", so fade-in
- is disabled there — the streaming tail keeps it. */}
-
-
-
- {trailingStreamingEntry ? (
-
- ) : null}
-
-
-
-
+ // Virtualized rows unmount/remount during scroll; re-running the reveal
+ // fade on every remount reads as blinking. Rows are never "new" from the
+ // list's point of view, so fade-in is disabled for them — content
+ // arriving inside the streaming tail keeps its own animations.
+
+
+
);
});
diff --git a/packages/ui/src/components/chat/PendingChangesBar.tsx b/packages/ui/src/components/chat/PendingChangesBar.tsx
index 08fc6970..2573cea3 100644
--- a/packages/ui/src/components/chat/PendingChangesBar.tsx
+++ b/packages/ui/src/components/chat/PendingChangesBar.tsx
@@ -107,7 +107,7 @@ export const PendingChangesBar: React.FC = React.memo(() => {
>
{labelHead}
-
+
{t('chat.pendingChanges.changedInWorkspace')}
diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx
index 83b4b52b..a577a3d2 100644
--- a/packages/ui/src/components/chat/StatusRow.tsx
+++ b/packages/ui/src/components/chat/StatusRow.tsx
@@ -1,123 +1,18 @@
import React from "react";
import { useSessionUIStore } from '@/sync/session-ui-store';
-import { cn } from "@/lib/utils";
-import { useDirectorySync } from "@/sync/sync-context";
-import type { Todo } from "@opencode-ai/sdk/v2/client";
-
-// Compat aliases for old TodoItem shape
-type TodoItem = Todo & { id?: string };
-type TodoStatus = string;
-type TodoPriority = string;
-import { useUIStore } from "@/stores/useUIStore";
-import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
-import { isVSCodeRuntime } from "@/lib/desktop";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Icon } from "@/components/icon/Icon";
import { useI18n } from "@/lib/i18n";
+// The floating assistant-status chip that hovers above the composer while the
+// agent works ("Claude is working…", abort notice). ONLY that. The composer's
+// own bar — pending changes, todos dropdown — is ComposerStatusBar: they used
+// to share this component, and every restyle of this chip (glass, placement)
+// silently dragged the composer bar and its dropdown along with it.
+
const STATUS_ROW_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "status-row" };
-const statusConfig: Record = {
- in_progress: {
- textClassName: "text-foreground",
- },
- pending: {
- textClassName: "text-foreground",
- },
- completed: {
- textClassName: "text-muted-foreground line-through",
- },
- cancelled: {
- textClassName: "text-muted-foreground line-through",
- },
-};
-
-const priorityClassName: Record = {
- high: "text-[var(--status-warning)]",
- medium: "text-muted-foreground",
- low: "text-muted-foreground/70",
-};
-
-const priorityIcon: Record = {
- high: ,
- medium: ,
- low: ,
-};
-
-const statusLabelKey: Record = {
- in_progress: "chat.statusRow.todo.status.inProgress",
- pending: "chat.statusRow.todo.status.pending",
- completed: "chat.statusRow.todo.status.completed",
- cancelled: "chat.statusRow.todo.status.cancelled",
-};
-
-const priorityLabelKey: Record = {
- high: "chat.statusRow.todo.priority.high",
- medium: "chat.statusRow.todo.priority.medium",
- low: "chat.statusRow.todo.priority.low",
-};
-
-interface TodoItemRowProps {
- todo: TodoItem;
-}
-
-const TodoItemRow: React.FC = ({ todo }) => {
- const { t } = useI18n();
- const config = statusConfig[todo.status] || statusConfig.pending;
- const statusKey = statusLabelKey[todo.status] ?? statusLabelKey.pending;
- const priorityKey = priorityLabelKey[todo.priority] ?? priorityLabelKey.medium;
-
- const statusIcon =
- todo.status === "in_progress" ? (
-
- ) : todo.status === "completed" ? (
-
- ) : (
-
- );
-
- return (
-
{/* h-8 matches the turn footer's real row height: its h-8 action
buttons define the footer line, with the meta text centered in it. */}
-
- {/* Left: Abort status | Working placeholder | leftAccessory */}
-
- {showAssistantStatus && showAbortStatus ? (
+ {/* The glass chip lives here, not on the container: the root above is
+ an inline-size query container, whose width ignores its children —
+ a shrink-to-fit wrapper around it always collapsed to zero. */}
+
);
diff --git a/packages/ui/src/components/chat/StatusRowContainer.tsx b/packages/ui/src/components/chat/StatusRowContainer.tsx
index 46c8bd3a..40bfbe32 100644
--- a/packages/ui/src/components/chat/StatusRowContainer.tsx
+++ b/packages/ui/src/components/chat/StatusRowContainer.tsx
@@ -46,8 +46,6 @@ export const StatusRowContainer: React.FC = React.memo(() => {
wasAborted={wasAborted || working.wasAborted}
abortActive={wasAborted || working.abortActive}
retryInfo={working.retryInfo}
- showAssistantStatus
- showTodos={false}
agentName={currentAgentName}
modelName={modelDisplayName}
providerId={activeModel?.providerId ?? null}
diff --git a/packages/ui/src/components/chat/btw/BtwPanel.tsx b/packages/ui/src/components/chat/btw/BtwPanel.tsx
index 54c55c6d..3c8b37e2 100644
--- a/packages/ui/src/components/chat/btw/BtwPanel.tsx
+++ b/packages/ui/src/components/chat/btw/BtwPanel.tsx
@@ -26,9 +26,6 @@ import { QuestionCard } from '../QuestionCard';
const IDLE_SESSION_STATUS = { type: 'idle' as const };
-/** Stable no-op so ChatMessage memoization keeps working in the read-only peek. */
-const NOOP_CONTENT_CHANGE = (): void => {};
-
/**
* The `/btw` peek panel.
*
@@ -446,7 +443,6 @@ const BtwMessages: React.FC<{
message={record}
previousMessage={data.messageRecords[index - 1]}
nextMessage={data.messageRecords[index + 1]}
- onContentChange={NOOP_CONTENT_CHANGE}
isInActiveTurn={index === data.messageRecords.length - 1}
activeStreamingPhase={
record.info.id === data.streamingMessageId ? data.activeStreamingPhase : null
diff --git a/packages/ui/src/components/chat/components/ScrollToBottomButton.tsx b/packages/ui/src/components/chat/components/ScrollToBottomButton.tsx
index 15726e76..9622c5f5 100644
--- a/packages/ui/src/components/chat/components/ScrollToBottomButton.tsx
+++ b/packages/ui/src/components/chat/components/ScrollToBottomButton.tsx
@@ -1,33 +1,85 @@
import React from 'react';
-import { Button } from '@/components/ui/button';
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
+import { useAssistantStatus } from '@/hooks/useAssistantStatus';
+import { useConfigStore } from '@/stores/useConfigStore';
+import { getProviderModelDisplayName } from '@/lib/modelDisplay';
+
+/**
+ * Compact one-line mirror of the status row for the pill: same label, none of
+ * the status row's animation machinery (which does not survive being squeezed
+ * into a 32px chip).
+ */
+const PillWorkingStatus: React.FC = () => {
+ const { t } = useI18n();
+ const { activeModel, working } = useAssistantStatus();
+ const providers = useConfigStore((state) => state.providers);
+
+ const modelName = React.useMemo(() => {
+ if (!activeModel) return null;
+ const provider = providers.find((candidate) => candidate.id === activeModel.providerId);
+ return getProviderModelDisplayName(provider, activeModel.modelId) || null;
+ }, [activeModel, providers]);
+
+ if (!working.isWorking || !working.statusText) return null;
+ const status = working.statusText;
+ const label = modelName && modelName.trim().length > 0
+ ? t('chat.statusRow.modelStatus', { model: modelName.trim(), status })
+ : status.charAt(0).toUpperCase() + status.slice(1);
+
+ return (
+
+ {label}
+ …
+
+ );
+};
interface ScrollToBottomButtonProps {
visible: boolean;
+ /** The session is still streaming: the pill carries the status label
+ while the floating status row is hidden away from the live edge. */
+ working?: boolean;
onClick: () => void;
}
-const ScrollToBottomButton: React.FC = ({ visible, onClick }) => {
+const ScrollToBottomButton: React.FC = ({ visible, working = false, onClick }) => {
const { t } = useI18n();
return (
-
+ {/* The same column that centres the composer, so the pill's left
+ edge lines up exactly with the input frame. */}
+
+ {/* The soft shadow lives on this wrapper, away from the glass
+ button's backdrop-filter: sharing one element made the
+ shadow intermittently drop after hide/show cycles. */}
+
+
+
+
);
};
diff --git a/packages/ui/src/components/chat/components/TurnActivity.tsx b/packages/ui/src/components/chat/components/TurnActivity.tsx
index 4fa46392..4b49b91e 100644
--- a/packages/ui/src/components/chat/components/TurnActivity.tsx
+++ b/packages/ui/src/components/chat/components/TurnActivity.tsx
@@ -4,7 +4,6 @@ import ProgressiveGroup from '../message/parts/ProgressiveGroup';
import type { TurnActivityRecord } from '../lib/turns/types';
import type { ToolPopupContent } from '../message/types';
import type { StreamPhase } from '../message/types';
-import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
interface DiffStats {
additions: number;
@@ -21,7 +20,6 @@ interface TurnActivityProps {
expandedTools: Set;
onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void;
- onContentChange?: (reason?: ContentChangeReason) => void;
streamPhase: StreamPhase;
showHeader: boolean;
animateRows?: boolean;
diff --git a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts
new file mode 100644
index 00000000..9c5a8ae1
--- /dev/null
+++ b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts
@@ -0,0 +1,227 @@
+import { describe, expect, test } from 'bun:test';
+
+import {
+ CHAT_LIST_ANCHOR_OFFSET,
+ getAnchoredTurnMetrics,
+ getRowBottom,
+ resolveChatListAnchoredEndSpace,
+ resolveTimelineIsAtEnd,
+ type TimelineListMeasurementState,
+} from './timelineScrollAnchoring';
+
+const buildState = ({
+ positions,
+ sizes,
+ scroll = 0,
+ scrollLength = 700,
+}: {
+ readonly positions: readonly number[];
+ readonly sizes: readonly number[];
+ readonly scroll?: number;
+ readonly scrollLength?: number;
+}): TimelineListMeasurementState => ({
+ data: positions.map((_, index) => index),
+ scroll,
+ scrollLength,
+ positionAtIndex: (index) => positions[index],
+ sizeAtIndex: (index) => sizes[index],
+});
+
+describe('getRowBottom', () => {
+ test('measures row bottoms from list row position and size', () => {
+ const state = buildState({ positions: [0, 120], sizes: [80, 40] });
+
+ expect(getRowBottom(state, 1)).toBe(160);
+ });
+
+ test('returns null for unmeasured rows', () => {
+ const state = buildState({ positions: [0], sizes: [80] });
+
+ expect(getRowBottom(state, 5)).toBeNull();
+ });
+
+ test('treats a zero-height row as one pixel tall', () => {
+ const state = buildState({ positions: [0, 120], sizes: [120, 0] });
+
+ expect(getRowBottom(state, 1)).toBe(121);
+ });
+});
+
+describe('getAnchoredTurnMetrics', () => {
+ test('returns null for an empty timeline', () => {
+ const state = buildState({ positions: [], sizes: [] });
+
+ expect(getAnchoredTurnMetrics({
+ state,
+ anchorIndex: 0,
+ composerOverlayHeight: 180,
+ anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
+ })).toBeNull();
+ });
+
+ test('treats the active turn as fitting when it fits above the composer', () => {
+ const state = buildState({
+ positions: [0, 300, 460],
+ sizes: [240, 80, 140],
+ scrollLength: 760,
+ });
+
+ const metrics = getAnchoredTurnMetrics({
+ state,
+ anchorIndex: 1,
+ composerOverlayHeight: 180,
+ anchorOffset: 16,
+ });
+
+ expect(metrics?.turnHeight).toBe(300);
+ expect(metrics?.usableViewportHeight).toBe(564);
+ expect(metrics?.overflowsUsableViewport).toBe(false);
+ expect(metrics?.targetScrollToRevealEnd).toBe(36);
+ expect(metrics?.scrollDeltaToRevealEnd).toBe(36);
+ });
+
+ test('targets the real row end instead of any temporary reserved tail', () => {
+ const state = buildState({
+ positions: [0, 1720, 1880],
+ sizes: [1600, 80, 120],
+ scroll: 1900,
+ scrollLength: 760,
+ });
+
+ const metrics = getAnchoredTurnMetrics({
+ state,
+ anchorIndex: 1,
+ composerOverlayHeight: 180,
+ anchorOffset: 16,
+ });
+
+ expect(metrics?.lastBottom).toBe(2000);
+ expect(metrics?.targetScrollToRevealEnd).toBe(1436);
+ expect(metrics?.scrollDeltaToRevealEnd).toBe(0);
+ });
+
+ test('reports overflow only for the current anchored turn', () => {
+ const state = buildState({
+ positions: [0, 900, 1180],
+ sizes: [800, 220, 300],
+ scroll: 900,
+ scrollLength: 760,
+ });
+
+ const metrics = getAnchoredTurnMetrics({
+ state,
+ anchorIndex: 1,
+ composerOverlayHeight: 180,
+ anchorOffset: 16,
+ });
+
+ expect(metrics?.turnHeight).toBe(580);
+ expect(metrics?.usableViewportHeight).toBe(564);
+ expect(metrics?.overflowsUsableViewport).toBe(true);
+ });
+
+ test('returns the minimal positive scroll delta needed to reveal the turn end', () => {
+ const state = buildState({
+ positions: [0, 900, 1180],
+ sizes: [800, 220, 360],
+ scroll: 900,
+ scrollLength: 760,
+ });
+
+ const metrics = getAnchoredTurnMetrics({
+ state,
+ anchorIndex: 1,
+ composerOverlayHeight: 180,
+ anchorOffset: 16,
+ });
+
+ expect(metrics?.lastBottom).toBe(1540);
+ expect(metrics?.visibleUsableBottom).toBe(1464);
+ expect(metrics?.scrollDeltaToRevealEnd).toBe(76);
+ });
+
+ test('subtracts composer height from usable viewport height', () => {
+ const state = buildState({
+ positions: [0, 300],
+ sizes: [120, 470],
+ scrollLength: 700,
+ });
+
+ const withoutComposer = getAnchoredTurnMetrics({
+ state,
+ anchorIndex: 1,
+ composerOverlayHeight: 0,
+ anchorOffset: 16,
+ });
+ const withComposer = getAnchoredTurnMetrics({
+ state,
+ anchorIndex: 1,
+ composerOverlayHeight: 220,
+ anchorOffset: 16,
+ });
+
+ expect(withoutComposer?.overflowsUsableViewport).toBe(false);
+ expect(withComposer?.overflowsUsableViewport).toBe(true);
+ });
+
+ test('clamps an out-of-range anchor index to the last row', () => {
+ const state = buildState({
+ positions: [0, 300],
+ sizes: [240, 80],
+ scrollLength: 760,
+ });
+
+ const metrics = getAnchoredTurnMetrics({
+ state,
+ anchorIndex: 99,
+ composerOverlayHeight: 0,
+ anchorOffset: 16,
+ });
+
+ expect(metrics?.anchorTop).toBe(300);
+ expect(metrics?.turnHeight).toBe(80);
+ });
+});
+
+describe('resolveTimelineIsAtEnd', () => {
+ test('uses a tight distance band against the full content length', () => {
+ expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true);
+ expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1365, scrollLength: 600 })).toBe(true);
+ expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1300, scrollLength: 600 })).toBe(false);
+ });
+
+ test('falls back to the list flags when distances are unavailable', () => {
+ expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
+ expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);
+ });
+
+ test('reports nothing without a state', () => {
+ expect(resolveTimelineIsAtEnd(undefined)).toBe(undefined);
+ });
+});
+
+describe('resolveChatListAnchoredEndSpace', () => {
+ const rows = [{ id: 'a' }, { id: 'b' }, { id: 'a' }];
+
+ test('returns nothing when no anchor is set', () => {
+ expect(resolveChatListAnchoredEndSpace(rows, null, (row) => row.id)).toBe(undefined);
+ });
+
+ test('returns nothing when the anchor is not in the list', () => {
+ expect(resolveChatListAnchoredEndSpace(rows, 'z', (row) => row.id)).toBe(undefined);
+ });
+
+ test('resolves the last occurrence so a resent message anchors to its live row', () => {
+ expect(resolveChatListAnchoredEndSpace(rows, 'a', (row) => row.id)).toEqual({
+ anchorIndex: 2,
+ anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
+ });
+ });
+
+ test('honours an explicit anchor offset', () => {
+ expect(resolveChatListAnchoredEndSpace(rows, 'b', (row) => row.id, { anchorOffset: 40 })).toEqual({
+ anchorIndex: 1,
+ anchorOffset: 40,
+ });
+ });
+});
diff --git a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts
new file mode 100644
index 00000000..cd670a1f
--- /dev/null
+++ b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts
@@ -0,0 +1,167 @@
+// Anchored-turn scroll geometry for the chat timeline.
+//
+// The timeline has three mutually exclusive scroll modes:
+//
+// • `following-end` — stay pinned to the live edge as content grows.
+// • `anchoring-new-turn` — the just-sent user message is parked near the TOP
+// of the viewport and the reply streams into reserved space below it. The
+// viewport does NOT move until the turn outgrows the usable viewport.
+// • `free-scrolling` — the user took over; nothing moves the scroll
+// position until they opt back in.
+//
+// This module is pure geometry: it reads measurements from the virtualized
+// list and answers "how far, if at all, must we scroll to reveal the end of
+// the anchored turn". Keeping it free of DOM and React makes the mode machine
+// testable without a renderer.
+//
+// "Usable viewport" is the visible height minus the composer overlay (the
+// composer floats over the list) minus the anchor offset, so a turn is only
+// considered overflowing when it genuinely cannot be read.
+
+export type TimelineScrollMode = 'following-end' | 'anchoring-new-turn' | 'free-scrolling';
+
+// Distance from the top of the viewport at which an anchored user message
+// parks. Small enough to read as "at the top", large enough not to collide
+// with the timeline's top fade.
+export const CHAT_LIST_ANCHOR_OFFSET = 16;
+
+export interface TimelineListMeasurementState {
+ readonly data: readonly unknown[];
+ readonly scroll: number;
+ readonly scrollLength: number;
+ readonly positionAtIndex: (index: number) => number | undefined;
+ readonly sizeAtIndex: (index: number) => number | undefined;
+}
+
+export interface AnchoredTurnMetrics {
+ readonly anchorTop: number;
+ readonly lastBottom: number;
+ readonly turnHeight: number;
+ readonly usableViewportHeight: number;
+ readonly visibleUsableBottom: number;
+ readonly overflowsUsableViewport: boolean;
+ readonly targetScrollToRevealEnd: number;
+ readonly scrollDeltaToRevealEnd: number;
+}
+
+export const getRowBottom = (
+ state: TimelineListMeasurementState,
+ index: number,
+): number | null => {
+ const top = state.positionAtIndex(index);
+ const height = state.sizeAtIndex(index);
+ if (
+ typeof top !== 'number'
+ || typeof height !== 'number'
+ || !Number.isFinite(top)
+ || !Number.isFinite(height)
+ ) {
+ return null;
+ }
+ // Rows measured at zero height would make an anchored turn look empty and
+ // suppress the reveal scroll; treat them as one pixel tall instead.
+ return top + Math.max(1, height);
+};
+
+export const getAnchoredTurnMetrics = ({
+ state,
+ anchorIndex,
+ composerOverlayHeight,
+ anchorOffset,
+}: {
+ readonly state: TimelineListMeasurementState;
+ readonly anchorIndex: number;
+ readonly composerOverlayHeight: number;
+ readonly anchorOffset: number;
+}): AnchoredTurnMetrics | null => {
+ if (state.data.length === 0) return null;
+
+ const boundedAnchorIndex = Math.max(0, Math.min(anchorIndex, state.data.length - 1));
+ const anchorTop = state.positionAtIndex(boundedAnchorIndex);
+ // The LAST row bottom, not the content length: the reserved anchored end
+ // space lives past it, and targeting that reserved tail would scroll the
+ // real content off the top.
+ const lastBottom = getRowBottom(state, state.data.length - 1);
+ if (typeof anchorTop !== 'number' || !Number.isFinite(anchorTop) || lastBottom === null) {
+ return null;
+ }
+
+ const usableViewportHeight = Math.max(
+ 0,
+ state.scrollLength - composerOverlayHeight - anchorOffset,
+ );
+ const turnHeight = Math.max(0, lastBottom - anchorTop);
+ const visibleUsableBottom = state.scroll + usableViewportHeight;
+ const targetScrollToRevealEnd = Math.max(0, lastBottom - usableViewportHeight);
+ // Never negative: revealing the end must not scroll the timeline backwards.
+ const scrollDeltaToRevealEnd = Math.max(0, targetScrollToRevealEnd - state.scroll);
+
+ return {
+ anchorTop,
+ lastBottom,
+ turnHeight,
+ usableViewportHeight,
+ visibleUsableBottom,
+ overflowsUsableViewport: turnHeight > usableViewportHeight,
+ targetScrollToRevealEnd,
+ scrollDeltaToRevealEnd,
+ };
+};
+
+// "At the end" for follow purposes is a tight band, not the list's isNearEnd
+// (half a viewport): that band hid the scroll-to-bottom pill and re-armed
+// follow while the user had genuinely scrolled away, yanking them back on the
+// next stream chunk. Distance is measured against the full content length —
+// reserved anchored end space included — so a parked anchored turn counts as
+// the live edge.
+export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
+
+export const resolveTimelineIsAtEnd = (
+ state: {
+ readonly contentLength?: number;
+ readonly scroll?: number;
+ readonly scrollLength?: number;
+ readonly isNearEnd?: boolean;
+ readonly isAtEnd?: boolean;
+ } | undefined,
+): boolean | undefined => {
+ if (!state) return undefined;
+ const { contentLength, scroll, scrollLength } = state;
+ if (
+ typeof contentLength === 'number'
+ && typeof scroll === 'number'
+ && typeof scrollLength === 'number'
+ && Number.isFinite(contentLength)
+ ) {
+ return contentLength - (scroll + scrollLength) <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
+ }
+ return state.isNearEnd ?? state.isAtEnd;
+};
+
+export interface ChatListAnchoredEndSpace {
+ readonly anchorIndex: number;
+ readonly anchorOffset: number;
+}
+
+// Finds the anchored row from the BACK of the list: a retried or re-sent
+// message id can appear more than once, and the live one is always the last.
+export const resolveChatListAnchoredEndSpace = (
+ items: readonly Item[],
+ anchorId: AnchorId | null,
+ getAnchorId: (item: Item) => AnchorId | null,
+ options: { readonly anchorOffset?: number } = {},
+): ChatListAnchoredEndSpace | undefined => {
+ if (anchorId === null) return undefined;
+
+ for (let index = items.length - 1; index >= 0; index -= 1) {
+ const item = items[index];
+ if (item !== undefined && getAnchorId(item) === anchorId) {
+ return {
+ anchorIndex: index,
+ anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET,
+ };
+ }
+ }
+
+ return undefined;
+};
diff --git a/packages/ui/src/components/chat/lib/streamTextCommit.test.ts b/packages/ui/src/components/chat/lib/streamTextCommit.test.ts
new file mode 100644
index 00000000..8ee852fc
--- /dev/null
+++ b/packages/ui/src/components/chat/lib/streamTextCommit.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, test } from 'bun:test';
+
+import { commitStreamedText } from './streamTextCommit';
+
+describe('commitStreamedText', () => {
+ test('holds an incomplete short paragraph entirely', () => {
+ expect(commitStreamedText('An unfinished thought abo')).toBe('');
+ });
+
+ test('commits up to the last complete line', () => {
+ expect(commitStreamedText('First paragraph.\n\nSecond par')).toBe('First paragraph.\n\n');
+ });
+
+ test('reveals code fences line by line', () => {
+ const text = '```py\nprint("a")\nprint("b';
+ expect(commitStreamedText(text)).toBe('```py\nprint("a")\n');
+ });
+
+ test('releases a long held paragraph at the last sentence boundary', () => {
+ const sentence = 'A finished sentence lives here. ';
+ const text = sentence.repeat(12) + 'and an unfinished trail';
+ expect(commitStreamedText(text)).toBe(sentence.repeat(12));
+ });
+
+ test('falls back to the last word boundary without sentences', () => {
+ const words = 'word '.repeat(70);
+ const text = words + 'unfinishe';
+ expect(commitStreamedText(text)).toBe(words);
+ });
+
+ test('keeps unbreakable runs intact rather than splitting them', () => {
+ const run = 'x'.repeat(400);
+ expect(commitStreamedText(run)).toBe(run);
+ });
+
+ test('empty input stays empty', () => {
+ expect(commitStreamedText('')).toBe('');
+ });
+});
diff --git a/packages/ui/src/components/chat/lib/streamTextCommit.ts b/packages/ui/src/components/chat/lib/streamTextCommit.ts
new file mode 100644
index 00000000..15aa0d19
--- /dev/null
+++ b/packages/ui/src/components/chat/lib/streamTextCommit.ts
@@ -0,0 +1,47 @@
+// Block-level streaming reveal.
+//
+// Token-by-token streaming mutates the trailing paragraph in place on every
+// tick: words rewrap, the last line jitters, and the reader's eye fights the
+// motion. Committing only up to the last COMPLETE line keeps every rendered
+// block immutable once it appears — prose arrives a paragraph at a time (a
+// markdown paragraph is one logical line), code fences reveal line by line,
+// tables row by row — and the only remaining motion is the follow scroll.
+//
+// A paragraph with no newline for a long stretch must not stall the stream,
+// so once the held tail outgrows a threshold it is committed at the last
+// sentence boundary (falling back to the last word boundary).
+
+const HOLD_MAX_CHARS = 320;
+
+const SENTENCE_END = /[.!?…][)"'»”’]?\s/g;
+
+export const commitStreamedText = (text: string): string => {
+ if (text.length === 0) return text;
+
+ const lastNewline = text.lastIndexOf('\n');
+ const committed = lastNewline === -1 ? '' : text.slice(0, lastNewline + 1);
+ const held = text.slice(committed.length);
+
+ if (held.length <= HOLD_MAX_CHARS) {
+ return committed;
+ }
+
+ // The held paragraph got long: release it up to the last finished
+ // sentence so the block still never mutates mid-sentence.
+ let lastSentenceEnd = -1;
+ for (const match of held.matchAll(SENTENCE_END)) {
+ lastSentenceEnd = match.index + match[0].length;
+ }
+ if (lastSentenceEnd > 0) {
+ return committed + held.slice(0, lastSentenceEnd);
+ }
+
+ // No sentence boundary either (a URL, a very long token run): release up
+ // to the last word boundary, keeping only the incomplete word held.
+ const lastSpace = held.lastIndexOf(' ');
+ if (lastSpace > 0) {
+ return committed + held.slice(0, lastSpace + 1);
+ }
+
+ return text;
+};
diff --git a/packages/ui/src/components/chat/lib/turns/streamingTailEntry.test.ts b/packages/ui/src/components/chat/lib/turns/streamingTailEntry.test.ts
index ed674707..b283a61c 100644
--- a/packages/ui/src/components/chat/lib/turns/streamingTailEntry.test.ts
+++ b/packages/ui/src/components/chat/lib/turns/streamingTailEntry.test.ts
@@ -64,8 +64,7 @@ describe('buildLiveStreamingEntry', () => {
const entry = turnEntry(assistant);
const next = buildLiveStreamingEntry(entry, {
- activeStreamingMessageId: 'assistant_other',
- liveParts: [textPart('part_live', 'live')],
+ livePartsByMessageId: { assistant_other: [textPart('part_live', 'live')] },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
@@ -79,8 +78,7 @@ describe('buildLiveStreamingEntry', () => {
const liveParts = [reasoningPart('part_1_live', 'thinking')];
const next = buildLiveStreamingEntry(entry, {
- activeStreamingMessageId: 'assistant_1',
- liveParts,
+ livePartsByMessageId: { assistant_1: liveParts },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
@@ -102,8 +100,7 @@ describe('buildLiveStreamingEntry', () => {
const liveParts = [textPart('part_1_live', 'live')];
const next = buildLiveStreamingEntry(entry, {
- activeStreamingMessageId: 'assistant_1',
- liveParts,
+ livePartsByMessageId: { assistant_1: liveParts },
showTextJustificationActivity: false,
showTurnChangedFiles: false,
});
@@ -121,8 +118,7 @@ describe('buildLiveStreamingEntry', () => {
const synthetic = syntheticTextPart('part_synthetic', 'hidden while streaming');
const next = buildLiveStreamingEntry(entry, {
- activeStreamingMessageId: 'assistant_1',
- liveParts: [synthetic, visible],
+ livePartsByMessageId: { assistant_1: [synthetic, visible] },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
@@ -131,4 +127,39 @@ describe('buildLiveStreamingEntry', () => {
if (next.kind !== 'turn') return;
expect(next.turn.assistantMessages[0]?.parts).toEqual([visible]);
});
+
+ test('keeps a finished step message on its live parts after the stream moves on', () => {
+ const finished = message('assistant_1', 'assistant', 'user_1', []);
+ const streaming = message('assistant_2', 'assistant', 'user_1', []);
+ const entry = turnEntry(finished);
+ if (entry.kind !== 'turn') return;
+ entry.turn.assistantMessageIds = ['assistant_1', 'assistant_2'];
+ entry.turn.assistantMessages = [finished, streaming];
+ const finishedLive = [textPart('part_tool_done', 'tool output')];
+ const streamingLive = [textPart('part_streaming', 'streaming')];
+
+ const next = buildLiveStreamingEntry(entry, {
+ livePartsByMessageId: { assistant_1: finishedLive, assistant_2: streamingLive },
+ showTextJustificationActivity: true,
+ showTurnChangedFiles: false,
+ });
+
+ expect(next.kind).toBe('turn');
+ if (next.kind !== 'turn') return;
+ expect(next.turn.assistantMessages[0]?.parts).toEqual(finishedLive);
+ expect(next.turn.assistantMessages[1]?.parts).toEqual(streamingLive);
+ });
+
+ test('never erases record parts with an empty live array', () => {
+ const assistant = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'kept')]);
+ const entry = turnEntry(assistant);
+
+ const next = buildLiveStreamingEntry(entry, {
+ livePartsByMessageId: { assistant_1: [] },
+ showTextJustificationActivity: true,
+ showTurnChangedFiles: false,
+ });
+
+ expect(next).toBe(entry);
+ });
});
diff --git a/packages/ui/src/components/chat/lib/turns/streamingTailEntry.ts b/packages/ui/src/components/chat/lib/turns/streamingTailEntry.ts
index 2bf7dd2a..755a06f3 100644
--- a/packages/ui/src/components/chat/lib/turns/streamingTailEntry.ts
+++ b/packages/ui/src/components/chat/lib/turns/streamingTailEntry.ts
@@ -15,8 +15,13 @@ export type StreamingTailEntry =
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean };
type BuildLiveStreamingEntryOptions = {
- activeStreamingMessageId: string | null | undefined;
- liveParts: Part[];
+ // Live parts for EVERY message of the streaming tail, not only the one
+ // currently streaming: when the stream moves to the next step message, the
+ // previous message's base record can still lag behind the part store, and
+ // rendering it from that stale snapshot briefly drops its completed tool
+ // parts — remounting them (and replaying their reveal animation) once the
+ // record catches up.
+ livePartsByMessageId: Readonly>;
showTextJustificationActivity: boolean;
showTurnChangedFiles: boolean;
mergeHiddenUserTurns?: { planModeEnabled: boolean };
@@ -24,10 +29,12 @@ type BuildLiveStreamingEntryOptions = {
const withLiveParts = (
message: ChatMessageEntry,
- activeStreamingMessageId: string,
- liveParts: Part[],
+ livePartsByMessageId: Readonly>,
): ChatMessageEntry => {
- if (message.info.id !== activeStreamingMessageId || message.parts === liveParts) {
+ const liveParts = livePartsByMessageId[message.info.id];
+ // An empty live array is ambiguous — the store may simply not have loaded
+ // this message's parts — and must never erase parts the record does have.
+ if (!liveParts || liveParts.length === 0 || message.parts === liveParts) {
return message;
}
@@ -41,13 +48,10 @@ export const buildLiveStreamingEntry = (
entry: TEntry,
options: BuildLiveStreamingEntryOptions,
): TEntry => {
- const activeStreamingMessageId = options.activeStreamingMessageId;
- if (!activeStreamingMessageId) {
- return entry;
- }
+ const livePartsByMessageId = options.livePartsByMessageId;
if (entry.kind === 'ungrouped') {
- const message = withLiveParts(entry.message, activeStreamingMessageId, options.liveParts);
+ const message = withLiveParts(entry.message, livePartsByMessageId);
if (message === entry.message) {
return entry;
}
@@ -59,7 +63,7 @@ export const buildLiveStreamingEntry = (
let changed = false;
const assistantMessages = entry.turn.assistantMessages.map((message) => {
- const next = withLiveParts(message, activeStreamingMessageId, options.liveParts);
+ const next = withLiveParts(message, livePartsByMessageId);
if (next !== message) {
changed = true;
}
diff --git a/packages/ui/src/components/chat/markdown/decorate.ts b/packages/ui/src/components/chat/markdown/decorate.ts
index eeb15fdd..8981ebce 100644
--- a/packages/ui/src/components/chat/markdown/decorate.ts
+++ b/packages/ui/src/components/chat/markdown/decorate.ts
@@ -133,6 +133,9 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
const code = pre.querySelector(':scope > code');
if (!code || code.hasAttribute('data-md-code-lines')) return;
+ // The real gutter takes over the reserved footprint.
+ pre.removeAttribute('data-md-gutter-reserved');
+
const text = code.textContent ?? '';
const hasTrailingNewline = text.endsWith('\n');
const lines = hasTrailingNewline ? text.slice(0, -1).split('\n') : text.split('\n');
@@ -265,7 +268,15 @@ const decorateCodeBlocks = (root: HTMLElement, ctx: DecorateContext): void => {
pre.style.margin = '0';
pre.style.background = 'transparent';
pre.classList.add('min-w-0', 'w-full', 'flex-1');
- if (!ctx.deferCodeLineNumberSync) layoutCodeLines(pre);
+ if (!ctx.deferCodeLineNumberSync) {
+ layoutCodeLines(pre);
+ } else {
+ // Streaming defers the per-line gutter markup, but the gutter's
+ // horizontal footprint is reserved immediately — otherwise the
+ // end-of-stream decorate pass shifts every code line right by the
+ // gutter column and the finished message visibly jumps.
+ pre.setAttribute('data-md-gutter-reserved', '');
+ }
body.appendChild(pre);
wrapper.appendChild(header);
wrapper.appendChild(body);
diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts
index 91943d74..31c9af15 100644
--- a/packages/ui/src/components/chat/markdown/markdownCore.ts
+++ b/packages/ui/src/components/chat/markdown/markdownCore.ts
@@ -178,9 +178,11 @@ type MarkdownBlock = {
raw: string;
src: string;
mode: 'full' | 'live';
- // When false, skip syntax highlighting for this block. Set for the actively
- // streaming open code fence so we don't re-tokenize a growing block ~40x/sec
- // (O(n^2)); it highlights once the fence closes and becomes a stable block.
+ // When false, skip syntax highlighting for this block. Block-level commit
+ // feeds the open fence whole lines at the throttle cadence (<=10/sec), so a
+ // partial fence highlights too and streamed code arrives colored; only a
+ // very large open fence falls back to plain text until it closes, keeping
+ // the repeated worker re-tokenization bounded.
highlight: boolean;
};
@@ -201,6 +203,11 @@ const hasOpenFence = (raw: string): boolean => {
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last);
};
+// Above this, re-highlighting the still-open fence on every committed line
+// costs more than the colored preview is worth; the block highlights in one
+// pass when the fence closes.
+const OPEN_FENCE_HIGHLIGHT_LINE_LIMIT = 300;
+
const heal = (text: string): string => {
try {
return remend(text, { linkMode: 'text-only' });
@@ -250,11 +257,13 @@ const streamBlocks = (text: string, live: boolean): MarkdownBlock[] => {
const raw = token.raw ?? '';
const isLast = i === tail;
const openFence = token.type === 'code' && hasOpenFence(raw);
+ const openFenceHighlight = openFence
+ && raw.split('\n').length <= OPEN_FENCE_HIGHLIGHT_LINE_LIMIT;
blocks.push({
raw,
src: openFence ? raw : heal(raw),
mode: isLast ? 'live' : 'full',
- highlight: !openFence,
+ highlight: !openFence || openFenceHighlight,
});
}
diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx
index 3623d30e..35a17a4d 100644
--- a/packages/ui/src/components/chat/message/MessageBody.tsx
+++ b/packages/ui/src/components/chat/message/MessageBody.tsx
@@ -19,7 +19,6 @@ import { SaveProjectPlanDialog } from '@/components/session/SaveProjectPlanDialo
import { ForkSessionDialog, type ForkSessionExecution } from '@/components/session/ForkSessionDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
-import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { MarkdownImageGallery, SimpleMarkdownRenderer } from '../MarkdownRenderer';
import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -419,13 +418,10 @@ interface MessageBodyProps {
onShowPopup: (content: ToolPopupContent) => void;
streamPhase: StreamPhase;
allowAnimation: boolean;
- onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
-
shouldShowHeader?: boolean;
hasTextContent?: boolean;
onCopyMessage?: () => void | boolean | Promise;
copiedMessage?: boolean;
- onAuxiliaryContentComplete?: () => void;
showReasoningTraces?: boolean;
agentMention?: AgentMentionInfo;
turnGroupingContext?: TurnGroupingContext;
@@ -1112,10 +1108,8 @@ const AssistantMessageBody = React.memo(({
onShowPopup,
streamPhase: _streamPhase,
allowAnimation: _allowAnimation,
- onContentChange,
hasTextContent = false,
onCopyMessage,
- onAuxiliaryContentComplete,
showReasoningTraces = false,
turnGroupingContext,
errorMessage,
@@ -1423,50 +1417,6 @@ const AssistantMessageBody = React.memo(({
|| (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized));
const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning;
- const hasAuxiliaryContent = hasTools || reasoningParts.length > 0;
- const isTextlessAssistantMessage = assistantTextParts.length === 0;
- const auxiliaryContentComplete = hasAuxiliaryContent && isTextlessAssistantMessage && !shouldHoldTools && !shouldHoldReasoning && allToolsFinalized && reasoningComplete;
- const auxiliaryCompletionAnnouncedRef = React.useRef(false);
- const soloReasoningScrollTriggeredRef = React.useRef(false);
-
- React.useEffect(() => {
- soloReasoningScrollTriggeredRef.current = false;
- }, [messageId]);
-
- React.useEffect(() => {
- if (!auxiliaryContentComplete) {
- auxiliaryCompletionAnnouncedRef.current = false;
- return;
- }
- if (auxiliaryCompletionAnnouncedRef.current) {
- return;
- }
- auxiliaryCompletionAnnouncedRef.current = true;
- onAuxiliaryContentComplete?.();
- }, [auxiliaryContentComplete, onAuxiliaryContentComplete]);
-
- React.useEffect(() => {
- if (awaitingMessageCompletion) {
- soloReasoningScrollTriggeredRef.current = false;
- return;
- }
- if (hasTools) {
- soloReasoningScrollTriggeredRef.current = false;
- return;
- }
- if (reasoningParts.length === 0) {
- return;
- }
- if (shouldHoldReasoning || !reasoningComplete) {
- return;
- }
- if (soloReasoningScrollTriggeredRef.current) {
- return;
- }
- soloReasoningScrollTriggeredRef.current = true;
- onContentChange?.('structural');
- }, [awaitingMessageCompletion, hasTools, onContentChange, reasoningComplete, reasoningParts.length, shouldHoldReasoning]);
-
const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion;
const handleForkClick = React.useCallback(
@@ -1821,7 +1771,6 @@ const AssistantMessageBody = React.memo(({
expandedTools={expandedTools}
onToggleTool={onToggleTool}
onShowPopup={onShowPopup}
- onContentChange={onContentChange}
streamPhase={effectiveStreamPhase}
showHeader={true}
animateRows={animateActivityRows}
@@ -1898,7 +1847,6 @@ const AssistantMessageBody = React.memo(({
messageId={messageId}
streamPhase={effectiveStreamPhase}
chatRenderMode={chatRenderMode}
- onContentChange={onContentChange}
onShowPopup={onShowPopup}
/>
{shouldVirtualize ? (
diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts
deleted file mode 100644
index 7bfc13ca..00000000
--- a/packages/ui/src/hooks/useChatAutoFollow.ts
+++ /dev/null
@@ -1,938 +0,0 @@
-import React from 'react';
-
-import { MessageFreshnessDetector } from '@/lib/messageFreshness';
-import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
-import { useViewportStore } from '@/sync/viewport-store';
-
-type AutoFollowState = 'following' | 'released';
-
-export type ContentChangeReason = 'text' | 'structural' | 'permission' | 'animation';
-
-export interface AnimationHandlers {
- onChunk: () => void;
- onComplete: () => void;
- onStreamingCandidate?: () => void;
- onAnimationStart?: () => void;
- onReservationCancelled?: () => void;
- onReasoningBlock?: () => void;
- onAnimatedHeightChange?: (height: number) => void;
-}
-
-interface UseChatAutoFollowOptions {
- currentSessionId: string | null;
- currentSessionKey: string | null;
- sessionMessageCount: number;
- sessionIsWorking: boolean;
- isMobile: boolean;
- onActiveTurnChange?: (turnId: string | null) => void;
-}
-
-export interface UseChatAutoFollowResult {
- scrollRef: React.RefObject;
- state: AutoFollowState;
- isPinned: boolean;
- isOverflowing: boolean;
- isFollowingProgrammatically: boolean;
- showScrollButton: boolean;
- notifyContentChange: (reason?: ContentChangeReason) => void;
- getAnimationHandlers: (messageId: string) => AnimationHandlers;
- goToBottom: (mode?: 'instant' | 'smooth') => void;
- scrollToBottomOnSend: () => void;
- releaseAutoFollow: () => void;
- saveSnapshotNow: () => void;
- restoreSnapshot: () => Promise;
-}
-
-// ──────────────────────────────────────────────────────────────────────────
-// Chat auto-follow. The model is deliberately simple, which is what makes it
-// flicker-free:
-//
-// • Auto-follow is on unless the user scrolled up (`released`), AND passive
-// following only acts while the session is active (working, plus a short
-// settle window). When idle, content-size changes are layout churn
-// (virtualizer re-measurement, async tool/code rendering) rather than live
-// growth, so the hook leaves scroll alone — re-pinning then would fight the
-// virtualizer and twitch the viewport.
-// • Following the bottom is INSTANT — `scrollTop = scrollHeight` inside the
-// content ResizeObserver, which fires after layout and before paint. There
-// is NO easing loop and NO settle burst, so there are never two writers
-// racing for `scrollTop` (the root cause of the old jiggle/double-scroll).
-// • A short-lived "auto" marker (position + 1500ms) lets the scroll handler
-// distinguish our own programmatic writes from genuine user scrolling, so
-// a scroll event that lands at our just-written bottom never trips a false
-// release.
-//
-// The public interface below is unchanged from the old implementation so every
-// consumer (ChatContainer, message parts, the timeline controller) keeps
-// working without edits.
-// ──────────────────────────────────────────────────────────────────────────
-
-const BOTTOM_SPACER_DESKTOP_VH = 0.10;
-const BOTTOM_SPACER_MOBILE_PX = 40;
-const SAVE_DEBOUNCE_MS = 150;
-const TOUCH_FINGER_DOWN_THRESHOLD = 2;
-// How long an "auto" (programmatic) scroll position stays trusted. Browsers can
-// dispatch the `scroll` event for our write asynchronously, after newer content
-// has already changed the geometry; the window keeps us from reading that lag as
-// a user scroll.
-const AUTO_MARK_TTL_MS = 1500;
-const AUTO_MATCH_TOLERANCE_PX = 2;
-// While a tracked height animation runs (e.g. a Thinking block auto-collapsing
-// mid-stream), the timeline shrinks/grows over a couple hundred ms and the
-// virtualizer re-measures, producing transient geometry. Browsers dispatch the
-// resulting `scroll` events asynchronously, so a stale event can land after we
-// have already re-pinned — its position matching neither the bottom zone nor the
-// freshly-moved auto marker — and be misread as a user scroll-away. During this
-// guard window we treat any `following`-state scroll event as our own and never
-// release via the heuristic. GENUINE user gestures still release instantly
-// through releaseFromUserIntent, so this is not glue. Sized to the reasoning
-// animation (200ms) plus headroom for trailing async scroll events.
-const ANIMATION_GUARD_MS = 350;
-// After streaming stops, keep following the bottom for a short window so the
-// final content can settle into place.
-const SETTLE_MS = 300;
-// Entry-stick window. On the FIRST open of a session, late async data (most
-// visibly a task/subagent tool whose nested rows are fetched from the child
-// session after entry — see useEnsureSessionMessages in ToolPart.tsx) grows the
-// timeline a beat or two AFTER we have already pinned to the bottom, leaving the
-// viewport stranded mid-history. The steady-state idle gate deliberately ignores
-// that growth (it can't tell entry from a user reading idle history). So instead
-// of weakening the gate, we open a short, gesture-cancellable window on entry
-// during which we FORCE the bottom on every growth. It ends QUIESCENCE_MS after
-// growth stops (capped by MAX_MS), or instantly on any real user scroll gesture.
-const ENTRY_STICK_QUIESCENCE_MS = 600;
-const ENTRY_STICK_MAX_MS = 8000;
-
-const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
-
-// The bottom of the chat has an empty spacer (10vh on desktop, 40px on mobile)
-// — its height is exactly how far above scrollHeight the user can be while still
-// looking at "empty" space. We use that same value as the threshold for both
-// re-pinning auto-follow and showing the scroll-to-bottom button.
-const computeBottomZoneThreshold = (isMobile: boolean, container?: HTMLElement | null): number => {
- if (isMobile) return BOTTOM_SPACER_MOBILE_PX;
- const height = container?.clientHeight ?? 0;
- if (height <= 0) return 96;
- return Math.max(48, height * BOTTOM_SPACER_DESKTOP_VH);
-};
-
-const distanceFromBottom = (el: HTMLElement): number => {
- return el.scrollHeight - el.scrollTop - el.clientHeight;
-};
-
-const canScroll = (el: HTMLElement): boolean => {
- return el.scrollHeight - el.clientHeight > 1;
-};
-
-const isNearBottom = (el: HTMLElement, isMobile: boolean): boolean => {
- return distanceFromBottom(el) <= computeBottomZoneThreshold(isMobile, el);
-};
-
-const isReleaseKey = (event: KeyboardEvent): boolean => {
- if (event.altKey || event.ctrlKey || event.metaKey) {
- return false;
- }
- switch (event.key) {
- case 'ArrowUp':
- case 'PageUp':
- case 'Home':
- return true;
- default:
- return false;
- }
-};
-
-const nestedScrollableTarget = (root: HTMLElement, target: EventTarget | null): HTMLElement | null => {
- if (!(target instanceof Element)) return null;
- const nested = target.closest('[data-scrollable]');
- if (!nested || nested === root || !(nested instanceof HTMLElement)) return null;
- return nested;
-};
-
-const nestedScrollableCanConsumeUp = (root: HTMLElement, target: EventTarget | null): boolean => {
- const nested = nestedScrollableTarget(root, target);
- if (!nested) return false;
- return nested.scrollTop > 0;
-};
-
-export const useChatAutoFollow = ({
- currentSessionId,
- currentSessionKey,
- sessionMessageCount,
- sessionIsWorking,
- isMobile,
- onActiveTurnChange,
-}: UseChatAutoFollowOptions): UseChatAutoFollowResult => {
- const scrollRef = React.useRef(null);
- const [containerEl, setContainerEl] = React.useState(null);
- const lastSeenContainerRef = React.useRef(null);
-
- const [state, setState] = React.useState('following');
- const [isOverflowing, setIsOverflowing] = React.useState(false);
- const [showScrollButton, setShowScrollButton] = React.useState(false);
- const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
-
- // `stateRef` is the single source of truth for follow vs released; the React
- // state above is a mirror for rendering. `released` means the user scrolled
- // up and away from the bottom.
- const stateRef = React.useRef('following');
- const isMobileRef = React.useRef(isMobile);
- isMobileRef.current = isMobile;
- const sessionIsWorkingRef = React.useRef(sessionIsWorking);
- sessionIsWorkingRef.current = sessionIsWorking;
- // `settling` keeps passive follow alive for a short window after work stops
- // so the final content can land at the bottom.
- const settlingRef = React.useRef(false);
- const settleTimerRef = React.useRef | null>(null);
- const sessionMessageCountRef = React.useRef(sessionMessageCount);
- sessionMessageCountRef.current = sessionMessageCount;
- const currentSessionIdRef = React.useRef(currentSessionId);
- currentSessionIdRef.current = currentSessionId;
- const currentSessionKeyRef = React.useRef(currentSessionKey);
- currentSessionKeyRef.current = currentSessionKey;
-
- const lastSessionKeyRef = React.useRef(null);
-
- // Programmatic-scroll marker: the bottom position we last
- // wrote and when. A scroll event whose scrollTop matches `top` within a few
- // px while still inside the TTL is OUR write, not the user's.
- const autoRef = React.useRef<{ top: number; time: number } | null>(null);
- const autoTimerRef = React.useRef | null>(null);
-
- // Timestamp until which a tracked height animation is in flight (see
- // ANIMATION_GUARD_MS). 0 = no animation guard active.
- const animationGuardUntilRef = React.useRef(0);
-
- // True while the native (Capacitor iOS) keyboard slide choreography is in
- // flight (between 'oc:keyboard-anim' and 'oc:keyboard-settled' from
- // useNativeMobileChrome). During that window the pinned content is moved by a
- // transform on the inner wrapper, so the ResizeObserver chase must stand down.
- const keyboardAnimRef = React.useRef(false);
-
- // Last observed scrollTop, used to derive scroll DIRECTION in the scroll
- // handler so the bottom-zone re-engage only fires when arriving at the bottom
- // by scrolling down — never when a user scrolling UP merely lands in the zone.
- const lastScrollTopRef = React.useRef(0);
-
- // Entry-stick window state (see ENTRY_STICK_* above).
- const entryStickRef = React.useRef(false);
- const entryStickQuietTimerRef = React.useRef | null>(null);
- const entryStickCapTimerRef = React.useRef | null>(null);
- const entryStickLastHeightRef = React.useRef(0);
-
- const saveTimerRef = React.useRef | null>(null);
- const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
- // When restoreSnapshot is invoked while ChatViewport is still hydrating
- // (skeleton rendered, no scroll container yet), we record the session here
- // so a follow-up effect can replay the restore once the container mounts.
- const pendingInitialRestoreRef = React.useRef(null);
-
- const updateViewportAnchor = useViewportStore((s) => s.updateViewportAnchor);
-
- // Detect when the scroll container DOM element changes (mount, unmount, remount).
- // Without this, listener-attach effects would only ever bind to the element that
- // existed at the hook's first render, missing later mounts (e.g. after first send
- // promotes a draft session to a real chat with messages).
- // eslint-disable-next-line react-hooks/exhaustive-deps
- React.useLayoutEffect(() => {
- if (scrollRef.current !== lastSeenContainerRef.current) {
- lastSeenContainerRef.current = scrollRef.current;
- setContainerEl(scrollRef.current);
- }
- });
-
- // `active` is `working || settling`. Passive auto-follow
- // (the ResizeObserver re-pin and any non-forced scrollToBottom) only runs
- // while active. When the session is idle, content-size changes are layout
- // churn — virtualizer re-measurement, async tool/code rendering — NOT live
- // growth, so we must NOT yank the user to the bottom. Forcing this gate is
- // what stops the twitch when tall items (expanded tools) re-measure as the
- // user scrolls.
- const isActive = React.useCallback((): boolean => {
- return sessionIsWorkingRef.current || settlingRef.current;
- }, []);
-
- const setStateValue = React.useCallback((next: AutoFollowState) => {
- if (stateRef.current === next) return;
- stateRef.current = next;
- setState(next);
- }, []);
-
- // ── auto marker ────────────────────────────────────────────────────────
- const markAuto = React.useCallback((el: HTMLElement) => {
- autoRef.current = {
- top: Math.max(0, el.scrollHeight - el.clientHeight),
- time: now(),
- };
- if (autoTimerRef.current) clearTimeout(autoTimerRef.current);
- autoTimerRef.current = setTimeout(() => {
- autoRef.current = null;
- autoTimerRef.current = null;
- }, AUTO_MARK_TTL_MS);
- }, []);
-
- const isAuto = React.useCallback((el: HTMLElement): boolean => {
- const a = autoRef.current;
- if (!a) return false;
- if (now() - a.time > AUTO_MARK_TTL_MS) {
- autoRef.current = null;
- return false;
- }
- return Math.abs(el.scrollTop - a.top) < AUTO_MATCH_TOLERANCE_PX;
- }, []);
-
- const isAnimationGuardActive = React.useCallback((): boolean => {
- return now() < animationGuardUntilRef.current;
- }, []);
-
- // ── entry-stick window ───────────────────────────────────────────────────
- const endEntryStick = React.useCallback(() => {
- entryStickRef.current = false;
- if (entryStickQuietTimerRef.current) {
- clearTimeout(entryStickQuietTimerRef.current);
- entryStickQuietTimerRef.current = null;
- }
- if (entryStickCapTimerRef.current) {
- clearTimeout(entryStickCapTimerRef.current);
- entryStickCapTimerRef.current = null;
- }
- }, []);
-
- // (Re)arm the quiescence timer: the window closes this long after the last
- // growth. Called once on begin and again on every growth-driven re-pin.
- const armEntryStickQuiet = React.useCallback(() => {
- if (entryStickQuietTimerRef.current) {
- clearTimeout(entryStickQuietTimerRef.current);
- }
- entryStickQuietTimerRef.current = setTimeout(() => {
- entryStickQuietTimerRef.current = null;
- endEntryStick();
- }, ENTRY_STICK_QUIESCENCE_MS);
- }, [endEntryStick]);
-
- const beginEntryStick = React.useCallback(() => {
- const el = scrollRef.current;
- if (!el) return;
- entryStickRef.current = true;
- entryStickLastHeightRef.current = el.scrollHeight;
- armEntryStickQuiet();
- // Reset the absolute cap fresh on every entry (e.g. session switch) so a
- // stale cap from a previous open can't cut this window short.
- if (entryStickCapTimerRef.current) {
- clearTimeout(entryStickCapTimerRef.current);
- }
- entryStickCapTimerRef.current = setTimeout(() => {
- entryStickCapTimerRef.current = null;
- endEntryStick();
- }, ENTRY_STICK_MAX_MS);
- }, [armEntryStickQuiet, endEntryStick]);
-
- // ── overflow / scroll-to-bottom button ──────────────────────────────────
- const updateOverflowAndButton = React.useCallback(() => {
- const container = scrollRef.current;
- if (!container) {
- setIsOverflowing(false);
- setShowScrollButton(false);
- return;
- }
- const overflowing = canScroll(container);
- setIsOverflowing(overflowing);
- if (!overflowing) {
- setShowScrollButton(false);
- return;
- }
- const showButton = stateRef.current === 'released' && !isNearBottom(container, isMobileRef.current);
- setShowScrollButton(showButton);
- }, []);
-
- // ── core scroll primitives ───────────────────────────────────────────────
- const scrollToBottomNow = React.useCallback((behavior: ScrollBehavior) => {
- const el = scrollRef.current;
- if (!el) return;
- markAuto(el);
- // `scrollHeight` is rounded to an integer while the real content height
- // is fractional (prose line-heights), so `scrollTop = scrollHeight`
- // leaves a 0–1px remainder that oscillates per streamed token and makes
- // bottom-anchored rows jitter vertically. An over-large target clamps to
- // the exact fractional maximum instead, pinning content to the bottom.
- const overshootTarget = el.scrollHeight + 4096;
- if (behavior === 'smooth') {
- el.scrollTo({ top: overshootTarget, behavior });
- return;
- }
- // Direct `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`
- // and lands in the same frame — no visible catch-up animation.
- el.scrollTop = overshootTarget;
- }, [markAuto]);
-
- // `force` true = user-intent jump (clears released and always scrolls).
- // `force` false = passive follow (only while still following).
- const scrollToBottom = React.useCallback((force: boolean, behavior: ScrollBehavior = 'auto') => {
- const el = scrollRef.current;
-
- // Passive follow only while active (working/settling). Forced jumps
- // (send, go-to-bottom, session restore) always proceed.
- if (!force && !isActive()) return;
-
- if (force && stateRef.current !== 'following') {
- setStateValue('following');
- }
- if (!el) return;
- if (!force && stateRef.current !== 'following') return;
-
- // Always re-pin, even when already within tolerance of the bottom.
- // Sub-tolerance growth (fractional line-height remainders) would
- // otherwise leave the bottom drifting by up to ±AUTO_MATCH_TOLERANCE_PX
- // between full re-pins, which reads as 1px vertical jitter on
- // bottom-anchored rows during streaming. The write happens pre-paint
- // (ResizeObserver) and is a no-op when the position is unchanged.
- scrollToBottomNow(force ? behavior : 'auto');
- }, [isActive, scrollToBottomNow, setStateValue]);
-
- // User left the bottom — release auto-follow.
- const stop = React.useCallback(() => {
- const el = scrollRef.current;
- if (!el) return;
- if (!canScroll(el)) {
- setStateValue('following');
- return;
- }
- if (stateRef.current === 'released') return;
- setStateValue('released');
- updateOverflowAndButton();
- }, [setStateValue, updateOverflowAndButton]);
-
- // ── public scroll API (mapped onto the primitives) ───────────────────────
- const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
- scrollToBottom(true, mode === 'smooth' ? 'smooth' : 'auto');
- }, [scrollToBottom]);
-
- const scrollToBottomOnSend = React.useCallback(() => {
- // Single movement to the just-sent message. Force re-pins to the bottom
- // whether we were following or scrolled up; the content ResizeObserver
- // keeps us pinned as the optimistic message and its reply stream in.
- scrollToBottom(true);
- }, [scrollToBottom]);
-
- const releaseAutoFollow = React.useCallback(() => {
- setStateValue('released');
- updateOverflowAndButton();
- }, [setStateValue, updateOverflowAndButton]);
-
- const releaseFromUserIntent = React.useCallback(() => {
- // A genuine user gesture (wheel/touch/key/scrollbar) cancels the entry
- // window immediately so we never fight the user's read position.
- endEntryStick();
- stop();
- }, [endEntryStick, stop]);
-
- // ── per-session snapshot persistence (kept; restore still goes to bottom) ─
- const flushSave = React.useCallback(() => {
- if (saveTimerRef.current !== null) {
- clearTimeout(saveTimerRef.current);
- saveTimerRef.current = null;
- }
- const pending = pendingSaveRef.current;
- if (!pending) return;
- const container = scrollRef.current;
- if (!container) {
- pendingSaveRef.current = null;
- return;
- }
- updateViewportAnchor(pending.sessionId, pending.anchor, {
- scrollTop: container.scrollTop,
- scrollHeight: container.scrollHeight,
- clientHeight: container.clientHeight,
- });
- pendingSaveRef.current = null;
- }, [updateViewportAnchor]);
-
- const queueSave = React.useCallback(() => {
- const sessionId = currentSessionIdRef.current;
- if (!sessionId) return;
- const container = scrollRef.current;
- if (!container) return;
-
- const { scrollTop, scrollHeight, clientHeight } = container;
- const anchorRatio = scrollHeight > 0
- ? (scrollTop + clientHeight / 2) / scrollHeight
- : 0;
- const anchor = Math.floor(anchorRatio * sessionMessageCountRef.current);
-
- pendingSaveRef.current = { sessionId, anchor };
- if (saveTimerRef.current !== null) return;
- saveTimerRef.current = setTimeout(() => {
- saveTimerRef.current = null;
- flushSave();
- }, SAVE_DEBOUNCE_MS);
- }, [flushSave]);
-
- const saveSnapshotNow = React.useCallback(() => {
- flushSave();
- }, [flushSave]);
-
- const restoreSnapshot = React.useCallback(async (): Promise => {
- const sessionKey = currentSessionKeyRef.current;
- if (!sessionKey) return false;
-
- const container = scrollRef.current;
- if (!container) {
- // ChatViewport not mounted yet (e.g., session still hydrating).
- // Record the request so the container-attach effect can replay it.
- pendingInitialRestoreRef.current = sessionKey;
- setStateValue('following');
- return false;
- }
- pendingInitialRestoreRef.current = null;
-
- // Always return to the bottom on session switch. The content
- // ResizeObserver re-pins instantly as late
- // history measures in, so there is no smooth scroll-from-mid artifact.
- setStateValue('following');
- scrollToBottom(true);
- // Hold the bottom across late async growth (e.g. task/subagent child
- // session data landing a beat after entry) until content quiesces or the
- // user scrolls.
- beginEntryStick();
- updateOverflowAndButton();
- return false;
- }, [beginEntryStick, scrollToBottom, setStateValue, updateOverflowAndButton]);
-
- // ── session change ───────────────────────────────────────────────────────
- React.useEffect(() => {
- if (!currentSessionId || !currentSessionKey || currentSessionKey === lastSessionKeyRef.current) {
- return;
- }
- lastSessionKeyRef.current = currentSessionKey;
- MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
- flushSave();
- autoRef.current = null;
- // Drop any pending restore request inherited from a different session.
- if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current !== currentSessionKey) {
- pendingInitialRestoreRef.current = null;
- }
- }, [currentSessionId, currentSessionKey, flushSave]);
-
- // When work begins and we are still
- // following, pin to the bottom. When work stops, keep following alive for a
- // short settle window so the final content lands at the bottom, then go
- // idle (after which passive follow is disabled — see `isActive`).
- React.useEffect(() => {
- settlingRef.current = false;
- if (settleTimerRef.current) {
- clearTimeout(settleTimerRef.current);
- settleTimerRef.current = null;
- }
-
- if (sessionIsWorking) {
- if (stateRef.current === 'following') {
- scrollToBottom(true);
- }
- return;
- }
-
- settlingRef.current = true;
- settleTimerRef.current = setTimeout(() => {
- settlingRef.current = false;
- settleTimerRef.current = null;
- }, SETTLE_MS);
- }, [sessionIsWorking, scrollToBottom]);
-
- // Suppress the overlay scrollbar thumb only while we are actively following a
- // live stream (the thumb would otherwise jump on every instant re-pin). When
- // idle or released the scrollbar behaves normally. Stable: changes only when
- // follow-state or working-state flips, not on every frame.
- React.useEffect(() => {
- setIsFollowingProgrammatically(state === 'following' && sessionIsWorking);
- }, [state, sessionIsWorking]);
-
- // Replay a deferred restoreSnapshot once ChatViewport mounts.
- // useLayoutEffect ensures scroll position is set before the browser paints,
- // preventing a visible flash of content at the wrong scroll position.
- React.useLayoutEffect(() => {
- if (!containerEl) return;
- if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current === currentSessionKey) {
- void restoreSnapshot();
- }
- }, [containerEl, currentSessionKey, restoreSnapshot]);
-
- // ── scroll event handling ────────────────────────────────────────────────
- const handleScrollEvent = React.useCallback(() => {
- const el = scrollRef.current;
- if (!el) return;
-
- const previousTop = lastScrollTopRef.current;
- lastScrollTopRef.current = el.scrollTop;
- const scrollingDown = el.scrollTop > previousTop + 0.5;
-
- updateOverflowAndButton();
-
- if (!canScroll(el)) {
- setStateValue('following');
- return;
- }
-
- // Within the bottom zone → (re-)pin to following. This is how scrolling
- // back DOWN to the bottom resumes auto-follow. Crucially, re-engage only
- // when the user arrives by scrolling down (or is already following, or is
- // essentially at the true bottom). A user scrolling UP that merely lands
- // in the bottom spacer zone must NOT be yanked back into follow — that is
- // the dead-zone fight that made small upward scrolls impossible while
- // content streams.
- if (isNearBottom(el, isMobileRef.current)) {
- const atTrueBottom = distanceFromBottom(el) <= AUTO_MATCH_TOLERANCE_PX;
- if (scrollingDown || stateRef.current === 'following' || atTrueBottom) {
- setStateValue('following');
- }
- queueSave();
- return;
- }
-
- // Our own geometry change (a programmatic write that landed at the bottom
- // but where content grew between the write and this event, OR a tracked
- // height animation in flight) — keep following, don't release.
- if (stateRef.current === 'following' && (isAuto(el) || isAnimationGuardActive())) {
- scrollToBottom(false);
- queueSave();
- return;
- }
-
- // Genuine user scroll away from the bottom.
- stop();
- queueSave();
- }, [isAnimationGuardActive, isAuto, queueSave, scrollToBottom, setStateValue, stop, updateOverflowAndButton]);
-
- React.useEffect(() => {
- const container = containerEl;
- if (!container) return;
-
- lastScrollTopRef.current = container.scrollTop;
-
- const handleWheel = (event: WheelEvent) => {
- if (event.deltaY >= 0) return;
- if (nestedScrollableCanConsumeUp(container, event.target)) return;
- releaseFromUserIntent();
- };
-
- let touchLastY: number | null = null;
- const handleTouchStart = (event: TouchEvent) => {
- const touch = event.touches.item(0);
- touchLastY = touch ? touch.clientY : null;
- };
- const handleTouchMove = (event: TouchEvent) => {
- const touch = event.touches.item(0);
- if (!touch) {
- touchLastY = null;
- return;
- }
- const previousY = touchLastY;
- touchLastY = touch.clientY;
- if (previousY === null) return;
- const fingerDelta = touch.clientY - previousY;
- if (fingerDelta <= TOUCH_FINGER_DOWN_THRESHOLD) return;
- if (nestedScrollableCanConsumeUp(container, event.target)) return;
- releaseFromUserIntent();
- };
- const handleTouchEnd = () => {
- touchLastY = null;
- };
-
- const handleKeyDown = (event: KeyboardEvent) => {
- if (!isReleaseKey(event)) return;
- releaseFromUserIntent();
- };
-
- const handlePointerDownIntent = (event: PointerEvent) => {
- const target = event.target;
- if (!(target instanceof Element)) return;
- if (!target.closest('[data-overlay-scrollbar-thumb]')) return;
- releaseFromUserIntent();
- };
-
- container.addEventListener('scroll', handleScrollEvent, { passive: true });
- container.addEventListener('wheel', handleWheel, { passive: true });
- container.addEventListener('touchstart', handleTouchStart, { passive: true });
- container.addEventListener('touchmove', handleTouchMove, { passive: true });
- container.addEventListener('touchend', handleTouchEnd, { passive: true });
- container.addEventListener('touchcancel', handleTouchEnd, { passive: true });
- container.addEventListener('keydown', handleKeyDown);
- if (typeof window !== 'undefined') {
- window.addEventListener('pointerdown', handlePointerDownIntent, true);
- }
-
- return () => {
- container.removeEventListener('scroll', handleScrollEvent);
- container.removeEventListener('wheel', handleWheel);
- container.removeEventListener('touchstart', handleTouchStart);
- container.removeEventListener('touchmove', handleTouchMove);
- container.removeEventListener('touchend', handleTouchEnd);
- container.removeEventListener('touchcancel', handleTouchEnd);
- container.removeEventListener('keydown', handleKeyDown);
- if (typeof window !== 'undefined') {
- window.removeEventListener('pointerdown', handlePointerDownIntent, true);
- }
- };
- }, [containerEl, handleScrollEvent, releaseFromUserIntent]);
-
- // The heart of the follow behaviour: the content ResizeObserver fires after
- // layout and before paint, so re-pinning to the bottom here is invisible —
- // there is no "jump up then catch up". Observe both the container (composer
- // growth shrinks the viewport) and the inner content (streaming growth).
- React.useEffect(() => {
- const container = containerEl;
- if (!container || typeof ResizeObserver === 'undefined') return;
-
- const observer = new ResizeObserver(() => {
- // Keyboard slide in flight: the container/composer resizes it reports
- // are part of the transform choreography — the settle handler does the
- // single deterministic re-pin, so chasing here would just fight it.
- if (keyboardAnimRef.current) {
- updateOverflowAndButton();
- return;
- }
- const el = scrollRef.current;
- if (el && !canScroll(el)) {
- setStateValue('following');
- updateOverflowAndButton();
- return;
- }
- updateOverflowAndButton();
- // Entry-stick window: on first session open, FORCE the bottom on
- // every growth so late async data (task/subagent child rows, code
- // highlight, mermaid) can't strand the viewport mid-history. Force
- // overrides any false `released` from the growth itself; only a real
- // user gesture clears the window (releaseFromUserIntent).
- if (entryStickRef.current && el) {
- const grew = el.scrollHeight > entryStickLastHeightRef.current + 1;
- entryStickLastHeightRef.current = el.scrollHeight;
- scrollToBottom(true);
- if (grew) armEntryStickQuiet();
- return;
- }
- // Idle resize = layout churn (virtualizer re-measurement, async
- // tool/code rendering), NOT live growth. Never re-pin when idle, or
- // tall items re-measuring as the user scrolls cause an endless
- // scroll-to-bottom/re-measure twitch.
- if (!isActive()) return;
- if (stateRef.current !== 'following') return;
- scrollToBottom(false);
- });
- observer.observe(container);
- const inner = container.firstElementChild;
- if (inner instanceof Element) {
- observer.observe(inner);
- }
- return () => observer.disconnect();
- }, [armEntryStickQuiet, containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]);
-
- // ── native keyboard transitions (Capacitor choreography) ────────────────
- // The chat scroller gets NO transforms during the keyboard transition:
- // transforming the scroll container (or its content) forces WebKit to
- // rebuild the composited scrolling layers, which stalls for seconds on
- // long chats. Instead the chat repositions with instant snaps that hide
- // behind the keyboard itself:
- // show: content stays put while the keyboard/composer slide over it; the
- // settled event (shell layout snap) does ONE instant re-pin.
- // hide: the shell layout is restored up-front — the scrollTop clamp
- // happens while the keyboard still covers that region — and the
- // settled event re-pins once at the end.
- // During the window we only guard the scroll heuristics and the observer
- // chase. These events never fire outside the Capacitor app.
- React.useEffect(() => {
- if (typeof window === 'undefined') return;
-
- const handleKeyboardAnim = (event: Event) => {
- const detail = (event as CustomEvent<{ phase: 'show' | 'hide'; slide: number; durationMs: number; easing: string }>).detail;
- if (!detail) return;
- keyboardAnimRef.current = true;
- // The clamp/resize during the choreography can dispatch scroll events
- // that land away from the auto marker — never read those as a user
- // scroll-away.
- animationGuardUntilRef.current = now() + detail.durationMs + ANIMATION_GUARD_MS;
- };
-
- const handleKeyboardSettled = () => {
- keyboardAnimRef.current = false;
- const el = scrollRef.current;
- if (!el) {
- updateOverflowAndButton();
- return;
- }
- // Single deterministic re-pin, same task as the layout swap → lands
- // before paint. (scrollToBottomNow, not scrollToBottom: this must not
- // be gated on working/settling — the keyboard resize is a viewport
- // change, not content growth.)
- if (stateRef.current === 'following' && canScroll(el)) {
- scrollToBottomNow('auto');
- }
- updateOverflowAndButton();
- };
-
- window.addEventListener('oc:keyboard-anim', handleKeyboardAnim);
- window.addEventListener('oc:keyboard-settled', handleKeyboardSettled);
- return () => {
- window.removeEventListener('oc:keyboard-anim', handleKeyboardAnim);
- window.removeEventListener('oc:keyboard-settled', handleKeyboardSettled);
- keyboardAnimRef.current = false;
- };
- }, [scrollToBottomNow, updateOverflowAndButton]);
-
- React.useEffect(() => {
- updateOverflowAndButton();
- }, [sessionMessageCount, updateOverflowAndButton]);
-
- const notifyContentChange = React.useCallback((reason?: ContentChangeReason) => {
- // A tracked height animation (e.g. Thinking auto-collapse) opens a guard
- // window so its transient geometry / async scroll events are not misread
- // as a user scroll-away. Real gestures still release through
- // releaseFromUserIntent, so the user can always scroll up freely.
- if (reason === 'animation') {
- animationGuardUntilRef.current = now() + ANIMATION_GUARD_MS;
- }
- updateOverflowAndButton();
- // Entry-stick window: late structural growth (notably the task/subagent
- // summary landing from the child session — ToolPart emits 'structural'
- // here) must keep us pinned and refresh the quiescence timer, even though
- // the session is idle.
- if (entryStickRef.current) {
- scrollToBottom(true);
- armEntryStickQuiet();
- return;
- }
- if (stateRef.current === 'following') {
- scrollToBottom(false);
- }
- }, [armEntryStickQuiet, scrollToBottom, updateOverflowAndButton]);
-
- const animationHandlersRef = React.useRef