@plannotator/ui 0.46.1 → 0.49.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,66 @@
1
+ import React from 'react';
2
+ import { cn } from '../lib/utils';
3
+
4
+ /**
5
+ * The header "N/M answered" chip for documents with `:::question` blocks
6
+ * (the approved Card mock): a progress ring and the count. It is a button:
7
+ * a click jumps to the next open question. With no open question left it
8
+ * still reads the progress but has nothing to jump to.
9
+ */
10
+ export interface QuestionProgressChipProps {
11
+ /** Answered or settled questions. */
12
+ done: number;
13
+ total: number;
14
+ /** Whether a question is still open (not answered, settled or skipped). */
15
+ hasOpen: boolean;
16
+ onJump: () => void;
17
+ className?: string;
18
+ }
19
+
20
+ const Ring: React.FC<{ done: number; total: number }> = ({ done, total }) => {
21
+ const r = 6;
22
+ const c = 2 * Math.PI * r;
23
+ const f = total > 0 ? done / total : 0;
24
+ const complete = total > 0 && done === total;
25
+ return (
26
+ <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" className="shrink-0">
27
+ <circle cx="8" cy="8" r={r} fill="none" stroke="var(--border)" strokeWidth="2.2" />
28
+ <circle
29
+ cx="8"
30
+ cy="8"
31
+ r={r}
32
+ fill="none"
33
+ stroke={complete ? 'var(--success)' : 'var(--primary)'}
34
+ strokeWidth="2.2"
35
+ strokeDasharray={`${c * f} ${c}`}
36
+ transform="rotate(-90 8 8)"
37
+ strokeLinecap="round"
38
+ />
39
+ </svg>
40
+ );
41
+ };
42
+
43
+ export const QuestionProgressChip: React.FC<QuestionProgressChipProps> = ({ done, total, hasOpen, onJump, className }) => {
44
+ if (total <= 0) return null;
45
+ const label = `${done} of ${total} question${total === 1 ? '' : 's'} answered.${hasOpen ? ' Go to next unanswered.' : ''}`;
46
+ return (
47
+ <button
48
+ type="button"
49
+ data-question-progress="true"
50
+ onClick={onJump}
51
+ aria-label={label}
52
+ title={hasOpen ? 'Go to the next open question' : done >= total ? 'All questions answered' : 'No open questions'}
53
+ className={cn(
54
+ 'inline-flex h-7 shrink-0 items-center gap-[7px] whitespace-nowrap rounded-full border border-border bg-card pl-[7px] pr-2.5 text-xs text-muted-foreground transition-colors',
55
+ 'hover:border-primary/50 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
56
+ className,
57
+ )}
58
+ >
59
+ <Ring done={done} total={total} />
60
+ <span>
61
+ <b className="font-semibold text-foreground">{done}/{total}</b>
62
+ <span className="hidden md:inline"> answered</span>
63
+ </span>
64
+ </button>
65
+ );
66
+ };
@@ -0,0 +1,121 @@
1
+ import React from 'react';
2
+ import { cn } from '../lib/utils';
3
+ import { questionProgress, type QuestionPanelRow } from '../utils/questionAnswers';
4
+
5
+ /**
6
+ * The annotations panel's Questions section (the approved Card mock): a
7
+ * header with the progress count, a thin progress bar, and one row per
8
+ * `:::question` block with its answer or "Not answered yet" and an
9
+ * Open / answered state. A row whose question is no longer in the document
10
+ * carries the Unanchored chip; its answer still exports.
11
+ */
12
+ const CheckIcon = () => (
13
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.2" aria-hidden="true" className="text-success">
14
+ <path d="m3.5 8.5 3 3 6-7" strokeLinecap="round" strokeLinejoin="round" />
15
+ </svg>
16
+ );
17
+
18
+ const StateTag: React.FC<{ row: QuestionPanelRow }> = ({ row }) => {
19
+ if (row.orphaned) {
20
+ return (
21
+ <span
22
+ data-annotation-unanchored="true"
23
+ className="whitespace-nowrap rounded px-1.5 py-0.5 text-[9px] font-medium bg-muted text-muted-foreground"
24
+ title="This question is no longer in the document. The answer is still sent."
25
+ >
26
+ Unanchored
27
+ </span>
28
+ );
29
+ }
30
+ if (row.status === 'answered' || row.status === 'settled') {
31
+ return (
32
+ <span className="inline-flex" title={row.status === 'settled' ? 'Settled' : 'Answered'}>
33
+ <CheckIcon />
34
+ <span className="sr-only">{row.status === 'settled' ? 'Settled' : 'Answered'}</span>
35
+ </span>
36
+ );
37
+ }
38
+ if (row.status === 'skipped') {
39
+ return <span className="whitespace-nowrap rounded bg-muted px-1.5 py-px text-[10px] font-semibold text-muted-foreground">Skipped</span>;
40
+ }
41
+ return (
42
+ <span className="whitespace-nowrap rounded border border-dashed border-border px-1.5 py-px text-[10px] font-semibold text-muted-foreground">
43
+ Open
44
+ </span>
45
+ );
46
+ };
47
+
48
+ export interface QuestionsPanelSectionProps {
49
+ rows: readonly QuestionPanelRow[];
50
+ onSelect?: (row: QuestionPanelRow) => void;
51
+ /** Remove an orphaned answer (its card is gone, so the panel is the only
52
+ * place to drop it). Absent: no remove control. */
53
+ onRemoveOrphan?: (row: QuestionPanelRow) => void;
54
+ }
55
+
56
+ export const QuestionsPanelSection: React.FC<QuestionsPanelSectionProps> = ({ rows, onSelect, onRemoveOrphan }) => {
57
+ const { done, total } = questionProgress(rows);
58
+ const complete = total > 0 && done === total;
59
+ return (
60
+ <section data-questions-panel="true" aria-label="Questions" className="mb-2">
61
+ <div className="mx-0.5 mb-2 mt-1 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.07em] text-muted-foreground">
62
+ <span className="flex-1">Questions</span>
63
+ {total > 0 && <span className="tabular-nums">{done}/{total}</span>}
64
+ </div>
65
+ {total > 0 && (
66
+ <div className="mx-0.5 mb-2.5 h-1 overflow-hidden rounded-sm bg-muted" aria-hidden="true">
67
+ <i
68
+ className={cn('block h-full rounded-sm', complete ? 'bg-success' : 'bg-primary')}
69
+ style={{ width: `${(done / total) * 100}%` }}
70
+ />
71
+ </div>
72
+ )}
73
+ <div className="flex flex-col">
74
+ {rows.map((row) => {
75
+ const ok = !row.orphaned && (row.status === 'answered' || row.status === 'settled');
76
+ const summary = row.answerText ?? 'Not answered yet';
77
+ const clickable = !!onSelect && !row.orphaned;
78
+ const body = (
79
+ <>
80
+ <span className={cn('font-mono text-[10.5px] font-semibold leading-[1.6]', ok ? 'text-success' : 'text-muted-foreground')}>
81
+ {row.number ? `Q${row.number}` : 'Q'}
82
+ </span>
83
+ <span className="min-w-0 text-[12.5px] leading-[1.4] text-foreground">{row.prompt}</span>
84
+ <StateTag row={row} />
85
+ <span className={cn('col-start-2 col-end-4 text-xs', row.answerText && row.status !== 'skipped' ? 'text-foreground' : 'text-muted-foreground')}>
86
+ {summary}
87
+ {row.hasNote ? ' · has note' : ''}
88
+ </span>
89
+ </>
90
+ );
91
+ const rowClass = 'grid w-full grid-cols-[auto_1fr_auto] items-start gap-x-2 gap-y-0.5 rounded-[7px] px-2 py-2 text-left';
92
+ return clickable ? (
93
+ <button
94
+ key={row.key}
95
+ type="button"
96
+ data-question-row={row.key}
97
+ data-question-row-status={row.status}
98
+ onClick={() => onSelect!(row)}
99
+ className={cn(rowClass, 'cursor-pointer transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring')}
100
+ >
101
+ {body}
102
+ </button>
103
+ ) : (
104
+ <div key={row.key} data-question-row={row.key} data-question-row-status={row.status} className={rowClass}>
105
+ {body}
106
+ {row.orphaned && onRemoveOrphan && (
107
+ <button
108
+ type="button"
109
+ onClick={() => onRemoveOrphan(row)}
110
+ className="col-start-2 col-end-4 justify-self-start text-[11px] text-muted-foreground underline-offset-2 hover:text-destructive hover:underline"
111
+ >
112
+ Remove answer
113
+ </button>
114
+ )}
115
+ </div>
116
+ );
117
+ })}
118
+ </div>
119
+ </section>
120
+ );
121
+ };
@@ -102,6 +102,10 @@ interface SettingsProps {
102
102
  aiProviders?: Array<{ id: string; name: string; capabilities: Record<string, boolean>; models?: Array<{ id: string; label: string; default?: boolean }> }>;
103
103
  /** Git user name from `git config user.name`, for quick identity set */
104
104
  gitUser?: string;
105
+ /** Present when the server advertises the auto-update setting (#1634);
106
+ * `env` is PLANNOTATOR_AUTO_UPDATE when it overrides the config file.
107
+ * Hosts that omit it get no toggle. */
108
+ autoUpdateSetting?: { env?: boolean };
105
109
  /** Current session is a local git review where since-base ISN'T offered
106
110
  * (base ref unresolvable) — the Git tab shows a note that the Git-status
107
111
  * preference can't take effect in THIS repo. */
@@ -937,8 +941,9 @@ const CommentsTab: React.FC = () => {
937
941
  );
938
942
  };
