FN-7631: add content search to Chat sidebar with title-only toggle

Chat sidebar search now matches message content by default, not just the conversation title/agent, with an opt-out toggle to restore title-only filtering.

- Add ChatStore.searchSessionsByMessageContent (parameterized LIKE ... ESCAPE) for server-side content search across sessions
- GET /chat/sessions route (register-chat-routes.ts, legacy.ts) gains q/titleOnly query params, debounced server-side content lookup merged with local title/agent matches
- useChat hook exposes searchInTitleOnly state and wires debounced content search into session list results
- ChatView renders a "Search in title only" toggle beside the search box (desktop + mobile) and shows a "Matched: ..." preview snippet on content-matched rows
- Task-planner sessions remain excluded from content matches via the same common-feed visibility guard used for the normal session list
- Add unit/integration tests: chat-store content-search, chat-routes API test, ChatView content-search test
- Update docs/dashboard-guide.md to document the new content search behavior and toggle
- Add changeset fn-7631-chat-content-search.md (@runfusion/fusion minor)

Files changed:
 .changeset/fn-7631-chat-content-search.md          |   7 +
 docs/dashboard-guide.md                            |   2 +
 .../__tests__/chat-store.content-search.test.ts    | 157 +++++++++++++++++++++
 packages/core/src/chat-store.ts                    |  64 +++++++++
 packages/core/src/chat-types.ts                    |   8 ++
 packages/dashboard/app/api/legacy.ts               |  23 ++-
 packages/dashboard/app/components/ChatView.css     |  30 ++++
 packages/dashboard/app/components/ChatView.tsx     |  26 ++++
 .../__tests__/ChatView.autosize.test.tsx           |   2 +
 .../__tests__/ChatView.content-search.test.tsx     | 114 +++++++++++++++
 .../components/__tests__/ChatView.draft.test.tsx   |   2 +
 .../__tests__/ChatView.hash-mention.test.tsx       |   2 +
 .../__tests__/ChatView.mobile-render.test.tsx      |   2 +
 .../components/__tests__/ChatView.rooms.test.tsx   |   2 +
 .../__tests__/ChatView.scroll-to-top.test.tsx      |   2 +
 .../components/__tests__/ChatView.test-harness.tsx |   2 +
 packages/dashboard/app/hooks/useChat.ts            | 105 ++++++++++++--
 .../dashboard/src/__tests__/chat-routes.test.ts    |  78 ++++++++++
 .../dashboard/src/routes/register-chat-routes.ts   |  39 ++++-
 packages/i18n/locales/en/app.json                  |   2 +
 packages/i18n/locales/es/app.json                  |   2 +
 packages/i18n/locales/fr/app.json                  |   2 +
 packages/i18n/locales/ko/app.json                  |   2 +
 packages/i18n/locales/zh-CN/app.json               |   2 +
 packages/i18n/locales/zh-TW/app.json               |   2 +
 25 files changed, 667 insertions(+), 12 deletions(-)

Fusion-Task-Id: FN-7631

Fusion-Task-Lineage: bc68b489-26a7-453e-901b-bda816af364e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-07 09:46:04 -07:00
parent 1b7bb1fe18
commit 6777eea5d2
25 changed files with 667 additions and 12 deletions

View File

@@ -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.

View File

@@ -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.
<!-- FNXC:TaskDetailPlannerChat 2026-07-07-10:15: Document Planner Chat edit-and-resend and the steering/refinement side-effect decision on discard. -->
- 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.
<!-- FNXC:ChatSearch 2026-07-07-00:00: Document content search + the title-only toggle so users know search now covers message bodies by default, not just titles. -->
- 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)

View File

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

View File

@@ -742,6 +742,70 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
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<string, string> {
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<string, string>();
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.
*

View File

@@ -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 */

View File

@@ -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<ChatSessionListResponse> {
export function fetchChatSessions(
projectId?: string,
status?: string,
options?: FetchChatSessionsOptions,
): Promise<ChatSessionListResponse> {
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<ChatSessionListResponse>(`/chat/sessions${qs ? `?${qs}` : ""}`);
}

View File

@@ -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 {

View File

@@ -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.
*/}
<div className="chat-sidebar-search-container">
<div className="chat-sidebar-search-wrapper">
<Search size={14} className="chat-sidebar-search-icon" />
@@ -2797,6 +2807,17 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
data-testid="chat-search-input"
/>
</div>
<button
type="button"
className="btn btn-sm chat-search-title-only-toggle"
onClick={() => setSearchInTitleOnly(!searchInTitleOnly)}
aria-pressed={searchInTitleOnly}
aria-label={t("chat.searchInTitleOnly", "Search in title only")}
title={t("chat.searchInTitleOnly", "Search in title only")}
data-testid="chat-search-title-only-toggle"
>
{t("chat.searchInTitleOnly", "Search in title only")}
</button>
</div>
{/* Session list section */}
<div className="chat-session-list chat-sidebar-list">
@@ -2870,6 +2891,11 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
<div className="chat-session-preview">
{session.lastMessagePreview || t("chat.noMessages", "No messages")}
</div>
{session.matchedMessagePreview ? (
<div className="chat-session-preview chat-session-preview--matched" data-testid={`chat-session-matched-preview-${session.id}`}>
{t("chat.matchedInMessage", "Matched: \"{{preview}}\"", { preview: session.matchedMessagePreview })}
</div>
) : null}
<div className="chat-session-meta">
<span className="chat-session-meta-model">
{sessionResolvedModel?.provider ? <ProviderIcon provider={sessionResolvedModel.provider} size="sm" /> : null}

View File

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

View File

@@ -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<typeof import("../../hooks/useNavigationHistory")>();
return {
...actual,
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
};
});
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>();
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByTestId("chat-session-matched-preview-session-content-match")).toHaveTextContent(
"quarterly roadmap discussion",
);
});
});

View File

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

View File

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

View File

@@ -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" }]]),

View File

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

View File

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

View File

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

View File

@@ -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<Map<string, string>>(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<string, string>();
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<string, ChatSessionInfo>();
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,

View File

@@ -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", () => {

View File

@@ -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<string, string> | 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;
}
}
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "모델 선택",

View File

@@ -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": "选择模型",

View File

@@ -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": "選擇模型",