Distinguish main matches from related suggestions and ship 0.1.9

This commit is contained in:
karl committed 2026-10-14 12:45:32 +02:00
1 parent a8ba594d0e
commit b0bdfc7816
40 files changed
+2463 -185

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "searchi-desktop",
"version": "0.1.8",
"version": "0.1.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "searchi-desktop",
"version": "0.1.8",
"version": "0.1.9",
"dependencies": {
"@fluentui/svg-icons": "1.1.344",
"@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "searchi-desktop",
"version": "0.1.8",
"version": "0.1.9",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -3799,7 +3799,7 @@ dependencies = [
[[package]]
name = "searchi-desktop"
version = "0.1.8"
version = "0.1.9"
dependencies = [
"anyhow",
"image",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "searchi-desktop"
version = "0.1.8"
version = "0.1.9"
edition = "2021"
description = "Local Searchi desktop shell"
license = "MIT"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "searchi",
"version": "0.1.8",
"version": "0.1.9",
"identifier": "dev.searchi.desktop",
"build": {
"beforeDevCommand": "npm run dev",
+80 -18
View File
@@ -53,6 +53,8 @@ import {
renderHighlighted,
displayExcerpt,
matchExplanation,
baseMatchKind,
partitionResults,
} from "./match-display";
import "@fontsource-variable/inter/wght.css";
import "./style.css";
@@ -166,7 +168,7 @@ document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
<form id="search-form" role="search"><label class="sr-only" for="query">Search your files</label>${icon("search")}<input id="query" type="search" placeholder="Search a filename, phrase, or what you remember…" autocomplete="off" spellcheck="false" maxlength="1000"><kbd id="focus-shortcut">Ctrl K</kbd><button id="clear-query" class="icon-button" type="button" aria-label="Clear search" title="Clear search">${icon("dismiss")}</button></form>
<details id="semantic-panel"><summary>${icon("sparkles")}<span id="semantic-status" role="status" aria-live="polite">Preparing local semantic search…</span><span id="semantic-device" class="kind">Local model</span>${icon("chevron")}</summary><div class="semantic-content"><strong>Google EmbeddingGemma 2</strong><progress id="semantic-progress" aria-label="Local model setup progress"></progress><p id="semantic-detail" class="hint">The verified local model downloads automatically on first launch (865 MB). Your files stay on this device.</p><p id="speech-status" class="hint" role="status" hidden></p><progress id="speech-progress" aria-label="Local speech model setup progress" hidden></progress><div class="semantic-actions"><button id="retry-model" type="button" hidden>Retry model setup</button><button id="rebuild-semantic" type="button" hidden>Rebuild semantic index</button></div></div></details>
<form id="folder-form" hidden><label for="folder">Folder to index</label><div class="folder-row"><input id="folder" placeholder="Absolute folder path" required><button id="browse-folder" type="button">Browse…</button><button type="submit">Index folder</button><button id="cancel-folder" type="button">Cancel</button></div><p class="hint">Only choose folders you want searched. Large folders may take a while.</p></form>
<p id="notice" role="status" aria-live="polite"></p><details id="performance-panel" hidden><summary>Performance details</summary><div id="performance-detail"></div><button id="copy-performance" type="button">Copy local timing report</button><span id="copy-performance-status" role="status"></span></details><div class="workspace"><section class="results-pane" aria-label="Search results"><p id="result-summary" class="eyebrow">READY WHEN YOU ARE</p><ul id="application-results" aria-label="Applications" hidden></ul><ul id="results" aria-label="Matching files"></ul><div id="empty">${icon("search")}<h2>Find what matters. Locally.</h2><p>Your device is indexed in the background. Search an application, filename, phrase, or what you remember.</p></div></section><aside id="application-preview" aria-label="Application preview" hidden><div class="application-detail"><img src="/searchi-symbol.png" alt="" width="48" height="48"><h2 id="application-name"></h2><p class="hint">Installed application</p><button id="launch-application" type="button">${icon("open")} Launch application</button></div></aside><aside id="preview" aria-label="Result preview" hidden><div class="preview-header"><span id="preview-icon" class="file-icon"></span><div class="preview-heading"><h2 id="preview-title"></h2><p id="preview-path" class="path"></p></div></div><div class="preview-toolbar"><span id="preview-kind"></span><div class="preview-actions"><button id="open-result" type="button">${icon("open")}<span>Open file</span></button><button id="reveal-result" type="button" title="Show in folder" aria-label="Show in folder">${icon("folder")}</button></div></div><details id="match-details" class="match-details"><summary>Why this result?</summary><p id="match-reason"></p><p id="match-legend"></p></details><div id="media-preview" class="media-preview" hidden></div><p id="media-preview-status" class="hint" role="status" hidden></p><details id="ai-panel" class="ai-panel" hidden><summary>Summarize or ask</summary><p id="ai-disclosure" class="hint"></p><p class="eyebrow">PASSAGE THAT WILL BE SENT</p><pre id="ai-source-text"></pre><div class="ai-actions"><button id="ai-summarize" type="button" disabled>Summarize this passage</button><label class="sr-only" for="ai-question">Question about this passage</label><input id="ai-question" placeholder="Ask about this passage…" maxlength="2048"><button id="ai-ask" type="button" disabled>Ask</button></div><p id="ai-status" class="hint" role="status"></p><pre id="ai-answer"></pre><p id="ai-answer-label" class="hint"></p><button id="ai-citation" type="button" hidden>Selected source</button></details><div class="passage"><p id="passage-label" class="eyebrow">EXTRACTED PASSAGE</p><pre id="preview-text"></pre></div></aside></div>
<p id="notice" role="status" aria-live="polite"></p><details id="performance-panel" hidden><summary>Performance details</summary><div id="performance-detail"></div><button id="copy-performance" type="button">Copy local timing report</button><span id="copy-performance-status" role="status"></span></details><div class="workspace"><section class="results-pane" aria-label="Search results"><p id="result-summary" class="eyebrow">READY WHEN YOU ARE</p><ul id="application-results" aria-label="Applications" hidden></ul><ul id="results" aria-label="Matching files"></ul><details id="related-suggestions" hidden><summary>Related suggestions</summary><ul id="related-results" aria-label="Related file suggestions"></ul></details><div id="empty">${icon("search")}<h2>Find what matters. Locally.</h2><p>Your device is indexed in the background. Search an application, filename, phrase, or what you remember.</p></div></section><aside id="application-preview" aria-label="Application preview" hidden><div class="application-detail"><img src="/searchi-symbol.png" alt="" width="48" height="48"><h2 id="application-name"></h2><p class="hint">Installed application</p><button id="launch-application" type="button">${icon("open")} Launch application</button></div></aside><aside id="preview" aria-label="Result preview" hidden><div class="preview-header"><span id="preview-icon" class="file-icon"></span><div class="preview-heading"><h2 id="preview-title"></h2><p id="preview-path" class="path"></p></div></div><div class="preview-toolbar"><span id="preview-kind"></span><div class="preview-actions"><button id="open-result" type="button">${icon("open")}<span>Open file</span></button><button id="reveal-result" type="button" title="Show in folder" aria-label="Show in folder">${icon("folder")}</button></div></div><details id="match-details" class="match-details"><summary>Why this result?</summary><p id="match-reason"></p><p id="match-legend"></p></details><div id="media-preview" class="media-preview" hidden></div><p id="media-preview-status" class="hint" role="status" hidden></p><details id="ai-panel" class="ai-panel" hidden><summary>Summarize or ask</summary><p id="ai-disclosure" class="hint"></p><p class="eyebrow">PASSAGE THAT WILL BE SENT</p><pre id="ai-source-text"></pre><div class="ai-actions"><button id="ai-summarize" type="button" disabled>Summarize this passage</button><label class="sr-only" for="ai-question">Question about this passage</label><input id="ai-question" placeholder="Ask about this passage…" maxlength="2048"><button id="ai-ask" type="button" disabled>Ask</button></div><p id="ai-status" class="hint" role="status"></p><pre id="ai-answer"></pre><p id="ai-answer-label" class="hint"></p><button id="ai-citation" type="button" hidden>Selected source</button></details><div class="passage"><p id="passage-label" class="eyebrow">EXTRACTED PASSAGE</p><pre id="preview-text"></pre></div></aside></div>
</main><footer><span class="footer-privacy">${icon("lock")}<span id="privacy-summary">Private search. Everything stays on your device.</span><span id="privacy" class="privacy">Local</span></span><span class="keyboard-hints"><kbd>${icon("arrowUp")}</kbd><kbd>${icon("arrowDown")}</kbd><span>Navigate</span><kbd>Enter</kbd><span>Open</span><kbd>Esc</kbd><span>${isNativeDesktop() ? "Hide" : "Clear"}</span></span></footer>`;
const element = <T extends HTMLElement>(id: string) =>
@@ -248,6 +250,32 @@ try {
/* Diagnostics remain opt-in when storage is unavailable. */
}
let results: SearchResult[] = [];
let allResults: SearchResult[] = [];
const relatedDisclosure = element<HTMLDetailsElement>("related-suggestions");
const relatedList = element<HTMLUListElement>("related-results");
relatedDisclosure
.querySelector("summary")!
.addEventListener("keydown", (event) => {
if (event.key !== "ArrowDown") return;
event.preventDefault();
relatedDisclosure.open = true;
results = partitionResults(allResults, true).visible;
render();
const index = partitionResults(allResults).primary.length;
if (results[index]) select(index, true);
});
relatedDisclosure.addEventListener("toggle", () => {
const previous = results[selected]?.path;
results = partitionResults(allResults, relatedDisclosure.open).visible;
selected = -1;
render();
const retained = previous
? results.findIndex((r) => r.path === previous)
: -1;
if (retained >= 0) select(retained, false, false);
else if (results.length && selectedApplication < 0) select(0, false, false);
else if (selectedApplication < 0) element("preview").hidden = true;
});
interface ApplicationResult {
id: string;
name: string;
@@ -346,6 +374,7 @@ async function refreshApplications() {
element("application-preview").hidden = true;
}
element("empty").hidden = Boolean(found.length || results.length);
updateRelatedEmptyState();
const summary = element("result-summary");
const fileSummary = (summary.textContent ?? "").replace(
/^\d+ APPLICATIONS? � /,
@@ -513,7 +542,7 @@ function refreshEmptySearchState() {
if (
!latestSearch ||
latestSearch.id !== requestId ||
results.length ||
allResults.length ||
applications.length
)
return;
@@ -1105,12 +1134,16 @@ function select(index: number, focus = false, userChoice = true) {
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));
button.tabIndex = i === selected ? 0 : -1;
if (i === selected && focus) button.focus();
});
document
.querySelectorAll<HTMLButtonElement>(
"#results button, #related-results button",
)
.forEach((button, i) => {
button.classList.toggle("selected", i === selected);
button.setAttribute("aria-current", String(i === selected));
button.tabIndex = i === selected ? 0 : -1;
if (i === selected && focus) button.focus();
});
element("preview").hidden = false;
const selectedIcon = fileIcon(result.filename);
element("preview-icon").innerHTML = selectedIcon.html;
@@ -1126,14 +1159,14 @@ function select(index: number, focus = false, userChoice = true) {
displayedLiteralQuery,
);
element("preview-kind").textContent =
`${matchLabel(result.match_kind)}${result.source ? ` · ${sourceLabel(result.source)}` : ""}`;
`${matchLabel(baseMatchKind(result.match_kind))}${result.source ? ` · ${sourceLabel(result.source)}` : ""}`;
const media = mediaKind(result.filename);
const explanation = matchExplanation(result);
const semanticMedia = [
"semantic_image",
"semantic_video",
"semantic_audio",
].includes(result.match_kind);
].includes(baseMatchKind(result.match_kind));
element("passage-label").textContent = semanticMedia
? "MATCHED MEDIA"
: explanation.semanticPassage
@@ -1200,8 +1233,25 @@ function select(index: number, focus = false, userChoice = true) {
}
}
function updateRelatedEmptyState() {
const partition = partitionResults(allResults);
if (
!partition.primary.length &&
partition.related.length &&
!applications.length
) {
element("empty").querySelector("h2")!.textContent = "No strong matches";
element("empty").querySelector("p")!.textContent =
"You can explore the related suggestions.";
}
}
function render() {
const scrollTop = list.scrollTop;
const partition = partitionResults(allResults, relatedDisclosure.open);
relatedDisclosure.hidden = partition.related.length === 0;
relatedDisclosure.querySelector("summary")!.textContent =
`Related suggestions (${partition.related.length})`;
const retained = new Set(results.map((result) => result.path));
for (const [key, entry] of resultRows) {
if (!retained.has(key)) {
@@ -1210,6 +1260,7 @@ function render() {
}
}
element("empty").hidden = results.length > 0 || applications.length > 0;
updateRelatedEmptyState();
if (!results.length) {
element("preview").hidden = true;
previewPath = null;
@@ -1253,21 +1304,25 @@ function render() {
"semantic_image",
"semantic_video",
"semantic_audio",
].includes(result.match_kind);
].includes(baseMatchKind(result.match_kind));
entry.snippet.classList.toggle("code-snippet", displayedCodeScope);
renderHighlighted(
entry.snippet,
displayExcerpt(result.snippet, displayedLiteralQuery),
semanticMedia ? literalQuery("") : displayedLiteralQuery,
);
entry.kind.textContent = `${matchLabel(result.match_kind)}${result.source ? ` · ${sourceLabel(result.source)}` : ""}`;
entry.kind.textContent = `${matchLabel(baseMatchKind(result.match_kind))}${result.source ? ` · ${sourceLabel(result.source)}` : ""}`;
const asset = fileIcon(result.filename);
if (entry.file.dataset.type !== asset.type) {
entry.file.innerHTML = asset.html;
entry.file.dataset.type = asset.type;
}
const positioned = list.children.item(i);
if (positioned !== entry.row) list.insertBefore(entry.row, positioned);
const target = result.match_kind.endsWith("_related") ? relatedList : list;
const position = result.match_kind.endsWith("_related")
? i - partition.primary.length
: i;
const positioned = target.children.item(position);
if (positioned !== entry.row) target.insertBefore(entry.row, positioned);
});
list.scrollTop = scrollTop;
}
@@ -1326,12 +1381,15 @@ function applyResults(
)
return;
const renderStarted = performance.now();
const resultsHadFocus = list.contains(document.activeElement);
const resultsHadFocus =
list.contains(document.activeElement) ||
relatedList.contains(document.activeElement);
shownLane = lane;
displayedRequestId = request.id;
displayedLiteralQuery = literalQuery(request.query);
displayedCodeScope = request.filters.code_only === true;
results = found;
allResults = found;
results = partitionResults(found, relatedDisclosure.open).visible;
selected = -1;
render();
const preserved = resultSelection.resolve(
@@ -1344,12 +1402,12 @@ function applyResults(
lane,
renderStarted,
performance.now(),
found.length,
results.length,
);
const metric = request.measurement.latestNative(lane);
const trace = request.measurement.export();
element("result-summary").textContent =
`${found.length} RESULTS · ${lane === "hybrid" ? "KEYWORD + MEANING" : "KEYWORD"} · SEARCH ${briefMs(metric?.total_ms ?? 0)} MS · SHOWN ${briefMs(trace.latest_displayed_ms ?? 0)} MS`;
`${partitionResults(found).primary.length ? `${partitionResults(found).primary.length} RESULTS` : found.length ? "NO STRONG MATCHES" : "0 RESULTS"} · ${lane === "hybrid" ? "KEYWORD + MEANING" : "KEYWORD"} · SEARCH ${briefMs(metric?.total_ms ?? 0)} MS · SHOWN ${briefMs(trace.latest_displayed_ms ?? 0)} MS`;
refreshPerformance(request);
}
@@ -1551,6 +1609,7 @@ async function search(inputAt = performance.now()) {
syncLauncher();
void refreshApplications().catch(showError);
invalidateSearch();
relatedDisclosure.open = false;
querySemanticFailed = false;
shownLane = "lexical";
const value = query.value.trim();
@@ -1559,6 +1618,8 @@ async function search(inputAt = performance.now()) {
if (!hasSearchInput(value, filters)) {
resultSelection.begin("");
element("performance-panel").hidden = true;
allResults = [];
relatedDisclosure.open = false;
results = [];
displayedLiteralQuery = literalQuery("");
displayedRequestId = null;
@@ -1809,6 +1870,7 @@ document.addEventListener("keydown", (event) => {
if (
event.target === query ||
list.contains(event.target as Node) ||
relatedList.contains(event.target as Node) ||
element("application-results").contains(event.target as Node)
) {
if (event.key === "Enter" && selectedApplication >= 0) {
+26 -1
View File
@@ -189,6 +189,15 @@ interface MatchResult {
source?: { kind: string; number: number };
}
export function matchExplanation(result: MatchResult) {
const explanation = baseMatchExplanation(result);
return result.match_kind.endsWith("_related")
? {
...explanation,
reason: `Related suggestion: similarity alone does not verify this answers your search. ${explanation.reason}`,
}
: explanation;
}
function baseMatchExplanation(result: MatchResult) {
const source = result.source;
const location =
source?.kind === "page"
@@ -204,7 +213,7 @@ export function matchExplanation(result: MatchResult) {
: "";
const literalLegend =
"Marked words are literal query matches, ignoring case and accents.";
switch (result.match_kind) {
switch (baseMatchKind(result.match_kind)) {
case "semantic_image":
return {
reason:
@@ -293,3 +302,19 @@ export function matchExplanation(result: MatchResult) {
};
}
}
export function baseMatchKind(kind: string): string {
return kind.endsWith("_related") ? kind.slice(0, -8) : kind;
}
export function partitionResults<T extends { match_kind: string }>(
found: T[],
expanded = false,
) {
const primary = found.filter((r) => !r.match_kind.endsWith("_related"));
const related = found.filter((r) => r.match_kind.endsWith("_related"));
return {
primary,
related,
visible: expanded ? [...primary, ...related] : primary,
};
}
+22
View File
@@ -1205,3 +1205,25 @@ footer {
background: Canvas;
}
}
#related-suggestions {
margin-top: 10px;
flex: 0 1 auto;
min-height: 0;
max-height: 55%;
overflow-y: auto;
}
#related-suggestions > summary {
cursor: pointer;
font-size: 12px;
padding: 10px 12px;
}
#related-results {
list-style: none;
padding: 0 8px 8px;
margin: 0;
}
#related-results li {
padding: 3px 0;
}
+34
View File
@@ -88,3 +88,37 @@ test('code passage reasons retain actual indexed source line', () => {
assert.match(matchExplanation({match_kind:'semantic', source:{kind:'line',number:97}}).reason, /at line 97/);
assert.match(matchExplanation({match_kind:'hybrid', source:{kind:'line',number:97}}).reason, /showing the passage at line 97/);
});
import {baseMatchKind,partitionResults} from '../src/match-display.ts';
import {ResultSelection} from '../src/selection-state.ts';
test('related candidates remain retained but excluded from collapsed keyboard selection', () => {
const weak={path:'mountain',match_kind:'semantic_image_related'}, strong={path:'supplier',match_kind:'semantic'}, exact={path:'name',match_kind:'exact_filename'};
const found=[weak,strong,exact];
assert.deepEqual(partitionResults(found),{primary:[strong,exact],related:[weak],visible:[strong,exact]});
assert.deepEqual(partitionResults(found,true).visible,[strong,exact,weak]);
assert.deepEqual(found,[weak,strong,exact]);
});
test('only related has no implicit Enter target; empty stays distinct', () => {
const weak={path:'weak',match_kind:'semantic_related'};
const selection=new ResultSelection(); selection.begin('missing');
assert.equal(selection.resolve(partitionResults([weak]).visible.map(r=>r.path)),-1);
assert.equal(selection.resolve(partitionResults([weak],true).visible.map(r=>r.path)),0);
assert.deepEqual(partitionResults([]),{primary:[],related:[],visible:[]});
});
test('progressive primary results preserve user intent across related disclosure', () => {
const a={path:'a',match_kind:'content'},b={path:'b',match_kind:'semantic_related'};
const selection=new ResultSelection(); selection.begin('query');
selection.resolve(partitionResults([a,b]).visible.map(r=>r.path)); selection.choose('b');
assert.equal(selection.resolve(partitionResults([a,b],true).visible.map(r=>r.path)),1);
assert.equal(selection.resolve(partitionResults([a,b]).visible.map(r=>r.path)),0);
assert.equal(selection.resolve(partitionResults([a,b],true).visible.map(r=>r.path)),1);
});
test('related media and speech retain source routes and explain unverified similarity', () => {
for(const kind of ['semantic','semantic_image','semantic_audio','semantic_video','semantic_speech']) {
assert.equal(baseMatchKind(kind+'_related'),kind);
const primary=matchExplanation({match_kind:kind,source:{kind:'timestamp_ms',number:1500}});
const related=matchExplanation({match_kind:kind+'_related',source:{kind:'timestamp_ms',number:1500}});
assert.equal(related.semanticPassage,primary.semanticPassage); assert.equal(related.legend,primary.legend);
assert.ok(related.reason.includes('similarity alone does not verify')); assert.ok(related.reason.endsWith(primary.reason));
}
});