feat(ui): enable TimelineDialog with full-text search across all message roles in one session (#1104)
* feat: register open_timeline_dialog shortcut (mod+t) * feat: enhance TimelineDialog with full-text search across all roles * feat: add open_timeline_dialog shortcut labels to all locales * feat: wire TimelineDialog into ChatContainer * fix: add setTimelineDialogOpen to hook dependency array * fix: tighten timeline dialog interactions --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
495b9d56d2
commit
63b4a5b996
@@ -41,6 +41,8 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
|
||||
const [forkingMessageId, setForkingMessageId] = React.useState<string | null>(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();
|
||||
@@ -57,23 +59,79 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
}, [t]);
|
||||
|
||||
// Filter user messages (reversed for newest first)
|
||||
// Timeline actions are only valid for user messages.
|
||||
const userMessages = React.useMemo(() => {
|
||||
const filtered = messages.filter(m => m.info.role === 'user');
|
||||
return filtered.reverse();
|
||||
return messages
|
||||
.filter((message) => message.info.role === 'user')
|
||||
.map((message, index) => ({
|
||||
message,
|
||||
messageNumber: index + 1,
|
||||
}))
|
||||
.reverse();
|
||||
}, [messages]);
|
||||
|
||||
// Filter by search query
|
||||
// Filter by search query using all text parts in each user message.
|
||||
const filteredMessages = React.useMemo(() => {
|
||||
if (!searchQuery.trim()) return userMessages;
|
||||
const trimmedQuery = searchQuery.trim();
|
||||
if (!trimmedQuery) return userMessages;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return userMessages.filter((message) => {
|
||||
const preview = getMessagePreview(message.parts).toLowerCase();
|
||||
return preview.includes(query);
|
||||
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<HTMLInputElement>) => {
|
||||
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;
|
||||
@@ -104,9 +162,11 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
<div className="relative mt-2">
|
||||
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t('chat.timeline.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="pl-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -117,34 +177,49 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
{searchQuery ? t('chat.timeline.empty.search') : t('chat.timeline.empty.session')}
|
||||
</div>
|
||||
) : (
|
||||
filteredMessages.map((message) => {
|
||||
filteredMessages.map(({ message, messageNumber }, index) => {
|
||||
const preview = getMessagePreview(message.parts);
|
||||
const timestamp = message.info.time.created;
|
||||
const relativeTime = formatRelativeTime(timestamp);
|
||||
const messageNumber = userMessages.length - userMessages.indexOf(message);
|
||||
const isSelected = index === selectedIndex;
|
||||
|
||||
const snippet = searchQuery.trim()
|
||||
? getSearchSnippet(getFullText(message.parts), searchQuery)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.info.id}
|
||||
className="group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer"
|
||||
onClick={async () => {
|
||||
const didNavigate = await onScrollToMessage?.(message.info.id);
|
||||
if (didNavigate === false) {
|
||||
return;
|
||||
}
|
||||
onOpenChange(false);
|
||||
ref={(element) => {
|
||||
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)}
|
||||
>
|
||||
<span className="typography-meta text-muted-foreground w-5 text-right flex-shrink-0">
|
||||
<span className={cn(
|
||||
"typography-meta w-5 text-right flex-shrink-0",
|
||||
isSelected ? "text-interactive-selection-foreground/70" : "text-muted-foreground"
|
||||
)}>
|
||||
{messageNumber}.
|
||||
</span>
|
||||
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
|
||||
{preview || t('chat.timeline.noTextContent')}
|
||||
{preview && preview.length >= 80 && '…'}
|
||||
<p className={cn(
|
||||
"flex-1 min-w-0 typography-small truncate ml-0.5",
|
||||
isSelected ? "text-interactive-selection-foreground" : "text-foreground"
|
||||
)}>
|
||||
{snippet ?? (preview || t('chat.timeline.noTextContent'))}
|
||||
{!snippet && preview && preview.length >= 80 && '…'}
|
||||
</p>
|
||||
|
||||
<div className="flex-shrink-0 h-5 flex items-center mr-2">
|
||||
<span className={cn("typography-meta text-muted-foreground whitespace-nowrap", alwaysShowActions ? "hidden" : "group-hover:hidden")}>
|
||||
<span className={cn(
|
||||
"typography-meta whitespace-nowrap",
|
||||
isSelected ? "text-interactive-selection-foreground/70" : "text-muted-foreground",
|
||||
alwaysShowActions ? "hidden" : "group-hover:hidden"
|
||||
)}>
|
||||
{relativeTime}
|
||||
</span>
|
||||
|
||||
@@ -238,8 +313,26 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
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 ? '…' : ''}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user