@plannotator/ui 0.47.0 → 0.50.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 (42) hide show
  1. package/HANDOFF.md +147 -0
  2. package/LICENSE +191 -0
  3. package/README.md +56 -1
  4. package/components/AISettingsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +44 -3
  6. package/components/BlockRenderer.tsx +55 -1
  7. package/components/CompletionOverlay.tsx +23 -1
  8. package/components/DiffFileTree.tsx +363 -0
  9. package/components/ImageLightbox.tsx +66 -0
  10. package/components/PRPlatformIcon.tsx +22 -0
  11. package/components/ProviderIcons.tsx +15 -3
  12. package/components/QuestionProgressChip.tsx +66 -0
  13. package/components/QuestionsPanelSection.tsx +121 -0
  14. package/components/Settings.tsx +25 -1
  15. package/components/Viewer.tsx +170 -34
  16. package/components/ai/AIProviderBar.tsx +5 -5
  17. package/components/ai/DocumentAIChatPanel.tsx +28 -3
  18. package/components/ai/SessionAskNotice.tsx +89 -0
  19. package/components/blocks/QuestionBlock.tsx +591 -0
  20. package/components/html-viewer/bridge-script.asset.js +85 -8
  21. package/components/html-viewer/bridge-script.ts +85 -8
  22. package/config/settings.ts +17 -0
  23. package/configure.ts +12 -0
  24. package/hooks/useAIChat.ts +74 -9
  25. package/hooks/useAIProviderConfig.ts +32 -14
  26. package/hooks/useAnnotationHighlighter.ts +4 -0
  27. package/hooks/useDiffFileTreeExpansion.ts +92 -0
  28. package/hooks/usePinpoint.ts +10 -0
  29. package/hooks/useScrollKeyRouting.ts +157 -0
  30. package/hooks/useUndoHistory.ts +17 -0
  31. package/hooks/useVimDocumentFocus.ts +2 -1
  32. package/package.json +3 -2
  33. package/styles.css +1 -1
  34. package/types.ts +7 -0
  35. package/utils/aiPrompt.ts +31 -0
  36. package/utils/aiProvider.ts +68 -0
  37. package/utils/autoUpdateNotice.ts +59 -0
  38. package/utils/diffFileTree.ts +203 -0
  39. package/utils/htmlLinkNavigation.ts +87 -3
  40. package/utils/parser.ts +125 -563
  41. package/utils/questionAnswers.ts +227 -0
  42. package/utils/syntaxTheme.ts +33 -0
@@ -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,6 +1,7 @@
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
+ import { ImageLightbox } from './ImageLightbox';
4
5
  import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types';
5
6
  import { applyHighlight, codeBlockClassName, onCodeHighlightSwap } from '../utils/codeHighlight';
6
7
  import { paintCodeBlockMark } from '../utils/codeBlockMark';