939
943
 
940
- export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', annotateParity = false, onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false, webmcpAvailable = false }) => {
944
+ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', annotateParity = false, onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, autoUpdateSetting, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false, webmcpAvailable = false }) => {
941
945
  const webmcpTools = useWebMcpToolsEnabled();
946
+ const autoUpdate = useConfigValue('autoUpdate');
942
947
  const [showDialog, setShowDialog] = useState(false);
943
948
  const settingsWasOpenRef = useRef(false);
944
949
  const [themePreview, setThemePreview] = useState(false);
@@ -1379,6 +1384,25 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1379
1384
  </div>
1380
1385
  </div>
1381
1386
 
1387
+ {/* Auto-update (#1634): opt-in, default off. Writes
1388
+ autoUpdate to ~/.plannotator/config.json. */}
1389
+ {autoUpdateSetting && (
1390
+ <>
1391
+ <div className="border-t border-border" />
1392
+ <ToggleSwitch
1393
+ checked={autoUpdateSetting.env ?? autoUpdate}
1394
+ onChange={(v) => configStore.set('autoUpdate', v)}
1395
+ disabled={autoUpdateSetting.env !== undefined}
1396
+ label="Keep Plannotator up to date"
1397
+ description={
1398
+ autoUpdateSetting.env !== undefined
1399
+ ? 'Set by PLANNOTATOR_AUTO_UPDATE in your environment.'
1400
+ : 'Installs new releases in the background, at most once a day, never while another review is open.'
1401
+ }
1402
+ />
1403
+ </>
1404
+ )}
1405
+
1382
1406
  {/* Agent tools (WebMCP). Shown only when the browser
1383
1407
  exposes document.modelContext; off unregisters the
1384
1408
  tools for this browser. Cookie-only and idle at its
@@ -1,5 +1,5 @@
1
1
  import { generateId } from '../utils/generateId';
2
- import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback, lazy, Suspense } from 'react';
2
+ import React, { useRef, useState, useEffect, useLayoutEffect, useMemo, forwardRef, useImperativeHandle, useCallback, lazy, Suspense } from 'react';
3
3
  import { createPortal } from 'react-dom';
4
4
  import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types';
5
5
  import { applyHighlight, codeBlockClassName, onCodeHighlightSwap } from '../utils/codeHighlight';
@@ -9,6 +9,8 @@ import { computeListIndices, groupBlocks, type Frontmatter, type FrontmatterValu
9
9
  import { buildHeadingSlugMap } from '../utils/slugify';
10
10
  import { copyTextToClipboard } from '../utils/clipboard';
11
11
  import { BlockRenderer } from './BlockRenderer';
12
+ import { indexQuestionBlocks, type IndexedQuestion, type ParsedQuestion, type QuestionAnswer } from '@plannotator/core/question-block';
13
+ import { resolveQuestionAnswers } from '../utils/questionAnswers';
12
14
  import { CodeBlock } from './blocks/CodeBlock';
13
15
  import { TableBlock } from './blocks/TableBlock';
14
16
  import { TableToolbar } from './blocks/TableToolbar';
@@ -187,6 +189,34 @@ export interface ViewerProps {
187
189
  // Checkbox toggle props
188
190
  onToggleCheckbox?: (blockId: string, checked: boolean) => void;
189
191
  checkboxOverrides?: Map<string, boolean>;
192
+ /** Answer handler for `:::question` blocks. The Viewer draws each answer
193
+ * from the `annotations` row carrying `questionAnswer` for that question's
194
+ * key; a change calls this with the next answer, or null when it became
195
+ * empty, and the host upserts or removes that annotation (see
196
+ * `upsertQuestionAnswerAnnotation` in `utils/questionAnswers`). Absent (or
197
+ * `readOnly`), question blocks render read-only. */
198
+ onAnswerQuestion?: (blockId: string, answer: QuestionAnswer | null, key: string) => void;
199
+ /** Host-kept answers, keyed by the question's (de-duplicated) key from
200
+ * `indexQuestionBlocks` / `findQuestionBlocks`. When given, the cards read
201
+ * their answers from here and never from `annotations`; entries that fail
202
+ * `parseQuestionAnswer` are ignored. Absent: answers come from the
203
+ * annotations carrying `questionAnswer` (Plannotator's own path). */
204
+ questionAnswers?: ReadonlyMap<string, QuestionAnswer> | Readonly<Record<string, QuestionAnswer>>;
205
+ /** Explicit save mode for question cards: edits stay a draft in the card,
206
+ * which shows Save answer and Cancel (and no Skip); Save calls this with
207
+ * the question's key and the answer, or null when the draft is empty. Return
208
+ * a promise to keep the draft until it settles (a rejection keeps it for a
209
+ * retry). Takes precedence over `onAnswerQuestion`. Ignored when
210
+ * `readOnly`. */
211
+ onSaveQuestionAnswer?: (key: string, answer: QuestionAnswer | null) => void | Promise<unknown>;
212
+ /** Label of the explicit-save-mode Save button, or a function of the
213
+ * question returning it. Default "Save answer"; never derived from
214
+ * `decisionOnAnswer` (only the host knows whether saving records one). */
215
+ saveQuestionAnswerLabel?: string | ((question: ParsedQuestion) => string);
216
+ /** Host actions at the right of a question card's footer (e.g. "Mark as
217
+ * decision"), given the indexed question and its saved answer. Rendered in
218
+ * read-only cards too; return null for none. */
219
+ renderQuestionFooter?: (question: IndexedQuestion, answer: QuestionAnswer | undefined) => React.ReactNode;
190
220
  onAskAI?: CommentAskAIHandler;
191
221
  /** Whether comment popovers offer image attachments. Hosts without an
192
222
  * uploadTransport pass false so the attach affordance never dead-ends.
@@ -310,6 +340,87 @@ const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ frontmatter }
310
340
  );
311
341
  };
312
342
 
343
+ /** Viewport offset (px) the legacy action cluster pins to while stuck (`top-3`). */
344
+ const STICKY_ACTIONS_TOP_PX = 12;
345
+
346
+ interface StickyActionsBox {
347
+ readonly width: number;
348
+ readonly height: number;
349
+ readonly marginTop: number;
350
+ readonly marginRight: number;
351
+ }
352
+
353
+ /**
354
+ * Legacy top-right action cluster, pinned while the document scrolls.
355
+ *
356
+ * The cluster must not be a sticky float: when float-avoiding siblings (the
357
+ * `overflow-x-auto` table and code wrappers) reflow, Firefox lays them out
358
+ * around the float's *stuck* position, so annotating a long table while
359
+ * scrolled squeezed it to a sliver. A static float spacer reserves the title's
360
+ * wrap space instead, and the cluster sticks inside a zero-height lane. Both
361
+ * copy the cluster's measured box and margins, so host CSS that restyles
362
+ * `[data-sticky-actions]` keeps its effect.
363
+ */
364
+ const StickyActionsLane: React.FC<{ className: string; children: React.ReactNode }> = ({ className, children }) => {
365
+ const actionsRef = useRef<HTMLDivElement>(null);
366
+ const [box, setBox] = useState<StickyActionsBox | null>(null);
367
+
368
+ useLayoutEffect(() => {
369
+ const el = actionsRef.current;
370
+ if (!el) return;
371
+ const measure = () => {
372
+ const style = window.getComputedStyle(el);
373
+ const next: StickyActionsBox = {
374
+ width: el.offsetWidth,
375
+ height: el.offsetHeight,
376
+ marginTop: Number.parseFloat(style.marginTop) || 0,
377
+ marginRight: Number.parseFloat(style.marginRight) || 0,
378
+ };
379
+ setBox((prev) =>
380
+ prev
381
+ && prev.width === next.width
382
+ && prev.height === next.height
383
+ && prev.marginTop === next.marginTop
384
+ && prev.marginRight === next.marginRight
385
+ ? prev
386
+ : next);
387
+ };
388
+ measure();
389
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
390
+ observer?.observe(el);
391
+ // Breakpoint-only margin changes (lg/xl) don't resize the cluster.
392
+ window.addEventListener('resize', measure);
393
+ return () => {
394
+ observer?.disconnect();
395
+ window.removeEventListener('resize', measure);
396
+ };
397
+ }, []);
398
+
399
+ return (
400
+ <>
401
+ <div
402
+ data-print-hide
403
+ data-sticky-actions-lane
404
+ className="sticky z-30 flex h-0 items-start justify-end"
405
+ // The lane carries no margin, so offset it by the cluster's own top
406
+ // margin to pin the cluster's border box exactly where the float did.
407
+ style={{ top: STICKY_ACTIONS_TOP_PX - (box?.marginTop ?? 0) }}
408
+ >
409
+ <div ref={actionsRef} data-print-hide data-sticky-actions className={className}>
410
+ {children}
411
+ </div>
412
+ </div>
413
+ {/* After the lane: the lane is a flex box, so a float placed before it would narrow it. */}
414
+ <div
415
+ data-print-hide
416
+ aria-hidden="true"
417
+ className="float-right"
418
+ style={box ? { width: box.width, height: box.height, marginTop: box.marginTop, marginRight: box.marginRight } : undefined}
419
+ />
420
+ </>
421
+ );
422
+ };
423
+
313
424
  interface ViewerDocumentHeaderProps {
314
425
  readonly config: ViewerAnnotationHeaderConfig;
315
426
  readonly inputMethod: InputMethod;
@@ -473,6 +584,11 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
473
584
  messagePickerInfo,
474
585
  onToggleCheckbox,
475
586
  checkboxOverrides,
587
+ onAnswerQuestion,
588
+ questionAnswers: hostQuestionAnswers,
589
+ onSaveQuestionAnswer,
590
+ saveQuestionAnswerLabel,
591
+ renderQuestionFooter,
476
592
  onAskAI,
477
593
  allowImages = true,
478
594
  readOnly = false,
@@ -535,6 +651,19 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
535
651
  // anchor ids stay stable across re-renders and duplicate heading texts get
536
652
  // `-1`/`-2`/... suffixes rather than colliding on the same id.
537
653
  const headingSlugMap = useMemo(() => buildHeadingSlugMap(blocks), [blocks]);
654
+ // `:::question` blocks: numbered in document order, answers drawn from the
655
+ // annotations that carry `questionAnswer` (matched by question key, so an
656
+ // answer follows its question across re-parses).
657
+ const questionIndex = useMemo(() => {
658
+ const list = indexQuestionBlocks(blocks);
659
+ return { byBlock: new Map(list.map((q) => [q.blockId, q])), total: list.length };
660
+ }, [blocks]);
661
+ // A host that keeps answers itself (`questionAnswers`) replaces the
662
+ // annotation-derived map; each entry is validated like any other reader.
663
+ const answersByKey = useMemo(
664
+ () => resolveQuestionAnswers(annotations, hostQuestionAnswers),
665
+ [annotations, hostQuestionAnswers],
666
+ );
538
667
  const isTouchDevice = useMemo(() => window.matchMedia('(pointer: coarse)').matches, []);
539
668
  const [codeBlockToolbar, setCodeBlockToolbar] =
540
669
  useState<CodeBlockToolbarTarget | null>(null);
@@ -911,8 +1040,11 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
911
1040
  ? container.querySelector<HTMLElement>('[data-viewer-document-header]')
912
1041
  ?? container.querySelector<HTMLElement>('[data-sticky-actions]')
913
1042
  : null;
1043
+ // The legacy cluster sticks via its lane, so its own `top` is `auto`.
914
1044
  const stickyTop = documentHeader
915
- ? Number.parseFloat(window.getComputedStyle(documentHeader).top || '0') || 0
1045
+ ? documentHeader.hasAttribute('data-viewer-document-header')
1046
+ ? Number.parseFloat(window.getComputedStyle(documentHeader).top || '0') || 0
1047
+ : STICKY_ACTIONS_TOP_PX
916
1048
  : 0;
917
1049
  const headerOffset = documentHeader
918
1050
  ? documentHeader.getBoundingClientRect().height + stickyTop
@@ -1252,9 +1384,15 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1252
1384
  <>
1253
1385
  {badgeClearance > 0 && <div data-print-hide style={{ height: badgeClearance }} aria-hidden="true" />}
1254
1386
  {stickyActions && <div ref={stickySentinelRef} className="h-0 w-0 float-right" aria-hidden="true" />}
1255
- <div data-print-hide data-sticky-actions className={`${stickyActions ? 'sticky top-3' : ''} z-30 float-right flex items-start gap-1 md:gap-2 rounded-lg p-1 md:p-2 transition-colors duration-150 ${isStuck ? 'bg-card/95 backdrop-blur-sm shadow-sm' : ''} ${gridEnabled ? '-mr-3 md:-mr-5 lg:-mr-7 xl:-mr-9' : '-mr-1 md:-mr-2'} mt-6 md:-mt-5 lg:-mt-7 xl:-mt-9`}>
1256
- {documentActions}
1257
- </div>
1387
+ {stickyActions ? (
1388
+ <StickyActionsLane className={`flex items-start gap-1 md:gap-2 rounded-lg p-1 md:p-2 transition-colors duration-150 ${isStuck ? 'bg-card/95 backdrop-blur-sm shadow-sm' : ''} ${gridEnabled ? '-mr-3 md:-mr-5 lg:-mr-7 xl:-mr-9' : '-mr-1 md:-mr-2'} mt-6 md:-mt-5 lg:-mt-7 xl:-mt-9`}>
1389
+ {documentActions}
1390
+ </StickyActionsLane>
1391
+ ) : (
1392
+ <div data-print-hide data-sticky-actions className={`z-30 float-right flex items-start gap-1 md:gap-2 rounded-lg p-1 md:p-2 transition-colors duration-150 ${gridEnabled ? '-mr-3 md:-mr-5 lg:-mr-7 xl:-mr-9' : '-mr-1 md:-mr-2'} mt-6 md:-mt-5 lg:-mt-7 xl:-mt-9`}>
1393
+ {documentActions}
1394
+ </div>
1395
+ )}
1258
1396
  </>
1259
1397
  )}
