diff --git a/apps/desktop/src/components/AgentView.tsx b/apps/desktop/src/components/AgentView.tsx index b5d9998..1bc18ee 100644 --- a/apps/desktop/src/components/AgentView.tsx +++ b/apps/desktop/src/components/AgentView.tsx @@ -35,7 +35,7 @@ import { SessionRetry } from "@shob-ai/ui/session-retry" import { showToast } from "@shob-ai/ui/toast" import { useSDK } from "@/context/sdk" import { formatServerError } from "@/utils/server-errors" -import { Check, ChevronDown, Copy, MoreHorizontal, Pencil, Pin, RefreshCw, TriangleAlert, X } from "lucide-solid" +import { Check, ChevronDown, Copy, MoreHorizontal, Pencil, Pin, RefreshCw, Search, TriangleAlert, X } from "lucide-solid" import { DropdownMenu, DropdownMenuContent, @@ -57,6 +57,7 @@ import { type AgentTimelineRow, } from "@/components/agent-timeline-rows" import { SessionContextUsage } from "@/components/session-context-usage" +import { ChatSearch } from "@/components/ChatSearch" interface AgentViewProps { sessionId: string @@ -736,6 +737,7 @@ function AgentViewInner(props: AgentViewProps) { const syncShobSessions = useStore((s) => s.syncShobSessions) const language = useLanguage() const [showJump, setShowJump] = createSignal(false) + const [chatSearchOpen, setChatSearchOpen] = createSignal(false) const [sessionMenuOpen, setSessionMenuOpen] = createSignal(false) const [renameOpen, setRenameOpen] = createSignal(false) const [renameValue, setRenameValue] = createSignal("") @@ -830,6 +832,16 @@ function AgentViewInner(props: AgentViewProps) { setTitlebarLeftEl(document.getElementById("shob-titlebar-left")) setTitlebarRightEl(document.getElementById("shob-titlebar-right")) }) + onMount(() => { + const handleSearchShortcut = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "f") { + e.preventDefault() + setChatSearchOpen(true) + } + } + window.addEventListener("keydown", handleSearchShortcut) + onCleanup(() => window.removeEventListener("keydown", handleSearchShortcut)) + }) onMount(() => { if (windowChrome.isMac()) return const media = window.matchMedia(LAPTOP_TITLEBAR_CONTROLS_QUERY) @@ -1668,6 +1680,13 @@ function AgentViewInner(props: AgentViewProps) { Copy as Markdown + runSessionMenuAction(e, () => setChatSearchOpen(true))} + > + + Search in chat + @@ -1815,6 +1834,14 @@ function AgentViewInner(props: AgentViewProps) { + + scrollRef} + onClose={() => setChatSearchOpen(false)} + /> + {(el) => ( diff --git a/apps/desktop/src/components/ChatSearch.tsx b/apps/desktop/src/components/ChatSearch.tsx new file mode 100644 index 0000000..faf3ecd --- /dev/null +++ b/apps/desktop/src/components/ChatSearch.tsx @@ -0,0 +1,236 @@ +import { createEffect, createMemo, createSignal, on, onCleanup, onMount, Show } from "solid-js" +import { ArrowDown, ArrowUp, X } from "lucide-solid" +import type { Message as ChatMessage, Part } from "@shob-ai/sdk/v2/client" + +interface ChatSearchProps { + messages: () => ChatMessage[] + getParts: (messageId: string) => Part[] + scrollContainer: () => HTMLDivElement | undefined + onClose: () => void +} + +const HIGHLIGHT_CLASS = "chat-search-word-highlight" +const ACTIVE_HIGHLIGHT_CLASS = "chat-search-word-active" +const MARK_ATTR = "data-search-mark" + +function clearHighlights(container: HTMLElement | undefined) { + if (!container) return + const marks = container.querySelectorAll(`mark[${MARK_ATTR}]`) + marks.forEach((mark) => { + const parent = mark.parentNode + if (!parent) return + parent.replaceChild(document.createTextNode(mark.textContent || ""), mark) + parent.normalize() + }) +} + +function highlightTextInContainer(container: HTMLElement | undefined, query: string): HTMLElement[] { + if (!container || !query) return [] + const lowerQuery = query.toLowerCase() + const marks: HTMLElement[] = [] + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + if (node.parentElement?.closest(`mark[${MARK_ATTR}], input, textarea, [contenteditable]`)) { + return NodeFilter.FILTER_REJECT + } + if (node.textContent && node.textContent.toLowerCase().includes(lowerQuery)) { + return NodeFilter.FILTER_ACCEPT + } + return NodeFilter.FILTER_REJECT + }, + }) + + const textNodes: Text[] = [] + let current = walker.nextNode() + while (current) { + textNodes.push(current as Text) + current = walker.nextNode() + } + + for (const textNode of textNodes) { + const text = textNode.textContent || "" + const lowerText = text.toLowerCase() + const parent = textNode.parentNode + if (!parent) continue + + const fragment = document.createDocumentFragment() + let lastIndex = 0 + let pos = lowerText.indexOf(lowerQuery, 0) + + while (pos !== -1) { + if (pos > lastIndex) { + fragment.appendChild(document.createTextNode(text.slice(lastIndex, pos))) + } + const mark = document.createElement("mark") + mark.setAttribute(MARK_ATTR, "") + mark.className = HIGHLIGHT_CLASS + mark.textContent = text.slice(pos, pos + query.length) + fragment.appendChild(mark) + marks.push(mark) + lastIndex = pos + query.length + pos = lowerText.indexOf(lowerQuery, lastIndex) + } + + if (lastIndex < text.length) { + fragment.appendChild(document.createTextNode(text.slice(lastIndex))) + } + + parent.replaceChild(fragment, textNode) + } + + return marks +} + +function scrollContainerToElement(scrollEl: HTMLElement | undefined, target: HTMLElement) { + if (!scrollEl) return + const containerRect = scrollEl.getBoundingClientRect() + const targetRect = target.getBoundingClientRect() + const targetMiddle = targetRect.top + targetRect.height / 2 + const containerMiddle = containerRect.top + containerRect.height / 2 + const offset = targetMiddle - containerMiddle + scrollEl.scrollBy({ top: offset, behavior: "smooth" }) +} + +export function ChatSearch(props: ChatSearchProps) { + const [query, setQuery] = createSignal("") + const [currentIndex, setCurrentIndex] = createSignal(0) + const [markElements, setMarkElements] = createSignal([]) + let inputRef: HTMLInputElement | undefined + + const matchCount = createMemo(() => markElements().length) + + const applyHighlights = () => { + const container = props.scrollContainer() + clearHighlights(container) + const q = query().trim() + if (!q || !container) { + setMarkElements([]) + return + } + const marks = highlightTextInContainer(container, q) + setMarkElements(marks) + } + + const setActiveHighlight = (index: number) => { + const marks = markElements() + marks.forEach((m) => m.classList.remove(ACTIVE_HIGHLIGHT_CLASS)) + if (marks[index]) { + marks[index].classList.add(ACTIVE_HIGHLIGHT_CLASS) + } + } + + const scrollToMatch = (index: number) => { + const marks = markElements() + const container = props.scrollContainer() + if (marks[index] && container) { + setActiveHighlight(index) + scrollContainerToElement(container, marks[index]) + } + } + + const goNext = () => { + const count = matchCount() + if (count === 0) return + const next = (currentIndex() + 1) % count + setCurrentIndex(next) + scrollToMatch(next) + } + + const goPrev = () => { + const count = matchCount() + if (count === 0) return + const next = (currentIndex() - 1 + count) % count + setCurrentIndex(next) + scrollToMatch(next) + } + + createEffect(on(query, () => { + setCurrentIndex(0) + applyHighlights() + }, { defer: true })) + + createEffect(() => { + if (query().trim() && markElements().length > 0) { + scrollToMatch(0) + } + }) + + onMount(() => { + setTimeout(() => inputRef?.focus(), 30) + }) + + onCleanup(() => { + clearHighlights(props.scrollContainer()) + }) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault() + e.stopPropagation() + props.onClose() + } else if (e.key === "Enter" && e.shiftKey) { + e.preventDefault() + goPrev() + } else if (e.key === "Enter") { + e.preventDefault() + goNext() + } + } + + return ( + + ) +} diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index c26a0f4..cb42a91 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -4329,4 +4329,22 @@ html[data-color-scheme="dark"] .agent-terminal-composer-region { transition: none !important; } +/* ═══════════════════════════════════════════════════ + CHAT SEARCH HIGHLIGHT + ═══════════════════════════════════════════════════ */ +mark.chat-search-word-highlight { + background-color: color-mix(in srgb, var(--ring) 25%, transparent); + color: inherit; + border-radius: 2px; + padding: 1px 0; +} + +mark.chat-search-word-active { + background-color: color-mix(in srgb, var(--ring) 55%, transparent); + color: var(--text-strong); + border-radius: 2px; + padding: 1px 0; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 30%, transparent); +} +