Measure refresh latency independently and retain intended result selection
This commit is contained in:
1 parent
373ed1ed49
commit
8b993b23d7
7 files changed
+188
-11
No files matched your search
@@ -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<typeof setTimeout>;
|
||||
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<HTMLButtonElement>("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,
|
||||
|
||||
@@ -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<InputTimingSummary> | 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.",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user