diff --git a/apps/web/src/routes/chat/index.tsx b/apps/web/src/routes/chat/index.tsx index b46f9f9..45d42a4 100644 --- a/apps/web/src/routes/chat/index.tsx +++ b/apps/web/src/routes/chat/index.tsx @@ -9,6 +9,7 @@ import { redirect, useNavigate, } from "@tanstack/react-router"; +import { usePaginatedQuery } from "convex/react"; // any downsides to mixing convex/react with tanstack/react-query? import { AlertTriangle, ArrowUp, @@ -23,7 +24,9 @@ import { PanelLeftClose, PanelLeftOpen, Plus, + Search, // Icon for search Settings, + SlidersHorizontal, Sparkles, Square, Trash2, @@ -74,6 +77,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "../../components/ui/dropdown-menu"; +import { Input } from "../../components/ui/input"; // reuse input from components import { ScrollArea } from "../../components/ui/scroll-area"; import { Select, @@ -696,6 +700,17 @@ function ChatPage() { [userSettings, conversations, harnesses], ); + // State handlers for searching + const [scrollToMessageId, setScrollToMessageId] = + useState | null>(null); + const handleSelectMessage = useCallback( + (convoId: Id<"conversations">, messageId: Id<"messages">) => { + handleSelectConversation(convoId); + setScrollToMessageId(messageId); + }, + [handleSelectConversation], + ); + const removeMessage = useMutation({ mutationFn: useConvexMutation(api.messages.remove), }); @@ -850,6 +865,7 @@ function ChatPage() { )} activeConvoId={activeConvoId} onSelect={handleSelectConversation} + onSelectMessage={handleSelectMessage} harnessId={activeHarnessId} onClose={() => setSidebarOpen(false)} streamingConvoIds={chatStream.streamingConvoIds} @@ -917,6 +933,8 @@ function ChatPage() { forkedAtMessageCount={activeConversation?.forkedAtMessageCount} onNavigateToConversation={handleSelectConversation} isStreaming={isActiveConvoStreaming} + scrollToMessageId={scrollToMessageId} + onClearScrollTarget={() => setScrollToMessageId(null)} /> ) : ( {text}; + + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + const parts: React.ReactNode[] = []; + let lastIndex = 0; + + let index = lowerText.indexOf(lowerQuery, lastIndex); + while (index !== -1) { + if (index > lastIndex) { + parts.push(text.slice(lastIndex, index)); + } + parts.push( + + {text.slice(index, index + query.length)} + , + ); + lastIndex = index + query.length; + index = lowerText.indexOf(lowerQuery, lastIndex); + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return <>{parts}; +} + function ChatSidebar({ conversations, activeConvoId, onSelect, + onSelectMessage, // called when user clicks a content match harnessId, onClose, streamingConvoIds, @@ -962,6 +1013,10 @@ function ChatSidebar({ }>; activeConvoId: Id<"conversations"> | null; onSelect: (id: Id<"conversations"> | null) => void; + onSelectMessage: ( + convoId: Id<"conversations">, + messageId: Id<"messages">, + ) => void; harnessId: Id<"harnesses"> | null; onClose: () => void; streamingConvoIds: Set; @@ -979,6 +1034,44 @@ function ChatSidebar({ onSelect(null); }; + const [searchQuery, setSearchQuery] = useState(""); + const [titlesExpanded, setTitlesExpanded] = useState(false); + const [contentExpanded, setContentExpanded] = useState(false); + + // consts to set initial amounts for how many search hits we show + // as well as max amounts for how many results we show after + // show more is pressed + const INITIAL_TITLE_COUNT = 10; + const INITIAL_CONTENT_COUNT = 15; + const LOAD_MORE_TITLE_COUNT = 100; + const LOAD_MORE_CONTENT_COUNT = 250; + + const titleSearch = usePaginatedQuery( + api.conversations.searchTitles, + searchQuery.length > 0 ? { query: searchQuery } : "skip", + { initialNumItems: INITIAL_TITLE_COUNT }, + ); + + const contentSearch = usePaginatedQuery( + api.conversations.searchContent, + searchQuery.length > 0 ? { query: searchQuery } : "skip", + { initialNumItems: INITIAL_CONTENT_COUNT }, + ); + + const { data: titleCount } = useQuery({ + ...convexQuery( + api.conversations.searchTitlesCount, + searchQuery.length > 0 ? { query: searchQuery } : "skip", + ), + }); + + const { data: contentCount } = useQuery({ + ...convexQuery( + api.conversations.searchContentCount, + searchQuery.length > 0 ? { query: searchQuery } : "skip", + ), + }); + const grouped = groupByDate(conversations); const [settingsOpen, setSettingsOpen] = useState(false); @@ -1014,8 +1107,160 @@ function ChatSidebar({ + {/* Add input component connected to searchQuery state */} +
+
+ + { + setSearchQuery(e.target.value); + setTitlesExpanded(false); + setContentExpanded(false); + }} + className="h-8 pl-8 text-xs" + /> +
+
+ - {conversations.length === 0 ? ( + {/* BRANCH 1: Active search — show search results */} + {searchQuery && + titleSearch.status !== "LoadingFirstPage" && + contentSearch.status !== "LoadingFirstPage" ? ( +
+ {/* --- TITLE MATCHES SECTION --- */} + {titleSearch.results.length > 0 && ( +
+
+

+ Conversations +

+ {titlesExpanded ? ( + + ) : (titleCount ?? 0) > INITIAL_TITLE_COUNT ? ( + + ) : null} +
+ {(titlesExpanded + ? titleSearch.results + : titleSearch.results.slice(0, INITIAL_TITLE_COUNT) + ).map((convo) => ( + + ))} +
+ )} + + {/* --- CONTENT MATCHES SECTION --- */} + {contentSearch.results.length > 0 && ( +
+
+

+ Messages +

+ {contentExpanded ? ( + + ) : (contentCount ?? 0) > INITIAL_CONTENT_COUNT ? ( + + ) : null} +
+
+ {(contentExpanded + ? contentSearch.results + : contentSearch.results.slice(0, INITIAL_CONTENT_COUNT) + ).map((match) => ( + + ))} +
+
+ )} + + {/* --- NO RESULTS --- */} + {titleSearch.results.length === 0 && + contentSearch.results.length === 0 && ( +

+ No results found +

+ )} +
+ ) : /* BRANCH 2 & 3: Normal mode */ + conversations.length === 0 ? (

No conversations yet

@@ -1109,7 +1354,7 @@ function ChatSidebar({ asChild > - + Manage Harnesses @@ -1478,6 +1723,8 @@ function ChatMessages({ forkedAtMessageCount, onNavigateToConversation, isStreaming, + scrollToMessageId, + onClearScrollTarget, }: { conversationId: Id<"conversations">; messages: Array<{ @@ -1546,10 +1793,13 @@ function ChatMessages({ forkedAtMessageCount?: number; onNavigateToConversation: (convoId: Id<"conversations"> | null) => void; isStreaming: boolean; + scrollToMessageId: Id<"messages"> | null; + onClearScrollTarget: () => void; }) { const scrollRef = useRef(null); const userHasScrolledUp = useRef(false); const isAutoScrolling = useRef(false); + const highlightTimeoutRef = useRef>(null); // Track user scroll position to avoid hijacking scroll during streaming useEffect(() => { @@ -1663,6 +1913,39 @@ function ChatMessages({ } }, [messages, streamingContent, streamingReasoning]); + useEffect(() => { + if (!scrollToMessageId || !messages?.length) return; + + const el = document.querySelector( + `[data-message-id="${scrollToMessageId}"]`, + ); + if (!el) return; + + // Clear any previous highlight timeout + if (highlightTimeoutRef.current) clearTimeout(highlightTimeoutRef.current); + + el.scrollIntoView({ behavior: "smooth", block: "center" }); + // Add ring + yellow highlight + el.classList.add( + "ring-2", + "ring-primary", + "ring-offset-2", + "highlight-fade", + ); + + highlightTimeoutRef.current = setTimeout(() => { + el.classList.remove( + "ring-2", + "ring-primary", + "ring-offset-2", + "highlight-fade", + ); + highlightTimeoutRef.current = null; + }, 3000); + + onClearScrollTarget(); + }, [scrollToMessageId, messages, onClearScrollTarget]); + if (messages.length === 0 && !isActivelyStreaming) { return (
@@ -1711,6 +1994,7 @@ function ChatMessages({ return ( (null); const startEditing = () => { setUrlDraft(server.url); + setUrlError(""); setEditingUrl(true); // Focus after React renders the input setTimeout(() => inputRef.current?.focus(), 0); @@ -335,13 +349,20 @@ function McpServerRow({ const commitUrl = () => { const trimmed = urlDraft.trim(); if (trimmed && trimmed !== server.url) { + const error = validateMcpUrl(trimmed); + if (error) { + setUrlError(error); + return; + } onUpdate({ ...server, url: trimmed }); } + setUrlError(""); setEditingUrl(false); }; const cancelEdit = () => { setUrlDraft(server.url); + setUrlError(""); setEditingUrl(false); }; @@ -363,15 +384,23 @@ function McpServerRow({

{server.name}

{editingUrl ? ( -
- setUrlDraft(e.target.value)} - onBlur={commitUrl} - onKeyDown={handleKeyDown} - className="h-6 text-[11px]" - /> +
+
+ { + setUrlDraft(e.target.value); + setUrlError(""); + }} + onBlur={commitUrl} + onKeyDown={handleKeyDown} + className="h-6 text-[11px]" + /> +
+ {urlError && ( +

{urlError}

+ )}
) : (
diff --git a/apps/web/src/routes/onboarding.tsx b/apps/web/src/routes/onboarding.tsx index 544129d..74ce2f6 100644 --- a/apps/web/src/routes/onboarding.tsx +++ b/apps/web/src/routes/onboarding.tsx @@ -506,16 +506,34 @@ function AddMcpServerForm({ const [authToken, setAuthToken] = useState(""); const [showToken, setShowToken] = useState(false); + const [urlError, setUrlError] = useState(""); + const reset = () => { setName(""); setUrl(""); setAuthType("none"); setAuthToken(""); setShowToken(false); + setUrlError(""); }; const handleSubmit = () => { if (!name.trim() || !url.trim()) return; + if (/\s/.test(url.trim())) { + setUrlError("URL must not contain spaces"); + return; + } + try { + const parsed = new URL(url.trim()); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + setUrlError("URL must start with http:// or https://"); + return; + } + } catch { + setUrlError("Please enter a valid URL"); + return; + } + setUrlError(""); onAdd({ name: name.trim(), url: url.trim(), @@ -586,10 +604,16 @@ function AddMcpServerForm({ setUrl(e.target.value)} + onChange={(e) => { + setUrl(e.target.value); + if (urlError) setUrlError(""); + }} placeholder="https://mcp.example.com/sse" - className="text-xs" + className={`text-xs ${urlError ? "border-red-500" : ""}`} /> + {urlError && ( +

{urlError}

+ )}
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 260ece6..fc013c6 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -254,3 +254,14 @@ code { background: transparent; padding: 0; } + +/* Search result highlight animation */ +@keyframes highlight-fade { + 0% { background-color: rgba(250, 204, 21, 0.4); } + 50% { background-color: rgba(250, 204, 21, 0.35); } + 100% { background-color: transparent; } +} + +.highlight-fade { + animation: highlight-fade 3s ease-out forwards; +} \ No newline at end of file diff --git a/packages/convex-backend/convex/conversations.ts b/packages/convex-backend/convex/conversations.ts index 94fbbf6..865a0a2 100644 --- a/packages/convex-backend/convex/conversations.ts +++ b/packages/convex-backend/convex/conversations.ts @@ -1,5 +1,7 @@ import { v } from "convex/values"; +import { Id } from "./_generated/dataModel" import { mutation, query } from "./_generated/server"; +import { paginationOptsValidator } from "convex/server"; export const list = query({ handler: async (ctx) => { @@ -203,3 +205,118 @@ export const remove = mutation({ await ctx.db.delete(args.id); }, }); + +export const searchTitles = query({ + args: { query: v.string(), paginationOpts: paginationOptsValidator }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) + return { page: [], isDone: true, continueCursor: "" }; + + return await ctx.db + .query("conversations") + .withSearchIndex("search_title", (q) => + q.search("title", args.query).eq("userId", identity.subject) + ) + .paginate(args.paginationOpts); + }, +}); + +export const searchContent = query({ + args: { query: v.string(), paginationOpts: paginationOptsValidator }, + handler: async (ctx, args ) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) + return { page: [], isDone: true, continueCursor: ""}; + + const result = await ctx.db + .query("messages") + .withSearchIndex("search_content", (q) => + q.search("content", args.query).eq("userId", identity.subject) + ) + .paginate(args.paginationOpts); + + // Pre-fetch all referenced conversations in parallel to avoid N+1 + const uniqueConvoIds = [...new Set(result.page.map((m) => m.conversationId))]; + const convos = await Promise.all(uniqueConvoIds.map((id) => ctx.db.get(id))); + const convoMap = new Map( + convos.filter((c): c is NonNullable => c !== null).map((c) => [c._id, c]), + ); + + // Enrich each message with snippet + convo title + // make sure it has an annotated type so convex doesn't infer the paginate type + const enrichedPage: { + messageId: Id<"messages">; + conversationId: Id<"conversations">; + conversationTitle: string; + role: string; + snippet: string; + }[] = []; + for (const msg of result.page) { + const convo = convoMap.get(msg.conversationId); + if (!convo || convo.userId !== identity.subject) continue + + const lowerContent = msg.content.toLowerCase(); + const lowerQuery = args.query.toLowerCase(); + const matchIndex = lowerContent.indexOf(lowerQuery); + + let snippet: string; + if (matchIndex !== -1) { + const start = Math.max(0, matchIndex - 40); + const end = Math.min(msg.content.length, matchIndex + args.query.length + 40); + snippet = (start > 0 ? "..." : "") + + msg.content.slice(start, end) + + (end < msg.content.length ? "..." : ""); + } else { + snippet = msg.content.slice(0, 80) + (msg.content.length > 80 ? "..." : ""); + } + + enrichedPage.push({ + messageId: msg._id, + conversationId: msg.conversationId, + conversationTitle: convo.title, + role: msg.role, + snippet, + }); + } + + return { + ...result, + page: enrichedPage, + }; + }, +}); + +export const searchTitlesCount = query({ + args: { query: v.string() }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) return 0; + + const results = await ctx.db + .query("conversations") + .withSearchIndex("search_title", (q) => + q.search("title", args.query).eq("userId", identity.subject) + ) + .collect(); + return results.length; + }, +}); + +export const searchContentCount = query({ + args: { query: v.string() }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) return 0; + + // Filter at the index level using userId, cap to avoid read limits + const results = await ctx.db + .query("messages") + .withSearchIndex("search_content", (q) => + q.search("content", args.query).eq("userId", identity.subject) + ) + .take(1000); + + return results.length; + }, +}); \ No newline at end of file diff --git a/packages/convex-backend/convex/messages.ts b/packages/convex-backend/convex/messages.ts index 9ec2784..b12ec3a 100644 --- a/packages/convex-backend/convex/messages.ts +++ b/packages/convex-backend/convex/messages.ts @@ -33,6 +33,7 @@ export const send = mutation({ } const id = await ctx.db.insert("messages", { conversationId: args.conversationId, + userId: identity.subject, role: args.role, content: args.content, }); @@ -120,6 +121,7 @@ export const saveInterruptedMessage = mutation({ await ctx.db.insert("messages", { conversationId: args.conversationId, + userId: identity.subject, role: "assistant", content: args.content, interrupted: true, @@ -191,6 +193,7 @@ export const saveAssistantMessage = internalMutation({ await ctx.db.insert("messages", { conversationId: args.conversationId, + userId: convo.userId, role: "assistant", content: args.content, ...(args.reasoning ? { reasoning: args.reasoning } : {}), diff --git a/packages/convex-backend/convex/schema.ts b/packages/convex-backend/convex/schema.ts index 8a3b5c7..6cb8fa0 100644 --- a/packages/convex-backend/convex/schema.ts +++ b/packages/convex-backend/convex/schema.ts @@ -35,10 +35,15 @@ export default defineSchema({ editParentMessageCount: v.optional(v.number()), }) .index("by_user", ["userId"]) - .index("by_user_last_message", ["userId", "lastMessageAt"]), + .index("by_user_last_message", ["userId", "lastMessageAt"]) + .searchIndex("search_title", { + searchField: "title", + filterFields: ["userId"], + }), messages: defineTable({ conversationId: v.id("conversations"), + userId: v.optional(v.string()), role: v.union(v.literal("user"), v.literal("assistant")), content: v.string(), reasoning: v.optional(v.string()), @@ -78,7 +83,13 @@ export default defineSchema({ ), model: v.optional(v.string()), interrupted: v.optional(v.boolean()), - }).index("by_conversation", ["conversationId"]), + }) + .index("by_conversation", ["conversationId"]) + .searchIndex("search_content", { + searchField: "content", + filterFields: ["conversationId", "userId"] + }), + mcpOAuthTokens: defineTable({ userId: v.string(),