From 570e5c326b8f0f292da724b2de51a12d53ce1a17 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Tue, 12 May 2026 20:10:55 +0200 Subject: [PATCH] Add chat history search feature to navigation panel (#8448) Signed-off-by: Vincenzo Palazzo Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/Layout/CondensedRenderer.tsx | 15 + .../components/Layout/ExpandedRenderer.tsx | 14 + .../conversation/ChatHistorySearch.tsx | 393 ++++++++++++++++++ ui/desktop/src/i18n/messages/en.json | 18 + 4 files changed, 440 insertions(+) create mode 100644 ui/desktop/src/components/conversation/ChatHistorySearch.tsx diff --git a/ui/desktop/src/components/Layout/CondensedRenderer.tsx b/ui/desktop/src/components/Layout/CondensedRenderer.tsx index 20d0f451..680b28d5 100644 --- a/ui/desktop/src/components/Layout/CondensedRenderer.tsx +++ b/ui/desktop/src/components/Layout/CondensedRenderer.tsx @@ -5,6 +5,7 @@ import { defineMessages, useIntl } from '../../i18n'; import { cn } from '../../utils'; import { DropdownMenu, DropdownMenuTrigger } from '../ui/dropdown-menu'; import { ChatSessionsDropdown, SessionsList } from './navigation'; +import { ChatHistorySearch } from '../conversation/ChatHistorySearch'; import type { NavigationRendererProps } from './navigation/types'; const i18n = defineMessages({ @@ -76,6 +77,20 @@ export const CondensedRenderer: React.FC = ({
)} + {/* Search bar — skip mount entirely in icon-only mode so the + document-level Cmd/Ctrl+K handler inside ChatHistorySearch + does not intercept the shortcut when no UI is visible. */} + {!isCondensedIconOnly && ( +
+ +
+ )} + {/* Navigation items */} {isVertical ? (
diff --git a/ui/desktop/src/components/Layout/ExpandedRenderer.tsx b/ui/desktop/src/components/Layout/ExpandedRenderer.tsx index a3792736..e4ea296a 100644 --- a/ui/desktop/src/components/Layout/ExpandedRenderer.tsx +++ b/ui/desktop/src/components/Layout/ExpandedRenderer.tsx @@ -5,6 +5,7 @@ import { Z_INDEX } from './constants'; import { cn } from '../../utils'; import { DropdownMenu, DropdownMenuTrigger } from '../ui/dropdown-menu'; import { ChatSessionsDropdown } from './navigation'; +import { ChatHistorySearch } from '../conversation/ChatHistorySearch'; import type { NavigationRendererProps } from './navigation/types'; export const ExpandedRenderer: React.FC = ({ @@ -141,6 +142,19 @@ export const ExpandedRenderer: React.FC = ({ alignContent: 'start', }} > + {/* Search bar spanning full width */} +
+ +
+ {visibleItems.map((item, index) => { const Icon = item.icon; const active = isActive(item.path); diff --git a/ui/desktop/src/components/conversation/ChatHistorySearch.tsx b/ui/desktop/src/components/conversation/ChatHistorySearch.tsx new file mode 100644 index 00000000..5d332e74 --- /dev/null +++ b/ui/desktop/src/components/conversation/ChatHistorySearch.tsx @@ -0,0 +1,393 @@ +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { Search, X, MessageSquare, ChefHat, Clock } from 'lucide-react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { defineMessages, useIntl } from '../../i18n'; +import { searchSessions } from '../../api'; +import { cn } from '../../utils'; +import { ScrollArea } from '../ui/scroll-area'; +import { Skeleton } from '../ui/skeleton'; +import { SessionIndicators } from '../SessionIndicators'; +import { getSessionDisplayName, truncateMessage } from '../../hooks/useNavigationSessions'; +import type { Session } from '../../api'; +import type { SessionStatus } from '../Layout/navigation/types'; + +const i18n = defineMessages({ + searchPlaceholder: { + id: 'chatHistorySearch.searchPlaceholder', + defaultMessage: 'Search chats…', + }, + noResults: { + id: 'chatHistorySearch.noResults', + defaultMessage: 'No chats found', + }, + searchError: { + id: 'chatHistorySearch.searchError', + defaultMessage: 'Search failed', + }, + messageCount: { + id: 'chatHistorySearch.messageCount', + defaultMessage: '{count, plural, one {# message} other {# messages}}', + }, + keyboardHintMac: { + id: 'chatHistorySearch.keyboardHintMac', + defaultMessage: '⌘K', + }, + keyboardHintOther: { + id: 'chatHistorySearch.keyboardHintOther', + defaultMessage: 'Ctrl+K', + }, +}); + +function formatRelativeTime(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMin = Math.floor(diffMs / 60000); + const diffHr = Math.floor(diffMs / 3600000); + const diffDay = Math.floor(diffMs / 86400000); + + if (diffMin < 1) return 'just now'; + if (diffMin < 60) return `${diffMin}m ago`; + if (diffHr < 24) return `${diffHr}h ago`; + if (diffDay < 7) return `${diffDay}d ago`; + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +interface ChatHistorySearchProps { + onSessionClick: (sessionId: string) => void; + getSessionStatus: (sessionId: string) => SessionStatus | undefined; + clearUnread: (sessionId: string) => void; + activeSessionId?: string; + className?: string; +} + +const SEARCH_RESULTS_LIMIT = 8; + +export const ChatHistorySearch: React.FC = ({ + onSessionClick, + getSessionStatus, + clearUnread, + activeSessionId, + className, +}) => { + const intl = useIntl(); + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [hasError, setHasError] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const containerRef = useRef(null); + const inputRef = useRef(null); + const searchTimeoutRef = useRef | undefined>(undefined); + const latestRequestIdRef = useRef(0); + + const showDropdown = + isFocused && (query.trim().length > 0 || isSearching || hasError || results.length > 0); + + const performSearch = useCallback(async (searchQuery: string) => { + if (!searchQuery.trim()) { + latestRequestIdRef.current += 1; + setResults([]); + setHasError(false); + return; + } + + const requestId = ++latestRequestIdRef.current; + setIsSearching(true); + setHasError(false); + try { + const response = await searchSessions({ + query: { query: searchQuery, limit: SEARCH_RESULTS_LIMIT }, + throwOnError: false, + client: undefined, + }); + + if (requestId !== latestRequestIdRef.current) return; + + if (response.error || !response.data) { + setResults([]); + setHasError(true); + } else { + setResults(response.data); + } + } catch { + if (requestId !== latestRequestIdRef.current) return; + setResults([]); + setHasError(true); + } finally { + if (requestId === latestRequestIdRef.current) { + setIsSearching(false); + setHighlightedIndex(-1); + } + } + }, []); + + useEffect(() => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + // Invalidate any in-flight request and reset selection so Enter + // cannot pick a stale result during the debounce window. + latestRequestIdRef.current += 1; + setHighlightedIndex(-1); + + if (query.trim()) { + // Drop previous results and flip to the searching state immediately + // so (a) an item from the previous query cannot be clicked during the + // 250ms debounce window and (b) the empty-state does not flicker + // before the new request actually runs. + setResults([]); + setHasError(false); + setIsSearching(true); + searchTimeoutRef.current = setTimeout(() => { + performSearch(query); + }, 250); + } else { + setResults([]); + setHasError(false); + setIsSearching(false); + } + + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, [query, performSearch]); + + const handleClear = useCallback(() => { + setQuery(''); + setResults([]); + setHasError(false); + setHighlightedIndex(-1); + inputRef.current?.focus(); + }, []); + + const handleSelectSession = useCallback( + (sessionId: string) => { + clearUnread(sessionId); + onSessionClick(sessionId); + setQuery(''); + setResults([]); + setHighlightedIndex(-1); + inputRef.current?.blur(); + }, + [onSessionClick, clearUnread] + ); + + const handleContainerBlur = useCallback((event: React.FocusEvent) => { + const nextFocusedElement = event.relatedTarget; + if (!containerRef.current || !nextFocusedElement) { + setIsFocused(false); + return; + } + + if (!containerRef.current.contains(nextFocusedElement)) { + setIsFocused(false); + } + }, []); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsFocused(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + useEffect(() => { + const isMac = window.electron?.platform === 'darwin'; + const handleKeyDown = (e: KeyboardEvent) => { + if ((isMac ? e.metaKey : e.ctrlKey) && !e.shiftKey && !e.altKey && e.key === 'k') { + e.preventDefault(); + inputRef.current?.focus(); + setIsFocused(true); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); + + const handleInputKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!showDropdown) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setHighlightedIndex((prev) => (prev < results.length - 1 ? prev + 1 : 0)); + break; + case 'ArrowUp': + e.preventDefault(); + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : results.length - 1)); + break; + case 'Enter': + e.preventDefault(); + // Ignore Enter while a search is pending/in-flight so we don't + // select a result from the previous query. + if (isSearching) break; + if (highlightedIndex >= 0 && highlightedIndex < results.length) { + handleSelectSession(results[highlightedIndex].id); + } + break; + case 'Escape': + e.preventDefault(); + setIsFocused(false); + inputRef.current?.blur(); + break; + } + }, + [showDropdown, results, highlightedIndex, isSearching, handleSelectSession] + ); + + return ( +
+
+ + setQuery(e.target.value)} + onFocus={() => setIsFocused(true)} + onKeyDown={handleInputKeyDown} + placeholder={intl.formatMessage(i18n.searchPlaceholder)} + aria-label={intl.formatMessage(i18n.searchPlaceholder)} + aria-expanded={showDropdown} + aria-autocomplete="list" + aria-controls="chat-search-results" + role="combobox" + className={cn( + 'w-full pl-8 pr-14 py-1.5 text-sm', + 'bg-background-secondary border border-border-primary rounded-lg', + 'text-text-primary placeholder-text-secondary', + 'focus:outline-none focus:ring-1 focus:ring-border-tertiary focus:border-border-tertiary', + 'transition-all duration-150' + )} + /> + {query ? ( + + ) : ( + + {intl.formatMessage( + window.electron?.platform === 'darwin' ? i18n.keyboardHintMac : i18n.keyboardHintOther + )} + + )} +
+ + + {showDropdown && ( + + {isSearching ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ) : hasError ? ( +
+ {intl.formatMessage(i18n.searchError)} +
+ ) : results.length > 0 ? ( + +
+ {results.map((session, index) => { + const status = getSessionStatus(session.id); + const isStreaming = status?.streamState === 'streaming'; + const hasSessionError = status?.streamState === 'error'; + const hasUnread = status?.hasUnreadActivity ?? false; + const isActive = session.id === activeSessionId; + const isHighlighted = index === highlightedIndex; + const displayName = truncateMessage(getSessionDisplayName(session), 40); + const isRecipe = !!session.recipe; + + return ( + + ); + })} +
+
+ ) : query.trim() ? ( +
+ {intl.formatMessage(i18n.noResults)} +
+ ) : null} +
+ )} +
+
+ ); +}; diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index 2afe2020..c9d71838 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -119,6 +119,24 @@ "chat.notification.taskComplete.title": { "defaultMessage": "Goose finished the task." }, + "chatHistorySearch.keyboardHintMac": { + "defaultMessage": "⌘K" + }, + "chatHistorySearch.keyboardHintOther": { + "defaultMessage": "Ctrl+K" + }, + "chatHistorySearch.messageCount": { + "defaultMessage": "{count,plural,one{# message} other{# messages}}" + }, + "chatHistorySearch.noResults": { + "defaultMessage": "No chats found" + }, + "chatHistorySearch.searchError": { + "defaultMessage": "Search failed" + }, + "chatHistorySearch.searchPlaceholder": { + "defaultMessage": "Search chats…" + }, "chatInput.contextWindow": { "defaultMessage": "Context window" },