import React from 'react'; import { Dialog, DialogContent, DialogDescription, DialogHeader, 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'; import { Icon } from "@/components/icon/Icon"; import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; import { getFullText, getMessagePreview } from './lib/messagePreview'; import { useDeviceInfo } from '@/lib/device'; import { cn } from '@/lib/utils'; interface TimelineDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onScrollToMessage?: (messageId: string) => void | Promise; onScrollByTurnOffset?: (offset: number) => void; onResumeToLatest?: () => void; canLoadEarlier?: boolean; isLoadingEarlier?: boolean; onLoadEarlier?: () => void; } export const TimelineDialog: React.FC = ({ open, onOpenChange, onScrollToMessage, onScrollByTurnOffset, onResumeToLatest, canLoadEarlier = false, isLoadingEarlier = false, onLoadEarlier, }) => { const { t } = useI18n(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const messages = useSessionMessageRecords(currentSessionId ?? ''); const revertToMessage = useSessionUIStore((state) => state.revertToMessage); const forkFromMessage = useSessionUIStore((state) => state.forkFromMessage); const { isMobile, isTablet } = useDeviceInfo(); const alwaysShowActions = isMobile || isTablet; const [forkingMessageId, setForkingMessageId] = React.useState(null); 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 formatDateGroup = React.useCallback((timestamp: number): string => { return new Date(timestamp).toLocaleDateString(getCurrentIntlLocale(), { year: 'numeric', month: 'short', day: 'numeric', }); }, []); 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) => ({ message })); }, [messages]); // Filter by search query using all text parts in each user message. const filteredMessages = React.useMemo(() => { const trimmedQuery = searchQuery.trim(); if (!trimmedQuery) return userMessages; const query = trimmedQuery.toLowerCase(); return userMessages.filter(({ message }) => { const fullText = getFullText(message.parts).toLowerCase(); return fullText.includes(query); }); }, [userMessages, searchQuery]); React.useEffect(() => { 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) { return; } onOpenChange(false); }, [onOpenChange, onScrollToMessage]); const handleSearchKeyDown = React.useCallback((event: React.KeyboardEvent) => { const total = filteredMessages.length; if (total === 0) { return; } if (event.key === 'ArrowDown') { event.preventDefault(); setSelectedIndex((current) => (current + 1) % total); return; } if (event.key === 'ArrowUp') { event.preventDefault(); setSelectedIndex((current) => (current - 1 + total) % total); return; } if (event.key === 'Enter') { event.preventDefault(); const safeIndex = ((selectedIndex % total) + total) % total; const selected = filteredMessages[safeIndex]; if (selected) { void navigateToMessage(selected.message.info.id); } } }, [filteredMessages, navigateToMessage, selectedIndex]); // Handle fork with loading state and session refresh const handleFork = async (messageId: string) => { if (!currentSessionId) return; setForkingMessageId(messageId); try { await forkFromMessage(currentSessionId, messageId); onOpenChange(false); } finally { setForkingMessageId(null); } }; if (!currentSessionId) return null; return ( {t('chat.timeline.title')} {t('chat.timeline.description')}
setSearchQuery(e.target.value)} onKeyDown={handleSearchKeyDown} className="pl-9 w-full" />
{canLoadEarlier && onLoadEarlier && (
)}
{filteredMessages.length === 0 ? (
{searchQuery ? t('chat.timeline.empty.search') : t('chat.timeline.empty.session')}
) : ( filteredMessages.map(({ message }, index) => { const preview = getMessagePreview(message.parts); const timestamp = message.info.time.created; 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() ? getSearchSnippet(getFullText(message.parts), searchQuery) : null; return ( {showDateGroup && (
{dateGroup}
)}
{ 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)} > {messageTime}

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

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

{t('chat.timeline.actions.title')}

/
{t('chat.timeline.help.clickMessage')}
{t('chat.timeline.help.undoToPoint')}
{t('chat.timeline.help.createSessionFromHere')}
); }; function getSearchSnippet(text: string, query: string, contextChars: number = 30): string | null { const lowerText = text.toLowerCase(); const lowerQuery = query.toLowerCase(); const matchIndex = lowerText.indexOf(lowerQuery); if (matchIndex === -1) return null; const start = Math.max(0, matchIndex - contextChars); const end = Math.min(text.length, matchIndex + query.length + contextChars); return `${start > 0 ? '…' : ''}${text.slice(start, end).replace(/\n/g, ' ')}${end < text.length ? '…' : ''}`; }