diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 1e81935..3ac371c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -25,6 +25,7 @@ import { } from "./search-performance"; import type { MeasuredResponse, SearchLane } from "./search-performance"; import { displayPath } from "./path-display"; +import { ResultSelection } from "./selection-state"; import { AiRevision, aiConfigurationProblem, @@ -146,6 +147,7 @@ let displayedLiteralQuery = literalQuery(""); let displayedCodeScope = false; let explainedSelection = ""; let selected = -1; +const resultSelection = new ResultSelection(); let requestId = 0; let timer: ReturnType; let indexingActive = false; @@ -700,10 +702,11 @@ element("ai-citation").addEventListener("click", () => { block: "nearest", }); }); -function select(index: number, focus = false) { +function select(index: number, focus = false, userChoice = true) { if (!results.length) return; selected = Math.max(0, Math.min(index, results.length - 1)); const result = results[selected]; + if (userChoice) resultSelection.choose(result.path); list.querySelectorAll("button").forEach((button, i) => { button.classList.toggle("selected", i === selected); button.setAttribute("aria-current", String(i === selected)); @@ -923,7 +926,6 @@ function applyResults( ) return; const renderStarted = performance.now(); - const selectedPath = results[selected]?.path; const resultsHadFocus = list.contains(document.activeElement); shownLane = lane; displayedRequestId = request.id; @@ -932,9 +934,10 @@ function applyResults( results = found; selected = -1; render(); - const preserved = results.findIndex((result) => result.path === selectedPath); - if (preserved >= 0) select(preserved, resultsHadFocus); - else if (results.length) select(0, resultsHadFocus); + const preserved = resultSelection.resolve( + results.map((result) => result.path), + ); + if (preserved >= 0) select(preserved, resultsHadFocus, false); if (!found.length) refreshEmptySearchState(); request.measurement.displayed( lane, @@ -1064,14 +1067,18 @@ function refreshIndexedSearch() { query.value.trim() !== previous.query ) return; + const refreshStarted = performance.now(); clearTimeout(hybridTimer); lexicalLane.clear(); hybridLane.clear(); querySemanticFailed = false; shownLane = "lexical"; - // Keep the original input/first-response timing while rejecting responses - // captured before the index changed, even when the query text stays identical. - const refreshed: SearchRequest = { ...previous, id: ++requestId }; + const refreshed: SearchRequest = { + ...previous, + id: ++requestId, + started: refreshStarted, + measurement: previous.measurement.freshForRefresh(refreshStarted), + }; latestSearch = refreshed; submitSearch(refreshed, "lexical"); if (canSearchSemantically()) { @@ -1126,6 +1133,7 @@ async function search(inputAt = performance.now()) { shownLane = "lexical"; const value = query.value.trim(); if (!value) { + resultSelection.begin(""); element("performance-panel").hidden = true; results = []; displayedLiteralQuery = literalQuery(""); @@ -1140,10 +1148,12 @@ async function search(inputAt = performance.now()) { return; } try { + const filters = captureFilters(); + resultSelection.begin(JSON.stringify([value, filters])); const request: SearchRequest = { id: requestId, query: value, - filters: captureFilters(), + filters, started: performance.now(), measurement: new SearchMeasurement( inputAt, diff --git a/apps/desktop/src/search-performance.ts b/apps/desktop/src/search-performance.ts index d931fd0..7fc3219 100644 --- a/apps/desktop/src/search-performance.ts +++ b/apps/desktop/src/search-performance.ts @@ -35,8 +35,15 @@ interface Attempt { failed: boolean; ended_at: number; } +interface InputTimingSummary { + debounce_ms: number; + first_results_ms: number | null; + latest_displayed_ms: number | null; + hybrid_final_results_ms: number | null; +} export class SearchMeasurement { + private originalInput: Readonly | null = null; debug: boolean; inputAt: number; scheduledAt: number; @@ -67,6 +74,24 @@ export class SearchMeasurement { this.debug = debug; this.context = { ...context }; } + /** Filesystem updates are independent requests, not elapsed user idle time. */ + freshForRefresh(now: number): SearchMeasurement { + const refreshed = new SearchMeasurement(now, now, this.debug, this.context); + // Preserve one fixed-size numeric snapshot, never an ancestor trace/history. + // Pending responses on the old request cannot rewrite this snapshot. + refreshed.originalInput = Object.freeze({ + ...(this.originalInput ?? { + debounce_ms: Math.max(0, this.scheduledAt - this.inputAt), + first_results_ms: this.firstDisplayMs, + latest_displayed_ms: this.displays.at(-1)?.elapsed_ms ?? null, + hybrid_final_results_ms: + [...this.displays] + .reverse() + .find((display) => display.lane === "hybrid")?.elapsed_ms ?? null, + }), + }); + return refreshed; + } submitted(lane: SearchLane, at: number) { if (!this.submissions.has(lane)) this.submissions.set(lane, at); this.latestSubmission.set(lane, at); @@ -140,6 +165,8 @@ export class SearchMeasurement { export() { return { version: 1, + trigger: this.originalInput ? "filesystem_refresh" : "user_search", + original_input: this.originalInput ? { ...this.originalInput } : null, context: this.context, clock_note: "UI monotonic elapsed times; native command-relative spans. Nested spans and parallel lanes must not be summed.", diff --git a/apps/desktop/src/selection-state.ts b/apps/desktop/src/selection-state.ts new file mode 100644 index 0000000..d79d403 --- /dev/null +++ b/apps/desktop/src/selection-state.ts @@ -0,0 +1,24 @@ +/** Preserve the user's result choice while progressive indexing hides it briefly. */ +export class ResultSelection { + private scope: string | null = null; + private intendedPath: string | null = null; + + begin(scope: string) { + if (scope !== this.scope) { + this.scope = scope; + this.intendedPath = null; + } + } + + choose(path: string) { + this.intendedPath = path; + } + + resolve(paths: readonly string[]): number { + if (!paths.length) return -1; + if (this.intendedPath === null) this.intendedPath = paths[0]; + const intended = paths.indexOf(this.intendedPath); + // A temporary fallback is presentation, not a new user choice. + return intended >= 0 ? intended : 0; + } +} diff --git a/apps/desktop/tests/search-performance.test.mjs b/apps/desktop/tests/search-performance.test.mjs index bafa32c..47ed733 100644 --- a/apps/desktop/tests/search-performance.test.mjs +++ b/apps/desktop/tests/search-performance.test.mjs @@ -100,3 +100,64 @@ test('an in-flight attempt keeps its captured submission when a refresh queues', assert.equal(attempts[1].retry_wait_ms, 0); assert.notEqual(attempts[0].submission, attempts[1].submission); }); + +test('filesystem refresh excludes long idle time and retains original input timings separately', () => { + const original = new SearchMeasurement(100, 160, true, { device: 'cpu' }); + original.submitted('lexical', 160); + finish(original, 'lexical', 165, 177, native, false); + original.displayed('lexical', 177, 180, 2); + original.submitted('hybrid', 510); + finish(original, 'hybrid', 510, 522, native, false); + original.displayed('hybrid', 522, 524, 3); + const before = original.export(); + const refreshed = original.freshForRefresh(83500); + refreshed.submitted('lexical', 83500); + finish(refreshed, 'lexical', 83502, 83514, native, false); + refreshed.displayed('lexical', 83514, 83515, 3); + const report = refreshed.export(); + assert.equal(report.trigger, 'filesystem_refresh'); + assert.equal(report.first_results_ms, 15); + assert.equal(report.latest_displayed_ms, 15); + assert.equal(report.debounce_ms, 0); + assert.equal(report.hybrid_final_results_ms, null); + assert.deepEqual(report.original_input, { + debounce_ms: 60, first_results_ms: 80, latest_displayed_ms: 424, + hybrid_final_results_ms: 424, + }); + assert.equal(report.attempts.length, 1); + assert.equal(report.attempts[0].attempt, 1); + assert.equal(report.attempts[0].queued_ms, 2); + assert.deepEqual(original.export(), before); +}); + +test('repeated refreshes retain a bounded immutable numeric origin without replaying failures or private spans', () => { + const original = new SearchMeasurement(0, 60, false); + original.submitted('lexical', 60); + finish(original, 'lexical', 60, 70, { + ...native, trace: [{ name: 'private-path-and-snippet', start_ms: 0, duration_ms: 1, parent: null, counters: {} }], + }, true); + original.displayed('lexical', 70, 72, 1); + let refreshed = original.freshForRefresh(10000); + const origin = refreshed.export().original_input; + original.displayed('hybrid', 10001, 10002, 5); + for (let i = 0; i < 100; i++) { + const now = 20000 + i * 10000; + refreshed = refreshed.freshForRefresh(now); + assert.equal(refreshed.export().attempts.length, 0); + assert.equal(refreshed.export().displays.length, 0); + refreshed.submitted('lexical', now); + finish(refreshed, 'lexical', now + 2, now + 14, native, false); + refreshed.displayed('lexical', now + 14, now + 15, 2); + const report = refreshed.export(); + assert.equal(report.latest_displayed_ms, 15); + assert.equal(report.attempts[0].retry_wait_ms, 0); + assert.equal(report.attempts[0].attempt, 1); + assert.deepEqual(report.original_input, origin); + report.original_input.first_results_ms = 999; + assert.deepEqual(refreshed.export().original_input, origin); + assert.doesNotMatch(JSON.stringify(report), /private-path-and-snippet|ended_at/); + assert.ok(JSON.stringify(report).length < 1500); + } + assert.equal(origin.first_results_ms, 72); + assert.equal(origin.hybrid_final_results_ms, null); +}); diff --git a/apps/desktop/tests/selection-state.test.mjs b/apps/desktop/tests/selection-state.test.mjs new file mode 100644 index 0000000..d09bfd7 --- /dev/null +++ b/apps/desktop/tests/selection-state.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ResultSelection } from '../src/selection-state.ts'; + +test('restores intended result after temporary absence across empty and fallback responses', () => { + const selection = new ResultSelection(); + selection.begin('supplier/all'); + assert.equal(selection.resolve(['supplier', 'retry']), 0); + assert.equal(selection.resolve(['retry']), 0); + assert.equal(selection.resolve([]), -1); + assert.equal(selection.resolve(['retry', 'supplier']), 1); +}); + +test('explicit user navigation supersedes the hidden result even when choosing the fallback', () => { + const selection = new ResultSelection(); + selection.begin('supplier/all'); + selection.resolve(['supplier', 'retry']); + selection.resolve(['retry']); + selection.choose('retry'); + assert.equal(selection.resolve(['supplier', 'retry']), 1); +}); + +test('changed query or filters reset intent, while same-scope retries retain it', () => { + const selection = new ResultSelection(); + selection.begin('supplier/all'); + selection.resolve(['supplier', 'retry']); + selection.choose('retry'); + selection.begin('supplier/all'); + assert.equal(selection.resolve(['supplier', 'retry']), 1); + selection.begin('supplier/code'); + assert.equal(selection.resolve(['code', 'retry']), 0); + selection.begin('garden/code'); + assert.equal(selection.resolve(['garden', 'code']), 0); + selection.begin(''); + selection.begin('garden/code'); + assert.equal(selection.resolve(['code', 'garden']), 0); +}); + +test('initially empty search establishes its first choice only when results arrive', () => { + const selection = new ResultSelection(); + selection.begin('new/all'); + assert.equal(selection.resolve([]), -1); + assert.equal(selection.resolve(['new', 'other']), 0); + assert.equal(selection.resolve(['other', 'new']), 1); +}); diff --git a/docs/AUTOMATIC-INDEXING.md b/docs/AUTOMATIC-INDEXING.md index 6b9d882..a8068c6 100644 --- a/docs/AUTOMATIC-INDEXING.md +++ b/docs/AUTOMATIC-INDEXING.md @@ -24,8 +24,10 @@ Scans own separate SQLite connections. They do not hold the foreground search mutex during traversal or extraction. A changed scan prepares its reader and briefly swaps it into the desktop. UI events refresh counts and the active query, reject results from before the change, and request normal semantic indexing. -Refresh timing must start at the change event rather than include idle time since -the original input; native Linux interaction exposed a regression in this clock. +Refresh timing starts at the change event rather than include idle time since +the original input. A bounded numeric snapshot retains the original input +timings separately. Intended selection survives temporary result absence; +explicit navigation changes that intent, and a new query or filter clears it. ## Targeted reconciliation diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 4cc08a7..44aa974 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -83,6 +83,14 @@ model loading must not be silently included in a warm-query claim. ## Reproducible performance validation +Automatic filesystem refreshes start a new frontend measurement at the refresh +event. `trigger: "filesystem_refresh"` distinguishes them from user searches; +`SHOWN` and `latest_displayed_ms` exclude time spent idle before that event. +`original_input` retains a fixed-size snapshot of the original debounce and +display timings. Refreshes do not replay previous attempts or append ancestor +traces. A refresh before the first response leaves that original response timing +null rather than inventing a first-input measurement. + Record build profile, CPU/device and thread budget, VM/host resources, corpus and active generation, filters, cold/warm state, concurrent indexing and setup costs. Use multiple queries and p50/p95 distributions; retain raw reports. Compare basic