@gmickel/gno 0.16.0 → 0.17.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.
Files changed (37) hide show
  1. package/README.md +36 -1
  2. package/package.json +4 -1
  3. package/src/cli/commands/ask.ts +9 -0
  4. package/src/cli/commands/query.ts +3 -2
  5. package/src/cli/pager.ts +1 -1
  6. package/src/cli/program.ts +89 -0
  7. package/src/core/links.ts +92 -20
  8. package/src/ingestion/sync.ts +267 -23
  9. package/src/ingestion/types.ts +2 -0
  10. package/src/ingestion/walker.ts +2 -1
  11. package/src/mcp/tools/index.ts +30 -1
  12. package/src/mcp/tools/query.ts +22 -2
  13. package/src/mcp/tools/search.ts +8 -0
  14. package/src/mcp/tools/vsearch.ts +8 -0
  15. package/src/pipeline/answer.ts +324 -7
  16. package/src/pipeline/expansion.ts +243 -7
  17. package/src/pipeline/explain.ts +93 -5
  18. package/src/pipeline/hybrid.ts +240 -57
  19. package/src/pipeline/query-modes.ts +125 -0
  20. package/src/pipeline/rerank.ts +34 -13
  21. package/src/pipeline/search.ts +41 -3
  22. package/src/pipeline/temporal.ts +257 -0
  23. package/src/pipeline/types.ts +58 -0
  24. package/src/pipeline/vsearch.ts +107 -9
  25. package/src/serve/public/app.tsx +1 -3
  26. package/src/serve/public/globals.built.css +2 -2
  27. package/src/serve/public/lib/retrieval-filters.ts +167 -0
  28. package/src/serve/public/pages/Ask.tsx +339 -109
  29. package/src/serve/public/pages/Browse.tsx +71 -5
  30. package/src/serve/public/pages/DocView.tsx +2 -21
  31. package/src/serve/public/pages/Search.tsx +507 -120
  32. package/src/serve/routes/api.ts +202 -2
  33. package/src/store/migrations/006-document-metadata.ts +104 -0
  34. package/src/store/migrations/007-document-date-fields.ts +24 -0
  35. package/src/store/migrations/index.ts +3 -1
  36. package/src/store/sqlite/adapter.ts +218 -5
  37. package/src/store/types.ts +46 -0
@@ -1,4 +1,11 @@
1
- import { ArrowLeft, FileText, Search as SearchIcon, XIcon } from "lucide-react";
1
+ import {
2
+ ArrowLeft,
3
+ ChevronDown,
4
+ FileText,
5
+ Search as SearchIcon,
6
+ SlidersHorizontal,
7
+ XIcon,
8
+ } from "lucide-react";
2
9
  import { useCallback, useEffect, useMemo, useState } from "react";
3
10
 
4
11
  import { Loader } from "../components/ai-elements/loader";
