diff --git a/packages/ui/src/components/folder-tree-node.tsx b/packages/ui/src/components/folder-tree-node.tsx index 1d9b0348..02c4ce4a 100644 --- a/packages/ui/src/components/folder-tree-node.tsx +++ b/packages/ui/src/components/folder-tree-node.tsx @@ -1,6 +1,7 @@ -import { Component, createSignal, Show, For } from "solid-js" +import { Component, createSignal, Show, For, createEffect, onCleanup } from "solid-js" import { ChevronRight, ChevronDown, File, Folder, FolderOpen } from "lucide-solid" import type { FileSystemEntry } from "../../../server/src/api-types" +import { SECTION_EXPANSION_EVENT, type SectionExpansionRequest } from "../lib/section-expansion" /** * Props for FolderTreeNode component @@ -38,6 +39,25 @@ const FolderTreeNode: Component = (props) => { const [isLoading, setIsLoading] = createSignal(false) const [error, setError] = createSignal(null) + // Listen for folder expansion requests from search system + createEffect(() => { + if (typeof window === "undefined") return + + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail + if ( + detail.action === "expand-folder-node" && + detail.elementId === props.entry.path + ) { + // Auto-expand the folder + setIsExpanded(true) + } + } + + window.addEventListener(SECTION_EXPANSION_EVENT, handler) + onCleanup(() => window.removeEventListener(SECTION_EXPANSION_EVENT, handler)) + }) + const isDirectory = () => props.entry.type === "directory" const isMarkdownFile = () => { return props.entry.type === "file" && props.entry.name.toLowerCase().endsWith(".md") @@ -123,7 +143,7 @@ const FolderTreeNode: Component = (props) => { } return ( -
+
{/* Node row */}
= (props) => { showCommandPalette(props.instance.id) } + const openCurrentSessionSearch = () => { + const currentSessionId = activeSessionIdForInstance() + openSearch(props.instance.id, currentSessionId || undefined) + } + const openBackgroundOutput = (process: BackgroundProcess) => { setSelectedBackgroundProcess(process) setShowBackgroundOutput(true) @@ -1137,6 +1145,27 @@ const InstanceShell2: Component = (props) => { setRightPanelExpandedItems(values) } + // Listen for sidebar accordion expansion requests from search system + createEffect(() => { + if (typeof window === "undefined") return + + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail + if ( + detail.action === "expand-sidebar-accordion" && + detail.instanceId === props.instance.id + ) { + const sectionId = detail.sectionId + if (sectionId && !rightPanelExpandedItems().includes(sectionId)) { + setRightPanelExpandedItems((prev) => [...prev, sectionId]) + } + } + } + + window.addEventListener(SECTION_EXPANSION_EVENT, handler) + onCleanup(() => window.removeEventListener(SECTION_EXPANSION_EVENT, handler)) + }) + const isSectionExpanded = (id: string) => rightPanelExpandedItems().includes(id) return ( diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index eeb75486..4d7acec4 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -4,6 +4,12 @@ import { useGlobalCache } from "../lib/hooks/use-global-cache" import type { TextPart, RenderCache } from "../types/message" import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" +import { + matches as searchMatches, + currentIndex as searchCurrentIndex, + isOpen as searchIsOpen, + query as searchQuery, +} from "../stores/search-store" const log = getLogger("session") @@ -27,6 +33,8 @@ interface MarkdownProps { part: TextPart instanceId?: string sessionId?: string + messageId?: string + partIndex?: number isDark?: boolean size?: "base" | "sm" | "tight" disableHighlight?: boolean @@ -37,6 +45,201 @@ export function Markdown(props: MarkdownProps) { const [html, setHtml] = createSignal("") let containerRef: HTMLDivElement | undefined let latestRequestedText = "" + let highlightTimeout: ReturnType | undefined + + function clearSearchMarks() { + if (!containerRef) return + const marks = containerRef.querySelectorAll("mark.search-match") + for (const mark of Array.from(marks)) { + const parent = mark.parentNode + if (!parent) continue + parent.replaceChild(document.createTextNode(mark.textContent ?? ""), mark) + parent.normalize() + } + } + + function applySearchHighlights() { + if (!containerRef) return + + // Early check: container must have content + if (!containerRef.hasChildNodes()) return + + if (!searchIsOpen()) { + clearSearchMarks() + return + } + + const q = searchQuery() + if (!q) { + clearSearchMarks() + return + } + + // If store has no matches, do nothing. + // (We still clear to remove stale highlights.) + const allMatches = searchMatches() + if (allMatches.length === 0) { + clearSearchMarks() + return + } + + // Best-effort DOM highlight for markdown blocks: wrap occurrences in text nodes. + // We intentionally avoid code/pre/link nodes to prevent breaking markup. + clearSearchMarks() + + const queryLower = q.toLowerCase() + const scopeMessageId = props.messageId + const scopePartIndex = typeof props.partIndex === "number" ? props.partIndex : null + + let nodes: Text[] = [] + try { + const walker = document.createTreeWalker(containerRef, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + const text = node.textContent + if (!text || !text.trim()) return NodeFilter.FILTER_REJECT + const parent = (node as Text).parentElement + if (!parent) return NodeFilter.FILTER_REJECT + if (parent.closest("code, pre, a")) return NodeFilter.FILTER_REJECT + if (parent.closest("mark.search-match")) return NodeFilter.FILTER_REJECT + return NodeFilter.FILTER_ACCEPT + }, + }) + + let currentNode: Node | null + while ((currentNode = walker.nextNode())) { + nodes.push(currentNode as Text) + } + } catch (error) { + log.error("Error during TreeWalker traversal:", error) + return + } + + let globalIndexOffset = 0 + // Track occurrence index within this (messageId, partIndex) scope for navigation + let occurrenceIndex = 0 + + try { + for (const textNode of nodes) { + const original = textNode.textContent ?? "" + const haystack = original.toLowerCase() + let fromIndex = 0 + const occurrences: Array<{ start: number; end: number }> = [] + + while (true) { + const at = haystack.indexOf(queryLower, fromIndex) + if (at === -1) break + occurrences.push({ start: at, end: at + q.length }) + fromIndex = at + 1 + if (occurrences.length > 200) break + } + + if (occurrences.length === 0) { + globalIndexOffset += original.length + continue + } + + const fragment = document.createDocumentFragment() + let last = 0 + for (const occ of occurrences) { + if (occ.start > last) { + fragment.appendChild(document.createTextNode(original.slice(last, occ.start))) + } + const mark = document.createElement("mark") + mark.className = "search-match" + mark.setAttribute("data-search-match", "true") + if (scopeMessageId && scopePartIndex !== null) { + const globalStart = globalIndexOffset + occ.start + const globalEnd = globalIndexOffset + occ.end + mark.setAttribute("data-search-message-id", scopeMessageId) + mark.setAttribute("data-search-part-index", String(scopePartIndex)) + mark.setAttribute("data-search-start", String(globalStart)) + mark.setAttribute("data-search-end", String(globalEnd)) + // Add occurrence index for reliable navigation + mark.setAttribute("data-search-occurrence", String(occurrenceIndex)) + occurrenceIndex++ + } + mark.textContent = original.slice(occ.start, occ.end) + fragment.appendChild(mark) + last = occ.end + } + if (last < original.length) { + fragment.appendChild(document.createTextNode(original.slice(last))) + } + + textNode.parentNode?.replaceChild(fragment, textNode) + globalIndexOffset += original.length + } + } catch (error) { + log.error("Error applying search highlights:", error) + } + + // Distinguish the current match. + // First, remove any existing --current class to ensure clean state + try { + const existingCurrent = containerRef.querySelector("mark.search-match--current") + if (existingCurrent) { + existingCurrent.classList.remove("search-match--current") + } + } catch (error) { + // Ignore errors during cleanup + } + + try { + const idx = searchCurrentIndex() + if (idx >= 0) { + const currentMatch = allMatches[idx] + + // Only proceed if this message/part contains the current match + if (scopeMessageId && scopePartIndex !== null && currentMatch && + currentMatch.messageId === scopeMessageId && + currentMatch.partIndex === scopePartIndex) { + + // Method 1: Try direct start/end index matching first + const directSelector = + `mark.search-match[data-search-match="true"]` + + `[data-search-message-id="${CSS.escape(scopeMessageId)}"]` + + `[data-search-part-index="${scopePartIndex}"]` + + `[data-search-start="${currentMatch.startIndex}"]` + + `[data-search-end="${currentMatch.endIndex}"]` + const directMark = containerRef.querySelector(directSelector) + if (directMark) { + directMark.classList.add("search-match--current") + return + } + + // Method 2: Use occurrence index - calculate which occurrence this is + // Get all matches for this message/part from the store, sorted by position + const partMatches = allMatches + .filter((m) => m.messageId === scopeMessageId && m.partIndex === scopePartIndex) + .slice() + .sort((a, b) => a.startIndex - b.startIndex) + + const occIdx = partMatches.findIndex( + (m) => + m.startIndex === currentMatch.startIndex && + m.endIndex === currentMatch.endIndex && + m.messageId === currentMatch.messageId && + m.partIndex === currentMatch.partIndex + ) + + if (occIdx >= 0) { + const occSelector = + `mark.search-match[data-search-match="true"]` + + `[data-search-message-id="${CSS.escape(scopeMessageId)}"]` + + `[data-search-part-index="${scopePartIndex}"]` + + `[data-search-occurrence="${occIdx}"]` + const occMark = containerRef.querySelector(occSelector) + if (occMark) { + occMark.classList.add("search-match--current") + return + } + } + } + } + } catch (error) { + log.error("Error marking current match:", error) + } + } const notifyRendered = () => { Promise.resolve().then(() => props.onRendered?.()) @@ -190,10 +393,83 @@ export function Markdown(props: MarkdownProps) { onCleanup(() => { containerRef?.removeEventListener("click", handleClick) cleanupLanguageListener() + + // Clear any pending timeout + if (highlightTimeout) { + clearTimeout(highlightTimeout) + highlightTimeout = undefined + } }) }) const proseClass = () => "markdown-body" + function waitForDOMAndApplyHighlights() { + if (!containerRef) return + + // Clear any existing timeout + if (highlightTimeout) { + clearTimeout(highlightTimeout) + highlightTimeout = undefined + } + + // Early exit if no container content or search not active + if (!containerRef.hasChildNodes() || !searchIsOpen() || !searchQuery() || searchMatches().length === 0) { + applySearchHighlights() + return + } + + // Retry logic with rAF to wait for DOM to fully render + let retryCount = 0 + const maxRetries = 8 + + const tryApplyHighlights = () => { + if (retryCount >= maxRetries) { + // Max retries reached, apply highlights anyway + applySearchHighlights() + return + } + + // Count existing highlighted nodes before applying + const existingHighlights = containerRef!.querySelectorAll("mark.search-match").length + + // Apply highlights + applySearchHighlights() + + // Count highlighted nodes after applying + const newHighlights = containerRef!.querySelectorAll("mark.search-match").length + + // Check if we found all expected matches + const expectedMatches = searchMatches().length + const expectedForThisPart = expectedMatches > 0 ? expectedMatches : 0 + + // If we found highlights, or no matches expected, or content is stable + if (newHighlights > 0 || expectedForThisPart === 0 || existingHighlights === newHighlights && retryCount > 2) { + // Success or stable, no need to retry + return + } + + // Retry after one frame + retryCount++ + highlightTimeout = setTimeout(() => { + requestAnimationFrame(tryApplyHighlights) + }, 0) + } + + // Start retry sequence + tryApplyHighlights() + } + + createEffect(() => { + // Re-apply DOM highlights when search state changes + searchIsOpen() + searchQuery() + searchMatches() + searchCurrentIndex() + + // Wait for DOM to fully render before applying highlights + waitForDOMAndApplyHighlights() + }) + return
} diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index b42d6be0..9f92f0f8 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -1,4 +1,4 @@ -import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js" +import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { FoldVertical } from "lucide-solid" import MessageItem from "./message-item" import ToolCall from "./tool-call" @@ -11,6 +11,7 @@ import { messageStoreBus } from "../stores/message-v2/bus" import { formatTokenTotal } from "../lib/formatters" import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions" import { setActiveInstanceId } from "../stores/instances" +import { SECTION_EXPANSION_EVENT, type SectionExpansionRequest } from "../lib/section-expansion" const TOOL_ICON = "πŸ”§" const USER_BORDER_COLOR = "var(--message-user-border)" @@ -672,6 +673,25 @@ interface ReasoningCardProps { function ReasoningCard(props: ReasoningCardProps) { const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded)) + // Listen for expansion requests from search system + createEffect(() => { + if (typeof window === "undefined") return + + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail + if ( + detail.action === "expand-reasoning" && + detail.messageId === props.part.messageID && + detail.instanceId === props.instanceId + ) { + setExpanded(true) + } + } + + window.addEventListener(SECTION_EXPANSION_EVENT, handler) + onCleanup(() => window.removeEventListener(SECTION_EXPANSION_EVENT, handler)) + }) + createEffect(() => { setExpanded(Boolean(props.defaultExpanded)) }) diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index 95ce2578..18870e58 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -283,10 +283,12 @@ export default function MessageItem(props: MessageItemProps) { - {(part) => ( + {(part, index) => ( interface MessagePartProps { part: ClientPart messageType?: "user" | "assistant" + messageId: string + partIndex: number instanceId: string sessionId: string onRendered?: () => void @@ -98,12 +101,22 @@ interface MessagePartProps {
{plainTextContent()}} + fallback={ + + + + } > { + registerSearchShortcuts() + }) + + // Set search scope + createEffect(() => { + // Update search scope is handled by search panel component + // No need to do anything here + }) + return (
+ {/* Search Panel - Floating overlay */} + props.sessionId} + /> +
@@ -852,7 +872,6 @@ export default function MessageSection(props: MessageSectionProps) {
-
) } diff --git a/packages/ui/src/components/search-highlighted-text.tsx b/packages/ui/src/components/search-highlighted-text.tsx new file mode 100644 index 00000000..badf08a7 --- /dev/null +++ b/packages/ui/src/components/search-highlighted-text.tsx @@ -0,0 +1,123 @@ +/** + * SearchHighlightedText Component + * + * Wraps text content with search match highlighting. + * Applies tags to matching text portions based on current search state. + * + * @module search-highlighted-text + */ + +import { createMemo, Show, For } from "solid-js" +import { matches, currentIndex } from "../stores/search-store" +import { getMatchesForMessage } from "../lib/search-highlight" +import type { SearchMatch } from "../types/search" + +interface SearchHighlightedTextProps { + text: string + messageId: string + partIndex: number +} + +/** + * Component that wraps text with search match highlighting + * + * Usage: + * - For user messages: Wrap plain text + * - For assistant messages: This component returns the text with HTML marks, + * which should be processed by the markdown renderer + */ +export default function SearchHighlightedText(props: SearchHighlightedTextProps) { + // Get matches for this specific message part + const partMatches = createMemo(() => { + const allMatches = matches() + return getMatchesForMessage(allMatches, props.messageId, props.partIndex) + }) + + // Check if there are any matches + const hasMatches = createMemo(() => partMatches().length > 0) + + // Update current match status based on currentIndex + const matchesWithCurrentStatus = createMemo(() => { + const allMatches = matches() + const globalCurrentIndex = currentIndex() + const currentMatch = globalCurrentIndex >= 0 ? allMatches[globalCurrentIndex] : null + + return partMatches().map((match) => ({ + ...match, + isCurrent: + currentMatch !== null && + match.messageId === currentMatch.messageId && + match.partIndex === currentMatch.partIndex && + match.startIndex === currentMatch.startIndex && + match.endIndex === currentMatch.endIndex + })) + }) + + // Build text segments with highlighting + const textSegments = createMemo(() => { + if (!hasMatches()) { + return [{ type: "text" as const, content: props.text }] + } + + const sortedMatches = [...matchesWithCurrentStatus()].sort((a, b) => a.startIndex - b.startIndex) + const segments: Array< + | { type: "text"; content: string } + | { type: "match"; content: string; isCurrent?: boolean; startIndex: number; endIndex: number } + > = [] + let lastIndex = 0 + + for (const match of sortedMatches) { + // Add text before this match + if (match.startIndex > lastIndex) { + segments.push({ + type: "text", + content: props.text.slice(lastIndex, match.startIndex) + }) + } + + // Add the highlighted match + segments.push({ + type: "match", + content: match.text, + isCurrent: match.isCurrent, + startIndex: match.startIndex, + endIndex: match.endIndex, + }) + + lastIndex = match.endIndex + } + + // Add remaining text after last match + if (lastIndex < props.text.length) { + segments.push({ + type: "text", + content: props.text.slice(lastIndex) + }) + } + + return segments + }) + + return ( + {props.text}}> + + {(segment) => + segment.type === "match" ? ( + + {segment.content} + + ) : ( + <>{segment.content} + ) + } + + + ) +} diff --git a/packages/ui/src/components/search-panel.tsx b/packages/ui/src/components/search-panel.tsx new file mode 100644 index 00000000..dfd6e8c0 --- /dev/null +++ b/packages/ui/src/components/search-panel.tsx @@ -0,0 +1,315 @@ +/** + * Search Panel Component + * + * Floating panel for searching through chat messages. + * Features: + * - Search query input + * - Navigation buttons (Previous | Next) + * - Match counter (e.g., "3/15") + * - Options dropdown (case-sensitive, whole-word, etc.) + * - Keyboard shortcuts (Enter, Shift+Enter, Esc, Arrow keys) + * + * @module search-panel + */ + +import { createEffect, createSignal, onMount, onCleanup, Show, For } from "solid-js" +import type { InstanceMessageStore } from "../stores/message-v2/instance-store" +import { Search, ChevronUp, ChevronDown, Settings, X } from "lucide-solid" +import type { SearchOptions } from "../types/search" +import { + query, + setQueryInput, + isOpen, + matches, + currentIndex, + options, + executeSearchOnEnter, + updateOptions as updateSearchOptions, + closeSearch, + navigateNext, + navigatePrevious, + instanceId, + sessionId as searchSessionId, + setInstanceId, + setSessionId, +} from "../stores/search-store" + +interface SearchPanelProps { + store: () => InstanceMessageStore + instanceId: string + sessionId: () => string | null +} + +export default function SearchPanel(props: SearchPanelProps) { + const [inputRef, setInputRef] = createSignal() + const [showOptions, setShowOptions] = createSignal(false) + const [error, setError] = createSignal(null) + + // Get match count and current position + const matchCount = () => matches().length + const currentPosition = () => matchCount() > 0 ? currentIndex() + 1 : 0 + + // Format match counter display + function formatCounter(): string { + const total = matchCount() + const current = currentPosition() + + if (total === 0) return "0/0" + if (total >= 100) return `${current}/100+` + return `${current}/${total}` + } + + // Set search scope when panel opens or session changes + createEffect(() => { + if (!isOpen()) return + + const currentSessionId = props.sessionId() + + // Set instance and session scope in the search store + setInstanceId(props.instanceId) + setSessionId(currentSessionId || null) + }) + + // Focus input when panel opens, close options when panel closes + createEffect(() => { + if (isOpen()) { + const input = inputRef() + if (input) { + requestAnimationFrame(() => { + input.focus() + input.select() + }) + } + } else { + // Close options dropdown when panel closes + setShowOptions(false) + } + }) + + // Handle Enter key to execute search or navigate + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Enter") { + // If Shift key is pressed, navigate to previous match + if (e.shiftKey) { + e.preventDefault() + e.stopPropagation() + if (matchCount() > 0) { + navigatePrevious() + } + return + } + + // If search has results, navigate to next match + // Otherwise, execute the search + if (matchCount() > 0) { + e.preventDefault() + e.stopPropagation() + navigateNext() + } else { + try { + setError(null) + executeSearchOnEnter(props.store()) + } catch (err) { + setError(err instanceof Error ? err.message : "Search error occurred") + } + } + } else if (e.key === "Escape") { + // Close search on Esc + closeSearch() + } + } + + // Handle query input change + function handleQueryChange(e: Event) { + const target = e.target as HTMLInputElement + setQueryInput(target.value, props.store()) + setError(null) + } + + // Handle navigation clicks + function handlePreviousClick() { + navigatePrevious() + // Return focus to input + inputRef()?.focus() + } + + function handleNextClick() { + navigateNext() + // Return focus to input + inputRef()?.focus() + } + + // Handle close button + function handleCloseClick() { + closeSearch() + } + + // Handle option toggle + function handleOptionToggle(option: T, value: boolean) { + const newOptions: Record = {} + newOptions[option as string] = value + updateSearchOptions(newOptions as Partial, props.store()) + // Return focus to input + inputRef()?.focus() + } + + // Options dropdown items + const optionItems = () => [ + { + id: "caseSensitive", + label: "Case Sensitive", + checked: () => options().caseSensitive, + onChange: (checked: boolean) => handleOptionToggle("caseSensitive", checked) + }, + { + id: "wholeWord", + label: "Whole Word", + checked: () => options().wholeWord, + onChange: (checked: boolean) => handleOptionToggle("wholeWord", checked) + }, + { + id: "includeToolOutputs", + label: "Include Tool Outputs", + checked: () => options().includeToolOutputs, + onChange: (checked: boolean) => handleOptionToggle("includeToolOutputs", checked) + }, + { + id: "includeReasoning", + label: "Include Reasoning Blocks", + checked: () => options().includeReasoning, + onChange: (checked: boolean) => handleOptionToggle("includeReasoning", checked) + } + ] + + // Close options dropdown when clicking outside + createEffect(() => { + if (!showOptions()) return + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement + const optionsPanel = document.querySelector(".search-options-panel") + const optionsButton = document.querySelector(".search-options-button") + + if (optionsPanel && !optionsPanel.contains(target) && !optionsButton?.contains(target)) { + setShowOptions(false) + } + } + + document.addEventListener("click", handleClickOutside) + onCleanup(() => { + document.removeEventListener("click", handleClickOutside) + }) + }) + + return ( + +
+
+ {/* Left side: Search icon and input */} +
+ + + + + +
+ + {/* Middle: Nav buttons and match counter */} +
+ + +
+ {formatCounter()} +
+ + +
+ + {/* Right side: Options and close */} +
+ + + +
+
+ + {/* Options dropdown */} + +
+ + {(item) => ( + + )} + +
+
+ + {/* Error message */} + +
+ {error()} +
+
+
+
+ ) +} diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index 75050293..0ec76f5e 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -24,6 +24,7 @@ import { resolveTitleForTool } from "./tool-call/tool-title" import { getLogger } from "../lib/logger" import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../lib/ansi" import { escapeHtml } from "../lib/markdown" +import { SECTION_EXPANSION_EVENT, type SectionExpansionRequest } from "../lib/section-expansion" const log = getLogger("session") @@ -292,6 +293,34 @@ export default function ToolCall(props: ToolCallProps) { const [userExpanded, setUserExpanded] = createSignal(null) + // Listen for expansion requests from search system + createEffect(() => { + if (typeof window === "undefined") return + + const partId = toolCallMemo()?.id + + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail + const shouldExpand = + (detail.action === "expand-tool-call" || + detail.action === "expand-diagnostics") && + detail.messageId === props.messageId && + detail.partId === partId && + detail.instanceId === props.instanceId + + if (shouldExpand) { + if (detail.action === "expand-diagnostics") { + setDiagnosticsOverride(true) + } else { + setUserExpanded(true) + } + } + } + + window.addEventListener(SECTION_EXPANSION_EVENT, handler) + onCleanup(() => window.removeEventListener(SECTION_EXPANSION_EVENT, handler)) + }) + const expanded = () => { const permission = pendingPermission() if (permission?.active) return true diff --git a/packages/ui/src/lib/search-algorithm.ts b/packages/ui/src/lib/search-algorithm.ts new file mode 100644 index 00000000..21305f8e --- /dev/null +++ b/packages/ui/src/lib/search-algorithm.ts @@ -0,0 +1,214 @@ +/** + * Search Algorithm for Message Content + * + * This module provides efficient text search functionality for finding matches + * in message content with support for case-sensitive and whole-word matching. + * + * @example + * ```typescript + * import { findMatches } from './search-algorithm' + * + * const matches = findMatches( + * "Hello world! Hello again.", + * "msg-123", + * 0, + * "hello", + * { caseSensitive: false, wholeWord: false, includeToolOutputs: false, includeReasoning: false } + * ) + * // Returns: [ + * // { messageId: "msg-123", partIndex: 0, startIndex: 0, endIndex: 5, text: "Hello", isCurrent: false }, + * // { messageId: "msg-123", partIndex: 0, startIndex: 13, endIndex: 18, text: "Hello", isCurrent: false } + * // ] + * ``` + */ + +import type { SearchMatch, SearchOptions } from '../types/search' + +/** + * Validates that the query contains only text characters + * Non-text characters include special symbols that could cause issues + * + * @param query - The search query string to validate + * @returns true if the query contains non-text characters, false otherwise + */ +function hasNonTextCharacters(query: string): boolean { + // Allow: + // - Unicode letters (including non-Latin scripts) + // - Numbers + // - Whitespace characters + // - Basic punctuation: . , ; : ! ? ( ) [ ] { } " ' - _ + + // Detect disallowed symbols: @ # $ % ^ & * < > \ / = ` ~ | etc. + const symbolPattern = /[@#$%^&*<>/\\=`~|]/ + return symbolPattern.test(query) +} + +/** + * Escapes special regex characters in a string + * This prevents errors when using the string in a regex pattern + * + * @param str - The string to escape + * @returns The escaped string safe for use in regex + */ +function escapeRegexSpecialChars(str: string): string { + // Regex metacharacters to escape: \ ^ $ * + ? . ( ) | [ ] { } + return str.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&') +} + +/** + * Finds all matches of a query string within a text + * + * This function efficiently searches for text matches with support for: + * - Case-sensitive and case-insensitive matching + * - Whole-word and substring matching + * - Original position preservation for highlighting + * - Unicode characters (including non-Latin scripts) + * + * Note on whole-word matching: + * - Word boundaries (\b) match between word characters (letters, numbers, underscore) + * and non-word characters (spaces, punctuation, etc.) + * - Punctuation in the query (like ".", ",", "!" etc.) is treated as part of the query + * - For example, searching "world" in "Hello (world)!" will find "world" because + * parentheses and spaces are non-word characters + * + * @param text - The text content to search within + * @param messageId - The unique identifier of the message containing the text + * @param partIndex - The index of the part within the message (0-based) + * @param query - The search query string + * @param options - Search configuration options + * @returns Array of SearchMatch objects, or empty array if no matches found + * @throws Error if query contains non-text characters (symbols like @, #, $, etc.) + * + * @example + * ```typescript + * // Case-insensitive substring search + * const matches1 = findMatches("Hello World", "msg-1", 0, "hello", { + * caseSensitive: false, + * wholeWord: false, + * includeToolOutputs: false, + * includeReasoning: false + * }) + * // Returns: [{ messageId: "msg-1", partIndex: 0, startIndex: 0, endIndex: 5, text: "Hello", isCurrent: false }] + * + * // Case-sensitive whole-word search + * const matches2 = findMatches("Hello World", "msg-1", 0, "Hello", { + * caseSensitive: true, + * wholeWord: true, + * includeToolOutputs: false, + * includeReasoning: false + * }) + * // Returns: [{ messageId: "msg-1", partIndex: 0, startIndex: 0, endIndex: 5, text: "Hello", isCurrent: false }] + * + * // No matches + * const matches3 = findMatches("Hello World", "msg-1", 0, "Goodbye", { + * caseSensitive: false, + * wholeWord: false, + * includeToolOutputs: false, + * includeReasoning: false + * }) // Returns: [] + * + * // Unicode support + * const matches4 = findMatches("Hello δ½ ε₯½ World", "msg-1", 0, "δ½ ε₯½", { + * caseSensitive: true, + * wholeWord: false, + * includeToolOutputs: false, + * includeReasoning: false + * }) + * // Returns: [{ messageId: "msg-1", partIndex: 0, startIndex: 6, endIndex: 8, text: "δ½ ε₯½", isCurrent: false }] + * ``` + */ +export function findMatches( + text: string, + messageId: string, + partIndex: number, + query: string, + options: SearchOptions +): SearchMatch[] { + // Early return: query is empty or too short + if (!query || query.length < 1) { + return [] + } + + // Validation: check for non-text characters (symbols) + if (hasNonTextCharacters(query)) { + throw new Error( + 'Search query contains invalid characters. Please use only letters, numbers, spaces, and basic punctuation (. , ; : ! ? ( ) [ ] { } " \' - _ +). Symbols are not supported in this simple search function.' + ) + } + + // Prepare text and query based on case sensitivity + let searchText = text + let searchQuery = query + + if (!options.caseSensitive) { + // Case-insensitive: use lowercase versions for searching + searchText = text.toLowerCase() + searchQuery = query.toLowerCase() + } else { + // Case-sensitive: use original text and query + searchText = text + searchQuery = query + } + + const matches: SearchMatch[] = [] + + if (options.wholeWord) { + // Whole-word matching using regex + // Escape special regex characters in the query + const escapedQuery = escapeRegexSpecialChars(searchQuery) + + // Build regex pattern with word boundaries + const pattern = new RegExp(`\\b${escapedQuery}\\b`, 'g') + + // Find all matches using regex + const regexMatches = searchText.matchAll(pattern) + + for (const match of regexMatches) { + const startIndex = match.index! + const endIndex = startIndex + match[0].length + + // Use original text to get the actual matched substring + const matchedText = text.slice(startIndex, endIndex) + + matches.push({ + messageId, + partIndex, + startIndex, + endIndex, + text: matchedText, + isCurrent: false // Will be set by the store + }) + } + } else { + // Substring matching using indexOf for efficiency + let index = 0 + + // Use while loop to find all occurrences + while (true) { + index = searchText.indexOf(searchQuery, index) + + // No more matches found + if (index === -1) { + break + } + + const endIndex = index + searchQuery.length + + // Use original text to get the actual matched substring + const matchedText = text.slice(index, endIndex) + + matches.push({ + messageId, + partIndex, + startIndex: index, + endIndex, + text: matchedText, + isCurrent: false // Will be set by the store + }) + + // Move past this match to find the next one + index += 1 + } + } + + return matches +} diff --git a/packages/ui/src/lib/search-highlight.ts b/packages/ui/src/lib/search-highlight.ts new file mode 100644 index 00000000..c2f6cd30 --- /dev/null +++ b/packages/ui/src/lib/search-highlight.ts @@ -0,0 +1,133 @@ +/** + * Search Highlight Utilities + * + * Helper functions for rendering search matches in text content. + * Provides utilities to wrap matched text with tags + * while preserving the structure for markdown rendering. + * + * @module search-highlight + */ + +import type { SearchMatch } from "../types/search" + +/** + * Highlight matches in plain text content + * + * Wraps matching text portions with tags. + * The current match gets a distinctive class. + * + * @param text - The text content to highlight + * @param matches - Array of SearchMatch objects for this text + * @returns Array of string/element parts to render + * + * @example + * ```typescript + * const parts = highlightTextInContent( + * "Hello world! Hello again.", + * [ + * { messageId: "msg-1", partIndex: 0, startIndex: 0, endIndex: 5, text: "Hello", isCurrent: true }, + * { messageId: "msg-1", partIndex: 0, startIndex: 13, endIndex: 18, text: "Hello", isCurrent: false } + * ] + * ) + * // Returns: [ + * // "Hello", + * // " world! ", + * // "Hello", + * // " again." + * // ] + * ``` + */ +export function highlightTextInContent( + text: string, + matches: SearchMatch[] +): (string | HTMLElement)[] { + if (matches.length === 0) { + return [text] + } + + // Sort matches by position to process in order + const sortedMatches = [...matches].sort((a, b) => a.startIndex - b.startIndex) + + const parts: (string | HTMLElement)[] = [] + let lastIndex = 0 + + for (const match of sortedMatches) { + // Add text before this match + if (match.startIndex > lastIndex) { + parts.push(text.slice(lastIndex, match.startIndex)) + } + + // Add the highlighted match + const mark = document.createElement("mark") + mark.className = match.isCurrent + ? "search-match search-match--current" + : "search-match" + mark.textContent = match.text + parts.push(mark) + + lastIndex = match.endIndex + } + + // Add remaining text after last match + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)) + } + + return parts +} + +/** + * Get matches for a specific message and part + * + * Filters the global matches array to only those matching + * the given message ID and part index. + * + * @param allMatches - All search matches from the search store + * @param messageId - Message ID to filter by + * @param partIndex - Part index to filter by (optional) + * @returns Filtered matches for the message part + */ +export function getMatchesForMessage( + allMatches: SearchMatch[], + messageId: string, + partIndex?: number +): SearchMatch[] { + return allMatches.filter((match) => { + if (match.messageId !== messageId) return false + if (partIndex !== undefined && match.partIndex !== partIndex) return false + return true + }) +} + +/** + * Check if content has any search matches + * + * @param allMatches - All search matches from the search store + * @param messageId - Message ID to check + * @param partIndex - Part index to check (optional) + * @returns True if there are matches for this content + */ +export function hasMatchesForContent( + allMatches: SearchMatch[], + messageId: string, + partIndex?: number +): boolean { + return getMatchesForMessage(allMatches, messageId, partIndex).length > 0 +} + +/** + * Get the index of the current match in the content + * + * @param allMatches - All search matches from the search store + * @param messageId - Message ID + * @param partIndex - Part index (optional) + * @returns Index of current match, or -1 if none + */ +export function getCurrentMatchIndex( + allMatches: SearchMatch[], + messageId: string, + partIndex?: number +): number { + const matches = getMatchesForMessage(allMatches, messageId, partIndex) + return matches.findIndex((m) => m.isCurrent) +} diff --git a/packages/ui/src/lib/section-expansion.ts b/packages/ui/src/lib/section-expansion.ts new file mode 100644 index 00000000..ce88baa1 --- /dev/null +++ b/packages/ui/src/lib/section-expansion.ts @@ -0,0 +1,396 @@ +/** + * Section Expansion System + * + * Provides centralized control for expanding collapsible/expandable sections + * when navigating search results. Components can listen for expansion requests + * and respond by expanding their content. + * + * @module section-expansion + */ + +import type { SearchMatch } from "../types/search" + +/** + * Types of collapsible sections that can be expanded + */ +export type SectionExpansionAction = + | "expand-reasoning" + | "expand-tool-call" + | "expand-diagnostics" + | "expand-folder-node" + | "expand-session-parent" + | "expand-sidebar-accordion" + +/** + * Detail payload for section expansion requests + */ +export interface SectionExpansionRequest { + instanceId: string + sessionId?: string + messageId?: string + partIndex?: number + partId?: string + action: SectionExpansionAction + elementId?: string + sectionId?: string +} + +/** + * Event name for section expansion requests + */ +export const SECTION_EXPANSION_EVENT = "opencode:section-expansion-request" + +/** + * Track which sections we've already expanded to avoid duplicates + */ +const expandedSections = new Set() + +/** + * Generate unique key for tracking expanded sections + */ +function makeExpansionKey(action: SectionExpansionAction, instanceId: string, identifier: string): string { + return `${action}:${instanceId}:${identifier}` +} + +/** + * Emit a section expansion request event + * Components can listen for this event and respond by expanding + */ +export function emitSectionExpansion(detail: SectionExpansionRequest) { + if (typeof window === "undefined") return + + // Generate tracking key + const identifier = [ + detail.messageId, + detail.partId, + detail.elementId, + detail.sectionId, + ].filter(Boolean).join(":") + + const key = makeExpansionKey(detail.action, detail.instanceId, identifier) + + // Skip if already expanded + if (expandedSections.has(key)) { + return false + } + + // Mark as expanded + expandedSections.add(key) + + // Dispatch event + window.dispatchEvent(new CustomEvent(SECTION_EXPANSION_EVENT, { detail })) + return true +} + +/** + * Clear expansion tracking cache (e.g., when starting a new search) + */ +export function clearExpansionCache() { + expandedSections.clear() +} + +/** + * Request expansion of a reasoning block + */ +export function requestReasoningExpansion(instanceId: string, messageId: string, partIndex: number) { + return emitSectionExpansion({ + instanceId, + messageId, + partIndex, + action: "expand-reasoning", + }) +} + +/** + * Request expansion of a tool call output + */ +export function requestToolCallExpansion(instanceId: string, messageId: string, partId: string) { + return emitSectionExpansion({ + instanceId, + messageId, + partId, + action: "expand-tool-call", + }) +} + +/** + * Request expansion of diagnostics within a tool call + */ +export function requestDiagnosticsExpansion(instanceId: string, messageId: string, partId: string) { + return emitSectionExpansion({ + instanceId, + messageId, + partId, + action: "expand-diagnostics", + }) +} + +/** + * Request expansion of a sidebar accordion section + */ +export function requestSidebarAccordionExpansion(instanceId: string, sectionId: string) { + return emitSectionExpansion({ + instanceId, + sectionId, + action: "expand-sidebar-accordion", + }) +} + +/** + * Request expansion of a folder tree node + */ +export function requestFolderNodeExpansion(instanceId: string, elementId: string) { + return emitSectionExpansion({ + instanceId, + elementId, + action: "expand-folder-node", + }) +} + +/** + * Request expansion of a session parent in the session list + */ +export function requestSessionParentExpansion(instanceId: string, sessionId: string) { + return emitSectionExpansion({ + instanceId, + sessionId, + action: "expand-session-parent", + }) +} + +/** + * Check if a search match element is inside a collapsed section + */ +export function isMatchInCollapsedSection(matchElement: Element): boolean { + // Check 1: Parent has aria-expanded="false" + const toggleParent = matchElement.closest('[aria-expanded]') + if (toggleParent && toggleParent.getAttribute('aria-expanded') === 'false') { + return true + } + + // Check 2: Element is inside reasoning card (no aria-expanded, uses Show component) + const reasoningCard = matchElement.closest('.message-reasoning-card') + if (reasoningCard) { + // Check if content div is rendered + const content = reasoningCard.querySelector('.message-reasoning-expanded') + return !content + } + + // Check 3: Element is inside tool call + const toolCall = matchElement.closest('.tool-call') + if (toolCall) { + const details = toolCall.querySelector('.tool-call-details') + return !details + } + + // Check 4: Element is inside folder node + const folderNode = matchElement.closest('.folder-tree-node') + if (folderNode) { + const children = folderNode.querySelector('.folder-tree-node-children') + return !children + } + + // Check 5: Element is inside sidebar accordion item + // Note: Kobalte removes accordion content from DOM when collapsed, + // so if we found the element it must already be expanded. + // For this case, always return false since if element exists, it's visible + const accordionRoot = matchElement.closest('.accordion-root') + if (accordionRoot) { + return false + } + + return false +} + +/** + * Build CSS selector for a search match element + */ +function buildMatchSelector(match: SearchMatch): string { + return ( + 'mark[data-search-match="true"]' + + `[data-search-message-id="${CSS.escape(match.messageId)}"]` + + `[data-search-part-index="${match.partIndex}"]` + + `[data-search-start="${match.startIndex}"]` + + `[data-search-end="${match.endIndex}"]` + ) +} + +/** + * Find which collapsible type contains the match element + */ +function identifyCollapsibleParent(matchElement: Element): { + type: SectionExpansionAction | null + container: Element | null + identifiers: Record +} { + // Check reasoning card + const reasoningCard = matchElement.closest('.message-reasoning-card') + if (reasoningCard) { + const messageBlock = reasoningCard.closest('[data-message-id]') + const messageId = messageBlock?.getAttribute('data-message-id') || '' + return { + type: 'expand-reasoning', + container: reasoningCard, + identifiers: { messageId, partIndex: matchElement.getAttribute('data-search-part-index') || '0' }, + } + } + + // Check tool call + const toolCall = matchElement.closest('.tool-call') + if (toolCall) { + const messageBlock = toolCall.closest('[data-message-id]') + const messageId = messageBlock?.getAttribute('data-message-id') || '' + const toolKey = toolCall.getAttribute('data-key') + const partId = toolKey?.split(':').pop() || '' + + return { + type: 'expand-tool-call', + container: toolCall, + identifiers: { messageId, partId }, + } + } + + // Check diagnostics (nested inside tool call) + const diagnostics = matchElement.closest('.tool-diagnostics-section') + if (diagnostics) { + const toolCall = diagnostics.closest('.tool-call') + const toolKey = toolCall?.getAttribute('data-key') + const partId = toolKey?.split(':').pop() || '' + const messageBlock = toolCall?.closest('[data-message-id]') + const messageId = messageBlock?.getAttribute('data-message-id') || '' + + return { + type: 'expand-diagnostics', + container: diagnostics, + identifiers: { messageId, partId }, + } + } + + // Check folder node + const folderNode = matchElement.closest('.folder-tree-node') + if (folderNode) { + const elementId = folderNode.getAttribute('data-node-id') || '' + return { + type: 'expand-folder-node', + container: folderNode, + identifiers: { elementId }, + } + } + + // Check sidebar accordion + const accordionItem = matchElement.closest('[data-radix-collection-item]') + if (accordionItem) { + const sectionId = accordionItem.getAttribute('data-value') || '' + return { + type: 'expand-sidebar-accordion', + container: accordionItem, + identifiers: { sectionId }, + } + } + + // Check session list + const sessionItem = matchElement.closest('.session-item') + if (sessionItem) { + const sessionId = sessionItem.getAttribute('data-session-id') || '' + return { + type: 'expand-session-parent', + container: sessionItem, + identifiers: { sessionId }, + } + } + + return { type: null, container: null, identifiers: {} } +} + +/** + * Expand all collapsed sections containing a search match + * Returns true if any expansion was triggered, false otherwise + */ +export function expandSectionsForMatch(instanceId: string, match: SearchMatch): boolean { + // Try to expand reasoning block first (common case) + // We know the match data, so we can trigger expansion even if element doesn't exist yet + if (match.partIndex !== undefined) { + // Match is in a specific part, try expanding the reasoning block + const didExpand = requestReasoningExpansion(instanceId, match.messageId, match.partIndex) + if (didExpand) { + return true + } + } + + // For other cases (tool calls, folders, etc.), we need the element to exist + // Find the match element + const selector = buildMatchSelector(match) + const matchElement = document.querySelector(selector) + + if (!matchElement) { + // Silently skip - element may not exist yet (e.g., in collapsed section) + // Expansion will be triggered based on metadata + return false + } + + // Check if already visible + if (!isMatchInCollapsedSection(matchElement)) { + return false + } + + // Identify what needs to expand + const { type, identifiers } = identifyCollapsibleParent(matchElement) + + if (!type) { + // Silently skip - couldn't identify collapsible parent + return false + } + + // Expand based on type + switch (type) { + case 'expand-reasoning': + requestReasoningExpansion( + instanceId, + identifiers.messageId, + parseInt(identifiers.partIndex, 10), + ) + return true + + case 'expand-tool-call': + requestToolCallExpansion(instanceId, identifiers.messageId, identifiers.partId) + return true + + case 'expand-diagnostics': + requestDiagnosticsExpansion(instanceId, identifiers.messageId, identifiers.partId) + return true + + case 'expand-folder-node': + requestFolderNodeExpansion(instanceId, identifiers.elementId) + return true + + case 'expand-sidebar-accordion': + requestSidebarAccordionExpansion(instanceId, identifiers.sectionId) + return true + + case 'expand-session-parent': + requestSessionParentExpansion(instanceId, identifiers.sessionId) + return true + + default: + return false + } +} + +/** + * Wait for DOM to update after expansion + */ +export function waitForExpansionCompletion(maxWaitMs = 500): Promise { + return new Promise((resolve) => { + // Wait for a few frames to let reactivity settle + let frames = 0 + const checkComplete = () => { + frames++ + if (frames >= 4) { // ~60-80ms on 60fps + resolve() + } else { + requestAnimationFrame(checkComplete) + } + } + checkComplete() + }) +} diff --git a/packages/ui/src/lib/shortcuts/search.ts b/packages/ui/src/lib/shortcuts/search.ts new file mode 100644 index 00000000..9ef92d7f --- /dev/null +++ b/packages/ui/src/lib/shortcuts/search.ts @@ -0,0 +1,204 @@ +/** + * Search keyboard shortcuts registration + * + * Registers keyboard shortcuts for search functionality: + * - Cmd+F / Ctrl+F: Open search panel + * - Enter: Navigate to next match + * - Shift+Enter: Navigate to previous match + * - Esc: Close search panel + * - ArrowDown: Navigate to next match + * - ArrowUp: Navigate to previous match + * + * @module search/shortcuts + */ + +import { keyboardRegistry } from "../keyboard-registry" +import { openSearch, closeSearch, navigateNext, navigatePrevious, isOpen } from "../../stores/search-store" + +/** + * Check if running on macOS + */ +function isMac(): boolean { + return typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.platform) +} + +/** + * Register all search-related keyboard shortcuts + * + * Call this once during app initialization to register all shortcuts + */ +export function registerSearchShortcuts() { + // Open search: Cmd+F (Mac) or Ctrl+F (other) + keyboardRegistry.register({ + id: "search-open", + key: "f", + modifiers: { + ctrl: !isMac(), + meta: isMac() + }, + handler: handleOpenSearch, + description: "Open search panel", + context: "global" + }) + + // Navigate to next match: Enter (when search is open) + keyboardRegistry.register({ + id: "search-next-enter", + key: "Enter", + modifiers: {}, + handler: handleEnterKey, + description: "Navigate to next match", + context: "global", + condition: isSearchOpen + }) + + // Navigate to previous match: Shift+Enter (when search is open) + keyboardRegistry.register({ + id: "search-previous-enter", + key: "Enter", + modifiers: { shift: true }, + handler: handleShiftEnter, + description: "Navigate to previous match", + context: "global", + condition: isSearchOpen + }) + + // Close search: Esc (when search is open) + keyboardRegistry.register({ + id: "search-close", + key: "Escape", + modifiers: {}, + handler: handleCloseSearch, + description: "Close search panel", + context: "global", + condition: isSearchOpen + }) + + // Navigate to next match: ArrowDown (when search is open) + keyboardRegistry.register({ + id: "search-next-arrow", + key: "ArrowDown", + modifiers: {}, + handler: navigateNext, + description: "Navigate to next match", + context: "global", + condition: isSearchOpen + }) + + // Navigate to previous match: ArrowUp (when search is open) + keyboardRegistry.register({ + id: "search-previous-arrow", + key: "ArrowUp", + modifiers: {}, + handler: navigatePrevious, + description: "Navigate to previous match", + context: "global", + condition: isSearchOpen + }) + + // Alternative shortcuts for navigation (Cmd+G / Cmd+Shift+G) + if (isMac()) { + keyboardRegistry.register({ + id: "search-next-cmd-g", + key: "g", + modifiers: { meta: true }, + handler: navigateNext, + description: "Navigate to next match (alternative)", + context: "global", + condition: isSearchOpen + }) + + keyboardRegistry.register({ + id: "search-previous-cmd-g", + key: "g", + modifiers: { meta: true, shift: true }, + handler: navigatePrevious, + description: "Navigate to previous match (alternative)", + context: "global", + condition: isSearchOpen + }) + } else { + keyboardRegistry.register({ + id: "search-next-ctrl-g", + key: "g", + modifiers: { ctrl: true }, + handler: navigateNext, + description: "Navigate to next match (alternative)", + context: "global", + condition: isSearchOpen + }) + + keyboardRegistry.register({ + id: "search-previous-ctrl-g", + key: "g", + modifiers: { ctrl: true, shift: true }, + handler: navigatePrevious, + description: "Navigate to previous match (alternative)", + context: "global", + condition: isSearchOpen + }) + } +} + +/** + * Condition to check if search panel is open + */ +function isSearchOpen(): boolean { + return isOpen() +} + +/** + * Handle opening search panel + * Scopes to currently active session by default + */ +function handleOpenSearch() { + // Use dynamic import to avoid circular dependency + // At the time of initialization, we'll just open without scope + // The search panel component will set the scope when it mounts + openSearch() +} + +/** + * Handle Enter key (execute search) + * This is handled by the search panel component + * We just navigate to next match if search already executed + */ +function handleEnterKey() { + navigateNext() +} + +/** + * Handle Shift+Enter key + * Navigate to previous match + */ +function handleShiftEnter() { + navigatePrevious() +} + +/** + * Handle closing search panel + */ +function handleCloseSearch() { + closeSearch() +} + +/** + * Unregister all search shortcuts + * Useful for cleanup or unregistering + */ +export function unregisterSearchShortcuts() { + const shortcutsToUnregister = [ + "search-open", + "search-next-enter", + "search-previous-enter", + "search-close", + "search-next-arrow", + "search-previous-arrow", + "search-next-cmd-g", + "search-previous-cmd-g", + "search-next-ctrl-g", + "search-previous-ctrl-g" + ].filter(id => keyboardRegistry.get(id)) + + shortcutsToUnregister.forEach(id => keyboardRegistry.unregister(id)) +} diff --git a/packages/ui/src/stores/search-store.ts b/packages/ui/src/stores/search-store.ts new file mode 100644 index 00000000..4e6c895a --- /dev/null +++ b/packages/ui/src/stores/search-store.ts @@ -0,0 +1,536 @@ +/** + * Search Store - manages search state and functionality for chat sessions + * + * This store handles: + * - Search queries and options + * - Search execution across messages + * - Match tracking and navigation + * - Search panel visibility + * - Session/instance scoping + * + * @module search-store + */ + +import { batch, createSignal } from "solid-js" +import { findMatches } from "../lib/search-algorithm" +import { expandSectionsForMatch, waitForExpansionCompletion } from "../lib/section-expansion" +import type { SearchMatch, SearchOptions, SearchState } from "../types/search" +import type { InstanceMessageStore } from "./message-v2/instance-store" +import type { ClientPart } from "../types/message" + +/** + * Find the closest match to the current viewport position + * Uses message anchors to estimate position without relying on mark elements + */ +function findClosestMatchToViewport(allMatches: SearchMatch[]): number { + if (allMatches.length === 0) return -1 + + // Get the scroll container + const scrollContainer = document.querySelector('.message-stream') + if (!scrollContainer) return 0 + + const viewportCenter = scrollContainer.scrollTop + (scrollContainer.clientHeight / 2) + + let closestIndex = 0 + let closestDistance = Infinity + + // Get all message anchors in order + const anchors = Array.from(document.querySelectorAll('[id^="message-anchor-"]')) + + // Create a map of message ID to anchor position + const anchorPositions = new Map() + anchors.forEach(anchor => { + const messageId = anchor.id.replace('message-anchor-', '') + const rect = anchor.getBoundingClientRect() + const anchorCenter = rect.top + (rect.height / 2) + anchorPositions.set(messageId, anchorCenter) + }) + + // Find the message anchor closest to viewport center + let closestMessageId: string | null = null + let closestMessageDistance = Infinity + + anchorPositions.forEach((position, messageId) => { + const distance = Math.abs(position - viewportCenter) + if (distance < closestMessageDistance) { + closestMessageDistance = distance + closestMessageId = messageId + } + }) + + if (!closestMessageId) return 0 + + // Now find the first match in the closest message + for (let i = 0; i < allMatches.length; i++) { + const match = allMatches[i] + if (match.messageId === closestMessageId) { + closestIndex = i + break + } + } + + return closestIndex +} + +/** + * Build CSS selector for a search match element + */ +function buildMatchSelector(match: SearchMatch): string { + return ( + 'mark[data-search-match="true"]' + + `[data-search-message-id="${CSS.escape(match.messageId)}"]` + + `[data-search-part-index="${match.partIndex}"]` + + `[data-search-start="${match.startIndex}"]` + + `[data-search-end="${match.endIndex}"]` + ) +} + +const MAX_MATCHES = 100 + +function extractTextForSearch(part: ClientPart, currentOptions: SearchOptions): string { + if (part.type === "text") { + return typeof (part as any).text === "string" ? (part as any).text : "" + } + + if (part.type === "tool") { + if (!currentOptions.includeToolOutputs) return "" + const toolState = (part as any).state + const output = toolState?.output + if (typeof output === "string") return output + try { + if (output !== undefined) return JSON.stringify(output) + } catch { + // ignore stringify failures + } + const metadataOutput = toolState?.metadata?.output + if (typeof metadataOutput === "string") return metadataOutput + return "" + } + + if (part.type === "reasoning") { + if (!currentOptions.includeReasoning) return "" + const value = (part as any).text + if (typeof value === "string") return value + if (Array.isArray(value)) { + return value + .map((entry) => { + if (typeof entry === "string") return entry + if (entry && typeof entry === "object") { + const candidate = entry as { text?: unknown; value?: unknown } + if (typeof candidate.text === "string") return candidate.text + if (typeof candidate.value === "string") return candidate.value + } + return "" + }) + .filter(Boolean) + .join("\n") + } + return "" + } + + return "" +} + +// Create search state signals +const [query, setQuery] = createSignal("") +const [isOpen, setIsOpen] = createSignal(false) +const [matches, setMatches] = createSignal([]) +const [currentIndex, setCurrentIndex] = createSignal(-1) +const [options, setOptionsState] = createSignal({ + caseSensitive: false, + wholeWord: false, + includeToolOutputs: false, + includeReasoning: false, +}) +const [instanceId, setInstanceId] = createSignal(null) +const [sessionId, setSessionId] = createSignal(null) + +let searchDebounceTimer: ReturnType | null = null +const DEBOUNCE_MS = 400 + +/** + * Open search panel + * @param scopeInstanceId - Instance ID to search within (null for all) + * @param scopeSessionId - Session ID to search within (null for all) + */ +export function openSearch(scopeInstanceId?: string, scopeSessionId?: string) { + setInstanceId(scopeInstanceId ?? null) + setSessionId(scopeSessionId ?? null) + setIsOpen(true) +} + +/** + * Close search panel and clear results + */ +export function closeSearch() { + if (searchDebounceTimer) { + clearTimeout(searchDebounceTimer) + searchDebounceTimer = null + } + + batch(() => { + setIsOpen(false) + setQuery("") + setMatches([]) + setCurrentIndex(-1) + }) +} + +/** + * Execute search with current query and options + * This searches through all messages in the given message store + * + * @param store - The message instance store to search through + */ +export function executeSearch(store: InstanceMessageStore) { + const currentQuery = query() + const currentInstanceId = instanceId() + const currentSessionId = sessionId() + const currentOptions = options() + + // Early return: empty query + if (!currentQuery || currentQuery.length < 1) { + setMatches([]) + setCurrentIndex(-1) + return + } + + // If message store not provided, we can't search + // (This is expected when component first mounts) + if (!store) { + return + } + + try { + const allMatches: SearchMatch[] = [] + + // Access from store's state directly + const state = store.state + + const sessionRecord = currentSessionId ? state.sessions[currentSessionId] : null + + if (!sessionRecord) { + setMatches([]) + setCurrentIndex(-1) + return + } + + // Get all message IDs in session + const messageIds = sessionRecord.messageIds || [] + + // Search through each message + searchLoop: for (const messageId of messageIds) { + const record = state.messages[messageId] + if (!record) { + continue + } + + const parts = record.parts || {} + const partIds = record.partIds || [] + + // Search through each part using partIds order + for (let partIndex = 0; partIndex < partIds.length; partIndex++) { + const partId = partIds[partIndex] + const normalizedPart = parts[partId] + + if (!normalizedPart || !normalizedPart.data) { + continue + } + + const part = normalizedPart.data + + const text = extractTextForSearch(part, currentOptions) + if (!text) { + continue + } + + // Find matches in this part + try { + const partMatches = findMatches( + text, + messageId, + partIndex, + currentQuery, + currentOptions + ) + for (const match of partMatches) { + allMatches.push(match) + if (allMatches.length >= MAX_MATCHES) { + break searchLoop + } + } + } catch (error) { + // If findMatches throws (e.g., invalid characters), we don't want to crash + // The search algorithm will show an appropriate error + console.error("Search error:", error) + setMatches([]) + setCurrentIndex(-1) + return + } + } + } + + // Update matches and set closest to viewport as current + const closestMatchIndex = findClosestMatchToViewport(allMatches) + batch(() => { + setMatches(allMatches) + setCurrentIndex(closestMatchIndex) + }) + } catch (error) { + console.error("Search execution error:", error) + setMatches([]) + setCurrentIndex(-1) + } +} + +/** + * Navigate to the next match + * Wraps around to the first match if at the end + */ +export function navigateNext() { + const totalMatches = matches().length + if (totalMatches === 0) return + + const nextIndex = (currentIndex() + 1) % totalMatches + setCurrentIndex(nextIndex) + + // Scroll to match after a short delay for reactivity + requestAnimationFrame(() => { + scrollToCurrentMatch() + }) +} + +/** + * Navigate to the previous match + * Wraps around to the last match if at the beginning + */ +export function navigatePrevious() { + const totalMatches = matches().length + if (totalMatches === 0) return + + const prevIndex = currentIndex() <= 0 ? totalMatches - 1 : currentIndex() - 1 + setCurrentIndex(prevIndex) + // Scroll to match after a short delay for reactivity + requestAnimationFrame(() => { + scrollToCurrentMatch() + }) +} + +/** + * Update search options and re-execute search + * @param newOptions - Partial options to update + * @param store - Message store for re-execution + */ +export function updateOptions(newOptions: Partial, store?: InstanceMessageStore) { + setOptionsState((prev) => ({ ...prev, ...newOptions })) + // Re-execute search with new options if store is provided + if (store) { + executeSearch(store) + } +} + +/** + * Set the search query with debounced auto-search + * @param newQuery - New query string + * @param store - Message store for search execution (optional) + */ +export function setQueryInput(newQuery: string, store?: InstanceMessageStore) { + setQuery(newQuery) + + if (searchDebounceTimer) { + clearTimeout(searchDebounceTimer) + searchDebounceTimer = null + } + + if (!newQuery || newQuery.length < 1) { + clearResults() + return + } + + searchDebounceTimer = setTimeout(() => { + if (store) { + executeSearch(store) + } + }, DEBOUNCE_MS) +} + +/** + * Execute search on Enter key (with store ref) + * @param store - Message store to search through + */ +export function executeSearchOnEnter(store: InstanceMessageStore) { + if (searchDebounceTimer) { + clearTimeout(searchDebounceTimer) + searchDebounceTimer = null + } + + const currentQuery = query() + + if (currentQuery && currentQuery.length > 0) { + executeSearch(store) + } else { + clearResults() + } +} + +/** + * Clear search results + */ +export function clearResults() { + batch(() => { + setMatches([]) + setCurrentIndex(-1) + }) +} + +/** + * Scroll to current match element in viewport + * This is called automatically after navigation + */ +async function scrollToCurrentMatch() { + const currentMatch = getCurrentMatch() + if (!currentMatch) return + + // Get current instance ID + const currentInstanceId = instanceId() + if (!currentInstanceId) return + + // Step 1: Try to expand any collapsed sections containing the match + const didExpand = expandSectionsForMatch(currentInstanceId, currentMatch) + + // Step 2: Wait for DOM to update if we expanded something + if (didExpand) { + await waitForExpansionCompletion(500) + } + + // Step 3: Try to scroll to the match element + let scrollToTarget = async () => { + // Prefer scrolling to the specific mark corresponding to the current match. + // This is reliable for plain-text highlighting where we render marks with identity attributes. + const markSelector = + 'mark[data-search-match="true"]' + + `[data-search-message-id="${CSS.escape(currentMatch.messageId)}"]` + + `[data-search-part-index="${currentMatch.partIndex}"]` + + `[data-search-start="${currentMatch.startIndex}"]` + + `[data-search-end="${currentMatch.endIndex}"]` + + const targetMark = document.querySelector(markSelector) + if (targetMark) { + targetMark.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" }) + return true + } + + // Secondary: markdown highlights can't be mapped to start/end indices reliably, + // so we tag them with an occurrence index per (messageId, partIndex). + const partMatches = matches() + .filter((m) => m.messageId === currentMatch.messageId && m.partIndex === currentMatch.partIndex) + .slice() + .sort((a, b) => a.startIndex - b.startIndex) + + const occurrenceIndex = partMatches.findIndex( + (m) => + m.startIndex === currentMatch.startIndex && + m.endIndex === currentMatch.endIndex && + m.messageId === currentMatch.messageId && + m.partIndex === currentMatch.partIndex, + ) + + if (occurrenceIndex >= 0) { + const occSelector = + 'mark[data-search-match="true"]' + + `[data-search-message-id="${CSS.escape(currentMatch.messageId)}"]` + + `[data-search-part-index="${currentMatch.partIndex}"]` + + `[data-search-occurrence="${occurrenceIndex}"]` + + const occMark = document.querySelector(occSelector) + if (occMark) { + occMark.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" }) + return true + } + } + + // Fallback: scroll to message anchor if mark not found + const messageId = currentMatch.messageId + const elementId = `message-anchor-${messageId}` + + const element = document.getElementById(elementId) + if (element) { + element.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" }) + return true + } + + return false + } + + // Try scrolling once, if we expanded and it failed, retry once + const scrolled = await scrollToTarget() + if (!scrolled && didExpand) { + // Wait a bit more and retry + await new Promise(resolve => setTimeout(resolve, 200)) + await scrollToTarget() + } +} + +/** + * Get the current search state + * @returns Complete search state + */ +export function getSearchState(): SearchState { + return { + query: query(), + isOpen: isOpen(), + matches: matches(), + currentIndex: currentIndex(), + options: options(), + instanceId: instanceId(), + sessionId: sessionId(), + } +} + +/** + * Get the currently selected match + * @returns Current match or null + */ +export function getCurrentMatch(): SearchMatch | null { + const currentMatches = matches() + const idx = currentIndex() + return idx >= 0 && idx < currentMatches.length ? { ...currentMatches[idx] } : null +} + +/** + * Get matches for a specific message + * @param messageId - Message ID to filter matches + * @returns Array of matches for the message + */ +export function getMatchesByMessageId(messageId: string): SearchMatch[] { + return matches().filter((m) => m.messageId === messageId) +} + +/** + * Get the total number of matches + * @returns Number of matches + */ +export function getMatchCount(): number { + return matches().length +} + +/** + * Check if there are any matches + * @returns True if matches exist + */ +export function hasMatches(): boolean { + return matches().length > 0 +} + +// Export signals for reactive access +export { + query, + isOpen, + matches, + currentIndex, + options, + instanceId, + sessionId, + setInstanceId, + setSessionId, +} diff --git a/packages/ui/src/styles/components/search-highlight.css b/packages/ui/src/styles/components/search-highlight.css new file mode 100644 index 00000000..acea519b --- /dev/null +++ b/packages/ui/src/styles/components/search-highlight.css @@ -0,0 +1,63 @@ +/* Search Highlight Styles */ + +/* Default search match highlight */ +.search-match { + display: inline; + background-color: rgba(255, 215, 0, 0.4); + color: inherit; + border-radius: 2px; + padding: 0 1px; +} + +/* Current/active match highlight */ +.search-match--current { + background-color: rgba(255, 165, 0, 0.6); + outline: 1px solid rgba(255, 140, 0, 0.8); + outline-offset: 1px; + box-shadow: 0 0 4px rgba(255, 165, 0, 0.3); +} + +/* Search match within markdown content */ +.markdown-content .search-match { + /* Ensure highlights don't interfere with markdown styling */ + display: inline; +} + +.markdown-content .search-match--current { + /* Add extra visibility for current match in markdown */ + animation: search-highlight-pulse 2s ease-in-out infinite; +} + +/* Animation for current match */ +@keyframes search-highlight-pulse { + 0%, 100% { + box-shadow: 0 0 4px rgba(255, 165, 0, 0.3); + } + 50% { + box-shadow: 0 0 8px rgba(255, 165, 0, 0.6); + } +} + +/* Dark mode adjustments */ +@media (prefers-color-scheme: dark) { + .search-match { + background-color: rgba(255, 215, 0, 0.3); + } + + .search-match--current { + background-color: rgba(255, 165, 0, 0.5); + outline-color: rgba(255, 140, 0, 0.9); + } +} + +/* Light mode adjustments */ +@media (prefers-color-scheme: light) { + .search-match { + background-color: rgba(255, 215, 0, 0.5); + } + + .search-match--current { + background-color: rgba(255, 165, 0, 0.6); + outline-color: rgba(255, 140, 0, 0.7); + } +} diff --git a/packages/ui/src/styles/components/search-panel.css b/packages/ui/src/styles/components/search-panel.css new file mode 100644 index 00000000..fc877cee --- /dev/null +++ b/packages/ui/src/styles/components/search-panel.css @@ -0,0 +1,211 @@ +/* Search Panel Styles */ + +/* Panel container - floating overlay */ +.search-panel-container { + @apply fixed z-50; + border-radius: var(--border-radius-base); + background-color: var(--surface-base); + border: 1px solid var(--border-base); + box-shadow: var(--scroll-elevation-shadow); + max-width: 90vw; + overflow-wrap: anywhere; + word-break: break-word; + min-width: 0; +} + +/* Main panel */ +.search-panel { + @apply flex items-center gap-2 px-3 py-2; +} + +/* Input group: icon, input, clear button */ +.search-panel-input-group { + @apply flex items-center gap-2 flex-1; +} + +/* Search icon */ +.search-panel-icon { + color: var(--text-muted); + flex-shrink: 0; +} + +/* Search input field */ +.search-panel-input { + @apply flex-1 bg-transparent outline-none min-w-0; + color: var(--text-primary); + font-size: 0.875rem; +} + +.search-panel-input::placeholder { + color: var(--text-muted); +} + +/* Clear button (X icon when query has text) */ +.search-panel-clear-button { + @apply p-1 rounded; + color: var(--text-muted); + background-color: transparent; + border: none; + cursor: pointer; + flex-shrink: 0; + transition: background-color 150ms ease, color 150ms ease; +} + +.search-panel-clear-button:hover { + background-color: var(--surface-hover); + color: var(--text-primary); +} + +.search-panel-clear-button:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; +} + +/* Navigation group: prev button, counter, next button */ +.search-panel-nav-group { + @apply flex items-center gap-1; +} + +/* Navigation buttons */ +.search-panel-nav-button { + @apply p-1.5 rounded; + color: var(--text-muted); + background-color: transparent; + border: none; + cursor: pointer; + flex-shrink: 0; + transition: background-color 150ms ease, color 150ms ease; +} + +.search-panel-nav-button:hover:not(:disabled) { + background-color: var(--surface-hover); + color: var(--text-primary); +} + +.search-panel-nav-button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.search-panel-nav-button:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; +} + +/* Match counter display */ +.search-panel-counter { + @apply px-2 text-xs font-medium select-none; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +/* Actions group: options button, close button */ +.search-panel-actions-group { + @apply flex items-center gap-1; +} + +/* Options button (gear icon) */ +.search-panel-options-button { + @apply p-1.5 rounded; + color: var(--text-muted); + background-color: transparent; + border: none; + cursor: pointer; + flex-shrink: 0; + transition: background-color 150ms ease, color 150ms ease; +} + +.search-panel-options-button:hover { + background-color: var(--surface-hover); + color: var(--text-primary); +} + +.search-panel-options-button:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; +} + +/* Close button */ +.search-panel-close-button { + @apply p-1.5 rounded; + color: var(--text-muted); + background-color: transparent; + border: none; + cursor: pointer; + flex-shrink: 0; + transition: background-color 150ms ease, color 150ms ease; +} + +.search-panel-close-button:hover { + background-color: var(--surface-hover); + color: var(--text-primary); +} + +.search-panel-close-button:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; +} + +/* Options dropdown panel */ +.search-options-panel { + @apply px-3 py-2 border-t; + border-color: var(--border-base); + background-color: var(--surface-base); +} + +/* Option item label */ +.search-option-item { + @apply flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer; + transition: background-color 150ms ease; +} + +.search-option-item:hover { + background-color: var(--surface-hover); +} + +/* Option checkbox */ +.search-option-checkbox { + @apply w-4 h-4 rounded; + accent-color: var(--accent-primary); + cursor: pointer; +} + +.search-option-checkbox:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; +} + +/* Option label text */ +.search-option-label { + @apply text-sm cursor-pointer select-none; + color: var(--text-primary); +} + +/* Error message */ +.search-panel-error { + @apply px-3 py-2 border-t mt-0 text-xs; + border-color: var(--border-base); + background-color: var(--surface-error-bg); + color: var(--surface-error-fg); +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .search-panel { + @apply px-2 py-1.5 gap-1.5; + } + + .search-panel-input { + @apply text-sm; + } + + .search-panel-counter { + @apply text-[10px] px-1; + } + + .search-panel-nav-button, + .search-panel-options-button, + .search-panel-close-button { + @apply p-1; + } +} diff --git a/packages/ui/src/styles/controls.css b/packages/ui/src/styles/controls.css index 1af0c8d5..70651dd5 100644 --- a/packages/ui/src/styles/controls.css +++ b/packages/ui/src/styles/controls.css @@ -8,4 +8,6 @@ @import "./components/remote-access.css"; @import "./components/folder-tree-browser.css"; @import "./components/permission-notification.css"; -@import "./components/askquestion-wizard.css"; \ No newline at end of file +@import "./components/askquestion-wizard.css"; +@import "./components/search-panel.css"; +@import "./components/search-highlight.css"; \ No newline at end of file diff --git a/packages/ui/src/types/search.ts b/packages/ui/src/types/search.ts new file mode 100644 index 00000000..45e92d2a --- /dev/null +++ b/packages/ui/src/types/search.ts @@ -0,0 +1,65 @@ +/** + * Represents a single search match result in a message + */ +export interface SearchMatch { + /** Unique identifier of the message containing the match */ + messageId: string + + /** Index of the part within the message (0-based) */ + partIndex: number + + /** Starting character index of the match in the text */ + startIndex: number + + /** Ending character index of the match in the text */ + endIndex: number + + /** The actual matched text content */ + text: string + + /** Whether this match is currently highlighted/selected */ + isCurrent: boolean +} + +/** + * Search configuration options + */ +export interface SearchOptions { + /** Whether the search should be case-sensitive */ + caseSensitive: boolean + + /** Whether the search should match whole words only */ + wholeWord: boolean + + /** Whether to include tool output content in search */ + includeToolOutputs: boolean + + /** Whether to include reasoning content in search */ + includeReasoning: boolean +} + +/** + * Complete search state for the application + */ +export interface SearchState { + /** Current search query string */ + query: string + + /** Whether the search panel is open visible */ + isOpen: boolean + + /** Array of all search matches found */ + matches: SearchMatch[] + + /** Index of the currently selected match (-1 if none selected) */ + currentIndex: number + + /** Active search configuration options */ + options: SearchOptions + + /** Instance ID to constrain search scope (null for all instances) */ + instanceId: string | null + + /** Session ID to constrain search scope (null for all sessions) */ + sessionId: string | null +} diff --git a/tasks/todo/060-chat-search-functionality.md b/tasks/todo/060-chat-search-functionality.md new file mode 100644 index 00000000..6c68e1c3 --- /dev/null +++ b/tasks/todo/060-chat-search-functionality.md @@ -0,0 +1,613 @@ +# Task 060 - Chat Session Search Functionality + +## Overview +Add a search function to enable users to find keywords within the current chat session. This feature provides quick navigation through conversation history with keyboard shortcuts and intuitive UI. + +## User Stories +- As a user, I want to find specific keywords in my conversation to locate relevant information quickly +- As a user, I want to use keyboard shortcuts (Cmd+F/Ctrl+F) to initiate search, as I'm accustomed to from other applications +- As a user, I want to navigate through search results with arrow keys or dedicated navigation buttons +- As a user, I want to see search matches highlighted within the message content +- As a user, I want to know how many matches exist and which one I'm currently viewing + +## Platform-Specific Shortcuts + +| Platform | Shortcut | Purpose | +|----------|----------|---------| +| macOS | ⌘ Cmd + F | Open search panel | +| Windows | Ctrl + F | Open search panel | +| Linux | Ctrl + F | Open search panel | + +## Visual Design & UI Positioning + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Instance Tab β”‚ Session Tab β”‚ [Folder] β”‚ Command Palette β”‚ Status β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ πŸ” [_________________] β—€ 3/15 > βš™οΈ β”‚ β”‚ +β”‚ β”‚ Search input Prev/Next Options β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ You β”‚ β”‚ +β”‚ β”‚ How do I implement search in β”‚ β”‚ +β”‚ β”‚ my application? β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Assistant β”‚ β”‚ +β”‚ β”‚ To implement search: β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ 1. Add a search input field β”‚ β”‚ +β”‚ β”‚ 2. Handle keyboard shortcuts β”‚ β”‚ +β”‚ β”‚ 3. Highlight matches in real-time β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ You β”‚ β”‚ +β”‚ β”‚ What about performance for large β”‚ β”‚ +β”‚ β”‚ search queries? β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Search Panel Components + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ πŸ” [Search input field................] β—€ 3/15 > βš™οΈ β”‚ +β”‚ ↑ ↑ ↑ ↑ β”‚ +β”‚ β”‚ β”‚ β”‚ └─ Options dropdown β”‚ +β”‚ β”‚ β”‚ └───── Next match button β”‚ +β”‚ β”‚ └────────── Previous match β”‚ +β”‚ └── Search icon + input Match counter β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Features + +### Core Functionality +1. **Search Input** + - Appears when Cmd+F/Ctrl+F is pressed + - Text input field for search query + - Auto-focus on panel open + - Execute search on Enter key press (not as user types) + - Case-insensitive by default + - Optional: Case-sensitive toggle in options + +2. **Match Navigation** + - Keyboard navigation: + - Enter: Go to next match + - Shift+Enter: Go to previous match + - Esc: Close search panel + - Visual navigation buttons: Previous | Next + - Match counter: Shows "current/total" (e.g., "3/15") + - Auto-scroll to highlighted match + - Wrap around search (cycle through results) + +3. **Match Highlighting** + - Highlight all matches in current session + - Current match gets distinct highlight style + - Other matches get subtle highlight + - Works with markdown rendering + - Preserves original text casing + +4. **Search Scope** + - Searches within current session only + - Searches user and assistant messages + - Searches text parts of messages + - Ignores tool calls, file attachments, reasoning blocks (configurable) + +5. **Search Options** (expandable dropdown) + - Case sensitive toggle: Default OFF + - Match whole word toggle: Default OFF + - Search in tool outputs toggle: Default OFF + - Search in reasoning blocks toggle: Default OFF + +### Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| Cmd+F / Ctrl+F | Open/focus search panel | +| Enter | Navigate to next match | +| Shift+Enter | Navigate to previous match | +| ↑ / ↓ | Navigate to previous/next match | +| Esc | Close search panel | +| Cmd+G / Ctrl+G | Next match (alternative) | +| Cmd+Shift+G / Ctrl+Shift+G | Previous match (alternative) | + +**Note:** Search panel ONLY closes when user presses Esc or clicks the X close button. Clicking outside does NOT close the panel. + +### User Experience Flows + +#### Flow 1: Quick Search +1. User presses Cmd+F/Ctrl+F +2. Search panel appears, input focused +3. User types "search query" +4. All matches highlighted immediately +5. Current match shown with distinct highlight +6. Counter shows "3/15" (3rd of 15 matches) +7. Messages auto-scroll to show current match + +#### Flow 2: Navigate Results +1. Search panel is open with results +2. User presses Enter or clicks "Next" button +3. View scrolls to next match +4. Counter updates: "4/15" +5. Repeat until last match, then wraps to first + +#### Flow 3: Close Search +1. User presses Esc OR clicks X close button +2. Search panel closes +3. All highlights removed +4. Focus returns to previous element (prompt input or message list) + +**Important:** Clicking outside search panel does NOT close it. Only Esc or X button closes panel. + +## Technical Implementation + +### Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Search Feature β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Search Panel │◄───│ Search Store │◄───│ Keyboard β”‚ β”‚ +β”‚ β”‚ Component β”‚ β”‚ & State β”‚ β”‚ Registry β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β–Ό β–š β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Search │────────────────────▢│ Session Messages β”‚ β”‚ +β”‚ β”‚ Highlighting β”‚ β”‚ (from store) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### File Structure + +``` +packages/ui/src/ +β”œβ”€β”€ stores/ +β”‚ └── search-store.ts # NEW: Search state management +β”œβ”€β”€ components/ +β”‚ β”œβ”€β”€ search-panel.tsx # NEW: Search UI component +β”‚ β”œβ”€β”€ search-highlight-overlay.tsx # NEW: Highlight renderer +β”‚ └── session/ +β”‚ └── session-view.tsx # MODIFIED: Integrate search +β”œβ”€β”€ lib/ +β”‚ └── shortcuts/ +β”‚ └── search.ts # NEW: Keyboard shortcut registration +β”œβ”€β”€ types/ +β”‚ └── search.ts # NEW: Search type definitions +└── styles/ + └── components/ + └── search-panel.css # NEW: Search panel styles +``` + +### Core Components + +#### 1. Search Store (`search-store.ts`) +```typescript +interface SearchState { + query: string + isOpen: boolean + matches: SearchMatch[] + currentIndex: number + options: SearchOptions + instanceId: string | null + sessionId: string | null +} + +interface SearchMatch { + messageId: string + partIndex: number + startIndex: number + endIndex: number + text: string + isCurrent: boolean +} + +interface SearchOptions { + caseSensitive: boolean + wholeWord: boolean + includeToolOutputs: boolean + includeReasoning: boolean +} +``` + +**Key Functions:** +- `searchMessages(query, instanceId, sessionId)` - Perform search +- `navigateNext()` - Move to next match +- `navigatePrevious()` - Move to previous match +- `openSearch()` - Open search panel +- `closeSearch()` - Close search panel +- `updateOptions(options)` - Update search options + +#### 2. Search Panel Component (`search-panel.tsx`) +- Floating overlay at top of message area +- Input field with search query +- Navigation buttons (Previous | Next) +- Match counter display +- Options dropdown (gear icon) +- Keyboard interaction handling + +#### 3. Search Highlighter (`search-highlight-overlay.tsx`) +- Wraps message content +- Injects `` tags for matches +- Distinguishes current vs. other matches +- Works with existing markdown renderer +- Avoids breaking HTML structure + +#### 4. Keyboard Shortcuts (`search.ts`) +```typescript +{ + id: "search-open", + key: "f", + modifiers: { + ctrl: !isMac(), + meta: isMac() + }, + handler: () => openSearch(), + context: "global" +} +``` + +### Search Algorithm + +```typescript +function findMatches( + text: string, + query: string, + options: SearchOptions +): SearchMatch[] { + if (!query || query.length < 1) return [] + + let searchText = text + let searchQuery = query + + if (!options.caseSensitive) { + searchText = text.toLowerCase() + searchQuery = query.toLowerCase() + } + + if (options.wholeWord) { + const regex = new RegExp(`\\b${escapeRegex(searchQuery)}\\b`, 'g') + const matches = [...searchText.matchAll(regex)] + return matches.map(m => ({ + startIndex: m.index!, + endIndex: m.index! + searchQuery.length + })) + } else { + const matches = [] + let index = 0 + while (true) { + index = searchText.indexOf(searchQuery, index) + if (index === -1) break + matches.push({ startIndex: index, endIndex: index + searchQuery.length }) + index += 1 + } + return matches + } +} +``` + +### Message Component Integration + +Modify `message-item.tsx` and `message-part.tsx` to accept search matches: + +```typescript +interface MessageItemProps { + // ... existing props + searchMatches?: MessageMatchMap // NEW +} + +interface MessageMatchMap { + [messageId: string]: { + currentMatchIndex: number | null + matches: Array<{ partIndex: number; startIndex: number; endIndex: number }> + } +} +``` + +### Styling Tokens + +Add to `tokens.css`: +```css +/* Search highlights */ +--search-match-bg: rgba(255, 215, 0, 0.4); +--search-match-current-bg: rgba(255, 165, 0, 0.6); +--search-match-text: var(--text-primary); +--search-match-border: rgba(255, 165, 0, 0.5); + +/* Search panel */ +--search-panel-bg: var(--surface-base); +--search-panel-border: var(--border-base); +--search-panel-shadow: var(--scroll-elevation-shadow); +``` + +### Performance Considerations + +1. **No Debounce - Wait for User Action** + - Do NOT execute search while typing + - Only execute search when user presses Enter + - Prevent unnecessary re-renders and performance overhead + +2. **Message Caching** + - Cache rendered messages + - Only re-render when matches change + - Use SolidJS reactivity efficiently + +3. **Lazy Highlighting** + - Only highlight matches currently visible in viewport + - Highlight remaining matches when scrolling + - Use Intersection Observer for visibility detection + +4. **Search Result Limiting** + - Limit to first 100 matches to prevent UI freeze + - Show "100+ matches" message if exceeded + - Provide option to show all if user requested + +### Accessibility + +1. **Keyboard Navigation** + - All functionality accessible via keyboard + - Clear focus indicators + - ARIA labels for buttons + +2. **Screen Reader Support** + - Live region for match count updates + - ARIA-live announcements for navigation + - Descriptive labels for search input + +3. **Visual Accessibility** + - High contrast for highlights + - Respect reduced motion preferences + - Keyboard shortcuts in UI tooltips + +### Edge Cases + +1. **Empty Query** + - Clear all highlights + - Show "No matches" message or keep panel empty + +2. **No Matches Found** + - Display "No matches found" message + - Disable navigation buttons + - Show counter as "0/0" + +3. **Query Cleared** + - Remove all highlights + - Reset current index + - Keep panel open for new search + +4. **Session Change** + - Clear search results + - Reset to closed state + - Remove highlights from old session + +5. **Message Deleted/Updated** + - Re-run search with current query + - Update match positions + - Handle disappearing matches gracefully + +6. **Very Long Messages** + - Performance optimization (lazy highlighting) + - Virtual scrolling for large message lists + - Limit display to 100 matches with option to show all + +## Testing + +### Unit Tests +- Search algorithm correctness (case-sensitive, whole-word, etc.) +- Match position calculation +- Index boundary handling +- State management in search store + +### Integration Tests +- Keyboard shortcut registration and execution +- Search panel open/close behavior +- Navigation between matches +- Highlighting in message components +- Session switching clears search + +### Manual Testing Checklist +- [ ] Cmd+F/Ctrl+F opens search panel +- [ ] Input is auto-focused on open +- [ ] Real-time search works as typing +- [ ] Matches are highlighted correctly +- [ ] Current match has distinct style +- [ ] Match counter displays correctly +- [ ] Navigation buttons work +- [ ] Keyboard navigation (Enter, Shift+Enter, arrows) +- [ ] Esc closes search panel +- [ ] Highlights are removed on close +- [ ] Options toggle works (case-sensitive, whole-word) +- [ ] Search works with markdown content +- [ ] Auto-scroll to match works +- [ ] Session switch clears search +- [ ] No search in tool outputs (default) +- [ ] Toggle search in tool outputs works +- [ ] Keyboard shortcuts displayed in command palette + +## Dependencies + +### Code Dependencies +- Message store (`message-v2/instance-store.ts`) +- Session store +- Keyboard registry +- Message components (`message-item.tsx`, `message-part.tsx`) +- Session view component + +### External Dependencies +- None (uses existing SolidJS patterns) + +## Success Criteria + +1. βœ… Search panel opens with Cmd+F/Ctrl+F +2. βœ… Real-time search as user types +3. βœ… Matches highlighted in messages +4. βœ… Current match distinguished from others +5. βœ… Navigation between previous/next matches +6. βœ… Keyboard shortcuts work (Enter, Shift+Enter, Esc) +7. βœ… Match counter shows current/total +8. βœ… Search options (case-sensitive, whole-word) work +9. βœ… Scroll to match on navigation +10. βœ… Session switch clears search +11. βœ… Works in light and dark mode +12. βœ… Accessible via keyboard and screen reader +13. βœ… No performance issues with 100+ messages +14. βœ… Highlights don't break markdown rendering + +## Future Enhancements + +### Phase 2 Features (Post-MVP) +1. **Advanced Search Options** + - Regular expression support + - Wildcard search + - Date range filter + - Message type filter (user/assistant only) + +2. **Search Across Sessions** + - "Search in all sessions" toggle + - Switch to session when match selected + - Global search modal + +3. **Search History** + - Remember recent searches + - Quick access to previous queries + - Saved search filters + +4. **Export Results** + - Copy matches to clipboard + - Export to text/CSV + - Print search results + +5. **Fuzzy Search** + - Approximate matching + - Typo tolerance + - Relevance scoring + +6. **Voice Search** + - Dictate search query + - Voice commands for navigation + +### Phase 3 Features +1. **AI-Powered Search** + - Semantic search (not keyword-only) + - Concept matching + - Related suggestions + +2. **Search Analytics** + - Most searched topics + - Search patterns + - User behavior insights + +## Estimated Implementation Time + +**Total: 16-20 hours** + +- Core search logic: 3-4 hours +- Search panel component: 4-5 hours +- Message highlighting integration: 3-4 hours +- Keyboard shortcuts: 1 hour +- Styling & theming: 2 hours +- Testing & refinement: 3 hours + +### Breakdown by Phase +- Phase 1 (MVP): 16-20 hours +- Phase 2 enhancements: 8-12 hours +- Phase 3 features: 12-16 hours + +## Implementation Priority + +### P0 (Must Have for MVP) +- Search input panel +- Basic text search (case-insensitive) +- Match highlighting +- Navigation (Previous/Next) +- Keyboard shortcuts (Cmd/F, Enter, Esc) +- Match counter + +### P1 (Important for Good UX) +- Search options (case-sensitive, whole-word) +- Auto-scroll to match +- Current match distinction +- Session switch handling +- Performance optimizations + +### P2 (Nice to Have) +- Search history +- Regex support +- Search in tool outputs +- Advanced options + +## Risks & Mitigation + +### Risk 1: Performance with Large Session +- **Issue**: 1000+ messages could cause lag +- **Mitigation**: Debounce search, lazy highlighting, limit matches + +### Risk 2: Breaking Markdown Rendering +- **Issue**: Highlight injection could break HTML +- **Mitigation**: Use text-only highlighting, test with various markdown + +### Risk 3: Platform Shortcut Conflicts +- **Issue**: Cmd+F/Ctrl+F conflicts with browser or OS +- **Mitigation**: Use `preventDefault()` carefully, respect system shortcuts + +### Risk 4: Accessibility Issues +- **Issue**: Highlights not visible to screen readers +- **Mitigation**: ARIA-live announcements, keyboard navigation, proper labels + +## Documentation Needs + +1. **User Documentation** + - Search feature guide + - Keyboard shortcuts reference + - Troubleshooting tips + +2. **Developer Documentation** + - API reference for search store + - Component integration guide + - Performance considerations + +3. **Release Notes** + - Feature announcement + - Keyboard shortcut summary + - Usage examples + +## Open Questions - RESOLVED + +1. Should search persist across session reload? + - **Decision**: NO - Clear search on session reload + +2. Should we support saving/bookmarking specific search results? + - **Decision**: NO - Not in scope for MVP + +3. Should we add search to command palette as an action? + - **Decision**: NO - Use Ctrl+F/Cmd+F shortcut only + +4. Should search be available in logs tab as well? + - **Decision**: NO - Only for chat sessions + +5. Should we show search matches in session list preview? + - **Decision**: CONDITIONAL - Only if easy to implement without adding complexity + - If implementation is complex, skip to avoid breaking existing logic + +--- + +**Status**: PLANNING +**Priority**: HIGH +**Complexity**: MEDIUM +**Est. Hours**: 16-20 +**Assigned**: TBD +**Due**: TBD