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.
|
||||
<!-- 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**).
|
||||
|
||||

|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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}` : ""}`);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
searchQuery: "",
|
||||
setSearchQuery: vi.fn(),
|
||||
searchInTitleOnly: false,
|
||||
setSearchInTitleOnly: vi.fn(),
|
||||
filteredSessions: [sessionOne, sessionTwo],
|
||||
refreshSessions: vi.fn(),
|
||||
agentsMap: new Map(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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" }]]),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "모델 선택",
|
||||
|
||||
@@ -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": "选择模型",
|
||||
|
||||
@@ -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": "選擇模型",
|
||||
|
||||
Reference in New Issue
Block a user