2026-01-02 18:33:35 -05:00
|
|
|
import React from 'react';
|
|
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from '@/components/ui/dialog';
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
2026-03-31 18:47:00 +03:00
|
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
|
|
|
import { useSessionMessageRecords } from '@/sync/sync-context';
|
2026-01-03 14:45:46 +02:00
|
|
|
import { RiLoader4Line, RiSearchLine, RiTimeLine, RiGitBranchLine, RiArrowGoBackLine } from '@remixicon/react';
|
|
|
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
2026-01-03 13:39:59 +02:00
|
|
|
import type { Part } from '@opencode-ai/sdk/v2';
|
2026-04-26 14:03:39 +03:00
|
|
|
import { useI18n } from '@/lib/i18n';
|
2026-01-02 18:33:35 -05:00
|
|
|
|
|
|
|
|
interface TimelineDialogProps {
|
|
|
|
|
open: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
2026-03-12 23:45:45 +02:00
|
|
|
onScrollToMessage?: (messageId: string) => void | Promise<boolean>;
|
|
|
|
|
onScrollByTurnOffset?: (offset: number) => void;
|
|
|
|
|
onResumeToLatest?: () => void;
|
2026-01-02 18:33:35 -05:00
|
|
|
}
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
|
|
|
|
open,
|
|
|
|
|
onOpenChange,
|
|
|
|
|
onScrollToMessage,
|
|
|
|
|
onScrollByTurnOffset,
|
|
|
|
|
onResumeToLatest,
|
|
|
|
|
}) => {
|
2026-04-26 14:03:39 +03:00
|
|
|
const { t } = useI18n();
|
2026-03-31 18:47:00 +03:00
|
|
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
|
|
|
|
const messages = useSessionMessageRecords(currentSessionId ?? '');
|
|
|
|
|
const revertToMessage = useSessionUIStore((state) => state.revertToMessage);
|
|
|
|
|
const forkFromMessage = useSessionUIStore((state) => state.forkFromMessage);
|
2026-01-02 18:33:35 -05:00
|
|
|
|
|
|
|
|
const [forkingMessageId, setForkingMessageId] = React.useState<string | null>(null);
|
|
|
|
|
const [searchQuery, setSearchQuery] = React.useState('');
|
|
|
|
|
|
2026-04-26 14:03:39 +03:00
|
|
|
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]);
|
|
|
|
|
|
2026-01-02 18:33:35 -05:00
|
|
|
// Filter user messages (reversed for newest first)
|
|
|
|
|
const userMessages = React.useMemo(() => {
|
|
|
|
|
const filtered = messages.filter(m => m.info.role === 'user');
|
|
|
|
|
return filtered.reverse();
|
|
|
|
|
}, [messages]);
|
|
|
|
|
|
|
|
|
|
// Filter by search query
|
|
|
|
|
const filteredMessages = React.useMemo(() => {
|
|
|
|
|
if (!searchQuery.trim()) return userMessages;
|
|
|
|
|
|
|
|
|
|
const query = searchQuery.toLowerCase();
|
|
|
|
|
return userMessages.filter((message) => {
|
|
|
|
|
const preview = getMessagePreview(message.parts).toLowerCase();
|
|
|
|
|
return preview.includes(query);
|
|
|
|
|
});
|
|
|
|
|
}, [userMessages, searchQuery]);
|
|
|
|
|
|
|
|
|
|
// 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 (
|
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle className="flex items-center gap-2">
|
|
|
|
|
<RiTimeLine className="h-5 w-5" />
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('chat.timeline.title')}
|
2026-01-02 18:33:35 -05:00
|
|
|
</DialogTitle>
|
|
|
|
|
<DialogDescription>
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('chat.timeline.description')}
|
2026-01-02 18:33:35 -05:00
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
2026-01-03 14:45:46 +02:00
|
|
|
<div className="relative mt-2">
|
|
|
|
|
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
2026-01-02 18:33:35 -05:00
|
|
|
<Input
|
2026-04-26 14:03:39 +03:00
|
|
|
placeholder={t('chat.timeline.searchPlaceholder')}
|
2026-01-02 18:33:35 -05:00
|
|
|
value={searchQuery}
|
|
|
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
2026-01-03 14:45:46 +02:00
|
|
|
className="pl-9 w-full"
|
2026-01-02 18:33:35 -05:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-01-03 14:45:46 +02:00
|
|
|
<div className="flex-1 overflow-y-auto">
|
2026-01-02 18:33:35 -05:00
|
|
|
{filteredMessages.length === 0 ? (
|
|
|
|
|
<div className="text-center text-muted-foreground py-8">
|
2026-04-26 14:03:39 +03:00
|
|
|
{searchQuery ? t('chat.timeline.empty.search') : t('chat.timeline.empty.session')}
|
2026-01-02 18:33:35 -05:00
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-01-03 14:53:19 +02:00
|
|
|
filteredMessages.map((message) => {
|
2026-01-02 18:33:35 -05:00
|
|
|
const preview = getMessagePreview(message.parts);
|
|
|
|
|
const timestamp = message.info.time.created;
|
|
|
|
|
const relativeTime = formatRelativeTime(timestamp);
|
2026-01-03 14:45:46 +02:00
|
|
|
const messageNumber = userMessages.length - userMessages.indexOf(message);
|
2026-01-02 18:33:35 -05:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={message.info.id}
|
2026-02-01 18:29:34 +02:00
|
|
|
className="group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer"
|
2026-03-12 23:45:45 +02:00
|
|
|
onClick={async () => {
|
|
|
|
|
const didNavigate = await onScrollToMessage?.(message.info.id);
|
|
|
|
|
if (didNavigate === false) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-03 14:45:46 +02:00
|
|
|
onOpenChange(false);
|
|
|
|
|
}}
|
2026-01-02 18:33:35 -05:00
|
|
|
>
|
2026-01-03 14:45:46 +02:00
|
|
|
<span className="typography-meta text-muted-foreground w-5 text-right flex-shrink-0">
|
|
|
|
|
{messageNumber}.
|
|
|
|
|
</span>
|
|
|
|
|
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
|
2026-04-26 14:03:39 +03:00
|
|
|
{preview || t('chat.timeline.noTextContent')}
|
2026-01-03 14:45:46 +02:00
|
|
|
{preview && preview.length >= 80 && '…'}
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<div className="flex-shrink-0 h-5 flex items-center mr-2">
|
|
|
|
|
<span className="typography-meta text-muted-foreground whitespace-nowrap group-hover:hidden">
|
|
|
|
|
{relativeTime}
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
<div className="hidden group-hover:flex gap-1">
|
|
|
|
|
<Tooltip delayDuration={1000}>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="h-5 w-5 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
|
|
|
|
|
onClick={async (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
await revertToMessage(currentSessionId, message.info.id);
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<RiArrowGoBackLine className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</TooltipTrigger>
|
2026-04-26 14:03:39 +03:00
|
|
|
<TooltipContent sideOffset={6}>{t('chat.timeline.actions.revertFromHere')}</TooltipContent>
|
2026-01-03 14:45:46 +02:00
|
|
|
</Tooltip>
|
|
|
|
|
|
|
|
|
|
<Tooltip delayDuration={1000}>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="h-5 w-5 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
handleFork(message.info.id);
|
|
|
|
|
}}
|
|
|
|
|
disabled={forkingMessageId === message.info.id}
|
|
|
|
|
>
|
|
|
|
|
{forkingMessageId === message.info.id ? (
|
|
|
|
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<RiGitBranchLine className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</TooltipTrigger>
|
2026-04-26 14:03:39 +03:00
|
|
|
<TooltipContent sideOffset={6}>{t('chat.timeline.actions.forkFromHere')}</TooltipContent>
|
2026-01-03 14:45:46 +02:00
|
|
|
</Tooltip>
|
2026-01-02 18:33:35 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
|
2026-04-26 14:03:39 +03:00
|
|
|
<p className="typography-meta text-muted-foreground font-medium mb-2">{t('chat.timeline.actions.title')}</p>
|
2026-03-12 23:45:45 +02:00
|
|
|
<div className="mb-2 flex items-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
void onScrollByTurnOffset?.(-1);
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('chat.timeline.actions.previousTurn')}
|
2026-03-12 23:45:45 +02:00
|
|
|
</button>
|
|
|
|
|
<span className="text-muted-foreground/50">/</span>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
onResumeToLatest?.();
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('chat.timeline.actions.latest')}
|
2026-03-12 23:45:45 +02:00
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-01-03 14:45:46 +02:00
|
|
|
<div className="flex flex-col gap-1.5 typography-meta text-muted-foreground">
|
|
|
|
|
<div className="flex items-center gap-2">
|
2026-04-26 14:03:39 +03:00
|
|
|
<span>{t('chat.timeline.help.clickMessage')}</span>
|
2026-01-03 14:45:46 +02:00
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<RiArrowGoBackLine className="h-4 w-4 flex-shrink-0" />
|
2026-04-26 14:03:39 +03:00
|
|
|
<span>{t('chat.timeline.help.undoToPoint')}</span>
|
2026-01-03 14:45:46 +02:00
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<RiGitBranchLine className="h-4 w-4 flex-shrink-0" />
|
2026-04-26 14:03:39 +03:00
|
|
|
<span>{t('chat.timeline.help.createSessionFromHere')}</span>
|
2026-01-02 18:33:35 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function getMessagePreview(parts: Part[]): string {
|
|
|
|
|
const textPart = parts.find(p => p.type === 'text');
|
|
|
|
|
if (!textPart || typeof textPart.text !== 'string') return '';
|
|
|
|
|
return textPart.text.replace(/\n/g, ' ').slice(0, 80);
|
|
|
|
|
}
|