diff --git a/packages/app/e2e/browser/chat-find.spec.ts b/packages/app/e2e/browser/chat-find.spec.ts index dd00f93216..7ed957f513 100644 --- a/packages/app/e2e/browser/chat-find.spec.ts +++ b/packages/app/e2e/browser/chat-find.spec.ts @@ -139,14 +139,14 @@ test("loads older history through prompt navigation and finds repeated rendered await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0); await searchChat(page, "hello world"); await expectHighlight(page, "hello world"); - await expect(status(page)).toHaveText("1 of 2 in message"); + await expect(status(page)).toHaveText("1 of 90"); await page.screenshot({ path: testInfo.outputPath("chat-find-highlight.png") }); expect(jumps.requests()[0]).toMatchObject({ limit: 40, mergeWindow: true }); await nextMatch(page); - await expect(status(page)).toHaveText("2 of 2 in message"); + await expect(status(page)).toHaveText("2 of 90"); await expectHighlight(page, "hello world"); await previousMatch(page); - await expect(status(page)).toHaveText("1 of 2 in message"); + await expect(status(page)).toHaveText("1 of 90"); await query(page).fill("hello & world"); await expectHighlight(page, "hello & world"); await query(page).fill("a.b"); @@ -159,6 +159,65 @@ test("loads older history through prompt navigation and finds repeated rendered } }); +// The scope of the search is the whole chat, so the counter is the position across +// every message that holds a hit, and Next walks off the end of one into the next. +const ACROSS_PROMPT = "Find scope-needle in this chat"; +const ACROSS_RESPONSE = "Reply with scope-needle here.\n\nAnd scope-needle again there."; + +/** The test id of the message that holds the current match, so it can be watched crossing one. */ +async function highlightedMessage(page: Page) { + return page.evaluate(() => { + const entry = Array.from(CSS.highlights.entries()).find(([name]) => + name.startsWith("paseo-chat-find-"), + ); + const range = entry && Array.from(entry[1])[0]; + if (!(range instanceof Range)) return null; + const message = range.startContainer.parentElement?.closest( + '[data-testid="user-message"], [data-testid="assistant-message"]', + ); + return message?.dataset.testid ?? null; + }); +} +async function expectHighlightedMessage(page: Page, testId: string) { + await expect.poll(() => highlightedMessage(page)).toBe(testId); +} + +test("counts and steps through every match in the chat, not just the selected message", async ({ + page, +}, testInfo) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "chat-find-across-", + title: "Chat Find across messages", + initialPrompt: ACROSS_PROMPT, + featureValues: { mockAssistantResponse: ACROSS_RESPONSE }, + }); + try { + await agent.client.waitForFinish(agent.agentId, 15_000); + await openAgentRoute(page, agent); + await expect(page.getByTestId("assistant-message").first()).toBeVisible(); + // One hit in the prompt and two in the reply: three across the chat. + await searchChat(page, "scope-needle"); + await expect(status(page)).toHaveText("1 of 3"); + await expectHighlight(page, "scope-needle"); + await expectHighlightedMessage(page, "user-message"); + await page.screenshot({ path: testInfo.outputPath("chat-find-across-messages.png") }); + await expectNextMatch(page, "2 of 3", "scope-needle"); + await expectHighlightedMessage(page, "assistant-message"); + await expectNextMatch(page, "3 of 3", "scope-needle"); + await expectHighlightedMessage(page, "assistant-message"); + await page.screenshot({ path: testInfo.outputPath("chat-find-last-match.png") }); + await expectNextMatch(page, "1 of 3", "scope-needle"); + await expectHighlightedMessage(page, "user-message"); + await previousMatch(page); + await expect(status(page)).toHaveText("3 of 3"); + await expectHighlight(page, "scope-needle"); + await expectHighlightedMessage(page, "assistant-message"); + await closeFind(page); + } finally { + await agent.cleanup(); + } +}); + test("rejects invisible source matches and ignores a late reply after closing", async ({ page, }) => { @@ -264,12 +323,12 @@ test("finds a hit in a message that streamed while the chat was open", async ({ try { await openChatFind(page); // The only hit is in the first paragraph, which is no longer the row that grows. - await expectFindStatus(page, "alpha-needle", "1 of 1 in message"); + await expectFindStatus(page, "alpha-needle", "1 of 1"); await chat.replyFinished(); - await expectFindStatus(page, "beta-needle", "1 of 1 in message"); + await expectFindStatus(page, "beta-needle", "1 of 1"); // One message, two Markdown blocks, one count across both. - await expectFindStatus(page, "pair-needle", "1 of 2 in message"); - await expectNextMatch(page, "2 of 2 in message", "pair-needle"); + await expectFindStatus(page, "pair-needle", "1 of 2"); + await expectNextMatch(page, "2 of 2", "pair-needle"); await closeFind(page); } finally { await chat.cleanup(); @@ -311,9 +370,9 @@ test("counts every occurrence of a virtualized message and steps between them", const chat = await openChatWithVirtualizedTallMessage(page); try { await openChatFind(page); - await expectFindStatus(page, "span-needle", "1 of 2 in message"); + await expectFindStatus(page, "span-needle", "1 of 12"); await expectHighlight(page, "span-needle"); - await expectNextMatch(page, "2 of 2 in message", "span-needle"); + await expectNextMatch(page, "2 of 12", "span-needle"); await closeFind(page); } finally { await chat.cleanup(); diff --git a/packages/app/src/agent-stream/chat-find/index.web.tsx b/packages/app/src/agent-stream/chat-find/index.web.tsx index 7a36416948..c817b55028 100644 --- a/packages/app/src/agent-stream/chat-find/index.web.tsx +++ b/packages/app/src/agent-stream/chat-find/index.web.tsx @@ -126,7 +126,7 @@ export function ChatFind({ else if (state.phase === "error") status = t("paneFind.failed"); else if (state.query.trim()) status = state.count - ? t("paneFind.chatPosition", { current: state.occurrence + 1, total: state.count }) + ? t("paneFind.position", { current: state.occurrence + 1, total: state.count }) : t("paneFind.noMatches"); return (
diff --git a/packages/app/src/agent-stream/chat-find/model.test.ts b/packages/app/src/agent-stream/chat-find/model.test.ts index d1e784e19f..abc3ade6c6 100644 --- a/packages/app/src/agent-stream/chat-find/model.test.ts +++ b/packages/app/src/agent-stream/chat-find/model.test.ts @@ -2,7 +2,7 @@ import { expect, it } from "vitest"; import { hydrateStreamState, type StreamItem } from "@/types/stream"; import { ChatFindModel, type ChatFindOperations } from "./model"; -function response(locations: Array<{ seq: number; role: "user" | "assistant" }>) { +function response(locations: Array<{ seq: number; role: "user" | "assistant"; count?: number }>) { return { agentId: "agent", requestId: "request", @@ -241,3 +241,73 @@ it("can load a search location before the initial chat history has arrived", asy model.close(); } }); + +// The scope of the search is the whole chat, so the position spans every located +// message: the host's estimate until a message is on screen, the rendered count after. +it("reports the position across the whole chat and corrects it with rendered counts", async () => { + const model = new ChatFindModel({ + async search() { + return response([ + { seq: 1, role: "assistant", count: 2 }, + { seq: 2, role: "assistant", count: 3 }, + ]); + }, + async load() {}, + async reveal(id, _query, occurrence) { + const count = id === "row-1" ? 2 : 1; + return { count, occurrence: occurrence < 0 ? count - 1 : occurrence }; + }, + clear() {}, + }); + try { + model.updateHistory("epoch", [message(1), message(2)]); + model.open(); + model.setQuery("target"); + await expect.poll(() => model.getSnapshot().phase).toBe("ready"); + expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", occurrence: 0, count: 5 }); + model.next(); + await expect.poll(() => model.getSnapshot().occurrence).toBe(1); + expect(model.getSnapshot().count).toBe(5); + model.next(); + await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-2"); + expect(model.getSnapshot()).toMatchObject({ occurrence: 2, count: 3 }); + model.previous(); + await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-1"); + expect(model.getSnapshot()).toMatchObject({ occurrence: 1, count: 3 }); + } finally { + model.close(); + } +}); + +// COMPAT(timelineSearchCount): a host that reports no counts is worth one match per +// located message until a reveal replaces the estimate with the rendered count. +it("counts one match per message from a host that reports no counts", async () => { + const model = new ChatFindModel({ + async search() { + return response([ + { seq: 1, role: "assistant" }, + { seq: 2, role: "assistant" }, + ]); + }, + async load() {}, + async reveal(id, _query, occurrence) { + const count = id === "row-1" ? 2 : 1; + return { count, occurrence: occurrence < 0 ? count - 1 : occurrence }; + }, + clear() {}, + }); + try { + model.updateHistory("epoch", [message(1), message(2)]); + model.open(); + model.setQuery("target"); + await expect.poll(() => model.getSnapshot().phase).toBe("ready"); + expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", occurrence: 0, count: 3 }); + model.next(); + await expect.poll(() => model.getSnapshot().occurrence).toBe(1); + model.next(); + await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-2"); + expect(model.getSnapshot()).toMatchObject({ occurrence: 2, count: 3 }); + } finally { + model.close(); + } +}); diff --git a/packages/app/src/agent-stream/chat-find/model.ts b/packages/app/src/agent-stream/chat-find/model.ts index 0cd9f39af7..98acc9840f 100644 --- a/packages/app/src/agent-stream/chat-find/model.ts +++ b/packages/app/src/agent-stream/chat-find/model.ts @@ -1,7 +1,15 @@ import type { AgentTimelineSearchPayload } from "@getpaseo/client/internal/daemon-client"; import type { StreamItem } from "@/types/stream"; -type Location = AgentTimelineSearchPayload["locations"][number]; +/** + * A message the host says contains the query. `count` is the host's estimate of its + * occurrences until a reveal replaces it with the rendered count, so the whole-chat + * position the widget shows is exact for every message that has been on screen. The + * wire field is optional; the search boundary fills it in, so the interior trusts it. + */ +type Location = Omit & { + count: number; +}; interface Target { id: string; seq: number; @@ -36,6 +44,7 @@ interface Snapshot { query: string; phase: "idle" | "searching" | "loading" | "ready" | "error"; selectedItemId: string | null; + /** Position across the whole chat: the search scope, not the selected message. */ occurrence: number; count: number; failure: ChatFindFailure | null; @@ -70,6 +79,7 @@ export class ChatFindModel { private locations: Location[] = []; private resolved = new Map(); private current = -1; + private inMessage = { occurrence: 0, count: 0 }; private abort = new AbortController(); private timer: ReturnType | undefined; constructor(private operations: ChatFindOperations) {} @@ -105,6 +115,7 @@ export class ChatFindModel { this.resultEpoch = null; this.resolved.clear(); this.current = -1; + this.inMessage = { occurrence: 0, count: 0 }; this.publish({ query, phase: query.trim() ? "searching" : "idle", @@ -142,7 +153,14 @@ export class ChatFindModel { const searchChanged = this.resultEpoch !== null && result.epoch !== this.resultEpoch; if (historyChanged || searchChanged) throw new ChatFindFailureError("historyChanged"); this.resultEpoch = result.epoch; - this.locations.push(...result.locations); + this.locations.push( + ...result.locations.map((location) => ({ + seq: location.seq, + role: location.role, + // COMPAT(timelineSearchCount): hosts before v0.9.0 send no count; remove after 2027-09-22. + count: location.count ?? 1, + })), + ); cursor = result.nextCursor ?? undefined; } while (cursor !== undefined); await this.navigate(0, 1, signal); @@ -238,7 +256,7 @@ export class ChatFindModel { if (signal.aborted) return; if (result.count) { this.current = index; - this.publish({ phase: "ready", ...result }); + this.publish({ phase: "ready", ...this.position(location, result) }); return; } this.locations.splice(index, 1); @@ -247,13 +265,24 @@ export class ChatFindModel { this.operations.clear(); this.publish({ phase: "ready", selectedItemId: null, count: 0, occurrence: 0 }); } + private position(location: Location, revealed: { occurrence: number; count: number }) { + this.inMessage = revealed; + location.count = revealed.count; + let before = 0; + let total = 0; + for (const candidate of this.locations) { + if (candidate === location) before = total; + total += candidate.count; + } + return { occurrence: before + revealed.occurrence, count: total }; + } private async move(direction: 1 | -1) { if (this.state.phase !== "ready" || !this.state.count) return; this.cancel(); const signal = this.abort.signal; try { - const occurrence = this.state.occurrence + direction; - if (occurrence >= 0 && occurrence < this.state.count && this.state.selectedItemId) { + const occurrence = this.inMessage.occurrence + direction; + if (occurrence >= 0 && occurrence < this.inMessage.count && this.state.selectedItemId) { this.publish({ phase: "loading" }); const result = await tagged( "reveal", @@ -261,7 +290,7 @@ export class ChatFindModel { ); if (signal.aborted) return; if (result.count) { - this.publish({ phase: "ready", ...result }); + this.publish({ phase: "ready", ...this.position(this.locations[this.current]!, result) }); return; } } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index c7e6199af5..2f4925572d 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -10,7 +10,6 @@ export const ar: TranslationResources = { loading: "جارٍ التحميل…", failed: "فشل", retry: "إعادة المحاولة", - chatPosition: "{{current}} من {{total}} في الرسالة", title: "بحث", placeholder: "بحث في اللوحة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 9ae394bc29..671aeeaf32 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -7,7 +7,6 @@ export const en = { loading: "Loading…", failed: "Failed", retry: "Retry", - chatPosition: "{{current}} of {{total}} in message", title: "Find", placeholder: "Find in pane", close: "Close Find", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 7c3a9a15ad..ccbc3dcc98 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -11,7 +11,6 @@ export const es: TranslationResources = { loading: "Cargando…", failed: "Error", retry: "Reintentar", - chatPosition: "{{current}} de {{total}} en el mensaje", title: "Buscar", placeholder: "Buscar en el panel", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 310230d930..261565b863 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -11,7 +11,6 @@ export const fr: TranslationResources = { loading: "Chargement…", failed: "Échec", retry: "Réessayer", - chatPosition: "{{current}} sur {{total}} dans le message", title: "Rechercher", placeholder: "Rechercher dans le panneau", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 605ca7454d..db698b6e85 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -11,7 +11,6 @@ export const ja: TranslationResources = { loading: "読み込み中…", failed: "失敗", retry: "再試行", - chatPosition: "メッセージ内 {{current}} / {{total}}", title: "検索", placeholder: "ペイン内を検索", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 6e0a7897ba..44902af668 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -10,7 +10,6 @@ export const ko: TranslationResources = { loading: "불러오는 중…", failed: "실패", retry: "다시 시도", - chatPosition: "메시지 내 {{current}} / {{total}}", title: "찾기", placeholder: "패널에서 찾기", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 9c9ec29bf2..ff76c70b43 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -11,7 +11,6 @@ export const ptBR: TranslationResources = { loading: "Carregando…", failed: "Falhou", retry: "Tentar novamente", - chatPosition: "{{current}} de {{total}} na mensagem", title: "Buscar", placeholder: "Buscar no painel", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 6a1b9e4a9b..59eafaf092 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -11,7 +11,6 @@ export const ru: TranslationResources = { loading: "Загрузка…", failed: "Ошибка", retry: "Повторить", - chatPosition: "{{current}} из {{total}} в сообщении", title: "Найти", placeholder: "Найти в панели", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 467bf3cdeb..b2513843cb 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -10,7 +10,6 @@ export const zhCN: TranslationResources = { loading: "加载中…", failed: "失败", retry: "重试", - chatPosition: "消息内第 {{current}} 项,共 {{total}} 项", title: "查找", placeholder: "在窗格中查找", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index f174a2dfe0..1c6882223a 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -4608,7 +4608,13 @@ export const AgentTimelineSearchResponseMessageSchema = z.object({ agentId: z.string(), epoch: z.string(), locations: z.array( - z.object({ seq: z.number().int().nonnegative(), role: z.enum(["user", "assistant"]) }), + z.object({ + seq: z.number().int().nonnegative(), + role: z.enum(["user", "assistant"]), + // Estimated occurrences in the message; the client verifies against rendered text. + // COMPAT(timelineSearchCount): added in v0.9.0, remove optional after 2027-09-22. + count: z.number().int().positive().optional(), + }), ), nextCursor: z.number().int().nonnegative().nullable(), error: z.string().nullable(), diff --git a/packages/server/src/server/agent/chat-search/index.test.ts b/packages/server/src/server/agent/chat-search/index.test.ts index 2a9a9bf0e2..41054d167c 100644 --- a/packages/server/src/server/agent/chat-search/index.test.ts +++ b/packages/server/src/server/agent/chat-search/index.test.ts @@ -19,7 +19,10 @@ it("returns source locations for formatted assistant text and literal user text" ]), query: "hello world", }); - expect(result).toEqual({ locations: [{ seq: 2, role: "assistant" }], nextCursor: null }); + expect(result).toEqual({ + locations: [{ seq: 2, role: "assistant", count: 2 }], + nextCursor: null, + }); }); it("finds code and literal punctuation without searching tools, reasoning, or task state", async () => { @@ -34,8 +37,8 @@ it("finds code and literal punctuation without searching tools, reasoning, or ta query: "a.b", }); expect(result.locations).toEqual([ - { seq: 1, role: "assistant" }, - { seq: 5, role: "user" }, + { seq: 1, role: "assistant", count: 1 }, + { seq: 5, role: "user", count: 1 }, ]); }); @@ -49,8 +52,8 @@ it("bounds results and resumes without losing locations", async () => { const second = await searchTimeline({ rows: source, query: "target", cursor: first.nextCursor! }); expect(second).toEqual({ locations: [ - { seq: 201, role: "user" }, - { seq: 202, role: "user" }, + { seq: 201, role: "user", count: 1 }, + { seq: 202, role: "user", count: 1 }, ], nextCursor: null, }); @@ -59,9 +62,27 @@ it("bounds results and resumes without losing locations", async () => { it("finds ordinary Markdown entities and line breaks without requiring identical parsers", async () => { const source = rows([{ type: "assistant_message", text: "hello & **world**\nnext line" }]); expect((await searchTimeline({ rows: source, query: "hello & world" })).locations).toEqual([ - { seq: 1, role: "assistant" }, + { seq: 1, role: "assistant", count: 1 }, ]); expect((await searchTimeline({ rows: source, query: "world next" })).locations).toEqual([ - { seq: 1, role: "assistant" }, + { seq: 1, role: "assistant", count: 1 }, ]); }); + +it("counts occurrences per rendered block rather than across block boundaries", async () => { + const source = rows([ + { type: "assistant_message", text: "target one\n\ntarget two\n\n- target three\n- end target" }, + { type: "user_message", text: "TARGET target" }, + ]); + expect((await searchTimeline({ rows: source, query: "target" })).locations).toEqual([ + { seq: 1, role: "assistant", count: 4 }, + { seq: 2, role: "user", count: 2 }, + ]); + expect((await searchTimeline({ rows: source, query: "end target" })).locations).toEqual([ + { seq: 1, role: "assistant", count: 1 }, + ]); + expect((await searchTimeline({ rows: source, query: "two target" })).locations).toEqual([]); + // Rendered text never runs from the end of one block into the start of the next, so + // neither does a match, however contiguous the Markdown source looks. + expect((await searchTimeline({ rows: source, query: "one target" })).locations).toEqual([]); +}); diff --git a/packages/server/src/server/agent/chat-search/index.ts b/packages/server/src/server/agent/chat-search/index.ts index eee1cb57f5..70ada71b57 100644 --- a/packages/server/src/server/agent/chat-search/index.ts +++ b/packages/server/src/server/agent/chat-search/index.ts @@ -8,6 +8,7 @@ const PAGE_SIZE = 200; interface SearchLocation { seq: number; role: "user" | "assistant"; + count: number; } interface SearchInput { rows: readonly AgentTimelineRow[]; @@ -15,8 +16,13 @@ interface SearchInput { cursor?: number; } -// Discovery is deliberately approximate. Only the client can verify displayed text. -function searchableMarkdown(text: string): string { +// Discovery is deliberately approximate. Only the client can verify displayed text, so +// `count` estimates rendered occurrences and the client replaces it with the rendered +// count once the message is on screen. Blocks are counted separately, and the Markdown +// source is never searched whole, because a match the client cannot highlight — across +// a block boundary, or on syntax the reader never sees — still inflates the total until +// navigation reaches that message and drops it. +function searchableBlocks(text: string): string[] { return markdown .parse(text, {}) .map((token) => { @@ -32,28 +38,44 @@ function searchableMarkdown(text: string): string { if (token.type === "fence" || token.type === "code_block") return token.content; return ""; }) - .filter(Boolean) - .join("\n"); + .filter(Boolean); +} + +function searchPattern(query: string): RegExp { + const source = query + .trim() + .split(/\s+/) + .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("\\s+"); + return new RegExp(source, "giu"); +} + +function countMatches(text: string, pattern: RegExp): number { + let count = 0; + for (const _ of text.matchAll(pattern)) count += 1; + return count; } export async function searchTimeline({ rows, query, cursor = 0 }: SearchInput) { const locations: SearchLocation[] = []; if (!query.trim()) return { locations, nextCursor: null }; - const needle = normalizeSearchText(query); + const pattern = searchPattern(query); let lastYield = performance.now(); for (const entry of projectTimelineRows({ rows, mode: "projected" })) { if (entry.seqEnd <= cursor) continue; const item = entry.item; if (item.type !== "user_message" && item.type !== "assistant_message") continue; const raw = item.text.replace(/\r/g, ""); - const text = item.type === "assistant_message" ? searchableMarkdown(raw) : raw; - if (normalizeSearchText(raw).includes(needle) || normalizeSearchText(text).includes(needle)) { + const blocks = item.type === "assistant_message" ? searchableBlocks(raw) : [raw]; + const count = blocks.reduce((sum, block) => sum + countMatches(block, pattern), 0); + if (count) { if (locations.length === PAGE_SIZE) { return { locations, nextCursor: locations[PAGE_SIZE - 1]!.seq }; } locations.push({ seq: entry.seqEnd, role: item.type === "user_message" ? "user" : "assistant", + count, }); } if (performance.now() - lastYield >= 8) { @@ -63,7 +85,3 @@ export async function searchTimeline({ rows, query, cursor = 0 }: SearchInput) { } return { locations, nextCursor: null }; } - -function normalizeSearchText(text: string): string { - return text.replace(/\s+/g, " ").trim().toLowerCase(); -}