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:
7
.changeset/fn-7631-chat-content-search.md
Normal file
7
.changeset/fn-7631-chat-content-search.md
Normal 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.
|
||||||
@@ -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.
|
- 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. -->
|
<!-- 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.
|
- 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**).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
157
packages/core/src/__tests__/chat-store.content-search.test.ts
Normal file
157
packages/core/src/__tests__/chat-store.content-search.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -742,6 +742,70 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
|||||||
return Boolean(row);
|
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.
|
* Delete a message by ID.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -97,6 +97,14 @@ export type EnrichedChatSession = ChatSession & {
|
|||||||
lastMessageAt?: string;
|
lastMessageAt?: string;
|
||||||
/** Whether a generation is currently in progress for this session */
|
/** Whether a generation is currently in progress for this session */
|
||||||
isGenerating?: boolean;
|
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 */
|
/** A parsed @ mention of an agent in a chat message */
|
||||||
|
|||||||
@@ -10019,11 +10019,30 @@ export interface ChatRoomMessageResponse {
|
|||||||
message: ChatRoomMessage;
|
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 */
|
/** 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();
|
const search = new URLSearchParams();
|
||||||
if (projectId) search.set("projectId", projectId);
|
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();
|
const qs = search.toString();
|
||||||
return api<ChatSessionListResponse>(`/chat/sessions${qs ? `?${qs}` : ""}`);
|
return api<ChatSessionListResponse>(`/chat/sessions${qs ? `?${qs}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,6 +267,36 @@ When the movable chat popup is resized narrow, collapse Direct/Rooms labels to i
|
|||||||
|
|
||||||
.chat-sidebar-search-container {
|
.chat-sidebar-search-container {
|
||||||
padding: var(--space-sm);
|
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 {
|
.chat-sidebar-search-wrapper {
|
||||||
|
|||||||
@@ -494,6 +494,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
hasMoreMessages,
|
hasMoreMessages,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
|
searchInTitleOnly,
|
||||||
|
setSearchInTitleOnly,
|
||||||
filteredSessions,
|
filteredSessions,
|
||||||
agentsMap: chatAgentsMap,
|
agentsMap: chatAgentsMap,
|
||||||
} = useChat(projectId, addToast);
|
} = useChat(projectId, addToast);
|
||||||
@@ -2785,6 +2787,14 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
{!chatRoomsEnabled || chatScope === "direct" ? (
|
{!chatRoomsEnabled || chatScope === "direct" ? (
|
||||||
<>
|
<>
|
||||||
{/* Search section */}
|
{/* 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-container">
|
||||||
<div className="chat-sidebar-search-wrapper">
|
<div className="chat-sidebar-search-wrapper">
|
||||||
<Search size={14} className="chat-sidebar-search-icon" />
|
<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"
|
data-testid="chat-search-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/* Session list section */}
|
{/* Session list section */}
|
||||||
<div className="chat-session-list chat-sidebar-list">
|
<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">
|
<div className="chat-session-preview">
|
||||||
{session.lastMessagePreview || t("chat.noMessages", "No messages")}
|
{session.lastMessagePreview || t("chat.noMessages", "No messages")}
|
||||||
</div>
|
</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">
|
<div className="chat-session-meta">
|
||||||
<span className="chat-session-meta-model">
|
<span className="chat-session-meta-model">
|
||||||
{sessionResolvedModel?.provider ? <ProviderIcon provider={sessionResolvedModel.provider} size="sm" /> : null}
|
{sessionResolvedModel?.provider ? <ProviderIcon provider={sessionResolvedModel.provider} size="sm" /> : null}
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ const defaultChatState: UseChatReturn = {
|
|||||||
hasMoreMessages: false,
|
hasMoreMessages: false,
|
||||||
searchQuery: "",
|
searchQuery: "",
|
||||||
setSearchQuery: vi.fn(),
|
setSearchQuery: vi.fn(),
|
||||||
|
searchInTitleOnly: false,
|
||||||
|
setSearchInTitleOnly: vi.fn(),
|
||||||
filteredSessions: [sessionOne, sessionTwo],
|
filteredSessions: [sessionOne, sessionTwo],
|
||||||
refreshSessions: vi.fn(),
|
refreshSessions: vi.fn(),
|
||||||
agentsMap: new Map(),
|
agentsMap: new Map(),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -91,6 +91,8 @@ const defaultChatState: UseChatReturn = {
|
|||||||
hasMoreMessages: false,
|
hasMoreMessages: false,
|
||||||
searchQuery: "",
|
searchQuery: "",
|
||||||
setSearchQuery: vi.fn(),
|
setSearchQuery: vi.fn(),
|
||||||
|
searchInTitleOnly: false,
|
||||||
|
setSearchInTitleOnly: vi.fn(),
|
||||||
filteredSessions: [sessionOne, sessionTwo],
|
filteredSessions: [sessionOne, sessionTwo],
|
||||||
refreshSessions: vi.fn(),
|
refreshSessions: vi.fn(),
|
||||||
agentsMap: new Map(),
|
agentsMap: new Map(),
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ const defaultChatState: UseChatReturn = {
|
|||||||
hasMoreMessages: false,
|
hasMoreMessages: false,
|
||||||
searchQuery: "",
|
searchQuery: "",
|
||||||
setSearchQuery: vi.fn(),
|
setSearchQuery: vi.fn(),
|
||||||
|
searchInTitleOnly: false,
|
||||||
|
setSearchInTitleOnly: vi.fn(),
|
||||||
filteredSessions: [activeSession],
|
filteredSessions: [activeSession],
|
||||||
refreshSessions: vi.fn(),
|
refreshSessions: vi.fn(),
|
||||||
agentsMap: new Map(),
|
agentsMap: new Map(),
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ const defaultChatState: UseChatReturn = {
|
|||||||
hasMoreMessages: false,
|
hasMoreMessages: false,
|
||||||
searchQuery: "",
|
searchQuery: "",
|
||||||
setSearchQuery: vi.fn(),
|
setSearchQuery: vi.fn(),
|
||||||
|
searchInTitleOnly: false,
|
||||||
|
setSearchInTitleOnly: vi.fn(),
|
||||||
filteredSessions: [],
|
filteredSessions: [],
|
||||||
refreshSessions: vi.fn(),
|
refreshSessions: vi.fn(),
|
||||||
agentsMap: new Map([["agent-001", { id: "agent-001", name: "Alpha" }]]),
|
agentsMap: new Map([["agent-001", { id: "agent-001", name: "Alpha" }]]),
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ const defaultChatState: UseChatReturn = {
|
|||||||
hasMoreMessages: false,
|
hasMoreMessages: false,
|
||||||
searchQuery: "",
|
searchQuery: "",
|
||||||
setSearchQuery: vi.fn(),
|
setSearchQuery: vi.fn(),
|
||||||
|
searchInTitleOnly: false,
|
||||||
|
setSearchInTitleOnly: vi.fn(),
|
||||||
filteredSessions: [activeSession],
|
filteredSessions: [activeSession],
|
||||||
refreshSessions: vi.fn(),
|
refreshSessions: vi.fn(),
|
||||||
agentsMap: new Map(),
|
agentsMap: new Map(),
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ const defaultChatState: UseChatReturn = {
|
|||||||
hasMoreMessages: false,
|
hasMoreMessages: false,
|
||||||
searchQuery: "",
|
searchQuery: "",
|
||||||
setSearchQuery: vi.fn(),
|
setSearchQuery: vi.fn(),
|
||||||
|
searchInTitleOnly: false,
|
||||||
|
setSearchInTitleOnly: vi.fn(),
|
||||||
filteredSessions: [activeSession],
|
filteredSessions: [activeSession],
|
||||||
refreshSessions: vi.fn(),
|
refreshSessions: vi.fn(),
|
||||||
agentsMap: new Map(),
|
agentsMap: new Map(),
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export const defaultChatState: UseChatReturn = {
|
|||||||
hasMoreMessages: false,
|
hasMoreMessages: false,
|
||||||
searchQuery: "",
|
searchQuery: "",
|
||||||
setSearchQuery: vi.fn(),
|
setSearchQuery: vi.fn(),
|
||||||
|
searchInTitleOnly: false,
|
||||||
|
setSearchInTitleOnly: vi.fn(),
|
||||||
filteredSessions: [],
|
filteredSessions: [],
|
||||||
refreshSessions: vi.fn(),
|
refreshSessions: vi.fn(),
|
||||||
agentsMap: new Map(),
|
agentsMap: new Map(),
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ export interface ChatSessionInfo {
|
|||||||
cliExecutorAdapterId?: string | null;
|
cliExecutorAdapterId?: string | null;
|
||||||
/** Native CLI session id linkage (used as the terminal attach id for resume). */
|
/** Native CLI session id linkage (used as the terminal attach id for resume). */
|
||||||
cliSessionFile?: string | null;
|
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"`)
|
// Re-export shared chat types so existing consumers (`import { ChatMessageInfo } from "../hooks/useChat"`)
|
||||||
@@ -110,6 +117,14 @@ export interface UseChatReturn {
|
|||||||
// Search/filter
|
// Search/filter
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
setSearchQuery: (query: string) => void;
|
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[];
|
filteredSessions: ChatSessionInfo[];
|
||||||
|
|
||||||
// Refresh
|
// Refresh
|
||||||
@@ -336,6 +351,16 @@ export function useChat(
|
|||||||
|
|
||||||
// Search/filter
|
// Search/filter
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
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
|
// Pagination
|
||||||
const [hasMoreMessages, setHasMoreMessages] = useState(false);
|
const [hasMoreMessages, setHasMoreMessages] = useState(false);
|
||||||
@@ -1281,14 +1306,76 @@ export function useChat(
|
|||||||
[activeSession, projectId, addToast, loadMessages, getChatMessagesCacheKey, sendMessage],
|
[activeSession, projectId, addToast, loadMessages, getChatMessagesCacheKey, sendMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter sessions based on search query
|
/*
|
||||||
const filteredSessions = searchQuery
|
FNXC:ChatSearch 2026-07-07-00:00:
|
||||||
? sessions.filter(
|
Content search requires a server round trip (message bodies are not fully loaded
|
||||||
(s) =>
|
client-side), so it is debounced (300ms) and guarded against out-of-order responses via a
|
||||||
s.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
monotonic request id: a superseded query (typed-ahead, or a toggle back to title-only)
|
||||||
s.agentId.toLowerCase().includes(searchQuery.toLowerCase()),
|
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
|
||||||
: sessions;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!activeSession?.id || activeSession.isGenerating !== true || streamRef.current) {
|
if (!activeSession?.id || activeSession.isGenerating !== true || streamRef.current) {
|
||||||
@@ -1552,6 +1639,8 @@ export function useChat(
|
|||||||
hasMoreMessages,
|
hasMoreMessages,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
|
searchInTitleOnly,
|
||||||
|
setSearchInTitleOnly,
|
||||||
filteredSessions,
|
filteredSessions,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
agentsMap,
|
agentsMap,
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ const mockAddMessage = vi.fn();
|
|||||||
const mockGetMessages = vi.fn();
|
const mockGetMessages = vi.fn();
|
||||||
const mockGetMessage = vi.fn();
|
const mockGetMessage = vi.fn();
|
||||||
const mockGetLastMessageForSessions = vi.fn().mockReturnValue(new Map());
|
const mockGetLastMessageForSessions = vi.fn().mockReturnValue(new Map());
|
||||||
|
const mockSearchSessionsByMessageContent = vi.fn().mockReturnValue(new Map());
|
||||||
const mockFindLatestActiveSessionForTarget = vi.fn();
|
const mockFindLatestActiveSessionForTarget = vi.fn();
|
||||||
const mockDeleteMessage = vi.fn();
|
const mockDeleteMessage = vi.fn();
|
||||||
const mockDeleteSessionsForAgentId = vi.fn();
|
const mockDeleteSessionsForAgentId = vi.fn();
|
||||||
@@ -306,6 +307,7 @@ const mockChatStoreInstance = {
|
|||||||
getMessages: mockGetMessages,
|
getMessages: mockGetMessages,
|
||||||
getMessage: mockGetMessage,
|
getMessage: mockGetMessage,
|
||||||
getLastMessageForSessions: mockGetLastMessageForSessions,
|
getLastMessageForSessions: mockGetLastMessageForSessions,
|
||||||
|
searchSessionsByMessageContent: mockSearchSessionsByMessageContent,
|
||||||
findLatestActiveSessionForTarget: mockFindLatestActiveSessionForTarget,
|
findLatestActiveSessionForTarget: mockFindLatestActiveSessionForTarget,
|
||||||
deleteMessage: mockDeleteMessage,
|
deleteMessage: mockDeleteMessage,
|
||||||
deleteSessionsForAgentId: mockDeleteSessionsForAgentId,
|
deleteSessionsForAgentId: mockDeleteSessionsForAgentId,
|
||||||
@@ -930,6 +932,82 @@ describe("Chat API Routes", () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(mockListSessions).toHaveBeenCalledWith({ projectId: "proj-001" });
|
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", () => {
|
describe("POST /api/chat/sessions", () => {
|
||||||
|
|||||||
@@ -202,21 +202,37 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
|||||||
/**
|
/**
|
||||||
* GET /api/chat/sessions
|
* GET /api/chat/sessions
|
||||||
* List chat sessions with optional filtering.
|
* 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.
|
* Response is enriched with lastMessagePreview and lastMessageAt for each session.
|
||||||
*/
|
*/
|
||||||
router.get("/chat/sessions", rateLimit(RATE_LIMITS.api), async (req, res) => {
|
router.get("/chat/sessions", rateLimit(RATE_LIMITS.api), async (req, res) => {
|
||||||
try {
|
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;
|
projectId?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
lookup?: string;
|
lookup?: string;
|
||||||
modelProvider?: string;
|
modelProvider?: string;
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
|
q?: string;
|
||||||
|
titleOnly?: string;
|
||||||
};
|
};
|
||||||
const { store: scopedStore, chatStore } = await resolveScopedChatStore(projectId);
|
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 isResumeLookup = lookup === "resume";
|
||||||
const hasModelProvider = typeof modelProvider === "string" && modelProvider.trim().length > 0;
|
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
|
// Batch-gather generating session IDs to avoid N+1 calls
|
||||||
const resolvedChatManager = projectId
|
const resolvedChatManager = projectId
|
||||||
? await resolveScopedChatManager(projectId).catch(() => options?.chatManager)
|
? await resolveScopedChatManager(projectId).catch(() => options?.chatManager)
|
||||||
@@ -290,6 +319,12 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
|||||||
enriched.lastMessageAt = lastMessage.createdAt;
|
enriched.lastMessageAt = lastMessage.createdAt;
|
||||||
}
|
}
|
||||||
enriched.isGenerating = generatingSet.has(session.id);
|
enriched.isGenerating = generatingSet.has(session.id);
|
||||||
|
if (contentMatches) {
|
||||||
|
const matchedPreview = contentMatches.get(session.id);
|
||||||
|
if (matchedPreview !== undefined) {
|
||||||
|
enriched.matchedMessagePreview = matchedPreview;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1298,6 +1298,7 @@
|
|||||||
"loadingOlderMessages": "Loading older messages…",
|
"loadingOlderMessages": "Loading older messages…",
|
||||||
"loadingSessions": "Loading sessions…",
|
"loadingSessions": "Loading sessions…",
|
||||||
"loadingSkills": "Loading skills…",
|
"loadingSkills": "Loading skills…",
|
||||||
|
"matchedInMessage": "Matched: \"{{preview}}\"",
|
||||||
"mentionNonMember": "Not a member of {{roomName}}",
|
"mentionNonMember": "Not a member of {{roomName}}",
|
||||||
"messageAgentPlaceholder": "Message {{name}}",
|
"messageAgentPlaceholder": "Message {{name}}",
|
||||||
"messageModelPlaceholder": "Message {{name}}",
|
"messageModelPlaceholder": "Message {{name}}",
|
||||||
@@ -1362,6 +1363,7 @@
|
|||||||
"scopeRooms": "Rooms",
|
"scopeRooms": "Rooms",
|
||||||
"scrollMessageToTop": "Scroll message to top",
|
"scrollMessageToTop": "Scroll message to top",
|
||||||
"searchConversations": "Search conversations...",
|
"searchConversations": "Search conversations...",
|
||||||
|
"searchInTitleOnly": "Search in title only",
|
||||||
"selectAgentForNewChat": "Select agent for new chat",
|
"selectAgentForNewChat": "Select agent for new chat",
|
||||||
"selectAgentPlaceholder": "Select an agent to start chatting",
|
"selectAgentPlaceholder": "Select an agent to start chatting",
|
||||||
"selectModel": "Select a model",
|
"selectModel": "Select a model",
|
||||||
|
|||||||
@@ -1287,6 +1287,7 @@
|
|||||||
"loadingOlderMessages": "Cargando mensajes anteriores…",
|
"loadingOlderMessages": "Cargando mensajes anteriores…",
|
||||||
"loadingSessions": "Cargando sesiones…",
|
"loadingSessions": "Cargando sesiones…",
|
||||||
"loadingSkills": "Cargando habilidades…",
|
"loadingSkills": "Cargando habilidades…",
|
||||||
|
"matchedInMessage": "Matched: \"{{preview}}\"",
|
||||||
"mentionNonMember": "No es miembro de {{roomName}}",
|
"mentionNonMember": "No es miembro de {{roomName}}",
|
||||||
"messageAgentPlaceholder": "Mensaje a {{name}}",
|
"messageAgentPlaceholder": "Mensaje a {{name}}",
|
||||||
"messageModelPlaceholder": "Mensaje a {{name}}",
|
"messageModelPlaceholder": "Mensaje a {{name}}",
|
||||||
@@ -1351,6 +1352,7 @@
|
|||||||
"scopeRooms": "Canales",
|
"scopeRooms": "Canales",
|
||||||
"scrollMessageToTop": "Desplazar mensaje al inicio",
|
"scrollMessageToTop": "Desplazar mensaje al inicio",
|
||||||
"searchConversations": "Buscar conversaciones...",
|
"searchConversations": "Buscar conversaciones...",
|
||||||
|
"searchInTitleOnly": "Search in title only",
|
||||||
"selectAgentForNewChat": "Seleccionar agente para el nuevo chat",
|
"selectAgentForNewChat": "Seleccionar agente para el nuevo chat",
|
||||||
"selectAgentPlaceholder": "Selecciona un agente para empezar a chatear",
|
"selectAgentPlaceholder": "Selecciona un agente para empezar a chatear",
|
||||||
"selectModel": "Seleccionar un modelo",
|
"selectModel": "Seleccionar un modelo",
|
||||||
|
|||||||
@@ -1287,6 +1287,7 @@
|
|||||||
"loadingOlderMessages": "Chargement des messages plus anciens…",
|
"loadingOlderMessages": "Chargement des messages plus anciens…",
|
||||||
"loadingSessions": "Chargement des sessions…",
|
"loadingSessions": "Chargement des sessions…",
|
||||||
"loadingSkills": "Chargement des compétences…",
|
"loadingSkills": "Chargement des compétences…",
|
||||||
|
"matchedInMessage": "Matched: \"{{preview}}\"",
|
||||||
"mentionNonMember": "Pas membre de {{roomName}}",
|
"mentionNonMember": "Pas membre de {{roomName}}",
|
||||||
"messageAgentPlaceholder": "Message à {{name}}",
|
"messageAgentPlaceholder": "Message à {{name}}",
|
||||||
"messageModelPlaceholder": "Message à {{name}}",
|
"messageModelPlaceholder": "Message à {{name}}",
|
||||||
@@ -1351,6 +1352,7 @@
|
|||||||
"scopeRooms": "Salons",
|
"scopeRooms": "Salons",
|
||||||
"scrollMessageToTop": "Faire défiler le message vers le haut",
|
"scrollMessageToTop": "Faire défiler le message vers le haut",
|
||||||
"searchConversations": "Rechercher des conversations…",
|
"searchConversations": "Rechercher des conversations…",
|
||||||
|
"searchInTitleOnly": "Search in title only",
|
||||||
"selectAgentForNewChat": "Sélectionner un agent pour le nouveau chat",
|
"selectAgentForNewChat": "Sélectionner un agent pour le nouveau chat",
|
||||||
"selectAgentPlaceholder": "Sélectionnez un agent pour commencer à discuter",
|
"selectAgentPlaceholder": "Sélectionnez un agent pour commencer à discuter",
|
||||||
"selectModel": "Choisir un modèle",
|
"selectModel": "Choisir un modèle",
|
||||||
|
|||||||
@@ -1287,6 +1287,7 @@
|
|||||||
"loadingOlderMessages": "이전 메시지 로드 중…",
|
"loadingOlderMessages": "이전 메시지 로드 중…",
|
||||||
"loadingSessions": "세션 로드 중…",
|
"loadingSessions": "세션 로드 중…",
|
||||||
"loadingSkills": "스킬 로드 중…",
|
"loadingSkills": "스킬 로드 중…",
|
||||||
|
"matchedInMessage": "Matched: \"{{preview}}\"",
|
||||||
"mentionNonMember": "{{roomName}}의 구성원이 아닙니다",
|
"mentionNonMember": "{{roomName}}의 구성원이 아닙니다",
|
||||||
"messageAgentPlaceholder": "{{name}}에게 메시지",
|
"messageAgentPlaceholder": "{{name}}에게 메시지",
|
||||||
"messageModelPlaceholder": "{{name}}에게 메시지",
|
"messageModelPlaceholder": "{{name}}에게 메시지",
|
||||||
@@ -1351,6 +1352,7 @@
|
|||||||
"scopeRooms": "방",
|
"scopeRooms": "방",
|
||||||
"scrollMessageToTop": "메시지를 맨 위로 스크롤",
|
"scrollMessageToTop": "메시지를 맨 위로 스크롤",
|
||||||
"searchConversations": "대화 검색...",
|
"searchConversations": "대화 검색...",
|
||||||
|
"searchInTitleOnly": "Search in title only",
|
||||||
"selectAgentForNewChat": "새 채팅을 위한 에이전트 선택",
|
"selectAgentForNewChat": "새 채팅을 위한 에이전트 선택",
|
||||||
"selectAgentPlaceholder": "채팅을 시작할 에이전트를 선택하세요",
|
"selectAgentPlaceholder": "채팅을 시작할 에이전트를 선택하세요",
|
||||||
"selectModel": "모델 선택",
|
"selectModel": "모델 선택",
|
||||||
|
|||||||
@@ -1287,6 +1287,7 @@
|
|||||||
"loadingOlderMessages": "正在加载更早的消息…",
|
"loadingOlderMessages": "正在加载更早的消息…",
|
||||||
"loadingSessions": "正在加载会话……",
|
"loadingSessions": "正在加载会话……",
|
||||||
"loadingSkills": "正在加载技能……",
|
"loadingSkills": "正在加载技能……",
|
||||||
|
"matchedInMessage": "Matched: \"{{preview}}\"",
|
||||||
"mentionNonMember": "不是 {{roomName}} 的成员",
|
"mentionNonMember": "不是 {{roomName}} 的成员",
|
||||||
"messageAgentPlaceholder": "发送消息至 {{name}}",
|
"messageAgentPlaceholder": "发送消息至 {{name}}",
|
||||||
"messageModelPlaceholder": "发送消息至 {{name}}",
|
"messageModelPlaceholder": "发送消息至 {{name}}",
|
||||||
@@ -1351,6 +1352,7 @@
|
|||||||
"scopeRooms": "频道",
|
"scopeRooms": "频道",
|
||||||
"scrollMessageToTop": "将消息滚动到顶部",
|
"scrollMessageToTop": "将消息滚动到顶部",
|
||||||
"searchConversations": "搜索对话...",
|
"searchConversations": "搜索对话...",
|
||||||
|
"searchInTitleOnly": "Search in title only",
|
||||||
"selectAgentForNewChat": "为新聊天选择代理",
|
"selectAgentForNewChat": "为新聊天选择代理",
|
||||||
"selectAgentPlaceholder": "选择代理开始聊天",
|
"selectAgentPlaceholder": "选择代理开始聊天",
|
||||||
"selectModel": "选择模型",
|
"selectModel": "选择模型",
|
||||||
|
|||||||
@@ -1287,6 +1287,7 @@
|
|||||||
"loadingOlderMessages": "正在載入較早的訊息…",
|
"loadingOlderMessages": "正在載入較早的訊息…",
|
||||||
"loadingSessions": "正在載入工作階段……",
|
"loadingSessions": "正在載入工作階段……",
|
||||||
"loadingSkills": "正在載入技能……",
|
"loadingSkills": "正在載入技能……",
|
||||||
|
"matchedInMessage": "Matched: \"{{preview}}\"",
|
||||||
"mentionNonMember": "不是 {{roomName}} 的成員",
|
"mentionNonMember": "不是 {{roomName}} 的成員",
|
||||||
"messageAgentPlaceholder": "傳送訊息至 {{name}}",
|
"messageAgentPlaceholder": "傳送訊息至 {{name}}",
|
||||||
"messageModelPlaceholder": "傳送訊息至 {{name}}",
|
"messageModelPlaceholder": "傳送訊息至 {{name}}",
|
||||||
@@ -1351,6 +1352,7 @@
|
|||||||
"scopeRooms": "頻道",
|
"scopeRooms": "頻道",
|
||||||
"scrollMessageToTop": "將訊息捲動至頂部",
|
"scrollMessageToTop": "將訊息捲動至頂部",
|
||||||
"searchConversations": "搜尋對話...",
|
"searchConversations": "搜尋對話...",
|
||||||
|
"searchInTitleOnly": "Search in title only",
|
||||||
"selectAgentForNewChat": "為新聊天選擇代理",
|
"selectAgentForNewChat": "為新聊天選擇代理",
|
||||||
"selectAgentPlaceholder": "選擇代理以開始聊天",
|
"selectAgentPlaceholder": "選擇代理以開始聊天",
|
||||||
"selectModel": "選擇模型",
|
"selectModel": "選擇模型",
|
||||||
|
|||||||
Reference in New Issue
Block a user