@plannotator/ui 0.47.0 → 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.
@@ -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
 
@@ -49,7 +49,7 @@ export const AIProviderBar: React.FC<AIProviderBarProps> = ({
49
49
  <select
50
50
  value={effectiveProviderId ?? ''}
51
51
  onChange={(event) => onProviderChange(event.target.value)}
52
- 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"
53
53
  aria-label="AI provider"
54
54
  >
55
55
  {providers.map(provider => {
@@ -66,7 +66,7 @@ export const AIProviderBar: React.FC<AIProviderBarProps> = ({
66
66
  <select
67
67
  value={effectiveModel}
68
68
  onChange={(event) => onModelChange(event.target.value)}
69
- 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"
70
70
  aria-label="AI model"
71
71
  >
72
72
  {models.map(model => (
@@ -81,7 +81,7 @@ export const AIProviderBar: React.FC<AIProviderBarProps> = ({
81
81
  <select
82
82
  value={selectedReasoningEffort ?? ''}
83
83
  onChange={(event) => onReasoningEffortChange?.(event.target.value || null)}
84
- 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"
85
85
  aria-label="Reasoning effort"
86
86
  >
87
87
  <option value="">Auto</option>