@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.
- package/HANDOFF.md +147 -0
- package/LICENSE +191 -0
- package/README.md +56 -1
- package/components/AISettingsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +44 -3
- package/components/BlockRenderer.tsx +55 -1
- package/components/CompletionOverlay.tsx +23 -1
- package/components/DiffFileTree.tsx +363 -0
- package/components/ImageLightbox.tsx +66 -0
- package/components/PRPlatformIcon.tsx +22 -0
- package/components/ProviderIcons.tsx +15 -3
- package/components/QuestionProgressChip.tsx +66 -0
- package/components/QuestionsPanelSection.tsx +121 -0
- package/components/Settings.tsx +25 -1
- package/components/Viewer.tsx +170 -34
- package/components/ai/AIProviderBar.tsx +5 -5
- package/components/ai/DocumentAIChatPanel.tsx +28 -3
- package/components/ai/SessionAskNotice.tsx +89 -0
- package/components/blocks/QuestionBlock.tsx +591 -0
- package/components/html-viewer/bridge-script.asset.js +85 -8
- package/components/html-viewer/bridge-script.ts +85 -8
- package/config/settings.ts +17 -0
- package/configure.ts +12 -0
- package/hooks/useAIChat.ts +74 -9
- package/hooks/useAIProviderConfig.ts +32 -14
- package/hooks/useAnnotationHighlighter.ts +4 -0
- package/hooks/useDiffFileTreeExpansion.ts +92 -0
- package/hooks/usePinpoint.ts +10 -0
- package/hooks/useScrollKeyRouting.ts +157 -0
- package/hooks/useUndoHistory.ts +17 -0
- package/hooks/useVimDocumentFocus.ts +2 -1
- package/package.json +3 -2
- package/styles.css +1 -1
- package/types.ts +7 -0
- package/utils/aiPrompt.ts +31 -0
- package/utils/aiProvider.ts +68 -0
- package/utils/autoUpdateNotice.ts +59 -0
- package/utils/diffFileTree.ts +203 -0
- package/utils/htmlLinkNavigation.ts +87 -3
- package/utils/parser.ts +125 -563
- package/utils/questionAnswers.ts +227 -0
- 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
|
+
};
|
package/components/Settings.tsx
CHANGED
|
@@ -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
|
package/components/Viewer.tsx
CHANGED
|
@@ -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
|
-
?
|
|
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
|
-
|
|
1256
|
-
{
|
|
1257
|
-
|
|
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
|
-
|
|
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
|
|
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<{
|
|
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
|
-
|
|
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
|
+
};
|