{!hideAssistantIdentity &&
{activeModelProvider ?
:
}
{agentName}{showAssistantModelTag && activeModelTag &&
{activeModelTag}}
}
{streamingText ? renderStandardAssistantContent(streamingText, forcePlain) :
{streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.workingStatus", "Working…")}
}
{copyAction}
diff --git a/packages/dashboard/app/components/__tests__/ChatView.content-search.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.content-search.test.tsx
index fbfc466673..53df4a1b69 100644
--- a/packages/dashboard/app/components/__tests__/ChatView.content-search.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ChatView.content-search.test.tsx
@@ -5,11 +5,12 @@ sidebars (FN-7651 removed the affordance), and matchedMessagePreview still rende
content-mode drove a session's inclusion — content search remains always-on.
*/
import { describe, it, expect, vi } from "vitest";
-import { screen } from "@testing-library/react";
+import { fireEvent, screen } from "@testing-library/react";
import { ChatView } from "../ChatView";
import {
renderWithAct,
setupMockChat,
+ setupMockRooms,
mockViewportMode,
activeSessionFixture,
installChatViewEnv,
@@ -48,6 +49,24 @@ vi.mock("../../api", () => ({
installChatViewEnv();
+function dispatchFind(target: EventTarget, modifier: "ctrl" | "meta" = "ctrl") {
+ const event = new KeyboardEvent("keydown", {
+ key: "f",
+ ctrlKey: modifier === "ctrl",
+ metaKey: modifier === "meta",
+ bubbles: true,
+ cancelable: true,
+ });
+ target.dispatchEvent(event);
+ return event;
+}
+
+async function enterDirectDetail() {
+ fireEvent.pointerDown(screen.getByTestId(`chat-session-${activeSessionFixture.id}`));
+ fireEvent.click(screen.getByTestId(`chat-session-${activeSessionFixture.id}`));
+ return screen.findByTestId("chat-back-btn");
+}
+
describe("ChatView content search", () => {
it("does not render the title-only toggle on the desktop sidebar", async () => {
mockViewportMode("desktop");
@@ -69,6 +88,182 @@ describe("ChatView content search", () => {
expect(screen.queryByTestId("chat-search-title-only-toggle")).toBeNull();
});
+ it("focuses the existing list search and prevents native Find without changing its query", async () => {
+ mockViewportMode("desktop");
+ const setSearchQuery = vi.fn();
+ setupMockChat({ sessions: [activeSessionFixture], filteredSessions: [activeSessionFixture], searchQuery: "kept", setSearchQuery });
+
+ await renderWithAct(
);
+ const event = new KeyboardEvent("keydown", { key: "f", ctrlKey: true, bubbles: true, cancelable: true });
+ screen.getByTestId("chat-search-input").dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(screen.getByTestId("chat-search-input")).toHaveFocus();
+ expect(screen.getByTestId("chat-search-input")).toHaveValue("kept");
+ expect(setSearchQuery).not.toHaveBeenCalled();
+ });
+
+ it("does not retain native Find ownership while its host is hidden", async () => {
+ mockViewportMode("desktop");
+ setupMockChat({ sessions: [activeSessionFixture], filteredSessions: [activeSessionFixture] });
+
+ await renderWithAct(
);
+ const event = new KeyboardEvent("keydown", { key: "f", ctrlKey: true, bubbles: true, cancelable: true });
+ screen.getByTestId("chat-search-input").dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(false);
+ });
+
+ it.each([
+ ["desktop", "desktop" as const, {}],
+ ["mobile", "mobile" as const, {}],
+ ["compact", "desktop" as const, { floating: true, compactLayout: true }],
+ ])("focuses the retained list search in the %s host", async (_host, viewport, hostProps) => {
+ mockViewportMode(viewport);
+ const setSearchQuery = vi.fn();
+ setupMockChat({ sessions: [], filteredSessions: [], searchQuery: "retained", setSearchQuery });
+
+ await renderWithAct(
);
+ const input = screen.getByTestId("chat-search-input");
+ const event = dispatchFind(input, "meta");
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(input).toHaveFocus();
+ expect(input).toHaveValue("retained");
+ expect(setSearchQuery).not.toHaveBeenCalled();
+ });
+
+ it("opens conversation Find and navigates one matching row per message", async () => {
+ mockViewportMode("desktop");
+ setupMockChat({
+ activeSession: activeSessionFixture,
+ sessions: [activeSessionFixture],
+ filteredSessions: [activeSessionFixture],
+ messages: [
+ { id: "message-1", sessionId: activeSessionFixture.id, role: "user", content: "Needle needle", createdAt: "2026-01-01T00:00:00.000Z" },
+ { id: "message-2", sessionId: activeSessionFixture.id, role: "assistant", content: "Another needle", createdAt: "2026-01-01T00:01:00.000Z" },
+ ],
+ });
+
+ await renderWithAct(
);
+ await enterDirectDetail();
+ const event = dispatchFind(screen.getByTestId("chat-message-message-1"), "meta");
+
+ expect(event.defaultPrevented).toBe(true);
+ const input = await screen.findByTestId("chat-conversation-search-input");
+ expect(input).toHaveFocus();
+ fireEvent.change(input, { target: { value: "needle" } });
+ expect(screen.getByText("1 of 2 matches")).toBeInTheDocument();
+ expect(screen.getByTestId("chat-message-message-1")).toHaveClass("chat-message--search-active");
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(screen.getByTestId("chat-message-message-2")).toHaveClass("chat-message--search-active");
+ fireEvent.click(screen.getByRole("button", { name: "Next match" }));
+ expect(screen.getByTestId("chat-message-message-1")).toHaveClass("chat-message--search-active");
+ fireEvent.keyDown(input, { key: "Enter", shiftKey: true });
+ expect(screen.getByTestId("chat-message-message-2")).toHaveClass("chat-message--search-active");
+ fireEvent.click(screen.getByTestId("chat-back-btn"));
+ expect(screen.queryByTestId("chat-conversation-search")).toBeNull();
+ });
+
+ it("searches room details without changing the Direct list query", async () => {
+ mockViewportMode("mobile");
+ const room = { id: "room-find", projectId: "proj-123", slug: "find", name: "Find", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" };
+ const setSearchQuery = vi.fn();
+ localStorage.setItem("fusion:chat-scope", "rooms");
+ setupMockChat({ sessions: [], filteredSessions: [], setSearchQuery });
+ setupMockRooms({
+ rooms: [room],
+ activeRoom: room,
+ messages: [
+ { id: "room-find-1", roomId: room.id, role: "user", content: "Room needle", createdAt: "2026-01-01T00:00:00.000Z", senderAgentId: null, mentions: [] },
+ { id: "room-find-2", roomId: room.id, role: "assistant", content: "Another room needle", createdAt: "2026-01-01T00:01:00.000Z", senderAgentId: "agent-001", mentions: [] },
+ ],
+ });
+
+ await renderWithAct(
);
+ fireEvent.click(screen.getByTestId("chat-room-item-find"));
+ const firstRow = await screen.findByTestId("chat-message-room-find-1");
+ const event = dispatchFind(firstRow);
+
+ expect(event.defaultPrevented).toBe(true);
+ const input = await screen.findByTestId("chat-conversation-search-input");
+ fireEvent.change(input, { target: { value: "needle" } });
+ expect(screen.getByText("1 of 2 matches")).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Previous match" }));
+ expect(screen.getByTestId("chat-message-room-find-2")).toHaveClass("chat-message--search-active");
+ expect(setSearchQuery).not.toHaveBeenCalled();
+ });
+
+ it("keeps native Find terminal-owned while hybrid transcripts own it", async () => {
+ const hybrid = { ...activeSessionFixture, cliExecutorAdapterId: "claude-code", cliSessionFile: "cli-find" };
+ setupMockChat({ activeSession: hybrid, sessions: [hybrid], filteredSessions: [hybrid], messages: [{ id: "cli-find-message", sessionId: hybrid.id, role: "assistant", content: "Transcript needle", createdAt: "2026-01-01T00:00:00.000Z" }] });
+ const hybridView = await renderWithAct(
);
+ await enterDirectDetail();
+ const transcriptEvent = dispatchFind(screen.getByTestId("chat-message-cli-find-message"));
+ expect(transcriptEvent.defaultPrevented).toBe(true);
+ expect(await screen.findByTestId("chat-conversation-search-input")).toBeInTheDocument();
+
+ const rawTerminalTarget = document.createElement("textarea");
+ rawTerminalTarget.className = "xterm";
+ document.body.append(rawTerminalTarget);
+ expect(dispatchFind(rawTerminalTarget).defaultPrevented).toBe(false);
+ rawTerminalTarget.remove();
+ hybridView.unmount();
+ });
+
+ it("uses one activated visible host and releases ownership when retained Quick Chat hides", async () => {
+ setupMockChat({ sessions: [activeSessionFixture], filteredSessions: [activeSessionFixture] });
+ const first = await renderWithAct(
);
+ const second = await renderWithAct(
);
+ const inputs = screen.getAllByTestId("chat-search-input");
+ fireEvent.pointerDown(inputs[1]!);
+ const event = dispatchFind(document.body);
+ expect(event.defaultPrevented).toBe(true);
+ expect(inputs[1]).toHaveFocus();
+ expect(inputs[0]).not.toHaveFocus();
+
+ second.rerender(
);
+ const hiddenEvent = dispatchFind(document.body);
+ expect(hiddenEvent.defaultPrevented).toBe(false);
+ first.unmount();
+ second.unmount();
+ });
+
+ it("leaves dialog targets alone and clears stale no-match, whitespace, and streaming results", async () => {
+ const chatState = {
+ activeSession: activeSessionFixture,
+ sessions: [activeSessionFixture],
+ filteredSessions: [activeSessionFixture],
+ messages: [{ id: "message-search", sessionId: activeSessionFixture.id, role: "assistant" as const, content: "stable needle", createdAt: "2026-01-01T00:00:00.000Z" }],
+ isStreaming: false,
+ streamingText: "",
+ };
+ setupMockChat(chatState);
+ const result = await renderWithAct(
);
+ await enterDirectDetail();
+ const dialog = document.createElement("div");
+ dialog.setAttribute("role", "dialog");
+ const dialogInput = document.createElement("input");
+ dialog.append(dialogInput);
+ document.body.append(dialog);
+ expect(dispatchFind(dialogInput).defaultPrevented).toBe(false);
+ dialog.remove();
+
+ dispatchFind(screen.getByTestId("chat-message-message-search"));
+ const input = await screen.findByTestId("chat-conversation-search-input");
+ fireEvent.change(input, { target: { value: "missing" } });
+ expect(screen.getByText("No matches")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Next match" })).toBeDisabled();
+ fireEvent.change(input, { target: { value: " " } });
+ expect(screen.getByText("No matches")).toBeInTheDocument();
+
+ setupMockChat({ ...chatState, isStreaming: true, streamingText: "stream needle" });
+ result.rerender(
);
+ fireEvent.change(screen.getByTestId("chat-conversation-search-input"), { target: { value: "stream" } });
+ expect(screen.getByText("1 of 1 matches")).toBeInTheDocument();
+ expect(screen.getByTestId("chat-message-__streaming__")).toHaveClass("chat-message--search-active");
+ });
+
it("shows matchedMessagePreview for a session included via content match, with no toggle present", async () => {
const contentMatchedSession = {
...activeSessionFixture,
diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts
index 8eef48493d..2eb811720b 100644
--- a/packages/dashboard/vitest.config.ts
+++ b/packages/dashboard/vitest.config.ts
@@ -248,6 +248,7 @@ FNXC:ChatNavigation 2026-08-19-21:10:
FN-054 requires the focused Chat lane to collect every suite that protects shared list-to-detail navigation. Keep responsive, history, creation, and mount contracts together so the task command cannot silently omit stale selector or split-pane assertions.
*/
const qualityAppChatOnlyTests = [
+ "app/components/__tests__/ChatView.content-search.test.tsx",
"app/components/__tests__/ChatView.core.test.tsx",
"app/components/__tests__/ChatView.core-contracts.test.tsx",
"app/components/__tests__/ChatView.context-window.test.tsx",
diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json
index 9a7c24b1ee..1170ae15ab 100644
--- a/packages/i18n/locales/en/app.json
+++ b/packages/i18n/locales/en/app.json
@@ -1379,6 +1379,13 @@
"scopeRooms": "Rooms",
"scrollMessageToTop": "Scroll message to top",
"searchConversations": "Search conversations...",
+ "conversationSearchPlaceholder": "Find in conversation",
+ "conversationSearchLabel": "Find in conversation",
+ "conversationSearchNoMatches": "No matches",
+ "conversationSearchMatchCount": "{{current}} of {{count}} matches",
+ "conversationSearchPrevious": "Previous match",
+ "conversationSearchNext": "Next match",
+ "conversationSearchClose": "Close search",
"selectAgentForNewChat": "Select agent for new chat",
"selectAgentPlaceholder": "Select an agent to start chatting",
"selectModel": "Select a model",
diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json
index 0c8b283a68..03810a7d9a 100644
--- a/packages/i18n/locales/es/app.json
+++ b/packages/i18n/locales/es/app.json
@@ -1366,6 +1366,13 @@
"scopeRooms": "Canales",
"scrollMessageToTop": "Desplazar mensaje al inicio",
"searchConversations": "Buscar conversaciones...",
+ "conversationSearchPlaceholder": "Buscar en la conversación",
+ "conversationSearchLabel": "Buscar en la conversación",
+ "conversationSearchNoMatches": "Sin resultados",
+ "conversationSearchMatchCount": "{{current}} de {{count}} resultados",
+ "conversationSearchPrevious": "Resultado anterior",
+ "conversationSearchNext": "Resultado siguiente",
+ "conversationSearchClose": "Cerrar búsqueda",
"selectAgentForNewChat": "Seleccionar agente para el nuevo chat",
"selectAgentPlaceholder": "Selecciona un agente para empezar a chatear",
"selectModel": "Seleccionar un modelo",
diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json
index 8f7993a751..e47af78ace 100644
--- a/packages/i18n/locales/fr/app.json
+++ b/packages/i18n/locales/fr/app.json
@@ -1366,6 +1366,13 @@
"scopeRooms": "Salons",
"scrollMessageToTop": "Faire défiler le message vers le haut",
"searchConversations": "Rechercher des conversations…",
+ "conversationSearchPlaceholder": "Rechercher dans la conversation",
+ "conversationSearchLabel": "Rechercher dans la conversation",
+ "conversationSearchNoMatches": "Aucun résultat",
+ "conversationSearchMatchCount": "{{current}} sur {{count}} résultats",
+ "conversationSearchPrevious": "Résultat précédent",
+ "conversationSearchNext": "Résultat suivant",
+ "conversationSearchClose": "Fermer la recherche",
"selectAgentForNewChat": "Sélectionner un agent pour le nouveau chat",
"selectAgentPlaceholder": "Sélectionnez un agent pour commencer à discuter",
"selectModel": "Choisir un modèle",
diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json
index eb3794a361..ac50bf09e3 100644
--- a/packages/i18n/locales/ko/app.json
+++ b/packages/i18n/locales/ko/app.json
@@ -1366,6 +1366,13 @@
"scopeRooms": "방",
"scrollMessageToTop": "메시지를 맨 위로 스크롤",
"searchConversations": "대화 검색...",
+ "conversationSearchPlaceholder": "대화에서 찾기",
+ "conversationSearchLabel": "대화에서 찾기",
+ "conversationSearchNoMatches": "일치하는 항목 없음",
+ "conversationSearchMatchCount": "{{current}} / {{count}} 일치",
+ "conversationSearchPrevious": "이전 일치",
+ "conversationSearchNext": "다음 일치",
+ "conversationSearchClose": "검색 닫기",
"selectAgentForNewChat": "새 채팅을 위한 에이전트 선택",
"selectAgentPlaceholder": "채팅을 시작할 에이전트를 선택하세요",
"selectModel": "모델 선택",
diff --git a/packages/i18n/locales/pt-BR/app.json b/packages/i18n/locales/pt-BR/app.json
index 2c62b2ec65..5de80ef145 100644
--- a/packages/i18n/locales/pt-BR/app.json
+++ b/packages/i18n/locales/pt-BR/app.json
@@ -1379,6 +1379,13 @@
"scopeRooms": "Salas",
"scrollMessageToTop": "Rolar mensagem para o topo",
"searchConversations": "Buscar conversas...",
+ "conversationSearchPlaceholder": "Localizar na conversa",
+ "conversationSearchLabel": "Localizar na conversa",
+ "conversationSearchNoMatches": "Nenhuma correspondência",
+ "conversationSearchMatchCount": "{{current}} de {{count}} correspondências",
+ "conversationSearchPrevious": "Correspondência anterior",
+ "conversationSearchNext": "Próxima correspondência",
+ "conversationSearchClose": "Fechar pesquisa",
"selectAgentForNewChat": "Selecionar agente para novo chat",
"selectAgentPlaceholder": "Selecione um agente para começar a conversar",
"selectModel": "Selecione um modelo",
diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json
index aef26de21b..6b75785f20 100644
--- a/packages/i18n/locales/zh-CN/app.json
+++ b/packages/i18n/locales/zh-CN/app.json
@@ -1366,6 +1366,13 @@
"scopeRooms": "频道",
"scrollMessageToTop": "将消息滚动到顶部",
"searchConversations": "搜索对话...",
+ "conversationSearchPlaceholder": "在对话中查找",
+ "conversationSearchLabel": "在对话中查找",
+ "conversationSearchNoMatches": "无匹配项",
+ "conversationSearchMatchCount": "第 {{current}} 项,共 {{count}} 项",
+ "conversationSearchPrevious": "上一个匹配项",
+ "conversationSearchNext": "下一个匹配项",
+ "conversationSearchClose": "关闭搜索",
"selectAgentForNewChat": "为新聊天选择代理",
"selectAgentPlaceholder": "选择代理开始聊天",
"selectModel": "选择模型",
diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json
index b836307555..9909922a72 100644
--- a/packages/i18n/locales/zh-TW/app.json
+++ b/packages/i18n/locales/zh-TW/app.json
@@ -1366,6 +1366,13 @@
"scopeRooms": "頻道",
"scrollMessageToTop": "將訊息捲動至頂部",
"searchConversations": "搜尋對話...",
+ "conversationSearchPlaceholder": "在對話中尋找",
+ "conversationSearchLabel": "在對話中尋找",
+ "conversationSearchNoMatches": "沒有相符項目",
+ "conversationSearchMatchCount": "第 {{current}} 項,共 {{count}} 項",
+ "conversationSearchPrevious": "上一個相符項目",
+ "conversationSearchNext": "下一個相符項目",
+ "conversationSearchClose": "關閉搜尋",
"selectAgentForNewChat": "為新聊天選擇代理",
"selectAgentPlaceholder": "選擇代理以開始聊天",
"selectModel": "選擇模型",
diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts
index fba76cfa68..42c456e5df 100644
--- a/packages/i18n/src/resources.d.ts
+++ b/packages/i18n/src/resources.d.ts
@@ -1377,6 +1377,13 @@ export default interface Resources {
"scopeRooms": "Rooms",
"scrollMessageToTop": "Scroll message to top",
"searchConversations": "Search conversations...",
+ "conversationSearchPlaceholder": "Find in conversation",
+ "conversationSearchLabel": "Find in conversation",
+ "conversationSearchNoMatches": "No matches",
+ "conversationSearchMatchCount": "{{current}} of {{count}} matches",
+ "conversationSearchPrevious": "Previous match",
+ "conversationSearchNext": "Next match",
+ "conversationSearchClose": "Close search",
"selectAgentForNewChat": "Select agent for new chat",
"selectAgentPlaceholder": "Select an agent to start chatting",
"selectModel": "Select a model",