Find text throughout chat history (#4765)

* feat(app): add pane Find to file source

* fix(app): float the pane Find widget and tighten its chrome

Find pushed the first lines of the file down, ran 480px wide with 32px
ghost buttons, and let the replacement input start left of the query
input. Float the CodeMirror top panel out of flow so the widget overlays
the top right of the pane, move the disclosure toggle into a gutter that
spans both rows so the two fields share a leading rail, put the match
count inside the query field, and drop the icon controls to the shared
pane-toolbar chrome the file bar above already uses.

* fix(app): keep the active match visible and drop the Find button

The floating widget covered the active match whenever the match sat under
it and the document was too short to scroll clear. CodeMirror's own panel
scroll margin cannot reveal a first line, so the file integration now owns
placement: the widget renders as an overlay beside the editor, the model
measures it against the active match, and flips it to the opposite corner
only when the current corner collides. Hysteresis keeps navigation from
bouncing the widget between corners.

Also removes the standalone Find button shown while Find is closed, per
user direction. Cmd/Ctrl+F is the entry point.

* fix(app): stop the Find placeholder from hiding Go to line

Hiding `.cm-panels` suppressed every CodeMirror panel in the file editor,
including the Go to line dialog that Mod-Alt-g opens through the same
search keymap. Hide only the inert Find placeholder panel instead, by its
own class, and leave the shared panel container alone.

* feat(app): find text throughout chat history

Discover candidate transcript locations on the daemon, then reuse prompt navigation to load history and verify rendered occurrences on the client. Keep search state inside its own modules without changing timeline or task presentation contracts.

* fix(app): show actionable chat search failures

Keep transport error details in the search model while showing localized retry guidance. Use named client request options for timeline search.
This commit is contained in:
Mohamed Boudra
2026-09-15 17:54:01 +02:00
committed by GitHub
parent 326a37cc89
commit 80b4387bc6
30 changed files with 1472 additions and 68 deletions
+13
View File
@@ -39011,6 +39011,7 @@
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"lru-cache": "^11.5.1",
"markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.15",
"openai": "^6.44.0",
@@ -39033,6 +39034,7 @@
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/express": "^4.17.20",
"@types/markdown-it": "^14.2.0",
"@types/node": "^20.9.0",
"@types/qrcode": "^1.5.6",
"@types/uuid": "^9.0.7",
@@ -39477,6 +39479,17 @@
"cross-spawn": "7.0.6"
}
},
"packages/server/node_modules/@types/markdown-it": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz",
"integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"packages/server/node_modules/@vitest/expect": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
+205
View File
@@ -0,0 +1,205 @@
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
expectReconnectingToastGone,
expectReconnectingToastVisible,
} from "../support/helpers/workspace-ui";
import { trackPromptJumpRequests } from "../support/helpers/agent-timeline-gate";
const RESPONSE =
'İ😀hello **world**; hello *world*.\n\n[hello & world](https://example.com/hidden-destination)\n\n```ts\nconst a = "a.b";\n```\n\n- first cell\n- second cell';
function query(page: Page) {
return page.getByRole("textbox", { name: "Find in pane", exact: true });
}
function status(page: Page) {
return page.getByRole("status", { name: "Find matches" });
}
async function searchChat(page: Page, text: string) {
await page.getByTestId("assistant-message").filter({ visible: true }).last().click();
await page.keyboard.press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill(text);
}
async function expectHighlight(page: Page, text: string) {
await expect
.poll(() =>
page.evaluate(() => {
const entries = Array.from(CSS.highlights.entries()).filter(([name]) =>
name.startsWith("paseo-chat-find-"),
);
const texts: string[] = [];
for (const [, highlight] of entries) {
for (const range of highlight) texts.push(range.toString());
}
return texts;
}),
)
.toEqual([text]);
await expect
.poll(() =>
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 false;
const rect = range.getBoundingClientRect();
const row = range.startContainer.parentElement?.closest(
'[data-testid="agent-chat-scroll"]',
);
const viewport = row?.getBoundingClientRect();
return (
!!viewport && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom
);
}),
)
.toBe(true);
}
async function nextMatch(page: Page) {
await query(page).press("Enter");
}
async function previousMatch(page: Page) {
await query(page).press("Shift+Enter");
}
async function closeFind(page: Page) {
await query(page).press("Escape");
await expect(query(page)).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(() =>
Array.from(CSS.highlights.keys()).filter((key) => key.startsWith("paseo-chat-find-")),
),
)
.toEqual([]);
}
test("loads older history through prompt navigation and finds repeated rendered Markdown", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-",
title: "Chat Find",
featureValues: { mockAssistantResponse: RESPONSE },
});
try {
for (let index = 0; index < 45; index++) {
await agent.client.sendAgentMessage(agent.agentId, `Prompt number ${index}`);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
const jumps = await trackPromptJumpRequests(page, agent.agentId);
await openAgentRoute(page, agent);
await expect(page.getByText("Prompt number 44", { exact: true })).toBeVisible();
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 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 expectHighlight(page, "hello world");
await previousMatch(page);
await expect(status(page)).toHaveText("1 of 2 in message");
await query(page).fill("hello & world");
await expectHighlight(page, "hello & world");
await query(page).fill("a.b");
await expectHighlight(page, "a.b");
await query(page).fill("Prompt number 0");
await expectHighlight(page, "Prompt number 0");
await closeFind(page);
} finally {
await agent.cleanup();
}
});
test("rejects invisible source matches and ignores a late reply after closing", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-cancel-",
title: "Chat Find cancellation",
initialPrompt: "Render the response",
featureValues: { mockAssistantResponse: RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, agent);
await expect(page.getByTestId("assistant-message")).toBeVisible();
await searchChat(page, "hidden-destination");
await expect(status(page)).toHaveText("No matches");
await query(page).fill("first cell second cell");
await expect(status(page)).toHaveText("No matches");
gate.holdNextServerMessage("agent.timeline.search.response");
await query(page).fill("hello world");
await expect(status(page)).toHaveText("Searching…");
await gate.waitForHeldServerMessage("agent.timeline.search.response");
await closeFind(page);
gate.releaseHeldServerMessage("agent.timeline.search.response");
await searchChat(page, "a.b");
await expectHighlight(page, "a.b");
await closeFind(page);
} finally {
await agent.cleanup();
}
});
test("shows a disconnected search failure and recovers with Retry", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-retry-",
title: "Chat Find retry",
initialPrompt: "Render the response",
featureValues: { mockAssistantResponse: RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, agent);
await expect(page.getByTestId("assistant-message")).toBeVisible();
await gate.drop();
await expectReconnectingToastVisible(page);
await searchChat(page, "hello world");
await expect(status(page)).toHaveText("Failed");
await expect(
page.getByText("Could not search this chat. Check the host connection and retry.", {
exact: true,
}),
).toBeVisible();
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
gate.restore();
await expectReconnectingToastGone(page);
await page.getByRole("button", { name: "Retry", exact: true }).click();
await expectHighlight(page, "hello world");
} finally {
await agent.cleanup();
}
});
test("finds text beyond the normal render cap and restores the cap when Find closes", async ({
page,
}) => {
const tail = "oversized-tail-needle";
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-cap-",
title: "Chat Find long message",
initialPrompt: "Render a long response",
featureValues: { mockAssistantResponse: `${"background ".repeat(3100)}\n\n${tail}` },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
const message = page.getByTestId("assistant-message");
await expect(message).toBeVisible();
await expect(message).not.toContainText(tail);
await searchChat(page, tail);
await expectHighlight(page, tail);
await closeFind(page);
await expect(message).not.toContainText(tail);
await expect(message.getByTestId("assistant-message-capped-notice")).toBeVisible();
} finally {
await agent.cleanup();
}
});
@@ -0,0 +1,8 @@
import type { ChatFindProps, ChatFindExpansionProps } from "./types";
export function ChatFind({ children }: ChatFindProps) {
return children;
}
export function ChatFindExpansion({ children }: ChatFindExpansionProps) {
return children(false);
}
@@ -0,0 +1,166 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { PaneFind, type PaneFindHandle } from "@/pane-find";
import { Button } from "@/components/ui/button";
import { usePaneFocus } from "@/panels/pane-context";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { hasActiveWebOverlay } from "@/lib/overlay-root";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { planTimelinePromptJump } from "@/timeline/timeline-sync-plan";
import { ChatFindModel } from "./model";
import { createFindViewport } from "./viewport.web";
import type { ChatFindProps, ChatFindExpansionProps } from "./types";
const Expansion = createContext<string | null>(null);
const WIDGET_DATASET = { chatFindWidget: "true" };
const ROOT_STYLE = {
display: "flex",
flex: 1,
minHeight: 0,
position: "relative",
outline: "none",
} as const;
let nextHighlightId = 0;
export function ChatFindExpansion({ itemId, children }: ChatFindExpansionProps) {
return children(useContext(Expansion) === itemId);
}
export function ChatFind({
agentId,
serverId,
epoch,
items,
viewportRef,
revealLoadedItem,
visibleItemIds,
children,
}: ChatFindProps) {
const { t } = useTranslation();
const widget = useRef<PaneFindHandle>(null);
const root = useRef<HTMLDivElement>(null);
const bindings = useRef({ viewportRef, revealLoadedItem, visibleItemIds });
bindings.current = { viewportRef, revealLoadedItem, visibleItemIds };
const [highlightName] = useState(() => `paseo-chat-find-${++nextHighlightId}`);
const { isInteractive } = usePaneFocus();
const active = useRetainedPanelActive();
const model = useMemo(
() =>
new ChatFindModel({
search(query, cursor) {
const client = getHostRuntimeStore().getClient(serverId);
if (!client) return Promise.reject(new Error("Host disconnected"));
return client.searchAgentTimeline({ agentId, query, cursor });
},
load(targetEpoch, seq) {
return getHostRuntimeStore().fetchAgentTimeline(
serverId,
agentId,
planTimelinePromptJump({ epoch: targetEpoch, seq }),
);
},
...createFindViewport({
getBindings: () => bindings.current,
getRoot: () => root.current,
highlightName,
}),
}),
[agentId, serverId, highlightName],
);
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
useEffect(() => {
model.updateHistory(epoch, items);
}, [model, epoch, items]);
useEffect(() => () => model.close(), [model]);
useEffect(() => {
if (!active) model.close();
}, [active, model]);
useEffect(() => {
if (!active || !isInteractive) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
return;
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === "f"
) {
event.preventDefault();
model.open();
widget.current?.focus();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [active, isInteractive, model]);
const close = useCallback(() => {
model.close();
root.current?.focus({ preventScroll: true });
}, [model]);
let status = "";
if (state.phase === "searching") status = t("paneFind.searching");
else if (state.phase === "loading") status = t("paneFind.loading");
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.noMatches");
return (
<div ref={root} tabIndex={-1} style={ROOT_STYLE}>
<style>{`::highlight(${highlightName}) { background-color: ${styles.highlight.backgroundColor}; color: ${styles.highlight.color}; }`}</style>
<Expansion.Provider value={state.selectedItemId}>{children}</Expansion.Provider>
{state.open && (
<View style={styles.overlay} dataSet={WIDGET_DATASET}>
<PaneFind
ref={widget}
query={state.query}
status={status}
canNavigate={state.phase === "ready" && state.count > 0}
onQueryChange={model.setQuery}
onNext={model.next}
onPrevious={model.previous}
onClose={close}
/>
{state.error && (
<View style={styles.error}>
<Text style={styles.errorText}>{t("paneFind.searchFailed")}</Text>
<Button size="xs" variant="ghost" onPress={model.retry}>
{t("paneFind.retry")}
</Button>
</View>
)}
</View>
)}
</div>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
position: "absolute",
top: theme.spacing[2],
right: theme.spacing[3],
zIndex: 10,
maxWidth: "100%",
},
error: {
flexDirection: "row",
alignItems: "center",
padding: theme.spacing[2],
backgroundColor: theme.colors.surface1,
},
errorText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, flex: 1 },
highlight: { backgroundColor: theme.colors.statusWarning, color: theme.colors.surface0 },
}));
@@ -0,0 +1,194 @@
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" }>) {
return {
agentId: "agent",
requestId: "request",
epoch: "epoch",
locations,
nextCursor: null,
error: null,
};
}
function message(seq: number, text = "target"): StreamItem {
return {
id: `row-${seq}`,
kind: "assistant_message",
text,
timestamp: new Date(0),
timelineCursor: { epoch: "epoch", seq },
};
}
it("loads an older location and searches a response merged across suppressed tasks once", async () => {
const source = [
{ type: "assistant_message" as const, text: "first **target** " },
{
type: "tool_call" as const,
callId: "task",
name: "TaskCreate",
status: "completed" as const,
detail: { type: "unknown" as const, input: {}, output: null },
error: null,
},
{ type: "assistant_message" as const, text: "second target" },
];
const history = hydrateStreamState(
source.map((item, index) => ({
event: { type: "timeline" as const, provider: "claude" as const, item },
timestamp: new Date(0),
timelineCursor: { epoch: "epoch", seq: index + 1 },
})),
{ source: "canonical" },
);
expect(history).toHaveLength(1);
const loaded: number[] = [];
const revealed: number[] = [];
const model = new ChatFindModel({
async search() {
return response([
{ seq: 1, role: "assistant" },
{ seq: 3, role: "assistant" },
]);
},
async load(_epoch, seq) {
loaded.push(seq);
model.updateHistory("epoch", history);
},
async reveal(_id, _query, occurrence) {
revealed.push(occurrence);
return { count: 2, occurrence: occurrence < 0 ? 1 : occurrence };
},
clear() {},
});
try {
model.updateHistory("epoch", []);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
model.next();
await expect.poll(() => model.getSnapshot().occurrence).toBe(1);
model.next();
await expect.poll(() => revealed).toEqual([0, 1, 0]);
model.previous();
await expect.poll(() => model.getSnapshot().occurrence).toBe(1);
expect(loaded).toEqual([1]);
} finally {
model.close();
}
});
it("skips a source-only candidate and highlights a displayed match", async () => {
const revealed: string[] = [];
const model = new ChatFindModel({
async search() {
return response([
{ seq: 1, role: "assistant" },
{ seq: 2, role: "assistant" },
]);
},
async load() {
throw new Error("Already loaded");
},
async reveal(id) {
revealed.push(id);
return { count: id === "row-1" ? 0 : 1, occurrence: 0 };
},
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)]);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-2");
expect(revealed).toEqual(["row-1", "row-2"]);
expect(model.getSnapshot().phase).toBe("ready");
} finally {
model.close();
}
});
it("ignores replies after closing and does not navigate", async () => {
let finish!: (value: ReturnType<typeof response>) => void;
const calls: string[] = [];
const model = new ChatFindModel({
search() {
calls.push("search");
return new Promise((resolve) => {
finish = resolve;
});
},
async load() {
calls.push("load");
},
async reveal() {
calls.push("reveal");
return { count: 1, occurrence: 0 };
},
clear() {},
});
model.updateHistory("epoch", [message(1)]);
model.open();
model.setQuery("target");
await expect.poll(() => calls).toEqual(["search"]);
model.close();
finish(response([{ seq: 1, role: "assistant" }]));
await Promise.resolve();
expect(calls).toEqual(["search"]);
expect(model.getSnapshot()).toMatchObject({ open: false, phase: "idle" });
});
it("retains a failed search for retry and distinguishes failure from zero matches", async () => {
let disconnected = true;
const operations: ChatFindOperations = {
async search() {
if (disconnected) throw new Error("Host disconnected");
return response([]);
},
async load() {},
async reveal() {
return { count: 0, occurrence: 0 };
},
clear() {},
};
const model = new ChatFindModel(operations);
try {
model.updateHistory("epoch", []);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("error");
expect(model.getSnapshot().error).toBe("Host disconnected");
disconnected = false;
model.retry();
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
expect(model.getSnapshot()).toMatchObject({ count: 0, error: null });
} finally {
model.close();
}
});
it("can load a search location before the initial chat history has arrived", async () => {
const model = new ChatFindModel({
async search() {
return response([{ seq: 1, role: "assistant" }]);
},
async load() {
model.updateHistory("epoch", [message(1)]);
},
async reveal() {
return { count: 1, occurrence: 0 };
},
clear() {},
});
try {
model.updateHistory(null, []);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", count: 1, error: null });
} finally {
model.close();
}
});
@@ -0,0 +1,256 @@
import type { AgentTimelineSearchPayload } from "@getpaseo/client/internal/daemon-client";
import type { StreamItem } from "@/types/stream";
type Location = AgentTimelineSearchPayload["locations"][number];
interface Target {
id: string;
seq: number;
before: number;
}
interface Snapshot {
open: boolean;
query: string;
phase: "idle" | "searching" | "loading" | "ready" | "error";
selectedItemId: string | null;
occurrence: number;
count: number;
error: string | null;
}
export interface ChatFindOperations {
search(query: string, cursor?: number): Promise<AgentTimelineSearchPayload>;
load(epoch: string, seq: number): Promise<unknown>;
reveal(
itemId: string,
query: string,
occurrence: number,
signal: AbortSignal,
): Promise<{ occurrence: number; count: number }>;
clear(): void;
}
export class ChatFindModel {
private state: Snapshot = {
open: false,
query: "",
phase: "idle",
selectedItemId: null,
occurrence: 0,
count: 0,
error: null,
};
private listeners = new Set<() => void>();
private historyListeners = new Set<() => void>();
private items: StreamItem[] = [];
private epoch: string | null = null;
private resultEpoch: string | null = null;
private locations: Location[] = [];
private resolved = new Map<Location, Target>();
private current = -1;
private abort = new AbortController();
private timer: ReturnType<typeof setTimeout> | undefined;
constructor(private operations: ChatFindOperations) {}
readonly getSnapshot = () => this.state;
readonly subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
private publish(change: Partial<Snapshot>) {
this.state = { ...this.state, ...change };
this.listeners.forEach((listener) => listener());
}
private cancel() {
this.abort.abort();
this.abort = new AbortController();
clearTimeout(this.timer);
this.operations.clear();
}
readonly open = () => {
if (this.state.open) return;
this.publish({ open: true });
if (this.state.query) this.setQuery(this.state.query);
};
readonly close = () => {
this.cancel();
this.publish({ open: false, phase: "idle", selectedItemId: null, count: 0, error: null });
};
readonly setQuery = (query: string) => {
this.cancel();
this.locations = [];
this.resultEpoch = null;
this.resolved.clear();
this.current = -1;
this.publish({
query,
phase: query.trim() ? "searching" : "idle",
selectedItemId: null,
occurrence: 0,
count: 0,
error: null,
});
if (!query.trim()) return;
const signal = this.abort.signal;
this.timer = setTimeout(() => {
void this.search(signal);
}, 120);
};
readonly retry = () => this.setQuery(this.state.query);
private fail(error: unknown, signal: AbortSignal) {
if (signal.aborted) return;
this.cancel();
this.publish({
phase: "error",
selectedItemId: null,
error: error instanceof Error ? error.message : String(error),
});
}
private async search(signal: AbortSignal) {
try {
let cursor: number | undefined;
do {
const result = await this.operations.search(this.state.query, cursor);
if (signal.aborted) return;
const historyChanged = this.epoch !== null && result.epoch !== this.epoch;
const searchChanged = this.resultEpoch !== null && result.epoch !== this.resultEpoch;
if (historyChanged || searchChanged) throw new Error("History changed; search again");
this.resultEpoch = result.epoch;
this.locations.push(...result.locations);
cursor = result.nextCursor ?? undefined;
} while (cursor !== undefined);
await this.navigate(0, 1, signal);
} catch (error) {
this.fail(error, signal);
}
}
updateHistory(epoch: string | null, items: StreamItem[]) {
if (
this.resultEpoch !== null &&
epoch !== null &&
epoch !== this.resultEpoch &&
this.state.open
) {
this.fail(new Error("History changed; search again"), this.abort.signal);
}
this.epoch = epoch;
this.items = items;
this.historyListeners.forEach((listener) => listener());
}
private target(location: Location, exact: boolean): Target | null {
let before = 0;
for (const item of this.items) {
if (item.timelineCursor?.epoch !== this.resultEpoch) continue;
const seq = item.timelineCursor.seq;
if (seq < location.seq) {
before = Math.max(before, seq);
continue;
}
const sameRole = item.kind === `${location.role}_message`;
if (sameRole && (!exact || seq === location.seq)) return { id: item.id, seq, before };
// A prompt is a hard boundary; never resolve an assistant hit in a later response.
if (item.kind === "user_message") return null;
}
return null;
}
private waitForTarget(location: Location, signal: AbortSignal): Promise<Target> {
return new Promise((resolve, reject) => {
const finish = (target?: Target) => {
clearTimeout(timeout);
this.historyListeners.delete(check);
signal.removeEventListener("abort", cancelled);
if (target) resolve(target);
else reject(new Error("Could not load this search location; retry"));
};
const check = () => {
const target = this.target(location, false);
if (target) finish(target);
};
const cancelled = () => finish();
const timeout = setTimeout(() => finish(), 5000);
this.historyListeners.add(check);
signal.addEventListener("abort", cancelled, { once: true });
if (signal.aborted) cancelled();
else check();
});
}
private async navigate(index: number, direction: 1 | -1, signal: AbortSignal) {
const epoch = this.resultEpoch;
if (epoch === null) return;
this.publish({ phase: "loading", error: null });
let remaining = this.locations.length;
while (remaining-- > 0 && this.locations.length) {
index = (index + this.locations.length) % this.locations.length;
const location = this.locations[index]!;
const cached = this.resolved.get(location);
const stillLoaded =
cached &&
this.items.some((item) => item.id === cached.id && item.timelineCursor?.seq === cached.seq);
let target = stillLoaded ? cached : this.target(location, true);
if (!target) {
await this.operations.load(epoch, location.seq);
if (signal.aborted) return;
target = await this.waitForTarget(location, signal);
}
if (signal.aborted) return;
this.resolved.set(location, target);
// Multiple source hits may belong to the same displayed message. Resolve that
// here, using loaded client rows, without changing the timeline's data model.
this.locations = this.locations.filter(
(candidate) =>
candidate === location ||
candidate.role !== location.role ||
candidate.seq <= target.before ||
candidate.seq > target.seq,
);
index = this.locations.indexOf(location);
this.publish({ selectedItemId: target.id });
const result = await this.operations.reveal(
target.id,
this.state.query,
direction === 1 ? 0 : -1,
signal,
);
if (signal.aborted) return;
if (result.count) {
this.current = index;
this.publish({ phase: "ready", ...result });
return;
}
this.locations.splice(index, 1);
if (direction === -1) index--;
}
this.operations.clear();
this.publish({ phase: "ready", selectedItemId: null, count: 0, occurrence: 0 });
}
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) {
this.publish({ phase: "loading" });
const result = await this.operations.reveal(
this.state.selectedItemId,
this.state.query,
occurrence,
signal,
);
if (signal.aborted) return;
if (result.count) {
this.publish({ phase: "ready", ...result });
return;
}
}
await this.navigate(this.current + direction, direction, signal);
} catch (error) {
this.fail(error, signal);
}
}
readonly next = () => {
void this.move(1);
};
readonly previous = () => {
void this.move(-1);
};
}
@@ -0,0 +1,37 @@
import { afterEach, expect, it } from "vitest";
import { findRenderedMatches } from "./ranges.web";
afterEach(() => {
document.body.replaceChildren();
});
function content(html: string) {
const row = document.createElement("div");
row.innerHTML = `<div data-message-text="true">${html}</div>`;
document.body.append(row);
return row;
}
it("finds each actual occurrence across inline formatting with original Unicode offsets", () => {
const row = content(
'<div data-paseo-markdown-tag="p">İ😀hello <strong>world</strong> then <em>hello</em> world</div>',
);
const matches = findRenderedMatches(row, "hello world");
expect(matches.map((range) => range.toString())).toEqual(["hello world", "hello world"]);
expect(matches[0]!.startOffset).toBe(3);
expect(matches[0]!.compareBoundaryPoints(Range.START_TO_START, matches[1]!)).toBe(-1);
});
it("ignores controls, keeps block boundaries, and preserves literal code punctuation", () => {
const row = content(
'<div data-paseo-markdown-tag="p">first</div><div data-paseo-markdown-tag="p">second</div><div data-paseo-markdown-tag="pre"><span>const a</span><span>.b</span><button>copy a.b</button><span data-paseo-markdown-ignore="true">a.b</span></div>',
);
expect(findRenderedMatches(row, "firstsecond")).toEqual([]);
expect(findRenderedMatches(row, "a.b").map((range) => range.toString())).toEqual(["a.b"]);
});
it("matches whitespace across text nodes without inspecting hidden link destinations", () => {
const row = content(
'<div data-paseo-markdown-tag="p"><a href="https://example.com/hidden">hello &amp; world</a>\nline</div>',
);
expect(findRenderedMatches(row, "hidden")).toEqual([]);
expect(findRenderedMatches(row, "world line").map((range) => range.toString())).toEqual([
"world\nline",
]);
});
@@ -0,0 +1,48 @@
const BLOCKS = ["p", "pre", "li", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6"]
.map((tag) => `[data-paseo-markdown-tag="${tag}"]`)
.join(",");
const IGNORED =
'[data-paseo-markdown-ignore="true"], [aria-hidden="true"], button, [role="button"], svg, script, style';
/** Local offsets belong to the DOM that supplied the text, never to a host parser. */
export function findRenderedMatches(row: HTMLElement, query: string): Range[] {
if (!query.trim()) return [];
const groups = new Map<Element, Text[]>();
for (const content of row.querySelectorAll<HTMLElement>('[data-message-text="true"]')) {
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
let node: Node | null;
while ((node = walker.nextNode())) {
if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue;
const block = node.parentElement?.closest(BLOCKS) ?? content;
const texts = groups.get(block) ?? [];
texts.push(node);
groups.set(block, texts);
}
}
const pattern = query
.trim()
.split(/\s+/)
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("\\s+");
const ranges: Range[] = [];
for (const texts of groups.values()) {
const points = texts.map((node) => ({ node, start: 0, end: 0 }));
let text = "";
for (const point of points) {
point.start = text.length;
text += point.node.data;
point.end = text.length;
}
for (const match of text.matchAll(new RegExp(pattern, "giu"))) {
const start = points.find((point) => point.start <= match.index && point.end > match.index);
const endOffset = match.index + match[0].length;
const end = points.find((point) => point.start < endOffset && point.end >= endOffset);
if (!start || !end) continue;
const range = document.createRange();
range.setStart(start.node, match.index - start.start);
range.setEnd(end.node, endOffset - end.start);
ranges.push(range);
}
}
return ranges;
}
@@ -0,0 +1,18 @@
import type { ReactNode, RefObject } from "react";
import type { StreamItem } from "@/types/stream";
import type { StreamViewportHandle } from "../strategy";
export interface ChatFindProps {
agentId: string;
serverId: string;
epoch: string | null;
items: StreamItem[];
viewportRef: RefObject<StreamViewportHandle | null>;
revealLoadedItem(itemId: string): boolean;
visibleItemIds: ReadonlySet<string>;
children: ReactNode;
}
export interface ChatFindExpansionProps {
itemId: string;
children(renderFullContent: boolean): ReactNode;
}
@@ -0,0 +1,89 @@
import type { ChatFindOperations } from "./model";
import type { ChatFindProps } from "./types";
import { findRenderedMatches } from "./ranges.web";
interface ViewportInput {
getBindings(): Pick<ChatFindProps, "viewportRef" | "revealLoadedItem" | "visibleItemIds">;
getRoot(): HTMLElement | null;
highlightName: string;
}
export function createFindViewport({
getBindings,
getRoot,
highlightName,
}: ViewportInput): Pick<ChatFindOperations, "reveal" | "clear"> {
return {
clear() {
CSS.highlights.delete(highlightName);
},
reveal(itemId, query, occurrence, signal) {
return new Promise((resolve, reject) => {
let frame = 0;
let started = false;
const deadline = performance.now() + 5000;
const cleanup = () => {
cancelAnimationFrame(frame);
signal.removeEventListener("abort", cancelled);
};
const cancelled = () => {
cleanup();
reject(new Error("Search cancelled"));
};
const poll = () => {
if (signal.aborted) {
cancelled();
return;
}
if (performance.now() >= deadline) {
cleanup();
reject(new Error("Could not reveal this match; retry"));
return;
}
const current = getBindings();
if (!current.visibleItemIds.has(itemId)) {
current.revealLoadedItem(itemId);
frame = requestAnimationFrame(poll);
return;
}
if (!started) {
current.viewportRef.current?.scrollToMessage?.(itemId);
started = true;
}
const row = getRoot()?.querySelector<HTMLElement>(
`[data-history-row-id="${CSS.escape(itemId)}"]`,
);
if (!row || !row.getBoundingClientRect().height) {
frame = requestAnimationFrame(poll);
return;
}
const ranges = findRenderedMatches(row, query);
const index =
occurrence < 0 ? ranges.length - 1 : Math.min(occurrence, ranges.length - 1);
const range = ranges[index];
cleanup();
if (!range) {
resolve({ occurrence: 0, count: 0 });
return;
}
current.viewportRef.current?.scrollToMessage?.(itemId, {
signal,
targetTop(currentRow) {
const selected = findRenderedMatches(currentRow, query)[index];
if (!selected) return null;
CSS.highlights.set(highlightName, new Highlight(selected));
const widget = getRoot()?.querySelector<HTMLElement>(
'[data-chat-find-widget="true"]',
);
const clearance = widget ? widget.getBoundingClientRect().height + 8 : 0;
return selected.getBoundingClientRect().top - clearance;
},
});
CSS.highlights.set(highlightName, new Highlight(range));
resolve({ occurrence: index, count: ranges.length });
};
signal.addEventListener("abort", cancelled, { once: true });
frame = requestAnimationFrame(poll);
});
},
};
}
+4 -1
View File
@@ -43,7 +43,10 @@ export interface StreamEdgeSlotProps {
export interface StreamViewportHandle {
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
prepareForViewportChange: () => void;
scrollToMessage?: (itemId: string) => void;
scrollToMessage?: (
itemId: string,
occurrence?: { signal: AbortSignal; targetTop(row: HTMLElement): number | null },
) => void;
}
export interface StreamSegmentRenderers {
@@ -1,5 +1,5 @@
import type React from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
import type { Virtualizer } from "@tanstack/react-virtual";
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
@@ -32,6 +32,10 @@ export function useScrollToMessage({
setFollowOutput,
onNearBottomChange,
}: UseScrollToMessageInput) {
const occurrenceRef = useRef<
{ signal: AbortSignal; targetTop(row: HTMLElement): number | null } | undefined
>(undefined);
const removeAbortListener = useRef<(() => void) | null>(null);
const settleController = useMemo(
() =>
createPromptJumpSettleController({
@@ -41,7 +45,11 @@ export function useScrollToMessage({
const target = container?.querySelector<HTMLElement>(
`[data-history-row-id="${CSS.escape(itemId)}"]`,
);
return target?.getBoundingClientRect().top ?? null;
const occurrence = occurrenceRef.current;
if (occurrence?.signal.aborted) return null;
if (!target) return null;
if (occurrence) return occurrence.targetTop(target);
return target.getBoundingClientRect().top;
},
getContainerTop() {
return scrollContainerRef.current?.getBoundingClientRect().top ?? 0;
@@ -80,7 +88,13 @@ export function useScrollToMessage({
[scrollContainerRef],
);
useEffect(() => () => settleController.cancel(), [settleController]);
useEffect(
() => () => {
removeAbortListener.current?.();
settleController.cancel();
},
[settleController],
);
useLayoutEffect(() => {
if (!active) {
settleController.cancel();
@@ -88,7 +102,15 @@ export function useScrollToMessage({
}, [active, settleController]);
const scrollToMessage = useCallback(
(itemId: string) => {
(
itemId: string,
occurrence?: { signal: AbortSignal; targetTop(row: HTMLElement): number | null },
) => {
occurrenceRef.current = occurrence;
removeAbortListener.current?.();
const cancel = () => settleController.cancel();
occurrence?.signal.addEventListener("abort", cancel, { once: true });
removeAbortListener.current = () => occurrence?.signal.removeEventListener("abort", cancel);
if (!active) return;
const container = scrollContainerRef.current;
if (!container) return;
+78 -58
View File
@@ -1,3 +1,4 @@
import { ChatFind, ChatFindExpansion } from "@/agent-stream/chat-find";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, {
forwardRef,
@@ -717,16 +718,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
onOpenWorkspaceFile={handleInlinePathPress}
toast={toast}
>
<AssistantMessage
occurrenceKey={createAssistantImageOccurrenceKey({ agentId, itemId: item.id })}
message={item.text}
timestamp={item.timestamp.getTime()}
workspaceRoot={workspaceRoot}
serverId={resolvedServerId}
client={client}
spacing={layoutItem.assistantSpacing}
phase={layoutItem.phase}
/>
<ChatFindExpansion itemId={item.id}>
{(renderFullContent) => (
<AssistantMessage
renderFullContent={renderFullContent}
occurrenceKey={createAssistantImageOccurrenceKey({ agentId, itemId: item.id })}
message={item.text}
timestamp={item.timestamp.getTime()}
workspaceRoot={workspaceRoot}
serverId={resolvedServerId}
client={client}
spacing={layoutItem.assistantSpacing}
phase={layoutItem.phase}
/>
)}
</ChatFindExpansion>
</AssistantFileLinkResolverProvider>
);
},
@@ -1081,55 +1087,69 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[expandedToolCallGroupIds, isMobile, presentation.historyGroupUpdatesByHostId],
);
const findItems = useMemo(
() => [...effectiveStreamItems, ...(effectiveStreamHead ?? [])],
[effectiveStreamItems, effectiveStreamHead],
);
return (
<ToolCallSheetProvider>
<AssistantSelectionCopySurface style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing>
{streamRenderStrategy.render({
agentId,
segments: renderModel.segments,
historyRowRevision,
liveHeadRowRevision: expandedToolCallGroupIds,
boundary,
renderers,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange: setIsNearBottom,
onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder,
isLoadingOlderHistory: isLoadingOlder,
hasOlderHistory: hasOlder,
olderHistoryProgressKey: progressKey,
scrollEnabled: streamScrollEnabled,
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
})}
</MessageOuterSpacingProvider>
<ChatOutlineRail
prompts={chatOutline.prompts}
activePrompt={chatOutline.activePrompt}
onJumpToPrompt={chatOutline.jumpToPrompt}
/>
{(!isNearBottom || isTimelineDetached) && (
<View style={scrollToBottomContainerStyle} pointerEvents="box-none">
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
<Pressable
style={stylesheet.scrollToBottomButton}
onPress={scrollToBottom}
accessibilityRole="button"
accessibilityLabel={t("agentStream.scrollToBottom")}
testID="scroll-to-bottom-button"
>
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
</Pressable>
</Animated.View>
</View>
)}
</AssistantSelectionCopySurface>
</ToolCallSheetProvider>
<ChatFind
agentId={agentId}
serverId={resolvedServerId}
epoch={timelineEpoch}
items={findItems}
viewportRef={viewportRef}
revealLoadedItem={revealLoadedHistory}
visibleItemIds={visibleHistoryItemIds}
>
<ToolCallSheetProvider>
<AssistantSelectionCopySurface style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing>
{streamRenderStrategy.render({
agentId,
segments: renderModel.segments,
historyRowRevision,
liveHeadRowRevision: expandedToolCallGroupIds,
boundary,
renderers,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange: setIsNearBottom,
onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder,
isLoadingOlderHistory: isLoadingOlder,
hasOlderHistory: hasOlder,
olderHistoryProgressKey: progressKey,
scrollEnabled: streamScrollEnabled,
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
})}
</MessageOuterSpacingProvider>
<ChatOutlineRail
prompts={chatOutline.prompts}
activePrompt={chatOutline.activePrompt}
onJumpToPrompt={chatOutline.jumpToPrompt}
/>
{(!isNearBottom || isTimelineDetached) && (
<View style={scrollToBottomContainerStyle} pointerEvents="box-none">
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
<Pressable
style={stylesheet.scrollToBottomButton}
onPress={scrollToBottom}
accessibilityRole="button"
accessibilityLabel={t("agentStream.scrollToBottom")}
testID="scroll-to-bottom-button"
>
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
</Pressable>
</Animated.View>
</View>
)}
</AssistantSelectionCopySurface>
</ToolCallSheetProvider>
</ChatFind>
);
},
);
+17 -5
View File
@@ -421,6 +421,8 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage
);
}
const MESSAGE_TEXT_DATASET = { messageText: "true" };
export const UserMessage = memo(function UserMessage({
serverId,
agentId,
@@ -540,7 +542,7 @@ export const UserMessage = memo(function UserMessage({
</View>
) : null}
{hasText ? (
<Text selectable style={userMessageStylesheet.text}>
<Text selectable style={userMessageStylesheet.text} dataSet={MESSAGE_TEXT_DATASET}>
{message}
</Text>
) : null}
@@ -746,6 +748,7 @@ export const LiveElapsed = memo(function LiveElapsed({
});
interface AssistantMessageProps {
renderFullContent?: boolean;
occurrenceKey: string;
message: string;
timestamp: number;
@@ -1493,6 +1496,7 @@ function MarkdownListView({
}
export const AssistantMessage = memo(function AssistantMessage({
renderFullContent = false,
occurrenceKey,
message,
timestamp: _timestamp,
@@ -1508,11 +1512,15 @@ export const AssistantMessage = memo(function AssistantMessage({
() => createAssistantMarkdownParser({ streaming: true }),
[],
);
const renderedMessage = useMemo(() => capAssistantMessageForRender(message), [message]);
const renderedMessage = useMemo(
() =>
renderFullContent ? { text: message, capped: false } : capAssistantMessageForRender(message),
[message, renderFullContent],
);
// Paint a paced prefix while the turn is streaming so text arrives at a steady
// rate instead of in whatever lumps the daemon's coalescing window produced.
const stream = useWordStream(renderedMessage.text, phase);
const revealedMessage = stream.text;
const revealedMessage = renderFullContent ? renderedMessage.text : stream.text;
const fullMessageByteLength = useMemo(
() => (renderedMessage.capped && phase === "complete" ? getUtf8ByteLength(message) : null),
[message, phase, renderedMessage.capped],
@@ -1978,8 +1986,12 @@ export const AssistantMessage = memo(function AssistantMessage({
const revealDataSet = useMemo(
() =>
isRenderProfileEnabled()
? { revealKey: occurrenceKey, revealLength: String(revealedMessage.length) }
: undefined,
? {
...MESSAGE_TEXT_DATASET,
revealKey: occurrenceKey,
revealLength: String(revealedMessage.length),
}
: MESSAGE_TEXT_DATASET,
[occurrenceKey, revealedMessage.length],
);
+7
View File
@@ -3,6 +3,13 @@ import { pluginSettings } from "./plugin-settings";
export const ar: TranslationResources = {
paneFind: {
searchFailed: "تعذر البحث في هذه المحادثة. تحقق من الاتصال بالمضيف وأعد المحاولة.",
searching: "جارٍ البحث…",
loading: "جارٍ التحميل…",
failed: "فشل",
retry: "إعادة المحاولة",
chatPosition: "{{current}} من {{total}} في الرسالة",
title: "بحث",
placeholder: "بحث في اللوحة",
close: "إغلاق البحث",
+6
View File
@@ -1,5 +1,11 @@
export const en = {
paneFind: {
searchFailed: "Could not search this chat. Check the host connection and retry.",
searching: "Searching…",
loading: "Loading…",
failed: "Failed",
retry: "Retry",
chatPosition: "{{current}} of {{total}} in message",
title: "Find",
placeholder: "Find in pane",
close: "Close Find",
+8
View File
@@ -3,6 +3,14 @@ import { pluginSettings } from "./plugin-settings";
export const es: TranslationResources = {
paneFind: {
searchFailed:
"No se pudo buscar en este chat. Comprueba la conexión con el host y vuelve a intentarlo.",
searching: "Buscando…",
loading: "Cargando…",
failed: "Error",
retry: "Reintentar",
chatPosition: "{{current}} de {{total}} en el mensaje",
title: "Buscar",
placeholder: "Buscar en el panel",
close: "Cerrar búsqueda",
+8
View File
@@ -3,6 +3,14 @@ import { pluginSettings } from "./plugin-settings";
export const fr: TranslationResources = {
paneFind: {
searchFailed:
"Impossible de rechercher dans cette conversation. Vérifiez la connexion à l’hôte et réessayez.",
searching: "Recherche…",
loading: "Chargement…",
failed: "Échec",
retry: "Réessayer",
chatPosition: "{{current}} sur {{total}} dans le message",
title: "Rechercher",
placeholder: "Rechercher dans le panneau",
close: "Fermer la recherche",
+8
View File
@@ -3,6 +3,14 @@ import { pluginSettings } from "./plugin-settings";
export const ja: TranslationResources = {
paneFind: {
searchFailed:
"このチャットを検索できませんでした。ホストへの接続を確認して再試行してください。",
searching: "検索中…",
loading: "読み込み中…",
failed: "失敗",
retry: "再試行",
chatPosition: "メッセージ内 {{current}} / {{total}}",
title: "検索",
placeholder: "ペイン内を検索",
close: "検索を閉じる",
+7
View File
@@ -3,6 +3,13 @@ import { pluginSettings } from "./plugin-settings";
export const ko: TranslationResources = {
paneFind: {
searchFailed: "이 채팅을 검색할 수 없습니다. 호스트 연결을 확인하고 다시 시도하세요.",
searching: "검색 중…",
loading: "불러오는 중…",
failed: "실패",
retry: "다시 시도",
chatPosition: "메시지 내 {{current}} / {{total}}",
title: "찾기",
placeholder: "패널에서 찾기",
close: "찾기 닫기",
+8
View File
@@ -3,6 +3,14 @@ import { pluginSettings } from "./plugin-settings";
export const ptBR: TranslationResources = {
paneFind: {
searchFailed:
"Não foi possível pesquisar nesta conversa. Verifique a conexão com o host e tente novamente.",
searching: "Buscando…",
loading: "Carregando…",
failed: "Falhou",
retry: "Tentar novamente",
chatPosition: "{{current}} de {{total}} na mensagem",
title: "Buscar",
placeholder: "Buscar no painel",
close: "Fechar busca",
+8
View File
@@ -3,6 +3,14 @@ import { pluginSettings } from "./plugin-settings";
export const ru: TranslationResources = {
paneFind: {
searchFailed:
"Не удалось выполнить поиск в чате. Проверьте подключение к хосту и повторите попытку.",
searching: "Поиск…",
loading: "Загрузка…",
failed: "Ошибка",
retry: "Повторить",
chatPosition: "{{current}} из {{total}} в сообщении",
title: "Найти",
placeholder: "Найти в панели",
close: "Закрыть поиск",
+7
View File
@@ -3,6 +3,13 @@ import { pluginSettings } from "./plugin-settings";
export const zhCN: TranslationResources = {
paneFind: {
searchFailed: "无法搜索此聊天。请检查主机连接后重试。",
searching: "搜索中…",
loading: "加载中…",
failed: "失败",
retry: "重试",
chatPosition: "消息内第 {{current}} 项,共 {{total}} 项",
title: "查找",
placeholder: "在窗格中查找",
close: "关闭查找",
+26
View File
@@ -595,6 +595,17 @@ export interface FetchAgentTimelineOptions {
timeout?: number;
}
export interface AgentTimelineSearchOptions {
agentId: string;
query: string;
cursor?: number;
}
export type AgentTimelineSearchPayload = Extract<
SessionOutboundMessage,
{ type: "agent.timeline.search.response" }
>["payload"];
export type AgentTimelinePromptIndexPayload = Extract<
SessionOutboundMessage,
{ type: "agent.timeline.list_prompts.response" }
@@ -3172,6 +3183,21 @@ export class DaemonClient {
return { seq: payload.seq, epoch: payload.epoch };
}
async searchAgentTimeline({
agentId,
query,
cursor,
}: AgentTimelineSearchOptions): Promise<AgentTimelineSearchPayload> {
const requestId = this.createRequestId();
const payload = await this.sendCorrelatedSessionRequest({
requestId,
message: { type: "agent.timeline.search.request", requestId, agentId, query, cursor },
responseType: "agent.timeline.search.response",
});
if (payload.error) throw new Error(payload.error);
return payload;
}
async listAgentTimelinePrompts(
agentId: string,
options: { requestId?: string; timeout?: number } = {},
+24
View File
@@ -1840,6 +1840,14 @@ export const FetchAgentTimelineRequestMessageSchema = z.object({
mergeWindow: z.boolean().optional(),
});
export const AgentTimelineSearchRequestMessageSchema = z.object({
type: z.literal("agent.timeline.search.request"),
agentId: z.string(),
requestId: z.string(),
query: z.string(),
cursor: z.number().int().nonnegative().optional(),
});
export const AgentTimelineListPromptsRequestMessageSchema = z.object({
type: z.literal("agent.timeline.list_prompts.request"),
agentId: z.string(),
@@ -3230,6 +3238,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
RestartServerRequestMessageSchema,
DaemonUpdateRequestMessageSchema,
FetchAgentTimelineRequestMessageSchema,
AgentTimelineSearchRequestMessageSchema,
AgentTimelineListPromptsRequestMessageSchema,
ProviderSubagentListRequestMessageSchema,
ProviderSubagentTimelineRequestMessageSchema,
@@ -4591,6 +4600,20 @@ export const AgentTimelineReplacementMessageSchema = z.object({
}),
});
export const AgentTimelineSearchResponseMessageSchema = z.object({
type: z.literal("agent.timeline.search.response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
epoch: z.string(),
locations: z.array(
z.object({ seq: z.number().int().nonnegative(), role: z.enum(["user", "assistant"]) }),
),
nextCursor: z.number().int().nonnegative().nullable(),
error: z.string().nullable(),
}),
});
export const AgentTimelineListPromptsResponseMessageSchema = z.object({
type: z.literal("agent.timeline.list_prompts.response"),
payload: z.object({
@@ -6732,6 +6755,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FetchAgentResponseMessageSchema,
FetchAgentTimelineResponseMessageSchema,
AgentTimelineReplacementMessageSchema,
AgentTimelineSearchResponseMessageSchema,
AgentTimelineListPromptsResponseMessageSchema,
ProviderSubagentListResponseMessageSchema,
ProviderSubagentTimelineResponseMessageSchema,
+2
View File
@@ -88,6 +88,7 @@
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"lru-cache": "^11.5.1",
"markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.15",
"openai": "^6.44.0",
@@ -110,6 +111,7 @@
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/express": "^4.17.20",
"@types/markdown-it": "^14.2.0",
"@types/node": "^20.9.0",
"@types/qrcode": "^1.5.6",
"@types/uuid": "^9.0.7",
@@ -0,0 +1,67 @@
import { expect, it } from "vitest";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import { searchTimeline } from "./index.js";
function rows(items: AgentTimelineItem[]) {
return items.map((item, index) => ({ item, seq: index + 1, timestamp: "2026-09-12T00:00:00Z" }));
}
it("returns source locations for formatted assistant text and literal user text", async () => {
const result = await searchTimeline({
rows: rows([
{ type: "user_message", text: "hello **world**" },
{ type: "assistant_message", text: "İ😀hello **world** and hello *world*" },
{
type: "assistant_message",
text: "[hello &amp; world](https://example.com)",
messageId: "separate",
},
]),
query: "hello world",
});
expect(result).toEqual({ locations: [{ seq: 2, role: "assistant" }], nextCursor: null });
});
it("finds code and literal punctuation without searching tools, reasoning, or task state", async () => {
const result = await searchTimeline({
rows: rows([
{ type: "assistant_message", text: "```ts\nconst value = 'a.b';\n```" },
{ type: "reasoning", text: "a.b" },
{ type: "todo", items: [{ text: "a.b", completed: false }] },
{ type: "user_message", text: "axb" },
{ type: "user_message", text: "a.b" },
]),
query: "a.b",
});
expect(result.locations).toEqual([
{ seq: 1, role: "assistant" },
{ seq: 5, role: "user" },
]);
});
it("bounds results and resumes without losing locations", async () => {
const source = rows(
Array.from({ length: 202 }, () => ({ type: "user_message", text: "target" })),
);
const first = await searchTimeline({ rows: source, query: "target" });
expect(first.locations).toHaveLength(200);
expect(first.nextCursor).toBe(200);
const second = await searchTimeline({ rows: source, query: "target", cursor: first.nextCursor! });
expect(second).toEqual({
locations: [
{ seq: 201, role: "user" },
{ seq: 202, role: "user" },
],
nextCursor: null,
});
});
it("finds ordinary Markdown entities and line breaks without requiring identical parsers", async () => {
const source = rows([{ type: "assistant_message", text: "hello &amp; **world**\nnext line" }]);
expect((await searchTimeline({ rows: source, query: "hello & world" })).locations).toEqual([
{ seq: 1, role: "assistant" },
]);
expect((await searchTimeline({ rows: source, query: "world next" })).locations).toEqual([
{ seq: 1, role: "assistant" },
]);
});
@@ -0,0 +1,69 @@
import MarkdownIt from "markdown-it";
import { setImmediate } from "node:timers/promises";
import type { AgentTimelineRow } from "../agent-timeline-store-types.js";
import { projectTimelineRows } from "../timeline-projection.js";
const markdown = new MarkdownIt({ html: false });
const PAGE_SIZE = 200;
interface SearchLocation {
seq: number;
role: "user" | "assistant";
}
interface SearchInput {
rows: readonly AgentTimelineRow[];
query: string;
cursor?: number;
}
// Discovery is deliberately approximate. Only the client can verify displayed text.
function searchableMarkdown(text: string): string {
return markdown
.parse(text, {})
.map((token) => {
if (token.type === "inline") {
return (token.children ?? [])
.map((child) => {
if (child.type === "text" || child.type === "code_inline") return child.content;
if (child.type === "softbreak" || child.type === "hardbreak") return "\n";
return "";
})
.join("");
}
if (token.type === "fence" || token.type === "code_block") return token.content;
return "";
})
.filter(Boolean)
.join("\n");
}
export async function searchTimeline({ rows, query, cursor = 0 }: SearchInput) {
const locations: SearchLocation[] = [];
if (!query.trim()) return { locations, nextCursor: null };
const needle = normalizeSearchText(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)) {
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",
});
}
if (performance.now() - lastYield >= 8) {
await setImmediate();
lastYield = performance.now();
}
}
return { locations, nextCursor: null };
}
function normalizeSearchText(text: string): string {
return text.replace(/\s+/g, " ").trim().toLowerCase();
}
@@ -18,6 +18,7 @@ const INBOUND_PERMISSION = {
"agent.skills.reconcile.request": "daemon.manage",
"agent.skills.save_selection.request": "daemon.manage",
"agent.skills.uninstall.request": "daemon.manage",
"agent.timeline.search.request": "workspace.read",
"agent.timeline.list_prompts.request": "workspace.read",
"agent.timeline.append.request": "workspace.write",
"browser.host.register.request": ["workspace.write"],
@@ -228,6 +229,7 @@ const OUTBOUND_PERMISSION = {
"agent.skills.reconcile.response": "daemon.manage",
"agent.skills.save_selection.response": "daemon.manage",
"agent.skills.uninstall.response": "daemon.manage",
"agent.timeline.search.response": "workspace.read",
"agent.timeline.list_prompts.response": "workspace.read",
"agent.timeline.append.response": "workspace.write",
"agent.timeline.replacement": ["workspace.read", "hub.execute"],
+56
View File
@@ -1,3 +1,4 @@
import { searchTimeline } from "./agent/chat-search/index.js";
import type { BrowserToolsBroker } from "./browser-tools/broker.js";
import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities";
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
@@ -2584,6 +2585,8 @@ export class Session {
return this.handleFetchAgentTimelineRequest(msg, source);
case "agent.timeline.append.request":
return this.handleAgentTimelineAppendRequest(msg);
case "agent.timeline.search.request":
return this.handleAgentTimelineSearchRequest(msg, source);
case "agent.timeline.list_prompts.request":
return this.handleAgentTimelineListPromptsRequest(msg, source);
case "agent.provider_subagents.list.request":
@@ -7763,6 +7766,59 @@ export class Session {
});
}
private async handleAgentTimelineSearchRequest(
msg: Extract<SessionInboundMessage, { type: "agent.timeline.search.request" }>,
source?: object,
): Promise<void> {
try {
await ensureAgentLoaded(msg.agentId, {
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.sessionLogger,
});
const rows = await this.agentManager.getTimelineRows(msg.agentId);
const { epoch } = this.agentManager.fetchTimeline(msg.agentId, {
direction: "tail",
limit: 1,
});
const result = await searchTimeline({ rows, query: msg.query, cursor: msg.cursor });
if (
this.agentManager.fetchTimeline(msg.agentId, { direction: "tail", limit: 1 }).epoch !==
epoch
) {
throw new Error("History changed; search again");
}
this.emitForSource(
{
type: "agent.timeline.search.response",
payload: {
requestId: msg.requestId,
agentId: msg.agentId,
epoch,
...result,
error: null,
},
},
source,
);
} catch (error) {
this.emitForSource(
{
type: "agent.timeline.search.response",
payload: {
requestId: msg.requestId,
agentId: msg.agentId,
epoch: "",
locations: [],
nextCursor: null,
error: error instanceof Error ? error.message : String(error),
},
},
source,
);
}
}
private async handleAgentTimelineListPromptsRequest(
msg: Extract<SessionInboundMessage, { type: "agent.timeline.list_prompts.request" }>,
source?: object,