Compare commits

...
12 changed files with 403 additions and 27 deletions

No files matched your search

+1
View File
@@ -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',
+1
View File
@@ -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í',
+1
View File
@@ -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': 'Ветвление отсюда',
+1
View File
@@ -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': '从此处分叉',
+1
View File
@@ -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();
});
+57
View File
@@ -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);
},
+84 -15
View File
@@ -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"));
+11 -11
View File
@@ -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.