@gmickel/gno 0.9.0 → 0.9.2

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.
@@ -5,7 +5,7 @@ import {
5
5
  FileText,
6
6
  Sparkles,
7
7
  } from "lucide-react";
8
- import { useEffect, useRef, useState } from "react";
8
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9
9
 
10
10
  import { Loader } from "../components/ai-elements/loader";
11
11
  import {
@@ -15,11 +15,16 @@ import {
15
15
  SourcesTrigger,
16
16
  } from "../components/ai-elements/sources";
17
17
  import { AIModelSelector } from "../components/AIModelSelector";
18
+ import {
19
+ ThoroughnessSelector,
20
+ type Thoroughness,
21
+ } from "../components/ThoroughnessSelector";
18
22
  import { Badge } from "../components/ui/badge";
19
23
  import { Button } from "../components/ui/button";
20
24
  import { Card, CardContent } from "../components/ui/card";
21
25
  import { Textarea } from "../components/ui/textarea";
22
26
  import { apiFetch } from "../hooks/use-api";
27
+ import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
23
28
 
24
29
  interface PageProps {
25
30
  navigate: (to: string | number) => void;
@@ -75,6 +80,8 @@ interface ConversationEntry {
75
80
  error?: string;
76
81
  }
77
82
 
83
+ const THOROUGHNESS_ORDER: Thoroughness[] = ["fast", "balanced", "thorough"];
84
+
78
85
  /**
79
86
  * Render answer text with clickable citation badges.
80
87
  * Citations like [1] become clickable to navigate to source.
@@ -133,6 +140,7 @@ export default function Ask({ navigate }: PageProps) {
133
140
  const [query, setQuery] = useState("");
134
141
  const [conversation, setConversation] = useState<ConversationEntry[]>([]);
135
142
  const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
143
+ const [thoroughness, setThoroughness] = useState<Thoroughness>("balanced");
136
144
  const messagesEndRef = useRef<HTMLDivElement>(null);
137
145
  const textareaRef = useRef<HTMLTextAreaElement>(null);
138
146
 
@@ -142,11 +150,33 @@ export default function Ask({ navigate }: PageProps) {
142
150
  const { data } = await apiFetch<Capabilities>("/api/capabilities");
143
151
  if (data) {
144
152
  setCapabilities(data);
153
+ // Auto-select balanced if hybrid available, otherwise fast
154
+ if (data.hybrid) {
155
+ setThoroughness("balanced");
156
+ } else {
157
+ setThoroughness("fast");
158
+ }
145
159
  }
146
160
  }
147
161
  void fetchCapabilities();
148
162
  }, []);
149
163
 
164
+ // Cycle thoroughness with 't' key
165
+ const cycleThoroughness = useCallback(() => {
166
+ setThoroughness((current) => {
167
+ const currentIdx = THOROUGHNESS_ORDER.indexOf(current);
168
+ const nextIdx = (currentIdx + 1) % THOROUGHNESS_ORDER.length;
169
+ return THOROUGHNESS_ORDER[nextIdx];
170
+ });
171
+ }, []);
172
+
173
+ const shortcuts = useMemo(
174
+ () => [{ key: "t", action: cycleThoroughness }],
175
+ [cycleThoroughness]
176
+ );
177
+
178
+ useKeyboardShortcuts(shortcuts);
179
+
150
180
  // Scroll to bottom when conversation updates
151
181
  useEffect(() => {
152
182
  messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -168,10 +198,31 @@ export default function Ask({ navigate }: PageProps) {
168
198
  ]);
169
199
  setQuery("");
170
200
 
201
+ // Build request body with thoroughness-mapped params
202
+ // fast: BM25-only via noExpand + noRerank
203
+ // balanced: with reranking, no expansion
204
+ // thorough: full pipeline
205
+ const requestBody: Record<string, unknown> = {
206
+ query: currentQuery,
207
+ limit: 5,
208
+ };
209
+
210
+ if (thoroughness === "fast") {
211
+ requestBody.noExpand = true;
212
+ requestBody.noRerank = true;
213
+ } else if (thoroughness === "balanced") {
214
+ requestBody.noExpand = true;
215
+ requestBody.noRerank = false;
216
+ } else {
217
+ // thorough - full pipeline
218
+ requestBody.noExpand = false;
219
+ requestBody.noRerank = false;
220
+ }
221
+
171
222
  // Make API call
172
223
  const { data, error } = await apiFetch<AskResponse>("/api/ask", {
173
224
  method: "POST",
174
- body: JSON.stringify({ query: currentQuery, limit: 5 }),
225
+ body: JSON.stringify(requestBody),
175
226
  });
176
227
 
177
228
  // Update conversation with response
@@ -213,8 +264,21 @@ export default function Ask({ navigate }: PageProps) {
213
264
  Back
214
265
  </Button>
215
266
  <h1 className="font-semibold text-xl">Ask</h1>
216
- <div className="ml-auto flex items-center gap-3">
267
+ <div className="ml-auto flex items-center gap-4">
268
+ {/* Search depth selector */}
269
+ <ThoroughnessSelector
270
+ disabled={!capabilities?.hybrid}
271
+ onChange={setThoroughness}
272
+ value={thoroughness}
273
+ />
274
+
275
+ {/* Divider */}
276
+ <div className="h-6 w-px bg-border/40" />
277
+
278
+ {/* AI model selector */}
217
279
  <AIModelSelector />
280
+
281
+ {/* Capability badges */}
218
282
  {capabilities && (
219
283
  <div className="flex items-center gap-2">
220
284
  {capabilities.vector && (
@@ -3,11 +3,13 @@ import {
3
3
  ArrowLeft,
4
4
  Calendar,
5
5
  ChevronRightIcon,
6
+ CodeIcon,
6
7
  FileText,
7
8
  FolderOpen,
8
9
  HardDrive,
9
10
  Loader2Icon,
10
11
  PencilIcon,
12
+ TextIcon,
11
13
  TrashIcon,
12
14
  } from "lucide-react";
13
15
  import { useEffect, useState } from "react";
@@ -17,6 +19,7 @@ import {
17
19
  CodeBlockCopyButton,
18
20
  } from "../components/ai-elements/code-block";
19
21
  import { Loader } from "../components/ai-elements/loader";
22
+ import { MarkdownPreview } from "../components/editor";
20
23
  import { Badge } from "../components/ui/badge";
21
24
  import { Button } from "../components/ui/button";
22
25
  import {
@@ -165,6 +168,7 @@ export default function DocView({ navigate }: PageProps) {
165
168
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
166
169
  const [deleting, setDeleting] = useState(false);
167
170
  const [deleteError, setDeleteError] = useState<string | null>(null);
171
+ const [showRawView, setShowRawView] = useState(false);
168
172
 
169
173
  useEffect(() => {
170
174
  const params = new URLSearchParams(window.location.search);
@@ -188,6 +192,10 @@ export default function DocView({ navigate }: PageProps) {
188
192
  );
189
193
  }, []);
190
194
 
195
+ const isMarkdown =
196
+ doc?.source.ext &&
197
+ [".md", ".markdown"].includes(doc.source.ext.toLowerCase());
198
+
191
199
  const isCodeFile =
192
200
  doc?.source.ext &&
193
201
  [
@@ -393,9 +401,31 @@ export default function DocView({ navigate }: PageProps) {
393
401
  {/* Content */}
394
402
  <Card>
395
403
  <CardHeader className="pb-0">
396
- <CardTitle className="flex items-center gap-2 text-lg">
397
- <FileText className="size-4" />
398
- Content
404
+ <CardTitle className="flex items-center justify-between text-lg">
405
+ <span className="flex items-center gap-2">
406
+ <FileText className="size-4" />
407
+ Content
408
+ </span>
409
+ {isMarkdown && doc.contentAvailable && (
410
+ <Button
411
+ className="gap-1.5"
412
+ onClick={() => setShowRawView(!showRawView)}
413
+ size="sm"
414
+ variant="ghost"
415
+ >
416
+ {showRawView ? (
417
+ <>
418
+ <TextIcon className="size-4" />
419
+ <span className="hidden sm:inline">Rendered</span>
420
+ </>
421
+ ) : (
422
+ <>
423
+ <CodeIcon className="size-4" />
424
+ <span className="hidden sm:inline">Source</span>
425
+ </>
426
+ )}
427
+ </Button>
428
+ )}
399
429
  </CardTitle>
400
430
  </CardHeader>
401
431
  <CardContent className="pt-4">
@@ -406,7 +436,21 @@ export default function DocView({ navigate }: PageProps) {
406
436
  </p>
407
437
  </div>
408
438
  )}
409
- {doc.contentAvailable && isCodeFile && (
439
+ {doc.contentAvailable && isMarkdown && !showRawView && (
440
+ <div className="rounded-lg border border-border/40 bg-gradient-to-br from-background to-muted/10 p-6 shadow-inner">
441
+ <MarkdownPreview content={doc.content ?? ""} />
442
+ </div>
443
+ )}
444
+ {doc.contentAvailable && isMarkdown && showRawView && (
445
+ <CodeBlock
446
+ code={doc.content ?? ""}
447
+ language={"markdown" as BundledLanguage}
448
+ showLineNumbers
449
+ >
450
+ <CodeBlockCopyButton />
451
+ </CodeBlock>
452
+ )}
453
+ {doc.contentAvailable && isCodeFile && !isMarkdown && (
410
454
  <CodeBlock
411
455
  code={doc.content ?? ""}
412
456
  language={
@@ -16,8 +16,10 @@ import {
16
16
  CloudIcon,
17
17
  EyeIcon,
18
18
  EyeOffIcon,
19
+ LinkIcon,
19
20
  Loader2Icon,
20
21
  PenIcon,
22
+ UnlinkIcon,
21
23
  } from "lucide-react";
22
24
  import { useCallback, useEffect, useRef, useState } from "react";
23
25
 
@@ -131,11 +133,81 @@ export default function DocumentEditor({ navigate }: PageProps) {
131
133
  const [saveError, setSaveError] = useState<string | null>(null);
132
134
 
133
135
  const [showPreview, setShowPreview] = useState(true);
136
+ const [syncScroll, setSyncScroll] = useState(true);
134
137
  const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
135
138
  const editorRef = useRef<CodeMirrorEditorRef>(null);
139
+ const previewRef = useRef<HTMLDivElement>(null);
140
+ // Event-based suppression: ignore the echo event caused by programmatic scroll
141
+ const ignoreNextEditorScroll = useRef(false);
142
+ const ignoreNextPreviewScroll = useRef(false);
136
143
 
137
144
  const hasUnsavedChanges = content !== originalContent;
138
145
 
146
+ // Reset ignore flags when sync is toggled to prevent stale state
147
+ useEffect(() => {
148
+ ignoreNextEditorScroll.current = false;
149
+ ignoreNextPreviewScroll.current = false;
150
+ }, [syncScroll, showPreview]);
151
+
152
+ // Scroll sync handlers with event-based loop prevention
153
+ // Note: Uses percentage-based mapping which provides approximate correspondence.
154
+ // For very different layouts (headings, code blocks, images), perfect alignment
155
+ // would require anchor-based mapping between editor lines and rendered elements.
156
+ const handleEditorScroll = useCallback(
157
+ (scrollPercent: number) => {
158
+ // Clear ignore flag first, even if we early-return (prevents lingering)
159
+ if (ignoreNextEditorScroll.current) {
160
+ ignoreNextEditorScroll.current = false;
161
+ return;
162
+ }
163
+ if (!syncScroll || !showPreview) return;
164
+ if (!Number.isFinite(scrollPercent)) return;
165
+
166
+ const clamped = Math.max(0, Math.min(1, scrollPercent));
167
+ const preview = previewRef.current;
168
+ if (!preview) return;
169
+
170
+ const maxScroll = preview.scrollHeight - preview.clientHeight;
171
+ if (maxScroll <= 0) return;
172
+
173
+ const targetScroll = clamped * maxScroll;
174
+ // Only set ignore flag if scroll position actually changes (avoids lingering flag)
175
+ if (Math.abs(preview.scrollTop - targetScroll) > 0.5) {
176
+ ignoreNextPreviewScroll.current = true;
177
+ preview.scrollTop = targetScroll;
178
+ }
179
+ },
180
+ [syncScroll, showPreview]
181
+ );
182
+
183
+ const handlePreviewScroll = useCallback(() => {
184
+ // Clear ignore flag first, even if we early-return (prevents lingering)
185
+ if (ignoreNextPreviewScroll.current) {
186
+ ignoreNextPreviewScroll.current = false;
187
+ return;
188
+ }
189
+ if (!syncScroll) return;
190
+
191
+ const preview = previewRef.current;
192
+ if (!preview) return;
193
+
194
+ const maxScroll = preview.scrollHeight - preview.clientHeight;
195
+ if (maxScroll <= 0) return;
196
+
197
+ const scrollPercentRaw = preview.scrollTop / maxScroll;
198
+ if (!Number.isFinite(scrollPercentRaw)) return;
199
+ const scrollPercent = Math.max(0, Math.min(1, scrollPercentRaw));
200
+
201
+ // Set ignore flag BEFORE programmatic scroll to prevent race condition
202
+ ignoreNextEditorScroll.current = true;
203
+ const didScroll =
204
+ editorRef.current?.scrollToPercent(scrollPercent) ?? false;
205
+ // Clear flag if no scroll actually occurred (avoids lingering)
206
+ if (!didScroll) {
207
+ ignoreNextEditorScroll.current = false;
208
+ }
209
+ }, [syncScroll]);
210
+
139
211
  // Save function
140
212
  const saveDocument = useCallback(
141
213
  async (contentToSave: string) => {
@@ -442,6 +514,36 @@ export default function DocumentEditor({ navigate }: PageProps) {
442
514
  </Tooltip>
443
515
  </TooltipProvider>
444
516
 
517
+ {/* Sync scroll toggle (only show when preview is visible) */}
518
+ {showPreview && (
519
+ <TooltipProvider>
520
+ <Tooltip>
521
+ <TooltipTrigger asChild>
522
+ <Button
523
+ aria-label={
524
+ syncScroll ? "Disable scroll sync" : "Enable scroll sync"
525
+ }
526
+ className="transition-all duration-200"
527
+ onClick={() => setSyncScroll(!syncScroll)}
528
+ size="sm"
529
+ variant={syncScroll ? "secondary" : "ghost"}
530
+ >
531
+ {syncScroll ? (
532
+ <LinkIcon className="size-4" />
533
+ ) : (
534
+ <UnlinkIcon className="size-4" />
535
+ )}
536
+ </Button>
537
+ </TooltipTrigger>
538
+ <TooltipContent>
539
+ <p>
540
+ {syncScroll ? "Disable scroll sync" : "Enable scroll sync"}
541
+ </p>
542
+ </TooltipContent>
543
+ </Tooltip>
544
+ </TooltipProvider>
545
+ )}
546
+
445
547
  {/* Save button */}
446
548
  <Button
447
549
  disabled={!hasUnsavedChanges || saveStatus === "saving"}
@@ -462,19 +564,26 @@ export default function DocumentEditor({ navigate }: PageProps) {
462
564
  <div className="flex min-h-0 flex-1">
463
565
  {/* Editor pane */}
464
566
  <div
465
- className={`min-h-0 overflow-hidden ${showPreview ? "w-1/2 border-border/30 border-r" : "w-full"}`}
567
+ className={`min-h-0 overflow-hidden ${showPreview ? "w-1/2 border-r border-border/40 shadow-[1px_0_3px_-1px_rgba(0,0,0,0.3)]" : "w-full"}`}
466
568
  >
467
569
  <CodeMirrorEditor
468
570
  className="h-full"
469
571
  initialContent={content}
470
572
  onChange={handleContentChange}
573
+ onScroll={
574
+ syncScroll && showPreview ? handleEditorScroll : undefined
575
+ }
471
576
  ref={editorRef}
472
577
  />
473
578
  </div>
474
579
 
475
580
  {/* Preview pane */}
476
581
  {showPreview && (
477
- <div className="w-1/2 min-h-0 overflow-auto bg-background p-6">
582
+ <div
583
+ className="w-1/2 min-h-0 overflow-auto bg-background px-8 py-6"
584
+ onScroll={handlePreviewScroll}
585
+ ref={previewRef}
586
+ >
478
587
  <div className="mx-auto max-w-3xl">
479
588
  <MarkdownPreview content={content} />
480
589
  </div>
@@ -71,6 +71,8 @@ export interface AskRequestBody {
71
71
  collection?: string;
72
72
  lang?: string;
73
73
  maxAnswerTokens?: number;
74
+ noExpand?: boolean;
75
+ noRerank?: boolean;
74
76
  }
75
77
 
76
78
  export interface CreateCollectionRequestBody {
@@ -928,6 +930,8 @@ export async function handleAsk(
928
930
  limit,
929
931
  collection: body.collection,
930
932
  lang: body.lang,
933
+ noExpand: body.noExpand,
934
+ noRerank: body.noRerank,
931
935
  }
932
936
  );
933
937
 
Binary file
Binary file