diff --git a/.changeset/fn-7631-chat-content-search.md b/.changeset/fn-7631-chat-content-search.md new file mode 100644 index 0000000000..657a0c88d9 --- /dev/null +++ b/.changeset/fn-7631-chat-content-search.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Chat search now matches message content, with a "Search in title only" toggle. +category: feature +dev: ChatStore.searchSessionsByMessageContent (parameterized LIKE ... ESCAPE); GET /chat/sessions gains q/titleOnly params; useChat exposes searchInTitleOnly. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 5bf32b36ac..67abda68f9 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -511,6 +511,8 @@ Chat view provides project-scoped conversations with agents. - Editing is truncate-and-resend, not append: the edited message and everything after it are removed first, then the edited text is sent as a new turn through the normal streaming path — so the resulting transcript looks the same as if you had deleted the old messages and typed the correction from scratch, but in one action. - In task-detail **Planner Chat**, editing an earlier message resumes the conversation from that point exactly as in direct chat. If the discarded turns already triggered task-scoped side effects — a steering comment added via the planner steering tool, or a refinement task created via the planner refinement tool — those **are not reverted**: the steering comment stays on the task and the refinement task stays open, because undoing either is destructive and out of scope for a chat edit. After a successful edit-and-resend, task detail refreshes automatically (so Activity/steering reflects reality), and if the discarded range held one of those confirmations you get an informational toast noting that the earlier change was not undone. + +- The Chat sidebar search box matches message **content** by default, not just conversation title/agent — so you can find a past conversation by remembering something that was said in it, even if the title doesn't contain the query. Content matches run as a debounced server-side lookup and are merged with local title/agent matches into the same results list. A **Search in title only** toggle beside the search box restores the original title/agent-only, fully client-side filtering behavior. The toggle is available on both desktop and mobile Chat sidebars and does not appear in the Rooms scope (Rooms already hides search/list). When a session is shown because of a content match, its row shows a subtle "Matched: ..." preview of the matching message so it's clear why the conversation appeared. Task-planner sessions stay excluded from content matches by the same common-feed guard used for the normal session list (see **Settings → Project General → Show task chats in common Chat feed**). ![Chat view](./screenshots/chat-view.png) diff --git a/packages/core/src/__tests__/chat-store.content-search.test.ts b/packages/core/src/__tests__/chat-store.content-search.test.ts new file mode 100644 index 0000000000..5c73f19234 --- /dev/null +++ b/packages/core/src/__tests__/chat-store.content-search.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest"; +import { ChatStore } from "../chat-store.js"; +import { Database } from "../db.js"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { rm } from "node:fs/promises"; + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "kb-chat-store-content-search-test-")); +} + +/* +FNXC:ChatSearch 2026-07-07-00:00: +Covers ChatStore.searchSessionsByMessageContent: matches driven purely by message content +(not title), dedup to one session per match, and LIKE-escape correctness for literal `%`/`_`. +*/ +describe("ChatStore.searchSessionsByMessageContent", () => { + let tmpDir: string; + let fusionDir: string; + let db: Database; + let store: ChatStore; + + beforeAll(() => { + tmpDir = makeTmpDir(); + fusionDir = join(tmpDir, ".fusion"); + db = new Database(fusionDir, { inMemory: true }); + db.init(); + store = new ChatStore(fusionDir, db); + }); + + beforeEach(() => { + db.exec(` + DELETE FROM chat_room_messages; + DELETE FROM chat_room_members; + DELETE FROM chat_rooms; + DELETE FROM chat_messages; + DELETE FROM chat_sessions; + `); + store.removeAllListeners(); + }); + + afterAll(async () => { + try { + db.close(); + } catch { + // already closed + } + await rm(tmpDir, { recursive: true, force: true }); + }); + + function createTestSession(title: string | null = "Untitled") { + return store.createSession({ + agentId: "agent-001", + title, + projectId: null, + modelProvider: null, + modelId: null, + }); + } + + it("matches a session by message content when the title does not contain the query", () => { + const session = createTestSession("Weekend plans"); + store.addMessage(session.id, { role: "user", content: "Let's talk about the quarterly roadmap" }); + + const result = store.searchSessionsByMessageContent("roadmap", [session.id]); + + expect(result.has(session.id)).toBe(true); + expect(result.get(session.id)).toBe("Let's talk about the quarterly roadmap"); + }); + + it("matches when the query appears only in a user message", () => { + const session = createTestSession(); + store.addMessage(session.id, { role: "user", content: "Remember the unicorn codename" }); + store.addMessage(session.id, { role: "assistant", content: "Sure, noted." }); + + const result = store.searchSessionsByMessageContent("unicorn", [session.id]); + + expect(result.get(session.id)).toBe("Remember the unicorn codename"); + }); + + it("matches when the query appears only in an assistant message", () => { + const session = createTestSession(); + store.addMessage(session.id, { role: "user", content: "How do I deploy?" }); + store.addMessage(session.id, { role: "assistant", content: "Use the falcon deploy script" }); + + const result = store.searchSessionsByMessageContent("falcon", [session.id]); + + expect(result.get(session.id)).toBe("Use the falcon deploy script"); + }); + + it("deduplicates to a single entry per session with multiple matching messages", () => { + const session = createTestSession(); + store.addMessage(session.id, { role: "user", content: "first mention of gizmo" }); + store.addMessage(session.id, { role: "assistant", content: "second mention of gizmo here" }); + store.addMessage(session.id, { role: "user", content: "third gizmo reference, most recent" }); + + const result = store.searchSessionsByMessageContent("gizmo", [session.id]); + + expect(result.size).toBe(1); + expect(result.get(session.id)).toBe("third gizmo reference, most recent"); + }); + + it("returns an empty map when there is no match", () => { + const session = createTestSession(); + store.addMessage(session.id, { role: "user", content: "totally unrelated content" }); + + const result = store.searchSessionsByMessageContent("nonexistent-term", [session.id]); + + expect(result.size).toBe(0); + }); + + it("returns an empty map for an empty/whitespace-only query", () => { + const session = createTestSession(); + store.addMessage(session.id, { role: "user", content: "some content" }); + + expect(store.searchSessionsByMessageContent("", [session.id]).size).toBe(0); + expect(store.searchSessionsByMessageContent(" ", [session.id]).size).toBe(0); + }); + + it("returns an empty map when sessionIds is empty", () => { + const result = store.searchSessionsByMessageContent("anything", []); + expect(result.size).toBe(0); + }); + + it("treats literal % and _ as literal characters, not SQL LIKE wildcards", () => { + const literalSession = createTestSession(); + store.addMessage(literalSession.id, { role: "user", content: "Discount is 50% off, use code A_B" }); + + const otherSession = createTestSession(); + store.addMessage(otherSession.id, { role: "user", content: "Discount is 50X off, use code AZB" }); + + // A naive unescaped LIKE '%50%%' would also match "50X" via the wildcard; escaped search must not. + const percentResult = store.searchSessionsByMessageContent("50%", [literalSession.id, otherSession.id]); + expect(percentResult.has(literalSession.id)).toBe(true); + expect(percentResult.has(otherSession.id)).toBe(false); + + // A naive unescaped LIKE '%A_B%' would also match "AZB" via the single-char wildcard. + const underscoreResult = store.searchSessionsByMessageContent("A_B", [literalSession.id, otherSession.id]); + expect(underscoreResult.has(literalSession.id)).toBe(true); + expect(underscoreResult.has(otherSession.id)).toBe(false); + }); + + it("only searches within the provided sessionIds scope", () => { + const inScope = createTestSession(); + store.addMessage(inScope.id, { role: "user", content: "shared keyword hello" }); + + const outOfScope = createTestSession(); + store.addMessage(outOfScope.id, { role: "user", content: "shared keyword hello" }); + + const result = store.searchSessionsByMessageContent("keyword", [inScope.id]); + + expect(result.size).toBe(1); + expect(result.has(inScope.id)).toBe(true); + expect(result.has(outOfScope.id)).toBe(false); + }); +}); diff --git a/packages/core/src/chat-store.ts b/packages/core/src/chat-store.ts index 1e4d59a1bd..8543777283 100644 --- a/packages/core/src/chat-store.ts +++ b/packages/core/src/chat-store.ts @@ -742,6 +742,70 @@ export class ChatStore extends EventEmitter { return Boolean(row); } + /** + * Escape a raw search term for safe use inside a SQL `LIKE ... ESCAPE '\'` pattern. + * Escapes the LIKE wildcard characters (`%`, `_`) and the escape character itself (`\`) + * so a literal user-typed `%`/`_` is matched literally instead of acting as a wildcard. + */ + private escapeLikePattern(raw: string): string { + return raw.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); + } + + /** + * Search sessions by message content (not just title/agentId). + * + * FNXC:ChatSearch 2026-07-07-00:00: + * Message content is not fully loaded client-side (only sessions + a last-message preview + * are), so "find a conversation by something that was said in it" requires a server round + * trip against chat_messages. There is no FTS table for chat_messages (see db.ts schema), so + * this uses a parameterized SQL `LIKE ... ESCAPE '\'` query — never string-concatenated — + * with `%`/`_`/`\` escaped in the search term so a literal `%` or `_` typed by the user is + * matched literally rather than acting as a wildcard (injection- and wildcard-safety). + * + * Scoped to the given session IDs (already filtered by projectId/status/agentId by the + * caller via listSessions) to keep the query bounded and avoid re-deriving scope filters + * against chat_sessions here. Deduplicates to one row per session using MAX(createdAt) so a + * session with multiple matching messages appears once, with a preview of its most recent + * matching message (truncated to ~100 chars, mirroring getLastMessageForSessions). + * + * @param query - Raw user search text (content match) + * @param sessionIds - Session IDs to search within (already scope-filtered by the caller) + * @returns Map of sessionId -> truncated preview of the most recent matching message + */ + searchSessionsByMessageContent(query: string, sessionIds: string[]): Map { + const trimmed = query.trim(); + if (!trimmed || !sessionIds || sessionIds.length === 0) { + return new Map(); + } + + const escaped = this.escapeLikePattern(trimmed); + const pattern = `%${escaped}%`; + const placeholders = sessionIds.map(() => "?").join(", "); + + // Single bounded query: find the most recent matching message per session via a + // GROUP BY + join-back, avoiding N+1 per-session queries. Ties on createdAt (common in + // fast test/bulk-insert scenarios where multiple messages share a millisecond timestamp) + // are broken by SQLite's implicit rowid, which tracks insertion order. + const rows = this.db.prepare(` + SELECT cm.* FROM chat_messages cm + INNER JOIN ( + SELECT sessionId, MAX(rowid) as maxRowid + FROM chat_messages + WHERE sessionId IN (${placeholders}) AND content LIKE ? ESCAPE '\\' + GROUP BY sessionId + ) matched ON cm.sessionId = matched.sessionId AND cm.rowid = matched.maxRowid + `).all(...sessionIds, pattern); + + const result = new Map(); + for (const row of rows as unknown as ChatMessageRow[]) { + const message = this.rowToMessage(row); + if (result.has(message.sessionId)) continue; + const content = message.content || ""; + result.set(message.sessionId, content.length > 100 ? content.slice(0, 100) + "…" : content); + } + return result; + } + /** * Delete a message by ID. * diff --git a/packages/core/src/chat-types.ts b/packages/core/src/chat-types.ts index f1d52e6085..494604a597 100644 --- a/packages/core/src/chat-types.ts +++ b/packages/core/src/chat-types.ts @@ -97,6 +97,14 @@ export type EnrichedChatSession = ChatSession & { lastMessageAt?: string; /** Whether a generation is currently in progress for this session */ isGenerating?: boolean; + /** + * FNXC:ChatSearch 2026-07-07-00:00: + * When a session is included in `GET /chat/sessions` because its message content (not + * title) matched a server-side content search, this carries a truncated preview of the + * matching message so the UI can show "why did this match" without a second round trip. + * Absent when the session was not returned via content search. + */ + matchedMessagePreview?: string; }; /** A parsed @ mention of an agent in a chat message */ diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 5318845f18..c8b79a877b 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -10019,11 +10019,30 @@ export interface ChatRoomMessageResponse { message: ChatRoomMessage; } +/** + * FNXC:ChatSearch 2026-07-07-00:00: + * `q`/`titleOnly` mirror the server's GET /chat/sessions content-search params (see + * register-chat-routes.ts). `q` triggers server-side message-content search; `titleOnly=true` + * (or omitting `q`) preserves the pre-existing client-side title/agent-only filtering. + */ +export interface FetchChatSessionsOptions { + status?: string; + q?: string; + titleOnly?: boolean; +} + /** Fetch all chat sessions for a project */ -export function fetchChatSessions(projectId?: string, status?: string): Promise { +export function fetchChatSessions( + projectId?: string, + status?: string, + options?: FetchChatSessionsOptions, +): Promise { const search = new URLSearchParams(); if (projectId) search.set("projectId", projectId); - if (status) search.set("status", status); + const resolvedStatus = options?.status ?? status; + if (resolvedStatus) search.set("status", resolvedStatus); + if (options?.q && options.q.trim()) search.set("q", options.q.trim()); + if (options?.titleOnly) search.set("titleOnly", "true"); const qs = search.toString(); return api(`/chat/sessions${qs ? `?${qs}` : ""}`); } diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index f19a90e208..d568a8b2fd 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -267,6 +267,36 @@ When the movable chat popup is resized narrow, collapse Direct/Rooms labels to i .chat-sidebar-search-container { padding: var(--space-sm); + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +/* +FNXC:ChatSearch 2026-07-07-00:00: +Compact toggle button, reusing .btn/.btn-sm so it inherits the shared button visual language +instead of one-off styling. aria-pressed drives the active/inactive look via existing token +colors (no hardcoded hex). +*/ +.chat-search-title-only-toggle { + align-self: flex-start; + font-size: 11px; + color: var(--text-dim); +} + +.chat-search-title-only-toggle[aria-pressed="true"] { + color: var(--text); + border-color: var(--accent); + background: var(--surface-hover); +} + +.chat-session-preview--matched { + color: var(--text-dim); + font-size: 11px; + font-style: italic; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .chat-sidebar-search-wrapper { diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index c8edcf528e..fc5f922361 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -494,6 +494,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout hasMoreMessages, searchQuery, setSearchQuery, + searchInTitleOnly, + setSearchInTitleOnly, filteredSessions, agentsMap: chatAgentsMap, } = useChat(projectId, addToast); @@ -2785,6 +2787,14 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout {!chatRoomsEnabled || chatScope === "direct" ? ( <> {/* Search section */} + {/* + FNXC:ChatSearch 2026-07-07-00:00: + Search now matches message content by default (server round trip), not just + title/agentId. The "Search in title only" toggle restores the prior client-only + title/agentId behavior on demand. Rendered on both desktop and mobile since this + sidebar markup is shared (mobile layout is a CSS breakpoint of the same DOM), and + only within the Direct scope — Rooms already hides search/list entirely. + */}
@@ -2797,6 +2807,17 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout data-testid="chat-search-input" />
+
{/* Session list section */}
@@ -2870,6 +2891,11 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
{session.lastMessagePreview || t("chat.noMessages", "No messages")}
+ {session.matchedMessagePreview ? ( +
+ {t("chat.matchedInMessage", "Matched: \"{{preview}}\"", { preview: session.matchedMessagePreview })} +
+ ) : null}
{sessionResolvedModel?.provider ? : null} diff --git a/packages/dashboard/app/components/__tests__/ChatView.autosize.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.autosize.test.tsx index 694987746d..4def138e6f 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.autosize.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.autosize.test.tsx @@ -83,6 +83,8 @@ const defaultChatState: UseChatReturn = { hasMoreMessages: false, searchQuery: "", setSearchQuery: vi.fn(), + searchInTitleOnly: false, + setSearchInTitleOnly: vi.fn(), filteredSessions: [sessionOne, sessionTwo], refreshSessions: vi.fn(), agentsMap: new Map(), diff --git a/packages/dashboard/app/components/__tests__/ChatView.content-search.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.content-search.test.tsx new file mode 100644 index 0000000000..9f4ad0422b --- /dev/null +++ b/packages/dashboard/app/components/__tests__/ChatView.content-search.test.tsx @@ -0,0 +1,114 @@ +/* +FNXC:ChatSearch 2026-07-07-00:00: +Covers the "Search in title only" toggle affordance: renders on desktop AND mobile chat +sidebars (shared DOM, CSS-breakpoint driven), toggling calls setSearchInTitleOnly, the toggle +does not leak into the Rooms scope, and matchedMessagePreview renders when content-mode drove +a session's inclusion. +*/ +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { ChatView } from "../ChatView"; +import { + renderWithAct, + setupMockChat, + setupMockRooms, + mockViewportMode, + activeSessionFixture, + installChatViewEnv, +} from "./ChatView.test-harness"; + +vi.mock("../../hooks/useChat"); +vi.mock("../../hooks/useChatRooms"); +vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }), + }; +}); +vi.mock("lucide-react", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual }; +}); +vi.mock("../../api", () => ({ + fetchModels: vi.fn().mockResolvedValue({ + models: [ + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 }, + { provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 }, + ], + favoriteProviders: [], + favoriteModels: [], + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", + }), + fetchAgents: vi.fn().mockResolvedValue([]), + fetchDiscoveredSkills: vi.fn().mockResolvedValue([]), + fetchTasks: vi.fn().mockResolvedValue([]), + searchFiles: vi.fn().mockResolvedValue({ files: [] }), +})); + +installChatViewEnv(); + +describe("ChatView content search toggle", () => { + it("renders the title-only toggle on the desktop sidebar and calls setSearchInTitleOnly on click", async () => { + mockViewportMode("desktop"); + const setSearchInTitleOnly = vi.fn(); + setupMockChat({ sessions: [], filteredSessions: [], searchInTitleOnly: false, setSearchInTitleOnly }); + + await renderWithAct(); + + const toggle = screen.getByTestId("chat-search-title-only-toggle"); + expect(toggle).toBeInTheDocument(); + expect(toggle).toHaveAttribute("aria-pressed", "false"); + + await userEvent.click(toggle); + expect(setSearchInTitleOnly).toHaveBeenCalledWith(true); + }); + + it("renders the title-only toggle on the mobile sidebar and calls setSearchInTitleOnly on click", async () => { + mockViewportMode("mobile"); + const setSearchInTitleOnly = vi.fn(); + setupMockChat({ sessions: [], filteredSessions: [], searchInTitleOnly: true, setSearchInTitleOnly }); + + await renderWithAct(); + + const toggle = screen.getByTestId("chat-search-title-only-toggle"); + expect(toggle).toBeInTheDocument(); + expect(toggle).toHaveAttribute("aria-pressed", "true"); + + await userEvent.click(toggle); + expect(setSearchInTitleOnly).toHaveBeenCalledWith(false); + }); + + it("does not render the toggle in Rooms scope", async () => { + setupMockChat({ sessions: [], filteredSessions: [] }); + + await renderWithAct(); + + await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms")); + + expect(screen.queryByTestId("chat-search-title-only-toggle")).toBeNull(); + }); + + it("shows matchedMessagePreview for a session included via content match", async () => { + const contentMatchedSession = { + ...activeSessionFixture, + id: "session-content-match", + title: "Weekend plans", + matchedMessagePreview: "quarterly roadmap discussion", + }; + setupMockChat({ + sessions: [contentMatchedSession], + filteredSessions: [contentMatchedSession], + searchQuery: "roadmap", + searchInTitleOnly: false, + }); + + await renderWithAct(); + + expect(screen.getByTestId("chat-session-matched-preview-session-content-match")).toHaveTextContent( + "quarterly roadmap discussion", + ); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/ChatView.draft.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.draft.test.tsx index 6183c61dfb..355468f64c 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.draft.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.draft.test.tsx @@ -91,6 +91,8 @@ const defaultChatState: UseChatReturn = { hasMoreMessages: false, searchQuery: "", setSearchQuery: vi.fn(), + searchInTitleOnly: false, + setSearchInTitleOnly: vi.fn(), filteredSessions: [sessionOne, sessionTwo], refreshSessions: vi.fn(), agentsMap: new Map(), diff --git a/packages/dashboard/app/components/__tests__/ChatView.hash-mention.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.hash-mention.test.tsx index d0ad0e01f4..ab82bd17c0 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.hash-mention.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.hash-mention.test.tsx @@ -76,6 +76,8 @@ const defaultChatState: UseChatReturn = { hasMoreMessages: false, searchQuery: "", setSearchQuery: vi.fn(), + searchInTitleOnly: false, + setSearchInTitleOnly: vi.fn(), filteredSessions: [activeSession], refreshSessions: vi.fn(), agentsMap: new Map(), diff --git a/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx index 9b7ed4a42c..be531ec401 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx @@ -74,6 +74,8 @@ const defaultChatState: UseChatReturn = { hasMoreMessages: false, searchQuery: "", setSearchQuery: vi.fn(), + searchInTitleOnly: false, + setSearchInTitleOnly: vi.fn(), filteredSessions: [], refreshSessions: vi.fn(), agentsMap: new Map([["agent-001", { id: "agent-001", name: "Alpha" }]]), diff --git a/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx index ed19525853..ae96c8ff88 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx @@ -83,6 +83,8 @@ const defaultChatState: UseChatReturn = { hasMoreMessages: false, searchQuery: "", setSearchQuery: vi.fn(), + searchInTitleOnly: false, + setSearchInTitleOnly: vi.fn(), filteredSessions: [activeSession], refreshSessions: vi.fn(), agentsMap: new Map(), diff --git a/packages/dashboard/app/components/__tests__/ChatView.scroll-to-top.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.scroll-to-top.test.tsx index 8b46cced2f..10525b8d75 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.scroll-to-top.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.scroll-to-top.test.tsx @@ -79,6 +79,8 @@ const defaultChatState: UseChatReturn = { hasMoreMessages: false, searchQuery: "", setSearchQuery: vi.fn(), + searchInTitleOnly: false, + setSearchInTitleOnly: vi.fn(), filteredSessions: [activeSession], refreshSessions: vi.fn(), agentsMap: new Map(), diff --git a/packages/dashboard/app/components/__tests__/ChatView.test-harness.tsx b/packages/dashboard/app/components/__tests__/ChatView.test-harness.tsx index afaa7d28e7..adade20445 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.test-harness.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.test-harness.tsx @@ -74,6 +74,8 @@ export const defaultChatState: UseChatReturn = { hasMoreMessages: false, searchQuery: "", setSearchQuery: vi.fn(), + searchInTitleOnly: false, + setSearchInTitleOnly: vi.fn(), filteredSessions: [], refreshSessions: vi.fn(), agentsMap: new Map(), diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index c65753d65a..f6472a0b9a 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -51,6 +51,13 @@ export interface ChatSessionInfo { cliExecutorAdapterId?: string | null; /** Native CLI session id linkage (used as the terminal attach id for resume). */ cliSessionFile?: string | null; + /** + * FNXC:ChatSearch 2026-07-07-00:00: + * Set only when this session's inclusion in `filteredSessions` (content mode) was driven by + * a server-side message-content match rather than the title/agentId filter, so the sidebar + * can show "why did this match" without a second round trip. + */ + matchedMessagePreview?: string; } // Re-export shared chat types so existing consumers (`import { ChatMessageInfo } from "../hooks/useChat"`) @@ -110,6 +117,14 @@ export interface UseChatReturn { // Search/filter searchQuery: string; setSearchQuery: (query: string) => void; + /** + * FNXC:ChatSearch 2026-07-07-00:00: + * When true, search matches only session title/agentId (the original client-side-only + * behavior). When false (default), search also matches message content via a debounced + * server round trip; matched sessions are unioned into `filteredSessions`. + */ + searchInTitleOnly: boolean; + setSearchInTitleOnly: (value: boolean) => void; filteredSessions: ChatSessionInfo[]; // Refresh @@ -336,6 +351,16 @@ export function useChat( // Search/filter const [searchQuery, setSearchQuery] = useState(""); + /* + FNXC:ChatSearch 2026-07-07-00:00: + Default is content mode (searchInTitleOnly=false): the query matches title/agentId AND + message content. The toggle flips this to the pre-existing title/agentId-only behavior. + */ + const [searchInTitleOnly, setSearchInTitleOnly] = useState(false); + const [contentMatchedPreviews, setContentMatchedPreviews] = useState>(new Map()); + // Monotonic request counter: guards against an out-of-order/superseded debounced content + // search response overwriting a newer query's results (or a toggle-to-title-only reset). + const contentSearchRequestIdRef = useRef(0); // Pagination const [hasMoreMessages, setHasMoreMessages] = useState(false); @@ -1281,14 +1306,76 @@ export function useChat( [activeSession, projectId, addToast, loadMessages, getChatMessagesCacheKey, sendMessage], ); - // Filter sessions based on search query - const filteredSessions = searchQuery - ? sessions.filter( - (s) => - s.title?.toLowerCase().includes(searchQuery.toLowerCase()) || - s.agentId.toLowerCase().includes(searchQuery.toLowerCase()), - ) - : sessions; + /* + FNXC:ChatSearch 2026-07-07-00:00: + Content search requires a server round trip (message bodies are not fully loaded + client-side), so it is debounced (300ms) and guarded against out-of-order responses via a + monotonic request id: a superseded query (typed-ahead, or a toggle back to title-only) + invalidates in-flight responses instead of letting a stale result flash in. Switching to + title-only or clearing the query resets `contentMatchedPreviews` synchronously so there is + no stale-result flash while the (now-irrelevant) debounced fetch is still pending/aborted. + */ + const trimmedSearchQuery = searchQuery.trim(); + useEffect(() => { + if (searchInTitleOnly || !trimmedSearchQuery) { + contentSearchRequestIdRef.current++; + setContentMatchedPreviews(new Map()); + return; + } + + const requestId = ++contentSearchRequestIdRef.current; + const timeoutId = setTimeout(() => { + void (async () => { + try { + const data = await fetchChatSessions(projectId, undefined, { + q: trimmedSearchQuery, + titleOnly: false, + }); + if (contentSearchRequestIdRef.current !== requestId) return; + const previews = new Map(); + for (const s of data.sessions) { + if (s.matchedMessagePreview) previews.set(s.id, s.matchedMessagePreview); + } + setContentMatchedPreviews(previews); + } catch { + if (contentSearchRequestIdRef.current === requestId) { + setContentMatchedPreviews(new Map()); + } + } + })(); + }, 300); + + return () => clearTimeout(timeoutId); + }, [trimmedSearchQuery, searchInTitleOnly, projectId]); + + // Filter sessions based on search query: title/agentId match always applies; content + // matches (from contentMatchedPreviews) are unioned in unless searchInTitleOnly is set. + const filteredSessions = (() => { + if (!trimmedSearchQuery) return sessions; + + const lowerQuery = trimmedSearchQuery.toLowerCase(); + const titleMatched = sessions.filter( + (s) => + s.title?.toLowerCase().includes(lowerQuery) || + s.agentId.toLowerCase().includes(lowerQuery), + ); + + if (searchInTitleOnly || contentMatchedPreviews.size === 0) { + return titleMatched; + } + + const merged = new Map(); + for (const s of titleMatched) merged.set(s.id, s); + for (const session of sessions) { + const preview = contentMatchedPreviews.get(session.id); + if (preview === undefined) continue; + const existing = merged.get(session.id); + merged.set(session.id, { ...(existing ?? session), matchedMessagePreview: preview }); + } + return Array.from(merged.values()).sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + })(); useEffect(() => { if (!activeSession?.id || activeSession.isGenerating !== true || streamRef.current) { @@ -1552,6 +1639,8 @@ export function useChat( hasMoreMessages, searchQuery, setSearchQuery, + searchInTitleOnly, + setSearchInTitleOnly, filteredSessions, refreshSessions, agentsMap, diff --git a/packages/dashboard/src/__tests__/chat-routes.test.ts b/packages/dashboard/src/__tests__/chat-routes.test.ts index ca42a119e9..685f5efc88 100644 --- a/packages/dashboard/src/__tests__/chat-routes.test.ts +++ b/packages/dashboard/src/__tests__/chat-routes.test.ts @@ -145,6 +145,7 @@ const mockAddMessage = vi.fn(); const mockGetMessages = vi.fn(); const mockGetMessage = vi.fn(); const mockGetLastMessageForSessions = vi.fn().mockReturnValue(new Map()); +const mockSearchSessionsByMessageContent = vi.fn().mockReturnValue(new Map()); const mockFindLatestActiveSessionForTarget = vi.fn(); const mockDeleteMessage = vi.fn(); const mockDeleteSessionsForAgentId = vi.fn(); @@ -306,6 +307,7 @@ const mockChatStoreInstance = { getMessages: mockGetMessages, getMessage: mockGetMessage, getLastMessageForSessions: mockGetLastMessageForSessions, + searchSessionsByMessageContent: mockSearchSessionsByMessageContent, findLatestActiveSessionForTarget: mockFindLatestActiveSessionForTarget, deleteMessage: mockDeleteMessage, deleteSessionsForAgentId: mockDeleteSessionsForAgentId, @@ -930,6 +932,82 @@ describe("Chat API Routes", () => { expect(response.status).toBe(200); expect(mockListSessions).toHaveBeenCalledWith({ projectId: "proj-001" }); }); + + describe("content search (q / titleOnly)", () => { + it("narrows results to content-matched sessions and attaches matchedMessagePreview", async () => { + const matchSession = { ...sampleSession, id: "chat-match" }; + const noMatchSession = { ...sampleSession, id: "chat-no-match" }; + mockListSessions.mockReturnValue([matchSession, noMatchSession]); + mockGetLastMessageForSessions.mockReturnValue(new Map()); + mockSearchSessionsByMessageContent.mockReturnValue(new Map([["chat-match", "found the roadmap here"]])); + + const response = await request(app, "GET", "/api/chat/sessions?q=roadmap"); + + expect(response.status).toBe(200); + expect(mockSearchSessionsByMessageContent).toHaveBeenCalledWith("roadmap", ["chat-match", "chat-no-match"]); + const body = (response.body as any).sessions; + expect(body.map((s: any) => s.id)).toEqual(["chat-match"]); + expect(body[0].matchedMessagePreview).toBe("found the roadmap here"); + }); + + it("ignores content search and preserves current behavior when titleOnly=true", async () => { + mockListSessions.mockReturnValue([sampleSession]); + mockGetLastMessageForSessions.mockReturnValue(new Map()); + + const response = await request(app, "GET", "/api/chat/sessions?q=roadmap&titleOnly=true"); + + expect(response.status).toBe(200); + expect(mockSearchSessionsByMessageContent).not.toHaveBeenCalled(); + expect((response.body as any).sessions).toHaveLength(1); + }); + + it("performs no content search when q is absent (unchanged default behavior)", async () => { + mockListSessions.mockReturnValue([sampleSession]); + mockGetLastMessageForSessions.mockReturnValue(new Map()); + + const response = await request(app, "GET", "/api/chat/sessions"); + + expect(response.status).toBe(200); + expect(mockSearchSessionsByMessageContent).not.toHaveBeenCalled(); + expect((response.body as any).sessions).toHaveLength(1); + }); + + it("keeps task-planner sessions excluded from content matches when the setting is off", async () => { + const plannerMatch = { ...sampleSession, id: "chat-planner", agentId: "task-planner:FN-7337" }; + mockListSessions.mockReturnValue([plannerMatch]); + mockGetLastMessageForSessions.mockReturnValue(new Map()); + mockSearchSessionsByMessageContent.mockReturnValue(new Map([["chat-planner", "secret plan"]])); + + const response = await request(app, "GET", "/api/chat/sessions?q=secret"); + + expect(response.status).toBe(200); + // Task-planner filter runs before content search narrowing, so the planner session + // never reaches searchSessionsByMessageContent and is excluded from the response. + expect((response.body as any).sessions).toHaveLength(0); + }); + + it("handles injection-style q values (%, ') safely and passes them through verbatim", async () => { + mockListSessions.mockReturnValue([sampleSession]); + mockGetLastMessageForSessions.mockReturnValue(new Map()); + mockSearchSessionsByMessageContent.mockReturnValue(new Map()); + + const response = await request(app, "GET", `/api/chat/sessions?q=${encodeURIComponent("50%' OR 1=1--")}`); + + expect(response.status).toBe(200); + expect(mockSearchSessionsByMessageContent).toHaveBeenCalledWith("50%' OR 1=1--", [sampleSession.id]); + expect((response.body as any).sessions).toHaveLength(0); + }); + + it("does not run content search for lookup=resume", async () => { + mockFindLatestActiveSessionForTarget.mockReturnValue(sampleSession); + mockGetLastMessageForSessions.mockReturnValue(new Map()); + + const response = await request(app, "GET", "/api/chat/sessions?lookup=resume&agentId=agent-001&q=roadmap"); + + expect(response.status).toBe(200); + expect(mockSearchSessionsByMessageContent).not.toHaveBeenCalled(); + }); + }); }); describe("POST /api/chat/sessions", () => { diff --git a/packages/dashboard/src/routes/register-chat-routes.ts b/packages/dashboard/src/routes/register-chat-routes.ts index fcb00aa292..27a3dd2d32 100644 --- a/packages/dashboard/src/routes/register-chat-routes.ts +++ b/packages/dashboard/src/routes/register-chat-routes.ts @@ -202,21 +202,37 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): /** * GET /api/chat/sessions * List chat sessions with optional filtering. - * Query params: projectId?, status?, agentId? + * Query params: projectId?, status?, agentId?, q?, titleOnly? + * + * FNXC:ChatSearch 2026-07-07-00:00: + * `q` triggers a server-side message-content search (title/agentId filtering stays + * client-side, unchanged) because chat message bodies are not fully loaded client-side. + * `titleOnly=true` (or `q` absent) preserves the exact prior behavior: the normal enriched + * session list, with title/agent filtering left to the client. When `q` is present and + * titleOnly is not set, the result is narrowed to sessions whose content matches + * `q` (via ChatStore.searchSessionsByMessageContent), scoped by the same + * projectId/status/agentId filters and the task-planner common-feed guard used below, with + * `matchedMessagePreview` attached. The dashboard hook unions this with its local + * title/agent match so "content mode" covers both signals. * * Response is enriched with lastMessagePreview and lastMessageAt for each session. */ router.get("/chat/sessions", rateLimit(RATE_LIMITS.api), async (req, res) => { try { - const { projectId, status, agentId, lookup, modelProvider, modelId } = req.query as { + const { projectId, status, agentId, lookup, modelProvider, modelId, q, titleOnly } = req.query as { projectId?: string; status?: string; agentId?: string; lookup?: string; modelProvider?: string; modelId?: string; + q?: string; + titleOnly?: string; }; const { store: scopedStore, chatStore } = await resolveScopedChatStore(projectId); + const hasSearchQuery = typeof q === "string" && q.trim().length > 0; + const isTitleOnly = titleOnly === "true" || !hasSearchQuery; + const isContentSearch = hasSearchQuery && !isTitleOnly; const isResumeLookup = lookup === "resume"; const hasModelProvider = typeof modelProvider === "string" && modelProvider.trim().length > 0; @@ -272,6 +288,19 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): }); } + /* + FNXC:ChatSearch 2026-07-07-00:00: + Content search runs AFTER the task-planner common-feed filter above so a matching + message inside a hidden task-planner session can never bypass that guard. It also runs + after resume-lookup narrowing, so `lookup=resume` and task-detail routes are unaffected + (isContentSearch is only true for the plain listSessions path). + */ + let contentMatches: Map | undefined; + if (isContentSearch && !isResumeLookup) { + contentMatches = chatStore.searchSessionsByMessageContent(q!.trim(), sessions.map((s) => s.id)); + sessions = sessions.filter((session) => contentMatches!.has(session.id)); + } + // Batch-gather generating session IDs to avoid N+1 calls const resolvedChatManager = projectId ? await resolveScopedChatManager(projectId).catch(() => options?.chatManager) @@ -290,6 +319,12 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): enriched.lastMessageAt = lastMessage.createdAt; } enriched.isGenerating = generatingSet.has(session.id); + if (contentMatches) { + const matchedPreview = contentMatches.get(session.id); + if (matchedPreview !== undefined) { + enriched.matchedMessagePreview = matchedPreview; + } + } } } diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 440fadba5c..4a8bc34a97 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -1298,6 +1298,7 @@ "loadingOlderMessages": "Loading older messages…", "loadingSessions": "Loading sessions…", "loadingSkills": "Loading skills…", + "matchedInMessage": "Matched: \"{{preview}}\"", "mentionNonMember": "Not a member of {{roomName}}", "messageAgentPlaceholder": "Message {{name}}", "messageModelPlaceholder": "Message {{name}}", @@ -1362,6 +1363,7 @@ "scopeRooms": "Rooms", "scrollMessageToTop": "Scroll message to top", "searchConversations": "Search conversations...", + "searchInTitleOnly": "Search in title only", "selectAgentForNewChat": "Select agent for new chat", "selectAgentPlaceholder": "Select an agent to start chatting", "selectModel": "Select a model", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index ec872b4933..eea2a064c0 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -1287,6 +1287,7 @@ "loadingOlderMessages": "Cargando mensajes anteriores…", "loadingSessions": "Cargando sesiones…", "loadingSkills": "Cargando habilidades…", + "matchedInMessage": "Matched: \"{{preview}}\"", "mentionNonMember": "No es miembro de {{roomName}}", "messageAgentPlaceholder": "Mensaje a {{name}}", "messageModelPlaceholder": "Mensaje a {{name}}", @@ -1351,6 +1352,7 @@ "scopeRooms": "Canales", "scrollMessageToTop": "Desplazar mensaje al inicio", "searchConversations": "Buscar conversaciones...", + "searchInTitleOnly": "Search in title only", "selectAgentForNewChat": "Seleccionar agente para el nuevo chat", "selectAgentPlaceholder": "Selecciona un agente para empezar a chatear", "selectModel": "Seleccionar un modelo", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index d942a960f1..016a618985 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -1287,6 +1287,7 @@ "loadingOlderMessages": "Chargement des messages plus anciens…", "loadingSessions": "Chargement des sessions…", "loadingSkills": "Chargement des compétences…", + "matchedInMessage": "Matched: \"{{preview}}\"", "mentionNonMember": "Pas membre de {{roomName}}", "messageAgentPlaceholder": "Message à {{name}}", "messageModelPlaceholder": "Message à {{name}}", @@ -1351,6 +1352,7 @@ "scopeRooms": "Salons", "scrollMessageToTop": "Faire défiler le message vers le haut", "searchConversations": "Rechercher des conversations…", + "searchInTitleOnly": "Search in title only", "selectAgentForNewChat": "Sélectionner un agent pour le nouveau chat", "selectAgentPlaceholder": "Sélectionnez un agent pour commencer à discuter", "selectModel": "Choisir un modèle", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index d24159fb82..2768165bcb 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -1287,6 +1287,7 @@ "loadingOlderMessages": "이전 메시지 로드 중…", "loadingSessions": "세션 로드 중…", "loadingSkills": "스킬 로드 중…", + "matchedInMessage": "Matched: \"{{preview}}\"", "mentionNonMember": "{{roomName}}의 구성원이 아닙니다", "messageAgentPlaceholder": "{{name}}에게 메시지", "messageModelPlaceholder": "{{name}}에게 메시지", @@ -1351,6 +1352,7 @@ "scopeRooms": "방", "scrollMessageToTop": "메시지를 맨 위로 스크롤", "searchConversations": "대화 검색...", + "searchInTitleOnly": "Search in title only", "selectAgentForNewChat": "새 채팅을 위한 에이전트 선택", "selectAgentPlaceholder": "채팅을 시작할 에이전트를 선택하세요", "selectModel": "모델 선택", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index a3caa46a41..a54adc1b12 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -1287,6 +1287,7 @@ "loadingOlderMessages": "正在加载更早的消息…", "loadingSessions": "正在加载会话……", "loadingSkills": "正在加载技能……", + "matchedInMessage": "Matched: \"{{preview}}\"", "mentionNonMember": "不是 {{roomName}} 的成员", "messageAgentPlaceholder": "发送消息至 {{name}}", "messageModelPlaceholder": "发送消息至 {{name}}", @@ -1351,6 +1352,7 @@ "scopeRooms": "频道", "scrollMessageToTop": "将消息滚动到顶部", "searchConversations": "搜索对话...", + "searchInTitleOnly": "Search in title only", "selectAgentForNewChat": "为新聊天选择代理", "selectAgentPlaceholder": "选择代理开始聊天", "selectModel": "选择模型", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 5115139fba..dd4808c5b6 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -1287,6 +1287,7 @@ "loadingOlderMessages": "正在載入較早的訊息…", "loadingSessions": "正在載入工作階段……", "loadingSkills": "正在載入技能……", + "matchedInMessage": "Matched: \"{{preview}}\"", "mentionNonMember": "不是 {{roomName}} 的成員", "messageAgentPlaceholder": "傳送訊息至 {{name}}", "messageModelPlaceholder": "傳送訊息至 {{name}}", @@ -1351,6 +1352,7 @@ "scopeRooms": "頻道", "scrollMessageToTop": "將訊息捲動至頂部", "searchConversations": "搜尋對話...", + "searchInTitleOnly": "Search in title only", "selectAgentForNewChat": "為新聊天選擇代理", "selectAgentPlaceholder": "選擇代理以開始聊天", "selectModel": "選擇模型",