From 06cdc5362f14a422aef19012450d7c108a215f82 Mon Sep 17 00:00:00 2001 From: Philip Zeyliger Date: Mon, 12 Jan 2026 18:48:45 -0800 Subject: [PATCH] shelley: add server-side conversation content search to command palette Prompt: Does it search the text of the conversations too? This will need a backend endpoint. Add backend support for searching conversation content: - New SQL query SearchConversationsWithMessages that searches both conversation slugs and message content (user and agent messages only, excluding system prompts) - New API parameter search_content=true to enable content search - Frontend CommandPalette now calls the search API with debouncing (150ms) when the user types, showing a loading spinner during search The search looks for the query string in: - Conversation slug - User message text (from user_data.text JSON field) - Agent response text (from llm_data JSON field) Co-authored-by: Shelley --- db/db.go | 19 +++ db/generated/conversations.sql.go | 59 ++++++++++ db/query/conversations.sql | 13 +++ server/handlers.go | 9 +- ui/src/components/CommandPalette.tsx | 167 +++++++++++++++++---------- ui/src/services/api.ts | 12 ++ ui/src/styles.css | 10 ++ 7 files changed, 227 insertions(+), 62 deletions(-) diff --git a/db/db.go b/db/db.go index 36ec8e75e6bb7c38b405bb71a60e89d4b9eacc84..9cf9fc89541e859943f8acca8b24cf9c6b39113f 100644 --- a/db/db.go +++ b/db/db.go @@ -300,6 +300,25 @@ func (db *DB) SearchConversations(ctx context.Context, query string, limit, offs return conversations, err } +// SearchConversationsWithMessages searches for conversations containing the query in slug or message content +func (db *DB) SearchConversationsWithMessages(ctx context.Context, query string, limit, offset int64) ([]generated.Conversation, error) { + queryPtr := &query + var conversations []generated.Conversation + err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error { + q := generated.New(rx.Conn()) + var err error + conversations, err = q.SearchConversationsWithMessages(ctx, generated.SearchConversationsWithMessagesParams{ + Column1: queryPtr, + Column2: queryPtr, + Column3: queryPtr, + Limit: limit, + Offset: offset, + }) + return err + }) + return conversations, err +} + // UpdateConversationSlug updates the slug of a conversation func (db *DB) UpdateConversationSlug(ctx context.Context, conversationID, slug string) (*generated.Conversation, error) { var conversation generated.Conversation diff --git a/db/generated/conversations.sql.go b/db/generated/conversations.sql.go index 197d61e50b3fbd021c8ad5dca8a2e6301003de51..5683f7f9875f7ad19101d609f433b06a60f69c11 100644 --- a/db/generated/conversations.sql.go +++ b/db/generated/conversations.sql.go @@ -310,6 +310,65 @@ func (q *Queries) SearchConversations(ctx context.Context, arg SearchConversatio return items, nil } +const searchConversationsWithMessages = `-- name: SearchConversationsWithMessages :many +SELECT DISTINCT c.conversation_id, c.slug, c.user_initiated, c.created_at, c.updated_at, c.cwd, c.archived FROM conversations c +LEFT JOIN messages m ON c.conversation_id = m.conversation_id AND m.type IN ('user', 'agent') +WHERE c.archived = FALSE + AND ( + c.slug LIKE '%' || ? || '%' + OR json_extract(m.user_data, '$.text') LIKE '%' || ? || '%' + OR m.llm_data LIKE '%' || ? || '%' + ) +ORDER BY c.updated_at DESC +LIMIT ? OFFSET ? +` + +type SearchConversationsWithMessagesParams struct { + Column1 *string `json:"column_1"` + Column2 *string `json:"column_2"` + Column3 *string `json:"column_3"` + Limit int64 `json:"limit"` + Offset int64 `json:"offset"` +} + +// Search conversations by slug OR message content (user messages and agent responses, not system prompts) +func (q *Queries) SearchConversationsWithMessages(ctx context.Context, arg SearchConversationsWithMessagesParams) ([]Conversation, error) { + rows, err := q.db.QueryContext(ctx, searchConversationsWithMessages, + arg.Column1, + arg.Column2, + arg.Column3, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Conversation{} + for rows.Next() { + var i Conversation + if err := rows.Scan( + &i.ConversationID, + &i.Slug, + &i.UserInitiated, + &i.CreatedAt, + &i.UpdatedAt, + &i.Cwd, + &i.Archived, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const unarchiveConversation = `-- name: UnarchiveConversation :one UPDATE conversations SET archived = FALSE, updated_at = CURRENT_TIMESTAMP diff --git a/db/query/conversations.sql b/db/query/conversations.sql index 7b2eeb193eac32780423306237b3081a39e9065e..42b859ece91f540341b1de07913183a8f851459f 100644 --- a/db/query/conversations.sql +++ b/db/query/conversations.sql @@ -29,6 +29,19 @@ WHERE slug LIKE '%' || ? || '%' AND archived = FALSE ORDER BY updated_at DESC LIMIT ? OFFSET ?; +-- name: SearchConversationsWithMessages :many +-- Search conversations by slug OR message content (user messages and agent responses, not system prompts) +SELECT DISTINCT c.* FROM conversations c +LEFT JOIN messages m ON c.conversation_id = m.conversation_id AND m.type IN ('user', 'agent') +WHERE c.archived = FALSE + AND ( + c.slug LIKE '%' || ? || '%' + OR json_extract(m.user_data, '$.text') LIKE '%' || ? || '%' + OR m.llm_data LIKE '%' || ? || '%' + ) +ORDER BY c.updated_at DESC +LIMIT ? OFFSET ?; + -- name: SearchArchivedConversations :many SELECT * FROM conversations WHERE slug LIKE '%' || ? || '%' AND archived = TRUE diff --git a/server/handlers.go b/server/handlers.go index dc3cd7e525f4a135cc3b69a857e2ac02862fd132..b4894df100a0883bab64c58a1d3cfb06d9898bed 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -496,13 +496,20 @@ func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) { } } query = r.URL.Query().Get("q") + searchContent := r.URL.Query().Get("search_content") == "true" // Get conversations from database var conversations []generated.Conversation var err error if query != "" { - conversations, err = s.db.SearchConversations(ctx, query, int64(limit), int64(offset)) + if searchContent { + // Search in both slug and message content + conversations, err = s.db.SearchConversationsWithMessages(ctx, query, int64(limit), int64(offset)) + } else { + // Search only in slug + conversations, err = s.db.SearchConversations(ctx, query, int64(limit), int64(offset)) + } } else { conversations, err = s.db.ListConversations(ctx, int64(limit), int64(offset)) } diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 7eae87d318605411d8ffd35edd36ce096b89c9fa..bf4a8953401531ba2c8a0f171117171c060ae8e1 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -1,5 +1,6 @@ -import React, { useState, useEffect, useRef, useMemo } from "react"; +import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { Conversation } from "../types"; +import { api } from "../services/api"; interface CommandItem { id: string; @@ -21,7 +22,7 @@ interface CommandPaletteProps { hasCwd: boolean; } -// Simple fuzzy match - returns score (higher is better), -1 if no match +// Simple fuzzy match for actions - returns score (higher is better), -1 if no match function fuzzyMatch(query: string, text: string): number { const lowerQuery = query.toLowerCase(); const lowerText = text.toLowerCase(); @@ -67,14 +68,58 @@ function CommandPalette({ }: CommandPaletteProps) { const [query, setQuery] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); const inputRef = useRef(null); const listRef = useRef(null); + const searchTimeoutRef = useRef(null); - // Build list of command items - const allItems: CommandItem[] = useMemo(() => { + // Search conversations on the server + const searchConversations = useCallback(async (searchQuery: string) => { + if (!searchQuery.trim()) { + setSearchResults([]); + setIsSearching(false); + return; + } + + setIsSearching(true); + try { + const results = await api.searchConversations(searchQuery); + setSearchResults(results); + } catch (err) { + console.error("Failed to search conversations:", err); + setSearchResults([]); + } finally { + setIsSearching(false); + } + }, []); + + // Debounced search when query changes + useEffect(() => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + if (query.trim()) { + searchTimeoutRef.current = window.setTimeout(() => { + searchConversations(query); + }, 150); // 150ms debounce + } else { + setSearchResults([]); + setIsSearching(false); + } + + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, [query, searchConversations]); + + // Build action items (these are always available) + const actionItems: CommandItem[] = useMemo(() => { const items: CommandItem[] = []; - // Actions items.push({ id: "new-conversation", type: "action", @@ -116,78 +161,75 @@ function CommandPalette({ }); } - // Add conversations - conversations.forEach((conv) => { - items.push({ - id: `conv-${conv.conversation_id}`, - type: "conversation", - title: conv.slug || conv.conversation_id, - subtitle: conv.cwd || undefined, - icon: ( - - - - ), - action: () => { - onSelectConversation(conv.conversation_id); - onClose(); - }, - keywords: [conv.slug || "", conv.cwd || ""].filter(Boolean), - }); - }); - return items; - }, [conversations, onNewConversation, onSelectConversation, onOpenDiffViewer, onClose, hasCwd]); + }, [onNewConversation, onOpenDiffViewer, onClose, hasCwd]); - // Filter and sort items based on query - const filteredItems = useMemo(() => { - if (!query.trim()) { - // No query - show actions first, then recent conversations - return allItems; - } + // Convert conversations to command items + const conversationToItem = useCallback( + (conv: Conversation): CommandItem => ({ + id: `conv-${conv.conversation_id}`, + type: "conversation", + title: conv.slug || conv.conversation_id, + subtitle: conv.cwd || undefined, + icon: ( + + + + ), + action: () => { + onSelectConversation(conv.conversation_id); + onClose(); + }, + }), + [onSelectConversation, onClose], + ); - // Score and filter items - const scored = allItems - .map((item) => { - let maxScore = fuzzyMatch(query, item.title); + // Compute the final list of items to display + const displayItems = useMemo(() => { + const trimmedQuery = query.trim(); - // Check subtitle + // Filter actions based on query (client-side fuzzy match) + let filteredActions = actionItems; + if (trimmedQuery) { + filteredActions = actionItems.filter((item) => { + let maxScore = fuzzyMatch(trimmedQuery, item.title); if (item.subtitle) { - const subtitleScore = fuzzyMatch(query, item.subtitle); - if (subtitleScore > maxScore) maxScore = subtitleScore * 0.8; // Slightly lower weight + const subtitleScore = fuzzyMatch(trimmedQuery, item.subtitle); + if (subtitleScore > maxScore) maxScore = subtitleScore * 0.8; } - - // Check keywords if (item.keywords) { for (const keyword of item.keywords) { - const keywordScore = fuzzyMatch(query, keyword); + const keywordScore = fuzzyMatch(trimmedQuery, keyword); if (keywordScore > maxScore) maxScore = keywordScore * 0.7; } } + return maxScore > 0; + }); + } - return { item, score: maxScore }; - }) - .filter(({ score }) => score > 0) - .sort((a, b) => b.score - a.score); + // Use search results if we have a query, otherwise use initial conversations + const conversationsToShow = trimmedQuery ? searchResults : conversations; + const conversationItems = conversationsToShow.map(conversationToItem); - return scored.map(({ item }) => item); - }, [allItems, query]); + return [...filteredActions, ...conversationItems]; + }, [query, actionItems, searchResults, conversations, conversationToItem]); - // Reset selection when query changes + // Reset selection when items change useEffect(() => { setSelectedIndex(0); - }, [query]); + }, [displayItems]); // Focus input when opened useEffect(() => { if (isOpen) { setQuery(""); setSelectedIndex(0); + setSearchResults([]); setTimeout(() => inputRef.current?.focus(), 0); } }, [isOpen]); @@ -204,7 +246,7 @@ function CommandPalette({ switch (e.key) { case "ArrowDown": e.preventDefault(); - setSelectedIndex((prev) => Math.min(prev + 1, filteredItems.length - 1)); + setSelectedIndex((prev) => Math.min(prev + 1, displayItems.length - 1)); break; case "ArrowUp": e.preventDefault(); @@ -212,8 +254,8 @@ function CommandPalette({ break; case "Enter": e.preventDefault(); - if (filteredItems[selectedIndex]) { - filteredItems[selectedIndex].action(); + if (displayItems[selectedIndex]) { + displayItems[selectedIndex].action(); } break; case "Escape": @@ -253,16 +295,19 @@ function CommandPalette({ onChange={(e) => setQuery(e.target.value)} onKeyDown={handleKeyDown} /> + {isSearching &&
}
esc
- {filteredItems.length === 0 ? ( -
No results found
+ {displayItems.length === 0 ? ( +
+ {isSearching ? "Searching..." : "No results found"} +
) : ( - filteredItems.map((item, index) => ( + displayItems.map((item, index) => (
{ + const params = new URLSearchParams({ + q: query, + search_content: "true", + }); + const response = await fetch(`${this.baseUrl}/conversations?${params}`); + if (!response.ok) { + throw new Error(`Failed to search conversations: ${response.statusText}`); + } + return response.json(); + } + async sendMessageWithNewConversation(request: ChatRequest): Promise<{ conversation_id: string }> { const response = await fetch(`${this.baseUrl}/conversations/new`, { method: "POST", diff --git a/ui/src/styles.css b/ui/src/styles.css index 232df119334eaf271cff1aaaadf01fb1775b6745..74d26b843360da29afcaef6c190bed195de38881 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -3461,6 +3461,16 @@ svg { flex-shrink: 0; } +.command-palette-spinner { + width: 16px; + height: 16px; + border: 2px solid var(--border-color); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.6s linear infinite; + flex-shrink: 0; +} + .command-palette-shortcut kbd, .command-palette-footer kbd { display: inline-block;