mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
fix: resolve chat message pagination scroll issues (#23169)
## Summary Fixes four interrelated issues that caused scroll position jumps and phantom scroll growth when paginating older chat messages. ## Changes ### 1. Removed client-side message windowing (`useMessageWindow`) There were two competing sentinel systems: server-side pagination and client-side windowing. The client windowing sentinel was nested deep inside the timeline with no explicit IntersectionObserver `root`, causing scroll position jumps when messages were prepended. Blink (coder/blink) has no client-side windowing. Removed it entirely; server pagination + `contentVisibility` handled performance. ### 2. Removed `contentVisibility: "auto"` from message sections Each section had `contentVisibility: "auto"` with `containIntrinsicSize: "1px 600px"`, causing the scroll region to grow/shrink as the browser swapped 600px placeholders for actual heights while scrolling. This created phantom scroll growth with no fetch involved. ### 3. Gated WebSocket on initial REST data The WebSocket `Subscribe` snapshot calls `GetChatMessagesByChatID` (no LIMIT) which returns every message when `afterMessageID` is 0. The WebSocket effect opened before the REST page resolved, so `lastMessageIdRef` was undefined, causing the server to replay the entire history and defeating pagination. Added `initialDataLoaded` guard so the socket waits for the first REST page. ### 4. Manual scroll position restoration Replaced unreliable CSS scroll anchoring in `flex-col-reverse` with a `ScrollAnchoredContainer` that snapshots `scrollHeight` before fetch and restores `scrollTop` via `useLayoutEffect` after render. Disabled browser scroll anchoring (`overflow-anchor: none`) to prevent conflicts.
This commit is contained in:
@@ -478,6 +478,13 @@ export const useChatStore = (
|
||||
? chatMessages[chatMessages.length - 1].id
|
||||
: undefined;
|
||||
|
||||
// True once the initial REST page has resolved for the current
|
||||
// chat. The WebSocket effect gates on this so that
|
||||
// lastMessageIdRef is populated before the socket opens;
|
||||
// otherwise the server replays the entire message history as
|
||||
// its snapshot, defeating pagination.
|
||||
const initialDataLoaded = chatMessages !== undefined;
|
||||
|
||||
const updateSidebarChat = useCallback(
|
||||
(updater: (chat: TypesGen.Chat) => TypesGen.Chat) => {
|
||||
if (!chatID) {
|
||||
@@ -616,7 +623,7 @@ export const useChatStore = (
|
||||
store.resetTransientState();
|
||||
activeChatIDRef.current = chatID ?? null;
|
||||
|
||||
if (!chatID) {
|
||||
if (!chatID || !initialDataLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -849,6 +856,7 @@ export const useChatStore = (
|
||||
cancelScheduledStreamReset,
|
||||
chatID,
|
||||
clearChatErrorReason,
|
||||
initialDataLoaded,
|
||||
scheduleStreamReset,
|
||||
setChatErrorReason,
|
||||
store,
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { createRef } from "react";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { ConversationTimeline } from "./ConversationTimeline";
|
||||
import {
|
||||
buildParsedMessageSections,
|
||||
parseMessagesWithMergedTools,
|
||||
} from "./messageParsing";
|
||||
import { parseMessagesWithMergedTools } from "./messageParsing";
|
||||
|
||||
// 1×1 solid coral (#FF6B6B) PNG encoded as base64.
|
||||
const TEST_PNG_B64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4n539HwAHFwLVF8kc1wAAAABJRU5ErkJggg==";
|
||||
|
||||
const buildSections = (messages: TypesGen.ChatMessage[]) =>
|
||||
buildParsedMessageSections(parseMessagesWithMergedTools(messages));
|
||||
const buildMessages = (messages: TypesGen.ChatMessage[]) =>
|
||||
parseMessagesWithMergedTools(messages);
|
||||
|
||||
const baseMessage = {
|
||||
chat_id: "story-chat",
|
||||
@@ -22,11 +18,9 @@ const baseMessage = {
|
||||
|
||||
const defaultArgs: Omit<
|
||||
React.ComponentProps<typeof ConversationTimeline>,
|
||||
"parsedSections"
|
||||
"parsedMessages"
|
||||
> = {
|
||||
isEmpty: false,
|
||||
hasMoreMessages: false,
|
||||
loadMoreSentinelRef: createRef<HTMLDivElement>(),
|
||||
hasStreamOutput: false,
|
||||
streamState: null,
|
||||
streamTools: [],
|
||||
@@ -53,7 +47,7 @@ type Story = StoryObj<typeof ConversationTimeline>;
|
||||
export const UserMessageWithSingleImage: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
parsedSections: buildSections([
|
||||
parsedMessages: buildMessages([
|
||||
{
|
||||
...baseMessage,
|
||||
id: 1,
|
||||
@@ -91,7 +85,7 @@ export const UserMessageWithSingleImage: Story = {
|
||||
export const UserMessageWithMultipleImages: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
parsedSections: buildSections([
|
||||
parsedMessages: buildMessages([
|
||||
{
|
||||
...baseMessage,
|
||||
id: 1,
|
||||
@@ -128,7 +122,7 @@ export const UserMessageWithMultipleImages: Story = {
|
||||
export const UserMessageWithFileIdImage: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
parsedSections: buildSections([
|
||||
parsedMessages: buildMessages([
|
||||
{
|
||||
...baseMessage,
|
||||
id: 1,
|
||||
@@ -160,7 +154,7 @@ export const UserMessageWithFileIdImage: Story = {
|
||||
export const UserMessageTextOnly: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
parsedSections: buildSections([
|
||||
parsedMessages: buildMessages([
|
||||
{
|
||||
...baseMessage,
|
||||
id: 1,
|
||||
@@ -181,7 +175,7 @@ export const UserMessageTextOnly: Story = {
|
||||
export const AssistantMessageWithImage: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
parsedSections: buildSections([
|
||||
parsedMessages: buildMessages([
|
||||
{
|
||||
...baseMessage,
|
||||
id: 1,
|
||||
@@ -214,7 +208,7 @@ export const AssistantMessageWithImage: Story = {
|
||||
export const UserMessageWithImagesAndFileRefs: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
parsedSections: buildSections([
|
||||
parsedMessages: buildMessages([
|
||||
{
|
||||
...baseMessage,
|
||||
id: 1,
|
||||
@@ -249,8 +243,7 @@ export const UserMessageWithImagesAndFileRefs: Story = {
|
||||
export const UsageLimitExceeded: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
loadMoreSentinelRef: { current: null },
|
||||
parsedSections: [],
|
||||
parsedMessages: [],
|
||||
detailError: {
|
||||
kind: "usage-limit",
|
||||
message:
|
||||
@@ -274,8 +267,7 @@ export const UsageLimitExceeded: Story = {
|
||||
export const GenericErrorDoesNotShowUsageAction: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
loadMoreSentinelRef: { current: null },
|
||||
parsedSections: [],
|
||||
parsedMessages: [],
|
||||
detailError: { kind: "generic", message: "Provider request failed." },
|
||||
onOpenAnalytics: fn(),
|
||||
subagentTitles: new Map(),
|
||||
@@ -294,7 +286,7 @@ export const GenericErrorDoesNotShowUsageAction: Story = {
|
||||
export const UserMessageWithInlineFileRef: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
parsedSections: buildSections([
|
||||
parsedMessages: buildMessages([
|
||||
{
|
||||
...baseMessage,
|
||||
id: 1,
|
||||
@@ -336,7 +328,7 @@ export const UserMessageWithInlineFileRef: Story = {
|
||||
export const UserMessageWithMultipleInlineFileRefs: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
parsedSections: buildSections([
|
||||
parsedMessages: buildMessages([
|
||||
{
|
||||
...baseMessage,
|
||||
id: 1,
|
||||
@@ -368,3 +360,77 @@ export const UserMessageWithMultipleInlineFileRefs: Story = {
|
||||
expect(canvas.getByText(/handler_test\.go/)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies the structural requirements for sticky user messages
|
||||
* in the flat (section-less) message list:
|
||||
* - Each user message renders a data-user-sentinel marker so
|
||||
* the push-up logic can find the next user message via DOM
|
||||
* traversal.
|
||||
* - The user message container gets position:sticky.
|
||||
* - Sentinels appear in the correct order (matching user
|
||||
* message order).
|
||||
*/
|
||||
export const StickyUserMessageStructure: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
parsedMessages: buildMessages([
|
||||
{
|
||||
...baseMessage,
|
||||
id: 1,
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "First prompt" }],
|
||||
},
|
||||
{
|
||||
...baseMessage,
|
||||
id: 2,
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "First response" }],
|
||||
},
|
||||
{
|
||||
...baseMessage,
|
||||
id: 3,
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Second prompt" }],
|
||||
},
|
||||
{
|
||||
...baseMessage,
|
||||
id: 4,
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Second response" }],
|
||||
},
|
||||
]),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
// Each user message should produce a data-user-sentinel
|
||||
// marker that the push-up scroll logic relies on.
|
||||
const sentinels = canvasElement.querySelectorAll("[data-user-sentinel]");
|
||||
expect(sentinels.length).toBe(2);
|
||||
|
||||
// Each sentinel should be immediately followed by a sticky
|
||||
// container (the user message itself).
|
||||
for (const sentinel of sentinels) {
|
||||
const container = sentinel.nextElementSibling;
|
||||
expect(container).not.toBeNull();
|
||||
const style = window.getComputedStyle(container!);
|
||||
expect(style.position).toBe("sticky");
|
||||
}
|
||||
|
||||
// Sentinels must appear in DOM order matching the message
|
||||
// order so nextElementSibling traversal finds the correct
|
||||
// next user message.
|
||||
const allElements = Array.from(
|
||||
canvasElement.querySelectorAll("[data-user-sentinel], [class*='sticky']"),
|
||||
);
|
||||
const sentinelIndices = Array.from(sentinels).map((s) =>
|
||||
allElements.indexOf(s),
|
||||
);
|
||||
// Sentinels should be in ascending DOM order.
|
||||
expect(sentinelIndices[0]).toBeLessThan(sentinelIndices[1]);
|
||||
|
||||
// Both user messages should be visible.
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("First prompt")).toBeVisible();
|
||||
expect(canvas.getByText("Second prompt")).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
Fragment,
|
||||
memo,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -37,7 +36,7 @@ import { useSmoothStreamingText } from "./SmoothText";
|
||||
import type {
|
||||
MergedTool,
|
||||
ParsedMessageContent,
|
||||
ParsedMessageSection,
|
||||
ParsedMessageEntry,
|
||||
RenderBlock,
|
||||
StreamState,
|
||||
} from "./types";
|
||||
@@ -695,9 +694,9 @@ const StickyUserMessage: FC<{
|
||||
if (tooTall) {
|
||||
container.style.setProperty("--clip-h", `${fullHeight}px`);
|
||||
container.style.setProperty("--fade-opacity", "0");
|
||||
container.style.top = "0px";
|
||||
return;
|
||||
}
|
||||
|
||||
const sentinelTop = sentinel.getBoundingClientRect().top;
|
||||
const scrolledPast = scrollerTop - sentinelTop;
|
||||
|
||||
@@ -706,10 +705,10 @@ const StickyUserMessage: FC<{
|
||||
// correct height immediately when isStuck flips.
|
||||
container.style.setProperty("--clip-h", `${fullHeight}px`);
|
||||
container.style.setProperty("--fade-opacity", "0");
|
||||
container.style.top = "0px";
|
||||
return;
|
||||
}
|
||||
|
||||
const visible = Math.max(fullHeight - scrolledPast, MIN_HEIGHT);
|
||||
const visible = Math.max(fullHeight - scrolledPast - 48, MIN_HEIGHT);
|
||||
container.style.setProperty("--clip-h", `${visible}px`);
|
||||
// Only show the fade gradient once enough content is
|
||||
// clipped to be visually meaningful.
|
||||
@@ -717,6 +716,24 @@ const StickyUserMessage: FC<{
|
||||
"--fade-opacity",
|
||||
visible < fullHeight - 8 ? "1" : "0",
|
||||
);
|
||||
|
||||
// Push-up effect: when the next user message's sentinel
|
||||
// approaches the bottom of this sticky container, shift
|
||||
// this container upward so it slides out of view — the
|
||||
// same visual as the old section-boundary behavior.
|
||||
let nextSentinel: Element | null = sentinel.nextElementSibling;
|
||||
while (nextSentinel) {
|
||||
if (nextSentinel.hasAttribute("data-user-sentinel")) {
|
||||
break;
|
||||
}
|
||||
nextSentinel = nextSentinel.nextElementSibling;
|
||||
}
|
||||
if (nextSentinel) {
|
||||
const nextY = nextSentinel.getBoundingClientRect().top - scrollerTop;
|
||||
container.style.top = `${Math.min(0, nextY - visible)}px`;
|
||||
} else {
|
||||
container.style.top = "0px";
|
||||
}
|
||||
};
|
||||
updateFnRef.current = update;
|
||||
|
||||
@@ -786,12 +803,12 @@ const StickyUserMessage: FC<{
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={sentinelRef} className="h-0" />
|
||||
<div ref={sentinelRef} className="h-0" data-user-sentinel />
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative px-3 -mx-3 pt-2 pb-2",
|
||||
!isTooTall && "sticky top-0 z-10",
|
||||
!isTooTall && "sticky z-10",
|
||||
!isReady && "invisible",
|
||||
isStuck && !isTooTall && "pointer-events-none",
|
||||
)}
|
||||
@@ -871,9 +888,7 @@ const StickyUserMessage: FC<{
|
||||
|
||||
interface ConversationTimelineProps {
|
||||
isEmpty: boolean;
|
||||
hasMoreMessages: boolean;
|
||||
loadMoreSentinelRef: RefObject<HTMLDivElement | null>;
|
||||
parsedSections: readonly ParsedMessageSection[];
|
||||
parsedMessages: readonly ParsedMessageEntry[];
|
||||
hasStreamOutput: boolean;
|
||||
streamState: StreamState | null;
|
||||
streamTools: readonly MergedTool[];
|
||||
@@ -895,9 +910,7 @@ interface ConversationTimelineProps {
|
||||
|
||||
export const ConversationTimeline: FC<ConversationTimelineProps> = ({
|
||||
isEmpty,
|
||||
hasMoreMessages,
|
||||
loadMoreSentinelRef,
|
||||
parsedSections,
|
||||
parsedMessages,
|
||||
hasStreamOutput,
|
||||
streamState,
|
||||
streamTools,
|
||||
@@ -912,8 +925,8 @@ export const ConversationTimeline: FC<ConversationTimelineProps> = ({
|
||||
savingMessageId,
|
||||
urlTransform,
|
||||
}) => {
|
||||
const shouldRenderStreamInLastSection =
|
||||
hasStreamOutput && parsedSections.length > 0;
|
||||
const shouldRenderStreamAfterMessages =
|
||||
hasStreamOutput && parsedMessages.length > 0;
|
||||
const isUsageLimitError = detailError?.kind === "usage-limit";
|
||||
const showUsageAction = onOpenAnalytics !== undefined && isUsageLimitError;
|
||||
|
||||
@@ -922,15 +935,13 @@ export const ConversationTimeline: FC<ConversationTimelineProps> = ({
|
||||
const afterEditingMessageIds = new Set<number>();
|
||||
if (editingMessageId != null) {
|
||||
let found = false;
|
||||
for (const section of parsedSections) {
|
||||
for (const entry of section.entries) {
|
||||
if (entry.message.id === editingMessageId) {
|
||||
found = true;
|
||||
continue;
|
||||
}
|
||||
if (found) {
|
||||
afterEditingMessageIds.add(entry.message.id);
|
||||
}
|
||||
for (const entry of parsedMessages) {
|
||||
if (entry.message.id === editingMessageId) {
|
||||
found = true;
|
||||
continue;
|
||||
}
|
||||
if (found) {
|
||||
afterEditingMessageIds.add(entry.message.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -943,66 +954,40 @@ export const ConversationTimeline: FC<ConversationTimelineProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{hasMoreMessages && (
|
||||
<div
|
||||
ref={loadMoreSentinelRef}
|
||||
className="flex items-center justify-center py-4 text-xs text-content-secondary"
|
||||
>
|
||||
Loading earlier messages…
|
||||
</div>
|
||||
{parsedMessages.map(({ message, parsed }) =>
|
||||
message.role === "user" ? (
|
||||
<StickyUserMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
parsed={parsed}
|
||||
onEditUserMessage={onEditUserMessage}
|
||||
editingMessageId={editingMessageId}
|
||||
savingMessageId={savingMessageId}
|
||||
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
|
||||
/>
|
||||
) : (
|
||||
<ChatMessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
parsed={parsed}
|
||||
savingMessageId={savingMessageId}
|
||||
urlTransform={urlTransform}
|
||||
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{parsedSections.map((section, sectionIdx) => (
|
||||
<div
|
||||
key={section.userEntry?.message.id ?? `section-${sectionIdx}`}
|
||||
className="-mx-1 px-1"
|
||||
style={{
|
||||
contentVisibility: "auto",
|
||||
containIntrinsicSize: "1px 600px",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{section.entries.map(({ message, parsed }) =>
|
||||
message.role === "user" ? (
|
||||
<StickyUserMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
parsed={parsed}
|
||||
onEditUserMessage={onEditUserMessage}
|
||||
editingMessageId={editingMessageId}
|
||||
savingMessageId={savingMessageId}
|
||||
isAfterEditingMessage={afterEditingMessageIds.has(
|
||||
message.id,
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ChatMessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
parsed={parsed}
|
||||
savingMessageId={savingMessageId}
|
||||
urlTransform={urlTransform}
|
||||
isAfterEditingMessage={afterEditingMessageIds.has(
|
||||
message.id,
|
||||
)}
|
||||
/>
|
||||
),
|
||||
)}{" "}
|
||||
{shouldRenderStreamInLastSection &&
|
||||
sectionIdx === parsedSections.length - 1 && (
|
||||
<StreamingOutput
|
||||
streamState={streamState}
|
||||
streamTools={streamTools}
|
||||
subagentTitles={subagentTitles}
|
||||
subagentStatusOverrides={subagentStatusOverrides}
|
||||
showInitialPlaceholder={isAwaitingFirstStreamChunk}
|
||||
retryState={retryState}
|
||||
urlTransform={urlTransform}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{hasStreamOutput && parsedSections.length === 0 && (
|
||||
{shouldRenderStreamAfterMessages && (
|
||||
<StreamingOutput
|
||||
streamState={streamState}
|
||||
streamTools={streamTools}
|
||||
subagentTitles={subagentTitles}
|
||||
subagentStatusOverrides={subagentStatusOverrides}
|
||||
showInitialPlaceholder={isAwaitingFirstStreamChunk}
|
||||
retryState={retryState}
|
||||
urlTransform={urlTransform}
|
||||
/>
|
||||
)}
|
||||
{hasStreamOutput && parsedMessages.length === 0 && (
|
||||
<StreamingOutput
|
||||
streamState={streamState}
|
||||
streamTools={streamTools}
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
MergedTool,
|
||||
ParsedMessageContent,
|
||||
ParsedMessageEntry,
|
||||
ParsedMessageSection,
|
||||
ParsedToolCall,
|
||||
ParsedToolResult,
|
||||
RenderBlock,
|
||||
@@ -383,23 +382,3 @@ export const buildSubagentTitles = (
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
export const buildParsedMessageSections = (
|
||||
parsedMessages: readonly ParsedMessageEntry[],
|
||||
): ParsedMessageSection[] => {
|
||||
const sections: ParsedMessageSection[] = [];
|
||||
|
||||
for (const entry of parsedMessages) {
|
||||
if (entry.message.role === "user") {
|
||||
sections.push({ userEntry: entry, entries: [entry] });
|
||||
continue;
|
||||
}
|
||||
if (sections.length === 0) {
|
||||
sections.push({ userEntry: null, entries: [entry] });
|
||||
continue;
|
||||
}
|
||||
sections[sections.length - 1].entries.push(entry);
|
||||
}
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
@@ -70,11 +70,6 @@ export type ParsedMessageEntry = {
|
||||
parsed: ParsedMessageContent;
|
||||
};
|
||||
|
||||
export type ParsedMessageSection = {
|
||||
userEntry: ParsedMessageEntry | null;
|
||||
entries: ParsedMessageEntry[];
|
||||
};
|
||||
|
||||
type StreamToolCall = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
|
||||
type UseMessageWindowOptions = {
|
||||
messages: readonly TypesGen.ChatMessage[];
|
||||
resetKey?: string;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
export const useMessageWindow = ({
|
||||
messages,
|
||||
resetKey,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
}: UseMessageWindowOptions) => {
|
||||
const [renderedMessageCount, setRenderedMessageCount] = useState(pageSize);
|
||||
const loadMoreSentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void resetKey;
|
||||
setRenderedMessageCount(pageSize);
|
||||
}, [resetKey, pageSize]);
|
||||
|
||||
const hasMoreMessages = renderedMessageCount < messages.length;
|
||||
const windowedMessages = useMemo(() => {
|
||||
if (renderedMessageCount >= messages.length) {
|
||||
return messages;
|
||||
}
|
||||
return messages.slice(messages.length - renderedMessageCount);
|
||||
}, [messages, renderedMessageCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = loadMoreSentinelRef.current;
|
||||
if (!node || !hasMoreMessages) {
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
setRenderedMessageCount((prev) => prev + pageSize);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMoreMessages, pageSize]);
|
||||
|
||||
return {
|
||||
hasMoreMessages,
|
||||
windowedMessages,
|
||||
loadMoreSentinelRef,
|
||||
};
|
||||
};
|
||||
@@ -25,12 +25,10 @@ import {
|
||||
import { ConversationTimeline } from "./AgentDetail/ConversationTimeline";
|
||||
import { getLatestContextUsage } from "./AgentDetail/chatHelpers";
|
||||
import {
|
||||
buildParsedMessageSections,
|
||||
buildSubagentTitles,
|
||||
parseMessagesWithMergedTools,
|
||||
} from "./AgentDetail/messageParsing";
|
||||
import { buildStreamTools } from "./AgentDetail/streamState";
|
||||
import { useMessageWindow } from "./AgentDetail/useMessageWindow";
|
||||
import type { ChatDetailError } from "./usageLimitMessage";
|
||||
import { useFileAttachments } from "./useFileAttachments";
|
||||
|
||||
@@ -42,7 +40,6 @@ const isChatMessage = (
|
||||
|
||||
interface AgentDetailTimelineProps {
|
||||
store: ChatStoreHandle;
|
||||
chatID: string;
|
||||
persistedErrorReason: ChatDetailError | undefined;
|
||||
onOpenAnalytics?: () => void;
|
||||
onEditUserMessage?: (
|
||||
@@ -57,7 +54,6 @@ interface AgentDetailTimelineProps {
|
||||
|
||||
export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
|
||||
store,
|
||||
chatID,
|
||||
persistedErrorReason,
|
||||
onOpenAnalytics,
|
||||
onEditUserMessage,
|
||||
@@ -87,23 +83,14 @@ export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
|
||||
() => buildStreamTools(streamState),
|
||||
[streamState],
|
||||
);
|
||||
const { hasMoreMessages, windowedMessages, loadMoreSentinelRef } =
|
||||
useMessageWindow({
|
||||
messages,
|
||||
resetKey: chatID,
|
||||
});
|
||||
const parsedMessages = useMemo(
|
||||
() => parseMessagesWithMergedTools(windowedMessages),
|
||||
[windowedMessages],
|
||||
() => parseMessagesWithMergedTools(messages),
|
||||
[messages],
|
||||
);
|
||||
const subagentTitles = useMemo(
|
||||
() => buildSubagentTitles(parsedMessages),
|
||||
[parsedMessages],
|
||||
);
|
||||
const parsedSections = useMemo(
|
||||
() => buildParsedMessageSections(parsedMessages),
|
||||
[parsedMessages],
|
||||
);
|
||||
const detailError: ChatDetailError | undefined =
|
||||
(persistedErrorReason?.kind === "usage-limit" || chatStatus === "error"
|
||||
? persistedErrorReason
|
||||
@@ -123,9 +110,7 @@ export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
|
||||
return (
|
||||
<ConversationTimeline
|
||||
isEmpty={messages.length === 0}
|
||||
hasMoreMessages={hasMoreMessages}
|
||||
loadMoreSentinelRef={loadMoreSentinelRef}
|
||||
parsedSections={parsedSections}
|
||||
parsedMessages={parsedMessages}
|
||||
hasStreamOutput={hasStreamOutput}
|
||||
streamState={streamState}
|
||||
streamTools={streamTools}
|
||||
|
||||
@@ -258,14 +258,15 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex min-h-0 flex-1 flex-col-reverse overflow-y-auto [scrollbar-gutter:stable] [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]"
|
||||
<ScrollAnchoredContainer
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
isFetchingMoreMessages={isFetchingMoreMessages}
|
||||
hasMoreMessages={hasMoreMessages}
|
||||
onFetchMoreMessages={onFetchMoreMessages}
|
||||
>
|
||||
<div className="px-4">
|
||||
<AgentDetailTimeline
|
||||
store={store}
|
||||
chatID={agentId}
|
||||
persistedErrorReason={
|
||||
chatErrorReasons[agentId] ??
|
||||
(chatStatus === "error" && chatRecord?.last_error
|
||||
@@ -279,14 +280,7 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
|
||||
urlTransform={urlTransform}
|
||||
/>
|
||||
</div>
|
||||
{hasMoreMessages && (
|
||||
<MessagesPaginationSentinel
|
||||
containerRef={scrollContainerRef}
|
||||
isFetching={isFetchingMoreMessages}
|
||||
onLoadMore={onFetchMoreMessages}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollAnchoredContainer>
|
||||
<div className="shrink-0 overflow-y-auto px-4 [scrollbar-gutter:stable] [scrollbar-width:thin]">
|
||||
<AgentDetailInput
|
||||
store={store}
|
||||
@@ -502,37 +496,83 @@ export const AgentDetailNotFoundView: FC<AgentDetailNotFoundViewProps> = ({
|
||||
};
|
||||
|
||||
/**
|
||||
* Invisible sentinel that triggers loading older messages when it
|
||||
* scrolls into view. Placed at the visual top of the flex-col-reverse
|
||||
* container (which is the DOM bottom).
|
||||
* Scroll container that uses flex-col-reverse for bottom-anchored chat
|
||||
* layout. Handles loading older message pages via an IntersectionObserver
|
||||
* sentinel and manually restores scroll position after new content
|
||||
* renders — CSS scroll anchoring is unreliable in flex-col-reverse
|
||||
* containers.
|
||||
*/
|
||||
const MessagesPaginationSentinel: FC<{
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
isFetching: boolean;
|
||||
onLoadMore: () => void;
|
||||
}> = ({ containerRef, isFetching, onLoadMore }) => {
|
||||
const ScrollAnchoredContainer: FC<{
|
||||
scrollContainerRef: RefObject<HTMLDivElement | null>;
|
||||
isFetchingMoreMessages: boolean;
|
||||
hasMoreMessages: boolean;
|
||||
onFetchMoreMessages: () => void;
|
||||
children: React.ReactNode;
|
||||
}> = ({
|
||||
scrollContainerRef,
|
||||
isFetchingMoreMessages,
|
||||
hasMoreMessages,
|
||||
onFetchMoreMessages,
|
||||
children,
|
||||
}) => {
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const isFetchingRef = useRef(isFetchingMoreMessages);
|
||||
isFetchingRef.current = isFetchingMoreMessages;
|
||||
const onFetchRef = useRef(onFetchMoreMessages);
|
||||
onFetchRef.current = onFetchMoreMessages;
|
||||
|
||||
// Sentinel observer — triggers loading older messages.
|
||||
// All changing values are read from refs so the observer
|
||||
// is created once and never torn down / recreated, which
|
||||
// would cause spurious intersection callbacks.
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
const container = containerRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
if (!sentinel || !container) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting && !isFetching) {
|
||||
onLoadMore();
|
||||
if (entry.isIntersecting && !isFetchingRef.current) {
|
||||
onFetchRef.current();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: container,
|
||||
rootMargin: "200px 0px 0px 0px",
|
||||
rootMargin: "600px 0px 0px 0px",
|
||||
threshold: 0.01,
|
||||
},
|
||||
);
|
||||
observerRef.current = observer;
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [containerRef, isFetching, onLoadMore]);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
observerRef.current = null;
|
||||
};
|
||||
}, [scrollContainerRef]);
|
||||
|
||||
return <div ref={sentinelRef} className="h-px shrink-0" />;
|
||||
// When a fetch completes, re-observe the sentinel to force
|
||||
// the IntersectionObserver to re-evaluate. The observer only
|
||||
// fires on state *changes* (entering/leaving), so if the
|
||||
// sentinel stayed visible throughout the fetch it won't fire
|
||||
// again on its own.
|
||||
useEffect(() => {
|
||||
if (isFetchingMoreMessages) return;
|
||||
const sentinel = sentinelRef.current;
|
||||
const observer = observerRef.current;
|
||||
if (!sentinel || !observer) return;
|
||||
observer.unobserve(sentinel);
|
||||
observer.observe(sentinel);
|
||||
}, [isFetchingMoreMessages]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex min-h-0 flex-1 flex-col-reverse overflow-y-auto [scrollbar-gutter:stable] [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]"
|
||||
style={{ overflowAnchor: "none" }}
|
||||
>
|
||||
{children}
|
||||
{hasMoreMessages && <div ref={sentinelRef} className="h-px shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user