From 18d1c9111a8341ca109a5b65aed9d455aa1b930c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 6 Jul 2026 17:24:01 +0300 Subject: [PATCH] feat: add load earlier support to chat timeline Adds a load older button when earlier history is available Preserves scroll position while older messages are loaded Shows date-grouped messages with clearer per-message timestamps --- .../ui/src/components/chat/ChatContainer.tsx | 3 + .../ui/src/components/chat/TimelineDialog.tsx | 286 ++++++++++++------ 2 files changed, 198 insertions(+), 91 deletions(-) diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 47e5d92b..883b832e 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -993,6 +993,9 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr onScrollToMessage={timelineController.scrollToMessage} onScrollByTurnOffset={navigation.scrollByTurnOffset} onResumeToLatest={resumeToLatestInstant} + canLoadEarlier={timelineController.historySignals.canLoadEarlier} + isLoadingEarlier={timelineController.isLoadingOlder} + onLoadEarlier={handleLoadOlderClick} /> ); diff --git a/packages/ui/src/components/chat/TimelineDialog.tsx b/packages/ui/src/components/chat/TimelineDialog.tsx index 4800c27b..bc16b005 100644 --- a/packages/ui/src/components/chat/TimelineDialog.tsx +++ b/packages/ui/src/components/chat/TimelineDialog.tsx @@ -7,6 +7,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionMessageRecords } from '@/sync/sync-context'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -22,6 +23,9 @@ interface TimelineDialogProps { onScrollToMessage?: (messageId: string) => void | Promise; onScrollByTurnOffset?: (offset: number) => void; onResumeToLatest?: () => void; + canLoadEarlier?: boolean; + isLoadingEarlier?: boolean; + onLoadEarlier?: () => void; } export const TimelineDialog: React.FC = ({ @@ -30,6 +34,9 @@ export const TimelineDialog: React.FC = ({ onScrollToMessage, onScrollByTurnOffset, onResumeToLatest, + canLoadEarlier = false, + isLoadingEarlier = false, + onLoadEarlier, }) => { const { t } = useI18n(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); @@ -43,31 +50,31 @@ export const TimelineDialog: React.FC = ({ const [searchQuery, setSearchQuery] = React.useState(''); const [selectedIndex, setSelectedIndex] = React.useState(0); const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); + const listRef = React.useRef(null); + const pendingLoadAnchorRef = React.useRef<{ messageId: string; top: number } | null>(null); + const preservingLoadPositionRef = React.useRef(false); + const wasOpenRef = React.useRef(open); - const formatRelativeTime = React.useCallback((timestamp: number): string => { - const now = Date.now(); - const diffMs = now - timestamp; - const diffSecs = Math.floor(diffMs / 1000); - const diffMins = Math.floor(diffSecs / 60); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); + const formatDateGroup = React.useCallback((timestamp: number): string => { + return new Date(timestamp).toLocaleDateString(getCurrentIntlLocale(), { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }, []); - if (diffSecs < 60) return t('chat.timeline.relative.justNow'); - if (diffMins < 60) return t('chat.timeline.relative.minutesAgo', { count: diffMins }); - if (diffHours < 24) return t('chat.timeline.relative.hoursAgo', { count: diffHours }); - if (diffDays < 7) return t('chat.timeline.relative.daysAgo', { count: diffDays }); - return new Date(timestamp).toLocaleDateString(getCurrentIntlLocale()); - }, [t]); + const formatMessageTime = React.useCallback((timestamp: number): string => { + return new Date(timestamp).toLocaleTimeString(getCurrentIntlLocale(), { + hour: 'numeric', + minute: '2-digit', + }); + }, []); // Timeline actions are only valid for user messages. const userMessages = React.useMemo(() => { return messages .filter((message) => message.info.role === 'user') - .map((message, index) => ({ - message, - messageNumber: index + 1, - })) - .reverse(); + .map((message) => ({ message })); }, [messages]); // Filter by search query using all text parts in each user message. @@ -83,19 +90,89 @@ export const TimelineDialog: React.FC = ({ }, [userMessages, searchQuery]); React.useEffect(() => { - setSelectedIndex(0); - }, [filteredMessages]); + if (preservingLoadPositionRef.current) { + return; + } + + setSelectedIndex(searchQuery.trim() ? 0 : Math.max(0, filteredMessages.length - 1)); + }, [filteredMessages, searchQuery]); React.useEffect(() => { itemRefs.current = itemRefs.current.slice(0, filteredMessages.length); }, [filteredMessages.length]); React.useEffect(() => { + if (preservingLoadPositionRef.current) { + return; + } + itemRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest', }); }, [selectedIndex]); + React.useEffect(() => { + if (!preservingLoadPositionRef.current || pendingLoadAnchorRef.current || isLoadingEarlier) { + return; + } + + preservingLoadPositionRef.current = false; + }, [filteredMessages.length, isLoadingEarlier]); + + React.useLayoutEffect(() => { + const wasOpen = wasOpenRef.current; + wasOpenRef.current = open; + + if (!open || wasOpen || preservingLoadPositionRef.current || searchQuery.trim()) { + return; + } + + const container = listRef.current; + if (!container) { + return; + } + + container.scrollTop = container.scrollHeight; + }, [open, searchQuery]); + + React.useLayoutEffect(() => { + const anchor = pendingLoadAnchorRef.current; + const container = listRef.current; + if (!anchor || !container || isLoadingEarlier) { + return; + } + + pendingLoadAnchorRef.current = null; + const anchoredRow = itemRefs.current.find((row) => row?.dataset.timelineMessageId === anchor.messageId); + if (!anchoredRow) { + return; + } + + const nextTop = anchoredRow.getBoundingClientRect().top - container.getBoundingClientRect().top; + container.scrollTop += nextTop - anchor.top; + }, [filteredMessages.length, isLoadingEarlier]); + + const handleLoadEarlier = React.useCallback(() => { + const container = listRef.current; + if (container) { + const containerTop = container.getBoundingClientRect().top; + const firstVisibleRow = itemRefs.current.find((row) => { + if (!row) return false; + return row.getBoundingClientRect().bottom >= containerTop; + }); + + if (firstVisibleRow?.dataset.timelineMessageId) { + pendingLoadAnchorRef.current = { + messageId: firstVisibleRow.dataset.timelineMessageId, + top: firstVisibleRow.getBoundingClientRect().top - containerTop, + }; + } + } + + preservingLoadPositionRef.current = true; + onLoadEarlier?.(); + }, [onLoadEarlier]); + const navigateToMessage = React.useCallback(async (messageId: string) => { const didNavigate = await onScrollToMessage?.(messageId); if (didNavigate === false) { @@ -171,16 +248,40 @@ export const TimelineDialog: React.FC = ({ /> -
+ {canLoadEarlier && onLoadEarlier && ( +
+ +
+ )} + +
{filteredMessages.length === 0 ? (
{searchQuery ? t('chat.timeline.empty.search') : t('chat.timeline.empty.session')}
) : ( - filteredMessages.map(({ message, messageNumber }, index) => { + filteredMessages.map(({ message }, index) => { const preview = getMessagePreview(message.parts); const timestamp = message.info.time.created; - const relativeTime = formatRelativeTime(timestamp); + const dateGroup = formatDateGroup(timestamp); + const previous = filteredMessages[index - 1]; + const previousDateGroup = previous + ? formatDateGroup(previous.message.info.time.created) + : null; + const showDateGroup = dateGroup !== previousDateGroup; + const messageTime = formatMessageTime(timestamp); const isSelected = index === selectedIndex; const snippet = searchQuery.trim() @@ -188,82 +289,85 @@ export const TimelineDialog: React.FC = ({ : null; return ( -
{ - itemRefs.current[index] = element; - }} - className={cn( - "group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer", - isSelected && "bg-interactive-selection text-interactive-selection-foreground" + + {showDateGroup && ( +
+
+ + {dateGroup} + +
+
)} - onClick={() => void navigateToMessage(message.info.id)} - onMouseEnter={() => setSelectedIndex(index)} - > - - {messageNumber}. - -

- {snippet ?? (preview || t('chat.timeline.noTextContent'))} - {!snippet && preview && preview.length >= 80 && '…'} -

- -
+
{ + itemRefs.current[index] = element; + }} + data-timeline-message-id={message.info.id} + className={cn( + "group flex items-center gap-3 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer", + isSelected && "bg-interactive-selection text-interactive-selection-foreground" + )} + onClick={() => void navigateToMessage(message.info.id)} + onMouseEnter={() => setSelectedIndex(index)} + > - {relativeTime} + {messageTime} +

+ {snippet ?? (preview || t('chat.timeline.noTextContent'))} + {!snippet && preview && preview.length >= 80 && '…'} +

-
- - - - - {t('chat.timeline.actions.revertFromHere')} - +
+
+ + + + + {t('chat.timeline.actions.revertFromHere')} + - - - - - {t('chat.timeline.actions.forkFromHere')} - + + + + + {t('chat.timeline.actions.forkFromHere')} + +
-
+ ); }) )}