Measure refresh latency independently and retain intended result selection

This commit is contained in:
karl committed 2026-10-13 06:50:38 +02:00
1 parent 373ed1ed49
commit 8b993b23d7
7 files changed
+188 -11

No files matched your search

+19 -9
View File
@@ -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,
+27
View File
@@ -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.",
+24
View File
@@ -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;
}
}