Detailed changes
@@ -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
@@ -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
@@ -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
@@ -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))
}
@@ -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}
@@ -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",
@@ -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;