1260
1398
  {frontmatter && <><div className="clear-right md:hidden" /><FrontmatterCard frontmatter={frontmatter} /></>}
@@ -1385,7 +1523,32 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1385
1523
  }
1386
1524
  />
1387
1525
  ) : (
1388
- <BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} onNavigateAnchor={scrollToAnchor} onToggleCheckbox={readOnly ? undefined : onToggleCheckbox} checkboxOverrides={checkboxOverrides} githubRepo={repoInfo?.display} repoHost={repoInfo?.host} headingAnchorId={headingSlugMap.get(group.block.id)} />
1526
+ (() => {
1527
+ const question = questionIndex.byBlock.get(group.block.id);
1528
+ return (
1529
+ <BlockRenderer
1530
+ imageBaseDir={imageBaseDir}
1531
+ onImageClick={(src, alt) => setLightbox({ src, alt })}
1532
+ key={group.block.id}
1533
+ block={group.block}
1534
+ onOpenLinkedDoc={onOpenLinkedDoc}
1535
+ onOpenCodeFile={onOpenCodeFile}
1536
+ onNavigateAnchor={scrollToAnchor}
1537
+ onToggleCheckbox={readOnly ? undefined : onToggleCheckbox}
1538
+ checkboxOverrides={checkboxOverrides}
1539
+ githubRepo={repoInfo?.display}
1540
+ repoHost={repoInfo?.host}
1541
+ headingAnchorId={headingSlugMap.get(group.block.id)}
1542
+ question={question}
1543
+ questionTotal={questionIndex.total}
1544
+ questionAnswer={question ? answersByKey.get(question.question.key) : undefined}
1545
+ onAnswerQuestion={readOnly ? undefined : onAnswerQuestion}
1546
+ onSaveQuestionAnswer={readOnly ? undefined : onSaveQuestionAnswer}
1547
+ saveQuestionAnswerLabel={saveQuestionAnswerLabel}
1548
+ renderQuestionFooter={renderQuestionFooter}
1549
+ />
1550
+ );
1551
+ })()
1389
1552
  )
