@deepwatch/dsh-library 0.1.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.
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The Library's end of the read plane: what the surface asks, and what it does
3
+ * with the answer.
4
+ *
5
+ * The Host end is `@deepwatch/dsh-tools`, which registers `WatchQueryService`
6
+ * and lets Typert generate a strict Remote from it. The Library does not import
7
+ * that generated artifact and does not mount it. Doing either would make the
8
+ * package that owns the Library capability depend on the package that reads it,
9
+ * which is the cycle `@deepwatch/dsh-client-remotes` exists to remove.
10
+ *
11
+ * So the namespace is described here from the contracts both ends already
12
+ * share. `@deepwatch/dsh-contracts/query/wire` is the single definition of
13
+ * every request and response on this wire — the generated declaration imports
14
+ * its types from exactly that module — and `RemoteResult` is upstream's own
15
+ * envelope. Nothing below restates a shape either side owns.
16
+ *
17
+ * That leaves one thing a shared contract cannot prove: that the namespace
18
+ * really is called `watchQuery` and really carries these two methods. Two
19
+ * things hold it. `@deepwatch/dsh-client-remotes` compares this interface
20
+ * against the generated one at compile time, so a changed signature stops the
21
+ * build; and `tests/remote-client-mount.test.mjs` mounts the real contribution
22
+ * through the real Gateway and calls it, so a changed *name* fails a test
23
+ * rather than a page.
24
+ *
25
+ * @module @deepwatch/dsh-library/client/read-plane
26
+ */
27
+ import { WATCH_QUERY_WIRE_VERSION } from '@deepwatch/dsh-contracts/query/wire';
28
+ /**
29
+ * Correlation ids, from a counter rather than from randomness.
30
+ *
31
+ * The host refuses a `requestId` that is not an identifier, and a counter
32
+ * produces one by construction. It is also what makes a failing request
33
+ * quotable: `library-7` names a call somebody can find twice.
34
+ */
35
+ let sequence = 0;
36
+ /** The next correlation id for a Library read. */
37
+ export function nextRequestId() {
38
+ sequence += 1;
39
+ return `library-${String(sequence)}`;
40
+ }
41
+ /** An answer that produced no rows, and says why. */
42
+ function nothing(note, health = 'stale') {
43
+ return { rows: [], total: 0, health, generation: 0, notes: [note], pageable: false };
44
+ }
45
+ /** The host's own vocabulary for index condition, in the surface's terms. */
46
+ function healthOf(state) {
47
+ return state === 'rebuilding' ? 'indexing' : state;
48
+ }
49
+ /** One wire record as a row. */
50
+ function rowOf(record) {
51
+ return {
52
+ key: `${record.recordId}@${record.revisionId}`,
53
+ recordId: record.recordId,
54
+ title: record.title,
55
+ kind: record.modality,
56
+ // The wire record carries provenance, not excerpts: the Host answers with
57
+ // what it persisted, and inventing a snippet from a title would put text on
58
+ // screen that no record contains.
59
+ snippets: [],
60
+ evidenceCount: record.evidenceIds.length,
61
+ current: record.current,
62
+ };
63
+ }
64
+ /**
65
+ * Ask the host, and turn whatever comes back into something renderable.
66
+ *
67
+ * Every outcome is an answer the surface shows rather than an exception it
68
+ * swallows. A refusal, an elapsed deadline and an expired cursor are different
69
+ * facts, and a person acts differently on each, so each keeps its own sentence.
70
+ */
71
+ export async function readLibraryPage(reads, query, signal) {
72
+ const answer = await reads.librarySearch({
73
+ protocol: WATCH_QUERY_WIRE_VERSION,
74
+ requestId: nextRequestId(),
75
+ query: query.text,
76
+ modalities: query.modality === '' ? [] : [query.modality],
77
+ limit: query.limit,
78
+ cursor: null,
79
+ deadlineMs: query.deadlineMs,
80
+ }, signal);
81
+ // The transport envelope first. `ok: false` means the call never produced a
82
+ // domain answer at all — no Connection, a Gateway refusal, a codec that
83
+ // rejected the response — and reporting that as an empty library would be a
84
+ // lie about what the workspace contains.
85
+ if (!answer.ok) {
86
+ return nothing(`The Library host did not answer: ${answer.error.message}`, 'corrupt');
87
+ }
88
+ const value = answer.value;
89
+ switch (value.outcome) {
90
+ case 'page': {
91
+ const notes = value.records.length < value.total
92
+ ? [`Showing ${String(value.records.length)} of ${String(value.total)} matches; `
93
+ + 'the host answered with one page and offered no cursor.']
94
+ : [];
95
+ return {
96
+ rows: value.records.map(rowOf),
97
+ total: value.total,
98
+ health: healthOf(value.indexState),
99
+ generation: value.generation,
100
+ notes,
101
+ // `nextCursor` is the host's own statement about whether more remains.
102
+ // Deriving it from `total` instead would offer a Next control the host
103
+ // has no way to answer.
104
+ pageable: value.nextCursor !== null,
105
+ };
106
+ }
107
+ case 'rejected':
108
+ return nothing(`The host refused the request (${value.reason}`
109
+ + `${value.field === null ? '' : ` at ${value.field}`}).`);
110
+ case 'deadline_exceeded':
111
+ return nothing(`The host did not answer within ${String(value.deadlineMs)}ms. Try a narrower query.`);
112
+ case 'cursor_expired':
113
+ return nothing('That page is no longer held by the host. Search again.');
114
+ }
115
+ }
116
+ /** A local index answer as the same view model, so the surface renders one shape. */
117
+ export function fromIndex(result) {
118
+ return {
119
+ rows: result.results.map(entry => ({
120
+ key: entry.sourceId,
121
+ recordId: entry.sourceId,
122
+ title: entry.title,
123
+ kind: entry.kind,
124
+ snippets: entry.hits.map(hit => hit.text),
125
+ evidenceCount: entry.hits[0]?.evidenceIds.length ?? 0,
126
+ current: entry.current,
127
+ })),
128
+ total: result.total,
129
+ health: result.health,
130
+ // The local index is not a host generation and does not pretend to be one.
131
+ generation: 0,
132
+ notes: result.notes,
133
+ pageable: true,
134
+ };
135
+ }
136
+ /**
137
+ * Ask the host to read its roots again.
138
+ *
139
+ * Every outcome is rendered, and none of them is an exception. A refusal, an
140
+ * elapsed deadline, an abandoned rebuild and a failed one are four different
141
+ * facts; so is a rebuild that succeeded and found nothing new. Reporting any
142
+ * of them as "refreshed" would be a control that lies about what it did.
143
+ */
144
+ export async function refreshLibrary(reads, deadlineMs, signal) {
145
+ const answer = await reads.libraryRefresh({
146
+ protocol: WATCH_QUERY_WIRE_VERSION,
147
+ requestId: nextRequestId(),
148
+ deadlineMs,
149
+ }, signal);
150
+ if (!answer.ok) {
151
+ return failedRefresh(`The Library host did not answer: ${answer.error.message}`);
152
+ }
153
+ const value = answer.value;
154
+ switch (value.outcome) {
155
+ case 'refreshed':
156
+ return {
157
+ refreshed: true,
158
+ generation: value.index.generation,
159
+ recordCount: value.index.recordCount,
160
+ note: value.skipped.length === 0
161
+ ? ''
162
+ : `${String(value.skipped.length)} file(s) were not readable: `
163
+ + value.skipped.slice(0, 3).join('; '),
164
+ failed: false,
165
+ };
166
+ case 'refresh_cancelled':
167
+ return {
168
+ refreshed: false,
169
+ generation: value.index.generation,
170
+ recordCount: value.index.recordCount,
171
+ note: 'The refresh was abandoned. The Library is unchanged.',
172
+ failed: false,
173
+ };
174
+ case 'refresh_failed':
175
+ return {
176
+ refreshed: false,
177
+ generation: value.index.generation,
178
+ recordCount: value.index.recordCount,
179
+ note: `The refresh failed: ${value.reason}. The previous index is still searchable.`,
180
+ failed: true,
181
+ };
182
+ case 'rejected':
183
+ return failedRefresh(`The host refused the refresh (${value.reason}).`);
184
+ case 'deadline_exceeded':
185
+ return failedRefresh(`The refresh did not finish within ${String(value.deadlineMs)}ms. `
186
+ + 'It may still be running on the host.');
187
+ }
188
+ }
189
+ /** A refresh that produced no generation, and why. */
190
+ function failedRefresh(note) {
191
+ return { refreshed: false, generation: 0, recordCount: 0, note, failed: true };
192
+ }
193
+ //# sourceMappingURL=read-plane.js.map
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The Library, as a working search surface.
3
+ *
4
+ * It is backed by `LibraryIndex` — a local, derived, rebuildable inverted index
5
+ * — so search works offline, needs no service and needs no embedding model.
6
+ * Semantic retrieval stays a future optional plugin; this is what runs on any
7
+ * machine today.
8
+ *
9
+ * The records come from the evidence the workspace has actually seen. Where
10
+ * there are none the surface says so and offers a rebuild, rather than
11
+ * presenting an empty result set as though a search had run and found nothing —
12
+ * those are different facts and a person acts differently on each.
13
+ *
14
+ * Accessibility is not an afterthought here because a search box is where
15
+ * keyboard and screen-reader behaviour is most obviously felt: the field is
16
+ * labelled, results are a live region announcing their own count, every filter
17
+ * is a real control, and the index's condition is announced rather than only
18
+ * coloured.
19
+ *
20
+ * @module @deepwatch/dsh-library/client/search-view
21
+ */
22
+ import type { ReactNode } from 'react';
23
+ import { LibraryIndex, MAX_LIMIT } from '../index-store.js';
24
+ import type { IndexableRecord } from '../index-store.js';
25
+ import type { WatchQueryRemote } from './read-plane.js';
26
+ export interface LibrarySearchProps {
27
+ /** The records to index. The store remains the source of truth. */
28
+ readonly records?: readonly IndexableRecord[];
29
+ /** Injected for tests; production builds its own. */
30
+ readonly index?: LibraryIndex;
31
+ /**
32
+ * The mounted `ctx.remote.watchQuery` namespace, when there is one.
33
+ *
34
+ * Present in a profile: the workspace's own host holds the library, and
35
+ * answering from a browser-side copy of it would be a second index with its
36
+ * own drift. Absent everywhere else — a test, a story, a build without the
37
+ * Host row — and the local index answers instead.
38
+ */
39
+ readonly reads?: WatchQueryRemote | undefined;
40
+ }
41
+ /** The Library search workflow: query, filter, sort, page, rebuild. */
42
+ export declare function LibrarySearch({ records, index: injected, reads }: LibrarySearchProps): ReactNode;
43
+ export { MAX_LIMIT };
44
+ //# sourceMappingURL=search-view.d.ts.map
@@ -0,0 +1,233 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
3
+ import { LibraryIndex, MAX_LIMIT, tokenize } from '../index-store.js';
4
+ import { fromIndex, readLibraryPage, refreshLibrary } from './read-plane.js';
5
+ const PAGE = 10;
6
+ /**
7
+ * How long the surface will wait for the host before it must show something.
8
+ *
9
+ * Shorter than the host's own ceiling on purpose: the host clamps to 30s, and a
10
+ * search box that can appear frozen for half a minute is a search box people
11
+ * stop trusting. The host is told this number, so an answer it cannot produce
12
+ * in time comes back as `deadline_exceeded` — a sentence on the screen — rather
13
+ * than as a request nobody ever cancelled.
14
+ */
15
+ const DEADLINE_MS = 10_000;
16
+ /**
17
+ * How long the surface will wait for a rebuild.
18
+ *
19
+ * Longer than a search, because it is one: reading a corpus is work a person
20
+ * has asked for and expects to take a moment. Still bounded, and still the
21
+ * number the host is told, so an overrun comes back as an answer rather than
22
+ * as a control that never settles.
23
+ */
24
+ const REFRESH_DEADLINE_MS = 60_000;
25
+ const S = {
26
+ root: {
27
+ display: 'flex', flexDirection: 'column', gap: '14px',
28
+ height: '100%', minHeight: 0,
29
+ },
30
+ bar: { display: 'flex', gap: '10px', flexWrap: 'wrap', alignItems: 'flex-end' },
31
+ field: { display: 'flex', flexDirection: 'column', gap: '4px', flex: '1 1 260px', minWidth: 0 },
32
+ label: {
33
+ fontSize: '11px', fontWeight: 600, letterSpacing: '.05em',
34
+ textTransform: 'uppercase', color: 'var(--dsw-alias-label-tertiary)',
35
+ },
36
+ input: {
37
+ background: 'var(--dsw-alias-bg-layer-2)',
38
+ border: '1px solid color-mix(in srgb, var(--watch-accent) 12%, var(--dsw-alias-border-l2))',
39
+ borderRadius: '10px', padding: '9px 11px', fontSize: '13px',
40
+ color: 'inherit', font: 'inherit', minWidth: 0, width: '100%',
41
+ },
42
+ select: {
43
+ background: 'var(--dsw-alias-bg-layer-2)',
44
+ border: '1px solid color-mix(in srgb, var(--watch-accent) 12%, var(--dsw-alias-border-l2))',
45
+ borderRadius: '10px', padding: '9px 11px', fontSize: '13px', color: 'inherit',
46
+ },
47
+ button: {
48
+ background: 'transparent', border: '1px solid var(--dsw-alias-border-l2)',
49
+ borderRadius: '10px', padding: '9px 13px', fontSize: '13px',
50
+ color: 'inherit', cursor: 'pointer',
51
+ },
52
+ status: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)', margin: 0 },
53
+ list: { display: 'flex', flexDirection: 'column', gap: '8px', margin: 0, padding: 0, listStyle: 'none' },
54
+ hit: {
55
+ border: '1px solid color-mix(in srgb, var(--watch-accent) 9%, var(--dsw-alias-border-l2))', borderRadius: '14px',
56
+ padding: '14px 16px', background: 'linear-gradient(145deg, color-mix(in srgb, var(--watch-accent) 3%, var(--dsw-alias-bg-layer-2)), var(--dsw-alias-bg-base))',
57
+ boxShadow: '0 8px 24px color-mix(in srgb, black 7%, transparent)',
58
+ },
59
+ title: { fontSize: '13.5px', fontWeight: 600, margin: 0 },
60
+ snippet: {
61
+ fontSize: '12.5px', lineHeight: 1.6, margin: '6px 0 0',
62
+ color: 'var(--dsw-alias-label-secondary)', wordBreak: 'break-word',
63
+ },
64
+ meta: { fontSize: '11.5px', color: 'var(--dsw-alias-label-tertiary)', marginTop: '6px', display: 'flex', gap: '10px', flexWrap: 'wrap' },
65
+ };
66
+ /** What the index says about itself, in words a person can act on. */
67
+ const HEALTH = {
68
+ empty: { says: 'Nothing indexed yet.', tone: 'var(--watch-tone-neutral)' },
69
+ ready: { says: 'Index ready.', tone: 'var(--watch-tone-active)' },
70
+ indexing: { says: 'Indexing — results are partial.', tone: 'var(--watch-tone-caution)' },
71
+ stale: { says: 'Index is behind the store.', tone: 'var(--watch-tone-caution)' },
72
+ corrupt: { says: 'Index unreadable. Rebuild required.', tone: 'var(--watch-tone-error)' },
73
+ };
74
+ const KINDS = ['video', 'audio', 'page', 'stream', 'document', 'screen_capture'];
75
+ /**
76
+ * Highlight matches without building markup.
77
+ *
78
+ * The snippet is evidence, so it is never altered and never handed to a
79
+ * renderer as HTML. Splitting into plain segments and marking them with React
80
+ * elements keeps escaping the renderer's job, which is the only place it is
81
+ * reliably done.
82
+ */
83
+ function Highlighted({ text, terms }) {
84
+ if (terms.length === 0 || text === '')
85
+ return _jsx(_Fragment, { children: text });
86
+ const pattern = terms
87
+ .map(term => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
88
+ .filter(term => term !== '')
89
+ .join('|');
90
+ if (pattern === '')
91
+ return _jsx(_Fragment, { children: text });
92
+ const parts = text.split(new RegExp(`(${pattern})`, 'giu'));
93
+ return (_jsx(_Fragment, { children: parts.map((part, index) => (terms.includes(part.toLowerCase())
94
+ ? _jsx("mark", { style: { background: 'var(--watch-wash-active)', color: 'inherit' }, children: part }, `${part}-${String(index)}`)
95
+ : _jsx("span", { children: part }, `${part}-${String(index)}`))) }));
96
+ }
97
+ /** The Library search workflow: query, filter, sort, page, rebuild. */
98
+ export function LibrarySearch({ records = [], index: injected, reads }) {
99
+ const queryId = useId();
100
+ const kindId = useId();
101
+ const verdictId = useId();
102
+ const sortId = useId();
103
+ const [text, setText] = useState('');
104
+ const [kind, setKind] = useState('');
105
+ const [verdict, setVerdict] = useState('');
106
+ const [sort, setSort] = useState('relevance');
107
+ const [offset, setOffset] = useState(0);
108
+ const [generation, setGeneration] = useState(0);
109
+ const [state, setState] = useState(null);
110
+ // Bounded progress. `true` only while a rebuild the host accepted is
111
+ // outstanding, so the control cannot appear busy after the answer arrived.
112
+ const [refreshing, setRefreshing] = useState(false);
113
+ const [refreshed, setRefreshed] = useState(null);
114
+ const index = useMemo(() => {
115
+ if (injected !== undefined)
116
+ return injected;
117
+ const built = new LibraryIndex();
118
+ built.addAll(records);
119
+ return built;
120
+ }, [injected, records, generation]);
121
+ // Every query supersedes the one before it. Without this, a slow search over
122
+ // a large corpus can land after a newer one and overwrite it with stale
123
+ // results — the classic race that makes a search box feel haunted. It is what
124
+ // carries cancellation to the host too: the Remote takes the same signal, so
125
+ // an abandoned query is abandoned on both sides rather than only on this one.
126
+ const inFlight = useRef(null);
127
+ const run = useCallback((nextOffset) => {
128
+ inFlight.current?.abort();
129
+ const controller = new AbortController();
130
+ inFlight.current = controller;
131
+ setOffset(nextOffset);
132
+ if (reads === undefined) {
133
+ setState(fromIndex(index.search({
134
+ text,
135
+ ...(kind === '' ? {} : { kinds: [kind] }),
136
+ ...(verdict === '' ? {} : { verdicts: [verdict] }),
137
+ sort,
138
+ offset: nextOffset,
139
+ limit: PAGE,
140
+ signal: controller.signal,
141
+ })));
142
+ return;
143
+ }
144
+ void readLibraryPage(reads, { text, modality: kind, limit: PAGE, deadlineMs: DEADLINE_MS }, controller.signal).then(next => {
145
+ // A superseded answer is dropped rather than rendered: the newer query
146
+ // already owns the screen.
147
+ if (!controller.signal.aborted)
148
+ setState(next);
149
+ });
150
+ }, [reads, index, text, kind, verdict, sort]);
151
+ useEffect(() => { run(0); }, [run]);
152
+ useEffect(() => () => { inFlight.current?.abort(); }, []);
153
+ /**
154
+ * Ask the host to read its roots again, then search the result.
155
+ *
156
+ * Two steps rather than one, and deliberately in that order: the refresh
157
+ * reports what the host now holds, and the search that follows is what puts
158
+ * it on the screen. Collapsing them would leave the count and the rows
159
+ * describing two different generations.
160
+ */
161
+ const refresh = useCallback(() => {
162
+ if (reads === undefined) {
163
+ // No host to ask. The local index is derived, so discarding it and
164
+ // building it again is the whole refresh.
165
+ setGeneration(value => value + 1);
166
+ return;
167
+ }
168
+ if (refreshing)
169
+ return;
170
+ setRefreshing(true);
171
+ setRefreshed(null);
172
+ const controller = new AbortController();
173
+ void refreshLibrary(reads, REFRESH_DEADLINE_MS, controller.signal)
174
+ .then(next => {
175
+ setRefreshed(next);
176
+ setRefreshing(false);
177
+ // Re-read only where the host actually swapped something in. A failed
178
+ // refresh leaves the previous index searchable and the rows on screen
179
+ // are still correct for it.
180
+ if (next.refreshed)
181
+ setGeneration(value => value + 1);
182
+ });
183
+ }, [reads, refreshing]);
184
+ const terms = useMemo(() => tokenize(text), [text]);
185
+ // `noUncheckedIndexedAccess` is on, so an index lookup is optional even
186
+ // with a total record type. Falling back keeps the surface renderable
187
+ // for a health value a future build adds before this one knows it.
188
+ const health = HEALTH[state?.health ?? (reads === undefined ? index.health : 'empty')]
189
+ ?? { says: 'Index state unknown.', tone: 'var(--watch-tone-neutral)' };
190
+ const rows = state?.rows ?? [];
191
+ const total = state?.total ?? 0;
192
+ const shown = rows.length;
193
+ const page = Math.floor(offset / PAGE) + 1;
194
+ // The host answers one page and offers no cursor, so it never claims more
195
+ // pages than it can produce. Only the local index pages.
196
+ const pages = state?.pageable === true ? Math.max(1, Math.ceil(total / PAGE)) : 1;
197
+ return (_jsxs("div", { style: S.root, children: [_jsxs("form", { style: S.bar, role: "search", onSubmit: event => { event.preventDefault(); run(0); }, children: [_jsxs("div", { style: S.field, children: [_jsx("label", { htmlFor: queryId, style: S.label, children: "Search evidence" }), _jsx("input", { id: queryId, style: S.input, type: "search", value: text, placeholder: "Words in a transcript, a title, a run\u2026", onChange: event => { setText(event.target.value); } })] }), _jsxs("div", { style: S.field, children: [_jsx("label", { htmlFor: kindId, style: S.label, children: "Type" }), _jsxs("select", { id: kindId, style: S.select, value: kind, onChange: event => { setKind(event.target.value); }, children: [_jsx("option", { value: "", children: "Any type" }), KINDS.map(value => _jsx("option", { value: value, children: value.replace('_', ' ') }, value))] })] }), _jsxs("div", { style: S.field, children: [_jsx("label", { htmlFor: verdictId, style: S.label, children: "Verification" }), _jsxs("select", { id: verdictId, style: S.select, value: verdict, disabled: reads !== undefined, onChange: event => { setVerdict(event.target.value); }, children: [_jsx("option", { value: "", children: "Any state" }), ['VERIFIED', 'FAILED', 'UNVERIFIED', 'INCONCLUSIVE'].map(value => (_jsx("option", { value: value, children: value }, value)))] })] }), _jsxs("div", { style: S.field, children: [_jsx("label", { htmlFor: sortId, style: S.label, children: "Sort" }), _jsxs("select", { id: sortId, style: S.select, value: sort, disabled: reads !== undefined, onChange: event => { setSort(event.target.value); }, children: [_jsx("option", { value: "relevance", children: "Relevance" }), _jsx("option", { value: "newest", children: "Newest first" }), _jsx("option", { value: "oldest", children: "Oldest first" }), _jsx("option", { value: "title", children: "Title" })] })] }), _jsx("button", { type: "submit", style: S.button, children: "Search" }), _jsx("button", { type: "button", style: S.button, disabled: refreshing,
198
+ // Rebuilding is safe precisely because the index is derived: it can
199
+ // be thrown away and reconstructed from the records at any time.
200
+ //
201
+ // It says what it does in each mode rather than one word for two
202
+ // actions. Locally it discards the index and builds it again. Against
203
+ // a host it asks the host to read its roots again — a real operation
204
+ // with a real answer, which is why the label is the same verb and the
205
+ // subject is the Library rather than a local structure.
206
+ onClick: refresh, children: refreshing
207
+ ? 'Refreshing…'
208
+ : (reads === undefined ? 'Rebuild index' : 'Refresh library') })] }), _jsxs("p", { style: { ...S.status, color: health.tone }, children: [health.says, ' ', _jsx("span", { style: { color: 'var(--dsw-alias-label-tertiary)' }, children: reads === undefined
209
+ ? `${String(index.size)} record(s) indexed on this machine.`
210
+ : 'Answered by this workspace’s own host.' })] }), _jsx("p", { style: S.status, role: "status", "aria-live": "polite", children: total === 0
211
+ ? (terms.length === 0 ? 'No records to list.' : `No matches for “${text}”.`)
212
+ : `${String(total)} match${total === 1 ? '' : 'es'}, showing ${String(shown)} (page ${String(page)} of ${String(pages)}).` }), refreshing
213
+ ? (_jsx("p", { style: S.status, role: "status", "aria-live": "polite", children: "Reading the library again. The results below are the previous index until it finishes." }))
214
+ : null, refreshed === null || refreshing
215
+ ? null
216
+ : (_jsxs("p", { style: {
217
+ ...S.status,
218
+ color: refreshed.failed ? 'var(--watch-tone-error)' : 'var(--dsw-alias-label-tertiary)',
219
+ }, role: "status", "aria-live": "polite", children: [refreshed.refreshed
220
+ ? `Library refreshed: ${String(refreshed.recordCount)} record(s), `
221
+ + `generation ${String(refreshed.generation)}.`
222
+ : refreshed.note, refreshed.refreshed && refreshed.note !== '' ? ` ${refreshed.note}` : ''] })), (state?.notes ?? []).map(note => (_jsx("p", { style: S.status, children: note }, note))), total === 0
223
+ ? (_jsx("div", { style: { ...S.hit, borderStyle: 'dashed' }, children: _jsx("p", { style: { ...S.snippet, margin: 0 }, children: reads === undefined && index.size === 0
224
+ ? 'Nothing has been indexed yet. Evidence appears here once the workspace has recorded some — then this searches it locally, with no service and no model.'
225
+ : 'Nothing matched. Every word has to appear in a record; try fewer words, or clear the filters.' }) }))
226
+ : (_jsx("ul", { style: S.list, children: rows.map(entry => (_jsxs("li", { style: S.hit, children: [_jsx("h4", { style: S.title, children: _jsx(Highlighted, { text: entry.title, terms: terms }) }), entry.snippets.map((snippet, at) => (_jsx("p", { style: S.snippet, children: _jsx(Highlighted, { text: snippet, terms: terms }) }, `${entry.key}-${String(at)}`))), _jsxs("div", { style: S.meta, children: [_jsx("span", { children: entry.kind }), _jsx("span", { "data-watch-ltr": true, children: entry.recordId }), entry.evidenceCount > 0
227
+ ? (_jsx("span", { "data-watch-ltr": true, children: `${String(entry.evidenceCount)} evidence ref(s)` }))
228
+ : null] })] }, entry.key))) })), pages > 1
229
+ ? (_jsxs("nav", { style: { display: 'flex', gap: '8px' }, "aria-label": "Search results pages", children: [_jsx("button", { type: "button", style: S.button, disabled: offset === 0, onClick: () => { run(Math.max(0, offset - PAGE)); }, children: "Previous" }), _jsx("button", { type: "button", style: S.button, disabled: offset + PAGE >= total, onClick: () => { run(Math.min(offset + PAGE, Math.max(0, total - 1))); }, children: "Next" })] }))
230
+ : null] }));
231
+ }
232
+ export { MAX_LIMIT };
233
+ //# sourceMappingURL=search-view.js.map