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
@@ -14,6 +14,7 @@ import ScrollToBottomButton from './components/ScrollToBottomButton';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { useChatScrollManager, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import { useChatTimelineController } from './hooks/useChatTimelineController';
|
||||
import { TimelineDialog } from './TimelineDialog';
|
||||
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -342,6 +343,8 @@ export const ChatContainer: React.FC = () => {
|
||||
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
|
||||
const stickyUserHeader = useUIStore((state) => state.stickyUserHeader);
|
||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||
const isTimelineDialogOpen = useUIStore((s) => s.isTimelineDialogOpen);
|
||||
const setTimelineDialogOpen = useUIStore((s) => s.setTimelineDialogOpen);
|
||||
|
||||
// Streaming state
|
||||
const streamingMessageId = useStreamingStore(
|
||||
@@ -958,19 +961,27 @@ export const ChatContainer: React.FC = () => {
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10',
|
||||
isDesktopExpandedInput
|
||||
? 'flex-1 min-h-0 bg-background'
|
||||
: 'bg-background'
|
||||
)}
|
||||
>
|
||||
{!isDesktopExpandedInput && sessionMessages.length > 0 && (
|
||||
<ScrollToBottomButton
|
||||
isDesktopExpandedInput
|
||||
? 'flex-1 min-h-0 bg-background'
|
||||
: 'bg-background'
|
||||
)}
|
||||
>
|
||||
{!isDesktopExpandedInput && sessionMessages.length > 0 && (
|
||||
<ScrollToBottomButton
|
||||
visible={timelineController.showScrollToBottom}
|
||||
onClick={navigation.resumeToLatest}
|
||||
/>
|
||||
)}
|
||||
<ChatInput scrollToBottom={resumeToLatestInstant} />
|
||||
</div>
|
||||
|
||||
<TimelineDialog
|
||||
open={isTimelineDialogOpen}
|
||||
onOpenChange={setTimelineDialogOpen}
|
||||
onScrollToMessage={timelineController.scrollToMessage}
|
||||
onScrollByTurnOffset={navigation.scrollByTurnOffset}
|
||||
onResumeToLatest={resumeToLatestInstant}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -799,6 +799,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled);
|
||||
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
|
||||
const setExpandedInput = useUIStore((state) => state.setExpandedInput);
|
||||
const setTimelineDialogOpen = useUIStore((state) => state.setTimelineDialogOpen);
|
||||
const { git: runtimeGit } = useRuntimeAPIs();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const chatSearchDirectory = useChatSearchDirectory();
|
||||
@@ -1494,6 +1495,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
scrollToBottom?.({ instant: true, force: true });
|
||||
return;
|
||||
}
|
||||
else if (commandName === 'timeline' && currentSessionId) {
|
||||
setTimelineDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
else if (commandName === 'compact' && currentSessionId) {
|
||||
try {
|
||||
await sessionActions.waitForConnectionOrThrow();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine, RiArrowGoBackLine, RiArrowGoForwardLine, RiSearchEyeLine } from '@remixicon/react';
|
||||
import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine, RiArrowGoBackLine, RiArrowGoForwardLine, RiSearchEyeLine, RiTimeLine } from '@remixicon/react';
|
||||
import { cn, fuzzyMatch } from '@/lib/utils';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionMessages } from '@/sync/sync-context';
|
||||
@@ -120,6 +120,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
? [
|
||||
{ id: 'openchamber:undo', name: 'undo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.undoDescription'), isBuiltIn: true },
|
||||
{ id: 'openchamber:redo', name: 'redo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.redoDescription'), isBuiltIn: true },
|
||||
{ id: 'openchamber:timeline', name: 'timeline', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.timelineDescription'), isBuiltIn: true },
|
||||
]
|
||||
: []
|
||||
),
|
||||
@@ -164,6 +165,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
? [
|
||||
{ id: 'openchamber:undo', name: 'undo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.undoDescription'), isBuiltIn: true },
|
||||
{ id: 'openchamber:redo', name: 'redo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.redoDescription'), isBuiltIn: true },
|
||||
{ id: 'openchamber:timeline', name: 'timeline', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.timelineDescription'), isBuiltIn: true },
|
||||
]
|
||||
: []
|
||||
),
|
||||
@@ -246,6 +248,8 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
return <RiArrowGoBackLine className="h-3.5 w-3.5 text-orange-500" />;
|
||||
case 'redo':
|
||||
return <RiArrowGoForwardLine className="h-3.5 w-3.5 text-orange-500" />;
|
||||
case 'timeline':
|
||||
return <RiTimeLine className="h-3.5 w-3.5" />;
|
||||
case 'compact':
|
||||
return <RiScissorsLine className="h-3.5 w-3.5 text-purple-500" />;
|
||||
case 'review':
|
||||
|
||||
@@ -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