@@ -10,9 +17,28 @@ import {
10
17
  import { Badge } from "../components/ui/badge";
11
18
  import { Button } from "../components/ui/button";
12
19
  import { Card, CardContent } from "../components/ui/card";
20
+ import {
21
+ Collapsible,
22
+ CollapsibleContent,
23
+ CollapsibleTrigger,
24
+ } from "../components/ui/collapsible";
13
25
  import { Input } from "../components/ui/input";
26
+ import {
27
+ Select,
28
+ SelectContent,
29
+ SelectItem,
30
+ SelectTrigger,
31
+ SelectValue,
32
+ } from "../components/ui/select";
14
33
  import { apiFetch } from "../hooks/use-api";
15
34
  import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
35
+ import {
36
+ applyFiltersToUrl,
37
+ parseFiltersFromSearch,
38
+ type QueryModeEntry,
39
+ type QueryModeType,
40
+ type TagMode,
41
+ } from "../lib/retrieval-filters";
16
42
  import { cn } from "../lib/utils";
17
43
 
18
44
  /**
@@ -56,49 +82,6 @@ function renderSnippet(snippet: string): React.ReactNode {
56
82
  return parts;
57
83
  }
58
84
 
59
- /**
60
- * Tag grammar validation (matches src/core/tags.ts).
61
- * Validates that a tag follows the grammar for filtering.
62
- */
63
- const TAG_SEGMENT_REGEX = /^[\p{Ll}\p{Lo}\p{N}][\p{Ll}\p{Lo}\p{N}\-.]*$/u;
64
-
65
- function normalizeTag(tag: string): string {
66
- return tag.trim().normalize("NFC").toLowerCase();
67
- }
68
-
69
- function isValidTag(tag: string): boolean {
70
- if (tag.length === 0) return false;
71
- if (tag.startsWith("/") || tag.endsWith("/")) return false;
72
- const segments = tag.split("/");
73
- for (const segment of segments) {
74
- if (segment.length === 0) return false;
75
- if (!TAG_SEGMENT_REGEX.test(segment)) return false;
76
- }
77
- return true;
78
- }
79
-
80
- /** Parse and validate tags from URL search params */
81
- function parseTagsFromUrl(): string[] {
82
- const params = new URLSearchParams(window.location.search);
83
- const tagsAny = params.get("tagsAny");
84
- if (!tagsAny) return [];
85
- return tagsAny
86
- .split(",")
87
- .map((t) => normalizeTag(t))
88
- .filter((t) => t.length > 0 && isValidTag(t));
89
- }
90
-
91
- /** Update URL with tag filters */
92
- function updateUrlWithTags(tags: string[]): void {
93
- const url = new URL(window.location.href);
94
- if (tags.length > 0) {
95
- url.searchParams.set("tagsAny", tags.join(","));
96
- } else {
97
- url.searchParams.delete("tagsAny");
98
- }
99
- window.history.replaceState({}, "", url.toString());
100
- }
101
-
102
85
  interface PageProps {
103
86
  navigate: (to: string | number) => void;
104
87
  }
@@ -124,6 +107,11 @@ interface SearchResponse {
124
107
  expanded?: boolean;
125
108
  reranked?: boolean;
126
109
  vectorsUsed?: boolean;
110
+ queryModes?: {
111
+ term: number;
112
+ intent: number;
113
+ hyde: boolean;
114
+ };
127
115
  };
128
116
  }
129
117
 
@@ -134,9 +122,24 @@ interface Capabilities {
134
122
  answer: boolean;
135
123
  }
136
124
 
125
+ interface Collection {
126
+ name: string;
127
+ }
128
+
137
129
  const THOROUGHNESS_ORDER: Thoroughness[] = ["fast", "balanced", "thorough"];
138
130
 
131
+ const QUERY_MODE_LABEL: Record<QueryModeType, string> = {
132
+ term: "Term",
133
+ intent: "Intent",
134
+ hyde: "HyDE",
135
+ };
136
+
139
137
  export default function Search({ navigate }: PageProps) {
138
+ const initialFilters = useMemo(
139
+ () => parseFiltersFromSearch(window.location.search),
140
+ []
141
+ );
142
+
140
143
  const [query, setQuery] = useState("");
141
144
  const [thoroughness, setThoroughness] = useState<Thoroughness>("balanced");
142
145
  const [results, setResults] = useState<SearchResult[]>([]);
@@ -145,18 +148,64 @@ export default function Search({ navigate }: PageProps) {
145
148
  const [error, setError] = useState<string | null>(null);
146
149
  const [searched, setSearched] = useState(false);
147
150
  const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
151
+ const [collections, setCollections] = useState<Collection[]>([]);
152
+
153
+ const [showAdvanced, setShowAdvanced] = useState(
154
+ Boolean(
155
+ initialFilters.collection ||
156
+ initialFilters.since ||
157
+ initialFilters.until ||
158
+ initialFilters.category ||
159
+ initialFilters.author ||
160
+ initialFilters.queryModes.length > 0
161
+ )
162
+ );
148
163
 
149
- // Tag filter state - initialized from URL
150
- const [activeTags, setActiveTags] = useState<string[]>(() =>
151
- parseTagsFromUrl()
164
+ const [activeTags, setActiveTags] = useState<string[]>(initialFilters.tags);
165
+ const [tagMode, setTagMode] = useState<TagMode>(initialFilters.tagMode);
166
+ const [selectedCollection, setSelectedCollection] = useState(
167
+ initialFilters.collection
168
+ );
169
+ const [since, setSince] = useState(initialFilters.since);
170
+ const [until, setUntil] = useState(initialFilters.until);
171
+ const [category, setCategory] = useState(initialFilters.category);
172
+ const [author, setAuthor] = useState(initialFilters.author);
173
+ const [queryModes, setQueryModes] = useState<QueryModeEntry[]>(
174
+ initialFilters.queryModes
152
175
  );
176
+ const [queryModeDraft, setQueryModeDraft] = useState<QueryModeType>("term");
177
+ const [queryModeText, setQueryModeText] = useState("");
178
+ const [queryModeError, setQueryModeError] = useState<string | null>(null);
179
+ const [showMobileTags, setShowMobileTags] = useState(false);
180
+
181
+ const hybridAvailable = capabilities?.hybrid ?? false;
182
+ const forceHybridForModes = thoroughness === "fast" && queryModes.length > 0;
153
183
 
154
- // Sync URL when tags change
184
+ // Sync URL as filter state changes.
155
185
  useEffect(() => {
156
- updateUrlWithTags(activeTags);
157
- }, [activeTags]);
186
+ const url = new URL(window.location.href);
187
+ applyFiltersToUrl(url, {
188
+ collection: selectedCollection,
189
+ since,
190
+ until,
191
+ category,
192
+ author,
193
+ tagMode,
194
+ tags: activeTags,
195
+ queryModes,
196
+ });
197
+ window.history.replaceState({}, "", url.toString());
198
+ }, [
199
+ activeTags,
200
+ author,
201
+ category,
202
+ queryModes,
203
+ selectedCollection,
204
+ since,
205
+ tagMode,
206
+ until,
207
+ ]);
158
208
 
159
- // Tag filter handlers
160
209
  const handleTagSelect = useCallback((tag: string) => {
161
210
  setActiveTags((prev) => (prev.includes(tag) ? prev : [...prev, tag]));
162
211
  }, []);
@@ -165,28 +214,29 @@ export default function Search({ navigate }: PageProps) {
165
214
  setActiveTags((prev) => prev.filter((t) => t !== tag));
166
215
  }, []);
