shelley: add server-side conversation content search to command palette

Philip Zeyliger and Shelley created

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 <shelley@exe.dev>

Change summary

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(-)

Detailed changes

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

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

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

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))
 	}

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<Conversation[]>([]);
+  const [isSearching, setIsSearching] = useState(false);
   const inputRef = useRef<HTMLInputElement>(null);
   const listRef = useRef<HTMLDivElement>(null);
+  const searchTimeoutRef = useRef<number | null>(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: (
-          <svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="16" height="16">
-            <path
-              strokeLinecap="round"
-              strokeLinejoin="round"
-              strokeWidth={2}
-              d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
-            />
-          </svg>
-        ),
-        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: (
+        <svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="16" height="16">
+          <path
+            strokeLinecap="round"
+            strokeLinejoin="round"
+            strokeWidth={2}
+            d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
+          />
+        </svg>
+      ),
+      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 && <div className="command-palette-spinner" />}
           <div className="command-palette-shortcut">
             <kbd>esc</kbd>
           </div>
         </div>
 
         <div className="command-palette-list" ref={listRef}>
-          {filteredItems.length === 0 ? (
-            <div className="command-palette-empty">No results found</div>
+          {displayItems.length === 0 ? (
+            <div className="command-palette-empty">
+              {isSearching ? "Searching..." : "No results found"}
+            </div>
           ) : (
-            filteredItems.map((item, index) => (
+            displayItems.map((item, index) => (
               <div
                 key={item.id}
                 data-index={index}

ui/src/services/api.ts 🔗

@@ -24,6 +24,18 @@ class ApiService {
     return response.json();
   }
 
+  async searchConversations(query: string): Promise<Conversation[]> {
+    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",

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;