@karmaniverous/jeeves-server 3.0.1 → 3.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.
@@ -1,25 +1,31 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { useNavigate } from 'react-router-dom';
3
- import { ChevronDown, ChevronRight, FileText, RotateCcw, Search, X } from 'lucide-react';
4
-
5
- import { fetchFacets, searchDocuments, type SearchFacet, type SearchResult, type SearchMetadata } from '@/lib/api';
6
-
7
- type DatePreset = '24h' | '7d' | '30d' | 'custom' | null;
8
-
9
- const DATE_PRESETS: { label: string; value: DatePreset }[] = [
10
- { label: '24h', value: '24h' },
11
- { label: '7 days', value: '7d' },
12
- { label: '30 days', value: '30d' },
13
- { label: 'Custom', value: 'custom' },
14
- ];
15
-
16
- function getPresetDate(preset: DatePreset): Date | null {
17
- if (!preset || preset === 'custom') return null;
18
- const now = new Date();
19
- if (preset === '24h') return new Date(now.getTime() - 24 * 60 * 60 * 1000);
20
- if (preset === '7d') return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
21
- if (preset === '30d') return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
22
- return null;
3
+ import { ChevronDown, ChevronRight, FileText, Plus, RotateCcw, Search, X } from 'lucide-react';
4
+
5
+ import { fetchFacets, searchDocuments, type SearchFacet, type SearchResult } from '@/lib/api';
6
+
7
+ /** Enumerated facets with this many values render as chips; above → searchable dropdown */
8
+ const CHIP_THRESHOLD = 8;
9
+
10
+ /** Filter out empty, garbage, and unresolved template values */
11
+ function cleanFacetValues(values: string[]): string[] {
12
+ return values.filter((v) => {
13
+ const s = String(v ?? '');
14
+ if (!s || !s.trim()) return false;
15
+ if (s.includes('[object Object]')) return false;
16
+ if (/^\$\{.*\}$/.test(s)) return false;
17
+ return true;
18
+ });
19
+ }
20
+
21
+ function formatFieldLabel(field: string): string {
22
+ return field.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
23
+ }
24
+
25
+ interface GarbageEntry {
26
+ field: string;
27
+ removed: string[];
28
+ reason: string;
23
29
  }
24
30
 
25
31
  interface SearchModalProps {
@@ -27,18 +33,21 @@ interface SearchModalProps {
27
33
  onClose: () => void;
28
34
  }
29
35
 
36
+ // ─── Sub-components ───────────────────────────────────────────────────────
37
+
30
38
  function FilterChips({
31
39
  label,
32
40
  values,
33
41
  selected,
34
42
  onToggle,
43
+ onRemove,
35
44
  }: {
36
45
  label: string;
37
46
  values: string[];
38
47
  selected: Set<string>;
39
48
  onToggle: (value: string) => void;
49
+ onRemove: () => void;
40
50
  }) {
41
- if (values.length === 0) return null;
42
51
  return (
43
52
  <div className="flex items-center gap-1.5 flex-wrap">
44
53
  <span className="text-xs text-muted-foreground font-medium">{label}:</span>
@@ -55,21 +64,234 @@ function FilterChips({
55
64
  {v}
56
65
  </button>
57
66
  ))}
67
+ <button onClick={onRemove} className="text-muted-foreground hover:text-foreground ml-0.5" title={`Remove ${label} filter`}>
68
+ <X className="h-3 w-3" />
69
+ </button>
70
+ </div>
71
+ );
72
+ }
73
+
74
+ function SearchableSelect({
75
+ label,
76
+ values,
77
+ selected,
78
+ onToggle,
79
+ multi,
80
+ onRemove,
81
+ }: {
82
+ label: string;
83
+ values: string[];
84
+ selected: Set<string>;
85
+ onToggle: (value: string) => void;
86
+ multi: boolean;
87
+ onRemove: () => void;
88
+ }) {
89
+ const [open, setOpen] = useState(false);
90
+ const [filter, setFilter] = useState('');
91
+ const containerRef = useRef<HTMLDivElement>(null);
92
+
93
+ useEffect(() => {
94
+ if (!open) return;
95
+ const handler = (e: MouseEvent) => {
96
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
97
+ setOpen(false);
98
+ setFilter('');
99
+ }
100
+ };
101
+ document.addEventListener('mousedown', handler, true);
102
+ return () => document.removeEventListener('mousedown', handler, true);
103
+ }, [open]);
104
+
105
+ const filtered = filter
106
+ ? values.filter((v) => v.toLowerCase().includes(filter.toLowerCase()))
107
+ : values;
108
+
109
+ const selectedLabel =
110
+ selected.size === 0 ? 'All'
111
+ : selected.size <= 2 ? [...selected].join(', ')
112
+ : `${selected.size} selected`;
113
+
114
+ return (
115
+ <div className="flex items-center gap-1.5" ref={containerRef}>
116
+ <span className="text-xs text-muted-foreground font-medium">{label}:</span>
117
+ <div className="relative">
118
+ <button
119
+ onClick={() => setOpen(!open)}
120
+ className={`text-xs px-2 py-0.5 rounded border transition-colors flex items-center gap-1 min-w-[100px] ${
121
+ selected.size > 0
122
+ ? 'bg-primary/10 text-primary border-primary'
123
+ : 'bg-muted text-muted-foreground border-border hover:bg-accent'
124
+ }`}
125
+ >
126
+ <span className="truncate max-w-[200px]">{selectedLabel}</span>
127
+ <ChevronDown className="h-3 w-3 shrink-0" />
128
+ </button>
129
+ {open && (
130
+ <div className="absolute top-full left-0 mt-1 z-50 bg-background border border-border rounded shadow-lg w-64 max-h-48 flex flex-col">
131
+ <div className="p-1.5 border-b border-border">
132
+ <input
133
+ type="text"
134
+ value={filter}
135
+ onChange={(e) => setFilter(e.target.value)}
136
+ placeholder="Search..."
137
+ className="text-xs w-full px-2 py-1 rounded border border-border bg-muted text-foreground placeholder:text-muted-foreground outline-none focus:border-primary"
138
+ autoFocus
139
+ />
140
+ </div>
141
+ <div className="overflow-y-auto flex-1">
142
+ {filtered.length === 0 && (
143
+ <div className="px-3 py-2 text-xs text-muted-foreground">No matches</div>
144
+ )}
145
+ {filtered.map((v) => (
146
+ <button
147
+ key={v}
148
+ onClick={() => {
149
+ onToggle(v);
150
+ if (!multi) { setOpen(false); setFilter(''); }
151
+ }}
152
+ className={`w-full text-left text-xs px-3 py-1.5 hover:bg-accent transition-colors flex items-center gap-2 ${
153
+ selected.has(v) ? 'bg-primary/10 text-primary' : 'text-foreground'
154
+ }`}
155
+ >
156
+ {multi && (
157
+ <span className={`w-3 h-3 rounded-sm border flex items-center justify-center shrink-0 ${
158
+ selected.has(v) ? 'bg-primary border-primary' : 'border-border'
159
+ }`}>
160
+ {selected.has(v) && <span className="text-[8px] text-primary-foreground">&#10003;</span>}
161
+ </span>
162
+ )}
163
+ <span className="truncate">{v}</span>
164
+ </button>
165
+ ))}
166
+ </div>
167
+ </div>
168
+ )}
169
+ </div>
170
+ {selected.size > 0 && selected.size <= 5 && [...selected].map((v) => (
171
+ <button
172
+ key={v}
173
+ onClick={() => onToggle(v)}
174
+ className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/30 hover:bg-primary/20 flex items-center gap-0.5"
175
+ title={`Remove ${v}`}
176
+ >
177
+ <span className="truncate max-w-[120px]">{v}</span>
178
+ <X className="h-2.5 w-2.5 shrink-0" />
179
+ </button>
180
+ ))}
181
+ <button onClick={onRemove} className="text-muted-foreground hover:text-foreground ml-0.5" title={`Remove ${label} filter`}>
182
+ <X className="h-3 w-3" />
183
+ </button>
58
184
  </div>
59
185
  );
60
186
  }
61
187
 
62
- function ResultRow({ result, onNavigate }: { result: SearchResult; onNavigate: (path: string) => void }) {
188
+ function FacetTextInput({
189
+ label,
190
+ value,
191
+ onChange,
192
+ inputType = 'text',
193
+ onRemove,
194
+ }: {
195
+ label: string;
196
+ value: string;
197
+ onChange: (value: string) => void;
198
+ inputType?: 'text' | 'number';
199
+ onRemove: () => void;
200
+ }) {
201
+ return (
202
+ <div className="flex items-center gap-1.5">
203
+ <span className="text-xs text-muted-foreground font-medium">{label}:</span>
204
+ <input
205
+ type={inputType}
206
+ value={value}
207
+ onChange={(e) => onChange(e.target.value)}
208
+ placeholder={`Filter by ${label.toLowerCase()}...`}
209
+ className="text-xs px-2 py-0.5 rounded border border-border bg-muted text-foreground placeholder:text-muted-foreground w-64 outline-none focus:border-primary"
210
+ />
211
+ <button onClick={onRemove} className="text-muted-foreground hover:text-foreground" title={`Remove ${label} filter`}>
212
+ <X className="h-3 w-3" />
213
+ </button>
214
+ </div>
215
+ );
216
+ }
217
+
218
+ /** Max chips shown in collapsed view */
219
+ const COLLAPSED_CHIP_COUNT = 4;
220
+
221
+ /** Internal fields to exclude from metadata chips */
222
+ const META_INTERNAL_KEYS = new Set([
223
+ 'file_path', 'chunk_text', 'chunk_index', 'total_chunks', 'content_hash',
224
+ 'embedded_at',
225
+ ]);
226
+
227
+ interface ResultRowProps {
228
+ result: SearchResult;
229
+ facets: SearchFacet[];
230
+ onNavigate: (path: string) => void;
231
+ onChipClick: (field: string, value: string) => void;
232
+ }
233
+
234
+ /** Build sorted chip entries from result metadata × facets. Lowest cardinality first; text/number last. */
235
+ function buildChips(
236
+ result: SearchResult,
237
+ facets: SearchFacet[],
238
+ ): Array<{ field: string; label: string; value: string; cardinality: number }> {
239
+ const meta = result.metadata ?? {};
240
+ const chips: Array<{ field: string; label: string; value: string; cardinality: number }> = [];
241
+ const facetMap = new Map(facets.map((f) => [f.field, f]));
242
+
243
+ for (const [key, raw] of Object.entries(meta)) {
244
+ if (META_INTERNAL_KEYS.has(key)) continue;
245
+ const facet = facetMap.get(key);
246
+ if (!facet) continue; // only show facet-connected props
247
+
248
+ const values = Array.isArray(raw) ? raw.map(String) : [String(raw)];
249
+ const isEnum = facet.uiHint !== 'text' && facet.uiHint !== 'number';
250
+ const cardinality = isEnum ? facet.values.length : Infinity;
251
+
252
+ for (const v of values) {
253
+ if (!v || v === 'undefined' || v === 'null') continue;
254
+ chips.push({
255
+ field: key,
256
+ label: formatFieldLabel(key),
257
+ value: v,
258
+ cardinality,
259
+ });
260
+ }
261
+ }
262
+
263
+ chips.sort((a, b) => a.cardinality - b.cardinality);
264
+ return chips;
265
+ }
266
+
267
+ function ResultRow({ result, facets, onNavigate, onChipClick }: ResultRowProps) {
63
268
  const [expanded, setExpanded] = useState(false);
64
269
  const preview = result.chunks[0]?.text ?? '';
65
270
  const truncatedPreview = preview.length > 150 ? preview.slice(0, 150) + '…' : preview;
66
271
 
272
+ const allChips = buildChips(result, facets);
273
+ const collapsedChips = allChips.slice(0, COLLAPSED_CHIP_COUNT);
274
+ const hasMore = allChips.length > COLLAPSED_CHIP_COUNT;
275
+
276
+ const renderChip = (chip: { field: string; label: string; value: string }, i: number) => {
277
+ const display = chip.value.length > 30 ? chip.value.slice(0, 28) + '…' : chip.value;
278
+ return (
279
+ <button
280
+ key={`${chip.field}-${i}`}
281
+ onClick={(e) => { e.stopPropagation(); onChipClick(chip.field, chip.value); }}
282
+ className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-foreground border border-border hover:bg-primary/10 hover:border-primary/30 transition-colors cursor-pointer truncate max-w-[200px]"
283
+ title={`${chip.label}: ${chip.value} — click to filter`}
284
+ >
285
+ <span className="text-muted-foreground">{chip.label}:</span> {display}
286
+ </button>
287
+ );
288
+ };
289
+
67
290
  return (
68
291
  <div className="border-b border-border last:border-0 px-4 py-2">
69
292
  <div className="flex items-start gap-2">
70
293
  <FileText className="h-4 w-4 text-zinc-400 shrink-0 mt-1" />
71
294
  <div className="min-w-0 flex-1">
72
- {/* Header row: file name, domain, score, expand toggle */}
73
295
  <div className="flex items-center gap-2">
74
296
  <button
75
297
  onClick={() => onNavigate(`/browse/${result.browsePath}`)}
@@ -77,11 +299,6 @@ function ResultRow({ result, onNavigate }: { result: SearchResult; onNavigate: (
77
299
  >
78
300
  {result.fileName}
79
301
  </button>
80
- {result.domains && result.domains.length > 0 && (
81
- <span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border border-border">
82
- {result.domains.join(', ')}
83
- </span>
84
- )}
85
302
  <span className="text-[10px] text-muted-foreground shrink-0">
86
303
  {(result.bestScore * 100).toFixed(0)}%
87
304
  </span>
@@ -104,22 +321,38 @@ function ResultRow({ result, onNavigate }: { result: SearchResult; onNavigate: (
104
321
  <div className="text-xs text-muted-foreground mt-0.5 break-words leading-relaxed">
105
322
  {result.browsePath}
106
323
  </div>
107
- {/* Collapsed: one-line preview */}
108
- {!expanded && (
109
- <div className="text-sm text-foreground/70 mt-1 truncate">
110
- {truncatedPreview}
324
+ {/* Collapsed chips */}
325
+ {!expanded && collapsedChips.length > 0 && (
326
+ <div className="flex flex-wrap items-center gap-1 mt-1">
327
+ {collapsedChips.map((c, i) => renderChip(c, i))}
328
+ {hasMore && (
329
+ <button
330
+ onClick={() => setExpanded(true)}
331
+ className="text-[10px] text-blue-500 hover:underline cursor-pointer"
332
+ >+{allChips.length - COLLAPSED_CHIP_COUNT} more</button>
333
+ )}
111
334
  </div>
112
335
  )}
113
- {/* Expanded: scrollable chunk accordion */}
336
+ {!expanded && collapsedChips.length === 0 && (
337
+ <div className="text-sm text-foreground/70 mt-1 truncate">{truncatedPreview}</div>
338
+ )}
339
+ {/* Expanded: all chips + chunks */}
114
340
  {expanded && (
115
- <div className="mt-2 max-h-48 overflow-y-auto border border-border rounded bg-muted/30 divide-y divide-border">
116
- {result.chunks.map((chunk, i) => (
117
- <div key={i} className="px-3 py-2 text-sm text-foreground/80 leading-relaxed">
118
- <span className="text-[10px] text-muted-foreground mr-2">#{chunk.index}</span>
119
- {chunk.text}
341
+ <>
342
+ {allChips.length > 0 && (
343
+ <div className="flex flex-wrap items-center gap-1 mt-1.5 mb-2">
344
+ {allChips.map((c, i) => renderChip(c, i))}
120
345
  </div>
121
- ))}
122
- </div>
346
+ )}
347
+ <div className="max-h-48 overflow-y-auto border border-border rounded bg-muted/30 divide-y divide-border">
348
+ {result.chunks.map((chunk, i) => (
349
+ <div key={i} className="px-3 py-2 text-sm text-foreground/80 leading-relaxed">
350
+ <span className="text-[10px] text-muted-foreground mr-2">#{chunk.index}</span>
351
+ {chunk.text}
352
+ </div>
353
+ ))}
354
+ </div>
355
+ </>
123
356
  )}
124
357
  </div>
125
358
  </div>
@@ -127,159 +360,232 @@ function ResultRow({ result, onNavigate }: { result: SearchResult; onNavigate: (
127
360
  );
128
361
  }
129
362
 
363
+ // ─── Main component ───────────────────────────────────────────────────────
364
+
130
365
  export function SearchModal({ open, onClose }: SearchModalProps) {
131
366
  const [query, setQuery] = useState('');
132
367
  const [results, setResults] = useState<SearchResult[]>([]);
133
- const [metadata, setMetadata] = useState<SearchMetadata>({ domains: [], authors: [], participants: [] });
134
368
  const [loading, setLoading] = useState(false);
135
369
  const [error, setError] = useState<string | null>(null);
136
- const [domainFilter, setDomainFilter] = useState<Set<string>>(new Set());
137
- const [authorFilter, setAuthorFilter] = useState<Set<string>>(new Set());
138
- const [extFilter, setExtFilter] = useState<Set<string>>(new Set());
139
- const [datePreset, setDatePreset] = useState<DatePreset>(null);
140
- const [dateFrom, setDateFrom] = useState('');
141
- const [dateTo, setDateTo] = useState('');
370
+
371
+ // Schema-driven facets (lazy-loaded)
142
372
  const [facets, setFacets] = useState<SearchFacet[]>([]);
373
+ const [facetsLoading, setFacetsLoading] = useState(false);
374
+ const facetsLoadedRef = useRef(false);
143
375
  const [facetSelections, setFacetSelections] = useState<Record<string, Set<string>>>({});
376
+ const [facetTextInputs, setFacetTextInputs] = useState<Record<string, string>>({});
377
+ const [activeFacetFields, setActiveFacetFields] = useState<Set<string>>(new Set());
378
+ const [garbageEntries, setGarbageEntries] = useState<GarbageEntry[]>([]);
379
+ const [showGarbage, setShowGarbage] = useState(false);
380
+ const [addFilterOpen, setAddFilterOpen] = useState(false);
381
+ const [addFilterSearch, setAddFilterSearch] = useState('');
382
+ const addFilterRef = useRef<HTMLDivElement>(null);
383
+
144
384
  const inputRef = useRef<HTMLInputElement>(null);
145
385
  const navigate = useNavigate();
146
386
  const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
147
387
 
388
+ // Close "Add filter" on outside click
389
+ useEffect(() => {
390
+ if (!addFilterOpen) return;
391
+ const handler = (e: MouseEvent) => {
392
+ if (addFilterRef.current && !addFilterRef.current.contains(e.target as Node)) {
393
+ setAddFilterOpen(false);
394
+ setAddFilterSearch('');
395
+ }
396
+ };
397
+ document.addEventListener('mousedown', handler);
398
+ return () => document.removeEventListener('mousedown', handler);
399
+ }, [addFilterOpen]);
400
+
401
+ const loadFacets = useCallback(async () => {
402
+ if (facetsLoadedRef.current) return;
403
+ facetsLoadedRef.current = true;
404
+ setFacetsLoading(true);
405
+ try {
406
+ const res = await fetchFacets();
407
+ const cleaned: SearchFacet[] = [];
408
+ const garbage: GarbageEntry[] = [];
409
+ for (const f of res.facets) {
410
+ if (f.uiHint === 'hidden') continue;
411
+ // Text/number facets use free-text input — values array is irrelevant
412
+ if (f.uiHint === 'text' || f.uiHint === 'number') {
413
+ cleaned.push({ ...f, values: [] });
414
+ continue;
415
+ }
416
+ const goodValues = cleanFacetValues(f.values);
417
+ const badValues = f.values.filter((v) => !goodValues.includes(v));
418
+ if (badValues.length > 0) {
419
+ const reasons = badValues.map((v) => {
420
+ if (!v || !String(v).trim()) return 'empty';
421
+ if (String(v).includes('[object Object]')) return 'object-to-string';
422
+ if (/^\$\{.*\}$/.test(String(v))) return 'unresolved-template';
423
+ return 'unknown';
424
+ });
425
+ garbage.push({
426
+ field: f.field,
427
+ removed: badValues.map((v, i) => `${JSON.stringify(v)} (${reasons[i]})`),
428
+ reason: [...new Set(reasons)].join(', '),
429
+ });
430
+ }
431
+ if (goodValues.length > 0) {
432
+ cleaned.push({ ...f, values: goodValues });
433
+ } else {
434
+ // All values were garbage — include facet with empty values but log it
435
+ garbage.push({
436
+ field: f.field,
437
+ removed: badValues.length > 0 ? ['(all values filtered)'] : ['(no values)'],
438
+ reason: badValues.length > 0 ? 'no valid values remain' : 'empty values array',
439
+ });
440
+ }
441
+ }
442
+ console.log('[SearchModal] Loaded facets:', cleaned.length, 'clean,', garbage.length, 'garbage entries');
443
+ setFacets(cleaned);
444
+ setGarbageEntries(garbage);
445
+ } catch (err) {
446
+ console.error("Failed to load facets:", err);
447
+ setFacets([]);
448
+ } finally {
449
+ setFacetsLoading(false);
450
+ }
451
+ }, []);
452
+
148
453
  useEffect(() => {
149
454
  if (open) {
150
455
  setTimeout(() => inputRef.current?.focus(), 50);
151
- // Fetch facets on modal open
152
- void fetchFacets()
153
- .then((res) => setFacets(res.facets))
154
- .catch(() => setFacets([]));
456
+ void loadFacets();
155
457
  }
156
- }, [open]);
458
+ }, [open, loadFacets]);
157
459
 
158
460
  const resetSearch = useCallback(() => {
159
461
  setQuery('');
160
462
  setResults([]);
161
- setMetadata({ domains: [], authors: [], participants: [] });
162
463
  setError(null);
163
- setDomainFilter(new Set());
164
- setAuthorFilter(new Set());
165
- setExtFilter(new Set());
166
- setDatePreset(null);
167
- setDateFrom('');
168
- setDateTo('');
169
464
  setFacetSelections({});
465
+ setFacetTextInputs({});
466
+ setActiveFacetFields(new Set());
467
+ setShowGarbage(false);
170
468
  inputRef.current?.focus();
171
469
  }, []);
172
470
 
173
471
  const doSearch = useCallback(async (q: string) => {
174
472
  if (!q.trim()) {
175
473
  setResults([]);
176
- setMetadata({ domains: [], authors: [], participants: [] });
177
474
  return;
178
475
  }
179
476
  setLoading(true);
180
477
  setError(null);
181
478
  try {
182
- // Build Qdrant filter from facet selections
183
479
  const mustClauses: Record<string, unknown>[] = [];
184
480
  for (const [field, selected] of Object.entries(facetSelections)) {
185
481
  if (selected.size > 0) {
186
- mustClauses.push({
187
- key: field,
188
- match: { any: [...selected] },
189
- });
482
+ mustClauses.push({ key: field, match: { any: [...selected] } });
483
+ }
484
+ }
485
+ for (const [field, text] of Object.entries(facetTextInputs)) {
486
+ if (text.trim()) {
487
+ mustClauses.push({ key: field, match: { text: text.trim() } });
190
488
  }
191
489
  }
192
- const filter = mustClauses.length > 0
193
- ? { must: mustClauses }
194
- : undefined;
490
+ const filter = mustClauses.length > 0 ? { must: mustClauses } : undefined;
195
491
  const res = await searchDocuments(q, 30, filter);
196
492
  setResults(res.results);
197
- setMetadata(res.metadata);
198
493
  } catch (err) {
199
494
  setError(String(err));
200
495
  } finally {
201
496
  setLoading(false);
202
497
  }
203
- }, [facetSelections]);
204
-
205
- const handleInputChange = useCallback(
206
- (value: string) => {
207
- setQuery(value);
208
- if (debounceRef.current) clearTimeout(debounceRef.current);
209
- debounceRef.current = setTimeout(() => void doSearch(value), 400);
210
- },
211
- [doSearch],
212
- );
498
+ }, [facetSelections, facetTextInputs]);
213
499
 
214
- const handleNavigate = useCallback(
215
- (path: string) => {
216
- onClose();
217
- navigate(path);
218
- },
219
- [navigate, onClose],
220
- );
500
+ const handleInputChange = useCallback((value: string) => {
501
+ setQuery(value);
502
+ if (debounceRef.current) clearTimeout(debounceRef.current);
503
+ debounceRef.current = setTimeout(() => void doSearch(value), 400);
504
+ }, [doSearch]);
221
505
 
222
- const toggleFilter = useCallback(
223
- (set: Set<string>, setFn: React.Dispatch<React.SetStateAction<Set<string>>>, value: string) => {
224
- const next = new Set(set);
225
- if (next.has(value)) next.delete(value);
226
- else next.add(value);
227
- setFn(next);
228
- },
229
- [],
230
- );
506
+ const handleNavigate = useCallback((path: string) => {
507
+ onClose();
508
+ navigate(path);
509
+ }, [navigate, onClose]);
231
510
 
232
- const toggleFacet = useCallback(
233
- (field: string, value: string) => {
511
+ const handleChipClick = useCallback((field: string, value: string) => {
512
+ // Ensure the facet field is active
513
+ setActiveFacetFields((prev) => new Set([...prev, field]));
514
+ // Find the facet to determine uiHint
515
+ const facet = facets.find((f) => f.field === field);
516
+ if (facet && (facet.uiHint === 'text' || facet.uiHint === 'number')) {
517
+ setFacetTextInputs((prev) => ({ ...prev, [field]: value }));
518
+ } else {
234
519
  setFacetSelections((prev) => {
235
520
  const current = prev[field] ?? new Set<string>();
236
521
  const next = new Set(current);
237
- if (next.has(value)) next.delete(value);
238
- else next.add(value);
522
+ next.add(value);
239
523
  return { ...prev, [field]: next };
240
524
  });
241
- },
242
- [],
243
- );
525
+ }
526
+ }, [facets]);
244
527
 
245
- // Re-search when facet selections change
528
+ const toggleFacet = useCallback((field: string, value: string) => {
529
+ setFacetSelections((prev) => {
530
+ const current = prev[field] ?? new Set<string>();
531
+ const next = new Set(current);
532
+ if (next.has(value)) next.delete(value); else next.add(value);
533
+ return { ...prev, [field]: next };
534
+ });
535
+ }, []);
536
+
537
+ const addFacetField = useCallback((field: string) => {
538
+ setActiveFacetFields((prev) => new Set([...prev, field]));
539
+ }, []);
540
+
541
+ const removeFacetField = useCallback((field: string) => {
542
+ setActiveFacetFields((prev) => { const n = new Set(prev); n.delete(field); return n; });
543
+ setFacetSelections((prev) => { const n = { ...prev }; delete n[field]; return n; });
544
+ setFacetTextInputs((prev) => { const n = { ...prev }; delete n[field]; return n; });
545
+ }, []);
546
+
547
+ // Re-search when selections change
246
548
  useEffect(() => {
247
- if (query.trim()) {
248
- void doSearch(query);
249
- }
549
+ if (query.trim()) void doSearch(query);
250
550
  // eslint-disable-next-line react-hooks/exhaustive-deps
251
- }, [facetSelections]);
252
-
253
- // Extract extensions from results
254
- const extensions = [...new Set(results.map((r) => {
255
- const dot = r.fileName.lastIndexOf('.');
256
- return dot > 0 ? r.fileName.slice(dot).toLowerCase() : '(none)';
257
- }))].sort();
258
-
259
- // Compute effective date range
260
- const effectiveDateFrom = datePreset && datePreset !== 'custom'
261
- ? getPresetDate(datePreset)
262
- : dateFrom ? new Date(dateFrom) : null;
263
- const effectiveDateTo = datePreset === 'custom' && dateTo
264
- ? new Date(dateTo + 'T23:59:59.999Z') : null;
265
-
266
- // Apply client-side filters
267
- const filtered = results.filter((r) => {
268
- if (domainFilter.size > 0 && (!r.domains || !r.domains.some(d => domainFilter.has(d)))) return false;
269
- if (authorFilter.size > 0 && (!r.author || !authorFilter.has(r.author))) return false;
270
- if (extFilter.size > 0) {
271
- const dot = r.fileName.lastIndexOf('.');
272
- const ext = dot > 0 ? r.fileName.slice(dot).toLowerCase() : '(none)';
273
- if (!extFilter.has(ext)) return false;
274
- }
275
- if (effectiveDateFrom && r.mtime) {
276
- if (new Date(r.mtime) < effectiveDateFrom) return false;
551
+ }, [facetSelections, facetTextInputs]);
552
+
553
+ const activeFacets = facets.filter((f) => activeFacetFields.has(f.field));
554
+ const inactiveFacets = facets.filter((f) => !activeFacetFields.has(f.field));
555
+ const filteredInactive = addFilterSearch
556
+ ? inactiveFacets.filter((f) => formatFieldLabel(f.field).toLowerCase().includes(addFilterSearch.toLowerCase()))
557
+ : inactiveFacets;
558
+
559
+ function renderFacet(f: SearchFacet) {
560
+ const label = formatFieldLabel(f.field);
561
+ const remove = () => removeFacetField(f.field);
562
+
563
+ if (f.uiHint === 'text' || f.uiHint === 'number') {
564
+ return (
565
+ <FacetTextInput
566
+ key={f.field}
567
+ label={label}
568
+ value={facetTextInputs[f.field] ?? ''}
569
+ onChange={(v) => setFacetTextInputs((prev) => ({ ...prev, [f.field]: v }))}
570
+ inputType={f.uiHint === 'number' ? 'number' : 'text'}
571
+ onRemove={remove}
572
+ />
573
+ );
277
574
  }
278
- if (effectiveDateTo && r.mtime) {
279
- if (new Date(r.mtime) > effectiveDateTo) return false;
575
+
576
+ const sel = facetSelections[f.field] ?? new Set<string>();
577
+ if (f.values.length <= CHIP_THRESHOLD) {
578
+ return (
579
+ <FilterChips key={f.field} label={label} values={f.values} selected={sel}
580
+ onToggle={(v) => toggleFacet(f.field, v)} onRemove={remove} />
581
+ );
280
582
  }
281
- return true;
282
- });
583
+
584
+ return (
585
+ <SearchableSelect key={f.field} label={label} values={f.values} selected={sel}
586
+ onToggle={(v) => toggleFacet(f.field, v)} multi={f.uiHint === 'multiselect'} onRemove={remove} />
587
+ );
588
+ }
283
589
 
284
590
  if (!open) return null;
285
591
 
@@ -313,112 +619,117 @@ export function SearchModal({ open, onClose }: SearchModalProps) {
313
619
  </button>
314
620
  </div>
315
621
 
316
- {/* Schema-driven facet filters */}
317
- {facets.length > 0 && (
318
- <div className="px-4 py-2 border-b border-border flex flex-col gap-1.5">
319
- {facets
320
- .filter((f) => f.values.length > 0 && f.uiHint !== 'hidden')
321
- .map((f) => (
322
- <FilterChips
323
- key={f.field}
324
- label={f.field.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}
325
- values={f.values}
326
- selected={facetSelections[f.field] ?? new Set()}
327
- onToggle={(v) => toggleFacet(f.field, v)}
328
- />
329
- ))}
330
- </div>
331
- )}
332
-
333
- {/* Post-hoc filter chips — Type always shown when results exist */}
334
- {results.length > 0 && (
335
- <div className="px-4 py-2 border-b border-border flex flex-col gap-1.5">
336
- <FilterChips
337
- label="Type"
338
- values={extensions}
339
- selected={extFilter}
340
- onToggle={(v) => toggleFilter(extFilter, setExtFilter, v)}
341
- />
342
- <FilterChips
343
- label="Domain"
344
- values={metadata.domains}
345
- selected={domainFilter}
346
- onToggle={(v) => toggleFilter(domainFilter, setDomainFilter, v)}
347
- />
348
- <FilterChips
349
- label="Author"
350
- values={metadata.authors}
351
- selected={authorFilter}
352
- onToggle={(v) => toggleFilter(authorFilter, setAuthorFilter, v)}
353
- />
354
- {/* Date range filter */}
355
- <div className="flex items-center gap-1.5 flex-wrap">
356
- <span className="text-xs text-muted-foreground font-medium">Date:</span>
357
- {DATE_PRESETS.map((p) => (
358
- <button
359
- key={p.value}
360
- onClick={() => setDatePreset(datePreset === p.value ? null : p.value)}
361
- className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${
362
- datePreset === p.value
363
- ? 'bg-primary text-primary-foreground border-primary'
364
- : 'bg-muted text-muted-foreground border-border hover:bg-accent'
365
- }`}
366
- >
367
- {p.label}
368
- </button>
369
- ))}
370
- {datePreset === 'custom' && (
371
- <>
372
- <input
373
- type="month"
374
- value={dateFrom}
375
- onChange={(e) => setDateFrom(e.target.value ? e.target.value + '-01' : '')}
376
- className="text-xs px-1.5 py-0.5 rounded border border-border bg-muted text-foreground w-28"
377
- placeholder="From"
378
- />
379
- <span className="text-xs text-muted-foreground">to</span>
380
- <input
381
- type="month"
382
- value={dateTo}
383
- onChange={(e) => setDateTo(e.target.value ? e.target.value + '-28' : '')}
384
- className="text-xs px-1.5 py-0.5 rounded border border-border bg-muted text-foreground w-28"
385
- placeholder="To"
386
- />
387
- </>
622
+ {/* Active facet filters + Add filter button */}
623
+ <div className="px-4 py-2 border-b border-border flex flex-col gap-1.5">
624
+ {activeFacets.map(renderFacet)}
625
+ <div className="flex items-center gap-2">
626
+ <div className="relative" ref={addFilterRef}>
627
+ <button
628
+ onClick={() => {
629
+ if (!addFilterOpen) void loadFacets();
630
+ setAddFilterOpen(!addFilterOpen);
631
+ }}
632
+ className="text-xs px-2 py-0.5 rounded border border-dashed border-border text-muted-foreground hover:text-foreground hover:border-foreground transition-colors flex items-center gap-1"
633
+ >
634
+ <Plus className="h-3 w-3" />
635
+ {facetsLoading ? 'Loading filters...' : 'Add filter'}
636
+ </button>
637
+ {addFilterOpen && (
638
+ <div className="absolute top-full left-0 mt-1 z-50 bg-background border border-border rounded shadow-lg w-56 max-h-48 flex flex-col">
639
+ <div className="p-1.5 border-b border-border">
640
+ <input
641
+ type="text"
642
+ value={addFilterSearch}
643
+ onChange={(e) => setAddFilterSearch(e.target.value)}
644
+ placeholder="Search filters..."
645
+ className="text-xs w-full px-2 py-1 rounded border border-border bg-muted text-foreground placeholder:text-muted-foreground outline-none focus:border-primary"
646
+ autoFocus
647
+ />
648
+ </div>
649
+ <div className="overflow-y-auto flex-1">
650
+ {facetsLoading && (
651
+ <div className="px-3 py-2 text-xs text-muted-foreground flex items-center gap-2">
652
+ <div className="h-3 w-3 border-2 border-primary border-t-transparent rounded-full animate-spin" />
653
+ Loading...
654
+ </div>
655
+ )}
656
+ {!facetsLoading && filteredInactive.length === 0 && (
657
+ <div className="px-3 py-2 text-xs text-muted-foreground">
658
+ {facets.length === 0 ? 'No filters available' : 'All filters active'}
659
+ </div>
660
+ )}
661
+ {filteredInactive.map((f) => (
662
+ <button
663
+ key={f.field}
664
+ onClick={() => {
665
+ addFacetField(f.field);
666
+ setAddFilterOpen(false);
667
+ setAddFilterSearch('');
668
+ }}
669
+ className="w-full text-left text-xs px-3 py-1.5 hover:bg-accent transition-colors flex items-center justify-between"
670
+ >
671
+ <span className="text-foreground">{formatFieldLabel(f.field)}</span>
672
+ <span className="text-[10px] text-muted-foreground">
673
+ {f.uiHint === 'text' || f.uiHint === 'number' ? f.uiHint : `${f.values.length}`}
674
+ </span>
675
+ </button>
676
+ ))}
677
+ </div>
678
+ </div>
388
679
  )}
389
680
  </div>
681
+ {garbageEntries.length > 0 && (
682
+ <button
683
+ onClick={() => setShowGarbage(!showGarbage)}
684
+ className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 border border-amber-500/30 hover:bg-amber-500/20 transition-colors"
685
+ title="Show inference rule issues"
686
+ >
687
+ {garbageEntries.length} issue{garbageEntries.length !== 1 ? 's' : ''}
688
+ </button>
689
+ )}
390
690
  </div>
391
- )}
691
+ {showGarbage && garbageEntries.length > 0 && (
692
+ <div className="mt-1 p-2 rounded border border-amber-500/30 bg-amber-500/5 text-xs max-h-32 overflow-y-auto">
693
+ <div className="text-amber-600 font-medium mb-1">Filtered facet values (inference rule issues):</div>
694
+ {garbageEntries.map((g) => (
695
+ <div key={g.field} className="mb-1">
696
+ <span className="text-foreground font-medium">{formatFieldLabel(g.field)}</span>
697
+ <span className="text-muted-foreground">: </span>
698
+ {g.removed.map((r, i) => (
699
+ <span key={i} className="text-amber-700">
700
+ {i > 0 && ', '}
701
+ <code className="bg-amber-500/10 px-0.5 rounded">{r}</code>
702
+ </span>
703
+ ))}
704
+ </div>
705
+ ))}
706
+ </div>
707
+ )}
708
+ </div>
392
709
 
393
710
  {/* Results */}
394
711
  <div className="overflow-y-auto flex-1">
395
- {error && (
396
- <div className="px-4 py-3 text-sm text-red-500">{error}</div>
397
- )}
398
- {!error && filtered.length === 0 && query.trim() && !loading && (
399
- <div className="px-4 py-8 text-center text-muted-foreground text-sm">
400
- No results found
401
- </div>
712
+ {error && <div className="px-4 py-3 text-sm text-red-500">{error}</div>}
713
+ {!error && results.length === 0 && query.trim() && !loading && (
714
+ <div className="px-4 py-8 text-center text-muted-foreground text-sm">No results found</div>
402
715
  )}
403
716
  {!error && !query.trim() && !loading && (
404
717
  <div className="px-4 py-8 text-center text-muted-foreground text-sm">
405
718
  Type a query to search across all documents
406
719
  </div>
407
720
  )}
408
- {filtered.map((r) => (
409
- <ResultRow key={r.browsePath} result={r} onNavigate={handleNavigate} />
721
+ {results.map((r) => (
722
+ <ResultRow key={r.browsePath} result={r} facets={facets} onNavigate={handleNavigate} onChipClick={handleChipClick} />
410
723
  ))}
411
724
  </div>
412
725
 
413
726
  {/* Footer */}
414
- {filtered.length > 0 && (
727
+ {results.length > 0 && (
415
728
  <div className="px-4 py-2 border-t border-border text-xs text-muted-foreground">
416
- {filtered.length} result{filtered.length !== 1 ? 's' : ''}
417
- {filtered.length < results.length && ` (${results.length} total, ${results.length - filtered.length} filtered)`}
729
+ {results.length} result{results.length !== 1 ? 's' : ''}
418
730
  </div>
419
731
  )}
420
732
  </div>
421
733
  </div>
422
734
  );
423
735
  }
424
-