mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +00:00
Count chat Find matches across the whole chat (#5167)
* fix(app): count chat Find matches across the whole chat Chat Find searched the whole chat but its counter read "1 of N in message", where N was the occurrences inside the selected message. The per-message split is an implementation detail of how the client verifies and highlights a hit; a reader counting matches is counting the scope they searched. The counter now reads "3 of 7" for the position across every message that holds a hit, and Next/Previous walk that sequence, crossing message boundaries and wrapping at the ends. Per-message occurrence stays inside the model as navigation state. The total needs a per-message count for messages that are not on screen, so the daemon now reports one. It counts with the same matcher the browser uses on rendered text — query words joined by any whitespace, case-insensitive, never across a Markdown block boundary — which also stops it from reporting locations the client would reject. The client replaces the estimate with the rendered count as each message is revealed, so the total is exact for everything that has been on screen. `count` is optional on the wire and tagged COMPAT(timelineSearchCount): a host that omits it is worth one match per located message until a reveal corrects it. * fix(server): stop chat search reporting matches the browser cannot show Falling back to the whole Markdown source when the per-block scan found nothing reported a match whenever the query words straddled a block boundary — "one target" across "target one\n\ntarget two" — and on syntax the reader never sees. The client always rejected those locations, so before the whole-chat counter the cost was a wasted round trip. Now that the total sums the host's per-message counts, a rejected location inflates the number the reader sees until navigation reaches that message and drops it. The block scan alone is the whole rule. The model's Location derives from the wire type again, with count narrowed to required: the search boundary fills in the COMPAT default, so the interior never re-checks it.
This commit is contained in:
@@ -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<HTMLElement>(
|
||||
'[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();
|
||||
|
||||
@@ -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 (
|
||||
<div ref={root} tabIndex={-1} style={ROOT_STYLE}>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<AgentTimelineSearchPayload["locations"][number], "count"> & {
|
||||
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<Location, Target>();
|
||||
private current = -1;
|
||||
private inMessage = { occurrence: 0, count: 0 };
|
||||
private abort = new AbortController();
|
||||
private timer: ReturnType<typeof setTimeout> | 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ export const ar: TranslationResources = {
|
||||
loading: "جارٍ التحميل…",
|
||||
failed: "فشل",
|
||||
retry: "إعادة المحاولة",
|
||||
chatPosition: "{{current}} من {{total}} في الرسالة",
|
||||
|
||||
title: "بحث",
|
||||
placeholder: "بحث في اللوحة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,7 +11,6 @@ export const ja: TranslationResources = {
|
||||
loading: "読み込み中…",
|
||||
failed: "失敗",
|
||||
retry: "再試行",
|
||||
chatPosition: "メッセージ内 {{current}} / {{total}}",
|
||||
|
||||
title: "検索",
|
||||
placeholder: "ペイン内を検索",
|
||||
|
||||
@@ -10,7 +10,6 @@ export const ko: TranslationResources = {
|
||||
loading: "불러오는 중…",
|
||||
failed: "실패",
|
||||
retry: "다시 시도",
|
||||
chatPosition: "메시지 내 {{current}} / {{total}}",
|
||||
|
||||
title: "찾기",
|
||||
placeholder: "패널에서 찾기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,7 +11,6 @@ export const ru: TranslationResources = {
|
||||
loading: "Загрузка…",
|
||||
failed: "Ошибка",
|
||||
retry: "Повторить",
|
||||
chatPosition: "{{current}} из {{total}} в сообщении",
|
||||
|
||||
title: "Найти",
|
||||
placeholder: "Найти в панели",
|
||||
|
||||
@@ -10,7 +10,6 @@ export const zhCN: TranslationResources = {
|
||||
loading: "加载中…",
|
||||
failed: "失败",
|
||||
retry: "重试",
|
||||
chatPosition: "消息内第 {{current}} 项,共 {{total}} 项",
|
||||
|
||||
title: "查找",
|
||||
placeholder: "在窗格中查找",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user