From 0d86e593cb6a04233c7cd6ff5314ece8e46e3f49 Mon Sep 17 00:00:00 2001 From: jon3350 Date: Sun, 15 Mar 2026 14:55:23 -0400 Subject: [PATCH 1/7] Added search bar in chatsidebar to search for keywords in title and message content. Updated database schema to index chat titles and chat messages. Changed managed harness icon --- apps/web/src/routes/chat/index.tsx | 186 +++++++++++++++++- .../convex-backend/convex/conversations.ts | 59 ++++++ packages/convex-backend/convex/schema.ts | 14 +- 3 files changed, 255 insertions(+), 4 deletions(-) diff --git a/apps/web/src/routes/chat/index.tsx b/apps/web/src/routes/chat/index.tsx index 3516d11..6258ce8 100644 --- a/apps/web/src/routes/chat/index.tsx +++ b/apps/web/src/routes/chat/index.tsx @@ -23,7 +23,9 @@ import { PanelLeftClose, PanelLeftOpen, Plus, + Search, // Icon for search Settings, + SlidersHorizontal, Sparkles, Square, Trash2, @@ -41,6 +43,7 @@ import { useState, } from "react"; import toast from "react-hot-toast"; +import { Input } from "../../components/ui/input"; // reuse input from components import { HarnessMark } from "../../components/harness-mark"; import { MarkdownMessage } from "../../components/markdown-message"; import { @@ -693,6 +696,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), }); @@ -751,6 +765,7 @@ function ChatPage() { conversations={conversations ?? []} activeConvoId={activeConvoId} onSelect={handleSelectConversation} + onSelectMessage={handleSelectMessage} harnessId={activeHarnessId} onClose={() => setSidebarOpen(false)} streamingConvoIds={chatStream.streamingConvoIds} @@ -796,6 +811,8 @@ function ChatPage() { } onRegenerate={handleRegenerate} 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, @@ -841,6 +891,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; @@ -858,8 +912,29 @@ function ChatSidebar({ onSelect(null); }; + const [searchQuery, setSearchQuery] = useState(""); + + // const filtered = conversations.filter((c) => + // c.title.toLowerCase().includes(searchQuery.toLowerCase()) + // ); + // const grouped = groupByDate(filtered) + + // conditionally query if there's actual text in search bar + const searchResults = useQuery( + convexQuery( + api.conversations.search, + searchQuery.length > 0 ? { query: searchQuery } : "skip", + ), + ); + const grouped = groupByDate(conversations); + // const displayConversations = searchQuery + // ? (searchResults.data ?? []) + // : conversations; + + // const grouped = groupByDate(displayConversations) + const [settingsOpen, setSettingsOpen] = useState(false); return ( @@ -893,8 +968,94 @@ function ChatSidebar({ + {/* Add input component connected to searchQuery state */} +
+
+ + setSearchQuery(e.target.value)} + className="h-8 pl-8 text-xs" + /> +
+
+ - {conversations.length === 0 ? ( + {/* BRANCH 1: Active search — show search results */} + {searchQuery && searchResults.data ? ( +
+ {/* --- TITLE MATCHES SECTION --- */} + {searchResults.data.titleMatches.length > 0 && ( +
+ {/* Section header — same style as "Today", "Yesterday" etc. */} +

+ Conversations +

+ + {searchResults.data.titleMatches.map((convo) => ( + + ))} +
+ )} + + {/* --- CONTENT MATCHES SECTION --- */} + {searchResults.data.contentMatches.length > 0 && ( +
+

+ Messages +

+ + {searchResults.data.contentMatches.map((match) => ( + + ))} +
+ )} + + {/* --- NO RESULTS --- */} + {searchResults.data.titleMatches.length === 0 && + searchResults.data.contentMatches.length === 0 && ( +

+ No results found +

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

No conversations yet

@@ -988,7 +1149,7 @@ function ChatSidebar({ asChild > - + Manage Harnesses @@ -1344,6 +1505,8 @@ function ChatMessages({ displayMode, onRegenerate, isStreaming, + scrollToMessageId, + onClearScrollTarget, }: { conversationId: Id<"conversations">; messages: Array<{ @@ -1388,6 +1551,8 @@ function ChatMessages({ history: Array<{ role: string; content: string }>, ) => void; isStreaming: boolean; + scrollToMessageId: Id<"messages"> | null; + onClearScrollTarget: () => void; }) { const scrollRef = useRef(null); const userHasScrolledUp = useRef(false); @@ -1452,6 +1617,22 @@ function ChatMessages({ } }, [messages, streamingContent, streamingReasoning]); + useEffect(() => { + if (!scrollToMessageId || !messages?.length) return; + + const el = document.querySelector( + `[data-message-id="${scrollToMessageId}"]`, + ); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + el.classList.add("ring-2", "ring-primary", "ring-offset-2"); + setTimeout(() => { + el.classList.remove("ring-2", "ring-primary", "ring-offset-2"); + }, 2000); + onClearScrollTarget(); + } + }, [scrollToMessageId, messages, onClearScrollTarget]); + if (messages.length === 0 && !isActivelyStreaming) { return (
@@ -1471,6 +1652,7 @@ function ChatMessages({ return ( { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) return { titleMatches: [], contentMatches: [] }; + + // 1. Search conversation titles + const titleMatches = await ctx.db + .query("conversations") + .withSearchIndex("search_title", (q) => + q.search("title", args.query).eq("userId", identity.subject) + ) + .take(10); + + // 2. Search message content + const messageHits = await ctx.db + .query("messages") + .withSearchIndex("search_content", (q) => + q.search("content", args.query) + ) + .take(30); + + // 3. Build content matches with snippets + const contentMatches = []; + for (const msg of messageHits) { + const convo = await ctx.db.get(msg.conversationId); + if (!convo || convo.userId !== identity.subject) continue; + + // Extract a snippet around the first occurrence of the query + 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 { + // Full-text search matched but exact substring didn't + // (e.g. different word forms) — just take the beginning + snippet = msg.content.slice(0, 80) + (msg.content.length > 80 ? "..." : ""); + } + + contentMatches.push({ + messageId: msg._id, + conversationId: msg.conversationId, + conversationTitle: convo.title, + role: msg.role, + snippet, + }); + } + + return { titleMatches, contentMatches }; + }, +}); \ No newline at end of file diff --git a/packages/convex-backend/convex/schema.ts b/packages/convex-backend/convex/schema.ts index ff78ec2..928c9f5 100644 --- a/packages/convex-backend/convex/schema.ts +++ b/packages/convex-backend/convex/schema.ts @@ -31,7 +31,11 @@ export default defineSchema({ lastMessageAt: 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"), @@ -74,7 +78,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"] + }), + mcpOAuthTokens: defineTable({ userId: v.string(), From 21fcb1a45c7575940ea8033a623fdde2d6c7ae0a Mon Sep 17 00:00:00 2001 From: jon3350 Date: Sun, 15 Mar 2026 15:02:10 -0400 Subject: [PATCH 2/7] minor change. see last commit message --- apps/web/src/routes/chat/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/routes/chat/index.tsx b/apps/web/src/routes/chat/index.tsx index 6258ce8..722940e 100644 --- a/apps/web/src/routes/chat/index.tsx +++ b/apps/web/src/routes/chat/index.tsx @@ -43,7 +43,6 @@ import { useState, } from "react"; import toast from "react-hot-toast"; -import { Input } from "../../components/ui/input"; // reuse input from components import { HarnessMark } from "../../components/harness-mark"; import { MarkdownMessage } from "../../components/markdown-message"; import { @@ -77,6 +76,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, From da4a0f9fb894ad0de4f179026712d312e43428dd Mon Sep 17 00:00:00 2001 From: jon3350 Date: Mon, 16 Mar 2026 00:33:10 -0400 Subject: [PATCH 3/7] Added show more/show less feature when searching. Uses usePaginatedQuery (may not be best approach since we only load more once and usePaginatedQuery comes from convex/react while useQuery comes from @tanstack/react-query. Other changes are added yellow highlighting when jumping to a message from search --- apps/web/src/routes/chat/index.tsx | 204 ++++++++++++------ apps/web/src/styles.css | 11 + .../convex-backend/convex/conversations.ts | 57 +++-- 3 files changed, 191 insertions(+), 81 deletions(-) diff --git a/apps/web/src/routes/chat/index.tsx b/apps/web/src/routes/chat/index.tsx index 722940e..be6c5b4 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, @@ -913,28 +914,28 @@ function ChatSidebar({ }; const [searchQuery, setSearchQuery] = useState(""); + const [titlesExpanded, setTitlesExpanded] = useState(false); + const [contentExpanded, setContentExpanded] = useState(false); + + const INITIAL_TITLE_COUNT = 5; + const INITIAL_CONTENT_COUNT = 15; + const LOAD_MORE_TITLE_COUNT = 50; + const LOAD_MORE_CONTENT_COUNT = 100; + + const titleSearch = usePaginatedQuery( + api.conversations.searchTitles, + searchQuery.length > 0 ? { query: searchQuery } : "skip", + { initialNumItems: INITIAL_TITLE_COUNT }, + ); - // const filtered = conversations.filter((c) => - // c.title.toLowerCase().includes(searchQuery.toLowerCase()) - // ); - // const grouped = groupByDate(filtered) - - // conditionally query if there's actual text in search bar - const searchResults = useQuery( - convexQuery( - api.conversations.search, - searchQuery.length > 0 ? { query: searchQuery } : "skip", - ), + const contentSearch = usePaginatedQuery( + api.conversations.searchContent, + searchQuery.length > 0 ? { query: searchQuery } : "skip", + { initialNumItems: INITIAL_CONTENT_COUNT }, ); const grouped = groupByDate(conversations); - // const displayConversations = searchQuery - // ? (searchResults.data ?? []) - // : conversations; - - // const grouped = groupByDate(displayConversations) - const [settingsOpen, setSettingsOpen] = useState(false); return ( @@ -978,7 +979,11 @@ function ChatSidebar({ setSearchQuery(e.target.value)} + onChange={(e) => { + setSearchQuery(e.target.value); + setTitlesExpanded(false); + setContentExpanded(false); + }} className="h-8 pl-8 text-xs" />
@@ -986,17 +991,45 @@ function ChatSidebar({ {/* BRANCH 1: Active search — show search results */} - {searchQuery && searchResults.data ? ( -
+ {searchQuery && + (titleSearch.status !== "LoadingFirstPage" || + contentSearch.status !== "LoadingFirstPage") ? ( +
{/* --- TITLE MATCHES SECTION --- */} - {searchResults.data.titleMatches.length > 0 && ( -
- {/* Section header — same style as "Today", "Yesterday" etc. */} -

- Conversations -

- - {searchResults.data.titleMatches.map((convo) => ( + {titleSearch.results.length > 0 && ( +
+
+

+ Conversations +

+ {titlesExpanded ? ( + + ) : titleSearch.results.length > INITIAL_TITLE_COUNT || + titleSearch.status === "CanLoadMore" ? ( + + ) : null} +
+ {(titlesExpanded + ? titleSearch.results + : titleSearch.results.slice(0, INITIAL_TITLE_COUNT) + ).map((convo) => ( ))}
)} {/* --- CONTENT MATCHES SECTION --- */} - {searchResults.data.contentMatches.length > 0 && ( -
-

- Messages -

- - {searchResults.data.contentMatches.map((match) => ( - - ))} + {contentSearch.results.length > 0 && ( +
+
+

+ Messages +

+ {contentExpanded ? ( + + ) : contentSearch.results.length > INITIAL_CONTENT_COUNT || + contentSearch.status === "CanLoadMore" ? ( + + ) : null} +
+
+ {(contentExpanded + ? contentSearch.results + : contentSearch.results.slice(0, INITIAL_CONTENT_COUNT) + ).map((match) => ( + + ))} +
)} {/* --- NO RESULTS --- */} - {searchResults.data.titleMatches.length === 0 && - searchResults.data.contentMatches.length === 0 && ( + {titleSearch.results.length === 0 && + contentSearch.results.length === 0 && (

No results found

@@ -1625,10 +1694,23 @@ function ChatMessages({ ); if (el) { el.scrollIntoView({ behavior: "smooth", block: "center" }); - el.classList.add("ring-2", "ring-primary", "ring-offset-2"); + // Add ring + yellow highlight + el.classList.add( + "ring-2", + "ring-primary", + "ring-offset-2", + "highlight-fade", + ); + setTimeout(() => { - el.classList.remove("ring-2", "ring-primary", "ring-offset-2"); - }, 2000); + el.classList.remove( + "ring-2", + "ring-primary", + "ring-offset-2", + "highlight-fade", + ); + }, 3000); + onClearScrollTarget(); } }, [scrollToMessageId, messages, onClearScrollTarget]); 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 c2c5e22..8eb624b 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) => { @@ -84,35 +86,49 @@ export const remove = mutation({ }, }); -export const search = query({ - args: { query: v.string() }, +export const searchTitles = query({ + args: { query: v.string(), paginationOpts: paginationOptsValidator }, handler: async (ctx, args) => { const identity = await ctx.auth.getUserIdentity(); - if (!identity) return { titleMatches: [], contentMatches: [] }; - - // 1. Search conversation titles - const titleMatches = await ctx.db + 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) ) - .take(10); + .paginate(args.paginationOpts); + }, +}); - // 2. Search message content - const messageHits = await ctx.db +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) ) - .take(30); - - // 3. Build content matches with snippets - const contentMatches = []; - for (const msg of messageHits) { + .paginate(args.paginationOpts); + + // 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 = await ctx.db.get(msg.conversationId); - if (!convo || convo.userId !== identity.subject) continue; + if (!convo || convo.userId !== identity.subject) continue - // Extract a snippet around the first occurrence of the query const lowerContent = msg.content.toLowerCase(); const lowerQuery = args.query.toLowerCase(); const matchIndex = lowerContent.indexOf(lowerQuery); @@ -125,12 +141,10 @@ export const search = query({ + msg.content.slice(start, end) + (end < msg.content.length ? "..." : ""); } else { - // Full-text search matched but exact substring didn't - // (e.g. different word forms) — just take the beginning snippet = msg.content.slice(0, 80) + (msg.content.length > 80 ? "..." : ""); } - contentMatches.push({ + enrichedPage.push({ messageId: msg._id, conversationId: msg.conversationId, conversationTitle: convo.title, @@ -139,6 +153,9 @@ export const search = query({ }); } - return { titleMatches, contentMatches }; + return { + ...result, + page: enrichedPage, + }; }, }); \ No newline at end of file From ee6c7e608736257278993fed4af95746194129d3 Mon Sep 17 00:00:00 2001 From: jon3350 Date: Tue, 17 Mar 2026 20:56:26 -0400 Subject: [PATCH 4/7] fixed bug where show more button appears when inital number of queries matches total queries. This bug occurred because convex doesn't know if it's inital fetch is everyting. Thus, functions that get the total query count were added in conversations.ts --- apps/web/src/routes/chat/index.tsx | 29 +++++++++--- .../convex-backend/convex/conversations.ts | 44 +++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/apps/web/src/routes/chat/index.tsx b/apps/web/src/routes/chat/index.tsx index be6c5b4..3fb0513 100644 --- a/apps/web/src/routes/chat/index.tsx +++ b/apps/web/src/routes/chat/index.tsx @@ -917,10 +917,13 @@ function ChatSidebar({ const [titlesExpanded, setTitlesExpanded] = useState(false); const [contentExpanded, setContentExpanded] = useState(false); - const INITIAL_TITLE_COUNT = 5; + // 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 = 50; - const LOAD_MORE_CONTENT_COUNT = 100; + const LOAD_MORE_TITLE_COUNT = 100; + const LOAD_MORE_CONTENT_COUNT = 250; const titleSearch = usePaginatedQuery( api.conversations.searchTitles, @@ -934,6 +937,20 @@ function ChatSidebar({ { 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); @@ -1010,8 +1027,7 @@ function ChatSidebar({ > Show Less - ) : titleSearch.results.length > INITIAL_TITLE_COUNT || - titleSearch.status === "CanLoadMore" ? ( + ) : (titleCount ?? 0) > INITIAL_TITLE_COUNT ? ( - ) : contentSearch.results.length > INITIAL_CONTENT_COUNT || - contentSearch.status === "CanLoadMore" ? ( + ) : (contentCount ?? 0) > INITIAL_CONTENT_COUNT ? (
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}

+ )}
From a9c11bd5d257fac524eedade64d17287d0f38985 Mon Sep 17 00:00:00 2001 From: jon3350 Date: Tue, 17 Mar 2026 22:38:07 -0400 Subject: [PATCH 6/7] fixed comments from pull request. Conversations uses take instead of pagination. Index still paginates for now though. Stress testing has not been done yet --- apps/web/src/routes/chat/index.tsx | 39 +++++++------ apps/web/src/routes/harnesses/$harnessId.tsx | 57 ++++++++++++------- .../convex-backend/convex/conversations.ts | 29 +++++----- packages/convex-backend/convex/messages.ts | 3 + packages/convex-backend/convex/schema.ts | 3 +- 5 files changed, 76 insertions(+), 55 deletions(-) diff --git a/apps/web/src/routes/chat/index.tsx b/apps/web/src/routes/chat/index.tsx index 3fb0513..a262746 100644 --- a/apps/web/src/routes/chat/index.tsx +++ b/apps/web/src/routes/chat/index.tsx @@ -1009,8 +1009,8 @@ function ChatSidebar({ {/* BRANCH 1: Active search — show search results */} {searchQuery && - (titleSearch.status !== "LoadingFirstPage" || - contentSearch.status !== "LoadingFirstPage") ? ( + titleSearch.status !== "LoadingFirstPage" && + contentSearch.status !== "LoadingFirstPage" ? (
{/* --- TITLE MATCHES SECTION --- */} {titleSearch.results.length > 0 && ( @@ -1641,6 +1641,7 @@ function ChatMessages({ 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(() => { @@ -1707,27 +1708,31 @@ function ChatMessages({ const el = document.querySelector( `[data-message-id="${scrollToMessageId}"]`, ); - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "center" }); - // Add ring + yellow highlight - el.classList.add( + 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); - setTimeout(() => { - el.classList.remove( - "ring-2", - "ring-primary", - "ring-offset-2", - "highlight-fade", - ); - }, 3000); - - onClearScrollTarget(); - } + onClearScrollTarget(); }, [scrollToMessageId, messages, onClearScrollTarget]); if (messages.length === 0 && !isActivelyStreaming) { diff --git a/apps/web/src/routes/harnesses/$harnessId.tsx b/apps/web/src/routes/harnesses/$harnessId.tsx index 21879a9..f6cd9f5 100644 --- a/apps/web/src/routes/harnesses/$harnessId.tsx +++ b/apps/web/src/routes/harnesses/$harnessId.tsx @@ -312,6 +312,18 @@ function HarnessEditPage() { ); } +function validateMcpUrl(url: string): string | null { + if (/\s/.test(url)) return "URL must not contain spaces"; + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") + return "URL must start with http:// or https://"; + } catch { + return "Please enter a valid URL"; + } + return null; +} + function McpServerRow({ server, onRemove, @@ -323,10 +335,12 @@ function McpServerRow({ }) { const [editingUrl, setEditingUrl] = useState(false); const [urlDraft, setUrlDraft] = useState(server.url); + const [urlError, setUrlError] = useState(""); const inputRef = useRef(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,18 @@ 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}

}
) : (
- {urlError &&

{urlError}

} + {urlError && ( +

{urlError}

+ )}
) : (