1390
1553
  )}
1391
1554
 
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import { getProviderMeta } from '../ProviderIcons';
3
3
  import { type AIProviderOption } from '../../utils/aiProvider';
4
+ import { ModelSourceHint, modelSourceToolForProvider } from '../ModelSourceHint';
4
5
 
5
6
  interface AIProviderBarProps {
6
7
  providers: AIProviderOption[];
@@ -42,12 +43,13 @@ export const AIProviderBar: React.FC<AIProviderBarProps> = ({
42
43
  const showReasoningEffort = !!onReasoningEffortChange && reasoningEfforts.length > 0;
43
44
 
44
45
  return (
46
+ <>
45
47
  <div className="border-t border-border/50 px-2 py-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
46
48
  <Icon className="w-3.5 h-3.5 flex-shrink-0" />
47
49
  <select
48
50
  value={effectiveProviderId ?? ''}
49
51
  onChange={(event) => onProviderChange(event.target.value)}
50
- className="min-w-0 max-w-[8rem] bg-transparent text-[11px] text-foreground focus:outline-none"
52
+ className="min-w-0 max-w-[8rem] bg-transparent text-[11px] text-foreground focus:outline-none [&>option]:bg-card [&>option]:text-foreground"
51
53
  aria-label="AI provider"
52
54
  >
53
55
  {providers.map(provider => {
@@ -64,7 +66,7 @@ export const AIProviderBar: React.FC<AIProviderBarProps> = ({
64
66
  <select
65
67
  value={effectiveModel}
66
68
  onChange={(event) => onModelChange(event.target.value)}
67
- className="min-w-0 flex-1 bg-transparent text-[11px] text-foreground focus:outline-none"
69
+ className="min-w-0 flex-1 bg-transparent text-[11px] text-foreground focus:outline-none [&>option]:bg-card [&>option]:text-foreground"
68
70
  aria-label="AI model"
69
71
  >
70
72
  {models.map(model => (
@@ -79,7 +81,7 @@ export const AIProviderBar: React.FC<AIProviderBarProps> = ({
79
81
  <select
80
82
  value={selectedReasoningEffort ?? ''}
81
83
  onChange={(event) => onReasoningEffortChange?.(event.target.value || null)}
82
- className="w-16 bg-transparent text-[11px] text-foreground focus:outline-none"
84
+ className="w-16 bg-transparent text-[11px] text-foreground focus:outline-none [&>option]:bg-card [&>option]:text-foreground"
83
85
  aria-label="Reasoning effort"
84
86
  >
85
87
  <option value="">Auto</option>
@@ -91,5 +93,11 @@ export const AIProviderBar: React.FC<AIProviderBarProps> = ({
91
93
  </select>
92
94
  )}
93
95
  </div>
96
+ <ModelSourceHint
97
+ tool={modelSourceToolForProvider(currentProvider?.name)}
98
+ info={currentProvider}
99
+ className="-mt-1 px-2 pb-1.5 text-[10px] text-muted-foreground/50"
100
+ />
101
+ </>
94
102
  );
95
103
  };