From 294e826ea063f69a7db8ea143919e6e5bda98c32 Mon Sep 17 00:00:00 2001 From: Fusion Agent Date: Fri, 21 Aug 2026 17:00:57 +0000 Subject: [PATCH] FN-110: integrate Ctrl+F search into chat lists and conversations Add keyboard-driven search across chat navigation and conversation content. - Add Ctrl+F search controls for chat lists and conversation messages. - Support result navigation, highlighting, and localized labels across all dashboard locales. - Update chat surfaces, documentation, changeset metadata, and regression coverage. Files changed: .changeset/fn-110-chat-find.md | 7 + docs/dashboard-guide.md | 5 + packages/dashboard/app/App.tsx | 1 + packages/dashboard/app/components/ChatView.css | 41 +++++ packages/dashboard/app/components/ChatView.tsx | 162 +++++++++++++++-- .../dashboard/app/components/CliChatSurface.tsx | 5 +- .../dashboard/app/components/StandardChatSurface.tsx | 14 +- .../__tests__/ChatView.content-search.test.tsx | 197 ++++++++++++++++++++- packages/dashboard/vitest.config.ts | 1 + packages/i18n/locales/en/app.json | 7 + packages/i18n/locales/es/app.json | 7 + packages/i18n/locales/fr/app.json | 7 + packages/i18n/locales/ko/app.json | 7 + packages/i18n/locales/pt-BR/app.json | 7 + packages/i18n/locales/zh-CN/app.json | 7 + packages/i18n/locales/zh-TW/app.json | 7 + packages/i18n/src/resources.d.ts | 7 + 17 files changed, 467 insertions(+), 22 deletions(-) Fusion-Task-Id: FN-110 Fusion-Task-Lineage: 33ef1888-d250-489a-80b8-f3c5069da532 Co-authored-by: Fusion --- .changeset/fn-110-chat-find.md | 7 + docs/dashboard-guide.md | 5 + packages/dashboard/app/App.tsx | 1 + .../dashboard/app/components/ChatView.css | 41 ++++ .../dashboard/app/components/ChatView.tsx | 162 ++++++++++++-- .../app/components/CliChatSurface.tsx | 5 +- .../app/components/StandardChatSurface.tsx | 14 +- .../ChatView.content-search.test.tsx | 197 +++++++++++++++++- packages/dashboard/vitest.config.ts | 1 + packages/i18n/locales/en/app.json | 7 + packages/i18n/locales/es/app.json | 7 + packages/i18n/locales/fr/app.json | 7 + packages/i18n/locales/ko/app.json | 7 + packages/i18n/locales/pt-BR/app.json | 7 + packages/i18n/locales/zh-CN/app.json | 7 + packages/i18n/locales/zh-TW/app.json | 7 + packages/i18n/src/resources.d.ts | 7 + 17 files changed, 467 insertions(+), 22 deletions(-) create mode 100644 .changeset/fn-110-chat-find.md diff --git a/.changeset/fn-110-chat-find.md b/.changeset/fn-110-chat-find.md new file mode 100644 index 0000000000..2c5e5d7207 --- /dev/null +++ b/.changeset/fn-110-chat-find.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add contextual Find controls to Chat conversations. +category: feature +dev: Chat owns Ctrl/Cmd+F only in its active list or transcript surface. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index cda7e6fc30..576869d1c5 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -101,6 +101,11 @@ Every configured action toggles its interface: press once to open Quick Chat, Te Press `Escape` to close the current/topmost dashboard popup. Popped-out task windows and floating Quick Chat close before fixed app modals such as Terminal, Settings, Files, or Task Detail, and only one surface closes per key press. Nested editors and menus that already handle Escape keep first ownership by preventing the global handler. + +### Chat Find + +In an active visible Chat list, Ctrl+F or Cmd+F focuses the existing conversation search without changing its query. In a selected Direct conversation, Room, or native/hybrid CLI transcript, the same chord opens **Find in conversation**. Type a literal case-insensitive query, use Enter (Shift+Enter for previous) or the previous/next controls to move through matching message rows, and use Escape or Close search to dismiss it. Only the activated full, floating, or docked Chat host owns the chord; raw/generic CLI terminals and nested dialogs keep their own browser or terminal behavior. + Movable dashboard pop-outs remember their last desktop location and size, while centered resizable dialogs remember their size. When a pop-out becomes a full-screen sheet at mobile widths or its configured short-height breakpoint, it leaves that desktop record untouched; reopening it on desktop restores the prior floating geometry. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index cf9fbcee42..98457bf88a 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -2165,6 +2165,7 @@ function AppInner() { projectId={currentProject.id} experimentalFeatures={experimentalFeatures} floating + findActive={quickChatOpen} initialComposerDraft={chatComposerPrefill?.text} initialComposerDraftNonce={chatComposerPrefill?.nonce} onSendAsReport={handleSendChatMessageAsReport} diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index ae7f303cff..46376032a7 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -678,6 +678,47 @@ FNXC:ChatRenderToggle 2026-07-04-00:00: The thread-wide Markdown/plain render toggle (`.chat-thread-header-render-toggle*`, desktop + mobile floating variants) was removed per FN-7541. Chat always renders Markdown now; the button and its CSS shells no longer exist. */ +/* +FNXC:ChatFind 2026-08-21-16:29: +FN-110 keeps the conversation-local Find row compact across Chat hosts and distinguishes a matching row from the currently navigated result using existing semantic tokens. +*/ +.chat-conversation-search { + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-sm) var(--space-md); + border-bottom: 1px solid var(--border); + background: var(--surface); +} + +.chat-conversation-search-input { + flex: 1 1 auto; + min-width: 0; +} + +.chat-conversation-search-status { + color: var(--text-muted); + white-space: nowrap; +} + +.chat-message--search-match { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-warning) 55%, transparent); +} + +.chat-message--search-active { + box-shadow: inset 0 0 0 2px var(--color-warning); +} + +@media (max-width: 768px) { + .chat-conversation-search { + flex-wrap: wrap; + } + + .chat-conversation-search-input { + min-inline-size: 0; + } +} + /* Messages */ .chat-messages { flex: 1 1 auto; diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index c8541069a8..07a91d1640 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -11,6 +11,7 @@ import { Bot, Paperclip, ChevronDown, + ChevronUp, Copy, Check, Maximize2, @@ -104,6 +105,11 @@ export interface ChatViewProps { addToast: (msg: string, type?: "success" | "error" | "warning") => void; experimentalFeatures?: Record; floating?: boolean; + /** + * FNXC:ChatFind 2026-08-21-16:44: + * A retained-but-hidden Quick Chat must release document Find ownership; visible hosts retain the default. + */ + findActive?: boolean; /** Enables the "/" command registry (e.g. `/steer`) for this composer instance. See {@link ChatCommandContext}. */ chatCommandContext?: ChatCommandContext; /* @@ -149,6 +155,7 @@ export function resolveChatContextMenuPosition( /** Canonical definition lives in packages/dashboard/src/chat.ts (ROOM_SKIP_SENTINEL). */ const ROOM_SKIP_SENTINEL = "__SKIP__"; let chatViewWasPreviouslyInactive = false; +let activeChatFindOwner: HTMLElement | null = null; export { clampChatInputHeight, resolveChatInputOverflowY } from "../utils/chatInputAutosize"; @@ -550,7 +557,7 @@ interface RoomContext { memberIds: ReadonlySet; } -export function ChatView({ projectId, addToast, floating = false, compactLayout = false, onPopOut, onMaximize, onClose, chatCommandContext, initialComposerDraft, initialComposerDraftNonce, onSendAsReport }: ChatViewProps) { +export function ChatView({ projectId, addToast, floating = false, compactLayout = false, findActive = true, onPopOut, onMaximize, onClose, chatCommandContext, initialComposerDraft, initialComposerDraftNonce, onSendAsReport }: ChatViewProps) { const { t } = useTranslation("app"); const chatMessageLayout = useChatMessageLayout(); useEffect(() => { @@ -756,6 +763,9 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout return callback so either route restores the same list state. */ const [detailOpen, setDetailOpen] = useState(false); + const [conversationSearchOpen, setConversationSearchOpen] = useState(false); + const [conversationSearchQuery, setConversationSearchQuery] = useState(""); + const [conversationSearchIndex, setConversationSearchIndex] = useState(0); const [createRoomOpen, setCreateRoomOpen] = useState(false); const { agentsMap: cachedAgentsMap } = useAgentsMapCache(projectId); const agentsMap = useMemo(() => (chatAgentsMap.size > 0 ? chatAgentsMap : cachedAgentsMap), [cachedAgentsMap, chatAgentsMap]); @@ -807,6 +817,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout }, [fileMention.mentionActive]); const messagesEndRef = useRef(null); + const listSearchInputRef = useRef(null); + const conversationSearchInputRef = useRef(null); const loadMoreSentinelRef = useRef(null); const isUserScrollingRef = useRef(false); const lastAnchoredThreadStateRef = useRef<{ threadId: string; loaded: boolean; hasMessages: boolean } | null>(null); @@ -1209,6 +1221,16 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout }, []); const activeThreadMessages = roomThreadActive ? rooms.messages : messages; + const conversationSearchMatches = useMemo(() => { + const query = conversationSearchQuery.trim().toLocaleLowerCase(); + if (!query) return [] as string[]; + const messageIds = activeThreadMessages + .filter((message) => message.content.trim() !== ROOM_SKIP_SENTINEL && message.content.toLocaleLowerCase().includes(query)) + .map((message) => message.id); + if (!roomThreadActive && isStreaming && streamingText.toLocaleLowerCase().includes(query)) messageIds.push("__streaming__"); + return messageIds; + }, [activeThreadMessages, conversationSearchQuery, isStreaming, roomThreadActive, streamingText]); + const activeConversationMatchId = conversationSearchMatches[conversationSearchIndex] ?? null; /* FNXC:ChatMessageScrollToTop 2026-07-12-23:16: @@ -2576,10 +2598,16 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout const handleBack = useCallback(() => { setDetailOpen(false); + setConversationSearchOpen(false); + setConversationSearchQuery(""); + setConversationSearchIndex(0); }, []); const handleRoomBack = useCallback(() => { setDetailOpen(false); + setConversationSearchOpen(false); + setConversationSearchQuery(""); + setConversationSearchIndex(0); }, []); const handleVisibleDetailBack = useCallback(() => { @@ -2624,8 +2652,112 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout const activeModelProvider = activeResolvedModel?.provider ?? null; const hasThreadInView = Boolean(activeSession || isStreaming || messages.length > 0); const hasDetailSelection = detailOpen && (chatScope === "rooms" ? roomThreadActive : hasThreadInView); + // ── CLI-backed chat mount (U12) ────────────────────────────────────────── + // When the active chat session selects a cli-agent executor, the message-pane + // + composer region is delegated to (transcript + raw-terminal + // toggle for hybrid/native adapters, terminal-only for the generic adapter). + // The transcript renderer and composer renderer are the EXISTING ChatView JSX + // passed through as thunks so there is no parallel message/composer UI. + const cliAdapterId = activeSession?.cliExecutorAdapterId ?? null; + const cliChatActive = Boolean(cliAdapterId); + // Generic adapter has no structured transcript → terminal-only; every other + // bundled adapter exposes a transcript and gets the toggle (the authoritative + // tier is resolved server-side; this only needs the generic vs. non-generic + // split that drives the toggle's presence). + const cliChatTier: CliChatTier = cliAdapterId === "generic" ? "generic" : "hybrid"; + // Terminal attach id: the native session linkage when known, else the chat id. + const cliTerminalSessionId = activeSession?.cliSessionFile || activeSession?.id || ""; + const previousDetailOpenRef = useRef(hasDetailSelection); + /* + FNXC:ChatFind 2026-08-21-16:29: + FN-110 keeps browser Find outside Chat while the visible, activated Chat host owns Ctrl/Cmd+F. List Find reuses its server-backed input; thread Find is presentation-only over rendered rows and never changes chat state. + */ + useEffect(() => { + if (!conversationSearchOpen) return; + setConversationSearchIndex((index) => Math.min(index, Math.max(0, conversationSearchMatches.length - 1))); + }, [conversationSearchMatches.length, conversationSearchOpen]); + + useEffect(() => { + setConversationSearchOpen(false); + setConversationSearchQuery(""); + setConversationSearchIndex(0); + }, [activeSession?.id, rooms.activeRoom?.id, chatScope]); + + const focusConversationSearch = useCallback(() => { + setConversationSearchOpen(true); + window.setTimeout(() => conversationSearchInputRef.current?.focus(), 0); + }, []); + + const closeConversationSearch = useCallback(() => { + setConversationSearchOpen(false); + setConversationSearchQuery(""); + setConversationSearchIndex(0); + }, []); + + const navigateConversationSearch = useCallback((direction: 1 | -1) => { + if (conversationSearchMatches.length === 0) return; + setConversationSearchIndex((index) => (index + direction + conversationSearchMatches.length) % conversationSearchMatches.length); + }, [conversationSearchMatches.length]); + + useEffect(() => { + if (!activeConversationMatchId) return; + const message = messagesContainerRef.current?.querySelector(`[data-message-id="${activeConversationMatchId}"]`); + message?.scrollIntoView({ block: "nearest" }); + }, [activeConversationMatchId]); + + useEffect(() => { + const root = chatViewRef.current; + if (!root) return; + if (!findActive) { + if (activeChatFindOwner === root) activeChatFindOwner = null; + return; + } + const activate = () => { activeChatFindOwner = root; }; + root.addEventListener("pointerdown", activate); + root.addEventListener("focusin", activate); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key.toLowerCase() !== "f" || event.altKey || (!event.ctrlKey && !event.metaKey)) return; + const target = event.target instanceof Element ? event.target : null; + const ownsTarget = Boolean(target?.closest(".chat-view") === root); + const nestedDialog = target?.closest("[role=dialog]"); + if ((!ownsTarget && activeChatFindOwner !== root) || nestedDialog || target?.closest(".xterm, [data-terminal-owner]")) return; + if (!hasDetailSelection) { + if (chatScope !== "direct") return; + event.preventDefault(); + listSearchInputRef.current?.focus(); + return; + } + if (cliChatActive && cliChatTier === "generic") return; + event.preventDefault(); + focusConversationSearch(); + }; + document.addEventListener("keydown", onKeyDown); + return () => { + root.removeEventListener("pointerdown", activate); + root.removeEventListener("focusin", activate); + if (activeChatFindOwner === root) activeChatFindOwner = null; + document.removeEventListener("keydown", onKeyDown); + }; + }, [chatScope, cliChatActive, cliChatTier, findActive, focusConversationSearch, hasDetailSelection]); + + const renderConversationSearch = () => { + if (!conversationSearchOpen) return null; + const count = conversationSearchMatches.length; + const status = count === 0 + ? t("chat.conversationSearchNoMatches", "No matches") + : t("chat.conversationSearchMatchCount", "{{current}} of {{count}} matches", { current: conversationSearchIndex + 1, count }); + return
+
; + }; + useEffect(() => { const previousDetailOpen = previousDetailOpenRef.current; previousDetailOpenRef.current = hasDetailSelection; @@ -2723,22 +2855,6 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout containerEl.scrollTo({ top, behavior: prefersReducedMotion ? "auto" : "smooth" }); }, []); - // ── CLI-backed chat mount (U12) ────────────────────────────────────────── - // When the active chat session selects a cli-agent executor, the message-pane - // + composer region is delegated to (transcript + raw-terminal - // toggle for hybrid/native adapters, terminal-only for the generic adapter). - // The transcript renderer and composer renderer are the EXISTING ChatView JSX - // passed through as thunks so there is no parallel message/composer UI. - const cliAdapterId = activeSession?.cliExecutorAdapterId ?? null; - const cliChatActive = Boolean(cliAdapterId); - // Generic adapter has no structured transcript → terminal-only; every other - // bundled adapter exposes a transcript and gets the toggle (the authoritative - // tier is resolved server-side; this only needs the generic vs. non-generic - // split that drives the toggle's presence). - const cliChatTier: CliChatTier = cliAdapterId === "generic" ? "generic" : "hybrid"; - // Terminal attach id: the native session linkage when known, else the chat id. - const cliTerminalSessionId = activeSession?.cliSessionFile || activeSession?.id || ""; - /* * FNXC:ChatMessageEdit 2026-07-07-09:00: * Editing is supported only for direct (model-loop) chat sessions: never CLI-agent-backed @@ -2782,6 +2898,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout onQuestionSubmit={handleQuestionSubmit} canEdit={canEditChatMessages} onEditMessage={editMessageAndResend} + isSearchMatch={conversationSearchMatches.includes(message.id)} + isSearchActive={activeConversationMatchId === message.id} /> ))} ) : messagesLoading && messages.length === 0 ? ( @@ -2829,6 +2949,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout onQuestionSubmit={handleQuestionSubmit} canEdit={canEditChatMessages} onEditMessage={editMessageAndResend} + isSearchMatch={conversationSearchMatches.includes(message.id)} + isSearchActive={activeConversationMatchId === message.id} /> ))} @@ -3219,6 +3341,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
+ {renderConversationSearch()} {rooms.activeRoom ? ( <>
@@ -3703,6 +3827,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout isTopClipped={topClippedMessageIds.has(message.id)} isAwaitingQuestionAnswer={false} onQuestionSubmit={handleQuestionSubmit} + isSearchMatch={conversationSearchMatches.includes(message.id)} + isSearchActive={activeConversationMatchId === message.id} /> ); }) @@ -3893,9 +4019,11 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout projectId={projectId} renderTranscript={renderSessionMessagesPane} renderComposer={() => (activeSession ? renderSessionComposerPane() : null)} + renderSearch={renderConversationSearch} /> ) : ( <> + {renderConversationSearch()} {renderSessionMessagesPane()} {isUserScrolling && (
diff --git a/packages/dashboard/app/components/StandardChatSurface.tsx b/packages/dashboard/app/components/StandardChatSurface.tsx index 6fb51dd552..fba3ce0080 100644 --- a/packages/dashboard/app/components/StandardChatSurface.tsx +++ b/packages/dashboard/app/components/StandardChatSurface.tsx @@ -65,6 +65,9 @@ export interface StandardChatMessageItemProps { * false or `onEditMessage` is absent, no affordance renders at all — never a disabled/dead one. */ canEdit?: boolean; + /** Optional ChatView-only find presentation; omitted consumers remain unchanged. */ + isSearchMatch?: boolean; + isSearchActive?: boolean; } export interface StandardStreamingMessageProps { @@ -80,6 +83,9 @@ export interface StandardStreamingMessageProps { copyAction?: ReactNode; onQuestionSubmit?: (answerText: string, structured: Record) => void; toolCallRenderer?: (toolCall: ToolCallInfo, index: number) => ReactNode | undefined; + /** Optional ChatView-only find presentation; omitted consumers remain unchanged. */ + isSearchMatch?: boolean; + isSearchActive?: boolean; } export interface StandardChatActionButtonProps { @@ -630,6 +636,8 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({ onEditMessage, canEdit = false, isTopClipped = false, + isSearchMatch = false, + isSearchActive = false, projectId, }: StandardChatMessageItemProps) { const { t } = useTranslation("app"); @@ -760,7 +768,7 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({ const hasVisibleAssistantFooterContent = Boolean(message.thinkingOutput || copyAction || (onScrollToTop && isTopClipped)); const messageTime =
{formatRelativeTime(message.createdAt, t)}
; return ( -
+
{showAssistantIdentity &&
{activeModelProvider ? : }{agentName}{showAssistantModelTag && activeModelTag && {activeModelTag}}
} {isEditing ? ( +
{!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",