@crvouga/atlas 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52,6 +52,8 @@ import { useEvidence } from '../evidence/evidence-context';
52
52
 
53
53
  /** How many runs the Runs tab lists. */
54
54
  export const RUNS_SHOWN = 500;
55
+ /** How many runs are read to search them all (a search must see every run, not only the newest). */
56
+ export const RUNS_SEARCHED = 20_000;
55
57
  /** How many recorded results the Artifacts tab lists. */
56
58
  export const ARTIFACTS_SHOWN = 200;
57
59
  /** The server's catalog is read again at most this often while results keep arriving. */
@@ -284,22 +286,24 @@ export function runsAnswer(list: RunList, filter: ArtifactFilter): RunsAnswer {
284
286
 
285
287
  /**
286
288
  * CI runs, newest first, narrowed by a query (see `artifact-query.ts`). The server filters every run before it pages
287
- * them, so counts are the whole dataset's; a server that does not know a filter is handled by {@link runsAnswer}.
289
+ * them, so counts are the whole dataset's; a server that does not know a filter is handled by {@link runsAnswer}. With
290
+ * `all` every run is read (up to {@link RUNS_SEARCHED}), because a typed search must match the whole history rather than
291
+ * the newest page of it.
288
292
  */
289
- export function useRunList(enabled = true, filter: ArtifactFilter = {}) {
293
+ export function useRunList(enabled = true, filter: ArtifactFilter = {}, all = false) {
290
294
  const catalog = useTestCatalog();
291
295
  const query = forRuns(filter);
292
296
  const key = filterParams(query).toString();
293
297
  return useQuery({
294
- queryKey: ['test-runs', catalog.version, key],
298
+ queryKey: ['test-runs', catalog.version, key, all],
295
299
  queryFn: async ({ signal }): Promise<RunsAnswer> => {
296
300
  if (catalog.source === 'server') {
297
301
  const params = filterParams(query);
298
- params.set('limit', String(RUNS_SHOWN));
302
+ params.set('limit', String(all ? RUNS_SEARCHED : RUNS_SHOWN));
299
303
  return runsAnswer(await reportJson<RunList>(`/runs?${params}`, signal), query);
300
304
  }
301
305
  const history = catalog.model?.history;
302
- const list = history ? runList(history, catalog.version, RUNS_SHOWN, query) : { schemaVersion: 1 as const, version: catalog.version, total: 0, runs: [] };
306
+ const list = history ? runList(history, catalog.version, all ? RUNS_SEARCHED : RUNS_SHOWN, query) : { schemaVersion: 1 as const, version: catalog.version, total: 0, runs: [] };
303
307
  return runsAnswer(list, query);
304
308
  },
305
309
  enabled: enabled && catalog.model !== null,
@@ -0,0 +1,74 @@
1
+ import { Alert, Button, Stack, Text } from '@mantine/core';
2
+ import { IconX } from '@tabler/icons-react';
3
+ /**
4
+ * @module Search guidance
5
+ *
6
+ * What every search box in the explorer says beside its results, from one place: the filters typed in the box shown as
7
+ * removable chips, the parts of the query that were not understood (never silently dropped), and whether meaning-based
8
+ * matching is working, still refining, or off, so a search that is words only says so.
9
+ */
10
+ import { withoutToken, type FilterKey, type ParsedSearch } from '../find/query';
11
+ import { semanticNote, type SemanticStatus } from '../find/semantic-search';
12
+
13
+ const FILTER_LABEL: Record<FilterKey, string> = {
14
+ status: 'Status',
15
+ env: 'Environment',
16
+ producer: 'Producer',
17
+ domain: 'Domain',
18
+ spec: 'Spec',
19
+ tag: 'Tag',
20
+ branch: 'Branch',
21
+ video: 'Has video'
22
+ };
23
+
24
+ /** The label of a typed filter chip: `Status: failed`, or `Has video`. */
25
+ export function filterLabel(key: FilterKey, value: string): string {
26
+ return key === 'video' ? FILTER_LABEL.video : `${FILTER_LABEL[key]}: ${value}`;
27
+ }
28
+
29
+ /** The filters typed in the box, each a button that removes its text from the query. */
30
+ export function TypedFilters({ parsed, query, onChange }: { parsed: ParsedSearch; query: string; onChange: (next: string) => void }) {
31
+ return (
32
+ <>
33
+ {parsed.tokens.map((token) => {
34
+ const label = filterLabel(token.key, token.value);
35
+ return (
36
+ <Button key={`typed:${token.text}:${token.value}`} type="button" onClick={() => onChange(withoutToken(query, token))} aria-label={`Remove ${label}`} variant="light">
37
+ {label}{' '}
38
+ <Text aria-hidden="true" span inherit>
39
+ <IconX size={16} aria-hidden="true" />
40
+ </Text>
41
+ </Button>
42
+ );
43
+ })}
44
+ </>
45
+ );
46
+ }
47
+
48
+ /** The parts of a query that could not be used, with what to type instead, and the meaning-based search's state. */
49
+ export function SearchNotes({ issues, semantic }: { issues: ParsedSearch['issues']; semantic: SemanticStatus }) {
50
+ const note = semanticNote(semantic);
51
+ return (
52
+ <>
53
+ {issues.length > 0 && (
54
+ <Alert role="status" color="yellow" my="sm" title="Part of this search was not understood">
55
+ <Stack gap="xs">
56
+ {issues.map((issue) => (
57
+ <Text key={issue.token + issue.message} size="sm">
58
+ <Text span inherit fw={600}>
59
+ {issue.token}
60
+ </Text>{' '}
61
+ {issue.message}
62
+ </Text>
63
+ ))}
64
+ </Stack>
65
+ </Alert>
66
+ )}
67
+ {note && (
68
+ <Text role="status" size="xs" c="dimmed">
69
+ {note}
70
+ </Text>
71
+ )}
72
+ </>
73
+ );
74
+ }
@@ -10,7 +10,7 @@
10
10
  * `type`, `label`, `branch`, `outcome`, `since` and `until`, so a link to one tab's filters opens the same query on another.
11
11
  */
12
12
  import { joinList, splitList, type ArtifactFilter } from '@crvouga/atlas/reports/artifact-query';
13
- import { TEST_SORTS, type TestQuery, type TestSort } from '@crvouga/atlas/reports/test-catalog';
13
+ import { TEST_SORTS, type TestQuery } from '@crvouga/atlas/reports/test-catalog';
14
14
  import { z } from 'zod/v4';
15
15
 
16
16
  /** How the explorer groups its tests. `spec` is the default. */
@@ -18,6 +18,11 @@ export const TEST_GROUPS = ['spec', 'domain', 'environment', 'status', 'producer
18
18
  /** One of {@link TEST_GROUPS}. */
19
19
  export type TestGroup = (typeof TEST_GROUPS)[number];
20
20
 
21
+ /** The orders the explorer offers: `relevance` (best match first, only with words to match) and those of the catalog. */
22
+ export const EXPLORER_SORTS = ['relevance', ...TEST_SORTS] as const;
23
+ /** One of {@link EXPLORER_SORTS}. */
24
+ export type ExplorerSort = (typeof EXPLORER_SORTS)[number];
25
+
21
26
  /** A search value as text: the router reads `123` as a number and `["a"]` as an array, both of which are meant as text here. */
22
27
  const text = z
23
28
  .preprocess((value) => (typeof value === 'number' || typeof value === 'boolean' ? String(value) : value !== null && typeof value === 'object' ? JSON.stringify(value) : value), z.string().min(1))
@@ -56,12 +61,14 @@ export const ExplorerSearchFields = {
56
61
  /** Only the tests a CI run recorded (a `RunSummary.key`). Not `run`, which already names a recorded statechart run. */
57
62
  inRun: text,
58
63
  group: z.enum(TEST_GROUPS).optional().catch(undefined),
59
- sort: z.enum(TEST_SORTS).optional().catch(undefined),
64
+ sort: z.enum(EXPLORER_SORTS).optional().catch(undefined),
60
65
  tab: z.enum(['runs', 'artifacts']).optional().catch(undefined),
61
66
  /** The run opened in the Runs tab. */
62
67
  openRun: text,
63
68
  /** Pins the open test's detail to one exact layer (a run's record of it), instead of the latest combined evidence. With `openRun` and `artifact` it names the layer the file is in. */
64
69
  layer: text,
70
+ /** Which current source (a layer id) of the open test the detail's outcome summary, media and failure describe, without pinning a run. Unknown or absent: the newest. */
71
+ source: text,
65
72
  /** With `openRun` and `layer`: the one file of that layer (an `artifactRef`: `id:…`, `key:…` or `name:…`) the run's Artifacts select. */
66
73
  artifact: text,
67
74
  /** Only the runs, and the results of an opened run, that have a playable video recording. */
@@ -89,10 +96,11 @@ export type ExplorerSearch = {
89
96
  readonly until?: string;
90
97
  readonly inRun?: string;
91
98
  readonly group?: TestGroup;
92
- readonly sort?: TestSort;
99
+ readonly sort?: ExplorerSort;
93
100
  readonly tab?: 'runs' | 'artifacts';
94
101
  readonly openRun?: string;
95
102
  readonly layer?: string;
103
+ readonly source?: string;
96
104
  readonly artifact?: string;
97
105
  readonly video?: true;
98
106
  };
@@ -113,7 +121,12 @@ export function toggled(value: string | undefined, item: string): string | undef
113
121
  return joined(list.includes(item) ? list.filter((entry) => entry !== item) : [...list, item]);
114
122
  }
115
123
 
116
- /** The catalog query an address asks for, without the run (it is matched separately) and without paging. */
124
+ /** The order a list is in: what the address says, else best match when words are searched, else by spec. */
125
+ export function sortOf(search: ExplorerSearch): ExplorerSort {
126
+ return search.sort ?? (search.q ? 'relevance' : 'spec');
127
+ }
128
+
129
+ /** The catalog query an address asks for, without the run (it is matched separately) and without paging. The words in `q` are matched by the shared search, not by the catalog. */
117
130
  export function queryOf(search: ExplorerSearch): TestQuery {
118
131
  return {
119
132
  ...(search.q ? { q: search.q } : {}),
@@ -126,7 +139,7 @@ export function queryOf(search: ExplorerSearch): TestQuery {
126
139
  ...(values(search.scope).length ? { scope: values(search.scope) } : {}),
127
140
  artifactTag: values(search.artifactTag),
128
141
  ...(search.inherited ? { inherited: true } : {}),
129
- sort: search.sort ?? 'spec'
142
+ sort: search.sort === 'relevance' || !search.sort ? 'spec' : search.sort
130
143
  };
131
144
  }
132
145
 
@@ -137,7 +150,7 @@ export function explorerSearch(search: Record<string, unknown>): ExplorerSearch
137
150
 
138
151
  /** True when any filter or search narrows the list. */
139
152
  export function narrowed(search: ExplorerSearch): boolean {
140
- return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.scope || search.artifactTag || search.inRun);
153
+ return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.scope || search.artifactTag || search.inRun || search.video);
141
154
  }
142
155
 
143
156
  /** The filters of the Runs and Artifacts tabs, in the address. */
@@ -1,18 +1,26 @@
1
- import { Badge, Group, Kbd, List, Modal, Text } from '@mantine/core';
1
+ import { Badge, Button, Group, Kbd, List, Modal, Stack, Text } from '@mantine/core';
2
2
  import { Spotlight, useSpotlight } from '@mantine/spotlight';
3
3
  import { useQueryClient } from '@tanstack/react-query';
4
4
  import { useNavigate, useSearch } from '@tanstack/react-router';
5
- import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
5
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
6
6
  import { useAtlas } from '../app/atlas-context';
7
+ import { useTestCatalog } from '../explorer/catalog';
8
+ import { SearchNotes } from '../explorer/search-notes';
7
9
  import { keys, specKey, useAdapter } from '../data/queries';
8
10
  import { useEvidence } from '../evidence/evidence-context';
9
11
  import { plural } from '../evidence/format';
10
12
  import { StatusMark } from '../evidence/marks';
11
13
  import { kindOfFile } from '../spec-kind';
14
+ import { describeHit } from './behaviors';
12
15
  import { corpusFrom } from './corpus';
13
16
  import { finderStore, isHelpOpen, setFinderOpen, setHelpOpen, toggleFinder, useFinderOpen, useHelpOpen } from './open';
14
- import { FIND_GROUPS, indexCorpus, notationOf, rankIndex, splitQuery, type FindDoc, type FindKind, type FindTarget } from './rank';
15
- import { applySemantic, createWorkerEmbedder, embeddingsAvailable, embedText, textHash, type Embedder } from './semantic';
17
+ import { parseSearch, passesFilters } from './query';
18
+ import { FIND_GROUPS, indexCorpus, notationOf, rankIndex, splitQuery, type FindKind, type FindTarget, type Ranked } from './rank';
19
+ import { applySemantic } from './semantic';
20
+ import { useSemantic } from './semantic-search';
21
+
22
+ /** The finder lists at most this many results; it says so, and offers all of them, when there are more. */
23
+ export const FINDER_LIMIT = 40;
16
24
 
17
25
  const KIND_WORD: Record<FindKind, string> = {
18
26
  journey: 'Journey',
@@ -31,6 +39,7 @@ const SHORTCUTS: readonly {
31
39
  { keys: '/', does: 'Find a flow' },
32
40
  { keys: '↑ ↓', does: 'Move through the list' },
33
41
  { keys: 'Enter', does: 'Open' },
42
+ { keys: 'Shift+Enter', does: 'See all results of the search' },
34
43
  { keys: '⌘Enter or Ctrl+Enter', does: 'Open the recording' },
35
44
  { keys: 'Esc', does: 'Close' },
36
45
  { keys: '?', does: 'Show these shortcuts' },
@@ -102,16 +111,6 @@ function useSpecTexts(files: readonly string[], enabled: boolean, open: boolean)
102
111
  const failed = useMemo(() => files.filter((file) => failedFiles.has(file)).length, [files, failedFiles]);
103
112
  return { texts, pending, total: files.length, read, failed };
104
113
  }
105
- /** The vector cache outlives a keystroke, so a live run does not embed the suite again. */
106
- const vectorCache = new Map<string, Float32Array>();
107
- function vectorsFor(docs: readonly FindDoc[]): ReadonlyMap<string, Float32Array> {
108
- const vectors = new Map<string, Float32Array>();
109
- for (const doc of docs) {
110
- const vector = vectorCache.get(textHash(embedText(doc)));
111
- if (vector) vectors.set(doc.id, vector);
112
- }
113
- return vectors;
114
- }
115
114
  export function Finder() {
116
115
  const open = useFinderOpen();
117
116
  const help = useHelpOpen();
@@ -125,49 +124,85 @@ export function Finder() {
125
124
  useEffect(() => {
126
125
  if (open) setWanted(true);
127
126
  }, [open]);
128
- const { texts, pending, total, read, failed } = useSpecTexts(readable, wanted, open);
127
+ const { texts, pending, total: specTotal, read, failed } = useSpecTexts(readable, wanted, open);
128
+ const catalog = useTestCatalog();
129
+ const tests = useMemo(() => (catalog.model ? new Map(catalog.model.tests.map((test) => [test.key, test] as const)) : undefined), [catalog.model]);
129
130
  const docs = useMemo(
130
- () => corpusFrom(view, evidence.files, evidence.titleOf, evidence.view, texts),
131
- [view, evidence.files, evidence.titleOf, evidence.view, texts]
131
+ () => corpusFrom(view, evidence.files, evidence.titleOf, evidence.view, texts, tests),
132
+ [view, evidence.files, evidence.titleOf, evidence.view, texts, tests]
132
133
  );
133
134
  const index = useMemo(() => indexCorpus(docs), [docs]);
134
135
  // What search has not covered yet, so an empty answer is not read as "no such behavior".
135
136
  const unindexed = evidence.filesPending
136
137
  ? 'The spec list is still loading'
137
138
  : pending
138
- ? `${total - read} of ${total} specs are still being read`
139
+ ? `${specTotal - read} of ${specTotal} specs are still being read`
139
140
  : !evidence.ready && !evidence.unavailable
140
141
  ? 'Recorded results are still loading'
141
142
  : '';
142
143
  const [query, setQuery] = useState('');
144
+ const [showAll, setShowAll] = useState(false);
145
+ const [notice, setNotice] = useState<string | null>(null);
143
146
  const store = finderStore;
144
147
  const spotlightState = useSpotlight(store);
145
148
 
146
- const lexical = useMemo(() => rankIndex(index, query), [index, query]);
147
- const [embedded, setEmbedded] = useState<{
148
- readonly query: string;
149
- readonly vector: Float32Array;
150
- } | null>(null);
151
- const [vectors, setVectors] = useState<ReadonlyMap<string, Float32Array>>(() => new Map());
152
- const queryVector = embedded !== null && embedded.query === query.trim() ? embedded.vector : null;
149
+ const parsed = useMemo(() => parseSearch(query), [query]);
150
+ // Every match, best first: the list below is the first FINDER_LIMIT of it, and says so when there are more.
151
+ const everything = useMemo(() => rankIndex(index, query, Number.POSITIVE_INFINITY), [index, query]);
152
+ const lexical = useMemo(() => (showAll ? everything : everything.slice(0, FINDER_LIMIT)), [everything, showAll]);
153
153
  const searchable = useMemo(() => {
154
- const notations = splitQuery(query).notations;
155
- if (notations.size === 0) return docs;
156
- return docs.filter((doc) => notations.has(notationOf(doc)));
157
- }, [docs, query]);
158
- const shown = useMemo(
159
- () => (queryVector ? applySemantic(searchable, lexical, queryVector, vectors) : lexical),
160
- [queryVector, searchable, lexical, vectors]
154
+ const notations = splitQuery(parsed.words).notations;
155
+ return docs.filter((doc) => (notations.size === 0 || notations.has(notationOf(doc))) && passesFilters(doc.facets, parsed.filters));
156
+ }, [docs, parsed]);
157
+ const lexicalDocs = useMemo(() => lexical.map((hit) => hit.doc), [lexical]);
158
+ const literal = parsed.phrases.length + parsed.identifiers.length > 0;
159
+ const semantic = useSemantic(open && parsed.words.length > 0 && !literal, parsed.words, searchable, lexicalDocs);
160
+ const shown = useMemo<Ranked[]>(
161
+ () => (semantic.queryVector ? applySemantic(searchable, lexical, semantic.queryVector, semantic.vectors, showAll ? Number.POSITIVE_INFINITY : FINDER_LIMIT) : lexical),
162
+ [semantic.queryVector, searchable, lexical, semantic.vectors, showAll]
161
163
  );
162
164
  const ordered = useMemo(() => FIND_GROUPS.flatMap((group) => shown.filter((hit) => hit.doc.group === group)), [shown]);
165
+ const total = everything.length;
166
+ const bounded = !showAll && total > FINDER_LIMIT;
167
+ const orderedRef = useRef(ordered);
168
+ orderedRef.current = ordered;
169
+ const selectedId = useRef<string | null>(null);
170
+ useEffect(
171
+ () =>
172
+ store.subscribe((state) => {
173
+ selectedId.current = orderedRef.current[Math.max(0, state.selected)]?.doc.id ?? null;
174
+ }),
175
+ [store]
176
+ );
163
177
 
164
178
  // Rank results ourselves, then let Spotlight own keyboard selection and scrolling.
165
- // Compound Spotlight does not initialize a selection when asynchronous results arrive.
166
- useEffect(() => {
167
- if (open) store.setState((state) => ({ ...state, selected: 0 }));
168
- }, [open, query, store]);
169
- const embedder = useRef<Embedder | null>(null);
170
- const embedFailed = useRef(false);
179
+ // Compound Spotlight does not initialize a selection when asynchronous results arrive. A new query selects its first
180
+ // result; results that change under the same query (specs read, meaning-based re-ranking) keep the selected result
181
+ // selected by identity, and say so when it is gone, so Enter always opens what is highlighted.
182
+ const lastQuery = useRef<string | null>(null);
183
+ useLayoutEffect(() => {
184
+ if (!open) {
185
+ lastQuery.current = null;
186
+ return;
187
+ }
188
+ const first = ordered[0]?.doc.id ?? null;
189
+ if (lastQuery.current !== query) {
190
+ lastQuery.current = query;
191
+ selectedId.current = first;
192
+ setNotice(null);
193
+ setShowAll(false);
194
+ store.setState((state) => ({ ...state, selected: 0 }));
195
+ return;
196
+ }
197
+ const kept = selectedId.current === null ? -1 : ordered.findIndex((hit) => hit.doc.id === selectedId.current);
198
+ if (kept >= 0) {
199
+ if (store.getState().selected !== kept) store.setState((state) => ({ ...state, selected: kept }));
200
+ } else {
201
+ if (selectedId.current !== null) setNotice('The results changed and the selected result is no longer listed, so the first result is selected.');
202
+ selectedId.current = first;
203
+ store.setState((state) => ({ ...state, selected: 0 }));
204
+ }
205
+ }, [open, query, ordered, store]);
171
206
  useEffect(() => {
172
207
  if (open) return;
173
208
  setQuery('');
@@ -198,40 +233,6 @@ export function Finder() {
198
233
  window.addEventListener('keydown', onKey);
199
234
  return () => window.removeEventListener('keydown', onKey);
200
235
  }, []);
201
- useEffect(() => {
202
- if (!open || embedFailed.current || !embeddingsAvailable() || query.trim().length === 0) return;
203
- let cancelled = false;
204
- const needle = query.trim();
205
- embedder.current ??= createWorkerEmbedder();
206
- const rankedFirst = new Set(lexical.map((hit) => hit.doc.id));
207
- const ordered = [...lexical.map((hit) => hit.doc), ...docs.filter((doc) => !rankedFirst.has(doc.id))];
208
- void (async () => {
209
- try {
210
- const embedded = await embedder.current!.embed([needle]);
211
- const nextQuery = embedded[0];
212
- if (cancelled || !nextQuery) return;
213
- setEmbedded({ query: needle, vector: nextQuery });
214
- for (let start = 0; start < ordered.length && !cancelled; start += 8) {
215
- const batch = ordered.slice(start, start + 8);
216
- const missing = batch.filter((doc) => !vectorCache.has(textHash(embedText(doc))));
217
- if (missing.length > 0) {
218
- const batchVectors = await embedder.current!.embed(missing.map((doc) => embedText(doc)));
219
- if (cancelled) return;
220
- missing.forEach((doc, at) => {
221
- const vector = batchVectors[at];
222
- if (vector) vectorCache.set(textHash(embedText(doc)), vector);
223
- });
224
- }
225
- if (!cancelled) setVectors(vectorsFor(docs));
226
- }
227
- } catch {
228
- embedFailed.current = true;
229
- }
230
- })();
231
- return () => {
232
- cancelled = true;
233
- };
234
- }, [open, query, docs, lexical]);
235
236
  const openTarget = (target: FindTarget) => {
236
237
  const run = typeof routeSearch.run === 'string' ? routeSearch.run : undefined;
237
238
  if (target.to === '/chart/$chartId' && target.chartId) {
@@ -253,7 +254,22 @@ export function Finder() {
253
254
  }
254
255
  setFinderOpen(false);
255
256
  };
257
+ // The full results of a search: the explorer's list when the project has one (it filters, counts and sorts everything),
258
+ // else every result in this list.
259
+ const seeAll = () => {
260
+ if (catalog.model && query.trim()) {
261
+ const run = typeof routeSearch.run === 'string' ? routeSearch.run : undefined;
262
+ void navigate({ to: '/', search: { run, q: query.trim() } });
263
+ setFinderOpen(false);
264
+ } else setShowAll(true);
265
+ };
256
266
  const onCommandKey = (event: ReactKeyboardEvent) => {
267
+ if (event.key === 'Enter' && event.shiftKey && !event.metaKey && !event.ctrlKey) {
268
+ event.preventDefault();
269
+ event.stopPropagation();
270
+ seeAll();
271
+ return;
272
+ }
257
273
  if (event.key !== 'Enter' || (!event.metaKey && !event.ctrlKey)) return;
258
274
  event.preventDefault();
259
275
  event.stopPropagation();
@@ -311,9 +327,15 @@ export function Finder() {
311
327
  aria-selected={ordered[Math.max(0, spotlightState.selected)]?.doc.id === hit.doc.id}
312
328
  onClick={() => openTarget(hit.doc.target)}
313
329
  label={hit.doc.title}
314
- description={[hit.snippet !== hit.doc.subtitle ? hit.snippet : '', hit.doc.subtitle].filter(Boolean).join(' · ')}
330
+ description={[hit.snippet !== hit.doc.subtitle ? hit.snippet : '', hit.doc.subtitle, query.trim() ? describeHit(hit) : ''].filter(Boolean).join(' · ')}
315
331
  leftSection={hit.doc.status ? <StatusMark status={hit.doc.status} size={16} /> : undefined}
316
- rightSection={<Badge color="gray">{KIND_WORD[hit.doc.kind]}</Badge>}
332
+ rightSection={
333
+ <Group gap="xs" wrap="nowrap">
334
+ {hit.exact && <Badge color="blue">Exact</Badge>}
335
+ {hit.partial && <Badge color="gray" variant="outline">Partial</Badge>}
336
+ <Badge color="gray">{KIND_WORD[hit.doc.kind]}</Badge>
337
+ </Group>
338
+ }
317
339
  />
318
340
  ))}
319
341
  </Spotlight.ActionsGroup>
@@ -321,13 +343,37 @@ export function Finder() {
321
343
  })}
322
344
  </Spotlight.ActionsList>
323
345
  <Spotlight.Footer>
324
- <Group gap="md" justify="space-between">
325
- <Text size="xs" c="dimmed">
326
- {unindexed ? `Searching what is read so far: ${unindexed}` : failed > 0 ? `${plural(failed, 'spec', 'specs')} could not be read and ${failed === 1 ? 'is' : 'are'} not searched` : '↑↓ move'}
327
- </Text>
328
- <Text size="xs" c="dimmed">
329
- ↵ open · esc close
330
- </Text>
346
+ <Group gap="md" justify="space-between" align="flex-start">
347
+ <Stack gap={0}>
348
+ <Text role="status" size="xs" c="dimmed">
349
+ {query.trim() && total > 0
350
+ ? bounded
351
+ ? `Showing the first ${shown.length} of ${total} results, not all of them.`
352
+ : `${total} ${total === 1 ? 'result' : 'results'}.`
353
+ : '↑↓ move'}
354
+ </Text>
355
+ {(unindexed || failed > 0) && (
356
+ <Text role="status" size="xs" c="dimmed">
357
+ {unindexed ? `Searching what is read so far: ${unindexed}` : `${plural(failed, 'spec', 'specs')} could not be read and ${failed === 1 ? 'is' : 'are'} not searched`}
358
+ </Text>
359
+ )}
360
+ {notice && (
361
+ <Text role="status" size="xs" c="dimmed">
362
+ {notice}
363
+ </Text>
364
+ )}
365
+ <SearchNotes issues={parsed.issues} semantic={query.trim() ? semantic.status : 'idle'} />
366
+ </Stack>
367
+ <Group gap="xs" wrap="wrap" justify="flex-end">
368
+ {bounded && (
369
+ <Button size="compact-xs" variant="light" onClick={seeAll}>
370
+ See all {total} results
371
+ </Button>
372
+ )}
373
+ <Text size="xs" c="dimmed">
374
+ ↵ open · ⇧↵ all results · esc close
375
+ </Text>
376
+ </Group>
331
377
  </Group>
332
378
  </Spotlight.Footer>
333
379
  </Spotlight.Root>