Skip to content

Commit 61baa8e

Browse files
author
IM.codes
committed
Force chat view to follow new content
1 parent a0ba012 commit 61baa8e

File tree

2 files changed

+57
-7
lines changed

2 files changed

+57
-7
lines changed

web/src/components/ChatView.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -474,12 +474,12 @@ export function ChatView({ events, loading, refreshing: _refreshing, loadingOlde
474474
}
475475
}, [lastVisibleTs]);
476476

477-
// Preview cards should always show the latest content, including streaming
478-
// updates that mutate the last visible event without changing its timestamp.
477+
// Any visible content update should force-follow to the latest message.
478+
// Skip while prepending older history so anchor restoration can preserve position.
479479
useLayoutEffect(() => {
480-
if (!preview) return;
480+
if (loadingOlder || scrollAnchorRef.current) return;
481481
scrollToBottom();
482-
}, [preview, viewItems, loading]);
482+
}, [preview, viewItems, loading, loadingOlder]);
483483

484484
// Restore scroll position after Load Older prepends events
485485
useLayoutEffect(() => {
@@ -492,14 +492,14 @@ export function ChatView({ events, loading, refreshing: _refreshing, loadingOlde
492492
scrollAnchorRef.current = null;
493493
}, [events]);
494494

495-
// Subsequent auto-scroll (new messages while at bottom) — use rAF for smooth updates.
495+
// Fallback for timestamp-based message additions. The layout effect above handles
496+
// streaming edits and other view changes that do not advance timestamps.
496497
useEffect(() => {
497498
const changed = lastVisibleTs !== prevVisibleTsRef.current;
498499
prevVisibleTsRef.current = lastVisibleTs;
499500
if (!changed && !preview) return;
500501
requestAnimationFrame(() => {
501-
if (preview) { scrollToBottom(); return; }
502-
if (autoScrollRef.current) scrollToBottom();
502+
scrollToBottom();
503503
});
504504
}, [lastVisibleTs, preview]);
505505

web/test/components/ChatView.test.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,54 @@ describe('ChatView', () => {
8080
expect(scrollEl.scrollTop).toBe(1600);
8181
});
8282
});
83+
84+
it('forces the main chat view to follow streamed updates with the same timestamp', async () => {
85+
const initialEvents = [
86+
{
87+
eventId: 'evt-1',
88+
type: 'assistant.text',
89+
ts: 1000,
90+
payload: { text: 'hello' },
91+
},
92+
] as any;
93+
94+
const { container, rerender } = render(
95+
<ChatView
96+
events={initialEvents}
97+
loading={false}
98+
sessionId="deck_main_brain"
99+
/>,
100+
);
101+
102+
const scrollEl = container.querySelector('.chat-view') as HTMLDivElement;
103+
Object.defineProperty(scrollEl, 'scrollTop', { configurable: true, writable: true, value: 0 });
104+
Object.defineProperty(scrollEl, 'scrollHeight', { configurable: true, value: 1200 });
105+
Object.defineProperty(scrollEl, 'clientHeight', { configurable: true, value: 200 });
106+
107+
await waitFor(() => {
108+
expect(scrollEl.scrollTop).toBe(1200);
109+
});
110+
111+
scrollEl.scrollTop = 50;
112+
Object.defineProperty(scrollEl, 'scrollHeight', { configurable: true, value: 1800 });
113+
114+
rerender(
115+
<ChatView
116+
events={[
117+
{
118+
eventId: 'evt-1',
119+
type: 'assistant.text',
120+
ts: 1000,
121+
payload: { text: 'hello world' },
122+
},
123+
] as any}
124+
loading={false}
125+
sessionId="deck_main_brain"
126+
/>,
127+
);
128+
129+
await waitFor(() => {
130+
expect(scrollEl.scrollTop).toBe(1800);
131+
});
132+
});
83133
});

0 commit comments

Comments
 (0)