import React from 'react'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; 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 type { Part } from '@opencode-ai/sdk/v2'; import { useI18n } from '@/lib/i18n'; 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; } export const TimelineDialog: React.FC = ({ open, onOpenChange, onScrollToMessage, onScrollByTurnOffset, onResumeToLatest, }) => { 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 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); 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(); }, [t]); // 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(); }, [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(() => { setSelectedIndex(0); }, [filteredMessages]); React.useEffect(() => { itemRefs.current = itemRefs.current.slice(0, filteredMessages.length); }, [filteredMessages.length]); React.useEffect(() => { itemRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest', }); }, [selectedIndex]); 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" />
{filteredMessages.length === 0 ? (
{searchQuery ? t('chat.timeline.empty.search') : t('chat.timeline.empty.session')}
) : ( filteredMessages.map(({ message, messageNumber }, index) => { const preview = getMessagePreview(message.parts); const timestamp = message.info.time.created; const relativeTime = formatRelativeTime(timestamp); const isSelected = index === selectedIndex; const snippet = searchQuery.trim() ? getSearchSnippet(getFullText(message.parts), searchQuery) : 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" )} onClick={() => void navigateToMessage(message.info.id)} onMouseEnter={() => setSelectedIndex(index)} > {messageNumber}.

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

{relativeTime}
{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 getFullText(parts: Part[]): string { return parts .filter((p): p is Part & { type: 'text'; text: string } => p.type === 'text' && typeof p.text === 'string') .map((p) => p.text) .join('\n'); } function getMessagePreview(parts: Part[]): string { const full = getFullText(parts); const singleLine = full.replace(/\n/g, ' '); return singleLine.length > 80 ? singleLine.slice(0, 80) : singleLine; } 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 ? '…' : ''}`; }