From ad9269372110d5289b41a69ca0b9f1e04ffd24cc Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Wed, 26 Aug 2026 13:45:21 +0800 Subject: [PATCH 1/6] feat(chat): add projection-aware find-in-chat search Replace the integration shim with a projection-indexed search hook that scrolls to matches via flat-item targets, syncs highlights across panes, and fixes non-paginated mode where turn page slices clipped scroll indices. Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../ChatHistory/ChatHistory.types.ts | 1 - .../components/ChatHistoryList.tsx | 382 +++++++----- .../components/ChatHistoryListTypes.ts | 13 +- .../components/ChatHistoryView.tsx | 28 +- .../ChatHistory/components/ChatSearchBar.tsx | 84 +-- .../ChatPanel/ChatHistory/components/index.ts | 2 - .../hooks/__tests__/chatSearchHelpers.test.ts | 62 ++ .../__tests__/chatSearchProjection.test.ts | 119 ++++ .../chatSearch/chatSearchHighlightDom.ts | 64 ++ .../hooks/chatSearch/chatSearchSyncWrite.ts | 40 ++ .../hooks/chatSearch/chatSearchTargetDom.ts | 178 ++++++ .../ChatHistory/hooks/chatSearch/index.ts | 48 ++ .../useChatSearchPanePresentation.ts | 109 ++++ .../chatSearch/useChatSearchSyncState.ts | 22 + .../ChatHistory/hooks/chatSearchDom.ts | 2 + .../ChatHistory/hooks/chatSearchHelpers.ts | 91 +++ .../ChatHistory/hooks/chatSearchProjection.ts | 103 ++++ .../ChatPanel/ChatHistory/hooks/index.ts | 12 +- .../ChatHistory/hooks/useChatSearch.ts | 581 +++++++++++------- .../hooks/useChatSearchHighlight.ts | 32 + .../hooks/useChatSearchIntegration.ts | 239 ------- src/engines/ChatPanel/ChatHistory/index.scss | 14 +- src/engines/ChatPanel/ChatHistory/index.tsx | 20 +- .../renderers/GroupItemRenderer.tsx | 45 +- .../ChatPanel/ChatPanelContent.test.ts | 1 - src/engines/ChatPanel/ChatPanelContent.tsx | 3 - src/engines/ChatPanel/ChatView.tsx | 2 - .../ChatPanel/ChatViewHistorySurface.tsx | 3 - src/engines/ChatPanel/ChatViewTypes.ts | 1 - .../hooks/useChatPanelHeaderActions.ts | 4 +- .../hooks/useSessionHeaderActions.test.ts | 15 +- .../hooks/useSessionHeaderActions.ts | 21 +- src/engines/ChatPanel/index.tsx | 7 +- src/index.scss | 1 + .../Chat/Communication/MessageViewer.tsx | 21 +- .../MessageViewer/MessageBubbleRenderer.tsx | 66 +- .../WorkStation/Chat/Communication/index.tsx | 4 + .../Chat/Communication/useMessages.ts | 7 + .../TabContent/renderers/chatSession.tsx | 6 +- src/store/ui/chatPanel/miscAtoms.ts | 16 + src/styles/chat-search-highlight.scss | 18 + 41 files changed, 1683 insertions(+), 804 deletions(-) create mode 100644 src/engines/ChatPanel/ChatHistory/hooks/__tests__/chatSearchHelpers.test.ts create mode 100644 src/engines/ChatPanel/ChatHistory/hooks/__tests__/chatSearchProjection.test.ts create mode 100644 src/engines/ChatPanel/ChatHistory/hooks/chatSearch/chatSearchHighlightDom.ts create mode 100644 src/engines/ChatPanel/ChatHistory/hooks/chatSearch/chatSearchSyncWrite.ts create mode 100644 src/engines/ChatPanel/ChatHistory/hooks/chatSearch/chatSearchTargetDom.ts create mode 100644 src/engines/ChatPanel/ChatHistory/hooks/chatSearch/index.ts create mode 100644 src/engines/ChatPanel/ChatHistory/hooks/chatSearch/useChatSearchPanePresentation.ts create mode 100644 src/engines/ChatPanel/ChatHistory/hooks/chatSearch/useChatSearchSyncState.ts create mode 100644 src/engines/ChatPanel/ChatHistory/hooks/chatSearchDom.ts create mode 100644 src/engines/ChatPanel/ChatHistory/hooks/chatSearchHelpers.ts create mode 100644 src/engines/ChatPanel/ChatHistory/hooks/chatSearchProjection.ts create mode 100644 src/engines/ChatPanel/ChatHistory/hooks/useChatSearchHighlight.ts delete mode 100644 src/engines/ChatPanel/ChatHistory/hooks/useChatSearchIntegration.ts create mode 100644 src/styles/chat-search-highlight.scss 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..5afdb539ce 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx @@ -32,13 +32,16 @@ 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"; import { sameChatHistoryListProps } from "./ChatHistoryListEquality"; import { EMPTY_ROW_GROUP_META, - buildChatGroupRenderKeys, buildRowGroupMeta, isScrolledToContentBottom, resolveActiveGroupPinState, @@ -68,8 +71,6 @@ const ChatHistoryList: React.FC = memo( flatItems, groupCounts, turnIds, - assistantCopyEventIdsByGroup, - resolveAssistantTurnCopyContent, totalFlatItems, lastAssistantFlatIndexPerItem, codeBlockContainerWidth, @@ -133,10 +134,6 @@ const ChatHistoryList: React.FC = memo( return group; }); }, [effectiveGroupCounts]); - const groupRenderKeys = useMemo( - () => buildChatGroupRenderKeys(turnIds), - [turnIds] - ); const flatIndexToGroupIndex = useMemo(() => { const indexes: number[] = []; for (const group of virtualGroups) { @@ -152,10 +149,64 @@ const ChatHistoryList: React.FC = memo( getScrollElement: () => virtualScrollerRef.current, estimateSize: () => 360, overscan: 4, - getItemKey: (index) => - groupRenderKeys[index] ?? `chat-group-index:${index}`, + getItemKey: (index) => { + const group = virtualGroups[index]; + if (!group) return `chat-group-${index}:0`; + const itemKeys = flatItems + .slice(group.startFlatIndex, group.startFlatIndex + group.itemCount) + .map((item) => { + const event = item.event; + const displayTextLength = event?.displayText?.length ?? 0; + return [ + item.chunk_id, + event?.displayStatus ?? "", + event?.activityStatus ?? "", + displayTextLength, + ].join(":"); + }) + .join("|"); + return `${index}:${group.itemCount}:${itemKeys}`; + }, }); const virtualItems = virtualizer.getVirtualItems(); + const rowResizeObserverRef = useRef(null); + const measuredRowHeightsRef = useRef(new WeakMap()); + const observedRowsRef = useRef(new Set()); + const measureVirtualRow = useCallback( + (node: HTMLDivElement | null) => { + 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); + } + }); + } + virtualizer.measureElement(node); + 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 +226,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 +249,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( @@ -219,10 +309,6 @@ const ChatHistoryList: React.FC = memo( 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 @@ -254,21 +340,37 @@ const ChatHistoryList: React.FC = memo( const staticGroups = useMemo(() => { if (!useStaticRendering) return []; + const seenGroupKeys = new Set(); let nextGroupStartFlatIndex = 0; return effectiveGroupCounts.map((groupItemCount, groupIndex) => { const groupStartFlatIndex = nextGroupStartFlatIndex; nextGroupStartFlatIndex += groupItemCount; + // Only a group that owns at least one item may borrow its identity from + // one. A zero-count group's start index points at the *next* group's + // first item, so reading it unconditionally makes both groups emit the + // same key ("Encountered two children with the same key"). Empty groups + // are produced by useChatGroupsProjection when a collapsed turn has no + // structural source. + const firstItem = + groupItemCount > 0 ? flatItems[groupStartFlatIndex] : undefined; + let groupKey = + firstItem?.event?.id ?? + firstItem?.chunk_id ?? + `static-group-${groupIndex}`; + if (seenGroupKeys.has(groupKey)) { + groupKey = `${groupKey}#${groupIndex}`; + } + seenGroupKeys.add(groupKey); return { groupIndex, - groupKey: - groupRenderKeys[groupIndex] ?? `chat-group-index:${groupIndex}`, + groupKey, itemIndexes: Array.from( { length: groupItemCount }, (_, itemOffset) => groupStartFlatIndex + itemOffset ), }; }); - }, [useStaticRendering, effectiveGroupCounts, groupRenderKeys]); + }, [useStaticRendering, effectiveGroupCounts, flatItems]); const renderGroupItem = React.useCallback( (flatIndex: number, groupIndex: number) => { @@ -290,10 +392,6 @@ const ChatHistoryList: React.FC = memo( flatIndex={flatIndex} groupIndex={groupIndex} turnId={turnIdsRef.current[groupIndex] ?? null} - assistantCopyEventIds={ - assistantCopyEventIdsByGroupRef.current[groupIndex] ?? [] - } - resolveAssistantTurnCopyContent={resolveAssistantTurnCopyContent} chatItem={currentFlatItems[flatIndex]} previousChatItem={previousChatItemsRef.current[flatIndex]} lastAssistantFlatIndex={rowMeta.lastAssistantFlatIndex} @@ -319,7 +417,6 @@ const ChatHistoryList: React.FC = memo( onSkip, onEditUserMessage, newEventDividerLabel, - resolveAssistantTurnCopyContent, ] ); @@ -332,22 +429,91 @@ const ChatHistoryList: React.FC = memo( hideActiveGroupHeader, onActiveGroupIndexChange, }); - const setScrollContainerRef = useCallback( - (node: HTMLDivElement | null) => { - if (useStaticRendering) { - if (staticScrollerRef) staticScrollerRef.current = node; - virtualScrollerRef.current = null; - return; - } - if (staticScrollerRef) staticScrollerRef.current = null; - virtualScrollerRef.current = node; - }, - [staticScrollerRef, useStaticRendering, virtualScrollerRef] - ); + + if (useStaticRendering) { + return ( +
{ + const element = event.currentTarget; + onAtBottomStateChange( + isScrolledToContentBottom({ + element, + footerSpacerHeight, + bottomInset, + }) + ); + scheduleReportActiveGroupIndex(element); + }} + > +
+ {staticGroups.map(({ groupIndex, groupKey, itemIndexes }) => ( +
+
+
+ {renderGroupHeaderProp(groupIndex, "user")} +
+ {renderGroupHeaderProp(groupIndex, "collapse")} +
+ {itemIndexes.map((itemFlatIndex) => { + if (itemFlatIndex >= flatItems.length) { + return ( + + ); + } + const itemKey = + flatItems[itemFlatIndex]?.chunk_id ?? + `static-chat-${itemFlatIndex}`; + const rowMeta = + rowGroupMeta[itemFlatIndex] ?? EMPTY_ROW_GROUP_META; + return ( + + ); + })} +
+ ))} +
+
+
+ ); + } return (
{ + virtualScrollerRef.current = node; + }} data-testid="chat-history-scroll-container" className="allow-select-deep h-full w-full overflow-y-auto overscroll-contain scrollbar-hide" style={{ paddingTop: topPaddingPx }} @@ -360,119 +526,47 @@ const ChatHistoryList: React.FC = memo( }); onAtBottomStateChange(isAtBottom); scheduleReportActiveGroupIndex(element); - if (!useStaticRendering && isAtBottom) onEndReached(); + if (isAtBottom) onEndReached(); }} >
- {useStaticRendering - ? staticGroups.map(({ groupIndex, groupKey, itemIndexes }) => ( -
-
-
- {renderGroupHeaderProp(groupIndex, "user")} -
- {renderGroupHeaderProp(groupIndex, "collapse")} + {virtualItems.map((virtualItem) => { + const group = virtualGroups[virtualItem.index]; + if (!group) return null; + return ( +
+
+
+ {renderGroupHeaderProp(group.groupIndex, "user")}
- {itemIndexes.map((itemFlatIndex) => { - if (itemFlatIndex >= flatItems.length) { - return ( - - ); - } - const itemKey = - flatItems[itemFlatIndex]?.chunk_id ?? - `static-chat-${itemFlatIndex}`; - const rowMeta = - rowGroupMeta[itemFlatIndex] ?? EMPTY_ROW_GROUP_META; - return ( - - ); - })} + {renderGroupHeaderProp(group.groupIndex, "collapse")}
- )) - : virtualItems.map((virtualItem) => { - const group = virtualGroups[virtualItem.index]; - if (!group) return null; - return ( -
-
-
- {renderGroupHeaderProp(group.groupIndex, "user")} -
- {renderGroupHeaderProp(group.groupIndex, "collapse")} + {Array.from({ length: group.itemCount }, (_, itemOffset) => { + const flatIndex = group.startFlatIndex + itemOffset; + return ( +
+ {renderGroupItem(flatIndex, group.groupIndex)}
- {Array.from( - { length: group.itemCount }, - (_, itemOffset) => { - const flatIndex = group.startFlatIndex + itemOffset; - return ( -
- {renderGroupItem(flatIndex, group.groupIndex)} -
- ); - } - )} -
- ); - })} - {useStaticRendering ? ( -
- ) : null} + ); + })} +
+ ); + })}
); diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListTypes.ts b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListTypes.ts index f3cab38a94..e1517e8a52 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListTypes.ts +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListTypes.ts @@ -17,23 +17,22 @@ 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 { flatItems: OptimizedChatItem[]; groupCounts: number[]; turnIds: (string | null)[]; - assistantCopyEventIdsByGroup: readonly (readonly string[])[]; - resolveAssistantTurnCopyContent: (eventIds: readonly string[]) => string; totalFlatItems: number; lastAssistantFlatIndexPerItem: (number | null)[]; codeBlockContainerWidth: number; diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx index 4ae5b83035..6ab420b376 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; @@ -151,7 +151,6 @@ const ChatHistoryView: React.FC = ({ pages, planningIndicatorEnabled, projection: projectionResult, - resolveAssistantTurnCopyContent, selectTurnPage, setTurnPageListOpen, setTurnPageSortAscending, @@ -197,12 +196,6 @@ const ChatHistoryView: React.FC = ({ handleRegenerateGroup, handleSubmitAnswers, } = actions; - const { - search: searchState, - isSearchVisible, - searchBarRef, - handleCloseSearch, - } = search; const getIsWpGeneWorking = useCallback( () => isWpGeneWorkingRef.current ?? false, @@ -212,10 +205,6 @@ const ChatHistoryView: React.FC = ({ () => isExploringRef.current ?? false, [isExploringRef] ); - const assistantCopyEventIdsByGroup = useMemo( - () => displayGroupMeta.map((meta) => meta.assistantCopyEventIds), - [displayGroupMeta] - ); const hasCloudDownloadProgress = useCloudSessionHasDownloadSurface(activeId); const renderGroupHeader = useGroupHeaderRenderer({ @@ -330,12 +319,7 @@ const ChatHistoryView: React.FC = ({
- + {pinnedHeaderPortalHost ? createPortal( @@ -474,12 +458,6 @@ const ChatHistoryView: React.FC = ({ flatItems={displayFlatItems} groupCounts={displayGroupCounts} turnIds={displayTurnIds} - assistantCopyEventIdsByGroup={ - assistantCopyEventIdsByGroup - } - resolveAssistantTurnCopyContent={ - resolveAssistantTurnCopyContent - } totalFlatItems={displayTotalFlatItems} lastAssistantFlatIndexPerItem={ displayLastAssistantFlatIndexPerItem 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/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..f8dbb6a7ba --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/chatSearchHelpers.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { + mapRustResultsToSearchResults, + 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); + }); +}); 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..39188d7d4f --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/hooks/chatSearchHelpers.ts @@ -0,0 +1,91 @@ +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +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; +} 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/useChatSearch.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatSearch.ts index 5312262fb4..f4434edb8d 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatSearch.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatSearch.ts @@ -1,228 +1,352 @@ /** - * 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, + 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 rustResults = await invoke( + "es_search_chat_events", + { + sessionId, + options: { + query, + caseSensitive: modes.caseSensitive, + useRegex: modes.useRegex, + wholeWord: modes.wholeWord, + maxResults, + }, } - } - return index; + ); + return mapRustResultsToSearchResults(rustResults, chatHistory); } -// ============================================ -// 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 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; - const searchIdRef = useRef(0); + 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 +354,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 +376,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..15aa031d7b 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"; @@ -176,13 +183,6 @@ function areGroupItemRendererPropsEqual( previous.flatIndex === next.flatIndex && previous.groupIndex === next.groupIndex && previous.turnId === next.turnId && - previous.assistantCopyEventIds.length === - next.assistantCopyEventIds.length && - previous.assistantCopyEventIds.every( - (eventId, index) => eventId === next.assistantCopyEventIds[index] - ) && - previous.resolveAssistantTurnCopyContent === - next.resolveAssistantTurnCopyContent && sameChatItem(previous.chatItem, next.chatItem) && // previousChatItem affects group-chat continuation window only (createdAt // comparison). Shallow-compare the event rather than the full item — the @@ -291,10 +291,6 @@ export interface GroupItemRendererProps { flatIndex: number; groupIndex: number; turnId: string | null; - /** Assistant messages retained as the authoritative copy sources for this turn. */ - assistantCopyEventIds: readonly string[]; - /** Lazily resolves the ids against the uncollapsed projection on click. */ - resolveAssistantTurnCopyContent: (eventIds: readonly string[]) => string; /** The item at `flatIndex`. Passed directly to avoid the full array reference. */ chatItem: OptimizedChatItem | undefined; /** @@ -352,8 +348,6 @@ export const GroupItemRenderer: React.FC = memo( flatIndex, groupIndex, turnId, - assistantCopyEventIds, - resolveAssistantTurnCopyContent, chatItem, previousChatItem, lastAssistantFlatIndex, @@ -441,8 +435,6 @@ export const GroupItemRenderer: React.FC = memo( const turnContext = useMemo( () => ({ lastAssistantFlatIndex, - assistantCopyEventIds, - resolveAssistantTurnCopyContent, isLastGroup, isLastItemInGroup, onRegenerate: onRegenerate @@ -457,8 +449,6 @@ export const GroupItemRenderer: React.FC = memo( }), [ lastAssistantFlatIndex, - assistantCopyEventIds, - resolveAssistantTurnCopyContent, isLastGroup, isLastItemInGroup, isWpGeneWorking, @@ -531,9 +521,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 c4145b705d..56823cea34 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(); @@ -671,7 +673,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 7aa2dbfd5c..af5900fb3f 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/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; +} From b9d418fc7156c6d3e1379b4635dc019a371c838d Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Wed, 26 Aug 2026 14:20:57 +0800 Subject: [PATCH 2/6] fix(chat): restore copy-turn context dropped during search refactor Re-wire assistantCopyEventIds and resolveAssistantTurnCopyContent from group meta through ChatHistoryList into AgentTurnContext so AgentMessageBlock no longer crashes when rendering the copy-turn footer. Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../components/ChatHistoryList.tsx | 22 +++++++++++++++++-- .../components/ChatHistoryListTypes.ts | 2 ++ .../components/ChatHistoryView.tsx | 11 ++++++++++ .../renderers/GroupItemRenderer.tsx | 17 ++++++++++++++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx index 5afdb539ce..4a55c2ce8d 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx @@ -71,6 +71,8 @@ const ChatHistoryList: React.FC = memo( flatItems, groupCounts, turnIds, + assistantCopyEventIdsByGroup, + resolveAssistantTurnCopyContent, totalFlatItems, lastAssistantFlatIndexPerItem, codeBlockContainerWidth, @@ -113,6 +115,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 @@ -307,8 +316,6 @@ const ChatHistoryList: React.FC = memo( ); const rowGroupMetaRef = useRef(rowGroupMeta); rowGroupMetaRef.current = rowGroupMeta; - const turnIdsRef = useRef(turnIds); - turnIdsRef.current = turnIds; // For each flat index, the nearest preceding qualifying item — non-structural, // non-unloaded, with an event. Pre-computed once per flatItems change so @@ -392,6 +399,10 @@ const ChatHistoryList: React.FC = memo( flatIndex={flatIndex} groupIndex={groupIndex} turnId={turnIdsRef.current[groupIndex] ?? null} + assistantCopyEventIds={ + assistantCopyEventIdsByGroupRef.current[groupIndex] ?? [] + } + resolveAssistantTurnCopyContent={resolveAssistantTurnCopyContent} chatItem={currentFlatItems[flatIndex]} previousChatItem={previousChatItemsRef.current[flatIndex]} lastAssistantFlatIndex={rowMeta.lastAssistantFlatIndex} @@ -417,6 +428,7 @@ const ChatHistoryList: React.FC = memo( onSkip, onEditUserMessage, newEventDividerLabel, + resolveAssistantTurnCopyContent, ] ); @@ -485,6 +497,12 @@ const ChatHistoryList: React.FC = memo( flatIndex={itemFlatIndex} groupIndex={groupIndex} turnId={turnIds[groupIndex] ?? null} + assistantCopyEventIds={ + assistantCopyEventIdsByGroup[groupIndex] ?? [] + } + resolveAssistantTurnCopyContent={ + resolveAssistantTurnCopyContent + } chatItem={flatItems[itemFlatIndex]} previousChatItem={previousChatItems[itemFlatIndex]} lastAssistantFlatIndex={rowMeta.lastAssistantFlatIndex} diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListTypes.ts b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListTypes.ts index e1517e8a52..1eb0320131 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListTypes.ts +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListTypes.ts @@ -33,6 +33,8 @@ export interface ChatHistoryListProps { flatItems: OptimizedChatItem[]; groupCounts: number[]; turnIds: (string | null)[]; + assistantCopyEventIdsByGroup: readonly (readonly string[])[]; + resolveAssistantTurnCopyContent: (eventIds: readonly string[]) => string; totalFlatItems: number; lastAssistantFlatIndexPerItem: (number | null)[]; codeBlockContainerWidth: number; diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx index 6ab420b376..61264e2e13 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx @@ -151,6 +151,7 @@ const ChatHistoryView: React.FC = ({ pages, planningIndicatorEnabled, projection: projectionResult, + resolveAssistantTurnCopyContent, selectTurnPage, setTurnPageListOpen, setTurnPageSortAscending, @@ -206,6 +207,10 @@ const ChatHistoryView: React.FC = ({ [isExploringRef] ); const hasCloudDownloadProgress = useCloudSessionHasDownloadSurface(activeId); + const assistantCopyEventIdsByGroup = useMemo( + () => displayGroupMeta.map((meta) => meta.assistantCopyEventIds), + [displayGroupMeta] + ); const renderGroupHeader = useGroupHeaderRenderer({ displaySourceGroupIndices, @@ -458,6 +463,12 @@ const ChatHistoryView: React.FC = ({ flatItems={displayFlatItems} groupCounts={displayGroupCounts} turnIds={displayTurnIds} + assistantCopyEventIdsByGroup={ + assistantCopyEventIdsByGroup + } + resolveAssistantTurnCopyContent={ + resolveAssistantTurnCopyContent + } totalFlatItems={displayTotalFlatItems} lastAssistantFlatIndexPerItem={ displayLastAssistantFlatIndexPerItem diff --git a/src/engines/ChatPanel/ChatHistory/renderers/GroupItemRenderer.tsx b/src/engines/ChatPanel/ChatHistory/renderers/GroupItemRenderer.tsx index 15aa031d7b..94b022a6b0 100644 --- a/src/engines/ChatPanel/ChatHistory/renderers/GroupItemRenderer.tsx +++ b/src/engines/ChatPanel/ChatHistory/renderers/GroupItemRenderer.tsx @@ -183,6 +183,13 @@ function areGroupItemRendererPropsEqual( previous.flatIndex === next.flatIndex && previous.groupIndex === next.groupIndex && previous.turnId === next.turnId && + previous.assistantCopyEventIds.length === + next.assistantCopyEventIds.length && + previous.assistantCopyEventIds.every( + (eventId, index) => eventId === next.assistantCopyEventIds[index] + ) && + previous.resolveAssistantTurnCopyContent === + next.resolveAssistantTurnCopyContent && sameChatItem(previous.chatItem, next.chatItem) && // previousChatItem affects group-chat continuation window only (createdAt // comparison). Shallow-compare the event rather than the full item — the @@ -291,6 +298,10 @@ export interface GroupItemRendererProps { flatIndex: number; groupIndex: number; turnId: string | null; + /** Assistant messages retained as the authoritative copy sources for this turn. */ + assistantCopyEventIds: readonly string[]; + /** Lazily resolves the ids against the uncollapsed projection on click. */ + resolveAssistantTurnCopyContent: (eventIds: readonly string[]) => string; /** The item at `flatIndex`. Passed directly to avoid the full array reference. */ chatItem: OptimizedChatItem | undefined; /** @@ -348,6 +359,8 @@ export const GroupItemRenderer: React.FC = memo( flatIndex, groupIndex, turnId, + assistantCopyEventIds, + resolveAssistantTurnCopyContent, chatItem, previousChatItem, lastAssistantFlatIndex, @@ -435,6 +448,8 @@ export const GroupItemRenderer: React.FC = memo( const turnContext = useMemo( () => ({ lastAssistantFlatIndex, + assistantCopyEventIds, + resolveAssistantTurnCopyContent, isLastGroup, isLastItemInGroup, onRegenerate: onRegenerate @@ -449,6 +464,8 @@ export const GroupItemRenderer: React.FC = memo( }), [ lastAssistantFlatIndex, + assistantCopyEventIds, + resolveAssistantTurnCopyContent, isLastGroup, isLastItemInGroup, isWpGeneWorking, From 8de27b103e063a3a6d6702dcb3436911a854241a Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Wed, 26 Aug 2026 14:24:17 +0800 Subject: [PATCH 3/6] fix(chat): pin find-in-chat bar to header chrome portal Render ChatSearchBar in the pinned header portal above turn pagination instead of inside the scrollable transcript so it no longer overlays messages. Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../components/ChatHistoryView.tsx | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx index 61264e2e13..bfbd728941 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx @@ -307,6 +307,23 @@ const ChatHistoryView: React.FC = ({ } /> ); + const pinnedChromeLayer = ( + <> + {search.isSearchVisible ? ( +
+
+ +
+
+ ) : null} + {pinnedHeaderLayer} + + ); return ( @@ -324,19 +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 From 74976462b24b8536998aee03ff65371da67bbf08 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Wed, 26 Aug 2026 14:31:20 +0800 Subject: [PATCH 4/6] fix(chat): fall back to loaded history when store search is empty Rust EventStore search can return no hits while the rendered transcript still contains the query (evicted store or id drift). Fall back to scanning the already-loaded chatHistory so result counts match DOM highlights. Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../hooks/__tests__/chatSearchHelpers.test.ts | 43 ++++++ .../ChatHistory/hooks/chatSearchHelpers.ts | 131 ++++++++++++++++++ .../ChatHistory/hooks/useChatSearch.ts | 28 +++- 3 files changed, 195 insertions(+), 7 deletions(-) diff --git a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/chatSearchHelpers.test.ts b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/chatSearchHelpers.test.ts index f8dbb6a7ba..6947a24891 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/chatSearchHelpers.test.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/chatSearchHelpers.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from "vitest"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { + buildChatSearchableText, mapRustResultsToSearchResults, + searchChatHistoryLocally, wrapNextSearchResultIndex, } from "../chatSearchHelpers"; @@ -59,4 +61,45 @@ describe("chatSearchHelpers", () => { 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/chatSearchHelpers.ts b/src/engines/ChatPanel/ChatHistory/hooks/chatSearchHelpers.ts index 39188d7d4f..2b564ba820 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/chatSearchHelpers.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/chatSearchHelpers.ts @@ -1,4 +1,9 @@ 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; @@ -89,3 +94,129 @@ export function wrapNextSearchResultIndex( } 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/useChatSearch.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatSearch.ts index f4434edb8d..9cf44b2928 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatSearch.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatSearch.ts @@ -40,6 +40,7 @@ import { type MappedSearchResult, type RustSearchResult, mapRustResultsToSearchResults, + searchChatHistoryLocally, wrapNextSearchResultIndex, } from "./chatSearchHelpers"; import { @@ -100,20 +101,33 @@ async function fetchChatSearchResults( modes: ChatSearchModes, maxResults: number ): Promise { - const rustResults = await invoke( - "es_search_chat_events", - { + const trimmedQuery = query.trim(); + let rustResults: RustSearchResult[] = []; + + try { + rustResults = await invoke("es_search_chat_events", { sessionId, options: { - query, + query: trimmedQuery, caseSensitive: modes.caseSensitive, useRegex: modes.useRegex, wholeWord: modes.wholeWord, maxResults, }, - } - ); - return mapRustResultsToSearchResults(rustResults, chatHistory); + }); + } catch { + return searchChatHistoryLocally( + chatHistory, + trimmedQuery, + modes, + maxResults + ); + } + + const mapped = mapRustResultsToSearchResults(rustResults, chatHistory); + if (mapped.length > 0) return mapped; + + return searchChatHistoryLocally(chatHistory, trimmedQuery, modes, maxResults); } function resolveScrollContainer( From 3c8dab17b65b955e0061d661bc33ef1e44c0a849 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Wed, 26 Aug 2026 18:22:37 +0800 Subject: [PATCH 5/6] fix(chat): export setTurnPageSelection from projection model hook ChatHistory passes setTurnPageSelection into useChatSearch but the projection hook omitted it from its return object after the search refactor. Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../ChatPanel/ChatHistory/hooks/useChatHistoryProjectionModel.ts | 1 + 1 file changed, 1 insertion(+) 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, From 67bd78a16237728bab70485c992e9a863be3c27e Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Wed, 26 Aug 2026 18:45:18 +0800 Subject: [PATCH 6/6] fix(chat): restore turn identity keys and unified list rendering Search refactor regressed ChatHistoryList by deriving virtualizer keys from body content and splitting static/virtual into early returns. Restore buildChatGroupRenderKeys, keep one scroll container for both modes, forward null through measureVirtualRow, and mock ResizeObserver in the identity regression test. Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../components/ChatHistoryList.tsx | 289 ++++++++---------- .../__tests__/ChatHistoryListIdentity.test.ts | 9 + 2 files changed, 139 insertions(+), 159 deletions(-) diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx index 4a55c2ce8d..fefdd8afca 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx @@ -42,6 +42,7 @@ import { useChatHistoryListActiveGroupReporter } from "./ChatHistoryListActiveGr import { sameChatHistoryListProps } from "./ChatHistoryListEquality"; import { EMPTY_ROW_GROUP_META, + buildChatGroupRenderKeys, buildRowGroupMeta, isScrolledToContentBottom, resolveActiveGroupPinState, @@ -143,6 +144,10 @@ const ChatHistoryList: React.FC = memo( return group; }); }, [effectiveGroupCounts]); + const groupRenderKeys = useMemo( + () => buildChatGroupRenderKeys(turnIds), + [turnIds] + ); const flatIndexToGroupIndex = useMemo(() => { const indexes: number[] = []; for (const group of virtualGroups) { @@ -158,24 +163,8 @@ const ChatHistoryList: React.FC = memo( getScrollElement: () => virtualScrollerRef.current, estimateSize: () => 360, overscan: 4, - getItemKey: (index) => { - const group = virtualGroups[index]; - if (!group) return `chat-group-${index}:0`; - const itemKeys = flatItems - .slice(group.startFlatIndex, group.startFlatIndex + group.itemCount) - .map((item) => { - const event = item.event; - const displayTextLength = event?.displayText?.length ?? 0; - return [ - item.chunk_id, - event?.displayStatus ?? "", - event?.activityStatus ?? "", - displayTextLength, - ].join(":"); - }) - .join("|"); - return `${index}:${group.itemCount}:${itemKeys}`; - }, + getItemKey: (index) => + groupRenderKeys[index] ?? `chat-group-index:${index}`, }); const virtualItems = virtualizer.getVirtualItems(); const rowResizeObserverRef = useRef(null); @@ -183,6 +172,7 @@ const ChatHistoryList: React.FC = memo( 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) => { @@ -199,7 +189,6 @@ const ChatHistoryList: React.FC = memo( } }); } - virtualizer.measureElement(node); if (!observedRowsRef.current.has(node)) { observedRowsRef.current.add(node); rowResizeObserverRef.current.observe(node); @@ -347,37 +336,21 @@ const ChatHistoryList: React.FC = memo( const staticGroups = useMemo(() => { if (!useStaticRendering) return []; - const seenGroupKeys = new Set(); let nextGroupStartFlatIndex = 0; return effectiveGroupCounts.map((groupItemCount, groupIndex) => { const groupStartFlatIndex = nextGroupStartFlatIndex; nextGroupStartFlatIndex += groupItemCount; - // Only a group that owns at least one item may borrow its identity from - // one. A zero-count group's start index points at the *next* group's - // first item, so reading it unconditionally makes both groups emit the - // same key ("Encountered two children with the same key"). Empty groups - // are produced by useChatGroupsProjection when a collapsed turn has no - // structural source. - const firstItem = - groupItemCount > 0 ? flatItems[groupStartFlatIndex] : undefined; - let groupKey = - firstItem?.event?.id ?? - firstItem?.chunk_id ?? - `static-group-${groupIndex}`; - if (seenGroupKeys.has(groupKey)) { - groupKey = `${groupKey}#${groupIndex}`; - } - seenGroupKeys.add(groupKey); return { groupIndex, - groupKey, + groupKey: + groupRenderKeys[groupIndex] ?? `chat-group-index:${groupIndex}`, itemIndexes: Array.from( { length: groupItemCount }, (_, itemOffset) => groupStartFlatIndex + itemOffset ), }; }); - }, [useStaticRendering, effectiveGroupCounts, flatItems]); + }, [useStaticRendering, effectiveGroupCounts, groupRenderKeys]); const renderGroupItem = React.useCallback( (flatIndex: number, groupIndex: number) => { @@ -442,96 +415,22 @@ const ChatHistoryList: React.FC = memo( onActiveGroupIndexChange, }); - if (useStaticRendering) { - return ( -
{ - const element = event.currentTarget; - onAtBottomStateChange( - isScrolledToContentBottom({ - element, - footerSpacerHeight, - bottomInset, - }) - ); - scheduleReportActiveGroupIndex(element); - }} - > -
- {staticGroups.map(({ groupIndex, groupKey, itemIndexes }) => ( -
-
-
- {renderGroupHeaderProp(groupIndex, "user")} -
- {renderGroupHeaderProp(groupIndex, "collapse")} -
- {itemIndexes.map((itemFlatIndex) => { - if (itemFlatIndex >= flatItems.length) { - return ( - - ); - } - const itemKey = - flatItems[itemFlatIndex]?.chunk_id ?? - `static-chat-${itemFlatIndex}`; - const rowMeta = - rowGroupMeta[itemFlatIndex] ?? EMPTY_ROW_GROUP_META; - return ( - - ); - })} -
- ))} -
-
-
- ); - } + const setScrollContainerRef = useCallback( + (node: HTMLDivElement | null) => { + if (useStaticRendering) { + if (staticScrollerRef) staticScrollerRef.current = node; + virtualScrollerRef.current = null; + return; + } + if (staticScrollerRef) staticScrollerRef.current = null; + virtualScrollerRef.current = node; + }, + [staticScrollerRef, useStaticRendering, virtualScrollerRef] + ); return (
{ - virtualScrollerRef.current = node; - }} + ref={setScrollContainerRef} data-testid="chat-history-scroll-container" className="allow-select-deep h-full w-full overflow-y-auto overscroll-contain scrollbar-hide" style={{ paddingTop: topPaddingPx }} @@ -544,47 +443,119 @@ const ChatHistoryList: React.FC = memo( }); onAtBottomStateChange(isAtBottom); scheduleReportActiveGroupIndex(element); - if (isAtBottom) onEndReached(); + if (!useStaticRendering && isAtBottom) onEndReached(); }} >
- {virtualItems.map((virtualItem) => { - const group = virtualGroups[virtualItem.index]; - if (!group) return null; - return ( -
-
-
- {renderGroupHeaderProp(group.groupIndex, "user")} + {useStaticRendering + ? staticGroups.map(({ groupIndex, groupKey, itemIndexes }) => ( +
+
+
+ {renderGroupHeaderProp(groupIndex, "user")} +
+ {renderGroupHeaderProp(groupIndex, "collapse")}
- {renderGroupHeaderProp(group.groupIndex, "collapse")} + {itemIndexes.map((itemFlatIndex) => { + if (itemFlatIndex >= flatItems.length) { + return ( + + ); + } + const itemKey = + flatItems[itemFlatIndex]?.chunk_id ?? + `static-chat-${itemFlatIndex}`; + const rowMeta = + rowGroupMeta[itemFlatIndex] ?? EMPTY_ROW_GROUP_META; + return ( + + ); + })}
- {Array.from({ length: group.itemCount }, (_, itemOffset) => { - const flatIndex = group.startFlatIndex + itemOffset; - return ( -
- {renderGroupItem(flatIndex, group.groupIndex)} + )) + : virtualItems.map((virtualItem) => { + const group = virtualGroups[virtualItem.index]; + if (!group) return null; + return ( +
+
+
+ {renderGroupHeaderProp(group.groupIndex, "user")} +
+ {renderGroupHeaderProp(group.groupIndex, "collapse")}
- ); - })} -
- ); - })} + {Array.from( + { length: group.itemCount }, + (_, itemOffset) => { + const flatIndex = group.startFlatIndex + itemOffset; + return ( +
+ {renderGroupItem(flatIndex, group.groupIndex)} +
+ ); + } + )} +
+ ); + })} + {useStaticRendering ? ( +
+ ) : null}
); 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(() => {