@@ -9,6 +10,8 @@ import { computeListIndices, groupBlocks, type Frontmatter, type FrontmatterValu
9
10
  import { buildHeadingSlugMap } from '../utils/slugify';
10
11
  import { copyTextToClipboard } from '../utils/clipboard';
11
12
  import { BlockRenderer } from './BlockRenderer';
13
+ import { indexQuestionBlocks, type IndexedQuestion, type ParsedQuestion, type QuestionAnswer } from '@plannotator/core/question-block';
14
+ import { resolveQuestionAnswers } from '../utils/questionAnswers';
12
15
  import { CodeBlock } from './blocks/CodeBlock';
13
16
  import { TableBlock } from './blocks/TableBlock';
14
17
  import { TableToolbar } from './blocks/TableToolbar';
@@ -187,6 +190,34 @@ export interface ViewerProps {
187
190
  // Checkbox toggle props
188
191
  onToggleCheckbox?: (blockId: string, checked: boolean) => void;
189
192
  checkboxOverrides?: Map<string, boolean>;
193
+ /** Answer handler for `:::question` blocks. The Viewer draws each answer
194
+ * from the `annotations` row carrying `questionAnswer` for that question's
195
+ * key; a change calls this with the next answer, or null when it became
196
+ * empty, and the host upserts or removes that annotation (see
197
+ * `upsertQuestionAnswerAnnotation` in `utils/questionAnswers`). Absent (or
198
+ * `readOnly`), question blocks render read-only. */
199
+ onAnswerQuestion?: (blockId: string, answer: QuestionAnswer | null, key: string) => void;
200
+ /** Host-kept answers, keyed by the question's (de-duplicated) key from
201
+ * `indexQuestionBlocks` / `findQuestionBlocks`. When given, the cards read
202
+ * their answers from here and never from `annotations`; entries that fail
203
+ * `parseQuestionAnswer` are ignored. Absent: answers come from the
204
+ * annotations carrying `questionAnswer` (Plannotator's own path). */
205
+ questionAnswers?: ReadonlyMap<string, QuestionAnswer> | Readonly<Record<string, QuestionAnswer>>;
206
+ /** Explicit save mode for question cards: edits stay a draft in the card,
207
+ * which shows Save answer and Cancel (and no Skip); Save calls this with
208
+ * the question's key and the answer, or null when the draft is empty. Return
209
+ * a promise to keep the draft until it settles (a rejection keeps it for a
210
+ * retry). Takes precedence over `onAnswerQuestion`. Ignored when
211
+ * `readOnly`. */
212
+ onSaveQuestionAnswer?: (key: string, answer: QuestionAnswer | null) => void | Promise<unknown>;
213
+ /** Label of the explicit-save-mode Save button, or a function of the
214
+ * question returning it. Default "Save answer"; never derived from
215
+ * `decisionOnAnswer` (only the host knows whether saving records one). */
216
+ saveQuestionAnswerLabel?: string | ((question: ParsedQuestion) => string);
217
+ /** Host actions at the right of a question card's footer (e.g. "Mark as
218
+ * decision"), given the indexed question and its saved answer. Rendered in
219
+ * read-only cards too; return null for none. */
220
+ renderQuestionFooter?: (question: IndexedQuestion, answer: QuestionAnswer | undefined) => React.ReactNode;
190
221
  onAskAI?: CommentAskAIHandler;
191
222
  /** Whether comment popovers offer image attachments. Hosts without an
192
223
  * uploadTransport pass false so the attach affordance never dead-ends.
@@ -310,6 +341,87 @@ const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ frontmatter }
310
341
  );
311
342
  };
312
343
 
344
+ /** Viewport offset (px) the legacy action cluster pins to while stuck (`top-3`). */
345
+ const STICKY_ACTIONS_TOP_PX = 12;
346
+
347
+ interface StickyActionsBox {
348
+ readonly width: number;
349
+ readonly height: number;
350
+ readonly marginTop: number;
351
+ readonly marginRight: number;
352
+ }
353
+
354
+ /**
355
+ * Legacy top-right action cluster, pinned while the document scrolls.
356
+ *
357
+ * The cluster must not be a sticky float: when float-avoiding siblings (the
358
+ * `overflow-x-auto` table and code wrappers) reflow, Firefox lays them out
359
+ * around the float's *stuck* position, so annotating a long table while
360
+ * scrolled squeezed it to a sliver. A static float spacer reserves the title's
361
+ * wrap space instead, and the cluster sticks inside a zero-height lane. Both
362
+ * copy the cluster's measured box and margins, so host CSS that restyles
363
+ * `[data-sticky-actions]` keeps its effect.
364
+ */
365
+ const StickyActionsLane: React.FC<{ className: string; children: React.ReactNode }> = ({ className, children }) => {
366
+ const actionsRef = useRef<HTMLDivElement>(null);
367
+ const [box, setBox] = useState<StickyActionsBox | null>(null);
368
+
369
+ useLayoutEffect(() => {
370
+ const el = actionsRef.current;
371
+ if (!el) return;
372
+ const measure = () => {
373
+ const style = window.getComputedStyle(el);
374
+ const next: StickyActionsBox = {
375
+ width: el.offsetWidth,
376
+ height: el.offsetHeight,
377
+ marginTop: Number.parseFloat(style.marginTop) || 0,
378
+ marginRight: Number.parseFloat(style.marginRight) || 0,
379
+ };
380
+ setBox((prev) =>
381
+ prev
382
+ && prev.width === next.width
383
+ && prev.height === next.height
384
+ && prev.marginTop === next.marginTop
385
+ && prev.marginRight === next.marginRight
386
+ ? prev
387
+ : next);
388
+ };
389
+ measure();
390
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
391
+ observer?.observe(el);
392
+ // Breakpoint-only margin changes (lg/xl) don't resize the cluster.
393
+ window.addEventListener('resize', measure);
394
+ return () => {
395
+ observer?.disconnect();
396
+ window.removeEventListener('resize', measure);
397
+ };
398
+ }, []);
399
+
400
+ return (
401
+ <>
402
+ <div
403
+ data-print-hide
404
+ data-sticky-actions-lane
405
+ className="sticky z-30 flex h-0 items-start justify-end"
406
+ // The lane carries no margin, so offset it by the cluster's own top
407
+ // margin to pin the cluster's border box exactly where the float did.
408
+ style={{ top: STICKY_ACTIONS_TOP_PX - (box?.marginTop ?? 0) }}
409
+ >
410
+ <div ref={actionsRef} data-print-hide data-sticky-actions className={className}>
411
+ {children}
412
+ </div>
413
+ </div>
414
+ {/* After the lane: the lane is a flex box, so a float placed before it would narrow it. */}
415
+ <div
416
+ data-print-hide
417
+ aria-hidden="true"
418
+ className="float-right"
419
+ style={box ? { width: box.width, height: box.height, marginTop: box.marginTop, marginRight: box.marginRight } : undefined}
420
+ />
421
+ </>
422
+ );
423
+ };
424
+
313
425
  interface ViewerDocumentHeaderProps {
314
426
  readonly config: ViewerAnnotationHeaderConfig;
315
427
  readonly inputMethod: InputMethod;
@@ -473,6 +585,11 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
473
585
  messagePickerInfo,
474
586
  onToggleCheckbox,
475
587
  checkboxOverrides,
588
+ onAnswerQuestion,
589
+ questionAnswers: hostQuestionAnswers,
590
+ onSaveQuestionAnswer,
591
+ saveQuestionAnswerLabel,
592
+ renderQuestionFooter,
476
593
  onAskAI,
477
594
  allowImages = true,
478
595
  readOnly = false,
@@ -535,6 +652,19 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
535
652
  // anchor ids stay stable across re-renders and duplicate heading texts get
536
653
  // `-1`/`-2`/... suffixes rather than colliding on the same id.
537
654
  const headingSlugMap = useMemo(() => buildHeadingSlugMap(blocks), [blocks]);
655
+ // `:::question` blocks: numbered in document order, answers drawn from the
656
+ // annotations that carry `questionAnswer` (matched by question key, so an
657
+ // answer follows its question across re-parses).
658
+ const questionIndex = useMemo(() => {
659
+ const list = indexQuestionBlocks(blocks);
660
+ return { byBlock: new Map(list.map((q) => [q.blockId, q])), total: list.length };
661
+ }, [blocks]);
662
+ // A host that keeps answers itself (`questionAnswers`) replaces the
663
+ // annotation-derived map; each entry is validated like any other reader.
664
+ const answersByKey = useMemo(
665
+ () => resolveQuestionAnswers(annotations, hostQuestionAnswers),
666
+ [annotations, hostQuestionAnswers],
667
+ );
538
668
  const isTouchDevice = useMemo(() => window.matchMedia('(pointer: coarse)').matches, []);
539
669
  const [codeBlockToolbar, setCodeBlockToolbar] =
540
670
  useState<CodeBlockToolbarTarget | null>(null);
@@ -911,8 +1041,11 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
911
1041
  ? container.querySelector<HTMLElement>('[data-viewer-document-header]')
912
1042
  ?? container.querySelector<HTMLElement>('[data-sticky-actions]')
913
1043
  : null;
1044
+ // The legacy cluster sticks via its lane, so its own `top` is `auto`.
914
1045
  const stickyTop = documentHeader
915
- ? Number.parseFloat(window.getComputedStyle(documentHeader).top || '0') || 0
1046
+ ? documentHeader.hasAttribute('data-viewer-document-header')
1047
+ ? Number.parseFloat(window.getComputedStyle(documentHeader).top || '0') || 0
1048
+ : STICKY_ACTIONS_TOP_PX
916
1049
  : 0;
917
1050
  const headerOffset = documentHeader
918
1051
  ? documentHeader.getBoundingClientRect().height + stickyTop
@@ -1252,9 +1385,15 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1252
1385
  <>
1253
1386
  {badgeClearance > 0 && <div data-print-hide style={{ height: badgeClearance }} aria-hidden="true" />}
1254
1387
  {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>
1388
+ {stickyActions ? (
1389
+ <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`}>
1390
+ {documentActions}
1391
+ </StickyActionsLane>
1392
+ ) : (
1393
+ <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`}>
1394
+ {documentActions}
1395
+ </div>
1396
+ )}
1258
1397
  </>
1259
1398
  )}
1260
1399
  {frontmatter && <><div className="clear-right md:hidden" /><FrontmatterCard frontmatter={frontmatter} /></>}
@@ -1385,7 +1524,32 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1385
1524
  }
