diff --git a/packages/app/e2e/performance/README.md b/packages/app/e2e/performance/README.md index 05e862a2ff7..c3e60298fd5 100644 --- a/packages/app/e2e/performance/README.md +++ b/packages/app/e2e/performance/README.md @@ -43,6 +43,7 @@ The suite contains: - home-session click timing split between content and titlebar-tab paint - single-session tab close timing through stable home restoration - cached session repaint and mutation tracing +- large-session search scan, first-result reveal, and highlight stabilization - streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics - retained renderer heap with a large model catalog across repeated session navigation diff --git a/packages/app/e2e/performance/timeline/session-timeline-search-benchmark.spec.ts b/packages/app/e2e/performance/timeline/session-timeline-search-benchmark.spec.ts new file mode 100644 index 00000000000..a2ffd036ed6 --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-timeline-search-benchmark.spec.ts @@ -0,0 +1,69 @@ +import { benchmark, expect } from "../benchmark" +import { buildInitialStreamEvent, setupTimelineBenchmark, textPartID } from "./session-timeline-benchmark.fixture" +import { + collectTimelineSearchMetrics, + installTimelineSearchProbe, + waitForStableTimelineSearch, +} from "./session-timeline-search-probe" + +benchmark("searches a large virtualized session and reveals the first result", async ({ page, report }) => { + benchmark.setTimeout(180_000) + const historyTurns = Number(process.env.TIMELINE_SEARCH_HISTORY_TURNS ?? 320) + const completionTimeout = Number(process.env.TIMELINE_SEARCH_COMPLETION_TIMEOUT_MS ?? 60_000) + const query = "Historical prompt" + const targetPartID = "msg_0000_0000_a_user:text:0" + const expectedCounter = `1/${historyTurns}` + const fixture = await setupTimelineBenchmark(page, { + historyTurns, + eventBatch: 1, + }) + + fixture.transport.enqueue(buildInitialStreamEvent(1)) + await expect(fixture.text).toContainText("Implementation plan") + await fixture.scrollToBottom() + await fixture.waitForStableGeometry() + // Chromium reserves the physical shortcut for its native find overlay, so request the same controller path directly. + await page.evaluate(() => document.dispatchEvent(new Event("opencode:timeline-search-open"))) + + const search = page.locator('[data-component="timeline-search-bar"]') + const field = search.getByRole("searchbox", { name: "Find..." }) + const count = search.locator('[data-slot="timeline-search-count"]') + const target = page.locator(`[data-timeline-part-id="${targetPartID}"]`) + await expect(field).toBeVisible() + await expect(field).toBeFocused() + await installTimelineSearchProbe(page, { targetPartID }) + + await field.fill(query) + await expect(count).toHaveText(expectedCounter) + await expect(target).toBeVisible({ timeout: completionTimeout }) + await waitForStableTimelineSearch(page, { counter: expectedCounter, targetPartID, timeout: completionTimeout }) + const metrics = await collectTimelineSearchMetrics(page, { counter: expectedCounter, targetPartID }) + + expect(metrics.summary.handlerDurationMs).toBeDefined() + expect(metrics.summary.firstCountObservedMs).toBeDefined() + expect(metrics.summary.firstTargetVisibleMs).toBeDefined() + expect(metrics.summary.firstActiveHighlightObservedMs).toBeDefined() + expect(metrics.summary.stableResultObservedMs).toBeDefined() + expect(metrics.summary.activeHighlightRanges).toBe(1) + report(metrics, { historyTurns, query, expectedMatches: historyTurns }) + + // Check navigation and the V2 assistant content IDs outside the measured interval. + await field.press("Enter") + await expect(count).toHaveText(`2/${historyTurns}`) + await field.press("Shift+Enter") + await expect(count).toHaveText(expectedCounter) + await field.fill("Implementation plan") + await expect(count).toHaveText("1/1") + await expect(fixture.text).toBeInViewport() + await expect + .poll(() => + page.evaluate(() => { + const range = [...(CSS.highlights.get("timeline-search-hit-active") ?? [])][0] + return range?.startContainer.parentElement?.closest("[data-timeline-part-id]")?.dataset + .timelinePartId + }), + ) + .toBe(textPartID) + await field.press("Escape") + await expect(search).toBeHidden() +}) diff --git a/packages/app/e2e/performance/timeline/session-timeline-search-probe.ts b/packages/app/e2e/performance/timeline/session-timeline-search-probe.ts new file mode 100644 index 00000000000..53a2cbf96b1 --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-timeline-search-probe.ts @@ -0,0 +1,176 @@ +import type { Page } from "@playwright/test" + +export type TimelineSearchSample = { + observedAtMs: number + counter: string + targetMounted: boolean + targetVisible: boolean + targetTopPx?: number + activeRanges: number + activePartID?: string + activeVisible: boolean + scrollTopPx: number +} + +type TimelineSearchProbe = { + samples: TimelineSearchSample[] + handlerDurationMs?: number + initialScrollTopPx: number + stop: () => void +} + +export async function installTimelineSearchProbe(page: Page, input: { targetPartID: string }) { + await page.evaluate(({ targetPartID }) => { + const search = document.querySelector('[data-component="timeline-search-bar"]') + const field = search?.querySelector('[data-slot="text-input-v2-input"]') + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-row]"), + ) + if (!search || !field || !root) throw new Error("missing timeline search benchmark nodes") + + const samples: TimelineSearchSample[] = [] + const initialScrollTopPx = root.scrollTop + let startedAt: number | undefined + let handlerDurationMs: number | undefined + let frame: number | undefined + let running = true + + const visibleInRoot = (rect: DOMRect) => { + const viewport = root.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom + } + const sample = () => { + if (!running || startedAt === undefined) return + frame = requestAnimationFrame(() => { + frame = undefined + setTimeout(() => { + if (!running || startedAt === undefined) return + const target = root.querySelector(`[data-timeline-part-id="${targetPartID}"]`) + const targetRect = target?.getBoundingClientRect() + const highlight = CSS.highlights.get("timeline-search-hit-active") + const ranges = highlight ? [...highlight] : [] + const active = ranges.find((range): range is Range => range instanceof Range) + const activeRect = active?.getBoundingClientRect() + const activeElement = + active?.startContainer instanceof Element ? active.startContainer : active?.startContainer.parentElement + + samples.push({ + observedAtMs: performance.now() - startedAt, + counter: + search.querySelector('[data-slot="timeline-search-count"]')?.textContent?.trim() ?? "", + targetMounted: !!target, + targetVisible: !!targetRect && visibleInRoot(targetRect), + targetTopPx: targetRect?.top, + activeRanges: ranges.length, + activePartID: activeElement?.closest("[data-timeline-part-id]")?.dataset.timelinePartId, + activeVisible: !!activeRect && visibleInRoot(activeRect), + scrollTopPx: root.scrollTop, + }) + sample() + }, 0) + }) + } + const onInputCapture = (event: Event) => { + if (event.target !== field || startedAt !== undefined) return + startedAt = performance.now() + sample() + } + const onInput = (event: Event) => { + if (event.target !== field || startedAt === undefined || handlerDurationMs !== undefined) return + handlerDurationMs = performance.now() - startedAt + } + document.addEventListener("input", onInputCapture, { capture: true }) + document.addEventListener("input", onInput) + ;(window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark = { + samples, + initialScrollTopPx, + get handlerDurationMs() { + return handlerDurationMs + }, + stop: () => { + running = false + document.removeEventListener("input", onInputCapture, { capture: true }) + document.removeEventListener("input", onInput) + if (frame !== undefined) cancelAnimationFrame(frame) + }, + } + }, input) +} + +export async function waitForStableTimelineSearch( + page: Page, + input: { counter: string; targetPartID: string; timeout: number }, +) { + await page.waitForFunction( + ({ counter, targetPartID }) => { + const samples = (window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark + ?.samples + if (!samples) return false + return samples.some((_, index) => { + const stable = samples.slice(index, index + 3) + if (stable.length !== 3) return false + return stable.every( + (sample, sampleIndex) => + sample.counter === counter && + sample.targetVisible && + sample.activeRanges === 1 && + sample.activePartID === targetPartID && + sample.activeVisible && + (sampleIndex === 0 || + (Math.abs(sample.scrollTopPx - stable[sampleIndex - 1]!.scrollTopPx) <= 1 && + Math.abs((sample.targetTopPx ?? Infinity) - (stable[sampleIndex - 1]!.targetTopPx ?? -Infinity)) <= 1)), + ) + }) + }, + { counter: input.counter, targetPartID: input.targetPartID }, + { timeout: input.timeout }, + ) +} + +export async function collectTimelineSearchMetrics(page: Page, input: { counter: string; targetPartID: string }) { + const result = await page.evaluate(() => { + const probe = (window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark + if (!probe) throw new Error("missing timeline search benchmark probe") + probe.stop() + return { + samples: probe.samples, + handlerDurationMs: probe.handlerDurationMs, + initialScrollTopPx: probe.initialScrollTopPx, + } + }) + const first = (predicate: (sample: TimelineSearchSample) => boolean) => result.samples.find(predicate)?.observedAtMs + const stable = result.samples.findIndex((_, index) => { + const samples = result.samples.slice(index, index + 3) + if (samples.length !== 3) return false + return samples.every( + (sample, sampleIndex) => + sample.counter === input.counter && + sample.targetVisible && + sample.activeRanges === 1 && + sample.activePartID === input.targetPartID && + sample.activeVisible && + (sampleIndex === 0 || + (Math.abs(sample.scrollTopPx - samples[sampleIndex - 1]!.scrollTopPx) <= 1 && + Math.abs((sample.targetTopPx ?? Infinity) - (samples[sampleIndex - 1]!.targetTopPx ?? -Infinity)) <= 1)), + ) + }) + const final = result.samples.at(-1) + + return { + summary: { + handlerDurationMs: result.handlerDurationMs, + firstCountObservedMs: first((sample) => sample.counter === input.counter), + firstTargetMountedMs: first((sample) => sample.targetMounted), + firstTargetVisibleMs: first((sample) => sample.targetVisible), + firstActiveHighlightObservedMs: first( + (sample) => sample.activeRanges === 1 && sample.activePartID === input.targetPartID && sample.activeVisible, + ), + stableResultObservedMs: stable >= 0 ? result.samples[stable + 2]?.observedAtMs : undefined, + initialScrollTopPx: result.initialScrollTopPx, + finalScrollTopPx: final?.scrollTopPx, + scrollDistancePx: final === undefined ? undefined : Math.abs(result.initialScrollTopPx - final.scrollTopPx), + activeHighlightRanges: final?.activeRanges, + }, + samples: result.samples, + } +} diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 885c3a4236c..d36bbd34397 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -165,6 +165,15 @@ } } + ::highlight(timeline-search-hit) { + background-color: color-mix(in srgb, var(--v2-icon-icon-accent) 28%, transparent); + } + + ::highlight(timeline-search-hit-active) { + background-color: var(--v2-icon-icon-accent); + color: var(--v2-background-bg-deep); + } + [data-component="getting-started"] { container-type: inline-size; container-name: getting-started; diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 1b14f41b04d..00dfa776209 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -769,6 +769,8 @@ export const dict = { "session.header.search.placeholder": "Search {{project}}", "session.header.searchFiles": "Search files", + "session.search.placeholder": "Find...", + "session.search.noResults": "No matches", "session.header.openIn": "Open in", "session.header.open.action": "Open {{app}}", "session.header.open.ariaLabel": "Open in {{app}}", diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 6f4f04c0c5f..a68dcae954a 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -27,6 +27,8 @@ import { createSessionReview } from "./review/model" import { SessionDesktopReview, SessionMobileReview, SessionMobileViewTabs } from "./review/view" import { SessionContextTab } from "./files/session-context-tab" import { createSessionTimelineInteraction } from "./timeline/interaction" +import { createTimelineSearchController } from "./timeline/search-controller" +import { TimelineSearchBar } from "./timeline/search-bar" import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region" import { SessionIdentityHeader } from "./session-identity-header" import { SessionReviewToggle } from "./header/session-header-actions" @@ -47,6 +49,12 @@ export function SessionScreen(props: { session: SessionModel }) { const isDesktop = session.isDesktop const screen = createSessionScreenLayout(session) const timeline = createSessionTimelineInteraction(session) + const timelineSearch = createTimelineSearchController({ + sessionID: session.identity.sessionID, + scrollRef: timeline.scroller, + revealMessage: timeline.actions.revealMessage, + pauseAutoScroll: timeline.view.unpin, + }) const messagesReady = timeline.ready const [store, setStore] = createStore({ deferRender: false, @@ -262,6 +270,7 @@ export function SessionScreen(props: { session: SessionModel }) { anchor={timeline.view.anchor} setRevealMessage={timeline.view.setRevealMessage} setScrollToEnd={timeline.view.setScrollToEnd} + search={} /> )} diff --git a/packages/app/src/session/timeline/interaction.ts b/packages/app/src/session/timeline/interaction.ts index afcf4661743..1c30100e259 100644 --- a/packages/app/src/session/timeline/interaction.ts +++ b/packages/app/src/session/timeline/interaction.ts @@ -24,6 +24,7 @@ export function createSessionTimelineInteraction(session: SessionModel) { pinned: true, }, refs: { + scroller: undefined as HTMLDivElement | undefined, content: undefined as HTMLDivElement | undefined, dock: undefined as HTMLDivElement | undefined, }, @@ -38,7 +39,7 @@ export function createSessionTimelineInteraction(session: SessionModel) { } let scroller: HTMLDivElement | undefined let dockHeight = 0 - let revealMessage = (_id: string) => {} + let revealMessage = (_id: string, _partID?: string) => {} let scrollToEnd = () => {} let scrollMark = 0 let messageMark = 0 @@ -157,6 +158,7 @@ export function createSessionTimelineInteraction(session: SessionModel) { } const setScrollRef = (element: HTMLDivElement | undefined) => { scroller = element + setState("refs", "scroller", element) if (!element) return scheduleScrollState(element) fill() @@ -290,6 +292,7 @@ export function createSessionTimelineInteraction(session: SessionModel) { return { actions: { navigateMessage, + revealMessage: (id: string, partID?: string) => revealMessage(id, partID), resume, setActiveMessage, }, @@ -297,7 +300,7 @@ export function createSessionTimelineInteraction(session: SessionModel) { resource: timeline.resource, ready: timeline.ready, scroll: state.scroll, - scroller: () => scroller, + scroller: () => state.refs.scroller, view: { anchor, markUserScroll, @@ -313,7 +316,7 @@ export function createSessionTimelineInteraction(session: SessionModel) { setDockRef: (element: HTMLDivElement | undefined) => { setState("refs", "dock", element) }, - setRevealMessage: (reveal: (id: string) => void) => { + setRevealMessage: (reveal: (id: string, partID?: string) => void) => { revealMessage = reveal }, setScrollRef, diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index 6e6e9d3cd85..8733bbb4115 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -353,8 +353,9 @@ type MessageTimelineProps = { workspaceMoveEligible: boolean onSummaryOpenChange: (open: boolean) => void anchor: (id: string) => string - setRevealMessage?: (fn: (id: string) => void) => void + setRevealMessage?: (fn: (id: string, partID?: string) => void) => void setScrollToEnd?: (fn: () => void) => void + search?: JSX.Element } export function MessageTimeline(props: MessageTimelineProps) { @@ -789,6 +790,7 @@ function MessageTimelineView( {(id) => (
+ {props.search} {(project) => ( diff --git a/packages/app/src/session/timeline/search-bar.css b/packages/app/src/session/timeline/search-bar.css new file mode 100644 index 00000000000..c76429fd2e0 --- /dev/null +++ b/packages/app/src/session/timeline/search-bar.css @@ -0,0 +1,14 @@ +[data-component="timeline-search-bar"] [data-component="text-input-v2"] { + background: var(--v2-background-bg-base); + box-shadow: inset 0 0 0 1px var(--v2-border-border-base); + outline: none; +} + +[data-component="timeline-search-bar"] [data-component="text-input-v2"]:hover { + background: var(--v2-background-bg-base); +} + +[data-component="timeline-search-bar"] [data-component="text-input-v2"]:focus-within { + box-shadow: inset 0 0 0 1px var(--v2-border-border-focus); + outline: none; +} diff --git a/packages/app/src/session/timeline/search-bar.tsx b/packages/app/src/session/timeline/search-bar.tsx new file mode 100644 index 00000000000..382f2c50a38 --- /dev/null +++ b/packages/app/src/session/timeline/search-bar.tsx @@ -0,0 +1,72 @@ +import { Icon } from "@opencode-ai/ui/icon" +import "@opencode-ai/ui/text-input.css" +import { Show } from "solid-js" +import type { TimelineSearchController } from "./search-controller" +import "./search-bar.css" + +export function TimelineSearchBar(props: { controller: TimelineSearchController }) { + const c = props.controller + + return ( + +
+
+
+ + + + c.query.setValue(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault() + c.query.close() + return + } + if (event.altKey || event.metaKey || event.ctrlKey) return + if (event.key === "Enter" && !event.isComposing) { + event.preventDefault() + c.result.move(event.shiftKey ? -1 : 1) + return + } + if (event.key === "ArrowDown" && !event.isComposing) { + event.preventDefault() + c.result.move(1) + return + } + if (event.key === "ArrowUp" && !event.isComposing) { + event.preventDefault() + c.result.move(-1) + return + } + }} + /> +
+ + + {c.result.count() > 0 ? c.result.activeIndex() + 1 : 0}/{c.result.count()} + + + +
+
+
+ ) +} diff --git a/packages/app/src/session/timeline/search-controller.ts b/packages/app/src/session/timeline/search-controller.ts new file mode 100644 index 00000000000..6a8b633fc83 --- /dev/null +++ b/packages/app/src/session/timeline/search-controller.ts @@ -0,0 +1,275 @@ +import { useCommand } from "@/shell/commands/command" +import { useLanguage } from "@/runtime/i18n/language" +import { useData } from "@/runtime/server/current" +import { Timeline } from "@opencode-ai/session-ui/timeline/projection" +import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" + +export type TimelineSearchMatch = { + messageID: string + role: "user" | "assistant" + revealID: string + partID: string + occurrence: number + text: string +} + +const HIGHLIGHT_HIT = "timeline-search-hit" +const HIGHLIGHT_ACTIVE = "timeline-search-hit-active" +const TEXT_SELECTORS = '[data-slot="text-part-body"], [data-slot="user-message-text"]' + +function supportsHighlights() { + return typeof CSS !== "undefined" && typeof CSS.highlights === "object" && CSS.highlights !== null +} + +function clearHighlights() { + if (!supportsHighlights()) return + CSS.highlights.delete(HIGHLIGHT_HIT) + CSS.highlights.delete(HIGHLIGHT_ACTIVE) +} + +function collectRanges( + root: HTMLElement, + query: string, + activePartID: string | undefined, + activeOccurrence: number | undefined, +) { + const hits: Range[] = [] + const active: Range[] = [] + const lower = query.toLowerCase() + const bodies = root.querySelectorAll(TEXT_SELECTORS) + for (const body of bodies) { + const part = body.closest("[data-timeline-part-id]") + const partID = part?.getAttribute("data-timeline-part-id") + const isActivePart = activePartID !== undefined && partID === activePartID + let occurrenceInPart = 0 + const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT) + let node = walker.nextNode() as Text | null + while (node) { + const value = node.nodeValue ?? "" + const lowerValue = value.toLowerCase() + let from = 0 + let at = lowerValue.indexOf(lower, from) + while (at !== -1) { + const range = document.createRange() + range.setStart(node, at) + range.setEnd(node, at + query.length) + if (isActivePart && activeOccurrence === occurrenceInPart) active.push(range) + else hits.push(range) + occurrenceInPart += 1 + from = at + query.length + at = lowerValue.indexOf(lower, from) + } + node = walker.nextNode() as Text | null + } + } + return { hits, active } +} + +function applyHighlights( + root: HTMLElement, + query: string, + activePartID: string | undefined, + activeOccurrence: number | undefined, +) { + if (!supportsHighlights()) return + const { hits, active } = collectRanges(root, query, activePartID, activeOccurrence) + CSS.highlights.set(HIGHLIGHT_HIT, new Highlight(...hits)) + CSS.highlights.set(HIGHLIGHT_ACTIVE, new Highlight(...active)) +} + +export function createTimelineSearchController(input: { + sessionID: () => string | undefined + scrollRef: () => HTMLDivElement | undefined + revealMessage: (id: string, partID?: string) => void + pauseAutoScroll: () => void +}) { + const command = useCommand() + const language = useLanguage() + const data = useData() + const [state, setState] = createStore({ value: "", active: 0, visible: false }) + const [focusTick, setFocusTick] = createSignal(0) + let inputEl: HTMLInputElement | undefined + + const query = createMemo(() => state.value.trim().toLowerCase()) + + const matches = createMemo(() => { + const value = query() + if (!value) return [] + const sessionID = input.sessionID() + if (!sessionID) return [] + const messages = data.session.message.list(sessionID) + const result: TimelineSearchMatch[] = [] + let revealID = "" + for (const message of messages) { + if (message.type === "user" || message.type === "shell") revealID = message.id + if (message.type !== "user" && message.type !== "assistant") continue + const visibleParts = + message.type === "user" + ? [{ id: `${message.id}:text:0`, content: { type: "text" as const, text: message.text } }] + : Timeline.contentEntries(message) + for (const textPart of visibleParts) { + if (textPart.content.type !== "text") continue + const text = textPart.content.text + if (!text) continue + const lower = text.toLowerCase() + let from = 0 + let occurrence = 0 + let at = lower.indexOf(value, from) + while (at !== -1) { + result.push({ + messageID: message.id, + role: message.type, + revealID, + partID: textPart.id, + occurrence, + text, + }) + occurrence += 1 + from = at + value.length + at = lower.indexOf(value, from) + } + } + } + return result + }) + + const activeIndex = createMemo(() => { + const list = matches() + if (list.length === 0) return 0 + if (state.active >= list.length) return 0 + if (state.active < 0) return 0 + return state.active + }) + + const activePartID = createMemo(() => matches()[activeIndex()]?.partID) + const activeOccurrence = createMemo(() => matches()[activeIndex()]?.occurrence) + + createEffect(() => { + const root = input.scrollRef() + const q = query() + if (!root || !state.visible || !q) { + clearHighlights() + return + } + applyHighlights(root, q, activePartID(), activeOccurrence()) + let frame: number | undefined + const scheduleApply = () => { + if (frame !== undefined) return + frame = requestAnimationFrame(() => { + frame = undefined + if (!state.visible) return + applyHighlights(root, query(), activePartID(), activeOccurrence()) + }) + } + const observer = new MutationObserver(scheduleApply) + observer.observe(root, { childList: true, subtree: true, characterData: true }) + onCleanup(() => { + observer.disconnect() + if (frame !== undefined) cancelAnimationFrame(frame) + clearHighlights() + }) + }) + + createEffect( + on(focusTick, () => { + if (!state.visible) return + requestAnimationFrame(() => { + inputEl?.focus() + inputEl?.select() + }) + }), + ) + + command.register("session.search", () => [ + { + id: "session.search", + title: language.t("session.search.placeholder"), + keybind: "mod+f", + hidden: true, + onSelect: () => open(), + }, + ]) + const onOpenRequest = () => open() + document.addEventListener("opencode:timeline-search-open", onOpenRequest) + onCleanup(() => document.removeEventListener("opencode:timeline-search-open", onOpenRequest)) + + function open() { + setState("visible", true) + setFocusTick((t) => t + 1) + } + + function close() { + setState({ value: "", active: 0, visible: false }) + inputEl?.blur() + } + + function setValue(value: string) { + setState("value", value) + const list = matches() + const match = list[0] + if (!value.trim() || !match) { + setState("active", 0) + return + } + setState("active", 0) + input.pauseAutoScroll() + input.revealMessage(match.revealID, match.partID) + scrollToMatch(match) + } + + function scrollToMatch(match: TimelineSearchMatch) { + let attempts = 0 + const seek = () => { + if (!state.visible) return + const root = input.scrollRef() + if (!root) return + const { active } = collectRanges(root, query(), match.partID, match.occurrence) + if (active.length === 0) { + if (attempts++ < 12) requestAnimationFrame(seek) + return + } + const rect = active[0].getBoundingClientRect() + const rootRect = root.getBoundingClientRect() + const sticky = root.querySelector("[data-session-title]") + const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0 + const top = rect.top - rootRect.top + root.scrollTop - inset - (rootRect.height - rect.height) / 2 + root.scrollTo({ top: Math.max(0, top), behavior: "auto" }) + } + requestAnimationFrame(seek) + } + + function move(delta: number) { + const list = matches() + if (list.length === 0) return + const next = (activeIndex() + delta + list.length) % list.length + setState("active", next) + const match = list[next] + if (!match) return + input.pauseAutoScroll() + input.revealMessage(match.revealID, match.partID) + scrollToMatch(match) + } + + return { + visible: () => state.visible, + query: { + value: () => state.value, + placeholder: () => language.t("session.search.placeholder"), + noResults: () => language.t("session.search.noResults"), + open, + close, + setValue, + }, + result: { + activeIndex, + count: () => matches().length, + move, + }, + element: { + setInput: (element: HTMLInputElement) => (inputEl = element), + }, + } +} + +export type TimelineSearchController = ReturnType diff --git a/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx index 235fba7fdbb..9d5838f8cef 100644 --- a/packages/app/src/session/timeline/virtualizer.tsx +++ b/packages/app/src/session/timeline/virtualizer.tsx @@ -69,7 +69,7 @@ type Input = { row: TimelineRow.TimelineRow, disclosure: Readonly>, ) => boolean - setRevealMessage?: (fn: (id: string) => void) => void + setRevealMessage?: (fn: (id: string, partID?: string) => void) => void setScrollToEnd?: (fn: () => void) => void } @@ -271,8 +271,13 @@ export function createTimelineVirtualizer(input: Input) { const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => String(item.key))) createEffect(() => { - input.setRevealMessage?.((id) => { - const index = input.projection.messageRowIndex().get(id) + input.setRevealMessage?.((id, partID) => { + const partIndex = partID + ? rows().findIndex( + (row) => row._tag === "AssistantPart" && row.group.type === "part" && row.group.ref.partID === partID, + ) + : -1 + const index = partIndex >= 0 ? partIndex : input.projection.messageRowIndex().get(id) if (index === undefined) return virtualizer.scrollToIndex(index, { align: "center" }) })