diff --git a/src/components/ComposerInput/postedReferenceHref.test.ts b/src/components/ComposerInput/__tests__/postedReferenceHref.test.ts similarity index 98% rename from src/components/ComposerInput/postedReferenceHref.test.ts rename to src/components/ComposerInput/__tests__/postedReferenceHref.test.ts index 29fb472e7b..7b379dd9ec 100644 --- a/src/components/ComposerInput/postedReferenceHref.test.ts +++ b/src/components/ComposerInput/__tests__/postedReferenceHref.test.ts @@ -6,7 +6,7 @@ import { storePillText } from "@src/config/pillTokens"; import { isSafePostedReferenceHref, resolvePostedReferenceHref, -} from "./postedReferenceHref"; +} from "../postedReferenceHref"; describe("resolvePostedReferenceHref", () => { afterEach(() => { diff --git a/src/engines/ChatPanel/ChatHistory/ChatHistory.types.ts b/src/engines/ChatPanel/ChatHistory/ChatHistory.types.ts index a910eed918..4362eb4798 100644 --- a/src/engines/ChatPanel/ChatHistory/ChatHistory.types.ts +++ b/src/engines/ChatPanel/ChatHistory/ChatHistory.types.ts @@ -63,7 +63,6 @@ export interface ChatHistoryProps { onScrollNavChange?: (state: ScrollNavState) => void; followAgentNav?: FollowAgentNavState; browserAddToConversationNav?: BrowserAddToConversationNavState; - onRegisterSearchOpen?: (handler: (() => void) | null) => void; displayMode?: ChatHistoryDisplayMode; turnPaginationEnabled?: boolean; /** Optional external host for pinned/pagination chrome, outside the scroll body subtree. */ diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx index 6364cf8248..fefdd8afca 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx @@ -32,6 +32,10 @@ import { PlanningFooter } from "@src/engines/ChatPanel/blocks/primitives"; import { CHAT_PANEL_TRANSCRIPT_TOP_PADDING_PX } from "@src/engines/ChatPanel/header/chatPanelHeaderLayout"; import type { OptimizedChatItem } from "../chatItemPipeline/types"; +import { + findChatSearchTargetElement, + scrollSearchTargetIntoView, +} from "../hooks/chatSearch"; import { getUnloadedTurnMeta } from "../hooks/useChatGroups"; import { GroupItemRenderer } from "../renderers"; import { useChatHistoryListActiveGroupReporter } from "./ChatHistoryListActiveGroupReporter"; @@ -112,6 +116,13 @@ const ChatHistoryList: React.FC = memo( flatItemsRef.current = flatItems; const previousChatItemsRef = useRef<(OptimizedChatItem | undefined)[]>([]); + const turnIdsRef = useRef(turnIds); + turnIdsRef.current = turnIds; + const assistantCopyEventIdsByGroupRef = useRef( + assistantCopyEventIdsByGroup + ); + assistantCopyEventIdsByGroupRef.current = assistantCopyEventIdsByGroup; + // When the planning indicator is active, inject it as a virtual item // in the last group so it renders under the latest turn's header — // not as the global Virtuoso Footer which visually attaches to the @@ -156,6 +167,44 @@ const ChatHistoryList: React.FC = memo( groupRenderKeys[index] ?? `chat-group-index:${index}`, }); const virtualItems = virtualizer.getVirtualItems(); + const rowResizeObserverRef = useRef(null); + const measuredRowHeightsRef = useRef(new WeakMap()); + const observedRowsRef = useRef(new Set()); + const measureVirtualRow = useCallback( + (node: HTMLDivElement | null) => { + virtualizer.measureElement(node); + if (!node) return; + if (!rowResizeObserverRef.current) { + rowResizeObserverRef.current = new ResizeObserver((entries) => { + for (const entry of entries) { + const target = entry.target; + const nextHeight = + entry.borderBoxSize[0]?.blockSize ?? + target.getBoundingClientRect().height; + if (measuredRowHeightsRef.current.get(target) === nextHeight) { + continue; + } + measuredRowHeightsRef.current.set(target, nextHeight); + virtualizer.measureElement(target as HTMLElement); + } + }); + } + if (!observedRowsRef.current.has(node)) { + observedRowsRef.current.add(node); + rowResizeObserverRef.current.observe(node); + } + }, + [virtualizer] + ); + + useEffect(() => { + const observedRows = observedRowsRef.current; + return () => { + rowResizeObserverRef.current?.disconnect(); + rowResizeObserverRef.current = null; + observedRows.clear(); + }; + }, [virtualListDataKey]); useEffect(() => { if (virtualItems.length === 0) return; @@ -175,10 +224,6 @@ const ChatHistoryList: React.FC = memo( useImperativeHandle( virtualListRef, () => ({ - scrollToIndex: ({ index, behavior = "auto", align = "center" }) => { - const groupIndex = flatIndexToGroupIndex[index] ?? 0; - virtualizer.scrollToIndex(groupIndex, { align, behavior }); - }, scrollToGroup: ({ groupIndex, behavior = "smooth" }) => { const boundedGroupIndex = Math.max( 0, @@ -202,12 +247,55 @@ const ChatHistoryList: React.FC = memo( behavior, }); }, + scrollToChatTarget: ({ + eventId, + itemId, + flatIndex, + behavior = "auto", + }) => { + const scrollRoot = + virtualScrollerRef.current ?? staticScrollerRef?.current; + if (!scrollRoot) return; + + const scrollToDomTarget = (): boolean => { + const target = findChatSearchTargetElement(scrollRoot, { + eventId, + itemId, + flatIndex, + }); + if (!target) return false; + scrollSearchTargetIntoView(scrollRoot, target, behavior); + return true; + }; + + if (scrollToDomTarget()) return; + + if ( + flatIndex === undefined || + scrollRoot !== virtualScrollerRef.current + ) { + return; + } + + const groupIndex = flatIndexToGroupIndex[flatIndex] ?? 0; + virtualizer.scrollToIndex(groupIndex, { + align: "start", + behavior: "auto", + }); + + window.requestAnimationFrame(() => { + if (!scrollToDomTarget()) { + window.requestAnimationFrame(scrollToDomTarget); + } + }); + }, }), [ flatIndexToGroupIndex, staticScrollerRef, virtualGroups.length, virtualizer, + virtualScrollerRef, ] ); const rowGroupMeta = useMemo( @@ -217,12 +305,6 @@ const ChatHistoryList: React.FC = memo( ); const rowGroupMetaRef = useRef(rowGroupMeta); rowGroupMetaRef.current = rowGroupMeta; - const turnIdsRef = useRef(turnIds); - turnIdsRef.current = turnIds; - const assistantCopyEventIdsByGroupRef = useRef( - assistantCopyEventIdsByGroup - ); - assistantCopyEventIdsByGroupRef.current = assistantCopyEventIdsByGroup; // For each flat index, the nearest preceding qualifying item — non-structural, // non-unloaded, with an event. Pre-computed once per flatItems change so @@ -332,6 +414,7 @@ const ChatHistoryList: React.FC = memo( hideActiveGroupHeader, onActiveGroupIndexChange, }); + const setScrollContainerRef = useCallback( (node: HTMLDivElement | null) => { if (useStaticRendering) { @@ -439,7 +522,7 @@ const ChatHistoryList: React.FC = memo( groupRenderKeys[group.groupIndex] ?? `chat-group-index:${group.groupIndex}` } - ref={virtualizer.measureElement} + ref={measureVirtualRow} data-index={virtualItem.index} data-chat-group-index={group.groupIndex} className="absolute left-0 top-0 w-full" diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListTypes.ts b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListTypes.ts index f3cab38a94..1eb0320131 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListTypes.ts +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListTypes.ts @@ -17,15 +17,16 @@ import type { GroupHeaderRenderPart } from "../renderers/GroupHeaderRenderer"; export type EventSummary = NonNullable; export interface ChatHistoryListHandle { - scrollToIndex: (options: { - index: number; - behavior?: ScrollBehavior; - align?: "start" | "center" | "end" | "auto"; - }) => void; scrollToGroup: (options: { groupIndex: number; behavior?: ScrollBehavior; }) => void; + scrollToChatTarget: (options: { + eventId?: string; + itemId?: string; + flatIndex?: number; + behavior?: ScrollBehavior; + }) => void; } export interface ChatHistoryListProps { diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx index 4ae5b83035..bfbd728941 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx @@ -17,7 +17,7 @@ import type { useChatHistoryItemActions } from "../hooks/useChatHistoryItemActio import type { useChatHistoryProjectionModel } from "../hooks/useChatHistoryProjectionModel"; import type { UseChatHistoryStateReturn } from "../hooks/useChatHistoryState"; import type { useChatNavigationController } from "../hooks/useChatNavigationController"; -import type { UseChatSearchIntegrationReturn } from "../hooks/useChatSearchIntegration"; +import type { UseChatSearchReturn } from "../hooks/useChatSearch"; import type { useChatViewportController } from "../hooks/useChatViewportController"; import { useGroupHeaderRenderer } from "../hooks/useGroupHeaderRenderer"; import type { useReloadSession } from "../hooks/useReloadSession"; @@ -73,7 +73,7 @@ interface ChatHistoryViewProps { pinnedHeaderPortalHost: HTMLElement | null; planningIndicatorScope: { sessionId: string; isLive: boolean } | null; projection: ProjectionModel; - search: UseChatSearchIntegrationReturn; + search: UseChatSearchReturn; surfaceBgClass: string; turnPaginationEnabled: boolean; viewport: ViewportModel; @@ -197,12 +197,6 @@ const ChatHistoryView: React.FC = ({ handleRegenerateGroup, handleSubmitAnswers, } = actions; - const { - search: searchState, - isSearchVisible, - searchBarRef, - handleCloseSearch, - } = search; const getIsWpGeneWorking = useCallback( () => isWpGeneWorkingRef.current ?? false, @@ -212,11 +206,11 @@ const ChatHistoryView: React.FC = ({ () => isExploringRef.current ?? false, [isExploringRef] ); + const hasCloudDownloadProgress = useCloudSessionHasDownloadSurface(activeId); const assistantCopyEventIdsByGroup = useMemo( () => displayGroupMeta.map((meta) => meta.assistantCopyEventIds), [displayGroupMeta] ); - const hasCloudDownloadProgress = useCloudSessionHasDownloadSurface(activeId); const renderGroupHeader = useGroupHeaderRenderer({ displaySourceGroupIndices, @@ -313,6 +307,23 @@ const ChatHistoryView: React.FC = ({ } /> ); + const pinnedChromeLayer = ( + <> + {search.isSearchVisible ? ( +
+
+ +
+
+ ) : null} + {pinnedHeaderLayer} + + ); return ( @@ -330,24 +341,17 @@ const ChatHistoryView: React.FC = ({ - - {pinnedHeaderPortalHost ? createPortal(
- {pinnedHeaderLayer} + {pinnedChromeLayer}
, pinnedHeaderPortalHost ) - : pinnedHeaderLayer} + : pinnedChromeLayer} {/* Anchor cloud-download progress to the chat-pane header edge instead of the virtualized body below SessionHeader. Transcript items and diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatSearchBar.tsx b/src/engines/ChatPanel/ChatHistory/components/ChatSearchBar.tsx index c63b4cdc45..cf56bc51d6 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatSearchBar.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/ChatSearchBar.tsx @@ -3,53 +3,20 @@ * * "Find in chat" search bar reusing the shared SearchInput component * (same as TerminalSearchPanel) for visual consistency. - * - * Features: - * - Case-sensitive, whole-word & regex toggle buttons - * - Result count and up/down navigation - * - Escape to close */ import { X } from "lucide-react"; -import { - type RefObject, - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useRef, -} from "react"; +import { type RefObject, useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { SearchInput } from "@src/components/SearchInput"; import type { UseChatSearchReturn } from "../hooks/useChatSearch"; -// ============================================ -// Types -// ============================================ - export interface ChatSearchBarProps { - /** Search state from useChatSearch hook */ search: UseChatSearchReturn; - /** Callback when search bar is closed */ - onClose?: () => void; - /** Whether the search bar is visible */ - isVisible: boolean; -} - -export interface ChatSearchBarHandle { - /** Focus the search input */ - focus: () => void; } -// ============================================ -// Component -// ============================================ - -export const ChatSearchBar = forwardRef< - ChatSearchBarHandle | null, - ChatSearchBarProps ->(function ChatSearchBar({ search, onClose, isVisible }, ref) { +export function ChatSearchBar({ search }: ChatSearchBarProps) { const { t } = useTranslation("sessions"); const inputRef = useRef(null); @@ -61,7 +28,8 @@ export const ChatSearchBar = forwardRef< currentResultIndex, nextResult, prevResult, - clearSearch, + closeSearch, + isSearchVisible, caseSensitive, toggleCaseSensitive, useRegex, @@ -70,50 +38,34 @@ export const ChatSearchBar = forwardRef< toggleWholeWord, } = search; - // Expose focus method - useImperativeHandle(ref, () => ({ - focus: () => inputRef.current?.focus(), - })); - - // Focus input when visible useEffect(() => { - if (isVisible) { - const timer = setTimeout(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }, 50); - return () => clearTimeout(timer); - } - }, [isVisible]); + if (!isSearchVisible) return; + const timer = setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 50); + return () => clearTimeout(timer); + }, [isSearchVisible]); - // Handle Escape to close useEffect(() => { - if (!isVisible) return; + if (!isSearchVisible) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); - clearSearch(); - onClose?.(); + closeSearch(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [isVisible, clearSearch, onClose]); + }, [isSearchVisible, closeSearch]); - // Handle close button - const handleClose = useCallback(() => { - clearSearch(); - onClose?.(); - }, [clearSearch, onClose]); - - // Handle Enter → next result const handleSubmit = useCallback(() => { nextResult(); }, [nextResult]); - if (!isVisible) return null; + if (!isSearchVisible) return null; return (
@@ -136,7 +88,6 @@ export const ChatSearchBar = forwardRef< inputBoxClassName="flex-none w-full max-w-[240px]" /> - {/* Result count */} {!query ? "" @@ -147,10 +98,9 @@ export const ChatSearchBar = forwardRef< : t("chat.noResults")} - {/* Close button — pushed to the right end */}
); -}); +} export default ChatSearchBar; diff --git a/src/engines/ChatPanel/ChatHistory/components/__tests__/ChatHistoryListIdentity.test.ts b/src/engines/ChatPanel/ChatHistory/components/__tests__/ChatHistoryListIdentity.test.ts index 50a0607913..c9c49a0e1a 100644 --- a/src/engines/ChatPanel/ChatHistory/components/__tests__/ChatHistoryListIdentity.test.ts +++ b/src/engines/ChatPanel/ChatHistory/components/__tests__/ChatHistoryListIdentity.test.ts @@ -161,6 +161,14 @@ describe("ChatHistoryList turn identity", () => { imageMounts = 0; imageUnmounts = 0; measureElementSpy.mockClear(); + vi.stubGlobal( + "ResizeObserver", + class ResizeObserverMock { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + } + ); container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); @@ -169,6 +177,7 @@ describe("ChatHistoryList turn identity", () => { afterEach(() => { act(() => root.unmount()); container.remove(); + vi.unstubAllGlobals(); }); afterAll(() => { diff --git a/src/engines/ChatPanel/ChatHistory/components/index.ts b/src/engines/ChatPanel/ChatHistory/components/index.ts index 1c073997b5..2e52543f89 100644 --- a/src/engines/ChatPanel/ChatHistory/components/index.ts +++ b/src/engines/ChatPanel/ChatHistory/components/index.ts @@ -1,7 +1,5 @@ export { default as ChatHistoryEmptyState } from "./ChatHistoryEmptyState"; export { default as ChatHistoryList } from "./ChatHistoryList"; -export { ChatSearchBar } from "./ChatSearchBar"; -export type { ChatSearchBarHandle, ChatSearchBarProps } from "./ChatSearchBar"; export { default as RevertConfirmDialog } from "./RevertConfirmDialog"; export { revertConfirmAtom, showRevertConfirm } from "./RevertConfirmDialog"; export type { RevertChoice } from "./RevertConfirmDialog"; diff --git a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/chatSearchHelpers.test.ts b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/chatSearchHelpers.test.ts new file mode 100644 index 0000000000..6947a24891 --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/chatSearchHelpers.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { + buildChatSearchableText, + mapRustResultsToSearchResults, + searchChatHistoryLocally, + wrapNextSearchResultIndex, +} from "../chatSearchHelpers"; + +function event(id: string, chunkId: string | null = id): SessionEvent { + return { + chunk_id: chunkId, + id, + sessionId: "session-1", + createdAt: "2026-08-25T00:00:00.000Z", + functionName: "assistant_message", + uiCanonical: "assistant_message", + actionType: "assistant", + args: {}, + result: {}, + source: "assistant", + displayText: `text-${id}`, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + }; +} + +describe("chatSearchHelpers", () => { + it("maps rust results onto chat history items by event id", () => { + const history = [event("a"), event("b")]; + const mapped = mapRustResultsToSearchResults( + [{ eventId: "b", chatIndex: 0, score: 4, snippet: "hit" }], + history + ); + + expect(mapped).toEqual([ + { + item: history[1], + index: 1, + score: 4, + snippet: "hit", + }, + ]); + }); + + it("falls back to rust chatIndex when event id is missing", () => { + const history = [event("a"), event("b")]; + const mapped = mapRustResultsToSearchResults( + [{ eventId: "missing", chatIndex: 1, score: 1, snippet: "..." }], + history + ); + + expect(mapped[0]?.index).toBe(1); + }); + + it("wraps result navigation forward and backward", () => { + expect(wrapNextSearchResultIndex(0, 3, 1)).toBe(1); + expect(wrapNextSearchResultIndex(2, 3, 1)).toBe(0); + expect(wrapNextSearchResultIndex(0, 3, -1)).toBe(2); + }); + + it("builds searchable text from event fields", () => { + const history = [ + { + ...event("a"), + result: { content: "比较 Codex 网页与 org2 方案" }, + displayText: "", + }, + ]; + expect(buildChatSearchableText(history[0]!)).toContain("网页"); + }); + + it("searches loaded chat history when rust mapping would be empty", () => { + const history = [ + { + ...event("a"), + result: { content: "hello world" }, + displayText: "", + }, + { + ...event("b"), + result: { content: "比较 Codex 网页与 org2 方案" }, + displayText: "", + }, + ]; + + const results = searchChatHistoryLocally( + history, + "网页", + { + caseSensitive: false, + useRegex: false, + wholeWord: false, + }, + 10 + ); + + expect(results).toHaveLength(1); + expect(results[0]?.item.id).toBe("b"); + expect(results[0]?.snippet).toContain("网页"); + }); +}); diff --git a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/chatSearchProjection.test.ts b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/chatSearchProjection.test.ts new file mode 100644 index 0000000000..9cd2af8167 --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/chatSearchProjection.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment jsdom +import { describe, expect, it } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import type { OptimizedChatItem } from "../../chatItemPipeline/types"; +import { + CHAT_EVENT_IDS_ATTR, + CHAT_FLAT_INDEX_ATTR, + CHAT_ITEM_ID_ATTR, + findChatSearchTargetElement, + formatChatEventIdsAttribute, +} from "../chatSearchDom"; +import { + buildEventIdProjectionIndex, + collectChatItemEventIds, + resolvePageIndexForFlatIndex, + toDisplayFlatIndex, +} from "../chatSearchProjection"; + +function event(id: string): SessionEvent { + return { + chunk_id: id, + id, + sessionId: "session-1", + createdAt: "2026-08-25T00:00:00.000Z", + functionName: "glob_file_search", + uiCanonical: "glob_file_search", + actionType: "tool_call", + args: {}, + result: {}, + source: "assistant", + displayText: id, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + }; +} + +function item( + chunkId: string, + extra: Partial = {} +): OptimizedChatItem { + return { + chunk_id: chunkId, + type: "activity", + event: event(chunkId), + ...extra, + } as OptimizedChatItem; +} + +describe("chatSearchProjection", () => { + it("collects nested activity stack event ids", () => { + const stack = item("stack-1", { + type: "activityStackGroup", + activityStackGroup: { + category: "terminal", + events: [event("cmd-1"), event("cmd-2")], + }, + }); + + expect(collectChatItemEventIds(stack)).toEqual( + expect.arrayContaining(["stack-1", "cmd-1", "cmd-2"]) + ); + }); + + it("maps event ids to global flat indices and turn ids", () => { + const flatItems = [item("evt-1"), item("evt-2")]; + const index = buildEventIdProjectionIndex( + flatItems, + [1, 1], + [{ turnId: "turn-a" }, { turnId: "turn-b" }] + ); + + expect(index.get("evt-2")).toMatchObject({ + globalFlatIndex: 1, + groupIndex: 1, + turnId: "turn-b", + itemChunkId: "evt-2", + }); + }); + + it("resolves pagination page and display-local flat index", () => { + const pages = [ + { flatStartIndex: 0, flatEndIndex: 2 }, + { flatStartIndex: 2, flatEndIndex: 4 }, + ]; + + expect(resolvePageIndexForFlatIndex(3, pages)).toBe(1); + expect(toDisplayFlatIndex(3, pages[1])).toBe(1); + }); + + it("keeps global flat indices when pagination is off (no page slice)", () => { + const firstPageOnly = { flatStartIndex: 0, flatEndIndex: 2 }; + + expect(toDisplayFlatIndex(5, undefined)).toBe(5); + expect(toDisplayFlatIndex(5, firstPageOnly)).toBeNull(); + }); +}); + +describe("chatSearchDom", () => { + it("finds projected rows by event id and flat index", () => { + const root = document.createElement("div"); + root.innerHTML = ` +
+ `; + + expect( + findChatSearchTargetElement(root, { eventId: "cmd-1" })?.getAttribute( + CHAT_ITEM_ID_ATTR + ) + ).toBe("stack-1"); + expect( + findChatSearchTargetElement(root, { flatIndex: 4 })?.getAttribute( + CHAT_ITEM_ID_ATTR + ) + ).toBe("stack-1"); + }); +}); diff --git a/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/chatSearchHighlightDom.ts b/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/chatSearchHighlightDom.ts new file mode 100644 index 0000000000..3179f4b4cf --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/chatSearchHighlightDom.ts @@ -0,0 +1,64 @@ +export const SEARCH_TEXT_HIGHLIGHT_CLASS = "search-text-highlight"; +export const SEARCH_TEXT_HIGHLIGHT_ACTIVE_CLASS = + "search-text-highlight--active"; + +const SKIP_TAGS = new Set(["SCRIPT", "STYLE", "MARK"]); + +export function clearSearchTextHighlights(container: HTMLElement) { + container + .querySelectorAll( + `mark.${SEARCH_TEXT_HIGHLIGHT_CLASS}, mark.${SEARCH_TEXT_HIGHLIGHT_ACTIVE_CLASS}` + ) + .forEach((mark) => { + const parent = mark.parentNode; + if (!parent) return; + parent.replaceChild( + document.createTextNode(mark.textContent || ""), + mark + ); + parent.normalize(); + }); +} + +function highlightFirstMatch(node: Node, query: string) { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent || ""; + const matchIndex = text.toLowerCase().indexOf(query.toLowerCase()); + if (matchIndex < 0) return; + + const range = document.createRange(); + range.setStart(node, matchIndex); + range.setEnd(node, matchIndex + query.length); + const mark = document.createElement("mark"); + mark.className = SEARCH_TEXT_HIGHLIGHT_CLASS; + range.surroundContents(mark); + return; + } + + if (node.nodeType !== Node.ELEMENT_NODE) return; + const element = node as Element; + if ( + SKIP_TAGS.has(element.tagName) || + element.classList.contains(SEARCH_TEXT_HIGHLIGHT_CLASS) || + element.classList.contains(SEARCH_TEXT_HIGHLIGHT_ACTIVE_CLASS) + ) { + return; + } + + Array.from(node.childNodes).forEach((child) => + highlightFirstMatch(child, query) + ); +} + +/** Case-insensitive DOM substring highlight for the active search query. */ +export function applySearchTextHighlight( + container: HTMLElement | null, + query: string, + enabled: boolean +) { + if (!container) return; + clearSearchTextHighlights(container); + const trimmedQuery = query.trim(); + if (!enabled || !trimmedQuery) return; + highlightFirstMatch(container, trimmedQuery); +} diff --git a/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/chatSearchSyncWrite.ts b/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/chatSearchSyncWrite.ts new file mode 100644 index 0000000000..1b438c9cfc --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/chatSearchSyncWrite.ts @@ -0,0 +1,40 @@ +import type { ChatSearchSyncState } from "@src/store/ui/chatPanel/miscAtoms"; + +export const EMPTY_CHAT_SEARCH_SYNC: ChatSearchSyncState = { + query: "", + activeEventId: null, +}; + +type ChatSearchSyncResult = { + item: { id?: string | null; chunk_id?: string | null }; +}; + +export function resolveChatSearchActiveEventId( + result: ChatSearchSyncResult +): string | null { + return result.item.id || result.item.chunk_id || null; +} + +export function buildChatSearchSyncState(input: { + isOpen: boolean; + query: string; + results: ReadonlyArray; + currentResultIndex: number; +}): ChatSearchSyncState { + if (!input.isOpen) return EMPTY_CHAT_SEARCH_SYNC; + + const activeResult = input.results[input.currentResultIndex]; + return { + query: input.query, + activeEventId: activeResult + ? resolveChatSearchActiveEventId(activeResult) + : null, + }; +} + +export function writeChatSearchSyncState( + setSync: (value: ChatSearchSyncState) => void, + state: ChatSearchSyncState +) { + setSync(state); +} diff --git a/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/chatSearchTargetDom.ts b/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/chatSearchTargetDom.ts new file mode 100644 index 0000000000..3da2016d98 --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/chatSearchTargetDom.ts @@ -0,0 +1,178 @@ +export const CHAT_ITEM_ID_ATTR = "data-chat-item-id"; +export const CHAT_EVENT_IDS_ATTR = "data-chat-event-ids"; +export const CHAT_FLAT_INDEX_ATTR = "data-chat-flat-index"; + +/** Shared row marker for chat history + station message surfaces. */ +export const SEARCH_TARGET_MESSAGE_ID_ATTR = "data-search-target-message-id"; +export const SEARCH_TARGET_EVENT_ID_ATTR = "data-search-target-event-id"; +export const SEARCH_ACTIVE_ATTR = "data-search-active"; + +/** @deprecated Prefer SEARCH_TARGET_* — kept for in-flight DOM queries. */ +export const STATION_MESSAGE_ID_ATTR = SEARCH_TARGET_MESSAGE_ID_ATTR; +export const STATION_EVENT_ID_ATTR = SEARCH_TARGET_EVENT_ID_ATTR; + +export interface ChatSearchDomTarget { + eventId?: string; + itemId?: string; + flatIndex?: number; +} + +function escapeSelectorValue(value: string): string { + if (typeof CSS !== "undefined" && typeof CSS.escape === "function") { + return CSS.escape(value); + } + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +export function formatChatEventIdsAttribute( + eventIds: readonly string[] +): string { + return eventIds.join(" "); +} + +export function isSearchTargetActive( + target: { messageId: string; eventId: string }, + activeEventId: string | null | undefined +): boolean { + return Boolean( + activeEventId && + (target.messageId === activeEventId || target.eventId === activeEventId) + ); +} + +export function buildSearchTargetRowProps( + target: { messageId: string; eventId: string }, + activeEventId: string | null | undefined +) { + return { + [SEARCH_TARGET_MESSAGE_ID_ATTR]: target.messageId, + [SEARCH_TARGET_EVENT_ID_ATTR]: target.eventId, + ...(isSearchTargetActive(target, activeEventId) + ? { [SEARCH_ACTIVE_ATTR]: "true" as const } + : {}), + }; +} + +export function findChatSearchTargetElement( + scrollRoot: HTMLElement, + target: ChatSearchDomTarget +): HTMLElement | null { + if (target.eventId) { + const escaped = escapeSelectorValue(target.eventId); + const byEventIds = scrollRoot.querySelector( + `[${CHAT_EVENT_IDS_ATTR}~="${escaped}"]` + ); + if (byEventIds) return byEventIds; + const byEventId = scrollRoot.querySelector( + `[${CHAT_EVENT_IDS_ATTR}="${escaped}"]` + ); + if (byEventId) return byEventId; + const bySharedEventId = scrollRoot.querySelector( + `[${SEARCH_TARGET_EVENT_ID_ATTR}="${escaped}"]` + ); + if (bySharedEventId) return bySharedEventId; + } + + if (target.itemId) { + const byItemId = scrollRoot.querySelector( + `[${CHAT_ITEM_ID_ATTR}="${escapeSelectorValue(target.itemId)}"]` + ); + if (byItemId) return byItemId; + } + + if (target.flatIndex !== undefined) { + const byFlatIndex = scrollRoot.querySelector( + `[${CHAT_FLAT_INDEX_ATTR}="${target.flatIndex}"]` + ); + if (byFlatIndex) return byFlatIndex; + const byLegacyFlatIndex = scrollRoot.querySelector( + `[data-item-index="${target.flatIndex}"]` + ); + if (byLegacyFlatIndex) return byLegacyFlatIndex; + } + + return null; +} + +export function findSearchTargetElement( + scrollRoot: HTMLElement, + target: ChatSearchDomTarget +): HTMLElement | null { + const fromChat = findChatSearchTargetElement(scrollRoot, target); + if (fromChat) return fromChat; + + if (!target.eventId) return null; + const escaped = escapeSelectorValue(target.eventId); + return scrollRoot.querySelector( + `[${SEARCH_TARGET_MESSAGE_ID_ATTR}="${escaped}"]` + ); +} + +const SEARCH_SCROLL_IN_VIEW_PADDING_PX = 48; + +export function scrollSearchTargetIntoView( + scrollRoot: HTMLElement, + element: HTMLElement, + behavior: ScrollBehavior = "auto" +) { + const rootRect = scrollRoot.getBoundingClientRect(); + const elRect = element.getBoundingClientRect(); + const padding = SEARCH_SCROLL_IN_VIEW_PADDING_PX; + + if ( + elRect.top >= rootRect.top + padding && + elRect.bottom <= rootRect.bottom - padding + ) { + return; + } + + const elementTop = elRect.top - rootRect.top + scrollRoot.scrollTop; + let targetTop = scrollRoot.scrollTop; + + if (elRect.top < rootRect.top + padding) { + targetTop = elementTop - padding; + } else if (elRect.bottom > rootRect.bottom - padding) { + targetTop = elementTop + elRect.height - scrollRoot.clientHeight + padding; + } + + scrollRoot.scrollTo({ + top: Math.max(0, targetTop), + behavior, + }); +} + +export function resolveVisibleSearchResultIndex( + scrollRoot: HTMLElement, + resultEventIds: readonly string[] +): number | null { + if (resultEventIds.length === 0) return null; + + const rootRect = scrollRoot.getBoundingClientRect(); + const centerY = rootRect.top + rootRect.height * 0.35; + let bestIndex: number | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + + resultEventIds.forEach((eventId, index) => { + const element = findSearchTargetElement(scrollRoot, { eventId }); + if (!element) return; + + const rect = element.getBoundingClientRect(); + if (rect.bottom < rootRect.top || rect.top > rootRect.bottom) return; + + const distance = Math.abs(rect.top + rect.height / 2 - centerY); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = index; + } + }); + + return bestIndex; +} + +export function clearSearchActiveMarkers(container: HTMLElement) { + container + .querySelectorAll(`[${SEARCH_ACTIVE_ATTR}="true"]`) + .forEach((node) => { + node.removeAttribute(SEARCH_ACTIVE_ATTR); + }); +} diff --git a/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/index.ts b/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/index.ts new file mode 100644 index 0000000000..ee3b6ccb2b --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/index.ts @@ -0,0 +1,48 @@ +import { + findSearchTargetElement, + scrollSearchTargetIntoView, +} from "./chatSearchTargetDom"; + +export { + SEARCH_TEXT_HIGHLIGHT_CLASS, + SEARCH_TEXT_HIGHLIGHT_ACTIVE_CLASS, + applySearchTextHighlight, + clearSearchTextHighlights, +} from "./chatSearchHighlightDom"; +export { + CHAT_EVENT_IDS_ATTR, + CHAT_FLAT_INDEX_ATTR, + CHAT_ITEM_ID_ATTR, + SEARCH_ACTIVE_ATTR, + SEARCH_TARGET_EVENT_ID_ATTR, + SEARCH_TARGET_MESSAGE_ID_ATTR, + buildSearchTargetRowProps, + clearSearchActiveMarkers, + findChatSearchTargetElement, + findSearchTargetElement, + formatChatEventIdsAttribute, + isSearchTargetActive, + resolveVisibleSearchResultIndex, + scrollSearchTargetIntoView, +} from "./chatSearchTargetDom"; +export type { ChatSearchDomTarget } from "./chatSearchTargetDom"; +export { + EMPTY_CHAT_SEARCH_SYNC, + buildChatSearchSyncState, + resolveChatSearchActiveEventId, + writeChatSearchSyncState, +} from "./chatSearchSyncWrite"; +export { useChatSearchPanePresentation } from "./useChatSearchPanePresentation"; +export type { UseChatSearchPanePresentationOptions } from "./useChatSearchPanePresentation"; +export { useChatSearchSyncState } from "./useChatSearchSyncState"; + +/** @deprecated Alias for scrollSearchTargetIntoView */ +export const scrollElementIntoView = scrollSearchTargetIntoView; + +/** @deprecated Alias for findSearchTargetElement({ eventId }) */ +export function findStationMessageElement( + scrollRoot: HTMLElement, + eventId: string +): HTMLElement | null { + return findSearchTargetElement(scrollRoot, { eventId }); +} diff --git a/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/useChatSearchPanePresentation.ts b/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/useChatSearchPanePresentation.ts new file mode 100644 index 0000000000..a585f94841 --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/useChatSearchPanePresentation.ts @@ -0,0 +1,109 @@ +import { type RefObject, useEffect, useRef } from "react"; + +import { + applySearchTextHighlight, + clearSearchTextHighlights, +} from "./chatSearchHighlightDom"; +import { + clearSearchActiveMarkers, + findSearchTargetElement, + scrollSearchTargetIntoView, +} from "./chatSearchTargetDom"; +import { useChatSearchSyncState } from "./useChatSearchSyncState"; + +export interface UseChatSearchPanePresentationOptions { + sessionId: string | null; + /** DOM root that receives query substring highlighting. */ + highlightRootRef: RefObject; + /** When set, scroll this container to the active search target. */ + scrollRootRef?: RefObject; + /** Station replay lists set this to avoid snapping back to bottom. */ + suppressFollowBottomRef?: RefObject; + /** Fires when the shared active event id changes (e.g. clear local selection). */ + onActiveEventChange?: (eventId: string | null) => void; + /** Extra deps that should re-run scroll/highlight after layout (pagination, view mode). */ + layoutKey?: string | number; +} + +/** + * Single presentation hook for every chat-search surface (ChatHistory, Station, …). + * Reads shared sync atoms; applies highlight + optional scroll/follow overrides. + */ +export function useChatSearchPanePresentation({ + sessionId, + highlightRootRef, + scrollRootRef, + suppressFollowBottomRef, + onActiveEventChange, + layoutKey = 0, +}: UseChatSearchPanePresentationOptions) { + const { isOpen, query, trimmedQuery, activeEventId, enabled } = + useChatSearchSyncState(sessionId); + const lastNotifiedEventIdRef = useRef(null); + + useEffect(() => { + const container = highlightRootRef.current; + if (!container) return; + + clearSearchTextHighlights(container); + if (!enabled) return; + + const timeoutId = window.setTimeout(() => { + applySearchTextHighlight(container, trimmedQuery, true); + }, 50); + + return () => { + window.clearTimeout(timeoutId); + clearSearchTextHighlights(container); + clearSearchActiveMarkers(container); + }; + }, [enabled, highlightRootRef, layoutKey, trimmedQuery]); + + useEffect(() => { + if (!isOpen) { + if (lastNotifiedEventIdRef.current !== null) { + lastNotifiedEventIdRef.current = null; + onActiveEventChange?.(null); + } + return; + } + + if (lastNotifiedEventIdRef.current === activeEventId) return; + lastNotifiedEventIdRef.current = activeEventId; + onActiveEventChange?.(activeEventId); + }, [activeEventId, isOpen, onActiveEventChange]); + + useEffect(() => { + if (!enabled || !activeEventId || !scrollRootRef) return; + const scrollRoot = scrollRootRef.current; + if (!scrollRoot) return; + + if (suppressFollowBottomRef) { + suppressFollowBottomRef.current = false; + } + + const frameId = window.requestAnimationFrame(() => { + const target = findSearchTargetElement(scrollRoot, { + eventId: activeEventId, + }); + if (target) { + scrollSearchTargetIntoView(scrollRoot, target, "auto"); + } + }); + + return () => window.cancelAnimationFrame(frameId); + }, [ + activeEventId, + enabled, + layoutKey, + scrollRootRef, + suppressFollowBottomRef, + ]); + + return { + isOpen, + query, + activeEventId: isOpen ? activeEventId : null, + enabled, + }; +} diff --git a/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/useChatSearchSyncState.ts b/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/useChatSearchSyncState.ts new file mode 100644 index 0000000000..97171c3a7f --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/useChatSearchSyncState.ts @@ -0,0 +1,22 @@ +import { useAtomValue } from "jotai"; + +import { + chatFindInChatOpenAtomFamily, + chatSearchSyncAtomFamily, +} from "@src/store/ui/chatPanelAtom"; + +/** Read the shared chat-search sync snapshot for any pane (history / station). */ +export function useChatSearchSyncState(sessionId: string | null) { + const sessionKey = sessionId ?? ""; + const isOpen = useAtomValue(chatFindInChatOpenAtomFamily(sessionKey)); + const sync = useAtomValue(chatSearchSyncAtomFamily(sessionKey)); + const trimmedQuery = sync.query.trim(); + + return { + isOpen, + query: sync.query, + trimmedQuery, + activeEventId: sync.activeEventId, + enabled: isOpen && trimmedQuery.length > 0, + }; +} diff --git a/src/engines/ChatPanel/ChatHistory/hooks/chatSearchDom.ts b/src/engines/ChatPanel/ChatHistory/hooks/chatSearchDom.ts new file mode 100644 index 0000000000..8a3317c1bb --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/hooks/chatSearchDom.ts @@ -0,0 +1,2 @@ +/** @deprecated Import from `./chatSearch` instead. */ +export * from "./chatSearch"; diff --git a/src/engines/ChatPanel/ChatHistory/hooks/chatSearchHelpers.ts b/src/engines/ChatPanel/ChatHistory/hooks/chatSearchHelpers.ts new file mode 100644 index 0000000000..2b564ba820 --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/hooks/chatSearchHelpers.ts @@ -0,0 +1,222 @@ +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { isVisibleInChat } from "@src/engines/SessionCore/ingestion/visibilityFilters"; + +const MAX_STRING_LEN = 10_000; +const SNIPPET_CONTEXT = 40; +const MAX_SNIPPET_LEN = 160; + +export interface RustSearchResult { + eventId: string; + chatIndex: number; + score: number; + snippet: string; +} + +export interface ChatSearchModes { + caseSensitive: boolean; + useRegex: boolean; + wholeWord: boolean; +} + +export const DEFAULT_CHAT_SEARCH_MODES: ChatSearchModes = { + caseSensitive: false, + useRegex: false, + wholeWord: false, +}; + +export interface MappedSearchResult { + item: SessionEvent; + index: number; + score: number; + snippet: string; +} + +function buildEventIdIndex( + chatHistory: readonly SessionEvent[] +): Map { + const index = new Map(); + for (let idx = 0; idx < chatHistory.length; idx++) { + const event = chatHistory[idx]; + if (event.id) index.set(event.id, idx); + if (event.chunk_id && event.chunk_id !== event.id) { + index.set(event.chunk_id, idx); + } + } + return index; +} + +function resolveHistoryIndex( + rustResult: RustSearchResult, + eventIndex: ReadonlyMap, + chatHistoryLength: number +): number | undefined { + const byId = eventIndex.get(rustResult.eventId); + if (byId !== undefined) return byId; + if (rustResult.chatIndex >= 0 && rustResult.chatIndex < chatHistoryLength) { + return rustResult.chatIndex; + } + return undefined; +} + +export function mapRustResultsToSearchResults( + rustResults: readonly RustSearchResult[], + chatHistory: readonly SessionEvent[] +): MappedSearchResult[] { + const eventIndex = buildEventIdIndex(chatHistory); + const mapped: MappedSearchResult[] = []; + + for (const rustResult of rustResults) { + const historyIndex = resolveHistoryIndex( + rustResult, + eventIndex, + chatHistory.length + ); + if (historyIndex === undefined) continue; + mapped.push({ + item: chatHistory[historyIndex], + index: historyIndex, + score: rustResult.score, + snippet: rustResult.snippet, + }); + } + + return mapped; +} + +export function wrapNextSearchResultIndex( + currentIndex: number, + resultCount: number, + direction: 1 | -1 +): number { + if (resultCount <= 0) return 0; + if (direction === 1) { + return (currentIndex + 1) % resultCount; + } + return currentIndex === 0 ? resultCount - 1 : currentIndex - 1; +} + +function extractStringsFromValue( + value: unknown, + parts: string[], + maxDepth: number, + currentDepth = 0 +): void { + if (currentDepth >= maxDepth) return; + if (typeof value === "string") { + if (value.length < MAX_STRING_LEN) parts.push(value); + return; + } + if (Array.isArray(value)) { + for (const item of value) { + extractStringsFromValue(item, parts, maxDepth, currentDepth + 1); + } + return; + } + if (value && typeof value === "object") { + for (const nested of Object.values(value)) { + extractStringsFromValue(nested, parts, maxDepth, currentDepth + 1); + } + } +} + +/** Mirrors Rust `build_searchable_text` for local fallback search. */ +export function buildChatSearchableText(event: SessionEvent): string { + const parts: string[] = []; + if (event.functionName) parts.push(event.functionName); + if (event.actionType) parts.push(event.actionType); + extractStringsFromValue(event.args, parts, 3); + extractStringsFromValue(event.result, parts, 4); + if (event.displayText) parts.push(event.displayText); + return parts.join(" "); +} + +function createSearchSnippet( + text: string, + query: string, + caseSensitive: boolean +): string { + const matchIndex = caseSensitive + ? text.indexOf(query) + : text.toLowerCase().indexOf(query.toLowerCase()); + if (matchIndex < 0) return ""; + + const start = Math.max(0, matchIndex - SNIPPET_CONTEXT); + const end = Math.min( + text.length, + matchIndex + query.length + SNIPPET_CONTEXT + ); + + let snippet = `${start > 0 ? "..." : ""}${text.slice(start, end)}${end < text.length ? "..." : ""}`; + if (snippet.length > MAX_SNIPPET_LEN) { + snippet = `${snippet.slice(0, MAX_SNIPPET_LEN)}...`; + } + return snippet; +} + +function findSearchMatchIndex( + text: string, + query: string, + modes: ChatSearchModes +): number | null { + const trimmed = query.trim(); + if (!trimmed) return null; + + if (modes.useRegex || modes.wholeWord) { + const pattern = modes.wholeWord + ? modes.useRegex + ? `\\b${trimmed}\\b` + : `\\b${trimmed.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b` + : trimmed; + const flags = modes.caseSensitive ? "" : "i"; + try { + const match = new RegExp(pattern, flags).exec(text); + return match?.index ?? null; + } catch { + return null; + } + } + + const matchIndex = modes.caseSensitive + ? text.indexOf(trimmed) + : text.toLowerCase().indexOf(trimmed.toLowerCase()); + return matchIndex >= 0 ? matchIndex : null; +} + +/** + * Search the already-loaded chat history when Rust EventStore search returns + * nothing (evicted store, id mapping drift, etc.). + */ +export function searchChatHistoryLocally( + chatHistory: readonly SessionEvent[], + query: string, + modes: ChatSearchModes, + maxResults: number +): MappedSearchResult[] { + const trimmedQuery = query.trim(); + if (!trimmedQuery || maxResults <= 0) return []; + + const results: MappedSearchResult[] = []; + for (let index = 0; index < chatHistory.length; index++) { + if (results.length >= maxResults) break; + const event = chatHistory[index]; + if (!isVisibleInChat(event)) continue; + + const searchable = buildChatSearchableText(event); + const matchIndex = findSearchMatchIndex(searchable, trimmedQuery, modes); + if (matchIndex === null) continue; + + results.push({ + item: event, + index, + score: matchIndex, + snippet: createSearchSnippet( + searchable, + trimmedQuery, + modes.caseSensitive + ), + }); + } + + results.sort((a, b) => a.score - b.score); + return results; +} diff --git a/src/engines/ChatPanel/ChatHistory/hooks/chatSearchProjection.ts b/src/engines/ChatPanel/ChatHistory/hooks/chatSearchProjection.ts new file mode 100644 index 0000000000..7d5c0e77d8 --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/hooks/chatSearchProjection.ts @@ -0,0 +1,103 @@ +import type { OptimizedChatItem } from "../chatItemPipeline/types"; +import type { ChatGroupMeta } from "./useChatGroups"; +import type { ChatTurnPage } from "./useChatTurnPagination"; + +interface ChatSearchProjectionTarget { + globalFlatIndex: number; + groupIndex: number; + turnId: string | null; + itemChunkId: string; +} + +export function collectChatItemEventIds(item: OptimizedChatItem): string[] { + const ids = new Set(); + if (item.chunk_id) ids.add(item.chunk_id); + if (item.event?.id) ids.add(item.event.id); + if (item.event?.chunk_id) ids.add(item.event.chunk_id); + + for (const event of item.readFileEvents ?? []) { + if (event.id) ids.add(event.id); + if (event.chunk_id) ids.add(event.chunk_id); + } + for (const entry of item.actionSummaryEntries ?? []) { + for (const event of entry.events) { + if (event.id) ids.add(event.id); + if (event.chunk_id) ids.add(event.chunk_id); + } + } + for (const entry of item.actionSummaryItems ?? []) { + if (entry.event.id) ids.add(entry.event.id); + if (entry.event.chunk_id) ids.add(entry.event.chunk_id); + } + for (const event of item.activityStackGroup?.events ?? []) { + if (event.id) ids.add(event.id); + if (event.chunk_id) ids.add(event.chunk_id); + } + + return [...ids]; +} + +function buildFlatIndexToGroupIndex(groupCounts: readonly number[]): number[] { + const map: number[] = []; + for (let groupIndex = 0; groupIndex < groupCounts.length; groupIndex++) { + const count = groupCounts[groupIndex] ?? 0; + for (let i = 0; i < count; i++) { + map.push(groupIndex); + } + } + return map; +} + +export function buildEventIdProjectionIndex( + flatItems: readonly OptimizedChatItem[], + groupCounts: readonly number[], + groupMeta: readonly Pick[] +): Map { + const flatToGroup = buildFlatIndexToGroupIndex(groupCounts); + const index = new Map(); + + flatItems.forEach((item, globalFlatIndex) => { + const groupIndex = flatToGroup[globalFlatIndex] ?? 0; + const target: ChatSearchProjectionTarget = { + globalFlatIndex, + groupIndex, + turnId: groupMeta[groupIndex]?.turnId ?? null, + itemChunkId: item.chunk_id, + }; + for (const eventId of collectChatItemEventIds(item)) { + index.set(eventId, target); + } + }); + + return index; +} + +export function resolvePageIndexForFlatIndex( + globalFlatIndex: number, + pages: readonly Pick[] +): number | null { + for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) { + const page = pages[pageIndex]; + if ( + globalFlatIndex >= page.flatStartIndex && + globalFlatIndex < page.flatEndIndex + ) { + return pageIndex; + } + } + return null; +} + +export function toDisplayFlatIndex( + globalFlatIndex: number, + page: Pick | undefined +): number | null { + if (!page) return globalFlatIndex; + if ( + globalFlatIndex < page.flatStartIndex || + globalFlatIndex >= page.flatEndIndex + ) { + return null; + } + return globalFlatIndex - page.flatStartIndex; +} diff --git a/src/engines/ChatPanel/ChatHistory/hooks/index.ts b/src/engines/ChatPanel/ChatHistory/hooks/index.ts index 6114c80365..f5fd4bc38d 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/index.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/index.ts @@ -23,17 +23,7 @@ export { isTurnCollapseEligible, useChatGroups } from "./useChatGroups"; export type { ChatGroupMeta, UseChatGroupsReturn } from "./useChatGroups"; export { useChatSearch } from "./useChatSearch"; -export type { - SearchResult, - UseChatSearchOptions, - UseChatSearchReturn, -} from "./useChatSearch"; - -export { useChatSearchIntegration } from "./useChatSearchIntegration"; -export type { - UseChatSearchIntegrationOptions, - UseChatSearchIntegrationReturn, -} from "./useChatSearchIntegration"; +export type { UseChatSearchReturn } from "./useChatSearch"; export { useChatPagination } from "./useChatPagination"; export type { diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatHistoryProjectionModel.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatHistoryProjectionModel.ts index 9e4468f687..12214f3981 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatHistoryProjectionModel.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatHistoryProjectionModel.ts @@ -316,6 +316,7 @@ export function useChatHistoryProjectionModel({ turnMetadataReloadKey, turnPageListOpen, setTurnPageListOpen, + setTurnPageSelection, turnPageSortAscending, setTurnPageSortAscending, virtualListDataKey, diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatSearch.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatSearch.ts index 5312262fb4..9cf44b2928 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatSearch.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatSearch.ts @@ -1,228 +1,366 @@ /** - * useChatSearch Hook - * - * Full-text search across chat history using the Rust EventStore. - * The heavy search computation (text extraction, matching, snippet creation) - * runs in Rust via Tauri IPC, avoiding O(N) JS string scanning. - * - * Features: - * - Debounced search input - * - Highlighted snippets (from Rust) - * - Navigation to matched events - * - Case-sensitive / regex / whole-word modes - * - * Usage: - * ```tsx - * const { - * query, setQuery, - * results, - * isSearching, - * currentResultIndex, - * navigateToResult, - * nextResult, prevResult, - * clearSearch - * } = useChatSearch({ chatHistory, onNavigateToEvent }); - * ``` + * useChatSearch — Rust-backed search with projection-aware DOM scrolling. */ import { invoke } from "@tauri-apps/api/core"; -import { useCallback, useRef, useState } from "react"; - -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { useAtom, useSetAtom } from "jotai"; +import { + type Dispatch, + type RefObject, + type SetStateAction, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { useEventNavigation } from "@src/engines/SessionCore"; import { useDebouncedCallback } from "@src/hooks/perf"; - -// ============================================ -// Types -// ============================================ - -export interface SearchResult { - /** The matched event */ - item: SessionEvent; - /** Index in the original chatHistory array */ - index: number; - /** Search relevance score (lower = better match) */ - score: number; - /** Text snippet with match highlighted */ - snippet: string; -} - -interface RustSearchResult { - eventId: string; - chatIndex: number; - score: number; - snippet: string; +import { + chatFindInChatOpenAtomFamily, + chatSearchSyncAtomFamily, +} from "@src/store/ui/chatPanelAtom"; +import { + setCollapseStateAtom, + setTurnCollapseOverrideAtom, +} from "@src/store/ui/collapseStateAtom"; + +import type { OptimizedChatItem } from "../chatItemPipeline/types"; +import type { ChatHistoryListHandle } from "../components/ChatHistoryList"; +import { + EMPTY_CHAT_SEARCH_SYNC, + buildChatSearchSyncState, + useChatSearchPanePresentation, + writeChatSearchSyncState, +} from "./chatSearch"; +import { resolveVisibleSearchResultIndex } from "./chatSearchDom"; +import { + type ChatSearchModes, + DEFAULT_CHAT_SEARCH_MODES, + type MappedSearchResult, + type RustSearchResult, + mapRustResultsToSearchResults, + searchChatHistoryLocally, + wrapNextSearchResultIndex, +} from "./chatSearchHelpers"; +import { + buildEventIdProjectionIndex, + resolvePageIndexForFlatIndex, + toDisplayFlatIndex, +} from "./chatSearchProjection"; +import type { ChatGroupMeta } from "./useChatGroups"; +import type { ChatTurnPage } from "./useChatTurnPagination"; + +export type SearchResult = MappedSearchResult; + +interface TurnPageSelection { + pageIndex: number | null; + sessionId: string | null; } export interface UseChatSearchOptions { - /** Chat events to search within */ - chatHistory: SessionEvent[]; - /** Debounce delay in ms (default: 150) */ + sessionId: string | null; + chatHistory: MappedSearchResult["item"][]; + flatItems: OptimizedChatItem[]; + groupCounts: number[]; + groupMeta: ChatGroupMeta[]; + pages: ChatTurnPage[]; + turnPaginationEnabled: boolean; + currentPageIndex: number; + setTurnPageSelection: Dispatch>; + virtualListRef: RefObject; + chatContainerRef: RefObject; debounceMs?: number; - /** Max results to return (default: 100) */ maxResults?: number; - /** Callback when navigating to a result (includes search query for fallback navigation) */ - onNavigateToEvent?: ( - eventId: string, - index: number, - searchQuery: string - ) => void; } export interface UseChatSearchReturn { - /** Current search query */ query: string; - /** Set search query */ setQuery: (query: string) => void; - /** Search results */ results: SearchResult[]; - /** Whether search is in progress */ isSearching: boolean; - /** Whether search is active (has query) */ isSearchActive: boolean; - /** Current highlighted result index */ + isSearchVisible: boolean; + closeSearch: () => void; currentResultIndex: number; - /** Total result count */ resultCount: number; - /** Navigate to a specific result */ - navigateToResult: (index: number) => void; - /** Navigate to next result */ nextResult: () => void; - /** Navigate to previous result */ prevResult: () => void; - /** Clear search and results */ - clearSearch: () => void; - /** Get the event ID for a result index */ - getResultEventId: (index: number) => string | null; - /** Whether case-sensitive matching is enabled */ caseSensitive: boolean; - /** Toggle case-sensitive matching */ toggleCaseSensitive: () => void; - /** Whether regex matching is enabled */ useRegex: boolean; - /** Toggle regex matching */ toggleRegex: () => void; - /** Whether whole-word matching is enabled */ wholeWord: boolean; - /** Toggle whole-word matching */ toggleWholeWord: () => void; } -// ============================================ -// SessionEvent index by event id (for Rust → TS mapping) -// ============================================ - -function buildChunkIdIndex(chatHistory: SessionEvent[]): Map { - const index = new Map(); - for (let idx = 0; idx < chatHistory.length; idx++) { - const eventId = chatHistory[idx].id; - if (eventId) { - index.set(eventId, idx); - } +async function fetchChatSearchResults( + sessionId: string, + chatHistory: UseChatSearchOptions["chatHistory"], + query: string, + modes: ChatSearchModes, + maxResults: number +): Promise { + const trimmedQuery = query.trim(); + let rustResults: RustSearchResult[] = []; + + try { + rustResults = await invoke("es_search_chat_events", { + sessionId, + options: { + query: trimmedQuery, + caseSensitive: modes.caseSensitive, + useRegex: modes.useRegex, + wholeWord: modes.wholeWord, + maxResults, + }, + }); + } catch { + return searchChatHistoryLocally( + chatHistory, + trimmedQuery, + modes, + maxResults + ); } - return index; + + const mapped = mapRustResultsToSearchResults(rustResults, chatHistory); + if (mapped.length > 0) return mapped; + + return searchChatHistoryLocally(chatHistory, trimmedQuery, modes, maxResults); } -// ============================================ -// Hook Implementation -// ============================================ +function resolveScrollContainer( + chatContainerRef: RefObject +): HTMLElement | null { + const container = chatContainerRef.current; + if (!container) return null; + return ( + container.querySelector( + '[data-testid="chat-history-scroll-container"]' + ) ?? container + ); +} export function useChatSearch( options: UseChatSearchOptions ): UseChatSearchReturn { const { + sessionId, chatHistory, + flatItems, + groupCounts, + groupMeta, + pages, + turnPaginationEnabled, + currentPageIndex, + setTurnPageSelection, + virtualListRef, + chatContainerRef, debounceMs = 150, maxResults = 100, - onNavigateToEvent, } = options; - const [query, setQuery] = useState(""); + const sessionKey = sessionId ?? ""; + const [isSearchVisible, setIsSearchVisible] = useAtom( + chatFindInChatOpenAtomFamily(sessionKey) + ); + const setChatSearchSync = useSetAtom(chatSearchSyncAtomFamily(sessionKey)); + + const [query, setQueryState] = useState(""); const [results, setResults] = useState([]); const [isSearching, setIsSearching] = useState(false); const [currentResultIndex, setCurrentResultIndex] = useState(0); - const [caseSensitive, setCaseSensitive] = useState(false); - const [useRegex, setUseRegex] = useState(false); - const [wholeWord, setWholeWord] = useState(false); + const [modes, setModes] = useState( + DEFAULT_CHAT_SEARCH_MODES + ); + + const searchGenerationRef = useRef(0); + const queryRef = useRef(query); + queryRef.current = query; + const modesRef = useRef(modes); + modesRef.current = modes; + const suppressScrollSyncRef = useRef(false); + const pendingScrollResultRef = useRef(null); + const pendingScrollNeedsLayoutRef = useRef(false); + + const { navigateToEvent } = useEventNavigation(); + const setTurnCollapseOverride = useSetAtom(setTurnCollapseOverrideAtom); + const setCollapseState = useSetAtom(setCollapseStateAtom); + + const projectionIndex = useMemo( + () => buildEventIdProjectionIndex(flatItems, groupCounts, groupMeta), + [flatItems, groupCounts, groupMeta] + ); + const projectionIndexRef = useRef(projectionIndex); + projectionIndexRef.current = projectionIndex; - const searchIdRef = useRef(0); + const pagesRef = useRef(pages); + pagesRef.current = pages; + const currentPageIndexRef = useRef(currentPageIndex); + currentPageIndexRef.current = currentPageIndex; + + const resetLocalSearch = useCallback(() => { + setQueryState(""); + setResults([]); + setCurrentResultIndex(0); + setModes(DEFAULT_CHAT_SEARCH_MODES); + writeChatSearchSyncState(setChatSearchSync, EMPTY_CHAT_SEARCH_SYNC); + searchGenerationRef.current += 1; + }, [setChatSearchSync]); + + const finishPendingScroll = useCallback( + (result: SearchResult) => { + const eventId = result.item.id || result.item.chunk_id || ""; + const projection = eventId + ? projectionIndexRef.current.get(eventId) + : undefined; + + let targetPageIndex = currentPageIndexRef.current; + if (turnPaginationEnabled && projection) { + const resolvedPage = resolvePageIndexForFlatIndex( + projection.globalFlatIndex, + pagesRef.current + ); + if (resolvedPage !== null) { + targetPageIndex = resolvedPage; + } + } + + // Non-paginated view renders the full flat list; passing a turn page + // slice here would clip indices outside the first page to null. + const targetPage = turnPaginationEnabled + ? pagesRef.current[targetPageIndex] + : undefined; + const displayFlatIndex = projection + ? toDisplayFlatIndex(projection.globalFlatIndex, targetPage) + : null; + + virtualListRef.current?.scrollToChatTarget({ + eventId, + itemId: projection?.itemChunkId, + flatIndex: displayFlatIndex ?? undefined, + behavior: "auto", + }); + + window.setTimeout(() => { + suppressScrollSyncRef.current = false; + }, 80); + }, + [turnPaginationEnabled, virtualListRef] + ); + + const scrollToSearchResult = useCallback( + (result: SearchResult) => { + const eventId = result.item.id || result.item.chunk_id || ""; + const projection = eventId + ? projectionIndexRef.current.get(eventId) + : undefined; + const resolvedPage = + turnPaginationEnabled && projection + ? resolvePageIndexForFlatIndex( + projection.globalFlatIndex, + pagesRef.current + ) + : null; + const needsFlatItemsLayout = + Boolean(projection?.turnId) || + (resolvedPage !== null && resolvedPage !== currentPageIndexRef.current); + + if (projection?.turnId) { + setTurnCollapseOverride({ + turnId: projection.turnId, + collapsed: false, + }); + } + if (eventId) { + setCollapseState({ eventId, collapsed: false }); + navigateToEvent(eventId); + } + if (resolvedPage !== null && sessionId) { + setTurnPageSelection({ + pageIndex: resolvedPage, + sessionId, + }); + } + + suppressScrollSyncRef.current = true; + + if (needsFlatItemsLayout) { + pendingScrollResultRef.current = result; + pendingScrollNeedsLayoutRef.current = true; + return; + } + + window.requestAnimationFrame(() => { + finishPendingScroll(result); + }); + }, + [ + finishPendingScroll, + navigateToEvent, + sessionId, + setCollapseState, + setTurnCollapseOverride, + setTurnPageSelection, + turnPaginationEnabled, + ] + ); + + useEffect(() => { + if (!pendingScrollNeedsLayoutRef.current) return; + const result = pendingScrollResultRef.current; + if (!result) return; + + pendingScrollNeedsLayoutRef.current = false; + pendingScrollResultRef.current = null; + + window.requestAnimationFrame(() => { + finishPendingScroll(result); + }); + }, [currentPageIndex, finishPendingScroll, flatItems, groupCounts]); const performSearch = useCallback( async ( searchQuery: string, - isCaseSensitive: boolean = caseSensitive, - isRegex: boolean = useRegex, - isWholeWord: boolean = wholeWord + searchModes: ChatSearchModes = modesRef.current ) => { const trimmedQuery = searchQuery.trim(); - - if (!trimmedQuery || chatHistory.length === 0) { + if (!trimmedQuery || chatHistory.length === 0 || !sessionId) { setResults([]); setCurrentResultIndex(0); + setIsSearching(false); return; } setIsSearching(true); - const currentSearchId = ++searchIdRef.current; + const generation = ++searchGenerationRef.current; try { - const rustResults = await invoke( - "es_search_chat_events", - { - options: { - query: trimmedQuery, - caseSensitive: isCaseSensitive, - useRegex: isRegex, - wholeWord: isWholeWord, - maxResults, - }, - } + const searchResults = await fetchChatSearchResults( + sessionId, + chatHistory, + trimmedQuery, + searchModes, + maxResults ); - if (currentSearchId !== searchIdRef.current) return; - - const chunkIndex = buildChunkIdIndex(chatHistory); - const searchResults: SearchResult[] = []; - - for (const rustResult of rustResults) { - const historyIndex = chunkIndex.get(rustResult.eventId); - if (historyIndex !== undefined) { - searchResults.push({ - item: chatHistory[historyIndex], - index: historyIndex, - score: rustResult.score, - snippet: rustResult.snippet, - }); - } - } - - if (currentSearchId !== searchIdRef.current) return; + if (generation !== searchGenerationRef.current) return; setResults(searchResults); setCurrentResultIndex(0); setIsSearching(false); - if (searchResults.length > 0 && onNavigateToEvent) { - const firstResult = searchResults[0]; - onNavigateToEvent( - firstResult.item.id || "", - firstResult.index, - trimmedQuery - ); - } + const first = searchResults[0]; + if (first) scrollToSearchResult(first); } catch { - if (currentSearchId !== searchIdRef.current) return; + if (generation !== searchGenerationRef.current) return; setResults([]); setCurrentResultIndex(0); setIsSearching(false); } }, - [ - chatHistory, - maxResults, - onNavigateToEvent, - caseSensitive, - useRegex, - wholeWord, - ] + [chatHistory, maxResults, scrollToSearchResult, sessionId] ); const debouncedPerformSearch = useDebouncedCallback( @@ -230,17 +368,21 @@ export function useChatSearch( debounceMs ); + useEffect(() => { + resetLocalSearch(); + debouncedPerformSearch.cancel(); + }, [sessionId, resetLocalSearch, debouncedPerformSearch]); + const handleQueryChange = useCallback( (newQuery: string) => { - setQuery(newQuery); - + setQueryState(newQuery); if (!newQuery.trim()) { debouncedPerformSearch.cancel(); setResults([]); setCurrentResultIndex(0); + setIsSearching(false); return; } - debouncedPerformSearch(newQuery); }, [debouncedPerformSearch] @@ -248,93 +390,110 @@ export function useChatSearch( const navigateToResult = useCallback( (resultIndex: number) => { - if (resultIndex < 0 || resultIndex >= results.length) return; - - setCurrentResultIndex(resultIndex); const result = results[resultIndex]; - if (result && onNavigateToEvent) { - onNavigateToEvent( - result.item.id || "", - result.index, - query.trim().toLowerCase() - ); - } + if (!result) return; + setCurrentResultIndex(resultIndex); + scrollToSearchResult(result); }, - [results, onNavigateToEvent, query] + [results, scrollToSearchResult] ); const nextResult = useCallback(() => { if (results.length === 0) return; - const nextIndex = (currentResultIndex + 1) % results.length; - navigateToResult(nextIndex); - }, [currentResultIndex, results.length, navigateToResult]); + navigateToResult( + wrapNextSearchResultIndex(currentResultIndex, results.length, 1) + ); + }, [currentResultIndex, navigateToResult, results.length]); const prevResult = useCallback(() => { if (results.length === 0) return; - const prevIndex = - currentResultIndex === 0 ? results.length - 1 : currentResultIndex - 1; - navigateToResult(prevIndex); - }, [currentResultIndex, results.length, navigateToResult]); + navigateToResult( + wrapNextSearchResultIndex(currentResultIndex, results.length, -1) + ); + }, [currentResultIndex, navigateToResult, results.length]); - const clearSearch = useCallback(() => { - setQuery(""); - setResults([]); - setCurrentResultIndex(0); - searchIdRef.current++; + const closeSearch = useCallback(() => { debouncedPerformSearch.cancel(); - }, [debouncedPerformSearch]); - - const toggleCaseSensitive = useCallback(() => { - setCaseSensitive((prev) => { - const next = !prev; - if (query.trim()) performSearch(query, next, useRegex, wholeWord); - return next; - }); - }, [query, performSearch, useRegex, wholeWord]); - - const toggleRegex = useCallback(() => { - setUseRegex((prev) => { - const next = !prev; - if (query.trim()) performSearch(query, caseSensitive, next, wholeWord); - return next; - }); - }, [query, performSearch, caseSensitive, wholeWord]); - - const toggleWholeWord = useCallback(() => { - setWholeWord((prev) => { - const next = !prev; - if (query.trim()) performSearch(query, caseSensitive, useRegex, next); - return next; - }); - }, [query, performSearch, caseSensitive, useRegex]); - - const getResultEventId = useCallback( - (index: number): string | null => { - if (index < 0 || index >= results.length) return null; - return results[index].item.id || null; + resetLocalSearch(); + setIsSearchVisible(false); + }, [debouncedPerformSearch, resetLocalSearch, setIsSearchVisible]); + + const toggleSearchMode = useCallback( + (key: keyof ChatSearchModes) => { + setModes((previous) => { + const next = { ...previous, [key]: !previous[key] }; + if (queryRef.current.trim()) { + void performSearch(queryRef.current, next); + } + return next; + }); }, - [results] + [performSearch] ); + useChatSearchPanePresentation({ + sessionId, + highlightRootRef: chatContainerRef, + }); + + useEffect(() => { + writeChatSearchSyncState( + setChatSearchSync, + buildChatSearchSyncState({ + isOpen: isSearchVisible, + query, + results, + currentResultIndex, + }) + ); + }, [currentResultIndex, isSearchVisible, query, results, setChatSearchSync]); + + useEffect(() => { + if (!isSearchVisible || results.length === 0) return; + + const scrollRoot = resolveScrollContainer(chatContainerRef); + if (!scrollRoot) return; + + let frame = 0; + const handleScroll = () => { + if (suppressScrollSyncRef.current) return; + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const visibleIndex = resolveVisibleSearchResultIndex( + scrollRoot, + results.map((result) => result.item.id || result.item.chunk_id || "") + ); + if (visibleIndex !== null) { + setCurrentResultIndex(visibleIndex); + } + }); + }; + + scrollRoot.addEventListener("scroll", handleScroll, { passive: true }); + return () => { + scrollRoot.removeEventListener("scroll", handleScroll); + cancelAnimationFrame(frame); + }; + }, [chatContainerRef, isSearchVisible, results]); + return { query, setQuery: handleQueryChange, results, isSearching, isSearchActive: query.trim().length > 0, + isSearchVisible, + closeSearch, currentResultIndex, resultCount: results.length, - navigateToResult, nextResult, prevResult, - clearSearch, - getResultEventId, - caseSensitive, - toggleCaseSensitive, - useRegex, - toggleRegex, - wholeWord, - toggleWholeWord, + caseSensitive: modes.caseSensitive, + toggleCaseSensitive: () => toggleSearchMode("caseSensitive"), + useRegex: modes.useRegex, + toggleRegex: () => toggleSearchMode("useRegex"), + wholeWord: modes.wholeWord, + toggleWholeWord: () => toggleSearchMode("wholeWord"), }; } diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatSearchHighlight.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatSearchHighlight.ts new file mode 100644 index 0000000000..dac34e9200 --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatSearchHighlight.ts @@ -0,0 +1,32 @@ +import { type RefObject, useEffect } from "react"; + +import { applySearchTextHighlight } from "./chatSearch/chatSearchHighlightDom"; + +export { + SEARCH_TEXT_HIGHLIGHT_ACTIVE_CLASS, + SEARCH_TEXT_HIGHLIGHT_CLASS, + applySearchTextHighlight, + clearSearchTextHighlights, +} from "./chatSearch/chatSearchHighlightDom"; + +/** @deprecated Prefer `useChatSearchPanePresentation` (shared sync atoms). */ +export function useChatSearchHighlight( + containerRef: RefObject, + query: string, + enabled: boolean +) { + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const trimmedQuery = query.trim(); + const timeoutId = window.setTimeout(() => { + applySearchTextHighlight(container, trimmedQuery, enabled); + }, 50); + + return () => { + window.clearTimeout(timeoutId); + applySearchTextHighlight(container, "", false); + }; + }, [containerRef, enabled, query]); +} diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatSearchIntegration.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatSearchIntegration.ts deleted file mode 100644 index 92ecbe1e7b..0000000000 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatSearchIntegration.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * useChatSearchIntegration Hook - * - * Wires useChatSearch to the Virtuoso list and handles: - * - Search visibility state - * - chunk_id → optimizedChatHistory index mapping - * - Content-based fallback search - * - DOM text highlighting - * - Search visibility and Escape-to-close handling - */ -import { - type RefObject, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; - -import { useEventNavigation } from "@src/engines/SessionCore"; -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; - -import type { OptimizedChatItem } from "../chatItemPipeline/types"; -import type { ChatHistoryListHandle } from "../components/ChatHistoryList"; -import type { ChatSearchBarHandle } from "../components/ChatSearchBar"; -import { type UseChatSearchReturn, useChatSearch } from "./useChatSearch"; - -// ============================================ -// Types -// ============================================ - -export interface UseChatSearchIntegrationOptions { - chatHistory: SessionEvent[]; - optimizedChatHistory: OptimizedChatItem[]; - virtualListRef: RefObject; - chatContainerRef: RefObject; - /** Maps optimizedChatHistory index -> virtual flat item index. */ - originalToFlatIndex?: Map; -} - -export interface UseChatSearchIntegrationReturn { - search: UseChatSearchReturn; - isSearchVisible: boolean; - searchBarRef: RefObject; - handleOpenSearch: () => void; - handleCloseSearch: () => void; -} - -// ============================================ -// Hook -// ============================================ - -export function useChatSearchIntegration({ - chatHistory, - optimizedChatHistory, - virtualListRef, - chatContainerRef, - originalToFlatIndex, -}: UseChatSearchIntegrationOptions): UseChatSearchIntegrationReturn { - const [isSearchVisible, setIsSearchVisible] = useState(false); - const searchBarRef = useRef(null); - const { navigateToEvent } = useEventNavigation(); - - // Build a map from chunk_id to optimizedChatHistory index for scroll navigation - const chunkIdToOptimizedIndex = useMemo(() => { - const map = new Map(); - optimizedChatHistory.forEach((item, idx) => { - if (item.chunk_id) { - map.set(item.chunk_id, idx); - - // Also map original chunk_id if it's a group: prefixed id - if (item.chunk_id.startsWith("group:")) { - const parts = item.chunk_id.split(":"); - if (parts.length >= 4) { - const originalId = parts.slice(3).join(":"); - map.set(originalId, idx); - } - } - } - }); - return map; - }, [optimizedChatHistory]); - - // Fallback: find index by searching content - const findOptimizedIndexByContent = useCallback( - (searchQuery: string): number => { - const query = searchQuery.toLowerCase(); - for (let idx = 0; idx < optimizedChatHistory.length; idx++) { - const item = optimizedChatHistory[idx]; - if (item.event?.result) { - const resultStr = JSON.stringify(item.event.result).toLowerCase(); - if (resultStr.includes(query)) return idx; - } - if (item.event?.displayText) { - const displayText = String(item.event.displayText).toLowerCase(); - if (displayText.includes(query)) return idx; - } - } - return -1; - }, - [optimizedChatHistory] - ); - - // Wire useChatSearch with navigation callback - const search = useChatSearch({ - chatHistory, - onNavigateToEvent: useCallback( - (eventId: string, _index: number, searchQuery: string) => { - if (eventId) navigateToEvent(eventId); - - let optimizedIndex = chunkIdToOptimizedIndex.get(eventId); - if (optimizedIndex === undefined && searchQuery) { - optimizedIndex = findOptimizedIndexByContent(searchQuery); - } - - if ( - optimizedIndex !== undefined && - optimizedIndex >= 0 && - virtualListRef.current - ) { - const scrollIdx = originalToFlatIndex - ? (originalToFlatIndex.get(optimizedIndex) ?? optimizedIndex) - : optimizedIndex; - virtualListRef.current.scrollToIndex({ - index: scrollIdx, - behavior: "smooth", - align: "center", - }); - } - }, - [ - navigateToEvent, - virtualListRef, - chunkIdToOptimizedIndex, - findOptimizedIndexByContent, - originalToFlatIndex, - ] - ), - }); - - // DOM text highlighting - useEffect(() => { - if (!chatContainerRef.current) return; - - const container = chatContainerRef.current; - const query = search.query.trim(); - - const clearHighlights = () => { - const marks = container.querySelectorAll("mark.search-text-highlight"); - marks.forEach((mark) => { - const parent = mark.parentNode; - if (parent) { - parent.replaceChild( - document.createTextNode(mark.textContent || ""), - mark - ); - parent.normalize(); - } - }); - }; - - clearHighlights(); - - if (!query || !search.isSearchActive) return; - - const highlightText = (node: Node) => { - if (node.nodeType === Node.TEXT_NODE) { - const text = node.textContent || ""; - const lowerText = text.toLowerCase(); - const lowerQuery = query.toLowerCase(); - const index = lowerText.indexOf(lowerQuery); - - if (index >= 0) { - const range = document.createRange(); - range.setStart(node, index); - range.setEnd(node, index + query.length); - - const mark = document.createElement("mark"); - mark.className = "search-text-highlight"; - range.surroundContents(mark); - return true; - } - } else if (node.nodeType === Node.ELEMENT_NODE) { - const element = node as Element; - if ( - element.tagName === "SCRIPT" || - element.tagName === "STYLE" || - element.tagName === "MARK" || - element.classList.contains("search-text-highlight") - ) { - return false; - } - const children = Array.from(node.childNodes); - for (const child of children) { - highlightText(child); - } - } - return false; - }; - - const timeoutId = setTimeout(() => { - highlightText(container); - }, 50); - - return () => { - clearTimeout(timeoutId); - clearHighlights(); - }; - }, [search.query, search.isSearchActive, chatContainerRef]); - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape" && isSearchVisible) { - event.preventDefault(); - event.stopPropagation(); - setIsSearchVisible(false); - } - }; - - window.addEventListener("keydown", handleKeyDown, true); - return () => window.removeEventListener("keydown", handleKeyDown, true); - }, [isSearchVisible]); - - const handleOpenSearch = useCallback(() => { - setIsSearchVisible(true); - }, []); - - const handleCloseSearch = useCallback(() => { - setIsSearchVisible(false); - }, []); - - return { - search, - isSearchVisible, - searchBarRef, - handleOpenSearch, - handleCloseSearch, - }; -} diff --git a/src/engines/ChatPanel/ChatHistory/index.scss b/src/engines/ChatPanel/ChatHistory/index.scss index 3a58301cda..fee666807f 100644 --- a/src/engines/ChatPanel/ChatHistory/index.scss +++ b/src/engines/ChatPanel/ChatHistory/index.scss @@ -393,15 +393,5 @@ } } -// ============================================ -// Search Text Highlight Styles (like code editor) -// ============================================ - -// Text highlight for search matches -mark.search-text-highlight { - background-color: rgba(255, 213, 0, 0.45); - color: inherit; - padding: 0 1px; - border-radius: 2px; - box-shadow: 0 0 0 1px rgba(255, 213, 0, 0.3); -} +// Text highlight for search matches (shared with Communication / Station) +@import "../../../styles/chat-search-highlight"; diff --git a/src/engines/ChatPanel/ChatHistory/index.tsx b/src/engines/ChatPanel/ChatHistory/index.tsx index df55afa20a..202437fa6d 100644 --- a/src/engines/ChatPanel/ChatHistory/index.tsx +++ b/src/engines/ChatPanel/ChatHistory/index.tsx @@ -31,7 +31,7 @@ import { useChatHistoryProjectionModel, useChatHistoryState, useChatNavigationController, - useChatSearchIntegration, + useChatSearch, useChatViewportController, useReloadSession, } from "./hooks"; @@ -97,7 +97,6 @@ const ChatHistory: React.FC = ({ onScrollNavChange, followAgentNav = EMPTY_FOLLOW_AGENT_NAV, browserAddToConversationNav = EMPTY_BROWSER_ADD_TO_CONVERSATION_NAV, - onRegisterSearchOpen, displayMode = "full", turnPaginationEnabled = true, pinnedHeaderPortalHost = null, @@ -187,19 +186,20 @@ const ChatHistory: React.FC = ({ sessionLoadStatus: historyState.sessionLoadStatus, optimizedLen: historyState.chatHistory.length, }); - const search = useChatSearchIntegration({ + const search = useChatSearch({ + sessionId: activeId, chatHistory: historyState.chatHistory, - optimizedChatHistory: projection.activeProjectionHistory, + flatItems: projection.flatItems, + groupCounts: projection.groupCounts, + groupMeta: projection.groupMeta, + pages: projection.pages, + turnPaginationEnabled, + currentPageIndex: projection.currentPageIndex, + setTurnPageSelection: projection.setTurnPageSelection, virtualListRef: historyState.virtualListRef, chatContainerRef: historyState.chatContainerRef, - originalToFlatIndex: projection.originalToFlatIndex, }); - useEffect(() => { - onRegisterSearchOpen?.(search.handleOpenSearch); - return () => onRegisterSearchOpen?.(null); - }, [onRegisterSearchOpen, search.handleOpenSearch]); - const viewport = useChatViewportController({ activeId, activeProjectionHistoryLength: projection.activeProjectionHistory.length, diff --git a/src/engines/ChatPanel/ChatHistory/renderers/GroupItemRenderer.tsx b/src/engines/ChatPanel/ChatHistory/renderers/GroupItemRenderer.tsx index 7e838f995b..94b022a6b0 100644 --- a/src/engines/ChatPanel/ChatHistory/renderers/GroupItemRenderer.tsx +++ b/src/engines/ChatPanel/ChatHistory/renderers/GroupItemRenderer.tsx @@ -35,6 +35,13 @@ import type { OptimizedChatItem } from "../chatItemPipeline/types"; import { NewEventDivider } from "../components/NewEventDivider"; import TurnMetadataFooterSlot from "../components/TurnMetadataFooterSlot"; import { CHAT_FOOTER_SPACER } from "../config/chatFooterSpacer"; +import { + CHAT_EVENT_IDS_ATTR, + CHAT_FLAT_INDEX_ATTR, + CHAT_ITEM_ID_ATTR, + formatChatEventIdsAttribute, +} from "../hooks/chatSearchDom"; +import { collectChatItemEventIds } from "../hooks/chatSearchProjection"; import { getUnloadedTurnMeta, isTurnPreviewItem } from "../hooks/useChatGroups"; import { ChatItemRenderer } from "./ChatItemRenderer"; import ChatItemWrap from "./ChatItemWrap"; @@ -531,9 +538,28 @@ export const GroupItemRenderer: React.FC = memo( !isStructuralUnloadedTurnItem && !isStructuralOnlyItem; + const chatSearchEventIds = + chatItem && !isHiddenUnloadedTurnItem && !isStructuralOnlyItem + ? collectChatItemEventIds(chatItem) + : []; + return ( -
+
0 + ? { + [CHAT_EVENT_IDS_ATTR]: + formatChatEventIdsAttribute(chatSearchEventIds), + } + : {}), + } + : {})} + > {showNewEventDivider && ( )} diff --git a/src/engines/ChatPanel/ChatPanelContent.test.ts b/src/engines/ChatPanel/ChatPanelContent.test.ts index e2ec681f0a..e1eb08bb9c 100644 --- a/src/engines/ChatPanel/ChatPanelContent.test.ts +++ b/src/engines/ChatPanel/ChatPanelContent.test.ts @@ -17,7 +17,6 @@ function render(sessionViewMode: SessionViewMode): string { currentSessionId: "s-1", displayMode: "full" as const, emptyChatContent: createElement("div", { "data-empty": "true" }), - handleRegisterSearchOpen: () => undefined, onSessionContinuation: () => undefined, paginationEnabled: false, position: "right" as const, diff --git a/src/engines/ChatPanel/ChatPanelContent.tsx b/src/engines/ChatPanel/ChatPanelContent.tsx index c4541bccdd..fba7c40728 100644 --- a/src/engines/ChatPanel/ChatPanelContent.tsx +++ b/src/engines/ChatPanel/ChatPanelContent.tsx @@ -9,7 +9,6 @@ import type { SessionViewMode } from "./hooks/useSessionViewMode"; interface ChatPanelContentProps { currentSessionId: string | null; emptyChatContent: React.ReactNode; - handleRegisterSearchOpen: (handler: (() => void) | null) => void; onSessionContinuation: (continuation: SessionContinuation) => void; displayMode: ChatHistoryDisplayMode; paginationEnabled: boolean; @@ -34,7 +33,6 @@ interface ChatPanelContentProps { export function ChatPanelContent({ currentSessionId, emptyChatContent, - handleRegisterSearchOpen, onSessionContinuation, displayMode, paginationEnabled, @@ -60,7 +58,6 @@ export function ChatPanelContent({ > = memo( ({ sessionId, - onRegisterSearchOpen, displayMode = "full", turnPaginationEnabled = true, position = "right", @@ -521,7 +520,6 @@ const ChatView: React.FC = memo( handleScrollNavChange={handleScrollNavChange} followAgentNav={followAgentNav} browserAddToConversationNav={browserAddToConversationNav} - onRegisterSearchOpen={onRegisterSearchOpen} displayMode={displayMode} turnPaginationEnabled={turnPaginationEnabled} paginationTrailingSlot={groupChatHistoryAction} diff --git a/src/engines/ChatPanel/ChatViewHistorySurface.tsx b/src/engines/ChatPanel/ChatViewHistorySurface.tsx index aee0e6d584..0ea0c5f45a 100644 --- a/src/engines/ChatPanel/ChatViewHistorySurface.tsx +++ b/src/engines/ChatPanel/ChatViewHistorySurface.tsx @@ -34,7 +34,6 @@ interface ChatViewHistorySurfaceProps { handleScrollNavChange: NonNullable; followAgentNav: ChatHistoryProps["followAgentNav"]; browserAddToConversationNav: ChatHistoryProps["browserAddToConversationNav"]; - onRegisterSearchOpen: ChatHistoryProps["onRegisterSearchOpen"]; displayMode: ChatHistoryProps["displayMode"]; turnPaginationEnabled: boolean; paginationTrailingSlot: ChatHistoryProps["paginationTrailingSlot"]; @@ -67,7 +66,6 @@ export function ChatViewHistorySurface({ handleScrollNavChange, followAgentNav, browserAddToConversationNav, - onRegisterSearchOpen, displayMode, turnPaginationEnabled, paginationTrailingSlot, @@ -132,7 +130,6 @@ export function ChatViewHistorySurface({ onScrollNavChange={handleScrollNavChange} followAgentNav={followAgentNav} browserAddToConversationNav={browserAddToConversationNav} - onRegisterSearchOpen={onRegisterSearchOpen} displayMode={displayMode} turnPaginationEnabled={turnPaginationEnabled} paginationTrailingSlot={paginationTrailingSlot} diff --git a/src/engines/ChatPanel/ChatViewTypes.ts b/src/engines/ChatPanel/ChatViewTypes.ts index 8c8e2ea66c..093f19cab2 100644 --- a/src/engines/ChatPanel/ChatViewTypes.ts +++ b/src/engines/ChatPanel/ChatViewTypes.ts @@ -9,7 +9,6 @@ import type { ChatHistoryDisplayMode } from "@src/store/ui/chatPanelAtom"; export interface ChatViewProps { /** Session ID to display. Sync bridges and events load for this session. */ sessionId: string; - onRegisterSearchOpen?: (handler: (() => void) | null) => void; displayMode?: ChatHistoryDisplayMode; turnPaginationEnabled?: boolean; /** Dock side for the containing chat panel, used to place side previews inward. */ diff --git a/src/engines/ChatPanel/hooks/useChatPanelHeaderActions.ts b/src/engines/ChatPanel/hooks/useChatPanelHeaderActions.ts index bf448a1367..f3583a060e 100644 --- a/src/engines/ChatPanel/hooks/useChatPanelHeaderActions.ts +++ b/src/engines/ChatPanel/hooks/useChatPanelHeaderActions.ts @@ -1,11 +1,13 @@ import { useSessionHeaderActions } from "./useSessionHeaderActions"; interface UseChatPanelHeaderActionsOptions { + sessionId: string | null; handleReloadSession: () => void; } export function useChatPanelHeaderActions({ + sessionId, handleReloadSession, }: UseChatPanelHeaderActionsOptions) { - return useSessionHeaderActions({ handleReloadSession }); + return useSessionHeaderActions({ sessionId, handleReloadSession }); } diff --git a/src/engines/ChatPanel/hooks/useSessionHeaderActions.test.ts b/src/engines/ChatPanel/hooks/useSessionHeaderActions.test.ts index 24ed056ff8..2fd25d40d8 100644 --- a/src/engines/ChatPanel/hooks/useSessionHeaderActions.test.ts +++ b/src/engines/ChatPanel/hooks/useSessionHeaderActions.test.ts @@ -15,6 +15,7 @@ import { import { derivedSnapshotAtom } from "@src/engines/SessionCore/core/atoms/events"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { chatFindInChatOpenAtomFamily } from "@src/store/ui/chatPanelAtom"; import { useSessionHeaderActions } from "./useSessionHeaderActions"; @@ -49,7 +50,10 @@ const onReady = vi.fn((value: HeaderActions) => { }); function Harness({ onActions }: { onActions: (value: HeaderActions) => void }) { - const value = useSessionHeaderActions({ handleReloadSession: vi.fn() }); + const value = useSessionHeaderActions({ + sessionId: "session-1", + handleReloadSession: vi.fn(), + }); useEffect(() => onActions(value), [onActions, value]); return null; } @@ -140,6 +144,15 @@ describe("useSessionHeaderActions", () => { Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); }); + it("opens find-in-chat for the active session via atom", () => { + expect(store.get(chatFindInChatOpenAtomFamily("session-1"))).toBe(false); + + act(() => actions?.handleOpenSearch()); + + expect(store.get(chatFindInChatOpenAtomFamily("session-1"))).toBe(true); + expect(dropdownMocks.close).toHaveBeenCalledOnce(); + }); + it("reads the latest events on demand without subscribing to event updates", async () => { const latestEvents = [event("event-2", "Latest streamed text")]; const rendersBeforeEventUpdate = onReady.mock.calls.length; diff --git a/src/engines/ChatPanel/hooks/useSessionHeaderActions.ts b/src/engines/ChatPanel/hooks/useSessionHeaderActions.ts index 40596b02ef..a5c5e25249 100644 --- a/src/engines/ChatPanel/hooks/useSessionHeaderActions.ts +++ b/src/engines/ChatPanel/hooks/useSessionHeaderActions.ts @@ -1,5 +1,5 @@ import { useAtom, useAtomValue, useStore } from "jotai"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useState } from "react"; import { eventCountAtom, @@ -7,6 +7,7 @@ import { } from "@src/engines/SessionCore/core/atoms"; import { useDropdownEngine } from "@src/hooks/dropdown"; import { + chatFindInChatOpenAtomFamily, chatHistoryDisplayModeAtom, chatTokenUsageVisibleAtom, chatTurnMetadataVisibleAtom, @@ -14,14 +15,15 @@ import { } from "@src/store/ui/chatPanelAtom"; interface UseSessionHeaderActionsOptions { + sessionId: string | null; handleReloadSession: () => void; } /** Shared session-menu state used by Chat Panel and My Station. */ export function useSessionHeaderActions({ + sessionId, handleReloadSession, }: UseSessionHeaderActionsOptions) { - const openSearchRef = useRef<(() => void) | null>(null); const { isOpen: isHeaderActionsOpen, isPositioned: isHeaderActionsPositioned, @@ -48,21 +50,17 @@ export function useSessionHeaderActions({ ); const eventCount = useAtomValue(eventCountAtom); const store = useStore(); + const [, setFindInChatOpen] = useAtom( + chatFindInChatOpenAtomFamily(sessionId ?? "") + ); const [copyEventJsonLabel, setCopyEventJsonLabel] = useState< "idle" | "copied" | "failed" >("idle"); - const handleRegisterSearchOpen = useCallback( - (handler: (() => void) | null) => { - openSearchRef.current = handler; - }, - [] - ); - const handleOpenSearch = useCallback(() => { - openSearchRef.current?.(); + if (sessionId) setFindInChatOpen(true); closeHeaderActionsMenu(); - }, [closeHeaderActionsMenu]); + }, [closeHeaderActionsMenu, sessionId, setFindInChatOpen]); const handleReloadFromMenu = useCallback(() => { handleReloadSession(); @@ -110,7 +108,6 @@ export function useSessionHeaderActions({ handleCopyEventJson, handleOpenSearch, handlePaginationToggle, - handleRegisterSearchOpen, handleReloadFromMenu, handleTokenUsageVisibleToggle, handleTurnMetadataVisibleToggle, diff --git a/src/engines/ChatPanel/index.tsx b/src/engines/ChatPanel/index.tsx index e4f06bfeea..29c1278ed8 100644 --- a/src/engines/ChatPanel/index.tsx +++ b/src/engines/ChatPanel/index.tsx @@ -359,7 +359,6 @@ const ChatPanel: React.FC = memo( handleCopyEventJson, handleOpenSearch, handlePaginationToggle, - handleRegisterSearchOpen, handleReloadFromMenu, handleTokenUsageVisibleToggle, handleTurnMetadataVisibleToggle, @@ -372,7 +371,10 @@ const ChatPanel: React.FC = memo( tokenUsageVisible, turnMetadataVisible, toggleHeaderActionsMenu, - } = useChatPanelHeaderActions({ handleReloadSession }); + } = useChatPanelHeaderActions({ + sessionId: currentSessionId ?? null, + handleReloadSession, + }); const handleReturnToSessionCreator = useCallback(() => { handleOpenLaunchpadTab(); @@ -669,7 +671,6 @@ const ChatPanel: React.FC = memo( currentSessionId={currentSessionId ?? null} displayMode={displayMode} emptyChatContent={emptyChatContent} - handleRegisterSearchOpen={handleRegisterSearchOpen} onSessionContinuation={handleSessionContinuation} paginationEnabled={paginationEnabled} position={position} diff --git a/src/index.scss b/src/index.scss index 5a11fed811..8f733df73c 100644 --- a/src/index.scss +++ b/src/index.scss @@ -23,6 +23,7 @@ // Prism token colors for HTML-string highlighted code (`.prism-html`) @import "./styles/prism-tokens"; +@import "./styles/chat-search-highlight"; body { --scrollbar-thumb-color: color-mix( diff --git a/src/modules/WorkStation/Chat/Communication/MessageViewer.tsx b/src/modules/WorkStation/Chat/Communication/MessageViewer.tsx index 0afe5b27ed..dff9d497d9 100644 --- a/src/modules/WorkStation/Chat/Communication/MessageViewer.tsx +++ b/src/modules/WorkStation/Chat/Communication/MessageViewer.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "jotai"; import { ChevronsUpDown } from "lucide-react"; import React, { useCallback, @@ -11,7 +12,9 @@ import { useTranslation } from "react-i18next"; import type { AgentOrgRunMemberView, AgentOrgTask } from "@src/api/tauri/agent"; import Button from "@src/components/Button"; +import { useChatSearchPanePresentation } from "@src/engines/ChatPanel/ChatHistory/hooks/chatSearch"; import { useStreamingDeltaForSession } from "@src/engines/SessionCore"; +import { sessionIdAtom } from "@src/engines/SessionCore/core/atoms"; import { derivePlanApprovalViewState, isPlanDisplayEvent, @@ -118,6 +121,8 @@ export interface MessageViewerProps { orgMembers?: ReadonlyArray; /** Durable task snapshot for Agent Org sessions. Undefined for ordinary sessions. */ agentOrgTasks?: ReadonlyArray; + /** Shared chat-search sync: drop panel-local selection when the active match moves. */ + onSearchActiveEventChange?: (eventId: string | null) => void; } export const MessageViewer: React.FC = ({ @@ -135,12 +140,24 @@ export const MessageViewer: React.FC = ({ setViewMode, orgMembers, agentOrgTasks, + onSearchActiveEventChange, }) => { const handleNavigateToTodoList = useCallback(() => { setViewMode?.("todo"); }, [setViewMode]); const { t } = useTranslation(["common", "sessions"]); + const sessionId = useAtomValue(sessionIdAtom); const scrollContainerRef = useRef(null); + const followBottomRef = useRef(true); + const { activeEventId: activeSearchEventId, isOpen: isChatSearchOpen } = + useChatSearchPanePresentation({ + sessionId, + highlightRootRef: scrollContainerRef, + scrollRootRef: scrollContainerRef, + suppressFollowBottomRef: followBottomRef, + onActiveEventChange: onSearchActiveEventChange, + layoutKey: `${viewMode}:${currentEventId ?? ""}:${messages.length}`, + }); const loadMoreScrollAnchorRef = useRef<{ scrollTop: number; scrollHeight: number; @@ -209,7 +226,6 @@ export const MessageViewer: React.FC = ({ scrollContainer.scrollTop = anchor.scrollTop + heightDelta; }, [renderedMessageCount, visibleMessages.length]); - const followBottomRef = useRef(true); const lastScrollTopRef = useRef(0); // Switching to a different view/replay window starts fresh at the bottom, so @@ -246,6 +262,7 @@ export const MessageViewer: React.FC = ({ // still following it (or already sitting at the bottom). if ( !followBottomRef.current && + !isChatSearchOpen && !isViewportAtBottom({ scrollTop: scrollContainer.scrollTop, scrollHeight: scrollContainer.scrollHeight, @@ -263,6 +280,7 @@ export const MessageViewer: React.FC = ({ return () => cancelAnimationFrame(frameId); }, [ currentEventId, + isChatSearchOpen, lastMessageId, liveContentLength, messages.length, @@ -411,6 +429,7 @@ export const MessageViewer: React.FC = ({ } showChrome={showChrome} orgMembers={orgMembers} + activeSearchEventId={activeSearchEventId} /> ); diff --git a/src/modules/WorkStation/Chat/Communication/MessageViewer/MessageBubbleRenderer.tsx b/src/modules/WorkStation/Chat/Communication/MessageViewer/MessageBubbleRenderer.tsx index ecb330c132..1e974898ed 100644 --- a/src/modules/WorkStation/Chat/Communication/MessageViewer/MessageBubbleRenderer.tsx +++ b/src/modules/WorkStation/Chat/Communication/MessageViewer/MessageBubbleRenderer.tsx @@ -1,6 +1,7 @@ import React, { memo, useCallback } from "react"; import type { AgentOrgRunMemberView } from "@src/api/tauri/agent"; +import { buildSearchTargetRowProps } from "@src/engines/ChatPanel/ChatHistory/hooks/chatSearch"; import { isPlanDisplayEvent } from "@src/engines/SessionCore/derived/planDisplayEvents"; import { @@ -47,6 +48,8 @@ export const BubbleWrapper: React.FC<{ * (e.g. "Planner") from `event.sessionId`. */ orgMembers?: ReadonlyArray; + /** Active chat-search match event id (cross-pane sync). */ + activeSearchEventId?: string | null; }> = memo( ({ message, @@ -57,6 +60,7 @@ export const BubbleWrapper: React.FC<{ onNavigateToTodoList, showChrome = true, orgMembers, + activeSearchEventId = null, }) => { const handleClick = useCallback(() => { onMessageClick?.(message.eventId); @@ -64,9 +68,18 @@ export const BubbleWrapper: React.FC<{ const stableClick = onMessageClick ? handleClick : undefined; const isLatest = index === total - 1; + const targetRowProps = buildSearchTargetRowProps( + { messageId: message.eventId, eventId: message.event.id }, + activeSearchEventId + ); + const wrapSearchTarget = (content: React.ReactNode) => ( +
{content}
+ ); + + let content: React.ReactNode = null; switch (viewMode) { case "think": - return ( + content = ( ); + break; case "interaction": - return ( + content = ( <>{renderInteractionWidget(message, onMessageClick, orgMembers)} ); + break; case "todo": if (isOrgTaskEvent(message.event)) { - // Already in the Todo Kanban view — no navigate arrow needed. - return ( + content = ( ); + } else { + content = ( + + ); } - return ( - - ); + break; case "chat": // Lazy-load placeholder for a turn whose body was windowed out of // the initial load (PR #561). `message.content` is the backend's @@ -106,7 +122,7 @@ export const BubbleWrapper: React.FC<{ // bar uses; once the body lands, `derivedSnapshotAtom` recomputes // and this message re-derives without `unloadedTurn` set. if (message.unloadedTurn) { - return ( + content = ( ); + break; } if (message.type === "think") { - return ( + content = ( ); + break; } if (message.type === "todo") { if (isOrgTaskEvent(message.event)) { - return ( + content = ( ); + } else { + content = ; } - return ; + break; } if (message.type === "interaction") { - return isPlanDisplayEvent(message.event) ? ( + content = isPlanDisplayEvent(message.event) ? ( renderPlanDocCard(message, orgMembers) ) : ( <>{renderInteractionWidget(message, onMessageClick, orgMembers)} ); + break; } if (message.event.functionName === "org_send_message") { - return ( + content = ( ); + break; } if (isEmailBubbleEvent(message.event)) { - return ( + content = ( ); + break; } - return ( + content = ( ); + break; default: - return null; + content = null; } + + return content ? wrapSearchTarget(content) : null; } ); BubbleWrapper.displayName = "BubbleWrapper"; diff --git a/src/modules/WorkStation/Chat/Communication/index.tsx b/src/modules/WorkStation/Chat/Communication/index.tsx index 001a04ae6a..c05e219a72 100644 --- a/src/modules/WorkStation/Chat/Communication/index.tsx +++ b/src/modules/WorkStation/Chat/Communication/index.tsx @@ -76,6 +76,7 @@ const SimulatorMessagesComponent: React.FC = ({ state, hasLocalSelection, jumpToMessage, + clearLocalSelection, } = useMessages(); const messageViewModel = useMemo( () => @@ -225,6 +226,9 @@ const SimulatorMessagesComponent: React.FC = ({ (hasLocalSelection || selectedMessageIsPlan), onMessageClick: handleMessageClick, currentEventId: state.currentEventId, + onSearchActiveEventChange: (eventId) => { + if (eventId) clearLocalSelection(); + }, }} /> diff --git a/src/modules/WorkStation/Chat/Communication/useMessages.ts b/src/modules/WorkStation/Chat/Communication/useMessages.ts index dc446f4022..1daae64b30 100644 --- a/src/modules/WorkStation/Chat/Communication/useMessages.ts +++ b/src/modules/WorkStation/Chat/Communication/useMessages.ts @@ -48,6 +48,8 @@ export interface UseMessagesReturn { hasLocalSelection: boolean; /** Jump to a message's event or plan revision in replay */ jumpToMessage: (messageId: string) => void; + /** Drop panel-local selection so replay cursor drives selection again. */ + clearLocalSelection: () => void; } function findMessageByIdOrPlanAlias( @@ -165,6 +167,10 @@ export function useMessages( setLocalSelectedId(messageId); }, []); + const clearLocalSelection = useCallback(() => { + setLocalSelectedId(null); + }, []); + const setViewMode = useCallback((mode: MessageViewMode) => { setLocalViewMode(mode); }, []); @@ -182,6 +188,7 @@ export function useMessages( selectedMessage, hasLocalSelection: localSelectedId !== null, jumpToMessage, + clearLocalSelection, }; } diff --git a/src/modules/WorkStation/TabContent/renderers/chatSession.tsx b/src/modules/WorkStation/TabContent/renderers/chatSession.tsx index b5f267fa26..0458adf24b 100644 --- a/src/modules/WorkStation/TabContent/renderers/chatSession.tsx +++ b/src/modules/WorkStation/TabContent/renderers/chatSession.tsx @@ -60,7 +60,10 @@ const ChatSessionTabRenderer: React.FC = memo( const handleReloadSession = useReloadSession(sessionId || null); const retargetSessionTab = useSetAtom(retargetWorkstationSessionTabAtom); const moveSessionTab = useSetAtom(moveSessionTabAtom); - const headerActions = useSessionHeaderActions({ handleReloadSession }); + const headerActions = useSessionHeaderActions({ + sessionId: sessionId || null, + handleReloadSession, + }); const { closeHeaderActionsMenu } = headerActions; const sessionActions = useSessionActionModals({ activeSession: session, @@ -189,7 +192,6 @@ const ChatSessionTabRenderer: React.FC = memo( sessionId={sessionId} secondary displayMode={headerActions.displayMode} - onRegisterSearchOpen={headerActions.handleRegisterSearchOpen} onSessionContinuation={handleSessionContinuation} turnPaginationEnabled={headerActions.paginationEnabled} /> diff --git a/src/modules/WorkStation/shared/StatusBar/EditorStatusBar.hostless.test.ts b/src/modules/WorkStation/shared/StatusBar/__tests__/EditorStatusBar.hostless.test.ts similarity index 93% rename from src/modules/WorkStation/shared/StatusBar/EditorStatusBar.hostless.test.ts rename to src/modules/WorkStation/shared/StatusBar/__tests__/EditorStatusBar.hostless.test.ts index 5fba610baa..2f5426a71c 100644 --- a/src/modules/WorkStation/shared/StatusBar/EditorStatusBar.hostless.test.ts +++ b/src/modules/WorkStation/shared/StatusBar/__tests__/EditorStatusBar.hostless.test.ts @@ -23,7 +23,7 @@ import { currentBranchAtom } from "@src/store/repo"; import { workspaceFoldersAtom } from "@src/store/ui/workspaceFoldersAtom"; import type { WorkspaceFolder } from "@src/types/workspace"; -import { EditorStatusBar } from "./EditorStatusBar"; +import { EditorStatusBar } from "../EditorStatusBar"; vi.mock("react-i18next", () => ({ useTranslation: () => ({ @@ -44,7 +44,7 @@ vi.mock("@src/hooks/git/useRepoSelection", () => ({ // Git plumbing is exercised by its own suites; here we only care that the bar // feeds it the identity it read from the atoms. const gitCallArgs: Array> = []; -vi.mock("./utils/useEditorStatusBarGit", () => ({ +vi.mock("../utils/useEditorStatusBarGit", () => ({ useEditorStatusBarGit: (options: Record) => { gitCallArgs.push(options); return { @@ -71,9 +71,9 @@ vi.mock("./utils/useEditorStatusBarGit", () => ({ }, })); -vi.mock("./CiStatusMenu", () => ({ CiStatusMenu: () => null })); -vi.mock("./GitSyncStatusMenu", () => ({ default: () => null })); -vi.mock("./PortsStatusMenu", () => ({ PortsStatusMenu: () => null })); +vi.mock("../CiStatusMenu", () => ({ CiStatusMenu: () => null })); +vi.mock("../GitSyncStatusMenu", () => ({ default: () => null })); +vi.mock("../PortsStatusMenu", () => ({ PortsStatusMenu: () => null })); const folder: WorkspaceFolder = { id: "primary", diff --git a/src/store/ui/chatPanel/miscAtoms.ts b/src/store/ui/chatPanel/miscAtoms.ts index 7c4ea5b0ec..760b89b310 100644 --- a/src/store/ui/chatPanel/miscAtoms.ts +++ b/src/store/ui/chatPanel/miscAtoms.ts @@ -2,6 +2,7 @@ * Replay slider state, and the chat dropdown / read-only flags. */ import { atom } from "jotai"; +import { atomFamily } from "jotai-family"; /** * Replay display value while dragging @@ -23,3 +24,18 @@ chatDropDownShowAtom.debugLabel = "chatDropDownShowAtom"; /** Whether the chat panel / workspace is in read-only mode */ export const wpReadOnlyAtom = atom(true); wpReadOnlyAtom.debugLabel = "wpReadOnlyAtom"; + +/** Per-session "Find in chat" bar visibility (header menu → ChatHistory). */ +export const chatFindInChatOpenAtomFamily = atomFamily((_sessionId: string) => + atom(false) +); + +/** Live query + active match for cross-pane chat search sync (ChatHistory ↔ Station). */ +export interface ChatSearchSyncState { + query: string; + activeEventId: string | null; +} + +export const chatSearchSyncAtomFamily = atomFamily((_sessionId: string) => + atom({ query: "", activeEventId: null }) +); diff --git a/src/styles/chat-search-highlight.scss b/src/styles/chat-search-highlight.scss new file mode 100644 index 0000000000..ad0eb80443 --- /dev/null +++ b/src/styles/chat-search-highlight.scss @@ -0,0 +1,18 @@ +mark.search-text-highlight { + background-color: rgba(255, 213, 0, 0.45); + color: inherit; + padding: 0 1px; + border-radius: 2px; + box-shadow: 0 0 0 1px rgba(255, 213, 0, 0.3); +} + +mark.search-text-highlight--active { + background-color: rgba(255, 170, 0, 0.72); + box-shadow: 0 0 0 1px rgba(255, 140, 0, 0.55); +} + +[data-search-active="true"] { + box-shadow: inset 0 0 0 2px + color-mix(in srgb, var(--color-brand-6) 55%, transparent); + border-radius: 8px; +} diff --git a/src/util/language/prismHtml.test.ts b/src/util/language/__tests__/prismHtml.test.ts similarity index 98% rename from src/util/language/prismHtml.test.ts rename to src/util/language/__tests__/prismHtml.test.ts index bffdf96898..a8b8fd7452 100644 --- a/src/util/language/prismHtml.test.ts +++ b/src/util/language/__tests__/prismHtml.test.ts @@ -5,7 +5,7 @@ import { highlightToHtml, isPrismLanguage, resolvePrismLanguage, -} from "./prismHtml"; +} from "../prismHtml"; describe("prismHtml", () => { it("emits Prism token spans with class names and no inline styles", () => { diff --git a/src/util/language/prismLight.test.ts b/src/util/language/__tests__/prismLight.test.ts similarity index 93% rename from src/util/language/prismLight.test.ts rename to src/util/language/__tests__/prismLight.test.ts index 865978dbff..a682e4c1d7 100644 --- a/src/util/language/prismLight.test.ts +++ b/src/util/language/__tests__/prismLight.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { codeMirrorPrismTheme } from "@src/features/CodeMirror/themes/prism"; -import { PrismLight } from "./prismLight"; +import { PrismLight } from "../prismLight"; describe("PrismLight", () => { it("normalizes editor language aliases through the canonical registry", () => {