mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +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:
@@ -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