mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 06:02:56 +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:
@@ -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: "在窗格中查找",
|
||||
|
||||
Reference in New Issue
Block a user