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 <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-110-chat-find.md
Normal file
7
.changeset/fn-110-chat-find.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
<!-- FNXC:ChatFindDocs 2026-08-21-16:29: FN-110 gives the active visible Chat host contextual Find ownership without adding a configurable dashboard shortcut. -->
|
||||
### Chat Find
|
||||
|
||||
In an active visible Chat list, <kbd>Ctrl+F</kbd> or <kbd>Cmd+F</kbd> 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.
|
||||
|
||||
<!-- FNXC:ModalGeometryPersistenceDocs 2026-07-16-00:40: Full-screen mobile FloatingWindow sheets must preserve, rather than overwrite, the movable desktop geometry record so a later desktop reopen restores the user's chosen location and size. -->
|
||||
<!-- FNXC:GitHubImport 2026-08-02-02:51: FN-8722 confirms that standalone GitHub Import also uses the canonical width-or-height sheet contract, so the operator guide must not describe short-sheet preservation as Artifact Gallery-only. -->
|
||||
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.
|
||||
|
||||
@@ -2165,6 +2165,7 @@ function AppInner() {
|
||||
projectId={currentProject.id}
|
||||
experimentalFeatures={experimentalFeatures}
|
||||
floating
|
||||
findActive={quickChatOpen}
|
||||
initialComposerDraft={chatComposerPrefill?.text}
|
||||
initialComposerDraftNonce={chatComposerPrefill?.nonce}
|
||||
onSendAsReport={handleSendChatMessageAsReport}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, boolean>;
|
||||
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<string>;
|
||||
}
|
||||
|
||||
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<HTMLDivElement>(null);
|
||||
const listSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
const conversationSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
const loadMoreSentinelRef = useRef<HTMLDivElement>(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 <CliChatSurface> (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<HTMLElement>(`[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 <div className="chat-conversation-search" data-testid="chat-conversation-search">
|
||||
<Search size={14} aria-hidden="true" />
|
||||
<input ref={conversationSearchInputRef} className="input chat-conversation-search-input" value={conversationSearchQuery} onChange={(event) => { setConversationSearchQuery(event.target.value); setConversationSearchIndex(0); }} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); closeConversationSearch(); } else if (event.key === "Enter") { event.preventDefault(); navigateConversationSearch(event.shiftKey ? -1 : 1); } }} placeholder={t("chat.conversationSearchPlaceholder", "Find in conversation") } aria-label={t("chat.conversationSearchLabel", "Find in conversation")} data-testid="chat-conversation-search-input" />
|
||||
<span className="chat-conversation-search-status" role="status" aria-live="polite">{status}</span>
|
||||
<button type="button" className="btn-icon" aria-label={t("chat.conversationSearchPrevious", "Previous match")} disabled={count === 0} onClick={() => navigateConversationSearch(-1)}><ChevronUp size={14} /></button>
|
||||
<button type="button" className="btn-icon" aria-label={t("chat.conversationSearchNext", "Next match")} disabled={count === 0} onClick={() => navigateConversationSearch(1)}><ChevronDown size={14} /></button>
|
||||
<button type="button" className="btn-icon" aria-label={t("chat.conversationSearchClose", "Close search")} onClick={closeConversationSearch}><X size={14} /></button>
|
||||
</div>;
|
||||
};
|
||||
|
||||
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 <CliChatSurface> (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}
|
||||
/>
|
||||
))}
|
||||
<StandardStreamingMessage
|
||||
@@ -2797,6 +2915,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
/* FNXC:StructuralMail 2026-08-09-09:09: A streaming answer is unfinished and must never be routed as a report. */
|
||||
copyAction={showProviderResponseCopy && streamingText ? renderMessageActions("__streaming__", streamingText, "assistant", "chat-copy-response-streaming", false) : undefined}
|
||||
onQuestionSubmit={handleQuestionSubmit}
|
||||
isSearchMatch={conversationSearchMatches.includes("__streaming__")}
|
||||
isSearchActive={activeConversationMatchId === "__streaming__"}
|
||||
/>
|
||||
</>
|
||||
) : 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
|
||||
<div className="chat-sidebar-search-wrapper">
|
||||
<Search size={14} className="chat-sidebar-search-icon" />
|
||||
<input
|
||||
ref={listSearchInputRef}
|
||||
type="text"
|
||||
className="chat-sidebar-search"
|
||||
placeholder={t("chat.searchConversations", "Search conversations...")}
|
||||
@@ -3642,6 +3765,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
{/* Thread */}
|
||||
{hasDetailSelection && chatRoomsEnabled && chatScope === "rooms" ? (
|
||||
<div ref={chatThreadRef} className="chat-thread">
|
||||
{renderConversationSearch()}
|
||||
{rooms.activeRoom ? (
|
||||
<>
|
||||
<div className="chat-room-thread-header">
|
||||
@@ -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 && (
|
||||
<button
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface CliChatSurfaceProps {
|
||||
renderTranscript: () => ReactNode;
|
||||
/** Renders the existing ChatView composer (hidden in raw-terminal mode). */
|
||||
renderComposer: () => ReactNode;
|
||||
/** Renders ChatView's transcript-only find row; raw terminal stays terminal-owned. */
|
||||
renderSearch?: () => ReactNode;
|
||||
/** Number of composer messages queued behind a busy session (0 = none). */
|
||||
queuedCount?: number;
|
||||
/** Extra props forwarded to SessionTerminal (posture, settings link, etc.). */
|
||||
@@ -50,6 +52,7 @@ export function CliChatSurface({
|
||||
projectId,
|
||||
renderTranscript,
|
||||
renderComposer,
|
||||
renderSearch,
|
||||
queuedCount = 0,
|
||||
terminalProps,
|
||||
}: CliChatSurfaceProps) {
|
||||
@@ -101,7 +104,7 @@ export function CliChatSurface({
|
||||
// owns input. Composer is hidden below.
|
||||
<SessionTerminal sessionId={cliSessionId} projectId={projectId} {...terminalProps} />
|
||||
) : (
|
||||
renderTranscript()
|
||||
<>{renderSearch?.()}{renderTranscript()}</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<string, unknown>) => 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 = <div className="chat-message-time">{formatRelativeTime(message.createdAt, t)}</div>;
|
||||
return (
|
||||
<div className={`chat-message chat-message--${message.role}${failureInfo ? " chat-message--failure" : ""}${isEditing ? " chat-message--editing" : ""}`} data-testid={`chat-message-${message.id}`} data-message-id={message.id}>
|
||||
<div className={`chat-message chat-message--${message.role}${failureInfo ? " chat-message--failure" : ""}${isEditing ? " chat-message--editing" : ""}${isSearchMatch ? " chat-message--search-match" : ""}${isSearchActive ? " chat-message--search-active" : ""}`} data-testid={`chat-message-${message.id}`} data-message-id={message.id}>
|
||||
{showAssistantIdentity && <div className="chat-message-avatar">{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="sm" /> : <Bot size={14} />}<span>{agentName}</span>{showAssistantModelTag && activeModelTag && <span className="chat-model-tag">{activeModelTag}</span>}</div>}
|
||||
{isEditing ? (
|
||||
<StandardChatMessageEditComposer
|
||||
@@ -799,10 +807,10 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
|
||||
);
|
||||
});
|
||||
|
||||
export function StandardStreamingMessage({ streamingText, streamingThinking = "", streamingToolCalls = [], forcePlain, agentName, hideAssistantIdentity, showAssistantModelTag, activeModelTag, activeModelProvider, copyAction, onQuestionSubmit, toolCallRenderer }: StandardStreamingMessageProps) {
|
||||
export function StandardStreamingMessage({ streamingText, streamingThinking = "", streamingToolCalls = [], forcePlain, agentName, hideAssistantIdentity, showAssistantModelTag, activeModelTag, activeModelProvider, copyAction, onQuestionSubmit, toolCallRenderer, isSearchMatch = false, isSearchActive = false }: StandardStreamingMessageProps) {
|
||||
const { t } = useTranslation("app");
|
||||
return (
|
||||
<div className="chat-message chat-message--assistant chat-message--streaming" data-testid="chat-message-__streaming__">
|
||||
<div className={`chat-message chat-message--assistant chat-message--streaming${isSearchMatch ? " chat-message--search-match" : ""}${isSearchActive ? " chat-message--search-active" : ""}`} data-testid="chat-message-__streaming__" data-message-id="__streaming__">
|
||||
{!hideAssistantIdentity && <div className="chat-message-avatar">{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="sm" /> : <Bot size={14} />}<span>{agentName}</span>{showAssistantModelTag && activeModelTag && <span className="chat-model-tag">{activeModelTag}</span>}</div>}
|
||||
{streamingText ? renderStandardAssistantContent(streamingText, forcePlain) : <div className="chat-message-content chat-message-content--waiting">{streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.workingStatus", "Working…")}</div>}
|
||||
{copyAction}
|
||||
|
||||
@@ -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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
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(<ChatView projectId="proj-123" addToast={vi.fn()} findActive={false} />);
|
||||
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(<ChatView projectId="proj-123" addToast={vi.fn()} {...hostProps} />);
|
||||
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
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(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
const second = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} floating />);
|
||||
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(<ChatView projectId="proj-123" addToast={vi.fn()} floating findActive={false} />);
|
||||
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1366,6 +1366,13 @@
|
||||
"scopeRooms": "방",
|
||||
"scrollMessageToTop": "메시지를 맨 위로 스크롤",
|
||||
"searchConversations": "대화 검색...",
|
||||
"conversationSearchPlaceholder": "대화에서 찾기",
|
||||
"conversationSearchLabel": "대화에서 찾기",
|
||||
"conversationSearchNoMatches": "일치하는 항목 없음",
|
||||
"conversationSearchMatchCount": "{{current}} / {{count}} 일치",
|
||||
"conversationSearchPrevious": "이전 일치",
|
||||
"conversationSearchNext": "다음 일치",
|
||||
"conversationSearchClose": "검색 닫기",
|
||||
"selectAgentForNewChat": "새 채팅을 위한 에이전트 선택",
|
||||
"selectAgentPlaceholder": "채팅을 시작할 에이전트를 선택하세요",
|
||||
"selectModel": "모델 선택",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1366,6 +1366,13 @@
|
||||
"scopeRooms": "频道",
|
||||
"scrollMessageToTop": "将消息滚动到顶部",
|
||||
"searchConversations": "搜索对话...",
|
||||
"conversationSearchPlaceholder": "在对话中查找",
|
||||
"conversationSearchLabel": "在对话中查找",
|
||||
"conversationSearchNoMatches": "无匹配项",
|
||||
"conversationSearchMatchCount": "第 {{current}} 项,共 {{count}} 项",
|
||||
"conversationSearchPrevious": "上一个匹配项",
|
||||
"conversationSearchNext": "下一个匹配项",
|
||||
"conversationSearchClose": "关闭搜索",
|
||||
"selectAgentForNewChat": "为新聊天选择代理",
|
||||
"selectAgentPlaceholder": "选择代理开始聊天",
|
||||
"selectModel": "选择模型",
|
||||
|
||||
@@ -1366,6 +1366,13 @@
|
||||
"scopeRooms": "頻道",
|
||||
"scrollMessageToTop": "將訊息捲動至頂部",
|
||||
"searchConversations": "搜尋對話...",
|
||||
"conversationSearchPlaceholder": "在對話中尋找",
|
||||
"conversationSearchLabel": "在對話中尋找",
|
||||
"conversationSearchNoMatches": "沒有相符項目",
|
||||
"conversationSearchMatchCount": "第 {{current}} 項,共 {{count}} 項",
|
||||
"conversationSearchPrevious": "上一個相符項目",
|
||||
"conversationSearchNext": "下一個相符項目",
|
||||
"conversationSearchClose": "關閉搜尋",
|
||||
"selectAgentForNewChat": "為新聊天選擇代理",
|
||||
"selectAgentPlaceholder": "選擇代理以開始聊天",
|
||||
"selectModel": "選擇模型",
|
||||
|
||||
7
packages/i18n/src/resources.d.ts
vendored
7
packages/i18n/src/resources.d.ts
vendored
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user