mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
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.
This commit is contained in:
@@ -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}>{state.error}</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 & 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);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ChatFind, ChatFindExpansion } from "@/agent-stream/chat-find";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import React, {
|
||||
forwardRef,
|
||||
@@ -731,16 +732,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>
|
||||
);
|
||||
},
|
||||
@@ -1087,55 +1093,69 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[expandedToolCallGroupIds, isMobile, projectedToolCalls.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: chatOutline.reportReadingPosition,
|
||||
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: chatOutline.reportReadingPosition,
|
||||
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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -1490,6 +1493,7 @@ function MarkdownListView({
|
||||
}
|
||||
|
||||
export const AssistantMessage = memo(function AssistantMessage({
|
||||
renderFullContent = false,
|
||||
occurrenceKey,
|
||||
message,
|
||||
timestamp: _timestamp,
|
||||
@@ -1501,10 +1505,15 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
}: AssistantMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
const markdownParser = useMemo(createAssistantMarkdownParser, []);
|
||||
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 revealedMessage = useRevealedText(renderedMessage.text, phase);
|
||||
const pacedMessage = useRevealedText(renderedMessage.text, phase);
|
||||
const revealedMessage = renderFullContent ? renderedMessage.text : pacedMessage;
|
||||
const fullMessageByteLength = useMemo(
|
||||
() => (renderedMessage.capped && phase === "complete" ? getUtf8ByteLength(message) : null),
|
||||
[message, phase, renderedMessage.capped],
|
||||
@@ -1966,8 +1975,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],
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@ import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ar: TranslationResources = {
|
||||
paneFind: {
|
||||
searching: "جارٍ البحث…",
|
||||
loading: "جارٍ التحميل…",
|
||||
failed: "فشل",
|
||||
retry: "إعادة المحاولة",
|
||||
chatPosition: "{{current}} من {{total}} في الرسالة",
|
||||
|
||||
title: "بحث",
|
||||
placeholder: "بحث في اللوحة",
|
||||
close: "إغلاق البحث",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
export const en = {
|
||||
paneFind: {
|
||||
searching: "Searching…",
|
||||
loading: "Loading…",
|
||||
failed: "Failed",
|
||||
retry: "Retry",
|
||||
chatPosition: "{{current}} of {{total}} in message",
|
||||
title: "Find",
|
||||
placeholder: "Find in pane",
|
||||
close: "Close Find",
|
||||
|
||||
@@ -3,6 +3,12 @@ import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const es: TranslationResources = {
|
||||
paneFind: {
|
||||
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",
|
||||
|
||||
@@ -3,6 +3,12 @@ import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const fr: TranslationResources = {
|
||||
paneFind: {
|
||||
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",
|
||||
|
||||
@@ -3,6 +3,12 @@ import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ja: TranslationResources = {
|
||||
paneFind: {
|
||||
searching: "検索中…",
|
||||
loading: "読み込み中…",
|
||||
failed: "失敗",
|
||||
retry: "再試行",
|
||||
chatPosition: "メッセージ内 {{current}} / {{total}}",
|
||||
|
||||
title: "検索",
|
||||
placeholder: "ペイン内を検索",
|
||||
close: "検索を閉じる",
|
||||
|
||||
@@ -3,6 +3,12 @@ import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ko: TranslationResources = {
|
||||
paneFind: {
|
||||
searching: "검색 중…",
|
||||
loading: "불러오는 중…",
|
||||
failed: "실패",
|
||||
retry: "다시 시도",
|
||||
chatPosition: "메시지 내 {{current}} / {{total}}",
|
||||
|
||||
title: "찾기",
|
||||
placeholder: "패널에서 찾기",
|
||||
close: "찾기 닫기",
|
||||
|
||||
@@ -3,6 +3,12 @@ import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ptBR: TranslationResources = {
|
||||
paneFind: {
|
||||
searching: "Buscando…",
|
||||
loading: "Carregando…",
|
||||
failed: "Falhou",
|
||||
retry: "Tentar novamente",
|
||||
chatPosition: "{{current}} de {{total}} na mensagem",
|
||||
|
||||
title: "Buscar",
|
||||
placeholder: "Buscar no painel",
|
||||
close: "Fechar busca",
|
||||
|
||||
@@ -3,6 +3,12 @@ import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ru: TranslationResources = {
|
||||
paneFind: {
|
||||
searching: "Поиск…",
|
||||
loading: "Загрузка…",
|
||||
failed: "Ошибка",
|
||||
retry: "Повторить",
|
||||
chatPosition: "{{current}} из {{total}} в сообщении",
|
||||
|
||||
title: "Найти",
|
||||
placeholder: "Найти в панели",
|
||||
close: "Закрыть поиск",
|
||||
|
||||
@@ -3,6 +3,12 @@ import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const zhCN: TranslationResources = {
|
||||
paneFind: {
|
||||
searching: "搜索中…",
|
||||
loading: "加载中…",
|
||||
failed: "失败",
|
||||
retry: "重试",
|
||||
chatPosition: "消息内第 {{current}} 项,共 {{total}} 项",
|
||||
|
||||
title: "查找",
|
||||
placeholder: "在窗格中查找",
|
||||
close: "关闭查找",
|
||||
|
||||
Reference in New Issue
Block a user