Keep initial search results at the top

Keep the result list at its start when delayed categories or chat summaries arrive. Preserve anchoring after the user scrolls. Add deterministic and native renderer regressions.
This commit is contained in:
CloudTianTian committed 2026-11-05 09:07:40 +08:00
1 parent 306052663a
commit 678f06cfbb
3 files changed
+103 -2

No files matched your search

+4 -2
View File
@@ -315,7 +315,7 @@ async function _loadVisibleChatSnippets() {
const anchor = body.querySelector('.search-result.active');
const rect = anchor?.getBoundingClientRect();
const bounds = body.getBoundingClientRect();
const visible = rect && rect.bottom > bounds.top && rect.top < bounds.bottom;
const visible = top > 0 && rect && rect.bottom > bounds.top && rect.top < bounds.bottom;
_renderSearchResults(query);
body.scrollTop = top;
const retained = visible && body.querySelector('.search-result.active');
@@ -362,7 +362,9 @@ async function _runProgressiveSearch(query, seq, startedAt, projectId, pending)
const anchor = body?.querySelector('.search-result.active');
const rect = anchor?.getBoundingClientRect();
const bounds = anchor ? body.getBoundingClientRect() : null;
const visible = rect && rect.bottom > bounds.top && rect.top < bounds.bottom;
// At the start of the list, late categories must not pull the viewport
// down to the row that happened to be selected by the first page.
const visible = top > 0 && rect && rect.bottom > bounds.top && rect.top < bounds.bottom;
_renderSearchResults(query);
if (body) {
body.scrollTop = top;
+67
View File
@@ -601,6 +601,73 @@ test('shows cold chat matches within the input latency budget while snippet read
}
});
for (const [language, width, height] of [['zh', 1280, 800], ['en', 900, 640]] as const) {
test(`keeps the start of search results visible when earlier categories arrive later (${language})`, async ({}, info) => {
const app = new OrkasTestApp(info, { configuredModel: false, seedLanguage: language });
try {
await app.launch();
await installEmptyLibrarySearch(app, pcRoot);
const ids: string[] = [];
for (let index = 0; index < 10; index++) {
const { task } = await app.invoke<any>('autoTasks.create', {
title: `Scrollmarker automation ${index}`, content: 'Scrollmarker ' + 'long summary '.repeat(30),
enabled: false, schedule: { type: 'daily', hour: 9, minute: 0 },
});
ids.push(task.id);
}
await app.electronApp!.evaluate(async (_, root) => {
const req = (process as any).mainModule.require.bind((process as any).mainModule);
const uid = req(`${root}/src/main/features/users.ts`).getActiveUserId();
const now = new Date().toISOString();
await req(`${root}/src/main/features/connectors/registry.ts`).upsert(uid, {
id: 'custom-scrollmarker', origin: 'custom', display_name: 'Scrollmarker connector',
transport: { kind: 'streamable-http', url: 'https://example.test/mcp' },
enabled_subtools: null, tools_cache: [], tools_cached_at: 0,
status: { kind: 'disconnected' }, created_at: now, updated_at: now,
});
}, pcRoot);
const page = app.page!;
await page.setViewportSize({ width, height });
// Hold only the automation files, leaving the real connector search,
// stream/preload/IPC and renderer free to publish the first category.
await app.electronApp!.evaluate((_, input) => {
const req = (process as any).mainModule.require.bind((process as any).mainModule);
const uid = req(`${input.root}/src/main/features/users.ts`).getActiveUserId();
const layout = req(`${input.root}/src/main/util/project-layout.ts`);
const files = new Set(input.ids.map(id => layout.findAutoTaskLocation(uid, id).configFile));
const fsp = req('node:fs/promises'), originalRead = fsp.readFile;
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
fsp.readFile = async function(target: any, ...args: any[]) {
if (files.has(String(target))) await gate;
return originalRead.call(this, target, ...args);
};
req('node:module').syncBuiltinESMExports();
(globalThis as any).__releaseScrollmarker = () => {
release(); fsp.readFile = originalRead; req('node:module').syncBuiltinESMExports();
};
}, { root: pcRoot, ids });
await page.locator('#sidebar-search-btn').click();
await page.locator('#search-input').fill('Scrollmarker');
await page.locator('#search-input').press('Enter');
const results = page.locator('#search-body .search-result');
await expect(results).toHaveCount(1);
await expect(results).toContainText('Scrollmarker connector');
expect(await page.locator('#search-body').evaluate(body => body.scrollTop)).toBe(0);
await app.electronApp!.evaluate(() => (globalThis as any).__releaseScrollmarker());
await expect(results).toHaveCount(11);
await expect(page.locator('#search-body .search-loading-spinner')).toHaveCount(0);
expect(await page.locator('#search-body').evaluate(body => body.scrollHeight > body.clientHeight)).toBe(true);
expect(await page.locator('#search-body').evaluate(body => body.scrollTop)).toBe(0);
await expect(results.first()).toBeInViewport();
await expect(results.first()).toContainText('Scrollmarker automation 0');
await expect(page.locator('#search-input')).toBeFocused();
await page.screenshot({ path: info.outputPath(`search-top-${language}-${width}.png`) });
await page.locator('#search-input').press('Escape');
} finally { await app.dispose(); }
});
}
test('paints local hits before held semantic retrieval and preserves selection when body results arrive', async ({}, info) => {
const app = new OrkasTestApp(info, { configuredModel: false, seedLanguage: 'en' });
try {
@@ -630,6 +630,38 @@ it('hydrates only visible chat rows and requests newly visible rows after scroll
} finally { vi.useRealTimers(); vi.restoreAllMocks(); }
});
it.each([0, 40])('keeps the reading position at scrollTop=%i when summaries above the selection grow', async initialTop => {
vi.useFakeTimers();
try {
const { body, input, context, streams } = progressiveHarness(domHarness());
vi.spyOn(MiniElement.prototype, 'getBoundingClientRect').mockImplementation(function(this: MiniElement) {
if (this.id === 'search-body') return { top: 0, bottom: 200, left: 0, right: 0, width: 0, height: 200 };
const index = Number(this.dataset.idx);
const growth = index > 0 && body.querySelector('.search-result-snippet') ? 120 : 0;
const top = index * 80 + growth - body.scrollTop;
return { top, bottom: top + 80, left: 0, right: 0, width: 0, height: 80 };
});
input.value = 'needle';
const rows = [0, 1].map(index => ({ kind: 'chat', cid: `c${index}`, msg_index: 0, conv_title: `Result ${index}` }));
const search = context._runSearchNow(input.value);
streams[0].finish({ results: rows, pending_kinds: [], failed_kinds: [] });
await search;
vm.runInContext('_searchActiveIdx = 1', context);
context._renderSearchResults(input.value);
body.scrollTop = initialTop;
const before = body.querySelector('.search-result.active').getBoundingClientRect().top;
const summaries = context._loadVisibleChatSnippets();
streams[1].finish({
results: rows.map((row, index) => ({ ...row, snippet_loaded: true, snippet: index === 0 ? 'needle summary' : '' })),
pending_kinds: [], failed_kinds: [],
});
await summaries;
expect(body.querySelector('.search-result-snippet').textContent).toBe('needle summary');
if (initialTop === 0) expect(body.scrollTop).toBe(0);
else expect(body.querySelector('.search-result.active').getBoundingClientRect().top).toBe(before);
} finally { vi.useRealTimers(); vi.restoreAllMocks(); }
});
it('paints completed modules while chats are held and retains selection when chat and Library results arrive', async () => {
const { body, context, streams } = progressiveHarness();