1386
1525
  />
1387
1526
  ) : (
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)} />
1527
+ (() => {
1528
+ const question = questionIndex.byBlock.get(group.block.id);
1529
+ return (
1530
+ <BlockRenderer
1531
+ imageBaseDir={imageBaseDir}
1532
+ onImageClick={(src, alt) => setLightbox({ src, alt })}
1533
+ key={group.block.id}
1534
+ block={group.block}
1535
+ onOpenLinkedDoc={onOpenLinkedDoc}
1536
+ onOpenCodeFile={onOpenCodeFile}
1537
+ onNavigateAnchor={scrollToAnchor}
1538
+ onToggleCheckbox={readOnly ? undefined : onToggleCheckbox}
1539
+ checkboxOverrides={checkboxOverrides}
1540
+ githubRepo={repoInfo?.display}
1541
+ repoHost={repoInfo?.host}
1542
+ headingAnchorId={headingSlugMap.get(group.block.id)}
1543
+ question={question}
1544
+ questionTotal={questionIndex.total}
1545
+ questionAnswer={question ? answersByKey.get(question.question.key) : undefined}
1546
+ onAnswerQuestion={readOnly ? undefined : onAnswerQuestion}
1547
+ onSaveQuestionAnswer={readOnly ? undefined : onSaveQuestionAnswer}
1548
+ saveQuestionAnswerLabel={saveQuestionAnswerLabel}
1549
+ renderQuestionFooter={renderQuestionFooter}
1550
+ />
1551
+ );
1552
+ })()
1389
1553
  )
