You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/14 12:23:13 UTC
(maka) branch main updated: fix(desktop): stop canceled history searches and clear stale loading (#5256)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 34a6245ae7 fix(desktop): stop canceled history searches and clear stale loading (#5256)
34a6245ae7 is described below
commit 34a6245ae7b6518c3d87a06911a71ee80463f94c
Author: Xiao Liu <li...@qq.com>
AuthorDate: Mon Sep 14 20:23:07 2026 +0800
fix(desktop): stop canceled history searches and clear stale loading (#5256)
* fix(desktop): stop canceled history searches and clear stale loading
Propagate request cancellation through the palette, preload fan-out, and
Desktop Host IPC. Close the search's transcript handle and settle its UI
promise immediately so old work cannot retain the spinner. Keep searches
on their owning candidate to prevent replay after cancellation.
Provide search through its feature services and Desktop adapter. Add
regression tests for modal lifecycle, fan-out, scanning, and reconnect.
Fixes #5255
Generated-by: Codex
* fix(desktop): cancel history searches when renderer crashes
Abort pending history searches on render-process-gone as well as destroyed, and release both WebContents listeners when searches finish or cancel.
Cover crashes during session open and transcript reads, same-WebContents recovery, late replies, and listener cleanup.
Generated-by: Codex
---
apps/desktop/renderer-architecture.json | 22 +-
.../__tests__/multi-host-thread-search.test.ts | 37 ++-
.../runtime-host-guest-ipc-preload.test.ts | 26 ++-
.../__tests__/runtime-host-search-ipc-main.test.ts | 257 ++++++++++++++++++++-
.../src/main/runtime-host-search-ipc-main.ts | 92 ++++++--
apps/desktop/src/preload/bridge-contract.d.ts | 2 +
.../src/preload/multi-host-thread-search.ts | 45 +++-
apps/desktop/src/preload/preload.ts | 51 ++--
apps/desktop/src/renderer/app-shell.tsx | 2 +-
.../composition/desktop-feature-services.tsx | 7 +-
apps/desktop/src/renderer/features/search/index.ts | 22 ++
.../renderer/features/search/services-context.tsx | 33 +++
.../{ => features/search}/use-shell-search.ts | 8 +-
.../platform/desktop/create-search-services.ts | 30 +++
docs/astryx-surface-file-inventory.md | 3 +-
docs/astryx-surface-file-inventory.paths | 1 +
.../src/__tests__/search-modal-lifecycle.test.tsx | 129 +++++++++++
packages/ui/src/search-modal.tsx | 47 +++-
18 files changed, 721 insertions(+), 93 deletions(-)
diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index cffc1fc10f..68e0fa92f3 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -227,7 +227,6 @@
"src/renderer/use-shell-live-turn.ts",
"src/renderer/use-shell-memory-pill.ts",
"src/renderer/use-shell-resume.ts",
- "src/renderer/use-shell-search.ts",
"src/renderer/use-stable-actions.ts",
"src/renderer/use-system-ui-locale.ts",
"src/renderer/use-task-submission-readiness.ts",
@@ -698,7 +697,7 @@
"nonTriviaTokens": 1273
},
"src/renderer/app-shell.tsx": {
- "importDeclarations": 68,
+ "importDeclarations": 67,
"bridgePaths": {
"window.maka.attachments": 1,
"window.maka.attachments.readBytes": 1,
@@ -800,6 +799,7 @@
"./features/conversation/index.js": 1,
"./features/goals": 1,
"./features/module-hub": 1,
+ "./features/search/index.js": 1,
"./features/session-collaboration": 1,
"./features/session-navigation": 1,
"./features/session-settings": 1,
@@ -838,7 +838,6 @@
"./use-shell-live-turn": 1,
"./use-shell-memory-pill": 1,
"./use-shell-resume": 1,
- "./use-shell-search": 1,
"./use-stable-actions": 1,
"./use-system-ui-locale": 1,
"./use-task-submission-readiness": 1,
@@ -853,7 +852,7 @@
"@maka/ui": 1,
"react": 1
},
- "importSpecifiers": 104,
+ "importSpecifiers": 103,
"nonTriviaTokens": 13381
},
"src/renderer/use-app-shell-composer-quotes.ts": {
@@ -4227,21 +4226,6 @@
"react": 1
}
},
- "src/renderer/use-shell-search.ts": {
- "bridgePaths": {
- "window.maka.search.thread": 1
- },
- "environmentCapabilities": {},
- "hookCalls": {
- "useState": 2
- },
- "lifecycleMethods": {},
- "unresolvedDependencies": 0,
- "actionFactories": [],
- "dependencyPaths": {
- "react": 1
- }
- },
"src/renderer/use-stable-actions.ts": {
"bridgePaths": {},
"environmentCapabilities": {},
diff --git a/apps/desktop/src/main/__tests__/multi-host-thread-search.test.ts b/apps/desktop/src/main/__tests__/multi-host-thread-search.test.ts
index 6946b680de..bbe8a8bdf6 100644
--- a/apps/desktop/src/main/__tests__/multi-host-thread-search.test.ts
+++ b/apps/desktop/src/main/__tests__/multi-host-thread-search.test.ts
@@ -20,7 +20,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { SearchError, SearchResult } from '@maka/core/search';
-import { collectThreadSearchResponses } from '../../preload/multi-host-thread-search.js';
+import { deferred } from '@maka/core/test-only/async-primitives';
+import { collectThreadSearchResponses, createThreadSearchClient } from '../../preload/multi-host-thread-search.js';
const RESULT: SearchResult = {
source: 'thread',
@@ -74,3 +75,37 @@ test('shares a bounded result window across ready Hosts', async () => {
[result('A1'), result('B1'), result('A2')],
);
});
+
+test('canceling before Host discovery finishes never dispatches the abandoned search', async () => {
+ const scopes = deferred<readonly string[]>();
+ const calls: string[] = [];
+ const client = createThreadSearchClient({
+ scopes: () => scopes.promise,
+ search: async (scope: string) => { calls.push(scope); return []; },
+ cancel: async () => {},
+ });
+ const task = client.thread({ source: 'thread', query: 'old', limit: 10 }, 'old');
+ await client.cancelThread('old');
+ assert.deepEqual(await task, { ok: false, reason: 'aborted', message: 'History search was aborted.' });
+ scopes.resolve(['a', 'b']);
+ await Promise.resolve();
+ assert.deepEqual(calls, []);
+});
+
+test('canceling a multi-Host query reaches every dispatched Host without waiting for search results', async () => {
+ const started = deferred<void>();
+ const cancelled: string[] = [];
+ let count = 0;
+ const client = createThreadSearchClient({
+ scopes: async () => ['a', 'b'],
+ search: async () => { if (++count === 2) started.resolve(); return new Promise<never>(() => {}); },
+ cancel: async (scope, requestId) => { cancelled.push(`${scope}:${requestId}`); },
+ });
+ const task = client.thread({ source: 'thread', query: 'old', limit: 10 }, 'old');
+ await started.promise;
+ await client.cancelThread('old');
+ assert.equal((await task as SearchError).reason, 'aborted');
+ assert.deepEqual(cancelled, ['a:old', 'b:old']);
+ await client.cancelThread('old');
+ assert.equal(cancelled.length, 2);
+});
diff --git a/apps/desktop/src/main/__tests__/runtime-host-guest-ipc-preload.test.ts b/apps/desktop/src/main/__tests__/runtime-host-guest-ipc-preload.test.ts
index 6946f70675..3c6d5f7926 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-guest-ipc-preload.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-guest-ipc-preload.test.ts
@@ -23,6 +23,7 @@ import { fileURLToPath } from 'node:url';
import { runInNewContext } from 'node:vm';
import test from 'node:test';
import { build } from 'esbuild';
+import { deferred } from '@maka/core/test-only/async-primitives';
import type { MakaBridge } from '../../preload/bridge-contract.js';
test('onboarding and workspace search never fan out Owner IPC to a ready Guest', async () => {
@@ -35,9 +36,13 @@ test('onboarding and workspace search never fan out Owner IPC to a ready Guest',
profileName: 'Shared', profileKind: 'remote', profileAccess: 'session_guest', readiness: 'ready',
};
const calls: { channel: string; hostId?: string }[] = [];
+ const pendingSearch = deferred<never>();
+ const searchStarted = deferred<void>();
+ let searchRequestId: unknown;
+ let cancelRequestId: unknown;
const ipcRenderer = {
on() {}, off() {}, send() {},
- async invoke(channel: string, scope?: { hostId?: string }) {
+ async invoke(channel: string, scope?: { hostId?: string }, payload?: unknown, requestId?: unknown) {
calls.push({ channel, hostId: scope?.hostId });
// A missing Guest handler must not hold up either aggregate.
if (scope?.hostId === guest.hostId) throw new Error('Guest has no Owner handler');
@@ -56,8 +61,14 @@ test('onboarding and workspace search never fan out Owner IPC to a ready Guest',
createdAt: 1, activityAt: 1, name: 'Shared Session', status: 'active',
},
}];
- case 'sessions:list':
- case 'search:thread': return [];
+ case 'sessions:list': return [];
+ case 'search:thread':
+ searchRequestId = requestId;
+ searchStarted.resolve();
+ return pendingSearch.promise;
+ case 'search:thread:cancel':
+ cancelRequestId = payload;
+ return;
default: throw new Error('Unexpected channel: ' + channel);
}
},
@@ -83,9 +94,14 @@ test('onboarding and workspace search never fan out Owner IPC to a ready Guest',
assert.equal(snapshot.sessions.length, 1);
assert.equal(snapshot.sessions[0]!.shared, true);
assert.equal(snapshot.sessions[0]!.name, 'Shared Session');
- await bridge.search.thread({ query: 'hello', limit: 10, source: 'thread' });
+ const search = bridge.search.thread({ query: 'hello', limit: 10, source: 'thread' }, 'search-owner');
+ await searchStarted.promise;
+ await bridge.search.cancelThread('search-owner');
+ assert.equal((await search as { reason: string }).reason, 'aborted');
+ assert.equal(searchRequestId, 'search-owner');
+ assert.equal(cancelRequestId, searchRequestId);
assert.equal(calls.some(call => call.hostId === guest.hostId), false);
- for (const channel of ['onboarding:getSnapshot', 'search:thread']) {
+ for (const channel of ['onboarding:getSnapshot', 'search:thread', 'search:thread:cancel']) {
assert.equal(calls.filter(call => call.channel === channel && call.hostId === owner.hostId).length, 1);
}
});
diff --git a/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts
index 8ddd3391a7..714ead4d8c 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts
@@ -19,12 +19,18 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
+import { EventEmitter } from 'node:events';
+import { deferred } from '@maka/core/test-only/async-primitives';
import { createDefaultRuntimePolicy } from '@maka/core/runtime-policy';
+import type { StoredMessage } from '@maka/core/session';
+import type { SearchError, SearchResult } from '@maka/core/search';
import type { SessionCatalogProjection } from '@maka/runtime-host/protocol';
-import type { IpcHandler } from '../ipc-reconnect-policy.js';
+import type { IpcHandler, ReconnectableReadIpcMain } from '../ipc-reconnect-policy.js';
import type { DesktopRuntimeHostClient } from '../runtime-host-client.js';
import { registerRuntimeHostSearchIpc } from '../runtime-host-search-ipc-main.js';
+import { RuntimeHostReconnectingIpcMain } from '../runtime-host-reconnecting-ipc-main.js';
import { desktopSessionKey } from '../../shared/runtime-host-identity.js';
+import { createThreadSearchClient } from '../../preload/multi-host-thread-search.js';
// The catalog hands search a composed Desktop key, not a bare Runtime Host id.
// Naming it here is what makes the passthrough in runtime-host-search-ipc-main
@@ -137,6 +143,255 @@ test('a Runtime Host transcript failure yields no content hit', async () => {
);
});
+test('canceling a search closes its transcript and stops reading further sessions', async () => {
+ const handlers = new Map<string, IpcHandler>();
+ const firstRead = deferred<void>();
+ const transcript = deferred<never[]>();
+ const opened: string[] = [];
+ let closed = 0;
+ registerRuntimeHostSearchIpc({
+ ipcMain: {
+ handle: (channel, listener) => { handlers.set(channel, listener); },
+ handleReconnectableRead: (channel, listener) => { handlers.set(channel, listener); },
+ },
+ client: searchClient({
+ listSessions: async () => [catalogSession('a', 'First'), catalogSession('b', 'Second')],
+ openSession: async (id) => {
+ opened.push(id);
+ return {
+ loadTranscript: () => { firstRead.resolve(); return transcript.promise; },
+ close: async () => { closed += 1; transcript.resolve([]); },
+ } as never;
+ },
+ }),
+ });
+ const sender = new EventEmitter();
+ const event = { sender } as Parameters<IpcHandler>[0];
+ const search = handlers.get('search:thread')!;
+ const task = search(event, { source: 'thread', query: 'missing', limit: 10 }, 'first');
+ await firstRead.promise;
+ const cancel = handlers.get('search:thread:cancel');
+ assert.ok(cancel, 'Desktop must expose cancellation to the preload');
+ // Another window cannot cancel this window's request, even with its id.
+ await cancel({ sender: new EventEmitter() } as Parameters<IpcHandler>[0], 'first');
+ assert.equal(closed, 0);
+ await cancel(event, 'first');
+ const outcome = await task;
+ assert.equal(outcome.reason, 'aborted');
+ assert.deepEqual(opened, ['a']);
+ assert.equal(closed, 1);
+ assert.equal(sender.listenerCount('destroyed'), 0);
+ assert.equal(sender.listenerCount('render-process-gone'), 0);
+});
+
+test('a canceled search is not replayed on a replacement Host candidate', async (t) => {
+ const handlers = new Map<string, IpcHandler>();
+ const router = new RuntimeHostReconnectingIpcMain({
+ handle: (channel, listener) => { handlers.set(channel, listener); },
+ removeHandler: (channel) => { handlers.delete(channel); },
+ });
+ t.after(() => router.close());
+ const event = { sender: new EventEmitter() } as Parameters<IpcHandler>[0];
+ const scope = { hostId: 'host', targetEpoch: 'epoch' };
+ const started = deferred<void>();
+ const transcript = deferred<never[]>();
+ let opened = 0;
+ const client = searchClient({
+ listSessions: async () => [catalogSession('a', 'First')],
+ openSession: async () => {
+ opened += 1;
+ return {
+ loadTranscript: () => { started.resolve(); return transcript.promise; },
+ close: async () => {},
+ } as never;
+ },
+ });
+ const registerCandidate = () => {
+ const target = router.createTarget('epoch');
+ const scoped = (listener: IpcHandler): IpcHandler =>
+ (event, _scope, ...args) => listener(event, ...args);
+ const ipcMain: ReconnectableReadIpcMain = {
+ handle: (channel, listener) => target.handle(channel, scoped(listener)),
+ handleReconnectableRead: (channel, listener) => target.handleReconnectableRead!(channel, scoped(listener)),
+ };
+ registerRuntimeHostSearchIpc({ ipcMain, client });
+ target.completeRegistration();
+ return target;
+ };
+ const first = registerCandidate();
+ router.activate('epoch');
+ const task = handlers.get('search:thread')!(event, scope,
+ { source: 'thread', query: 'missing', limit: 10 }, 'old');
+ await started.promise;
+ await handlers.get('search:thread:cancel')!(event, scope, 'old');
+ first.removeHandler('search:thread');
+ first.removeHandler('search:thread:cancel');
+ registerCandidate();
+ transcript.resolve([]);
+ const outcome = await task;
+ assert.equal(opened, 1, 'reconnecting must not revive a canceled transcript scan');
+ assert.equal(outcome.reason, 'aborted');
+});
+
+for (const lifecycleEvent of ['destroyed', 'render-process-gone'] as const) {
+ test(`${lifecycleEvent} stops a pending search before reading its opening transcript`, async () => {
+ const handlers = new Map<string, IpcHandler>();
+ const opening = deferred<never>();
+ const started = deferred<void>();
+ const opened: string[] = [];
+ let closed = 0;
+ let read = 0;
+ registerRuntimeHostSearchIpc({
+ ipcMain: {
+ handle: (channel, listener) => { handlers.set(channel, listener); },
+ handleReconnectableRead: (channel, listener) => { handlers.set(channel, listener); },
+ },
+ client: searchClient({
+ listSessions: async () => [catalogSession('a', 'First'), catalogSession('b', 'Second')],
+ openSession: async (id) => { opened.push(id); started.resolve(); return opening.promise; },
+ }),
+ });
+ const sender = new EventEmitter();
+ const task = handlers.get('search:thread')!({ sender } as Parameters<IpcHandler>[0],
+ { source: 'thread', query: 'missing', limit: 10 }, 'request');
+ await started.promise;
+ sender.emit(lifecycleEvent, {}, { reason: 'crashed', exitCode: 1 });
+ opening.resolve({
+ loadTranscript: async () => { read += 1; return []; },
+ close: async () => { closed += 1; },
+ } as never);
+ assert.equal((await task).reason, 'aborted');
+ assert.deepEqual(opened, ['a']);
+ assert.equal(read, 0);
+ assert.equal(closed, 1);
+ assert.equal(sender.listenerCount('destroyed'), 0);
+ assert.equal(sender.listenerCount('render-process-gone'), 0);
+ });
+}
+
+test('renderer crash closes an in-flight search and allows a new search on the same WebContents', async () => {
+ const handlers = new Map<string, IpcHandler>();
+ const started = deferred<void>();
+ const transcript = deferred<StoredMessage[]>();
+ const opened: string[] = [];
+ const closed: string[] = [];
+ registerRuntimeHostSearchIpc({
+ ipcMain: {
+ handle: (channel, listener) => { handlers.set(channel, listener); },
+ handleReconnectableRead: (channel, listener) => { handlers.set(channel, listener); },
+ },
+ client: searchClient({
+ listSessions: async () => [catalogSession('a', 'First'), catalogSession('b', 'Second')],
+ openSession: async (id) => {
+ opened.push(id);
+ const abandoned = opened.length === 1;
+ return {
+ loadTranscript: async () => {
+ if (abandoned) { started.resolve(); return transcript.promise; }
+ return [{ type: 'user', id: 'message', turnId: 'turn', ts: 1, text: 'latest match' }];
+ },
+ close: async () => { closed.push(id); },
+ } as never;
+ },
+ }),
+ });
+ const sender = new EventEmitter();
+ const event = { sender } as Parameters<IpcHandler>[0];
+ const search = handlers.get('search:thread')!;
+ const abandoned = search(event, { source: 'thread', query: 'missing', limit: 10 }, 'old');
+ await started.promise;
+ sender.emit('render-process-gone', {}, { reason: 'crashed', exitCode: 1 });
+ assert.deepEqual(closed, ['a'], 'a crash closes the transcript before its pending reply arrives');
+ assert.equal(sender.listenerCount('destroyed'), 0);
+ assert.equal(sender.listenerCount('render-process-gone'), 0);
+
+ // Recovery reloads the same WebContents while the abandoned read is pending.
+ const latest = await search(event, { source: 'thread', query: 'latest', limit: 10 }, 'new');
+ assert.equal(expectResults(latest).length, 2);
+ assert.deepEqual(opened, ['a', 'a', 'b']);
+ assert.deepEqual(closed, ['a', 'a', 'b']);
+ assert.equal(sender.listenerCount('destroyed'), 0);
+ assert.equal(sender.listenerCount('render-process-gone'), 0);
+
+ transcript.resolve([]);
+ assert.equal((await abandoned).reason, 'aborted');
+ assert.deepEqual(opened, ['a', 'a', 'b'], 'a late reply must not resume the abandoned scan');
+ assert.deepEqual(closed, ['a', 'a', 'b'], 'each search handle closes exactly once');
+ sender.emit('destroyed');
+ assert.deepEqual(closed, ['a', 'a', 'b']);
+});
+
+test('rapid replacement and dismissal stop each old scan while the latest query still completes', async () => {
+ const handlers = new Map<string, IpcHandler>();
+ const sender = new EventEmitter();
+ const event = { sender } as Parameters<IpcHandler>[0];
+ const scans: Array<{ closed: number; page: ReturnType<typeof deferred<StoredMessage[]>> }> = [];
+ let started = deferred<void>();
+ let completeLatest = false;
+ registerRuntimeHostSearchIpc({
+ ipcMain: {
+ handle: (channel, listener) => { handlers.set(channel, listener); },
+ handleReconnectableRead: (channel, listener) => { handlers.set(channel, listener); },
+ },
+ client: searchClient({
+ listSessions: async () => [catalogSession('a', 'First'), catalogSession('b', 'Second')],
+ openSession: async () => {
+ const scan = { closed: 0, page: deferred<StoredMessage[]>() };
+ scans.push(scan);
+ return {
+ loadTranscript: async () => {
+ started.resolve();
+ if (completeLatest) return [
+ { type: 'user', id: 'message', turnId: 'turn', ts: 1, text: 'latest match' },
+ ];
+ return scan.page.promise;
+ },
+ close: async () => { scan.closed += 1; },
+ } as never;
+ },
+ }),
+ });
+ const outcomes: Array<Promise<SearchResult[] | SearchError>> = [];
+ const client = createThreadSearchClient({
+ scopes: async () => ['host'],
+ search: (_scope, request, requestId) => {
+ const task = Promise.resolve(handlers.get('search:thread')!(event, request, requestId));
+ outcomes.push(task);
+ return task;
+ },
+ cancel: async (_scope, requestId) => {
+ await handlers.get('search:thread:cancel')!(event, requestId);
+ },
+ });
+ for (let index = 0; index < 10; index += 1) {
+ started = deferred<void>();
+ const task = client.thread({ source: 'thread', query: `old-${index}`, limit: 10 }, `request-${index}`);
+ await started.promise;
+ await client.cancelThread(`request-${index}`);
+ const outcome = await task;
+ assert.equal(Array.isArray(outcome), false);
+ if (!Array.isArray(outcome)) assert.equal(outcome.reason, 'aborted');
+ assert.equal(scans[index]!.closed, 1, 'cancellation closes a read even before its reply arrives');
+ assert.equal(sender.listenerCount('destroyed'), 0, 'canceled reads release window listeners immediately');
+ assert.equal(sender.listenerCount('render-process-gone'), 0, 'canceled reads release crash listeners immediately');
+ }
+ assert.equal(scans.length, 10, 'each old query stops at its first transcript');
+
+ completeLatest = true;
+ const latest = await client.thread({ source: 'thread', query: 'latest', limit: 10 }, 'latest');
+ assert.equal(expectResults(latest).length, 2);
+ assert.equal(scans.length, 12);
+
+ // Replies for the abandoned reads can arrive after the new result. They
+ // must not resume scanning further sessions or close the handles twice.
+ for (const scan of scans.slice(0, 10)) scan.page.resolve([]);
+ await Promise.all(outcomes);
+ assert.equal(scans.length, 12);
+ assert.ok(scans.every((scan) => scan.closed === 1));
+ assert.equal(sender.listenerCount('destroyed'), 0);
+ assert.equal(sender.listenerCount('render-process-gone'), 0);
+});
+
function expectResults(outcome: unknown): Array<{
summary?: string;
target?: {
diff --git a/apps/desktop/src/main/runtime-host-search-ipc-main.ts b/apps/desktop/src/main/runtime-host-search-ipc-main.ts
index 194188998a..564e27e2a5 100644
--- a/apps/desktop/src/main/runtime-host-search-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-search-ipc-main.ts
@@ -20,9 +20,9 @@
import type { SearchResult } from '@maka/core/search';
import { runThreadSearch } from '@maka/core/thread-search';
import type { DesktopRuntimeHostClient } from './runtime-host-client.js';
+import type { WebContents } from 'electron';
import { toDesktopHostSessionSummary } from './runtime-host-session-catalog-ipc-main.js';
import {
- handleReconnectableRead,
readWithFallback,
type ReconnectableReadIpcMain,
} from './ipc-reconnect-policy.js';
@@ -38,25 +38,77 @@ interface RuntimeHostSearchIpcDeps {
export function registerRuntimeHostSearchIpc(
deps: RuntimeHostSearchIpcDeps,
): void {
- handleReconnectableRead(deps.ipcMain, 'search:thread', async (_event, request: unknown) => {
- const result = await runThreadSearch(request, {
- listSessions: async () =>
- (await deps.client.listSessions()).map(toDesktopHostSessionSummary),
- readMessages: (sessionId) =>
- readWithFallback(async () => {
- const session = await deps.client.openSession(sessionId);
- try {
- return await session.loadTranscript();
- } finally {
- await session.close();
- }
- }, null),
- getPrivacyContext: async () => ({
- incognitoActive: (await deps.client.queryRuntimePolicy()).policy.privacy
- .incognitoActive,
- }),
- });
- return result.ok ? result.results.map(projectDesktopSearchResult) : result;
+ const pending = new WeakMap<WebContents, Map<string, AbortController>>();
+ // A search belongs to this candidate and its cancellation registry. Replaying
+ // it on a replacement would revive work the renderer has already abandoned.
+ deps.ipcMain.handle('search:thread', async (event, request: unknown, requestId?: unknown) => {
+ if (requestId !== undefined && (typeof requestId !== 'string' || !requestId || requestId.length > 128)) {
+ return { ok: false, reason: 'invalid_query', message: 'Invalid search request identity.' };
+ }
+ const controller = new AbortController();
+ const release = () => {
+ event.sender?.removeListener('destroyed', abort);
+ event.sender?.removeListener('render-process-gone', abort);
+ if (typeof requestId === 'string') {
+ const requests = pending.get(event.sender);
+ if (requests?.get(requestId) === controller) requests.delete(requestId);
+ }
+ };
+ const abort = () => controller.abort();
+ if (typeof requestId === 'string') {
+ let requests = pending.get(event.sender);
+ if (!requests) {
+ requests = new Map();
+ pending.set(event.sender, requests);
+ }
+ requests.get(requestId)?.abort();
+ requests.set(requestId, controller);
+ }
+ controller.signal.addEventListener('abort', release, { once: true });
+ event.sender?.once('destroyed', abort);
+ // Crash recovery reloads the same WebContents without destroying it.
+ event.sender?.once('render-process-gone', abort);
+ try {
+ const result = await runThreadSearch(request, {
+ listSessions: async () =>
+ (await deps.client.listSessions()).map(toDesktopHostSessionSummary),
+ readMessages: (sessionId, signal) =>
+ readWithFallback(async () => {
+ if (signal?.aborted) return null;
+ const session = await deps.client.openSession(sessionId);
+ // This handle belongs only to this search. Closing it immediately
+ // stops the Host client's paginated transcript reader before its
+ // next page, including when a read is currently awaiting a reply.
+ let closeTask: Promise<void> | undefined;
+ const close = () => (closeTask ??= session.close());
+ const cancelRead = () => { void close().catch(() => undefined); };
+ signal?.addEventListener('abort', cancelRead, { once: true });
+ try {
+ if (signal?.aborted) return null;
+ return await session.loadTranscript();
+ } finally {
+ signal?.removeEventListener('abort', cancelRead);
+ await close();
+ }
+ }, null),
+ getPrivacyContext: async () => ({
+ incognitoActive: (await deps.client.queryRuntimePolicy()).policy.privacy
+ .incognitoActive,
+ }),
+ }, { abortSignal: controller.signal });
+ return result.ok ? result.results.map(projectDesktopSearchResult) : result;
+ } catch (error) {
+ if (!controller.signal.aborted) throw error;
+ return { ok: false, reason: 'aborted', message: 'History search was aborted.' };
+ } finally {
+ controller.signal.removeEventListener('abort', release);
+ release();
+ }
+ });
+ // Register after search so requests waiting for a candidate start before
+ // their queued cancellations are delivered.
+ deps.ipcMain.handle('search:thread:cancel', (event, requestId: unknown) => {
+ if (typeof requestId === 'string') pending.get(event.sender)?.get(requestId)?.abort();
});
}
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index ee55bbe010..f8d15013a3 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -1597,10 +1597,12 @@ export interface MakaBridge {
search: {
thread(
request: SearchRequest,
+ requestId?: string,
): Promise<
| SearchResult[]
| { ok: false; reason: SearchErrorReason; message: string }
>;
+ cancelThread(requestId: string): Promise<void>;
};
openAiCodex: {
getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget): Promise<DesktopOAuthAuthorizationStartResult>;
diff --git a/apps/desktop/src/preload/multi-host-thread-search.ts b/apps/desktop/src/preload/multi-host-thread-search.ts
index fa39e862d4..611b1bbca0 100644
--- a/apps/desktop/src/preload/multi-host-thread-search.ts
+++ b/apps/desktop/src/preload/multi-host-thread-search.ts
@@ -17,7 +17,50 @@
* under the License.
*/
-import type { SearchError, SearchResult } from '@maka/core/search';
+import type { SearchError, SearchRequest, SearchResult } from '@maka/core/search';
+
+/** Owns a request across Host discovery, fan-out, and cancellation. */
+export function createThreadSearchClient<Scope>(input: {
+ scopes(): Promise<readonly Scope[]>;
+ search(scope: Scope, request: SearchRequest, requestId: string): Promise<SearchResult[] | SearchError>;
+ cancel(scope: Scope, requestId: string): Promise<unknown>;
+}) {
+ const pending = new Map<string, { cancel(): Promise<void> }>();
+ return {
+ async thread(request: SearchRequest, requestId: string = crypto.randomUUID()): Promise<SearchResult[] | SearchError> {
+ if (pending.has(requestId)) throw new Error('Search request is already active');
+ let scopes: readonly Scope[] = [];
+ let cancelled = false;
+ let finishCancel!: (error: SearchError) => void;
+ const cancellation = new Promise<SearchError>((resolve) => { finishCancel = resolve; });
+ pending.set(requestId, {
+ async cancel() {
+ cancelled = true;
+ finishCancel({ ok: false, reason: 'aborted', message: 'History search was aborted.' });
+ await Promise.all(scopes.map((scope) => input.cancel(scope, requestId)));
+ },
+ });
+ try {
+ return await Promise.race([
+ (async () => {
+ scopes = await input.scopes();
+ if (cancelled) return cancellation;
+ return collectThreadSearchResponses(
+ scopes.map((scope) => input.search(scope, request, requestId)),
+ request.limit,
+ );
+ })(),
+ cancellation,
+ ]);
+ } finally {
+ pending.delete(requestId);
+ }
+ },
+ async cancelThread(requestId: string): Promise<void> {
+ await pending.get(requestId)?.cancel();
+ },
+ };
+}
export async function collectThreadSearchResponses(
requests: readonly Promise<SearchResult[] | SearchError>[],
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 31810bcee7..677e6f7dff 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -37,7 +37,7 @@ import {
} from '@maka/runtime-host/profile-kind';
import { AttachmentIngestBlockedError } from '@maka/core/attachments';
import { encodeIngestItems } from './attachment-ingest-payload.js';
-import { collectThreadSearchResponses } from './multi-host-thread-search.js';
+import { createThreadSearchClient } from './multi-host-thread-search.js';
import { releaseSessionObservation } from './session-observation-release.js';
import {
resolveDesktopWorkHubCoordinationCreateScope,
@@ -162,7 +162,7 @@ import type { OrchestrationMode } from '@maka/core/orchestration';
import type { TurnOrchestration, SessionListFilter, RegenerateTurnInput } from '@maka/core/runtime-inputs';
import type { PlanSessionState } from '@maka/core/plan';
-import type { SearchErrorReason, SearchRequest, SearchResult } from '@maka/core/search';
+import type { SearchErrorReason, SearchResult } from '@maka/core/search';
import type {
SessionCatalogSummary,
SessionChangedEvent,
@@ -3189,34 +3189,29 @@ const makaBridge = {
return invokeSessionRuntimeHost('attachments:readBytes', sessionId, artifactId);
},
},
- search: {
+ search: createThreadSearchClient({
// Search each ready Owner Host independently; Guests cannot search a workspace.
- // Remote queries use that Host's authenticated connection, never telemetry.
- async thread(request: SearchRequest): Promise<SearchResult[] | { ok: false; reason: SearchErrorReason; message: string }> {
- const scopes = await readyOwnerRuntimeHostScopes();
- return collectThreadSearchResponses(
- scopes.map(async (scope) => {
- const result = await ipcRenderer.invoke('search:thread', scope, request) as
- | SearchResult[]
- | { ok: false; reason: SearchErrorReason; message: string };
- return Array.isArray(result)
- ? result.map((entry) =>
- entry.target?.kind === 'thread'
- ? {
- ...entry,
- target: {
- ...entry.target,
- sessionId: recordRuntimeHostSessionScope(scope, entry.target.sessionId),
- },
- }
- : entry,
- )
- : result;
- }),
- request.limit,
- );
+ scopes: readyOwnerRuntimeHostScopes,
+ async search(scope, request, requestId) {
+ const result = await ipcRenderer.invoke('search:thread', scope, request, requestId) as
+ | SearchResult[]
+ | { ok: false; reason: SearchErrorReason; message: string };
+ return Array.isArray(result)
+ ? result.map((entry) =>
+ entry.target?.kind === 'thread'
+ ? {
+ ...entry,
+ target: {
+ ...entry.target,
+ sessionId: recordRuntimeHostSessionScope(scope, entry.target.sessionId),
+ },
+ }
+ : entry,
+ )
+ : result;
},
- },
+ cancel: (scope, requestId) => ipcRenderer.invoke('search:thread:cancel', scope, requestId),
+ }),
// Browser-assisted Codex account bridge. NEVER returns raw OAuth
// credentials; the renderer only sees account state and action results.
//
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index fe9d1e654e..42ac570674 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -128,7 +128,7 @@ import { getShellRemainingCopy } from './locales/shell-remaining-copy.js';
import { getDesktopConversationCopy } from './locales/conversation-copy';
import { ErrorBoundary } from './error-boundary';
import { useShellAppearance } from './use-shell-appearance';
-import { useShellSearch } from './use-shell-search';
+import { useShellSearch } from './features/search/index.js';
import { useSessionSettingIntent } from './features/session-settings';
import { deriveStaleSessionIds } from './stale-sessions';
import { pendingSessionView } from './pending-session-view';
diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx
index 9306b56766..222936583d 100644
--- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx
+++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx
@@ -31,6 +31,7 @@ import { ModuleHubServicesProvider } from '../features/module-hub';
import { RuntimeHostManagementServicesProvider } from '../features/runtime-host-management';
import { SessionCollaborationServicesProvider } from '../features/session-collaboration';
import { SessionNavigationServicesProvider } from '../features/session-navigation';
+import { SearchServicesProvider } from '../features/search/index.js';
import { SessionSettingsServicesProvider } from '../features/session-settings';
import { TaskEntryServicesProvider } from '../features/task-entry';
import { WorkbarServicesProvider } from '../features/workbar';
@@ -41,6 +42,7 @@ import { createDesktopModuleHubServices } from '../platform/desktop/create-modul
import { createDesktopRuntimeHostManagementServices } from '../platform/desktop/create-runtime-host-management-services';
import { createDesktopSessionCollaborationServices } from '../platform/desktop/create-session-collaboration-services';
import { createDesktopSessionNavigationServices } from '../platform/desktop/create-session-navigation-services';
+import { createDesktopSearchServices } from '../platform/desktop/create-search-services.js';
import { SessionBundleServicesProvider } from '../features/session-bundle';
import { createDesktopSessionBundleServices } from '../platform/desktop/create-session-bundle-services.js';
import { createDesktopSessionSettingsServices } from '../platform/desktop/create-session-settings-services';
@@ -65,6 +67,7 @@ export function createDesktopFeatureServices() {
runtimeHostManagement: createDesktopRuntimeHostManagementServices(),
sessionCollaboration: createDesktopSessionCollaborationServices(),
sessionNavigation: createDesktopSessionNavigationServices(),
+ search: createDesktopSearchServices(),
sessionBundle: createDesktopSessionBundleServices(),
sessionSettings: createDesktopSessionSettingsServices(),
taskEntry: createDesktopTaskEntryServices(),
@@ -91,7 +94,9 @@ export function DesktopFeatureServicesProvider(props: {
<ConversationServicesProvider services={props.services.conversation}>
<WorkHubServicesProvider services={props.services.workHub}>
<SessionBundleServicesProvider services={props.services.sessionBundle}>
- {props.children}
+ <SearchServicesProvider services={props.services.search}>
+ {props.children}
+ </SearchServicesProvider>
</SessionBundleServicesProvider>
</WorkHubServicesProvider>
</ConversationServicesProvider>
diff --git a/apps/desktop/src/renderer/features/search/index.ts b/apps/desktop/src/renderer/features/search/index.ts
new file mode 100644
index 0000000000..de15f5dbb9
--- /dev/null
+++ b/apps/desktop/src/renderer/features/search/index.ts
@@ -0,0 +1,22 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export { SearchServicesProvider } from './services-context.js';
+export { useShellSearch } from './use-shell-search.js';
+export type { SearchServices } from './services-context.js';
diff --git a/apps/desktop/src/renderer/features/search/services-context.tsx b/apps/desktop/src/renderer/features/search/services-context.tsx
new file mode 100644
index 0000000000..4d34f75339
--- /dev/null
+++ b/apps/desktop/src/renderer/features/search/services-context.tsx
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { SearchError, SearchRequest, SearchResult } from '@maka/core/search';
+import { createServicesContext } from '../../application/contracts/feature-services.js';
+
+export interface SearchServices {
+ searchThread(request: SearchRequest, requestId?: string): Promise<SearchResult[] | SearchError>;
+ cancelThread(requestId: string): Promise<void>;
+}
+
+const { Provider, useServices } = createServicesContext<SearchServices>('SearchServicesProvider');
+
+export const SearchServicesProvider = Provider;
+export function useSearchServices(): SearchServices {
+ return useServices();
+}
diff --git a/apps/desktop/src/renderer/use-shell-search.ts b/apps/desktop/src/renderer/features/search/use-shell-search.ts
similarity index 91%
rename from apps/desktop/src/renderer/use-shell-search.ts
rename to apps/desktop/src/renderer/features/search/use-shell-search.ts
index 5cff78c2a7..7a6f0ba227 100644
--- a/apps/desktop/src/renderer/use-shell-search.ts
+++ b/apps/desktop/src/renderer/features/search/use-shell-search.ts
@@ -17,7 +17,8 @@
* under the License.
*/
-import { useCallback, useMemo, useState } from 'react';
+import { useCallback, useState } from 'react';
+import { useSearchServices } from './services-context.js';
type OpenSessionInChat = (sessionId: string, turnId?: string, sequence?: number) => void;
@@ -51,10 +52,7 @@ export function useShellSearch({ openSessionInChatRef }: { openSessionInChatRef:
setSearchModalOpen(false);
}
- const searchModalDeps = useMemo(
- () => ({ searchThread: (request: Parameters<typeof window.maka.search.thread>[0]) => window.maka.search.thread(request) }),
- [],
- );
+ const searchModalDeps = useSearchServices();
const searchModalOnNavigate = useCallback((sessionId: string, turnId?: string, sequence?: number) => {
openSessionInChatRef.current(sessionId, turnId, sequence);
diff --git a/apps/desktop/src/renderer/platform/desktop/create-search-services.ts b/apps/desktop/src/renderer/platform/desktop/create-search-services.ts
new file mode 100644
index 0000000000..5e33e7dc52
--- /dev/null
+++ b/apps/desktop/src/renderer/platform/desktop/create-search-services.ts
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { MakaBridge } from '../../../preload/bridge-contract.js';
+import type { SearchServices } from '../../features/search/index.js';
+
+export function createDesktopSearchServices(
+ bridge: Pick<MakaBridge, 'search'> = window.maka,
+): SearchServices {
+ return {
+ searchThread: (...args: Parameters<MakaBridge['search']['thread']>) => bridge.search.thread(...args),
+ cancelThread: (requestId: string) => bridge.search.cancelThread(requestId),
+ };
+}
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index fc9235c400..55ece72b13 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports).
Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
-**Totals:** 277 files — blocker 0, reimplementation 0, polish 4, aligned 273.
+**Totals:** 278 files — blocker 0, reimplementation 0, polish 4, aligned 274.
## Exclusions (explicit)
@@ -73,6 +73,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx` | dialog-overlay | Badge, Banner, Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, MoreMenu, SegmentedControl, SegmentedControlItem, Selector, Switch, Text, TextArea, TextInput, Tooltip | aligned — uses Astryx (Badge, Banner, Button, Dialog, DialogHeader, HStack, Layout, LayoutContent) | aligned |
| `apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-profile-pairing-actions.tsx` | other | Button, MoreMenu | aligned — uses Astryx (Button, MoreMenu) | aligned |
| `apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-resource-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, Layout, LayoutContent, LayoutFooter, Spinner, Text | aligned — uses Astryx (Button, Dialog, DialogHeader, Layout, LayoutContent, LayoutFooter, Spinner, Text) | aligned |
+| `apps/desktop/src/renderer/features/search/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/session-bundle/export-tree.tsx` | shell-chrome-or-panel | Badge, Button, EmptyState, HStack, VStack | aligned — uses Astryx (Badge, Button, EmptyState, HStack, VStack) | aligned |
| `apps/desktop/src/renderer/features/session-bundle/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx` | shell-chrome-or-panel | Banner, Button, HStack, SegmentedControl, SegmentedControlItem, VStack | aligned — uses Astryx (Banner, Button, HStack, SegmentedControl, SegmentedControlItem, VStack) | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index 222b8df18c..66e55bfa33 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -44,6 +44,7 @@ apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-conne
apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx
apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-profile-pairing-actions.tsx
apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-resource-dialog.tsx
+apps/desktop/src/renderer/features/search/services-context.tsx
apps/desktop/src/renderer/features/session-bundle/export-tree.tsx
apps/desktop/src/renderer/features/session-bundle/services-context.tsx
apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx
diff --git a/packages/ui/src/__tests__/search-modal-lifecycle.test.tsx b/packages/ui/src/__tests__/search-modal-lifecycle.test.tsx
new file mode 100644
index 0000000000..64ab6970f7
--- /dev/null
+++ b/packages/ui/src/__tests__/search-modal-lifecycle.test.tsx
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { act, createElement } from 'react';
+import { parseHTML } from 'linkedom';
+import { deferred } from '@maka/core/test-only/async-primitives';
+import type { SearchRequest, SearchResult } from '@maka/core/search';
+
+// Exercise the real palette's async transitions: source-only tests cannot see
+// a canceled request retaining its optimistic input and loading indicator.
+test('dismissed and superseded searches stop holding the palette busy', async () => {
+ const previous = Object.getOwnPropertyDescriptors(globalThis);
+ const { document, window } = parseHTML('<html><body><div id="root"></div></body></html>');
+ document.oninput = null;
+ window.scrollTo = () => {};
+ window.getComputedStyle = () => ({
+ direction: 'ltr', writingMode: 'horizontal-tb', getPropertyValue: () => '',
+ }) as unknown as CSSStyleDeclaration;
+ Object.assign(window.HTMLElement.prototype, {
+ showModal(this: HTMLElement) { this.setAttribute('open', ''); },
+ close(this: HTMLElement) { this.removeAttribute('open'); },
+ scrollIntoView() {},
+ });
+ const globals = {
+ document, window, HTMLElement: window.HTMLElement,
+ matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
+ requestAnimationFrame: () => 1, cancelAnimationFrame() {},
+ CSS: { supports: () => false, escape: (value: string) => value },
+ IS_REACT_ACT_ENVIRONMENT: true,
+ };
+ Object.assign(globalThis, globals);
+ const { createRoot } = await import('react-dom/client');
+ const { SearchModal } = await import('../search-modal.js');
+ const { LocaleProvider } = await import('../locale-context.js');
+ const requests: ReturnType<typeof deferred<SearchResult[]>>[] = [];
+ const requestIds: string[] = [];
+ const cancelled: string[] = [];
+ const deps = {
+ searchThread: (_request: SearchRequest, requestId?: string) => {
+ assert.ok(requestId);
+ requestIds.push(requestId);
+ const request = deferred<SearchResult[]>();
+ requests.push(request);
+ return request.promise;
+ },
+ cancelThread: async (requestId: string) => { cancelled.push(requestId); },
+ };
+ const root = createRoot(document.getElementById('root')!);
+ let isOpen = true;
+ const navigate = () => {};
+ const onOpenChange = (open: boolean) => { isOpen = open; render(); };
+ const render = () => root.render(createElement(LocaleProvider, { locale: 'en', children:
+ createElement(SearchModal, {
+ isOpen, deps, onNavigateToSession: navigate, onOpenChange,
+ }),
+ }));
+ const input = () => document.querySelector<HTMLInputElement>('input')!;
+ const busy = () => document.querySelectorAll('[role="status"][aria-label="Loading"]').length;
+ const type = async (query: string) => act(async () => {
+ // Bypass React's value tracker just as a native input edit does.
+ Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!.call(input(), query);
+ input().dispatchEvent(new window.Event('input', { bubbles: true }));
+ });
+ try {
+ await act(async () => { render(); });
+ await type('old');
+ assert.equal(requests.length, 1);
+ assert.equal(busy(), 1);
+ await act(async () => {
+ input().dispatchEvent(Object.assign(new window.Event('keydown', { bubbles: true }), { key: 'Escape' }));
+ });
+ assert.equal(isOpen, false);
+ await act(async () => { isOpen = true; render(); });
+ assert.equal(busy(), 0, 'reopening must not wait for the dismissed request');
+ assert.equal(input().value, '');
+ assert.deepEqual(cancelled, [requestIds[0]]);
+
+ await type('older');
+ await type('maka');
+ assert.equal(requests.length, 3);
+ await act(async () => { requests[2]!.resolve([
+ { source: 'thread', title: 'Latest maka match', target: { kind: 'thread', sessionId: 'latest' } },
+ ]); });
+ assert.match(document.body.textContent ?? '', /Latest maka match/);
+ assert.equal(busy(), 0, 'completed results must not wait for the superseded request');
+ assert.equal(input().value, 'maka');
+ assert.deepEqual(cancelled, [requestIds[0], requestIds[1]]);
+
+ await act(async () => { requests[0]!.resolve([]); requests[1]!.resolve([]); });
+ assert.match(document.body.textContent ?? '', /Latest maka match/);
+ assert.equal(busy(), 0);
+
+ await type('programmatic close');
+ await act(async () => { isOpen = false; render(); });
+ await act(async () => { isOpen = true; render(); });
+ assert.equal(busy(), 0);
+ assert.equal(input().value, '');
+ assert.deepEqual(cancelled, [requestIds[0], requestIds[1], requestIds[3]]);
+
+ await type('unmount');
+ await act(async () => { root.unmount(); });
+ assert.deepEqual(cancelled, [requestIds[0], requestIds[1], requestIds[3], requestIds[4]]);
+ } finally {
+ await act(async () => { root.unmount(); });
+ for (const key of Object.keys(globals)) {
+ const descriptor = previous[key];
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
+ else Reflect.deleteProperty(globalThis, key);
+ }
+ }
+});
diff --git a/packages/ui/src/search-modal.tsx b/packages/ui/src/search-modal.tsx
index 804602b04a..7324253d8e 100644
--- a/packages/ui/src/search-modal.tsx
+++ b/packages/ui/src/search-modal.tsx
@@ -34,6 +34,7 @@ import { useUiLocale } from './locale-context.js';
interface SearchModalDeps {
searchThread(
request: SearchRequest,
+ requestId?: string,
): Promise<
SearchResult[] | {
ok: false;
@@ -41,6 +42,7 @@ interface SearchModalDeps {
message: string;
}
>;
+ cancelThread?(requestId: string): Promise<void>;
}
interface SearchItemAuxiliaryData {
@@ -51,6 +53,7 @@ type SearchItem = SearchableItem<SearchItemAuxiliaryData>;
interface ThreadSearchSourceInput {
searchThread?: SearchModalDeps['searchThread'];
+ cancelThread?: SearchModalDeps['cancelThread'];
canNavigate: boolean;
resultsLabel: string;
onQueryChange(query: string): void;
@@ -62,13 +65,19 @@ export function createThreadSearchSource(
input: ThreadSearchSourceInput,
): SearchSource<SearchItem> {
let generation = 0;
+ let cancelPending: (() => void) | undefined;
+ const cancel = () => {
+ generation += 1;
+ const pending = cancelPending;
+ cancelPending = undefined;
+ pending?.();
+ };
return {
bootstrap: () => [],
- cancel: () => {
- generation += 1;
- },
+ cancel,
search: async (query) => {
- const requestGeneration = ++generation;
+ cancel();
+ const requestGeneration = generation;
const trimmed = query.trim();
input.onQueryChange(trimmed);
if (!trimmed || !input.searchThread) {
@@ -76,13 +85,23 @@ export function createThreadSearchSource(
input.onItemsChange([]);
return [];
}
+ const requestId = crypto.randomUUID();
+ const cancelled = new Promise<undefined>((resolve) => {
+ cancelPending = () => {
+ // React's palette transition must finish even if the Host is slow
+ // or disconnected. Ignoring its eventual result alone leaves it busy.
+ resolve(undefined);
+ void input.cancelThread?.(requestId).catch((error) => {
+ console.error('[search] cancellation failed', error);
+ });
+ };
+ });
try {
- const response = await input.searchThread({
- source: 'thread',
- query: trimmed,
- limit: 10,
- });
- if (generation !== requestGeneration) return [];
+ const response = await Promise.race([
+ input.searchThread({ source: 'thread', query: trimmed, limit: 10 }, requestId),
+ cancelled,
+ ]);
+ if (generation !== requestGeneration || response === undefined) return [];
if (!Array.isArray(response)) {
console.error('[search] thread search failed', response);
input.onErrorChange({ reason: response.reason });
@@ -110,6 +129,8 @@ export function createThreadSearchSource(
input.onErrorChange({ reason: 'provider_error' });
input.onItemsChange([]);
return [];
+ } finally {
+ if (generation === requestGeneration) cancelPending = undefined;
}
},
};
@@ -170,6 +191,7 @@ export function SearchModal(props: {
() =>
createThreadSearchSource({
searchThread: props.deps?.searchThread,
+ cancelThread: props.deps?.cancelThread,
canNavigate: Boolean(props.onNavigateToSession),
resultsLabel: copy.resultsLabel,
onQueryChange: setActiveQuery,
@@ -183,6 +205,11 @@ export function SearchModal(props: {
[copy.resultsLabel, props.deps, props.onNavigateToSession],
);
+ useEffect(() => {
+ if (!props.isOpen) searchSource.cancel?.();
+ return () => searchSource.cancel?.();
+ }, [props.isOpen, searchSource]);
+
const emptySearchText = error
? searchErrorText(error.reason, copy)
: copy.empty;
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org