feat(app): cmd+F to search session with highlighting

This commit is contained in:
Aarav Sareen
2026-09-06 12:17:20 +05:30
committed by GitHub
Unverified
parent 8ea99ef9ad
commit 370b9965d3
12 changed files with 644 additions and 7 deletions
+1
View File
@@ -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
@@ -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<HTMLElement>("[data-timeline-part-id]")?.dataset
.timelinePartId
}),
)
.toBe(textPartID)
await field.press("Escape")
await expect(search).toBeHidden()
})
@@ -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<HTMLElement>('[data-component="timeline-search-bar"]')
const field = search?.querySelector<HTMLInputElement>('[data-slot="text-input-v2-input"]')
const root = [...document.querySelectorAll<HTMLElement>(".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<HTMLElement>(`[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<HTMLElement>('[data-slot="timeline-search-count"]')?.textContent?.trim() ?? "",
targetMounted: !!target,
targetVisible: !!targetRect && visibleInRoot(targetRect),
targetTopPx: targetRect?.top,
activeRanges: ranges.length,
activePartID: activeElement?.closest<HTMLElement>("[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,
}
}
+9
View File
@@ -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;
+2
View File
@@ -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}}",
+9
View File
@@ -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={<TimelineSearchBar controller={timelineSearch} />}
/>
)}
</Show>
@@ -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,
@@ -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(
<Show when={sessionID()} keyed>
{(id) => (
<div class="shrink-0 flex items-center gap-2">
{props.search}
<SessionContextUsage placement="bottom" />
<Show when={!parentID() && project()}>
{(project) => (
@@ -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;
}
@@ -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 (
<Show when={c.visible()}>
<div data-component="timeline-search-bar" class="h-7 w-[200px] max-w-[50vw] shrink-0">
<div data-component="text-input-v2" data-appearance="base" data-leading-icon class="!h-7 !w-full max-w-full">
<div data-slot="text-input-v2-value">
<span data-slot="text-input-v2-leading-icon">
<Icon name="magnifying-glass" size="small" />
</span>
<input
ref={c.element.setInput}
data-slot="text-input-v2-input"
type="search"
value={c.query.value()}
placeholder={c.query.placeholder()}
aria-label={c.query.placeholder()}
onInput={(event) => 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
}
}}
/>
</div>
<Show when={c.query.value()}>
<span
data-slot="timeline-search-count"
class="shrink-0 self-center text-[11px] text-v2-text-text-muted [font-weight:440] tabular-nums"
>
{c.result.count() > 0 ? c.result.activeIndex() + 1 : 0}/{c.result.count()}
</span>
</Show>
<button
type="button"
class="-me-1 flex size-5 shrink-0 self-center items-center justify-center rounded-[2px] border-0 bg-transparent p-0 text-v2-icon-icon-muted outline outline-1 outline-transparent hover:bg-v2-overlay-simple-overlay-hover active:bg-v2-overlay-simple-overlay-pressed focus-visible:outline-v2-border-border-focus"
aria-label={c.query.placeholder()}
onMouseDown={(event) => event.preventDefault()}
onClick={() => c.query.close()}
>
<Icon name="xmark-small" />
</button>
</div>
</div>
</Show>
)
}
@@ -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<HTMLElement>(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<TimelineSearchMatch[]>(() => {
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<typeof createTimelineSearchController>
@@ -69,7 +69,7 @@ type Input = {
row: TimelineRow.TimelineRow,
disclosure: Readonly<Record<string, boolean | undefined>>,
) => 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" })
})