mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
feat(app): cmd+F to search session with highlighting
This commit is contained in:
committed by
GitHub
Unverified
parent
8ea99ef9ad
commit
370b9965d3
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user