167
216
 
168
- // Fetch capabilities on mount
169
217
  useEffect(() => {
170
- async function fetchCapabilities() {
171
- const { data } = await apiFetch<Capabilities>("/api/capabilities");
172
- if (data) {
173
- setCapabilities(data);
174
- // Auto-select balanced if hybrid available, otherwise fast (BM25)
175
- if (data.hybrid) {
176
- setThoroughness("balanced");
177
- } else {
178
- setThoroughness("fast");
179
- }
218
+ async function bootstrap(): Promise<void> {
219
+ const [capabilitiesResult, collectionsResult] = await Promise.all([
220
+ apiFetch<Capabilities>("/api/capabilities"),
221
+ apiFetch<Collection[]>("/api/collections"),
222
+ ]);
223
+
224
+ if (capabilitiesResult.data) {
225
+ const caps = capabilitiesResult.data;
226
+ setCapabilities(caps);
227
+ setThoroughness(caps.hybrid ? "balanced" : "fast");
228
+ }
229
+
230
+ if (collectionsResult.data) {
231
+ setCollections(collectionsResult.data);
180
232
  }
181
233
  }
182
- void fetchCapabilities();
234
+
235
+ void bootstrap();
183
236
  }, []);
184
237
 
185
- // Cycle thoroughness with 't' key (only cycles to supported modes)
186
- const hybridAvailable = capabilities?.hybrid ?? false;
187
238
  const cycleThoroughness = useCallback(() => {
188
239
  setThoroughness((current) => {
189
- // If hybrid not available, stay on fast
190
240
  if (!hybridAvailable) return "fast";
191
241
  const currentIdx = THOROUGHNESS_ORDER.indexOf(current);
192
242
  const nextIdx = (currentIdx + 1) % THOROUGHNESS_ORDER.length;
@@ -198,9 +248,30 @@ export default function Search({ navigate }: PageProps) {
198
248
  () => [{ key: "t", action: cycleThoroughness }],
199
249
  [cycleThoroughness]
200
250
  );
201
-
202
251
  useKeyboardShortcuts(shortcuts);
203
252
 
253
+ const handleAddQueryMode = useCallback(() => {
254
+ const text = queryModeText.trim();
255
+ if (!text) {
256
+ return;
257
+ }
258
+ if (
259
+ queryModeDraft === "hyde" &&
260
+ queryModes.some((queryMode) => queryMode.mode === "hyde")
261
+ ) {
262
+ setQueryModeError("Only one HyDE mode is allowed.");
263
+ return;
264
+ }
265
+ setQueryModes((prev) => [...prev, { mode: queryModeDraft, text }]);
266
+ setQueryModeText("");
267
+ setQueryModeError(null);
268
+ }, [queryModeDraft, queryModeText, queryModes]);
269
+
270
+ const handleRemoveQueryMode = useCallback((index: number) => {
271
+ setQueryModes((prev) => prev.filter((_, i) => i !== index));
272
+ setQueryModeError(null);
273
+ }, []);
274
+
204
275
  const handleSearch = useCallback(
205
276
  async (e?: React.FormEvent) => {
206
277
  e?.preventDefault();
@@ -212,30 +283,50 @@ export default function Search({ navigate }: PageProps) {
212
283
  setError(null);
213
284
  setSearched(true);
214
285
 
215
- // Fast uses BM25 (/api/search), balanced/thorough use hybrid (/api/query)
216
- const useBm25 = thoroughness === "fast";
286
+ const useBm25 = thoroughness === "fast" && queryModes.length === 0;
217
287
  const endpoint = useBm25 ? "/api/search" : "/api/query";
288
+ const body: Record<string, unknown> = {
289
+ query,
290
+ limit: 20,
291
+ };
218
292
 
219
- // Build request body
220
- const body: Record<string, unknown> = { query, limit: 20 };
221
-
222
- // Add tag filters if present
293
+ if (selectedCollection) {
294
+ body.collection = selectedCollection;
295
+ }
296
+ if (since) {
297
+ body.since = since;
298
+ }
299
+ if (until) {
300
+ body.until = until;
301
+ }
302
+ if (category.trim()) {
303
+ body.category = category.trim();
304
+ }
305
+ if (author.trim()) {
306
+ body.author = author.trim();
307
+ }
223
308
  if (activeTags.length > 0) {
224
- body.tagsAny = activeTags.join(",");
309
+ if (tagMode === "all") {
310
+ body.tagsAll = activeTags.join(",");
311
+ } else {
312
+ body.tagsAny = activeTags.join(",");
313
+ }
225
314
  }
226
315
 
227
316
  if (!useBm25) {
228
- // Map thoroughness to noExpand/noRerank flags
229
- // balanced: with reranking, no expansion (~2-3s)
230
- // thorough: full pipeline (~5-8s)
231
- if (thoroughness === "balanced") {
317
+ if (thoroughness === "fast") {
318
+ body.noExpand = true;
319
+ body.noRerank = true;
320
+ } else if (thoroughness === "balanced") {
232
321
  body.noExpand = true;
233
322
  body.noRerank = false;
234
323
  } else {
235
- // thorough
236
324
  body.noExpand = false;
237
325
  body.noRerank = false;
238
326
  }
327
+ if (queryModes.length > 0) {
328
+ body.queryModes = queryModes;
329
+ }
239
330
  }
240
331
 
241
332
  const { data, error: fetchError } = await apiFetch<SearchResponse>(
@@ -256,18 +347,37 @@ export default function Search({ navigate }: PageProps) {
256
347
  setMeta(data.meta);
257
348
  }
258
349
  },
259
- [query, thoroughness, activeTags]
350
+ [
351
+ activeTags,
352
+ author,
353
+ category,
354
+ query,
355
+ queryModes,
356
+ selectedCollection,
357
+ since,
358
+ tagMode,
359
+ thoroughness,
360
+ until,
361
+ ]
260
362
  );
261
363
 
262
- // Re-search when tags change (if we've already searched)
364
+ // Re-search when filters change after an initial search.
263
365
  useEffect(() => {
264
366
  if (searched && query.trim()) {
265
367
  void handleSearch();
266
368
  }
267
369
  // eslint-disable-next-line react-hooks/exhaustive-deps
268
- }, [activeTags]);
370
+ }, [
371
+ activeTags,
372
+ author,
373
+ category,
374
+ queryModes,
375
+ selectedCollection,
376
+ since,
377
+ tagMode,
378
+ until,
379
+ ]);
269
380
 
270
- // Description for current thoroughness
271
381
  const thoroughnessDesc =
272
382
  thoroughness === "fast"
273
383
  ? "Keyword search (BM25)"
@@ -275,9 +385,31 @@ export default function Search({ navigate }: PageProps) {
275
385
  ? "Hybrid + reranking"
276
386
  : "Full pipeline with expansion";
277
387
 
388
+ const activeFilterPills = [
389
+ selectedCollection ? `collection:${selectedCollection}` : null,
390
+ since ? `since:${since}` : null,
391
+ until ? `until:${until}` : null,
392
+ category.trim() ? `category:${category.trim()}` : null,
393
+ author.trim() ? `author:${author.trim()}` : null,
394
+ queryModes.length > 0 ? `${queryModes.length} query mode(s)` : null,
395
+ ].filter((pill): pill is string => Boolean(pill));
396
+
397
+ const hasActiveFilters =
398
+ activeFilterPills.length > 0 || activeTags.length > 0;
399
+
400
+ const clearAdvancedFilters = () => {
401
+ setSelectedCollection("");
402
+ setSince("");
403
+ setUntil("");
404
+ setCategory("");
405
+ setAuthor("");
406
+ setQueryModes([]);
407
+ setQueryModeText("");
408
+ setQueryModeError(null);
409
+ };
410
+
278
411
  return (
279
412
  <div className="min-h-screen">
280
- {/* Header */}
281
413
  <header className="glass sticky top-0 z-10 border-border/50 border-b">
282
414
  <div className="flex items-center gap-4 px-8 py-4">
283
415
  <Button
@@ -293,9 +425,7 @@ export default function Search({ navigate }: PageProps) {
293
425
  </div>
294
426
  </header>
295
427
 
296
- {/* Main content with sidebar */}
297
428
  <div className="flex">
298
- {/* Sidebar - Tag Facets */}
299
429
  <aside className="sticky top-[65px] hidden h-[calc(100vh-65px)] w-64 shrink-0 overflow-y-auto border-border/30 border-r lg:block">
300
430
  <TagFacets
301
431
  activeTags={activeTags}
@@ -305,13 +435,10 @@ export default function Search({ navigate }: PageProps) {
305
435
  />
306
436
  </aside>
307
437
 
308
- {/* Main content */}
309
438
  <main className="min-w-0 flex-1 p-8">
310
439
  <div className="mx-auto max-w-3xl">
311
- {/* Search Form */}
312
- <form className="mb-6" onSubmit={handleSearch}>
440
+ <form className="mb-6 space-y-4" onSubmit={handleSearch}>
313
441
  <div className="group relative">
314
- {/* Gradient border effect on focus */}
315
442
  <div className="pointer-events-none absolute -inset-[1px] rounded-lg bg-gradient-to-r from-primary/50 via-primary to-primary/50 opacity-0 blur-sm transition-opacity duration-300 group-focus-within:opacity-100" />
316
443
  <div className="relative">
317
444
  <SearchIcon className="absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground transition-colors duration-200 group-focus-within:text-primary" />
@@ -334,8 +461,7 @@ export default function Search({ navigate }: PageProps) {
334
461
  </div>
335
462
  </div>
336
463
 
337
- {/* Thoroughness selector */}
338
- <div className="mt-4 flex flex-wrap items-center gap-4">
464
+ <div className="flex flex-wrap items-center gap-4">
339
465
  <ThoroughnessSelector
340
466
  disabled={
341
467
  loading || (!hybridAvailable && thoroughness !== "fast")
@@ -348,40 +474,292 @@ export default function Search({ navigate }: PageProps) {
348
474
  {thoroughnessDesc}
349
475
  </span>
350
476
 
477
+ {forceHybridForModes && (
478
+ <span className="text-amber-500/80 text-xs">
479
+ query modes active: using hybrid endpoint
480
+ </span>
481
+ )}
482
+
351
483
  {!hybridAvailable && thoroughness !== "fast" && (
352
484
  <span className="text-amber-500/70 text-xs">
353
485
  (vectors not available)
354
486
  </span>
355
487
  )}
356
488
  </div>
489
+
490
+ <Collapsible onOpenChange={setShowAdvanced} open={showAdvanced}>
491
+ <CollapsibleTrigger asChild>
492
+ <Button
493
+ className="gap-2"
494
+ size="sm"
495
+ type="button"
496
+ variant="outline"
497
+ >
498
+ <SlidersHorizontal className="size-4" />
499
+ Advanced Retrieval
500
+ <ChevronDown
501
+ className={cn(
502
+ "size-4 transition-transform",
503
+ showAdvanced && "rotate-180"
504
+ )}
505
+ />
506
+ </Button>
507
+ </CollapsibleTrigger>
508
+ <CollapsibleContent className="pt-3">
509
+ <Card className="border-border/50 bg-card/50">
510
+ <CardContent className="space-y-4 pt-4">
511
+ <div className="grid gap-3 md:grid-cols-2">
512
+ <div>
513
+ <p className="mb-1 text-muted-foreground text-xs">
514
+ Collection
515
+ </p>
516
+ <Select
517
+ onValueChange={(value) =>
518
+ setSelectedCollection(
519
+ value === "all" ? "" : value
520
+ )
521
+ }
522
+ value={selectedCollection || "all"}
523
+ >
524
+ <SelectTrigger className="w-full">
525
+ <SelectValue placeholder="All collections" />
526
+ </SelectTrigger>
527
+ <SelectContent>
528
+ <SelectItem value="all">
529
+ All collections
530
+ </SelectItem>
531
+ {collections.map((collection) => (
532
+ <SelectItem
533
+ key={collection.name}
534
+ value={collection.name}
535
+ >
536
+ {collection.name}
537
+ </SelectItem>
538
+ ))}
539
+ </SelectContent>
540
+ </Select>
541
+ </div>
542
+
543
+ <div>
544
+ <p className="mb-1 text-muted-foreground text-xs">
545
+ Author
546
+ </p>
547
+ <Input
548
+ onChange={(e) => setAuthor(e.target.value)}
549
+ placeholder="gordon"
550
+ value={author}
551
+ />
552
+ </div>
553
+
554
+ <div>
555
+ <p className="mb-1 text-muted-foreground text-xs">
556
+ Category
557
+ </p>
558
+ <Input
559
+ onChange={(e) => setCategory(e.target.value)}
560
+ placeholder="engineering, research"
561
+ value={category}
562
+ />
563
+ </div>
564
+
565
+ <div className="grid gap-3 sm:grid-cols-2">
566
+ <div>
567
+ <p className="mb-1 text-muted-foreground text-xs">
568
+ Since
569
+ </p>
570
+ <Input
571
+ onChange={(e) => setSince(e.target.value)}
572
+ type="date"
573
+ value={since}
574
+ />
575
+ </div>
576
+ <div>
577
+ <p className="mb-1 text-muted-foreground text-xs">
578
+ Until
579
+ </p>
580
+ <Input
581
+ onChange={(e) => setUntil(e.target.value)}
582
+ type="date"
583
+ value={until}
584
+ />
585
+ </div>
586
+ </div>
587
+ </div>
588
+
589
+ <div className="flex flex-wrap items-center gap-2">
590
+ <span className="text-muted-foreground text-xs">
591
+ Tag match:
592
+ </span>
593
+ <Button
594
+ onClick={() => setTagMode("any")}
595
+ size="sm"
596
+ type="button"
597
+ variant={tagMode === "any" ? "default" : "outline"}
598
+ >
599
+ Any
600
+ </Button>
601
+ <Button
602
+ onClick={() => setTagMode("all")}
603
+ size="sm"
604
+ type="button"
605
+ variant={tagMode === "all" ? "default" : "outline"}
606
+ >
607
+ All
608
+ </Button>
609
+
610
+ <Button
611
+ className="ml-auto"
612
+ onClick={clearAdvancedFilters}
613
+ size="sm"
614
+ type="button"
615
+ variant="ghost"
616
+ >
617
+ Clear advanced
618
+ </Button>
619
+ </div>
620
+
621
+ <div className="space-y-2">
622
+ <p className="text-muted-foreground text-xs">
623
+ Query modes (term, intent, hyde)
624
+ </p>
625
+ <div className="flex flex-wrap items-center gap-2">
626
+ <Select
627
+ onValueChange={(value) =>
628
+ setQueryModeDraft(value as QueryModeType)
629
+ }
630
+ value={queryModeDraft}
631
+ >
632
+ <SelectTrigger className="w-[120px]">
633
+ <SelectValue />
634
+ </SelectTrigger>
635
+ <SelectContent>
636
+ <SelectItem value="term">Term</SelectItem>
637
+ <SelectItem value="intent">Intent</SelectItem>
638
+ <SelectItem value="hyde">HyDE</SelectItem>
639
+ </SelectContent>
640
+ </Select>
641
+ <Input
642
+ className="min-w-[220px] flex-1"
643
+ onChange={(e) => setQueryModeText(e.target.value)}
644
+ onKeyDown={(e) => {
645
+ if (e.key === "Enter") {
646
+ e.preventDefault();
647
+ handleAddQueryMode();
648
+ }
649
+ }}
650
+ placeholder="Add query mode text"
651
+ value={queryModeText}
652
+ />
653
+ <Button
654
+ onClick={handleAddQueryMode}
655
+ size="sm"
656
+ type="button"
657
+ variant="outline"
658
+ >
659
+ Add mode
660
+ </Button>
661
+ </div>
662
+
663
+ {queryModeError && (
664
+ <p className="text-destructive text-xs">
665
+ {queryModeError}
666
+ </p>
667
+ )}
668
+
669
+ {queryModes.length > 0 && (
670
+ <div className="flex flex-wrap gap-2">
671
+ {queryModes.map((queryMode, index) => (
672
+ <button
673
+ className={cn(
674
+ "group inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/10",
675
+ "px-2.5 py-1 font-mono text-[11px] text-primary transition-all duration-150",
676
+ "hover:border-primary/50 hover:bg-primary/20"
677
+ )}
678
+ key={`${queryMode.mode}:${queryMode.text}:${index}`}
679
+ onClick={() => handleRemoveQueryMode(index)}
680
+ type="button"
681
+ >
682
+ <span>{`${QUERY_MODE_LABEL[queryMode.mode]}: ${queryMode.text}`}</span>
683
+ <XIcon className="size-3 opacity-60 transition-opacity group-hover:opacity-100" />
684
+ </button>
685
+ ))}
686
+ </div>
687
+ )}
688
+ </div>
689
+ </CardContent>
690
+ </Card>
691
+ </CollapsibleContent>
692
+ </Collapsible>
357
693
  </form>
358
694
 
359
- {/* Active Tag Filter Chips */}
360
- {activeTags.length > 0 && (
695
+ <div className="mb-6 lg:hidden">
696
+ <Collapsible
697
+ onOpenChange={setShowMobileTags}
698
+ open={showMobileTags}
699
+ >
700
+ <CollapsibleTrigger asChild>
701
+ <Button
702
+ className="gap-2"
703
+ size="sm"
704
+ type="button"
705
+ variant="outline"
706
+ >
707
+ <SlidersHorizontal className="size-4" />
708
+ Tags
709
+ <ChevronDown
710
+ className={cn(
711
+ "size-4 transition-transform",
712
+ showMobileTags && "rotate-180"
713
+ )}
714
+ />
715
+ </Button>
716
+ </CollapsibleTrigger>
717
+ <CollapsibleContent className="pt-3">
718
+ <TagFacets
719
+ activeTags={activeTags}
720
+ className="rounded-md border border-border/50"
721
+ onTagRemove={handleTagRemove}
722
+ onTagSelect={handleTagSelect}
723
+ />
724
+ </CollapsibleContent>
725
+ </Collapsible>
726
+ </div>
727
+
728
+ {hasActiveFilters && (
361
729
  <div className="mb-6 flex flex-wrap items-center gap-2">
362
730
  <span className="font-mono text-muted-foreground text-xs">
363
- Filtering by:
731
+ Filters:
364
732
  </span>
733
+ {activeFilterPills.map((pill) => (
734
+ <Badge
735
+ className="font-mono text-[10px]"
736
+ key={pill}
737
+ variant="outline"
738
+ >
739
+ {pill}
740
+ </Badge>
741
+ ))}
365
742
  {activeTags.map((tag) => (
366
743
  <button
367
744
  className={cn(
368
- "group inline-flex items-center gap-1",
369
- "rounded-full border border-primary/30 bg-primary/10",
370
- "px-2.5 py-1 font-mono text-xs text-primary",
371
- "transition-all duration-150",
745
+ "group inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/10",
746
+ "px-2.5 py-1 font-mono text-xs text-primary transition-all duration-150",
372
747
  "hover:border-primary/50 hover:bg-primary/20"
373
748
  )}
374
749
  key={tag}
375
750
  onClick={() => handleTagRemove(tag)}
376
751
  type="button"
377
752
  >
378
- <span>{tag}</span>
753
+ <span>{`${tagMode}:${tag}`}</span>
379
754
  <XIcon className="size-3 opacity-60 transition-opacity group-hover:opacity-100" />
380
755
  </button>
381
756
  ))}
382
757
  <button
383
758
  className="font-mono text-muted-foreground text-xs underline-offset-2 hover:text-foreground hover:underline"
384
- onClick={() => setActiveTags([])}
759
+ onClick={() => {
760
+ setActiveTags([]);
761
+ clearAdvancedFilters();
762
+ }}
385
763
  type="button"
386
764
  >
387
765
  Clear all
@@ -389,7 +767,6 @@ export default function Search({ navigate }: PageProps) {
389
767
  </div>
390
768
  )}
391
769
 
392
- {/* Error */}
393
770
  {error && (
394
771
  <Card className="mb-6 border-destructive bg-destructive/10">
395
772
  <CardContent className="py-4 text-destructive">
@@ -398,7 +775,6 @@ export default function Search({ navigate }: PageProps) {
398
775
  </Card>
399
776
  )}
400
777
 
401
- {/* Loading */}
402
778
  {loading && (
403
779
  <div className="flex flex-col items-center justify-center gap-4 py-20">
404
780
  <Loader className="text-primary" size={32} />
@@ -406,7 +782,6 @@ export default function Search({ navigate }: PageProps) {
406
782
  </div>
407
783
  )}
408
784
 
409
- {/* Empty state */}
410
785
  {!loading && searched && results.length === 0 && !error && (
411
786
  <div className="py-20 text-center">
412
787
  <div className="relative mx-auto mb-6 size-20">
@@ -416,10 +791,8 @@ export default function Search({ navigate }: PageProps) {
416
791
  </div>
417
792
  <h3 className="mb-2 font-semibold text-xl">No matches found</h3>
418
793
  <p className="mx-auto mb-6 max-w-sm text-muted-foreground">
419
- We couldn't find any documents matching "{query}"
420
- {activeTags.length > 0 &&
421
- ` with tags: ${activeTags.join(", ")}`}
422
- . Try different keywords or remove some filters.
794
+ We couldn't find any documents matching "{query}". Try fewer
795
+ filters, different keywords, or another depth mode.
423
796
  </p>
424
797
  <div className="flex justify-center gap-3">
425
798
  <Button
@@ -429,9 +802,12 @@ export default function Search({ navigate }: PageProps) {
429
802
  >
430
803
  Clear search
431
804
  </Button>
432
- {activeTags.length > 0 && (
805
+ {hasActiveFilters && (
433
806
  <Button
434
- onClick={() => setActiveTags([])}
807
+ onClick={() => {
808
+ setActiveTags([]);
809
+ clearAdvancedFilters();
810
+ }}
435
811
  size="sm"
436
812
  variant="outline"
437
813
  >
@@ -445,7 +821,6 @@ export default function Search({ navigate }: PageProps) {
445
821
  </div>
446
822
  )}
447
823
 
448
- {/* Results */}
449
824
  {!loading && results.length > 0 && (
450
825
  <div className="space-y-4">
451
826
  <div className="mb-6 flex items-center justify-between">
@@ -478,40 +853,52 @@ export default function Search({ navigate }: PageProps) {
478
853
  reranked
479
854
  </Badge>
480
855
  )}
856
+ {meta.queryModes &&
857
+ (meta.queryModes.term > 0 ||
858
+ meta.queryModes.intent > 0 ||
859
+ meta.queryModes.hyde) && (
860
+ <Badge
861
+ className="font-mono text-[10px]"
862
+ variant="secondary"
863
+ >
864
+ modes
865
+ </Badge>
866
+ )}
481
867
  </div>
482
868
  )}
483
869
  </div>
484
- {results.map((r, i) => (
870
+ {results.map((result, i) => (
485
871
  <Card
486
872
  className="group animate-fade-in cursor-pointer opacity-0 transition-all hover:border-primary/50 hover:bg-card/80"
487
- key={`${r.docid}-${i}`}
873
+ key={`${result.docid}-${i}`}
488
874
  onClick={() =>
489
- navigate(`/doc?uri=${encodeURIComponent(r.uri)}`)
875
+ navigate(`/doc?uri=${encodeURIComponent(result.uri)}`)
490
876
  }
491
877
  style={{ animationDelay: `${i * 0.05}s` }}
492
878
  >
493
879
  <CardContent className="py-4">
494
880
  <div className="mb-2 flex items-start justify-between gap-4">
495
881
  <h3 className="font-medium text-primary underline-offset-2 group-hover:underline">
496
- {r.title || r.uri.split("/").pop()}
882
+ {result.title || result.uri.split("/").pop()}
497
883
  </h3>
498
884
  <Badge
499
885
  className="shrink-0 font-mono text-xs"
500
886
  variant="secondary"
501
887
  >
502
- {(r.score * 100).toFixed(0)}%
888
+ {(result.score * 100).toFixed(0)}%
503
889
  </Badge>
504
890
  </div>
505
891
  <p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
506
- {renderSnippet(r.snippet)}
892
+ {renderSnippet(result.snippet)}
507
893
  </p>
508
894
  <div className="mt-2 flex items-center gap-2">
509
895
  <p className="truncate font-mono text-muted-foreground/60 text-xs">
510
- {r.uri}
896
+ {result.uri}
511
897
  </p>
512
- {r.snippetRange && (
898
+ {result.snippetRange && (
513
899
  <span className="shrink-0 font-mono text-[10px] text-muted-foreground/40">
514
- L{r.snippetRange.startLine}-{r.snippetRange.endLine}
900
+ L{result.snippetRange.startLine}-
901
+ {result.snippetRange.endLine}
515
902
  </span>
516
903
  )}
517
904
  </div>