1390
1554
  )}
1391
1555
 
@@ -1617,31 +1781,3 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1617
1781
  </CodePathValidationContext.Provider>
1618
1782
  );
1619
1783
  });
1620
-
1621
- /** Simple lightbox overlay for enlarged image viewing. */
1622
- const ImageLightbox: React.FC<{ src: string; alt: string; onClose: () => void }> = ({ src, alt, onClose }) => {
1623
- useEffect(() => {
1624
- const handleKeyDown = (e: KeyboardEvent) => {
1625
- if (e.key === 'Escape') onClose();
1626
- };
1627
- window.addEventListener('keydown', handleKeyDown);
1628
- return () => window.removeEventListener('keydown', handleKeyDown);
1629
- }, [onClose]);
1630
-
1631
- return (
1632
- <div
1633
- className="fixed inset-0 z-[200] flex flex-col items-center justify-center bg-black/80 backdrop-blur-sm cursor-zoom-out"
1634
- onClick={onClose}
1635
- >
1636
- <img
1637
- src={src}
1638
- alt={alt}
1639
- className="max-w-[90vw] max-h-[85vh] object-contain rounded-lg shadow-2xl"
1640
- onClick={(e) => e.stopPropagation()}
1641
- />
1642
- {alt && (
1643
- <div className="mt-3 text-sm text-white/70 max-w-[90vw] text-center truncate">{alt}</div>
1644
- )}
1645
- </div>
1646
- );
1647
- };
@@ -37,7 +37,7 @@ export const AIProviderBar: React.FC<AIProviderBarProps> = ({
37
37
  const effectiveModel = selectedModel ?? defaultModel?.id ?? '';
38
38
  const currentModel = models.find(m => m.id === effectiveModel) ?? defaultModel;
39
39
  const reasoningEfforts = currentModel?.reasoningEfforts ?? [];
40
- const meta = getProviderMeta(currentProvider?.name ?? 'AI');
40
+ const meta = getProviderMeta(currentProvider?.name ?? 'AI', currentProvider?.label);
41
41
  const Icon = meta.icon;
42
42
  // Show only when the selected model actually reports reasoning efforts.
43
43
  const showReasoningEffort = !!onReasoningEffortChange && reasoningEfforts.length > 0;
@@ -49,11 +49,11 @@ 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 => {
56
- const providerMeta = getProviderMeta(provider.name);
56
+ const providerMeta = getProviderMeta(provider.name, provider.label);
57
57
  return (
58
58
  <option key={provider.id} value={provider.id}>
59
59
  {providerMeta.label}
@@ -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>
@@ -5,6 +5,7 @@ import { formatRelativeTime, renderChatMarkdown } from '../../utils/aiChatFormat
5
5
  import { OverlayScrollArea } from '../OverlayScrollArea';
6
6
  import { SparklesIcon } from '../SparklesIcon';
7
7
  import { AIProviderBar } from './AIProviderBar';
8
+ import { SessionAskActions, SessionAskStatus, sessionAskErrorTone, type SessionAskAction } from './SessionAskNotice';
8
9
  import { submitHint } from '../../utils/platform';
9
10
 
10
11
  interface DocumentAIChatPanelProps {
@@ -19,6 +20,10 @@ interface DocumentAIChatPanelProps {
19
20
  aiProviders?: AIProviderOption[];
20
21
  aiConfig?: { providerId: string | null; model: string | null; reasoningEffort?: string | null };
21
22
  onAIConfigChange?: (config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => void;
23
+ /** "Ask this session": the reviewer's choice on a busy/gone/blocked answer. */
24
+ onSessionAskAction?: (questionId: string, action: SessionAskAction) => void;
25
+ /** Label of the provider "Ask a separate AI instead" would use; null hides it. */
26
+ sessionAskFallbackLabel?: string | null;
22
27
  }
23
28
 
24
29
  function truncate(text: string, max = 180): string {
@@ -64,6 +69,8 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
64
69
  aiProviders = [],
65
70
  aiConfig,
66
71
  onAIConfigChange,
72
+ onSessionAskAction,
73
+ sessionAskFallbackLabel,
67
74
  }) => {
68
75
  const scrollRef = useRef<HTMLDivElement>(null);
69
76
  const [generalInput, setGeneralInput] = useState('');
@@ -109,7 +116,12 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
109
116
  )}
110
117
 
111
118
  {messages.map(entry => (
112
- <DocumentQAPair key={entry.question.id} entry={entry} />
119
+ <DocumentQAPair
120
+ key={entry.question.id}
121
+ entry={entry}
122
+ onSessionAskAction={onSessionAskAction}
123
+ sessionAskFallbackLabel={sessionAskFallbackLabel}
124
+ />
113
125
  ))}
114
126
  </div>
115
127
  </OverlayScrollArea>
@@ -152,7 +164,11 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
152
164
  );
153
165
  };
154
166
 
155
- const DocumentQAPair = memo<{ entry: AIChatEntry }>(({ entry }) => {
167
+ const DocumentQAPair = memo<{
168
+ entry: AIChatEntry;
169
+ onSessionAskAction?: (questionId: string, action: SessionAskAction) => void;
170
+ sessionAskFallbackLabel?: string | null;
171
+ }>(({ entry, onSessionAskAction, sessionAskFallbackLabel }) => {
156
172
  const { question, response } = entry;
157
173
  const renderedResponse = useMemo(
158
174
  () => response.text ? renderChatMarkdown(response.text) : null,
@@ -190,12 +206,21 @@ const DocumentQAPair = memo<{ entry: AIChatEntry }>(({ entry }) => {
190
206
 
191
207
  <div className="group relative p-2.5 rounded-lg border border-border/50 bg-popover/50 hover:bg-muted/30 transition-colors">
192
208
  {response.error ? (
193
- <p className="text-xs text-destructive">{response.error}</p>
209
+ <>
210
+ <p className={`text-xs ${sessionAskErrorTone(response)}`}>{response.error}</p>
211
+ <SessionAskActions
212
+ response={response}
213
+ fallbackLabel={sessionAskFallbackLabel}
214
+ onAction={onSessionAskAction ? (action) => onSessionAskAction(question.id, action) : undefined}
215
+ />
216
+ </>
194
217
  ) : response.text ? (
195
218
  <div className="text-xs">
196
219
  {renderedResponse}
197
220
  {response.isStreaming && <span className="ai-streaming-cursor inline-block ml-0.5" />}
198
221
  </div>
222
+ ) : response.status ? (
223
+ <SessionAskStatus response={response} />
199
224
  ) : response.isStreaming ? (
200
225
  <span className="text-xs text-muted-foreground">
201
226
  <span className="ai-streaming-cursor" /> Thinking...
@@ -0,0 +1,89 @@
1
+ import React from 'react';
2
+ import type { AIResponse } from '../../types';
3
+ import { SESSION_ASK_ERROR_CODES } from '../../utils/aiProvider';
4
+
5
+ /** What the reviewer chose on an "Ask this session" answer that could not run. */
6
+ export type SessionAskAction = 'wait' | 'interrupt' | 'fallback';
7
+
8
+ /**
9
+ * In-progress line for a question waiting on a busy session. Renders nothing
10
+ * for any other response, so providers without a bridge are unaffected.
11
+ */
12
+ export const SessionAskStatus: React.FC<{ response: AIResponse }> = ({ response }) => {
13
+ if (!response.status) return null;
14
+ return (
15
+ <span className="text-xs text-muted-foreground" data-session-ask-status={response.status}>
16
+ <span className="ai-streaming-cursor" />{' '}
17
+ {response.status === 'waiting'
18
+ ? 'Waiting for the session to finish its current turn…'
19
+ : 'Interrupting the session…'}
20
+ </span>
21
+ );
22
+ };
23
+
24
+ /**
25
+ * Colour for an answer's error line: a busy session is a choice, not a
26
+ * failure, so it reads muted; every other error keeps the destructive tone.
27
+ */
28
+ export function sessionAskErrorTone(response: Pick<AIResponse, 'errorCode'>): string {
29
+ return response.errorCode === SESSION_ASK_ERROR_CODES.agentBusy ? 'text-muted-foreground' : 'text-destructive';
30
+ }
31
+
32
+ const buttonClass =
33
+ 'px-2 py-1 rounded-md text-[10px] font-medium transition-colors';
34
+
35
+ /**
36
+ * Follow-up actions under an "Ask this session" error:
37
+ * - the session is busy: ask when it finishes, or interrupt it and ask now;
38
+ * - the session is gone or blocked: ask a separate AI instead.
39
+ * Renders nothing for other errors or when the host passes no handler.
40
+ */
41
+ export const SessionAskActions: React.FC<{
42
+ response: AIResponse;
43
+ onAction?: (action: SessionAskAction) => void;
44
+ /** Label of the provider the fallback would use; null hides the fallback. */
45
+ fallbackLabel?: string | null;
46
+ }> = ({ response, onAction, fallbackLabel }) => {
47
+ if (!onAction || !response.errorCode) return null;
48
+
49
+ if (response.errorCode === SESSION_ASK_ERROR_CODES.agentBusy) {
50
+ return (
51
+ <div className="flex flex-wrap gap-1.5 mt-2" data-session-ask-actions="busy">
52
+ <button
53
+ type="button"
54
+ onClick={() => onAction('wait')}
55
+ className={`${buttonClass} bg-primary text-primary-foreground hover:opacity-90`}
56
+ >
57
+ Ask when it finishes
58
+ </button>
59
+ <button
60
+ type="button"
61
+ onClick={() => onAction('interrupt')}
62
+ className={`${buttonClass} bg-muted text-foreground hover:bg-muted/80`}
63
+ title="Stops the session's current work, then asks your question"
64
+ >
65
+ Interrupt and ask now
66
+ </button>
67
+ </div>
68
+ );
69
+ }
70
+
71
+ if (
72
+ (response.errorCode === SESSION_ASK_ERROR_CODES.gone || response.errorCode === SESSION_ASK_ERROR_CODES.blocked) &&
73
+ fallbackLabel
74
+ ) {
75
+ return (
76
+ <div className="flex flex-wrap gap-1.5 mt-2" data-session-ask-actions="fallback">
77
+ <button
78
+ type="button"
79
+ onClick={() => onAction('fallback')}
80
+ className={`${buttonClass} bg-muted text-foreground hover:bg-muted/80`}
81
+ >
82
+ Ask a separate AI instead ({fallbackLabel})
83
+ </button>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ return null;
89
+ };