(VIEWPORT_SELECTOR) ?? node;
+ }
+ return source;
+ };
+ const beginUpwardInput = (target: EventTarget | null) => {
+ const source = sourceViewport(target);
+ if (source.scrollHeight - source.clientHeight <= BOTTOM_EPSILON) return;
+ setPaused(true);
+ if (source === node) {
+ programmaticTopRef.current = null;
+ userScrollRef.current = { top: node.scrollTop, direction: -1 };
+ }
+ };
+ const beginDownwardInput = (target: EventTarget | null) => {
+ if (sourceViewport(target) !== node) return;
+ if (atBottom()) scrollToBottom();
+ else userScrollRef.current = { top: node.scrollTop, direction: 1 };
+ };
+ const wheel = (event: WheelEvent) => {
+ if (event.ctrlKey) return;
+ if (event.deltaY < 0) beginUpwardInput(event.target);
+ else if (event.deltaY > 0) beginDownwardInput(event.target);
+ };
+ const touchStart = (event: TouchEvent) => { touchY = event.touches[0]?.clientY ?? null; };
+ const touchMove = (event: TouchEvent) => {
+ const nextY = event.touches[0]?.clientY ?? null;
+ if (nextY !== null && touchY !== null && nextY > touchY) beginUpwardInput(event.target);
+ else if (nextY !== null && touchY !== null && nextY < touchY) beginDownwardInput(event.target);
+ touchY = nextY;
+ };
+ const keyDown = (event: KeyboardEvent) => {
+ const target = event.target;
+ if (target instanceof Element && target.closest('input, textarea, select, [contenteditable="true"]')) return;
+ if (['ArrowUp', 'PageUp', 'Home'].includes(event.key) || (event.key === ' ' && event.shiftKey)) beginUpwardInput(target);
+ else if (event.key === 'End' && sourceViewport(target) === node) scrollToBottom();
+ else if (['ArrowDown', 'PageDown', ' '].includes(event.key)) beginDownwardInput(target);
+ };
+ const pointerDown = (event: PointerEvent) => {
+ // A click in blank content is not a scrollbar drag.
+ const source = sourceViewport(event.target);
+ if (event.target === source && source.scrollHeight > source.clientHeight
+ && event.clientX >= source.getBoundingClientRect().right - 16) {
+ setPaused(true);
+ if (source === node) userScrollRef.current = { top: node.scrollTop, direction: 0, dragging: true };
+ }
+ };
+ const pointerUp = () => { if (userScrollRef.current?.dragging) userScrollRef.current = null; };
+ const scroll = () => {
+ const previous = metricsRef.current;
+ const top = node.scrollTop;
+ const userMoved = acceptUserDisplacement();
+ if (!userMoved && programmaticTopRef.current !== null && Math.abs(top - programmaticTopRef.current) < 0.5) {
+ programmaticTopRef.current = null;
+ rememberMetrics();
+ publish();
+ return;
+ }
+ programmaticTopRef.current = null;
+ const layoutChanged = node.scrollHeight !== previous.height || node.clientHeight !== previous.viewport;
+ if (userMoved || !layoutChanged) {
+ if (!userMoved && top < previous.top) setPaused(true);
+ else if (top > previous.top && atBottom()) scrollToBottom();
+ if (pausedRef.current || !optionsRef.current.enabled) captureAnchor();
+ }
+ rememberMetrics();
+ publish();
+ };
+ controllers.set(node, { resume: resumeOwnViewport });
+ node.addEventListener(FOLLOW_CHANGE, updateAvailability);
+ node.addEventListener('wheel', wheel, { capture: true, passive: true });
+ node.addEventListener('touchstart', touchStart, { capture: true, passive: true });
+ node.addEventListener('touchmove', touchMove, { capture: true, passive: true });
+ node.addEventListener('keydown', keyDown, true);
+ node.addEventListener('pointerdown', pointerDown, true);
+ window.addEventListener('pointerup', pointerUp);
+ node.addEventListener('scroll', scroll, { passive: true });
+ rememberMetrics();
+ return () => {
+ controllers.delete(node);
+ node.removeEventListener(FOLLOW_CHANGE, updateAvailability);
+ node.removeEventListener('wheel', wheel, true);
+ node.removeEventListener('touchstart', touchStart, true);
+ node.removeEventListener('touchmove', touchMove, true);
+ node.removeEventListener('keydown', keyDown, true);
+ node.removeEventListener('pointerdown', pointerDown, true);
+ window.removeEventListener('pointerup', pointerUp);
+ node.removeEventListener('scroll', scroll);
+ };
+ }, [containerRef, scopeKey, contentKey, acceptUserDisplacement, captureAnchor, rememberMetrics, publish, resumeOwnViewport, scrollToBottom, setPaused, updateAvailability]);
+
+ useLayoutEffect(() => {
+ const node = containerRef.current;
+ const content = contentSelector ? node?.querySelector(contentSelector) : node?.firstElementChild;
+ if (!node || !content || typeof ResizeObserver === 'undefined') return;
+ const observer = new ResizeObserver(() => {
+ acceptUserDisplacement();
+ if (pausedRef.current || !optionsRef.current.enabled) restoreAnchor();
+ else scheduleFollow();
+ publish();
+ });
+ observer.observe(content);
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [containerRef, contentSelector, scopeKey, contentKey, acceptUserDisplacement, restoreAnchor, scheduleFollow, publish]);
+
+ useLayoutEffect(() => {
+ acceptUserDisplacement();
+ if (pausedRef.current || !enabled) {
+ if (pausedRef.current || !initialFrameRef.current) cancelFollow();
+ restoreAnchor();
+ if (!anchorRef.current) captureAnchor();
+ } else scheduleFollow();
+ publish();
+ });
+
+ return { isPaused, canReturnToLatest, hasOverflow, setPaused, getIsPaused, pause, scrollToBottom, scheduleFollow, scheduleInitialPosition, cancelFollow, captureAnchor, getReadingAnchor, restoreReadingAnchor };
+}
diff --git a/ui/src/components/chat/types/types.ts b/ui/src/components/chat/types/types.ts
index 1169ed4e3..802af3aac 100644
--- a/ui/src/components/chat/types/types.ts
+++ b/ui/src/components/chat/types/types.ts
@@ -85,6 +85,7 @@ export interface SubagentChildTool {
export interface ChatMessage {
id?: string;
+ renderKey?: string;
entryId?: string;
type: string;
content?: string;
diff --git a/ui/src/components/chat/utils/messageKeys.ts b/ui/src/components/chat/utils/messageKeys.ts
index 410161fed..ec8e00f93 100644
--- a/ui/src/components/chat/utils/messageKeys.ts
+++ b/ui/src/components/chat/utils/messageKeys.ts
@@ -11,6 +11,7 @@ const toMessageKeyPart = (value: unknown): string | null => {
export const getIntrinsicMessageKey = (message: ChatMessage): string | null => {
const candidates = [
+ message.renderKey,
message.id,
message.messageId,
message.toolId,
diff --git a/ui/src/components/chat/view/subcomponents/Markdown.tsx b/ui/src/components/chat/view/subcomponents/Markdown.tsx
index 29619dddf..d0ea65f97 100644
--- a/ui/src/components/chat/view/subcomponents/Markdown.tsx
+++ b/ui/src/components/chat/view/subcomponents/Markdown.tsx
@@ -76,7 +76,7 @@ export function Markdown({
[onFileOpen],
);
const remarkPlugins = useMemo(() => {
- if (isStreaming) return [remarkGfm];
+ if (isStreaming) return [remarkGfm, remarkMath];
if (artifactFiles === undefined) return [remarkGfm, remarkMath];
return [remarkGfm, remarkMath, createRemarkArtifactFileTextPlugin(artifactFiles)];
}, [artifactFiles, isStreaming]);
@@ -92,7 +92,7 @@ export function Markdown({
{content}
diff --git a/ui/src/i18n/locales/en/chat.json b/ui/src/i18n/locales/en/chat.json
index e733f433d..1be520d68 100644
--- a/ui/src/i18n/locales/en/chat.json
+++ b/ui/src/i18n/locales/en/chat.json
@@ -472,6 +472,9 @@
"loadingAll": "Loading all messages...",
"allLoaded": "All messages loaded",
"perfWarning": "All messages loaded — scrolling may be slower. Click \"Scroll to bottom\" to restore performance."
+ },
+ "scroll": {
+ "returnToLatest": "Back to latest"
}
},
"loading": "Loading...",
diff --git a/ui/src/i18n/locales/zh-CN/chat.json b/ui/src/i18n/locales/zh-CN/chat.json
index d3e72f22e..b1a86a9f9 100644
--- a/ui/src/i18n/locales/zh-CN/chat.json
+++ b/ui/src/i18n/locales/zh-CN/chat.json
@@ -454,6 +454,9 @@
"loadingAll": "正在加载全部消息...",
"allLoaded": "全部消息已加载",
"perfWarning": "已加载全部消息 - 滚动可能变慢。点击「滚动到底部」恢复性能。"
+ },
+ "scroll": {
+ "returnToLatest": "回到最新"
}
},
"loading": "加载中...",
diff --git a/ui/src/stores/useSessionStore.renderKeys.test.tsx b/ui/src/stores/useSessionStore.renderKeys.test.tsx
new file mode 100644
index 000000000..2c00b0432
--- /dev/null
+++ b/ui/src/stores/useSessionStore.renderKeys.test.tsx
@@ -0,0 +1,44 @@
+import { act, cleanup, renderHook } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+import { normalizedToChatMessages } from '../components/chat/hooks/useChatMessages';
+import { getIntrinsicMessageKey } from '../components/chat/utils/messageKeys';
+import { inheritMessageRenderKeys, useSessionStore, type NormalizedMessage } from './useSessionStore';
+
+afterEach(cleanup);
+const msg = (id: string, runId = 'run'): NormalizedMessage => ({ id, sessionId: 'session', runId, kind: 'text', role: 'assistant', content: 'Same content', timestamp: '2026-09-05', provider: 'pilotdeck' });
+describe('streaming presentation identity', () => {
+ it.each(['thinking', 'text'])('keeps subagent %s identity and clears the live flag on completion', (kind) => {
+ const { result } = renderHook(() => useSessionStore());
+ const update = kind === 'thinking' ? result.current.updateSubagentDetailThinking : result.current.updateSubagentDetailStreaming;
+ const finalize = kind === 'thinking' ? result.current.finalizeSubagentDetailThinking : result.current.finalizeSubagentDetailStreaming;
+ act(() => update('session', 'child', 'Subagent block', 'pilotdeck'));
+ const before = normalizedToChatMessages(result.current.getSubagentDetailMessages('session', 'child'))[0];
+ expect(before.isStreaming).toBe(true);
+ act(() => finalize('session', 'child'));
+ const after = normalizedToChatMessages(result.current.getSubagentDetailMessages('session', 'child'))[0];
+ expect(after.isStreaming).toBeFalsy();
+ expect(getIntrinsicMessageKey(after)).toBe(getIntrinsicMessageKey(before));
+ });
+ it.each(['thinking', 'text'])('keeps the %s React key through finalization and allocates another for the next block', (kind) => {
+ const { result } = renderHook(() => useSessionStore());
+ const update = kind === 'thinking' ? result.current.updateStreamingThinking : result.current.updateStreaming;
+ const finalize = kind === 'thinking' ? result.current.finalizeStreamingThinking : result.current.finalizeStreaming;
+ act(() => update('session', 'First block', 'pilotdeck', 'run'));
+ const before = normalizedToChatMessages(result.current.getMessages('session'))[0];
+ act(() => finalize('session', 'run'));
+ const after = normalizedToChatMessages(result.current.getMessages('session'))[0];
+ expect(after.id).not.toBe(before.id);
+ expect(getIntrinsicMessageKey(after)).toBe(getIntrinsicMessageKey(before));
+ act(() => update('session', 'Second block', 'pilotdeck', 'run'));
+ const second = normalizedToChatMessages(result.current.getMessages('session'))[1];
+ expect(getIntrinsicMessageKey(second)).not.toBe(getIntrinsicMessageKey(before));
+ });
+ it('preserves keys when the server confirms content, without crossing runs or duplicating keys', () => {
+ const previous = [{ ...msg('live'), renderKey: 'render-one' }];
+ expect(inheritMessageRenderKeys(previous, [msg('server')])[0].renderKey).toBe('render-one');
+ expect(inheritMessageRenderKeys(previous, [msg('server', 'other-run')])[0].renderKey).toBeUndefined();
+ const duplicates = inheritMessageRenderKeys(previous, [msg('server'), { ...msg('live'), renderKey: 'render-one' }]);
+ expect(duplicates[0].renderKey).toBeUndefined();
+ expect(duplicates[1].renderKey).toBe('render-one');
+ });
+});
diff --git a/ui/src/stores/useSessionStore.ts b/ui/src/stores/useSessionStore.ts
index 9f825b1f4..05aac4282 100644
--- a/ui/src/stores/useSessionStore.ts
+++ b/ui/src/stores/useSessionStore.ts
@@ -47,6 +47,8 @@ export interface CompactProgress {
export interface NormalizedMessage {
id: string;
+ /** Stable UI identity across streaming finalization. */
+ renderKey?: string;
sessionId: string;
timestamp: string;
provider: SessionProvider;
@@ -1041,6 +1043,7 @@ export function upsertRealtimeMessages(
const previousKey = getUpsertKey(updated[duplicateAssistantTextIndex]);
updated[duplicateAssistantTextIndex] = {
...message,
+ renderKey: updated[duplicateAssistantTextIndex].renderKey ?? message.renderKey,
serverTailIdAtStart: message.serverTailIdAtStart ?? updated[duplicateAssistantTextIndex].serverTailIdAtStart,
};
indexByKey.delete(previousKey);
@@ -1055,10 +1058,12 @@ export function upsertRealtimeMessages(
} else {
const existingTailId = updated[existingIndex].serverTailIdAtStart;
const existingHistoryPending = updated[existingIndex].serverHistoryPendingAtStart;
- updated[existingIndex] = existingTailId === undefined && existingHistoryPending === undefined
+ const renderKey = updated[existingIndex].renderKey;
+ updated[existingIndex] = existingTailId === undefined && existingHistoryPending === undefined && !renderKey
? message
: {
...message,
+ ...(renderKey ? { renderKey } : {}),
...(existingTailId !== undefined ? { serverTailIdAtStart: existingTailId } : {}),
...(existingHistoryPending !== undefined
? { serverHistoryPendingAtStart: existingHistoryPending }
@@ -1079,6 +1084,34 @@ function findLatestToolResultIndex(messages: NormalizedMessage[], toolId: string
return -1;
}
+/** Carry UI identity through the server's confirmation of a displayed stream. */
+export function inheritMessageRenderKeys(previous: NormalizedMessage[], next: NormalizedMessage[]): NormalizedMessage[] {
+ const candidates = previous.filter((message) => message.renderKey);
+ if (candidates.length === 0) return next;
+ const used = new Set(next.flatMap((message) => message.renderKey ? [message.renderKey] : []));
+ let changed = false;
+ const result = next.map((message) => {
+ if (message.renderKey) {
+ used.add(message.renderKey);
+ return message;
+ }
+ const match = candidates.find((candidate) => {
+ if (!candidate.renderKey || used.has(candidate.renderKey)) return false;
+ if (candidate.id === message.id) return true;
+ const turn = getMessageTurnId(candidate);
+ if (!turn || turn !== getMessageTurnId(message)) return false;
+ const sameKind = candidate.kind === message.kind
+ || (candidate.kind === 'stream_delta' && message.kind === 'text' && message.role === 'assistant');
+ return sameKind && Boolean(candidate.content) && candidate.content === message.content;
+ });
+ if (!match?.renderKey) return message;
+ used.add(match.renderKey);
+ changed = true;
+ return { ...message, renderKey: match.renderKey };
+ });
+ return changed ? result : next;
+}
+
/**
* Recompute slot.merged only when the input arrays have actually changed
* (by reference). Returns true if merged was recomputed.
@@ -1089,14 +1122,14 @@ function recomputeMergedIfNeeded(slot: SessionSlot): boolean {
}
slot._lastServerRef = slot.serverMessages;
slot._lastRealtimeRef = slot.realtimeMessages;
- slot.merged = computeMerged(slot.serverMessages, slot.realtimeMessages);
+ slot.merged = inheritMessageRenderKeys(slot.merged, computeMerged(slot.serverMessages, slot.realtimeMessages));
return true;
}
function forceRecomputeMerged(slot: SessionSlot): void {
slot._lastServerRef = slot.serverMessages;
slot._lastRealtimeRef = slot.realtimeMessages;
- slot.merged = computeMerged(slot.serverMessages, slot.realtimeMessages);
+ slot.merged = inheritMessageRenderKeys(slot.merged, computeMerged(slot.serverMessages, slot.realtimeMessages));
}
function streamingKey(sessionId: string, runId?: string): string {
@@ -1570,6 +1603,7 @@ export function useSessionStore() {
...current,
{
id: streamId,
+ renderKey: `${streamId}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`,
sessionId,
timestamp: new Date().toISOString(),
provider: msgProvider,
@@ -1633,6 +1667,7 @@ export function useSessionStore() {
...current,
{
id: streamId,
+ renderKey: `${streamId}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`,
sessionId,
timestamp: new Date().toISOString(),
provider: msgProvider,
@@ -1874,6 +1909,7 @@ export function useSessionStore() {
: null;
const msg: NormalizedMessage = {
id: streamId,
+ renderKey: `${streamId}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`,
sessionId,
timestamp: new Date().toISOString(),
provider: msgProvider,
@@ -1943,6 +1979,7 @@ export function useSessionStore() {
: null;
const msg: NormalizedMessage = {
id: streamId,
+ renderKey: `${streamId}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`,
sessionId,
timestamp: new Date().toISOString(),
provider: msgProvider,