From 099d1d8bfb0bc1006cc252530a31fd983177fe09 Mon Sep 17 00:00:00 2001 From: khushal Patel Date: Tue, 23 Jun 2026 16:09:47 +0530 Subject: [PATCH 1/3] feat(desktop): add search in chat --- apps/desktop/src/components/AgentView.tsx | 29 +++- apps/desktop/src/components/ChatSearch.tsx | 157 +++++++++++++++++++++ apps/desktop/src/index.css | 13 ++ 3 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/components/ChatSearch.tsx 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..3a314d0 --- /dev/null +++ b/apps/desktop/src/components/ChatSearch.tsx @@ -0,0 +1,157 @@ +import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" +import { ChevronDown, ChevronUp, X } from "lucide-solid" +import type { Message as ChatMessage, Part } from "@shob-ai/sdk/v2/client" + +interface ChatSearchMatch { + messageId: string + index: number +} + +interface ChatSearchProps { + messages: () => ChatMessage[] + getParts: (messageId: string) => Part[] + scrollContainer: () => HTMLDivElement | undefined + onClose: () => void +} + +export function ChatSearch(props: ChatSearchProps) { + const [query, setQuery] = createSignal("") + const [currentIndex, setCurrentIndex] = createSignal(0) + let inputRef: HTMLInputElement | undefined + + const matches = createMemo(() => { + const q = query().trim().toLowerCase() + if (!q) return [] + const results: ChatSearchMatch[] = [] + for (const message of props.messages()) { + const parts = props.getParts(message.id) + for (const part of parts) { + if (part.type === "text" && (part as any).text) { + const text = ((part as any).text as string).toLowerCase() + let startPos = 0 + while (true) { + const idx = text.indexOf(q, startPos) + if (idx === -1) break + results.push({ messageId: message.id, index: results.length }) + startPos = idx + 1 + } + } + } + } + return results + }) + + const matchCount = createMemo(() => matches().length) + + const scrollToMatch = (index: number) => { + const match = matches()[index] + if (!match) return + const container = props.scrollContainer() + if (!container) return + const el = container.querySelector(`[data-message-id="${match.messageId}"]`) + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }) + el.classList.add("chat-search-highlight") + setTimeout(() => el.classList.remove("chat-search-highlight"), 1500) + } + } + + const goNext = () => { + if (matchCount() === 0) return + const next = (currentIndex() + 1) % matchCount() + setCurrentIndex(next) + scrollToMatch(next) + } + + const goPrev = () => { + if (matchCount() === 0) return + const next = (currentIndex() - 1 + matchCount()) % matchCount() + setCurrentIndex(next) + scrollToMatch(next) + } + + createEffect(() => { + query() + setCurrentIndex(0) + }) + + createEffect(() => { + const q = query().trim() + if (q && matchCount() > 0) { + scrollToMatch(0) + } + }) + + onMount(() => { + setTimeout(() => inputRef?.focus(), 30) + }) + + 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 ( +
e.stopPropagation()} + > + + + + + setQuery(e.currentTarget.value)} + onKeyDown={handleKeyDown} + /> + + + {matchCount() > 0 ? `${currentIndex() + 1}/${matchCount()}` : "0/0"} + + + + + +
+ ) +} diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index c26a0f4..0f68957 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -4329,4 +4329,17 @@ html[data-color-scheme="dark"] .agent-terminal-composer-region { transition: none !important; } +/* ═══════════════════════════════════════════════════ + CHAT SEARCH HIGHLIGHT + ═══════════════════════════════════════════════════ */ +@keyframes chat-search-flash { + 0% { background-color: color-mix(in srgb, var(--ring) 28%, transparent); } + 100% { background-color: transparent; } +} + +.chat-search-highlight { + animation: chat-search-flash 1.5s ease-out forwards; + border-radius: 6px; +} + From d59a6a92ec37ec448617a7b0938e8c352f40f2b1 Mon Sep 17 00:00:00 2001 From: khushal Patel Date: Tue, 23 Jun 2026 22:42:21 +0530 Subject: [PATCH 2/3] fix(desktop): fix search navigation - bar stays fixed, correct match cycling --- apps/desktop/src/components/ChatSearch.tsx | 175 +++++++++++++++------ apps/desktop/src/index.css | 26 ++- 2 files changed, 147 insertions(+), 54 deletions(-) diff --git a/apps/desktop/src/components/ChatSearch.tsx b/apps/desktop/src/components/ChatSearch.tsx index 3a314d0..b15e7c6 100644 --- a/apps/desktop/src/components/ChatSearch.tsx +++ b/apps/desktop/src/components/ChatSearch.tsx @@ -1,12 +1,7 @@ -import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" +import { createEffect, createMemo, createSignal, on, onCleanup, onMount, Show } from "solid-js" import { ChevronDown, ChevronUp, X } from "lucide-solid" import type { Message as ChatMessage, Part } from "@shob-ai/sdk/v2/client" -interface ChatSearchMatch { - messageId: string - index: number -} - interface ChatSearchProps { messages: () => ChatMessage[] getParts: (messageId: string) => Part[] @@ -14,70 +9,148 @@ interface ChatSearchProps { 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 matches = createMemo(() => { - const q = query().trim().toLowerCase() - if (!q) return [] - const results: ChatSearchMatch[] = [] - for (const message of props.messages()) { - const parts = props.getParts(message.id) - for (const part of parts) { - if (part.type === "text" && (part as any).text) { - const text = ((part as any).text as string).toLowerCase() - let startPos = 0 - while (true) { - const idx = text.indexOf(q, startPos) - if (idx === -1) break - results.push({ messageId: message.id, index: results.length }) - startPos = idx + 1 - } - } - } + const matchCount = createMemo(() => markElements().length) + + const applyHighlights = () => { + const container = props.scrollContainer() + clearHighlights(container) + const q = query().trim() + if (!q || !container) { + setMarkElements([]) + return } - return results - }) + const marks = highlightTextInContainer(container, q) + setMarkElements(marks) + } - const matchCount = createMemo(() => matches().length) + 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 match = matches()[index] - if (!match) return + const marks = markElements() const container = props.scrollContainer() - if (!container) return - const el = container.querySelector(`[data-message-id="${match.messageId}"]`) - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "center" }) - el.classList.add("chat-search-highlight") - setTimeout(() => el.classList.remove("chat-search-highlight"), 1500) + if (marks[index] && container) { + setActiveHighlight(index) + scrollContainerToElement(container, marks[index]) } } const goNext = () => { - if (matchCount() === 0) return - const next = (currentIndex() + 1) % matchCount() + const count = matchCount() + if (count === 0) return + const next = (currentIndex() + 1) % count setCurrentIndex(next) scrollToMatch(next) } const goPrev = () => { - if (matchCount() === 0) return - const next = (currentIndex() - 1 + matchCount()) % matchCount() + const count = matchCount() + if (count === 0) return + const next = (currentIndex() - 1 + count) % count setCurrentIndex(next) scrollToMatch(next) } - createEffect(() => { - query() + createEffect(on(query, () => { setCurrentIndex(0) - }) + applyHighlights() + }, { defer: true })) createEffect(() => { - const q = query().trim() - if (q && matchCount() > 0) { + if (query().trim() && markElements().length > 0) { scrollToMatch(0) } }) @@ -86,6 +159,10 @@ export function ChatSearch(props: ChatSearchProps) { setTimeout(() => inputRef?.focus(), 30) }) + onCleanup(() => { + clearHighlights(props.scrollContainer()) + }) + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault() @@ -102,8 +179,10 @@ export function ChatSearch(props: ChatSearchProps) { return (
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} > @@ -126,7 +205,7 @@ export function ChatSearch(props: ChatSearchProps) {