mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:31:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e061b89b43 | ||
|
|
a8b291b7cd |
No files matched your search
@@ -538,6 +538,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.searchSessions': 'Search titles and recent content…',
|
||||
'ai.chat.clearSessionSearch': 'Clear session search',
|
||||
'ai.chat.searchSessionsHint': 'Searches titles, prioritizing newer messages and entries. For long messages and tool outputs, only a limited portion at the beginning is searched. This is not full-text search.',
|
||||
'ai.chat.searchingSessions': 'Searching...',
|
||||
'ai.chat.noMatchingSessions': 'No matches in titles or recent retained content',
|
||||
'ai.chat.noSessions': 'No previous sessions',
|
||||
'ai.chat.forkFromHere': 'Fork from here',
|
||||
|
||||
@@ -529,6 +529,7 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.searchSessions': 'Buscar títulos y contenido reciente…',
|
||||
'ai.chat.clearSessionSearch': 'Borrar la búsqueda de sesiones',
|
||||
'ai.chat.searchSessionsHint': 'Busca en los títulos y da prioridad a los mensajes y entradas más recientes. De los mensajes largos y las salidas de herramientas, solo se busca una parte limitada del comienzo. No es una búsqueda de texto completo.',
|
||||
'ai.chat.searchingSessions': 'Buscando...',
|
||||
'ai.chat.noMatchingSessions': 'Sin coincidencias en títulos ni contenido reciente conservado',
|
||||
'ai.chat.noSessions': 'No hay sesiones anteriores',
|
||||
'ai.chat.forkFromHere': 'Bifurcar desde aquí',
|
||||
|
||||
@@ -485,6 +485,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.searchSessions': 'Поиск по заголовкам и недавнему содержимому…',
|
||||
'ai.chat.clearSessionSearch': 'Очистить поиск сессий',
|
||||
'ai.chat.searchSessionsHint': 'Поиск по заголовкам с приоритетом более новых сообщений и записей. В длинных сообщениях и результатах инструментов поиск охватывает только ограниченную начальную часть. Это не полнотекстовый поиск.',
|
||||
'ai.chat.searchingSessions': 'Поиск...',
|
||||
'ai.chat.noMatchingSessions': 'Нет совпадений в заголовках и недавнем сохранённом содержимом',
|
||||
'ai.chat.noSessions': 'Предыдущих сессий нет',
|
||||
'ai.chat.forkFromHere': 'Ветвление отсюда',
|
||||
|
||||
@@ -538,6 +538,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.searchSessions': '搜索标题和近期内容…',
|
||||
'ai.chat.clearSessionSearch': '清空会话搜索',
|
||||
'ai.chat.searchSessionsHint': '搜索标题,并优先搜索较新的消息和条目;超长消息及工具输出仅搜索开头的有限部分。这不是全文搜索。',
|
||||
'ai.chat.searchingSessions': '搜索中…',
|
||||
'ai.chat.noMatchingSessions': '标题和近期保留的内容中没有匹配结果',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
'ai.chat.forkFromHere': '从此处分叉',
|
||||
|
||||
@@ -528,6 +528,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.searchSessions': '搜尋標題和近期內容…',
|
||||
'ai.chat.clearSessionSearch': '清空會話搜尋',
|
||||
'ai.chat.searchSessionsHint': '搜尋標題,並優先搜尋較新的訊息與項目;超長訊息與工具輸出僅搜尋開頭的有限部分。這不是全文搜尋。',
|
||||
'ai.chat.searchingSessions': '搜尋中…',
|
||||
'ai.chat.noMatchingSessions': '標題和近期保留的內容中沒有符合的結果',
|
||||
'ai.chat.noSessions': '沒有歷史工作階段',
|
||||
'ai.chat.forkFromHere': '從此處分叉',
|
||||
|
||||
@@ -788,3 +788,44 @@ test("history alone bounds title pinyin while retaining literal title and body m
|
||||
assert.deepEqual(filterSessionHistory(sessions, "fuwuqi nginx"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "服务器 nginx"), sessions);
|
||||
});
|
||||
|
||||
test("cooperative search yields and preserves the synchronous matching and order", () => {
|
||||
const search = createSessionHistorySearch();
|
||||
const sessions = Array.from({ length: 90 }, (_, index) => createSession(
|
||||
String(index), `Session ${index}`, [{ content: index % 7 === 0 ? "needle 中文" : "ordinary body" }],
|
||||
));
|
||||
const expected = search.filterSessionHistory(sessions, "needle");
|
||||
const scan = search.createScan<typeof sessions[number]>("needle");
|
||||
assert.equal(scan.step(sessions), null, "a large cached search still yields between slices");
|
||||
let result: typeof sessions | null = null;
|
||||
for (let step = 0; step < 20 && result === null; step += 1) result = scan.step(sessions);
|
||||
assert.deepEqual(result, expected);
|
||||
scan.dispose();
|
||||
assert.equal(scan.step(sessions), null);
|
||||
});
|
||||
|
||||
test("cooperative search incorporates live replacements and removals without restarting stable work", () => {
|
||||
let reads = 0;
|
||||
const stable = Array.from({ length: 90 }, (_, index) => {
|
||||
const session = createSession(String(index), `Session ${index}`, []);
|
||||
session.messages = [{ get content() { reads += 1; return index % 7 === 0 ? "needle" : "ordinary"; } }];
|
||||
return session;
|
||||
});
|
||||
const search = createSessionHistorySearch();
|
||||
const scan = search.createScan<typeof stable[number]>("needle");
|
||||
let result: typeof stable | null = null;
|
||||
let sessions = stable;
|
||||
let steps = 0;
|
||||
while (result === null && steps < 30) {
|
||||
// The newest session changes on every slice, like an active stream.
|
||||
const streaming = createSession("streaming", `Streaming ${steps}`, [{ content: "needle" }]);
|
||||
sessions = [streaming, ...stable.slice(0, -1).reverse()];
|
||||
result = scan.step(sessions);
|
||||
steps += 1;
|
||||
}
|
||||
assert.ok(result, "a live stream cannot starve the other sessions");
|
||||
assert.ok(steps < 10, `stable work restarted (${steps} slices)`);
|
||||
assert.deepEqual(result, sessions.filter((session) => session.id === "streaming" || Number(session.id) % 7 === 0));
|
||||
assert.equal(reads, 89, "each retained stable session is indexed only once");
|
||||
scan.dispose();
|
||||
});
|
||||
@@ -349,6 +349,56 @@ function filterSessionHistoryStore<T extends SessionHistorySearchTarget>(
|
||||
}
|
||||
}
|
||||
|
||||
/** One query retains only match decisions while its indexes stay memory-bounded. */
|
||||
export type SessionHistorySearchScan<T extends SessionHistorySearchTarget> = {
|
||||
/** Null means more work remains; each call yields after a short bounded slice. */
|
||||
step: (sessions: readonly T[]) => T[] | null;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
function createSessionHistorySearchScan<T extends SessionHistorySearchTarget>(
|
||||
store: SessionHistorySearchStore,
|
||||
query: string,
|
||||
options: SessionHistorySearchOptions,
|
||||
): SessionHistorySearchScan<T> {
|
||||
const preparedQuery = prepareSearchQuery(truncateQueryForMatch(query.trim()));
|
||||
const matches = new Map<T, boolean>();
|
||||
let disposed = false;
|
||||
store.scanTouched.clear();
|
||||
return {
|
||||
step(sessions) {
|
||||
if (disposed) return null;
|
||||
const live = new Set<SessionHistorySearchTarget>(sessions);
|
||||
for (const session of matches.keys()) {
|
||||
if (!live.has(session)) matches.delete(session);
|
||||
}
|
||||
for (const session of store.scanTouched) {
|
||||
if (!live.has(session)) store.scanTouched.delete(session);
|
||||
}
|
||||
pruneSessionSearchIndexCache(store, sessions);
|
||||
const startedAt = performance.now();
|
||||
let processed = 0;
|
||||
for (const session of sessions) {
|
||||
if (matches.has(session)) continue;
|
||||
const index = getSessionSearchIndex(store, session, options.untitledLabel ?? '');
|
||||
matches.set(session, matchesSessionSearchIndex(preparedQuery, index));
|
||||
processed += 1;
|
||||
// A session already has a bounded searchable corpus. Also cap the
|
||||
// number per slice so a fast clock/cache cannot make a huge task.
|
||||
if (processed >= 16 || performance.now() - startedAt >= 4) return null;
|
||||
}
|
||||
pruneSessionSearchIndexCache(store, sessions);
|
||||
store.scanTouched.clear();
|
||||
return sessions.filter((session) => matches.get(session));
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
matches.clear();
|
||||
store.scanTouched.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One stateful search instance per consumer (one mounted session history
|
||||
* drawer). The cache lives in the returned instance's store instead of module
|
||||
@@ -362,6 +412,10 @@ export type SessionHistorySearch = {
|
||||
query: string,
|
||||
options?: SessionHistorySearchOptions,
|
||||
) => T[];
|
||||
createScan: <T extends SessionHistorySearchTarget>(
|
||||
query: string,
|
||||
options?: SessionHistorySearchOptions,
|
||||
) => SessionHistorySearchScan<T>;
|
||||
clearSessionHistorySearchCache: () => void;
|
||||
};
|
||||
|
||||
@@ -375,6 +429,9 @@ export function createSessionHistorySearch(): SessionHistorySearch {
|
||||
filterSessionHistory(sessions, query, options = {}) {
|
||||
return filterSessionHistoryStore(store, sessions, query, options);
|
||||
},
|
||||
createScan(query, options = {}) {
|
||||
return createSessionHistorySearchScan(store, query, options);
|
||||
},
|
||||
clearSessionHistorySearchCache() {
|
||||
clearSessionHistorySearchStore(store);
|
||||
},
|
||||
|
||||
@@ -1,24 +1,93 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { SessionHistorySearchTarget } from '../../domain/sessionHistorySearch';
|
||||
import {
|
||||
createSessionHistorySearch,
|
||||
type SessionHistorySearch,
|
||||
type SessionHistorySearchScan,
|
||||
} from './sessionHistorySearch';
|
||||
|
||||
/**
|
||||
* Owns one search instance per mounted session history drawer.
|
||||
*
|
||||
* The search index cache is bound to this instance (not module scope), so
|
||||
* several concurrently mounted drawers — one per terminal tab — each keep
|
||||
* their own indexes: pruning in one drawer never drops another drawer's
|
||||
* entries, and the unmount below releases only this drawer's store instead of
|
||||
* discarding the shared cache for every drawer. Cache keys are the session
|
||||
* objects themselves, so clearing on unmount drops all strong session
|
||||
* references together with the retained haystack characters.
|
||||
*/
|
||||
export function useSessionHistorySearch(): SessionHistorySearch {
|
||||
/** Search outside rendering, yielding between bounded slices for input/paint. */
|
||||
export function useSessionHistorySearch<T extends SessionHistorySearchTarget>(
|
||||
sessions: readonly T[],
|
||||
query: string,
|
||||
untitledLabel: string,
|
||||
): { filteredSessions: readonly T[]; isSearching: boolean } {
|
||||
const search = useMemo(() => createSessionHistorySearch(), []);
|
||||
const latest = useRef({ sessions, query, untitledLabel });
|
||||
const work = useRef<{
|
||||
query: string;
|
||||
untitledLabel: string;
|
||||
scan: SessionHistorySearchScan<T>;
|
||||
} | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [result, setResult] = useState<{
|
||||
sessions: readonly T[];
|
||||
query: string;
|
||||
untitledLabel: string;
|
||||
filteredSessions: T[];
|
||||
} | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
latest.current = { sessions, query, untitledLabel };
|
||||
}, [sessions, query, untitledLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
const cancel = () => {
|
||||
if (timer.current !== null) clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
work.current?.scan.dispose();
|
||||
work.current = null;
|
||||
};
|
||||
if (!query.trim()) {
|
||||
cancel();
|
||||
setResult(null);
|
||||
// Release deleted/replaced sessions without indexing any message bodies.
|
||||
search.filterSessionHistory(sessions, '');
|
||||
return;
|
||||
}
|
||||
if (work.current?.query !== query || work.current.untitledLabel !== untitledLabel) {
|
||||
cancel();
|
||||
work.current = { query, untitledLabel, scan: search.createScan<T>(query, { untitledLabel }) };
|
||||
}
|
||||
if (timer.current !== null) return;
|
||||
const job = work.current;
|
||||
const run = () => {
|
||||
timer.current = null;
|
||||
const current = latest.current;
|
||||
if (work.current !== job || current.query !== job.query || current.untitledLabel !== job.untitledLabel) return;
|
||||
const filteredSessions = job.scan.step(current.sessions);
|
||||
if (filteredSessions === null) {
|
||||
timer.current = setTimeout(run, 0);
|
||||
} else {
|
||||
setResult({ ...current, filteredSessions });
|
||||
}
|
||||
};
|
||||
// Even the first slice runs after the input's render/commit has returned.
|
||||
// Session updates reuse the in-flight query's match decisions, so one
|
||||
// streaming session cannot restart and starve a large history search.
|
||||
timer.current = setTimeout(run, 0);
|
||||
}, [sessions, query, untitledLabel, search]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (timer.current !== null) clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
work.current?.scan.dispose();
|
||||
work.current = null;
|
||||
search.clearSessionHistorySearchCache();
|
||||
}, [search]);
|
||||
return search;
|
||||
|
||||
const filteredSessions = useMemo(() => {
|
||||
if (!query.trim()) return sessions;
|
||||
if (result?.query !== query || result.untitledLabel !== untitledLabel) return [];
|
||||
if (result.sessions === sessions) return result.filteredSessions;
|
||||
// Keep only confirmed, unchanged objects in the current order while the
|
||||
// scheduled scan checks new/replaced sessions. Do not read message bodies
|
||||
// here, or make the whole list disappear on each streaming update.
|
||||
const confirmed = new Set(result.filteredSessions);
|
||||
return sessions.filter((session) => confirmed.has(session));
|
||||
}, [sessions, query, untitledLabel, result]);
|
||||
const sameQuery = result?.query === query && result.untitledLabel === untitledLabel;
|
||||
return {
|
||||
filteredSessions,
|
||||
isSearching: Boolean(query.trim()) && (!sameQuery || result.sessions !== sessions),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
|
||||
import type { AISession } from "../infrastructure/ai/types.ts";
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
|
||||
|
||||
test("history input yields before indexing long sessions and never publishes an obsolete query", async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const { SessionHistoryDrawer } = await import("./AIChatSessionHistoryDrawer.tsx");
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
|
||||
const { TooltipProvider } = await import("./ui/tooltip.tsx");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
t.after(async () => { await renderer.unmount(); env.cleanup(); });
|
||||
|
||||
let heartbeat = false;
|
||||
let readsBeforeHeartbeat = 0;
|
||||
let reads = 0;
|
||||
const body = "representative retained output and ordinary content ".repeat(400);
|
||||
let sessions: AISession[] = Array.from({ length: 180 }, (_, index) => ({
|
||||
id: `session-${index}`,
|
||||
title: `Session ${index}`,
|
||||
agentId: "catty",
|
||||
scope: { type: "terminal", targetId: "fixture" },
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
messages: Array.from({ length: 4 }, (_, part) => ({
|
||||
id: `message-${index}-${part}`,
|
||||
role: "assistant" as const,
|
||||
timestamp: 0,
|
||||
get content() {
|
||||
reads += 1;
|
||||
if (!heartbeat) readsBeforeHeartbeat += 1;
|
||||
return index === 179 && part === 3 ? "latest-match" : body;
|
||||
},
|
||||
})),
|
||||
}));
|
||||
const noop = () => {};
|
||||
const render = () => renderer.render(
|
||||
<I18nProvider locale="en"><TooltipProvider>
|
||||
<SessionHistoryDrawer sessions={sessions} activeSessionId={null} onSelect={noop} onDelete={noop} onClose={noop} />
|
||||
</TooltipProvider></I18nProvider>,
|
||||
);
|
||||
const query = async (value: string) => {
|
||||
const input = renderer.container.querySelector("input")!;
|
||||
Object.getOwnPropertyDescriptor(env.window.HTMLInputElement.prototype, "value")!.set!.call(input, value);
|
||||
await dispatchDomEvent(input, new env.window.Event("input", { bubbles: true }));
|
||||
assert.equal(input.value, value);
|
||||
};
|
||||
const waitFor = async (condition: () => boolean) => {
|
||||
for (let attempt = 0; attempt < 300 && !condition(); attempt += 1) {
|
||||
await runWithAct(() => new Promise((resolve) => setTimeout(resolve, 5)));
|
||||
}
|
||||
assert.ok(condition(), "search did not settle");
|
||||
};
|
||||
|
||||
await render();
|
||||
assert.equal(reads, 0, "blank query never indexes message bodies");
|
||||
setTimeout(() => { heartbeat = true; }, 0);
|
||||
await query("never-match");
|
||||
assert.equal(readsBeforeHeartbeat, 0, "typing must return to the event loop before indexing messages");
|
||||
await query("latest-match");
|
||||
await waitFor(() => renderer.container.querySelectorAll('[role="button"]').length === 1);
|
||||
assert.match(renderer.container.querySelector('[role="button"]')!.textContent!, /Session 179/);
|
||||
|
||||
// A streaming session changes while a large search is running. Keep progress
|
||||
// on unchanged sessions, search the replacement, and respect the new order.
|
||||
await query("new-live-match");
|
||||
sessions = [{ ...sessions[10], title: "New live match", messages: [{ id: "replacement", role: "assistant", content: "new-live-match", timestamp: 1 }] }, ...sessions.filter((session) => session.id !== "session-10" && session.id !== "session-179")];
|
||||
await render();
|
||||
await waitFor(() => renderer.container.querySelector('[role="button"]')?.textContent?.includes("New live match") === true);
|
||||
assert.equal(renderer.container.querySelectorAll('[role="button"]').length, 1);
|
||||
await query("");
|
||||
assert.equal(renderer.container.querySelectorAll('[role="button"]').length, 80);
|
||||
});
|
||||
|
||||
test("pending history updates immediately remove stale matches and retain current ordering", async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const { SessionHistoryDrawer } = await import("./AIChatSessionHistoryDrawer.tsx");
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
|
||||
const { TooltipProvider } = await import("./ui/tooltip.tsx");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
t.after(async () => { await renderer.unmount(); env.cleanup(); });
|
||||
const make = (id: string, matches: boolean): AISession => ({
|
||||
id, title: id, agentId: "catty", scope: { type: "terminal", targetId: "fixture" },
|
||||
createdAt: 0, updatedAt: 0,
|
||||
messages: [{ id: `${id}-message`, role: "assistant", content: matches ? "needle" : "ordinary", timestamp: 0 }],
|
||||
});
|
||||
const a = make("Match A", true);
|
||||
const b = make("Match B", true);
|
||||
const c = make("Match C", true);
|
||||
const rest = Array.from({ length: 100 }, (_, index) => make(`Other ${index}`, false));
|
||||
let sessions = [a, b, c, ...rest];
|
||||
let revision = 0;
|
||||
const commits: string[][] = [];
|
||||
const rowLabels = () => [...renderer.container.querySelectorAll('[role="button"]')]
|
||||
.map((row) => row.querySelector("span")?.textContent ?? "");
|
||||
const noop = () => {};
|
||||
const Frame = ({ items, version }: { items: AISession[]; version: number }) => {
|
||||
React.useLayoutEffect(() => {
|
||||
if (version > 0) commits.push(rowLabels());
|
||||
}, [items, version]);
|
||||
return <SessionHistoryDrawer sessions={items} activeSessionId={null} onSelect={noop} onDelete={noop} onClose={noop} />;
|
||||
};
|
||||
const render = () => renderer.render(<I18nProvider locale="en"><TooltipProvider>
|
||||
<Frame items={sessions} version={revision} />
|
||||
</TooltipProvider></I18nProvider>);
|
||||
const settle = async (expected: string[]) => {
|
||||
for (let attempt = 0; attempt < 200 && renderer.container.querySelector('[aria-busy="true"]'); attempt += 1) {
|
||||
await runWithAct(() => new Promise((resolve) => setTimeout(resolve, 5)));
|
||||
}
|
||||
assert.deepEqual(rowLabels(), expected);
|
||||
};
|
||||
await render();
|
||||
const input = renderer.container.querySelector("input")!;
|
||||
Object.getOwnPropertyDescriptor(env.window.HTMLInputElement.prototype, "value")!.set!.call(input, "needle");
|
||||
await dispatchDomEvent(input, new env.window.Event("input", { bubbles: true }));
|
||||
await settle(["Match A", "Match B", "Match C"]);
|
||||
|
||||
revision += 1;
|
||||
sessions = [c, a, b, ...rest];
|
||||
await render();
|
||||
assert.deepEqual(commits.at(-1), ["Match C", "Match A", "Match B"], "pending results follow the current list order in the very first commit");
|
||||
await settle(["Match C", "Match A", "Match B"]);
|
||||
|
||||
revision += 1;
|
||||
sessions = [c, make("Match A", false), ...rest];
|
||||
await render();
|
||||
assert.deepEqual(commits.at(-1), ["Match C"], "deleted and replaced objects cannot remain selectable; unchanged matches stay visible");
|
||||
await settle(["Match C"]);
|
||||
|
||||
revision += 1;
|
||||
sessions = [make("Match D", true), ...sessions];
|
||||
await render();
|
||||
assert.deepEqual(commits.at(-1), ["Match C"]);
|
||||
await settle(["Match D", "Match C"]);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import React from "react";
|
||||
|
||||
import type { AISession } from "../infrastructure/ai/types.ts";
|
||||
import { MESSAGES_BY_LOCALE } from "../application/i18n/messages.ts";
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "./test-support/renderReactDom.tsx";
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
|
||||
|
||||
test("history drawer filters, pages, clears and explains the bounded search in every locale", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
@@ -51,6 +51,10 @@ test("history drawer filters, pages, clears and explains the bounded search in e
|
||||
const setValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!;
|
||||
setValue.call(input, query);
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
for (let attempt = 0; attempt < 100 && renderer.container.querySelector('[aria-busy="true"]'); attempt += 1) {
|
||||
await runWithAct(() => new Promise((resolve) => setTimeout(resolve, 5)));
|
||||
}
|
||||
assert.equal(renderer.container.querySelector('[aria-busy="true"]'), null, "search must settle");
|
||||
};
|
||||
const loadMore = () => [...renderer.container.querySelectorAll("button")]
|
||||
.find((button) => button.textContent?.includes("Load more"));
|
||||
|
||||
@@ -37,15 +37,10 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const searchHintId = useId();
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
// One cache instance per mounted drawer: caches of concurrently mounted
|
||||
// drawers stay independent (no shared state to prune or discard across tabs).
|
||||
const search = useSessionHistorySearch();
|
||||
|
||||
const filteredSessions = React.useMemo(
|
||||
// The localized fallback below is displayed for untitled sessions
|
||||
// (row label), so it must be indexed for search as the displayed title.
|
||||
() => search.filterSessionHistory(sessions, searchQuery, { untitledLabel: t('ai.chat.untitled') }),
|
||||
[sessions, searchQuery, t, search],
|
||||
const { filteredSessions, isSearching } = useSessionHistorySearch(
|
||||
sessions,
|
||||
searchQuery,
|
||||
t('ai.chat.untitled'),
|
||||
);
|
||||
|
||||
// Reset the render batch when the list scope or query changes so matching
|
||||
@@ -103,7 +98,12 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
{t('ai.chat.searchSessionsHint')}
|
||||
</p>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<ScrollArea className="flex-1" aria-busy={isSearching}>
|
||||
{isSearching && (
|
||||
<p role="status" className="px-4 py-2 text-[12px] text-muted-foreground/60">
|
||||
{t('ai.chat.searchingSessions')}
|
||||
</p>
|
||||
)}
|
||||
<div className="px-3">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
@@ -111,7 +111,7 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
{t('ai.chat.noSessions')}
|
||||
</p>
|
||||
</div>
|
||||
) : filteredSessions.length === 0 ? (
|
||||
) : filteredSessions.length === 0 && !isSearching ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-[13px] text-muted-foreground/40">
|
||||
{t('ai.chat.noMatchingSessions')}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Chat history search responsiveness
|
||||
|
||||
The search introduced in #3601 ran its entire bounded-content scan inside the
|
||||
history drawer render. The index cache limits retained memory, but histories
|
||||
larger than that cache still rebuilt older indexes on each keystroke. A changing
|
||||
streamed conversation also refreshed the session list and repeated that scan.
|
||||
|
||||
Search now runs in scheduled slices of at most 16 sessions or approximately
|
||||
4 ms. The existing per-session text limits, matching rules, index-memory bounds
|
||||
and session order remain unchanged. A query change cancels the previous scan;
|
||||
streaming updates keep match decisions for unchanged sessions and recheck the
|
||||
replacement objects, so ongoing output cannot repeatedly restart the search.
|
||||
The drawer shows a searching status and clears obsolete-query results until the
|
||||
current query finishes. While the session list changes, only still-present,
|
||||
unchanged matched objects stay visible, in the current list order; removed or
|
||||
replaced objects cannot remain selectable until the next slice. Clearing the
|
||||
query restores the unfiltered history
|
||||
immediately and releases the search's previous results.
|
||||
|
||||
## Verification
|
||||
|
||||
A separate Electron window mounted the actual before/after history drawer with
|
||||
500 synthetic conversations, each containing about 64,000 retained characters.
|
||||
No user conversations, terminals or provider calls were used. The same input
|
||||
and ten updates to a single conversation produced:
|
||||
|
||||
| Work | Before | After |
|
||||
| --- | ---: | ---: |
|
||||
| Input handler return | 352.7 ms | 4.0 ms |
|
||||
| Single conversation update | 319.4–323.3 ms | 1.49–1.90 ms |
|
||||
| Final results for the fixture query | 1 matching conversation | Same conversation |
|
||||
|
||||
The old input/update tasks each blocked for over 300 ms; the repaired search
|
||||
produced no task of 50 ms or longer in this run. These are development-fixture
|
||||
measurements on one machine, not a guarantee for every device or history size.
|
||||
Native input, empty results and the matching conversation were also checked in
|
||||
the rendered window.
|
||||
|
||||
The drawer regression drives the actual input event and verifies that a queued
|
||||
event-loop heartbeat runs before any retained message is indexed. It fails on
|
||||
the previous implementation (720 synchronous message reads) and passes after
|
||||
the repair. Further tests cover obsolete queries, live replacements/removals,
|
||||
continuous-stream progress, result order and the existing matching limits.
|
||||
|
||||
```sh
|
||||
node --test --import tsx \
|
||||
components/AIChatSessionHistoryDrawer.test.tsx \
|
||||
components/AIChatSessionHistoryDrawer.responsiveness.test.tsx \
|
||||
application/state/sessionHistorySearch.test.ts
|
||||
```
|
||||
|
||||
## Pending-result freshness
|
||||
|
||||
A follow-up review found that the first repair could temporarily show results
|
||||
from the previous session snapshot. A second Electron comparison used the
|
||||
initial repair (`a8b291b7c`) and the reconciled version. Removing or replacing the
|
||||
matching session left it selectable in the initial repair's first commit; the
|
||||
reconciled version immediately removed it. Reversing the current list initially
|
||||
kept session 0 first; it now immediately shows session 499 first. Unchanged
|
||||
confirmed matches remain visible while changed objects are checked, so a stream
|
||||
does not blank every result. This check compares object references and ordering
|
||||
without rereading retained message text. Input remained about 4 ms for the same
|
||||
500-conversation fixture.
|
||||
Reference in new issue
Block a user