diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index bd54a210..7b356838 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -4,8 +4,8 @@ use std::sync::Arc; use tauri::{AppHandle, State}; use crate::services::acp::{ - make_composite_key, AcpRunningSession, AcpService, AcpSessionInfo, AcpSessionRegistry, - GooseAcpManager, + make_composite_key, search_sessions_via_exports, AcpRunningSession, AcpService, AcpSessionInfo, + AcpSessionRegistry, GooseAcpManager, SessionSearchResult, }; const DEPRECATED_PROVIDER_IDS: &[&str] = &["claude-code", "codex", "gemini-cli"]; @@ -166,6 +166,17 @@ pub async fn acp_list_sessions(app_handle: AppHandle) -> Result, +) -> Result, String> { + let manager = GooseAcpManager::start(app_handle).await?; + search_sessions_via_exports(&manager, &query, &session_ids).await +} + /// Load an existing session, replaying its messages as Tauri events. /// /// The goose binary sends `SessionNotification` events for each message in diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3aedb87d..2b937582 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -50,6 +50,7 @@ pub fn run() { commands::acp::acp_send_message, commands::acp::acp_cancel_session, commands::acp::acp_list_sessions, + commands::acp::acp_search_sessions, commands::acp::acp_load_session, commands::acp::acp_list_running, commands::acp::acp_cancel_all, diff --git a/src-tauri/src/services/acp/mod.rs b/src-tauri/src/services/acp/mod.rs index 83e20db8..5d681ecd 100644 --- a/src-tauri/src/services/acp/mod.rs +++ b/src-tauri/src/services/acp/mod.rs @@ -2,11 +2,13 @@ pub(crate) mod goose_serve; mod manager; mod payloads; mod registry; +mod search; mod writer; pub(crate) use goose_serve::resolve_goose_binary; pub use manager::{AcpSessionInfo, GooseAcpManager}; pub use registry::{AcpRunningSession, AcpSessionRegistry}; +pub use search::{search_sessions_via_exports, SessionSearchResult}; pub use writer::TauriMessageWriter; use std::path::PathBuf; diff --git a/src-tauri/src/services/acp/search.rs b/src-tauri/src/services/acp/search.rs new file mode 100644 index 00000000..015f4a4c --- /dev/null +++ b/src-tauri/src/services/acp/search.rs @@ -0,0 +1,467 @@ +use std::collections::HashSet; + +use serde::Serialize; +use serde_json::{Map, Value}; + +use super::GooseAcpManager; + +const SNIPPET_PREFIX_BYTES: usize = 40; +const SNIPPET_SUFFIX_BYTES: usize = 60; + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SessionSearchResult { + pub session_id: String, + pub snippet: String, + pub message_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub message_role: Option, + pub match_count: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ExportedMessage { + id: String, + role: Option, + searchable_texts: Vec, +} + +pub async fn search_sessions_via_exports( + manager: &GooseAcpManager, + query: &str, + session_ids: &[String], +) -> Result, String> { + let trimmed = query.trim(); + if trimmed.is_empty() { + return Ok(Vec::new()); + } + + let mut seen = HashSet::new(); + let mut results = Vec::new(); + + for session_id in session_ids { + if !seen.insert(session_id.clone()) { + continue; + } + + let exported = manager.export_session(session_id.clone()).await?; + if let Some(result) = search_exported_session(session_id, &exported, trimmed)? { + results.push(result); + } + } + + Ok(results) +} + +fn search_exported_session( + session_id: &str, + exported_json: &str, + query: &str, +) -> Result, String> { + let root: Value = serde_json::from_str(exported_json) + .map_err(|error| format!("Failed to parse exported session JSON: {error}"))?; + let Some(conversation) = root.get("conversation").or_else(|| root.get("messages")) else { + return Ok(None); + }; + + let messages = extract_messages(conversation); + if messages.is_empty() { + return Ok(None); + } + + let mut first_match: Option<(String, Option, String)> = None; + let mut match_count = 0; + + for message in messages { + for text in message.searchable_texts { + let occurrence_count = count_occurrences(&text, query); + if occurrence_count == 0 { + continue; + } + + match_count += occurrence_count; + + if first_match.is_none() { + first_match = Some(( + message.id.clone(), + message.role.clone(), + build_snippet(&text, query), + )); + } + } + } + + let Some((message_id, message_role, snippet)) = first_match else { + return Ok(None); + }; + + Ok(Some(SessionSearchResult { + session_id: session_id.to_string(), + snippet, + message_id, + message_role, + match_count, + })) +} + +fn extract_messages(value: &Value) -> Vec { + let mut messages = Vec::new(); + collect_messages(value, &mut messages); + messages +} + +fn collect_messages(value: &Value, messages: &mut Vec) { + match value { + Value::Array(items) => { + for item in items { + collect_messages(item, messages); + } + } + Value::Object(map) => { + if let Some(message_value) = map.get("message") { + collect_messages(message_value, messages); + return; + } + + if let Some(messages_value) = map.get("messages") { + collect_messages(messages_value, messages); + return; + } + + if looks_like_message(map) { + let fallback_id = format!("message-{}", messages.len()); + if let Some(message) = extract_message(map, fallback_id) { + messages.push(message); + } + } + } + _ => {} + } +} + +fn looks_like_message(map: &Map) -> bool { + map.contains_key("role") && (map.contains_key("content") || map.contains_key("text")) +} + +fn extract_message(map: &Map, fallback_id: String) -> Option { + let role = normalize_role(map.get("role").and_then(Value::as_str)); + let mut searchable_texts = Vec::new(); + + if let Some(content) = map.get("content") { + searchable_texts.extend(extract_searchable_texts(content, role.as_deref())); + } else if let Some(text) = map.get("text").and_then(Value::as_str) { + if role.is_some() && !text.trim().is_empty() { + searchable_texts.push(text.trim().to_string()); + } + } + + if searchable_texts.is_empty() { + return None; + } + + Some(ExportedMessage { + id: map + .get("id") + .and_then(Value::as_str) + .unwrap_or(&fallback_id) + .to_string(), + role, + searchable_texts, + }) +} + +fn extract_searchable_texts(value: &Value, role: Option<&str>) -> Vec { + match value { + Value::String(text) => role + .filter(|supported_role| is_searchable_role(supported_role)) + .and_then(|_| normalized_text(text)) + .into_iter() + .collect(), + Value::Array(items) => items + .iter() + .flat_map(|item| extract_searchable_block_text(item, role)) + .collect(), + Value::Object(_) => extract_searchable_block_text(value, role), + _ => Vec::new(), + } +} + +fn extract_searchable_block_text(value: &Value, role: Option<&str>) -> Vec { + let Value::Object(map) = value else { + return Vec::new(); + }; + + let block_type = map.get("type").and_then(Value::as_str); + let text = map.get("text").and_then(Value::as_str); + + match block_type { + Some("text") | Some("input_text") | Some("output_text") => { + text.and_then(normalized_text).into_iter().collect() + } + Some("systemNotification") | Some("system_notification") => { + text.and_then(normalized_text).into_iter().collect() + } + Some("toolRequest") + | Some("toolResponse") + | Some("thinking") + | Some("redactedThinking") + | Some("reasoning") + | Some("image") => Vec::new(), + _ => { + if role.is_some_and(is_searchable_role) { + return text.and_then(normalized_text).into_iter().collect(); + } + Vec::new() + } + } +} + +fn normalize_role(role: Option<&str>) -> Option { + let normalized = role?.trim(); + if normalized.eq_ignore_ascii_case("user") { + return Some("user".to_string()); + } + if normalized.eq_ignore_ascii_case("assistant") { + return Some("assistant".to_string()); + } + if normalized.eq_ignore_ascii_case("system") { + return Some("system".to_string()); + } + None +} + +fn is_searchable_role(role: &str) -> bool { + matches!(role, "user" | "assistant" | "system") +} + +fn normalized_text(text: &str) -> Option { + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +fn count_occurrences(text: &str, query: &str) -> usize { + let haystack = text.to_ascii_lowercase(); + let needle = query.to_ascii_lowercase(); + if needle.is_empty() { + return 0; + } + + let mut count = 0; + let mut search_start = 0; + + while let Some(relative_index) = haystack[search_start..].find(&needle) { + count += 1; + search_start += relative_index + needle.len(); + } + + count +} + +fn build_snippet(text: &str, query: &str) -> String { + let haystack = text.to_ascii_lowercase(); + let needle = query.to_ascii_lowercase(); + let match_index = haystack.find(&needle).unwrap_or(0); + + let start = floor_char_boundary(text, match_index.saturating_sub(SNIPPET_PREFIX_BYTES)); + let end = ceil_char_boundary( + text, + match_index + .saturating_add(query.len()) + .saturating_add(SNIPPET_SUFFIX_BYTES) + .min(text.len()), + ); + + let prefix = if start > 0 { "..." } else { "" }; + let suffix = if end < text.len() { "..." } else { "" }; + let body = text.get(start..end).unwrap_or(text).trim(); + + format!("{prefix}{body}{suffix}") +} + +fn floor_char_boundary(text: &str, mut index: usize) -> usize { + index = index.min(text.len()); + while index > 0 && !text.is_char_boundary(index) { + index -= 1; + } + index +} + +fn ceil_char_boundary(text: &str, mut index: usize) -> usize { + index = index.min(text.len()); + while index < text.len() && !text.is_char_boundary(index) { + index += 1; + } + index +} + +#[cfg(test)] +mod tests { + use super::{build_snippet, search_exported_session, SessionSearchResult}; + + #[test] + fn finds_user_and_assistant_text_matches() { + let exported = serde_json::json!({ + "conversation": [ + { + "id": "user-1", + "role": "user", + "content": [{ "type": "text", "text": "searchable user prompt" }] + }, + { + "id": "assistant-1", + "role": "assistant", + "content": [{ "type": "text", "text": "assistant searchable response" }] + } + ] + }) + .to_string(); + + let user_result = search_exported_session("session-1", &exported, "prompt") + .expect("search succeeds") + .expect("user result"); + let assistant_result = search_exported_session("session-1", &exported, "response") + .expect("search succeeds") + .expect("assistant result"); + + assert_eq!(user_result.message_id, "user-1"); + assert_eq!(user_result.message_role.as_deref(), Some("user")); + assert_eq!(assistant_result.message_id, "assistant-1"); + assert_eq!(assistant_result.message_role.as_deref(), Some("assistant")); + } + + #[test] + fn includes_system_notifications() { + let exported = serde_json::json!({ + "conversation": [ + { + "id": "system-1", + "role": "system", + "content": [{ + "type": "systemNotification", + "text": "Compaction completed successfully" + }] + } + ] + }) + .to_string(); + + let result = search_exported_session("session-1", &exported, "completed") + .expect("search succeeds") + .expect("system result"); + + assert_eq!(result.message_id, "system-1"); + assert_eq!(result.message_role.as_deref(), Some("system")); + } + + #[test] + fn skips_tool_and_reasoning_content() { + let exported = serde_json::json!({ + "conversation": [ + { + "id": "assistant-1", + "role": "assistant", + "content": [ + { "type": "toolRequest", "text": "tool request text" }, + { "type": "toolResponse", "text": "tool response text" }, + { "type": "thinking", "text": "private thinking" }, + { "type": "reasoning", "text": "private reasoning" } + ] + } + ] + }) + .to_string(); + + let result = + search_exported_session("session-1", &exported, "tool").expect("search succeeds"); + assert!(result.is_none()); + } + + #[test] + fn skips_single_object_tool_and_reasoning_blocks() { + let exported = serde_json::json!({ + "conversation": [ + { + "id": "assistant-1", + "role": "assistant", + "content": { "type": "toolResponse", "text": "tool response text" } + }, + { + "id": "assistant-2", + "role": "assistant", + "content": { "type": "reasoning", "text": "private reasoning" } + } + ] + }) + .to_string(); + + let tool_result = + search_exported_session("session-1", &exported, "tool").expect("search succeeds"); + let reasoning_result = + search_exported_session("session-1", &exported, "reasoning").expect("search succeeds"); + + assert!(tool_result.is_none()); + assert!(reasoning_result.is_none()); + } + + #[test] + fn includes_single_object_text_blocks() { + let exported = serde_json::json!({ + "conversation": [ + { + "id": "assistant-1", + "role": "assistant", + "content": { "type": "text", "text": "needle in a single object block" } + } + ] + }) + .to_string(); + + let result = search_exported_session("session-1", &exported, "needle") + .expect("search succeeds") + .expect("text result"); + + assert_eq!(result.message_id, "assistant-1"); + assert_eq!(result.message_role.as_deref(), Some("assistant")); + } + + #[test] + fn counts_multiple_matches_in_one_session() { + let exported = serde_json::json!({ + "conversation": [ + { + "id": "assistant-1", + "role": "assistant", + "content": [ + { "type": "text", "text": "needle once" }, + { "type": "text", "text": "needle twice needle" } + ] + } + ] + }) + .to_string(); + + let result = search_exported_session("session-1", &exported, "needle") + .expect("search succeeds") + .expect("result"); + + assert_eq!( + result, + SessionSearchResult { + session_id: "session-1".to_string(), + snippet: "needle once".to_string(), + message_id: "assistant-1".to_string(), + message_role: Some("assistant".to_string()), + match_count: 3, + } + ); + } + + #[test] + fn builds_trimmed_snippets_around_first_match() { + let text = "abcdefghijklmnopqrstuvwxyz0123456789prefix padding before needle and some trailing text that keeps going"; + let snippet = build_snippet(text, "needle"); + + assert!(snippet.starts_with("...")); + assert!(snippet.contains("needle and some trailing text")); + } +} diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 166c1221..386e4b37 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -64,9 +64,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { null, ); - // TODO: wire to ACP load_session replay instead of fetching from SessionStore const loadSessionMessages = useCallback(async (sessionId: string) => { - // Skip if we already have messages for this session (e.g. it was just created) const existing = useChatStore.getState().messagesBySession[sessionId]; if (existing && existing.length > 0) { console.log( @@ -399,6 +397,18 @@ export function AppShell({ children }: { children?: React.ReactNode }) { [sessionStore, chatStore, loadSessionMessages, cleanupEmptyDraft], ); + const handleSelectSearchResult = useCallback( + (sessionId: string, messageId?: string, query?: string) => { + if (messageId) { + useChatStore + .getState() + .setScrollTargetMessage(sessionId, messageId, query); + } + handleSelectSession(sessionId); + }, + [handleSelectSession], + ); + const handleNavigate = useCallback( (view: AppView) => { if (view !== "chat") { @@ -547,6 +557,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { onRenameChat={handleRenameChat} onMoveToProject={handleMoveToProject} onSelectSession={handleSelectSession} + onSelectSearchResult={handleSelectSearchResult} activeView={activeView} activeSessionId={activeSessionId} projects={projectStore.projects} @@ -554,7 +565,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) { /> - {/* Resize handle */} {/* biome-ignore lint/a11y/noStaticElementInteractions: drag handle for sidebar resize */}
)} diff --git a/src/app/ui/AppShellContent.tsx b/src/app/ui/AppShellContent.tsx index 8a9b427c..b8749baf 100644 --- a/src/app/ui/AppShellContent.tsx +++ b/src/app/ui/AppShellContent.tsx @@ -32,6 +32,11 @@ interface AppShellContentProps { onInitialMessageConsumed: () => void; onRenameChat: (sessionId: string, nextTitle: string) => void; onSelectSession: (sessionId: string) => void; + onSelectSearchResult: ( + sessionId: string, + messageId?: string, + query?: string, + ) => void; onStartChatFromProject: (project: ProjectInfo) => void; } @@ -49,6 +54,7 @@ export function AppShellContent({ onInitialMessageConsumed, onRenameChat, onSelectSession, + onSelectSearchResult, onStartChatFromProject, }: AppShellContentProps) { switch (activeView) { @@ -62,6 +68,7 @@ export function AppShellContent({ return ( diff --git a/src/features/chat/stores/__tests__/chatStore.test.ts b/src/features/chat/stores/__tests__/chatStore.test.ts index cf03fb63..72873531 100644 --- a/src/features/chat/stores/__tests__/chatStore.test.ts +++ b/src/features/chat/stores/__tests__/chatStore.test.ts @@ -158,6 +158,21 @@ describe("chatStore", () => { expect(store.draftsBySession.s1).toBeUndefined(); expect(store.activeSessionId).toBeNull(); }); + + it("stores and clears scroll targets per session", () => { + const store = useChatStore.getState(); + + store.setScrollTargetMessage("s1", "message-1", "needle"); + expect(useChatStore.getState().scrollTargetMessageBySession.s1).toEqual({ + messageId: "message-1", + query: "needle", + }); + + store.clearScrollTargetMessage("s1"); + expect( + useChatStore.getState().scrollTargetMessageBySession.s1, + ).toBeUndefined(); + }); }); describe("chatStore draft localStorage persistence", () => { diff --git a/src/features/chat/stores/chatStore.ts b/src/features/chat/stores/chatStore.ts index cb97a52e..3b378b67 100644 --- a/src/features/chat/stores/chatStore.ts +++ b/src/features/chat/stores/chatStore.ts @@ -54,34 +54,24 @@ export interface QueuedMessage { images?: { base64: string; mimeType: string }[]; } +export interface ScrollTargetMessage { + messageId: string; + query?: string; +} + interface ChatStoreState { - // Per-session messages messagesBySession: Record; - - // Per-session runtime state sessionStateById: Record; - - // Per-session queued message (single-slot, survives tab switches) queuedMessageBySession: Record; - - // Per-session draft input text (survives tab switches) draftsBySession: Record; - - // Current session activeSessionId: string | null; - - // Connection isConnected: boolean; - - // Sessions currently being loaded from history (replay in progress) loadingSessionIds: Set; + scrollTargetMessageBySession: Record; } interface ChatStoreActions { - // Session management setActiveSession: (sessionId: string) => void; - - // Message management addMessage: (sessionId: string, message: Message) => void; updateMessage: ( sessionId: string, @@ -91,42 +81,32 @@ interface ChatStoreActions { removeMessage: (sessionId: string, messageId: string) => void; setMessages: (sessionId: string, messages: Message[]) => void; clearMessages: (sessionId: string) => void; - - // Active session helpers (operate on activeSessionId) getActiveMessages: () => Message[]; getSessionRuntime: (sessionId: string) => SessionChatRuntime; - - // Streaming setStreamingMessageId: (sessionId: string, id: string | null) => void; appendToStreamingMessage: ( sessionId: string, content: MessageContent, ) => void; updateStreamingText: (sessionId: string, text: string) => void; - - // State setChatState: (sessionId: string, state: ChatState) => void; setError: (sessionId: string, error: string | null) => void; setConnected: (connected: boolean) => void; markSessionRead: (sessionId: string) => void; markSessionUnread: (sessionId: string) => void; - - // Token tracking updateTokenState: (sessionId: string, state: Partial) => void; resetTokenState: (sessionId: string) => void; - - // Message queue enqueueMessage: (sessionId: string, message: QueuedMessage) => void; dismissQueuedMessage: (sessionId: string) => void; - - // Drafts setDraft: (sessionId: string, text: string) => void; clearDraft: (sessionId: string) => void; - - // Session loading (replay) setSessionLoading: (sessionId: string, loading: boolean) => void; - - // Cleanup + setScrollTargetMessage: ( + sessionId: string, + messageId: string, + query?: string, + ) => void; + clearScrollTargetMessage: (sessionId: string) => void; cleanupSession: (sessionId: string) => void; } @@ -141,6 +121,7 @@ export const useChatStore = create((set, get) => ({ activeSessionId: null, isConnected: false, loadingSessionIds: new Set(), + scrollTargetMessageBySession: {}, // Session management setActiveSession: (sessionId) => @@ -441,6 +422,28 @@ export const useChatStore = create((set, get) => ({ return { loadingSessionIds: next }; }), + setScrollTargetMessage: (sessionId, messageId, query) => + set((state) => ({ + scrollTargetMessageBySession: { + ...state.scrollTargetMessageBySession, + [sessionId]: { messageId, query }, + }, + })), + + clearScrollTargetMessage: (sessionId) => + set((state) => { + if (!state.scrollTargetMessageBySession[sessionId]) { + return state; + } + + const nextTargets = { ...state.scrollTargetMessageBySession }; + delete nextTargets[sessionId]; + + return { + scrollTargetMessageBySession: nextTargets, + }; + }), + // Cleanup cleanupSession: (sessionId) => { // Discard any orphaned replay buffer so module-level Map doesn't leak. @@ -452,11 +455,14 @@ export const useChatStore = create((set, get) => ({ const { [sessionId]: ___, ...remainingQueued } = state.queuedMessageBySession; const { [sessionId]: ____, ...remainingDrafts } = state.draftsBySession; + const { [sessionId]: _____, ...remainingTargets } = + state.scrollTargetMessageBySession; return { messagesBySession: rest, sessionStateById: remainingSessionState, queuedMessageBySession: remainingQueued, draftsBySession: remainingDrafts, + scrollTargetMessageBySession: remainingTargets, activeSessionId: state.activeSessionId === sessionId ? null : state.activeSessionId, }; diff --git a/src/features/chat/ui/ChatView.tsx b/src/features/chat/ui/ChatView.tsx index 33246fd5..c1ba10a5 100644 --- a/src/features/chat/ui/ChatView.tsx +++ b/src/features/chat/ui/ChatView.tsx @@ -265,27 +265,21 @@ export function ChatView({ if (!activeSessionId || modelId === session?.modelId) { return; } - const previousModelId = session?.modelId; const previousModelName = session?.modelName; const models = useChatSessionStore .getState() .getSessionModels(activeSessionId); const selected = models.find((m) => m.id === modelId); - - // Optimistic update useChatSessionStore.getState().updateSession(activeSessionId, { modelId, modelName: selected?.displayName ?? selected?.name ?? modelId, }); - if (session?.draft) { return; } - acpSetModel(activeSessionId, modelId).catch((error) => { console.error("Failed to set model:", error); - // Rollback to previous model on failure useChatSessionStore.getState().updateSession(activeSessionId, { modelId: previousModelId, modelName: previousModelName, @@ -310,8 +304,6 @@ export function ChatView({ handleProviderChange(matchingProvider.id); } } - - // Update the active agent to match persona const agentStore = useAgentStore.getState(); const matchingAgent = agentStore.agents.find( (a) => a.personaId === personaId, @@ -319,8 +311,6 @@ export function ChatView({ if (matchingAgent) { agentStore.setActiveAgent(matchingAgent.id); } - - // Persist persona selection to session store useChatSessionStore .getState() .updateSession(activeSessionId, { personaId: personaId ?? undefined }); @@ -446,12 +436,18 @@ export function ChatView({ const draftValue = useChatStore( (s) => s.draftsBySession[activeSessionId] ?? "", ); + const scrollTarget = useChatStore( + (s) => s.scrollTargetMessageBySession[activeSessionId] ?? null, + ); const handleDraftChange = useCallback( (text: string) => { useChatStore.getState().setDraft(activeSessionId, text); }, [activeSessionId], ); + const handleScrollTargetHandled = useCallback(() => { + useChatStore.getState().clearScrollTargetMessage(activeSessionId); + }, [activeSessionId]); return ( diff --git a/src/features/chat/ui/MessageTimeline.tsx b/src/features/chat/ui/MessageTimeline.tsx index aabbafb1..14fe3724 100644 --- a/src/features/chat/ui/MessageTimeline.tsx +++ b/src/features/chat/ui/MessageTimeline.tsx @@ -1,15 +1,18 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { cn } from "@/shared/lib/cn"; import { useLocaleFormatting } from "@/shared/i18n"; import { MessageBubble } from "./MessageBubble"; -import type { Message } from "@/shared/types/messages"; +import { getTextContent, type Message } from "@/shared/types/messages"; interface MessageTimelineProps { messages: Message[]; agentName?: string; agentAvatarUrl?: string; streamingMessageId?: string | null; + scrollTargetMessageId?: string | null; + scrollTargetQuery?: string | null; + onScrollTargetHandled?: (messageId: string) => void; onRetryMessage?: (messageId: string) => void; onEditMessage?: (messageId: string) => void; className?: string; @@ -53,6 +56,9 @@ export function MessageTimeline({ agentName, agentAvatarUrl, streamingMessageId, + scrollTargetMessageId, + scrollTargetQuery, + onScrollTargetHandled, onRetryMessage, onEditMessage, className, @@ -61,7 +67,32 @@ export function MessageTimeline({ const { formatDate } = useLocaleFormatting(); const bottomRef = useRef(null); const containerRef = useRef(null); + const messageRefs = useRef>({}); const isNearBottomRef = useRef(true); + const [pulsingMessageId, setPulsingMessageId] = useState(null); + const visibleMessages = messages.filter( + (m) => m.metadata?.userVisible !== false, + ); + const resolvedScrollTargetMessageId = useMemo(() => { + if (scrollTargetMessageId) { + const exactMatch = visibleMessages.find( + (message) => message.id === scrollTargetMessageId, + ); + if (exactMatch) { + return exactMatch.id; + } + } + + const trimmedQuery = scrollTargetQuery?.trim().toLocaleLowerCase(); + if (!trimmedQuery) { + return null; + } + + const textMatch = visibleMessages.find((message) => + getTextContent(message).toLocaleLowerCase().includes(trimmedQuery), + ); + return textMatch?.id ?? null; + }, [scrollTargetMessageId, scrollTargetQuery, visibleMessages]); // Use scrollTo instead of scrollIntoView to avoid scrolling parent/document-level ancestors. // biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable and don't need to be in deps @@ -74,6 +105,42 @@ export function MessageTimeline({ } }, [messages, streamingMessageId]); + useEffect(() => { + if (!resolvedScrollTargetMessageId) { + return; + } + + const target = messageRefs.current[resolvedScrollTargetMessageId]; + if (!target) { + return; + } + + const frame = requestAnimationFrame(() => { + target.scrollIntoView({ + block: "center", + behavior: "smooth", + }); + setPulsingMessageId(resolvedScrollTargetMessageId); + onScrollTargetHandled?.(resolvedScrollTargetMessageId); + }); + + return () => cancelAnimationFrame(frame); + }, [onScrollTargetHandled, resolvedScrollTargetMessageId]); + + useEffect(() => { + if (!pulsingMessageId) { + return; + } + + const timer = window.setTimeout(() => { + setPulsingMessageId((current) => + current === pulsingMessageId ? null : current, + ); + }, 2000); + + return () => window.clearTimeout(timer); + }, [pulsingMessageId]); + const handleScroll = () => { const container = containerRef.current; if (!container) return; @@ -81,10 +148,6 @@ export function MessageTimeline({ isNearBottomRef.current = scrollHeight - scrollTop - clientHeight < 100; }; - const visibleMessages = messages.filter( - (m) => m.metadata?.userVisible !== false, - ); - if (visibleMessages.length === 0) { return (
@@ -116,7 +179,18 @@ export function MessageTimeline({ !prev || !isSameDay(prev.created, message.created); return ( -
+
{ + messageRefs.current[message.id] = el; + }} + className={cn( + index === 0 ? "mt-0" : "mt-4", + "rounded-xl transition-[background-color,box-shadow]", + pulsingMessageId === message.id && + "bg-accent/25 ring-2 ring-accent/35 ring-inset", + )} + > {showDateSeparator && (
diff --git a/src/features/sessions/hooks/__tests__/useSessionSearch.test.ts b/src/features/sessions/hooks/__tests__/useSessionSearch.test.ts new file mode 100644 index 00000000..29b9c383 --- /dev/null +++ b/src/features/sessions/hooks/__tests__/useSessionSearch.test.ts @@ -0,0 +1,87 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; + +const mockAcpSearchSessions = vi.fn(); + +vi.mock("@/shared/api/acp", () => ({ + acpSearchSessions: (...args: unknown[]) => mockAcpSearchSessions(...args), +})); + +import { useSessionSearch } from "../useSessionSearch"; + +function createDeferredPromise() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +const sessions: ChatSession[] = [ + { + id: "session-1", + acpSessionId: "acp-1", + title: "Needle notes", + createdAt: "2026-04-10T12:00:00Z", + updatedAt: "2026-04-10T12:00:00Z", + messageCount: 1, + }, +]; + +const resolvers = { + getPersonaName: () => undefined, + getProjectName: () => undefined, +}; + +describe("useSessionSearch", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("clears the loading state when a short query skips backend search", async () => { + const deferred = + createDeferredPromise< + Array<{ + sessionId: string; + snippet: string; + messageId: string; + matchCount: number; + }> + >(); + mockAcpSearchSessions.mockReturnValueOnce(deferred.promise); + + const { result } = renderHook(() => + useSessionSearch({ + sessions, + resolvers, + }), + ); + + await act(async () => { + result.current.setQuery("needle"); + }); + await act(async () => { + void result.current.search(); + }); + + expect(result.current.isSearching).toBe(true); + + await act(async () => { + result.current.setQuery("n"); + }); + await act(async () => { + await result.current.search(); + }); + + expect(result.current.isSearching).toBe(false); + expect(result.current.submittedQuery).toBe("n"); + + deferred.resolve([]); + await act(async () => { + await deferred.promise; + }); + + expect(result.current.isSearching).toBe(false); + }); +}); diff --git a/src/features/sessions/hooks/useSessionSearch.ts b/src/features/sessions/hooks/useSessionSearch.ts new file mode 100644 index 00000000..225d25f8 --- /dev/null +++ b/src/features/sessions/hooks/useSessionSearch.ts @@ -0,0 +1,131 @@ +import { useCallback, useRef, useState } from "react"; +import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; +import { acpSearchSessions } from "@/shared/api/acp"; +import { + buildSessionSearchResults, + type SessionSearchDisplayResult, +} from "../lib/buildSessionSearchResults"; +import type { FilterResolvers } from "../lib/filterSessions"; + +interface UseSessionSearchOptions { + sessions: ChatSession[]; + resolvers: FilterResolvers; + locale?: string; + getDisplayTitle?: (session: ChatSession) => string; +} + +export function useSessionSearch({ + sessions, + resolvers, + locale, + getDisplayTitle, +}: UseSessionSearchOptions) { + const [query, setQuery] = useState(""); + const [submittedQuery, setSubmittedQuery] = useState(""); + const [results, setResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [error, setError] = useState(null); + const requestIdRef = useRef(0); + + const clear = useCallback(() => { + requestIdRef.current += 1; + setQuery(""); + setSubmittedQuery(""); + setResults([]); + setIsSearching(false); + setError(null); + }, []); + + const updateQuery = useCallback((nextQuery: string) => { + setQuery(nextQuery); + if (!nextQuery.trim()) { + requestIdRef.current += 1; + setSubmittedQuery(""); + setResults([]); + setIsSearching(false); + setError(null); + } + }, []); + + const search = useCallback(async () => { + const trimmed = query.trim(); + if (!trimmed) { + clear(); + return; + } + + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + + const metadataResults = buildSessionSearchResults( + sessions, + trimmed, + [], + resolvers, + { + locale, + getDisplayTitle, + }, + ); + + setSubmittedQuery(trimmed); + setError(null); + setResults(metadataResults); + + const acpSessionIds = sessions.map( + (session) => session.acpSessionId ?? session.id, + ); + if (trimmed.length < 2 || acpSessionIds.length === 0) { + setIsSearching(false); + return; + } + + setIsSearching(true); + + try { + const messageResults = await acpSearchSessions(trimmed, acpSessionIds); + if (requestIdRef.current !== requestId) { + return; + } + + setResults( + buildSessionSearchResults( + sessions, + trimmed, + messageResults, + resolvers, + { + locale, + getDisplayTitle, + }, + ), + ); + } catch (searchError) { + if (requestIdRef.current !== requestId) { + return; + } + + const message = + searchError instanceof Error + ? searchError.message + : String(searchError); + setError(message || "Search failed"); + setResults(metadataResults); + } finally { + if (requestIdRef.current === requestId) { + setIsSearching(false); + } + } + }, [clear, getDisplayTitle, locale, query, resolvers, sessions]); + + return { + query, + submittedQuery, + results, + isSearching, + error, + setQuery: updateQuery, + search, + clear, + }; +} diff --git a/src/features/sessions/lib/buildSessionSearchResults.test.ts b/src/features/sessions/lib/buildSessionSearchResults.test.ts new file mode 100644 index 00000000..9e3bdc97 --- /dev/null +++ b/src/features/sessions/lib/buildSessionSearchResults.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; +import type { AcpSessionSearchResult } from "@/shared/api/acp"; +import { buildSessionSearchResults } from "./buildSessionSearchResults"; + +const resolvers = { + getPersonaName: (id: string) => (id === "persona-1" ? "Builder" : undefined), + getProjectName: (id: string) => (id === "project-1" ? "Goose2" : undefined), +}; + +function makeSession( + overrides: Partial & { id: string; updatedAt: string }, +): ChatSession { + return { + title: "Untitled", + createdAt: "2026-04-10T12:00:00Z", + messageCount: 1, + ...overrides, + }; +} + +describe("buildSessionSearchResults", () => { + it("merges metadata and message matches, preferring message details", () => { + const sessions = [ + makeSession({ + id: "session-1", + acpSessionId: "acp-1", + title: "Needle session", + updatedAt: "2026-04-10T12:00:00Z", + }), + makeSession({ + id: "session-2", + title: "Builder notes", + personaId: "persona-1", + updatedAt: "2026-04-09T12:00:00Z", + }), + ]; + const messageMatches: AcpSessionSearchResult[] = [ + { + sessionId: "acp-1", + snippet: "a matching snippet", + messageId: "message-1", + messageRole: "assistant", + matchCount: 2, + }, + ]; + + const results = buildSessionSearchResults( + sessions, + "needle", + messageMatches, + resolvers, + ); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + matchType: "message", + snippet: "a matching snippet", + messageId: "message-1", + matchCount: 2, + }); + expect(results[0].session.id).toBe("session-1"); + }); + + it("includes metadata-only matches and sorts by updatedAt descending", () => { + const sessions = [ + makeSession({ + id: "session-1", + title: "Draft architecture", + updatedAt: "2026-04-08T12:00:00Z", + }), + makeSession({ + id: "session-2", + projectId: "project-1", + title: "Unrelated title", + updatedAt: "2026-04-10T12:00:00Z", + }), + ]; + + const results = buildSessionSearchResults( + sessions, + "goose2", + [], + resolvers, + ); + + expect(results).toHaveLength(1); + expect(results[0].matchType).toBe("metadata"); + expect(results[0].session.id).toBe("session-2"); + }); + + it("includes message-only matches even when metadata does not match", () => { + const sessions = [ + makeSession({ + id: "session-1", + title: "Unrelated title", + updatedAt: "2026-04-10T12:00:00Z", + }), + ]; + const messageMatches: AcpSessionSearchResult[] = [ + { + sessionId: "session-1", + snippet: "found in body", + messageId: "message-1", + messageRole: "user", + matchCount: 1, + }, + ]; + + const results = buildSessionSearchResults( + sessions, + "body text", + messageMatches, + resolvers, + ); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + matchType: "message", + snippet: "found in body", + }); + }); +}); diff --git a/src/features/sessions/lib/buildSessionSearchResults.ts b/src/features/sessions/lib/buildSessionSearchResults.ts new file mode 100644 index 00000000..7770d7e2 --- /dev/null +++ b/src/features/sessions/lib/buildSessionSearchResults.ts @@ -0,0 +1,68 @@ +import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; +import type { AcpSessionSearchResult } from "@/shared/api/acp"; +import { filterSessions, type FilterResolvers } from "./filterSessions"; + +interface BuildSessionSearchResultsOptions { + locale?: string; + getDisplayTitle?: (session: ChatSession) => string; +} + +export interface SessionSearchDisplayResult { + session: ChatSession; + matchType: "metadata" | "message"; + snippet?: string; + messageId?: string; + messageRole?: "user" | "assistant" | "system"; + matchCount?: number; +} + +function sortByUpdatedAtDesc(sessions: ChatSession[]): ChatSession[] { + return [...sessions].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); +} + +export function buildSessionSearchResults( + sessions: ChatSession[], + query: string, + messageMatches: AcpSessionSearchResult[], + resolvers: FilterResolvers, + options: BuildSessionSearchResultsOptions = {}, +): SessionSearchDisplayResult[] { + const metadataMatchIds = new Set( + filterSessions(sessions, query, resolvers, options).map( + (session) => session.id, + ), + ); + const messageMatchesBySessionId = new Map( + messageMatches.map((match) => [match.sessionId, match]), + ); + + return sortByUpdatedAtDesc(sessions) + .filter((session) => { + const acpSessionId = session.acpSessionId ?? session.id; + return ( + metadataMatchIds.has(session.id) || + messageMatchesBySessionId.has(acpSessionId) + ); + }) + .map((session) => { + const acpSessionId = session.acpSessionId ?? session.id; + const messageMatch = messageMatchesBySessionId.get(acpSessionId); + if (!messageMatch) { + return { + session, + matchType: "metadata" as const, + }; + } + + return { + session, + matchType: "message" as const, + snippet: messageMatch.snippet, + messageId: messageMatch.messageId, + messageRole: messageMatch.messageRole, + matchCount: messageMatch.matchCount, + }; + }); +} diff --git a/src/features/sessions/ui/SessionCard.tsx b/src/features/sessions/ui/SessionCard.tsx index 5eeef3e1..48f03c83 100644 --- a/src/features/sessions/ui/SessionCard.tsx +++ b/src/features/sessions/ui/SessionCard.tsx @@ -36,6 +36,8 @@ interface SessionCardProps { projectColor?: string; workingDir?: string; archivedAt?: string; + snippet?: string; + matchCount?: number; onSelect?: (id: string) => void; onRename?: (id: string, nextTitle: string) => void; onArchive?: (id: string) => void; @@ -53,6 +55,8 @@ export function SessionCard({ projectColor, workingDir, archivedAt, + snippet, + matchCount, onSelect, onRename, onArchive, @@ -187,6 +191,22 @@ export function SessionCard({ )}
+ {(snippet || matchCount) && ( +
+ {snippet && ( +

{snippet}

+ )} + {typeof matchCount === "number" && ( +

+ {t("search.messageMatches", { + count: matchCount, + displayCount: matchCount, + })} +

+ )} +
+ )} + {/* Actions menu */} diff --git a/src/features/sessions/ui/SessionHistoryView.tsx b/src/features/sessions/ui/SessionHistoryView.tsx index 8f0515be..0b3e9404 100644 --- a/src/features/sessions/ui/SessionHistoryView.tsx +++ b/src/features/sessions/ui/SessionHistoryView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { History, Upload } from "lucide-react"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; @@ -7,7 +7,6 @@ import { SearchBar } from "@/shared/ui/SearchBar"; import { Button } from "@/shared/ui/button"; import { SessionCard } from "./SessionCard"; import { groupSessionsByDate } from "../lib/groupSessionsByDate"; -import { filterSessions } from "../lib/filterSessions"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import { useProjectStore } from "@/features/projects/stores/projectStore"; @@ -18,15 +17,22 @@ import { } from "@/shared/api/acp"; import { saveExportedSessionFile } from "@/shared/api/system"; import { defaultExportFilename, downloadJson } from "../lib/exportSession"; +import { useSessionSearch } from "../hooks/useSessionSearch"; interface SessionHistoryViewProps { onSelectSession?: (sessionId: string) => void; + onSelectSearchResult?: ( + sessionId: string, + messageId?: string, + query?: string, + ) => void; onRenameChat?: (sessionId: string, nextTitle: string) => void; onArchiveChat?: (sessionId: string) => void; } export function SessionHistoryView({ onSelectSession, + onSelectSearchResult, onRenameChat, onArchiveChat, }: SessionHistoryViewProps) { @@ -37,7 +43,6 @@ export function SessionHistoryView({ () => sessions.filter((session) => !session.draft && !session.archivedAt), [sessions], ); - const [search, setSearch] = useState(""); const fileInputRef = useRef(null); const getPersonaName = useCallback( @@ -64,12 +69,14 @@ export function SessionHistoryView({ ); const resolvers = { getPersonaName, getProjectName }; - const filtered = filterSessions(activeSessions, search, resolvers, { + const search = useSessionSearch({ + sessions: activeSessions, + resolvers, locale: i18n.resolvedLanguage, getDisplayTitle: (session) => getDisplaySessionTitle(session.title, t("common:session.defaultTitle")), }); - const dateGroups = groupSessionsByDate(filtered, { + const dateGroups = groupSessionsByDate(activeSessions, { locale: i18n.resolvedLanguage, todayLabel: t("dateGroups.today"), yesterdayLabel: t("dateGroups.yesterday"), @@ -157,6 +164,17 @@ export function SessionHistoryView({ [loadSessions], ); + const handleSelectResult = useCallback( + (sessionId: string, messageId?: string) => { + if (messageId) { + onSelectSearchResult?.(sessionId, messageId, search.submittedQuery); + return; + } + onSelectSession?.(sessionId); + }, + [onSelectSearchResult, onSelectSession, search.submittedQuery], + ); + return (
@@ -182,12 +200,81 @@ export function SessionHistoryView({
{ + if (event.key === "Enter") { + event.preventDefault(); + void search.search(); + } + }} /> - {dateGroups.length > 0 && + {search.error && ( +

{t("history.searchError")}

+ )} + + {search.submittedQuery ? ( + search.results.length > 0 ? ( +
+ {search.results.map((result) => ( + + handleSelectResult(result.session.id, result.messageId) + } + onRename={onRenameChat} + onArchive={handleArchive} + onExport={handleExport} + onDuplicate={handleDuplicate} + /> + ))} +
+ ) : ( +
+ +
+

+ {search.isSearching + ? t("history.searching") + : t("history.emptyNoMatches")} +

+ {!search.isSearching && ( +

+ {t("history.emptyNoMatchesHint")} +

+ )} +
+
+ ) + ) : dateGroups.length > 0 ? ( dateGroups.map((group) => (

@@ -230,21 +317,14 @@ export function SessionHistoryView({ ))}

- ))} - - {dateGroups.length === 0 && ( + )) + ) : (
-

- {activeSessions.length === 0 - ? t("history.emptyTitle") - : t("history.emptyNoMatches")} -

+

{t("history.emptyTitle")}

- {activeSessions.length === 0 - ? t("history.emptyHint") - : t("history.emptyNoMatchesHint")} + {t("history.emptyHint")}

diff --git a/src/features/sidebar/ui/Sidebar.tsx b/src/features/sidebar/ui/Sidebar.tsx index 358f2d3b..2dfef674 100644 --- a/src/features/sidebar/ui/Sidebar.tsx +++ b/src/features/sidebar/ui/Sidebar.tsx @@ -13,11 +13,12 @@ import type { ProjectInfo } from "@/features/projects/api/projects"; import { useChatStore } from "@/features/chat/stores/chatStore"; import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import { isSessionRunning } from "@/features/chat/lib/sessionActivity"; -import { filterSessions } from "@/features/sessions/lib/filterSessions"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useProjectStore } from "@/features/projects/stores/projectStore"; import { Button } from "@/shared/ui/button"; +import { useSessionSearch } from "@/features/sessions/hooks/useSessionSearch"; import { SidebarProjectsSection } from "./SidebarProjectsSection"; +import { SidebarSearchResults } from "./SidebarSearchResults"; import { useSidebarHighlight } from "./useSidebarHighlight"; interface SidebarProps { @@ -35,6 +36,11 @@ interface SidebarProps { onMoveToProject?: (sessionId: string, projectId: string | null) => void; onNavigate?: (view: AppView) => void; onSelectSession?: (sessionId: string) => void; + onSelectSearchResult?: ( + sessionId: string, + messageId?: string, + query?: string, + ) => void; activeView?: AppView; activeSessionId?: string | null; className?: string; @@ -58,6 +64,7 @@ export function Sidebar({ onMoveToProject, onNavigate, onSelectSession, + onSelectSearchResult, activeView, activeSessionId, className, @@ -65,7 +72,6 @@ export function Sidebar({ }: SidebarProps) { const { t, i18n } = useTranslation(["sidebar", "common"]); const [expanded, setExpanded] = useState(!collapsed); - const [sidebarSearch, setSidebarSearch] = useState(""); const searchInputRef = useRef(null); const prevCollapsed = useRef(collapsed); const [expandedProjects, setExpandedProjects] = useState< @@ -84,6 +90,9 @@ export function Sidebar({ const chatStore = useChatStore(); const { sessions } = useChatSessionStore(); + const activeSessions = sessions.filter( + (session) => !session.draft && !session.archivedAt, + ); useEffect(() => { if (collapsed) { @@ -170,56 +179,13 @@ export function Sidebar({ projectStoreState.projects.find((p: { id: string }) => p.id === projectId) ?.name, }; - - const filteredProjectSessions = (() => { - if (!sidebarSearch.trim()) return projectSessions; - - const allSessionItems = [ - ...Object.values(projectSessions.byProject).flat(), - ...projectSessions.standalone, - ]; - const matchingIds = new Set( - filterSessions( - allSessionItems.map((item) => ({ - id: item.id, - title: item.title, - projectId: item.projectId, - createdAt: item.updatedAt, - updatedAt: item.updatedAt, - messageCount: 0, - })), - sidebarSearch, - sidebarResolvers, - { - locale: i18n.resolvedLanguage, - getDisplayTitle: (session) => - getDisplaySessionTitle(session.title, defaultTitle), - }, - ).map((s) => s.id), - ); - - const filteredByProject: Record = - {}; - for (const [projectId, items] of Object.entries( - projectSessions.byProject, - )) { - const matching = items.filter((item) => matchingIds.has(item.id)); - const projectNameMatches = sidebarResolvers - .getProjectName(projectId) - ?.toLowerCase() - .includes(sidebarSearch.trim().toLowerCase()); - if (matching.length > 0 || projectNameMatches) { - filteredByProject[projectId] = matching.length > 0 ? matching : items; - } - } - - return { - byProject: filteredByProject, - standalone: projectSessions.standalone.filter((item) => - matchingIds.has(item.id), - ), - }; - })(); + const sidebarSearch = useSessionSearch({ + sessions: activeSessions, + resolvers: sidebarResolvers, + locale: i18n.resolvedLanguage, + getDisplayTitle: (session) => + getDisplaySessionTitle(session.title, defaultTitle), + }); useEffect(() => { if (!activeSessionId) return; @@ -410,12 +376,19 @@ export function Sidebar({ <> setSidebarSearch(e.target.value)} + type="text" + enterKeyHint="search" + value={sidebarSearch.query} + onChange={(e) => sidebarSearch.setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void sidebarSearch.search(); + } + }} placeholder={t("search.placeholder")} className={cn( - "bg-transparent border-none outline-none text-xs flex-1 min-w-0 placeholder:text-muted-foreground", + "focus-override appearance-none bg-transparent border-none text-xs flex-1 min-w-0 placeholder:text-muted-foreground outline-none focus-visible:ring-0 focus-visible:ring-offset-0", labelTransition, labelVisible ? "opacity-100 w-auto" @@ -467,30 +440,6 @@ export function Sidebar({ - {/* */} - {navItems.map((item, index) => { const Icon = item.icon; const isActive = activeView === item.id; @@ -543,30 +492,68 @@ export function Sidebar({ <>
- + {sidebarSearch.submittedQuery ? ( +
+ {sidebarSearch.error && ( +

+ {t("search.error")} +

+ )} + + {sidebarSearch.isSearching && + sidebarSearch.results.length === 0 && ( +
+ {t("search.searching")} +
+ )} + + {(!sidebarSearch.isSearching || + sidebarSearch.results.length > 0) && ( + { + if (messageId) { + onSelectSearchResult?.( + sessionId, + messageId, + sidebarSearch.submittedQuery, + ); + return; + } + onSelectSession?.(sessionId); + }} + getPersonaName={sidebarResolvers.getPersonaName} + getProjectName={sidebarResolvers.getProjectName} + /> + )} +
+ ) : ( + + )} )} diff --git a/src/features/sidebar/ui/SidebarSearchResults.tsx b/src/features/sidebar/ui/SidebarSearchResults.tsx new file mode 100644 index 00000000..d28bb9e9 --- /dev/null +++ b/src/features/sidebar/ui/SidebarSearchResults.tsx @@ -0,0 +1,104 @@ +import { useTranslation } from "react-i18next"; +import { Bot, Folder } from "lucide-react"; +import { getDisplaySessionTitle } from "@/features/chat/lib/sessionTitle"; +import type { SessionSearchDisplayResult } from "@/features/sessions/lib/buildSessionSearchResults"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; + +interface SidebarSearchResultsProps { + results: SessionSearchDisplayResult[]; + activeSessionId?: string | null; + onSelectResult?: (sessionId: string, messageId?: string) => void; + getPersonaName: (personaId: string) => string | undefined; + getProjectName: (projectId: string) => string | undefined; +} + +export function SidebarSearchResults({ + results, + activeSessionId, + onSelectResult, + getPersonaName, + getProjectName, +}: SidebarSearchResultsProps) { + const { t } = useTranslation(["sidebar", "sessions", "common"]); + + if (results.length === 0) { + return ( +
+

+ {t("sessions:history.emptyNoMatches")} +

+

{t("sessions:history.emptyNoMatchesHint")}

+
+ ); + } + + return ( +
+ {results.map((result) => { + const session = result.session; + const displayTitle = getDisplaySessionTitle( + session.title, + t("common:session.defaultTitle"), + ); + const personaName = session.personaId + ? getPersonaName(session.personaId) + : undefined; + const projectName = session.projectId + ? getProjectName(session.projectId) + : undefined; + + return ( + + ); + })} +
+ ); +} diff --git a/src/shared/api/acp.ts b/src/shared/api/acp.ts index c55ae8fa..78835960 100644 --- a/src/shared/api/acp.ts +++ b/src/shared/api/acp.ts @@ -77,11 +77,26 @@ export interface AcpSessionInfo { messageCount: number; } +export interface AcpSessionSearchResult { + sessionId: string; + snippet: string; + messageId: string; + messageRole?: "user" | "assistant" | "system"; + matchCount: number; +} + /** List all sessions known to the goose binary. */ export async function acpListSessions(): Promise { return invoke("acp_list_sessions"); } +export async function acpSearchSessions( + query: string, + sessionIds: string[], +): Promise { + return invoke("acp_search_sessions", { query, sessionIds }); +} + /** * Load an existing session from the goose binary. * diff --git a/src/shared/i18n/locales/en/sessions.json b/src/shared/i18n/locales/en/sessions.json index d01fabde..f9426bd8 100644 --- a/src/shared/i18n/locales/en/sessions.json +++ b/src/shared/i18n/locales/en/sessions.json @@ -18,11 +18,17 @@ "emptyNoMatchesHint": "Try a different search term.", "emptyTitle": "No sessions yet", "searchArchivedPlaceholder": "Search archived sessions...", - "searchPlaceholder": "Search sessions by title, persona, or project...", + "searchError": "Message search failed. Showing title, persona, and project matches only.", + "searchPlaceholder": "Search sessions by title, persona, project, or message content...", + "searching": "Searching sessions...", "subtitle": "Browse and search past sessions", "toggleArchived": "Archived", "title": "Session History" }, + "search": { + "messageMatches_one": "{{displayCount}} message match", + "messageMatches_other": "{{displayCount}} message matches" + }, "messageCount_one": "{{displayCount}} message", "messageCount_other": "{{displayCount}} messages" } diff --git a/src/shared/i18n/locales/en/sidebar.json b/src/shared/i18n/locales/en/sidebar.json index b31e81ff..d7d9c440 100644 --- a/src/shared/i18n/locales/en/sidebar.json +++ b/src/shared/i18n/locales/en/sidebar.json @@ -17,7 +17,9 @@ "skills": "Skills" }, "search": { - "placeholder": "Search..." + "error": "Message search failed. Showing metadata matches only.", + "placeholder": "Search chats by title, persona, project, or message...", + "searching": "Searching chats..." }, "sections": { "projects": "Projects", diff --git a/src/shared/i18n/locales/es/sessions.json b/src/shared/i18n/locales/es/sessions.json index 03207a2f..b224605e 100644 --- a/src/shared/i18n/locales/es/sessions.json +++ b/src/shared/i18n/locales/es/sessions.json @@ -18,11 +18,17 @@ "emptyNoMatchesHint": "Prueba con otro término de búsqueda.", "emptyTitle": "Aún no hay sesiones", "searchArchivedPlaceholder": "Buscar sesiones archivadas...", - "searchPlaceholder": "Busca sesiones por título, persona o proyecto...", + "searchError": "La búsqueda de mensajes falló. Mostrando solo coincidencias por título, persona y proyecto.", + "searchPlaceholder": "Busca sesiones por título, persona, proyecto o contenido del mensaje...", + "searching": "Buscando sesiones...", "subtitle": "Explora y busca sesiones anteriores", "toggleArchived": "Archivadas", "title": "Historial de sesiones" }, + "search": { + "messageMatches_one": "{{displayCount}} coincidencia en mensajes", + "messageMatches_other": "{{displayCount}} coincidencias en mensajes" + }, "messageCount_one": "{{displayCount}} mensaje", "messageCount_other": "{{displayCount}} mensajes" } diff --git a/src/shared/i18n/locales/es/sidebar.json b/src/shared/i18n/locales/es/sidebar.json index 4ac0c47a..a34114c2 100644 --- a/src/shared/i18n/locales/es/sidebar.json +++ b/src/shared/i18n/locales/es/sidebar.json @@ -17,7 +17,9 @@ "skills": "Habilidades" }, "search": { - "placeholder": "Buscar..." + "error": "La búsqueda de mensajes falló. Mostrando solo coincidencias de metadatos.", + "placeholder": "Busca chats por título, persona, proyecto o mensaje...", + "searching": "Buscando chats..." }, "sections": { "projects": "Proyectos", diff --git a/src/shared/ui/SearchBar.tsx b/src/shared/ui/SearchBar.tsx index 51f4958d..dd6f9727 100644 --- a/src/shared/ui/SearchBar.tsx +++ b/src/shared/ui/SearchBar.tsx @@ -1,3 +1,4 @@ +import type * as React from "react"; import { Search } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; @@ -9,6 +10,8 @@ interface SearchBarProps { onChange: (term: string) => void; /** Placeholder text */ placeholder?: string; + /** Optional keydown handler for the input */ + onKeyDown?: React.KeyboardEventHandler; /** Optional className for the wrapper */ className?: string; } @@ -17,6 +20,7 @@ export function SearchBar({ value, onChange, placeholder, + onKeyDown, className, }: SearchBarProps) { return ( @@ -28,6 +32,7 @@ export function SearchBar({ spellCheck={false} value={value} onChange={(e) => onChange(e.target.value)} + onKeyDown={onKeyDown} placeholder={placeholder} className="rounded-lg bg-background pr-3 pl-9 text-sm" />