@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.
- package/HANDOFF.md +114 -3
- package/README.md +41 -2
- package/components/AISettingsTab.tsx +6 -0
- package/components/AgentsTab.tsx +11 -1
- package/components/AnnotationPanel.tsx +44 -3
- package/components/BlockRenderer.tsx +55 -1
- package/components/CompletionOverlay.tsx +23 -1
- package/components/MarkdownEditor.tsx +9 -0
- package/components/ModelSourceHint.tsx +49 -0
- package/components/PRPlatformIcon.tsx +22 -0
- package/components/QuestionProgressChip.tsx +66 -0
- package/components/QuestionsPanelSection.tsx +121 -0
- package/components/Settings.tsx +25 -1
- package/components/Viewer.tsx +169 -6
- package/components/ai/AIProviderBar.tsx +11 -3
- package/components/blocks/QuestionBlock.tsx +591 -0
- package/config/settings.ts +17 -0
- package/hooks/useAgentSettings.ts +8 -1
- package/hooks/useAnnotationHighlighter.ts +4 -0
- package/hooks/useCodeAnnotationDraft.ts +24 -7
- package/hooks/useModelCatalogs.ts +28 -11
- 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 +4 -4
- package/styles.css +1 -1
- package/types.ts +3 -0
- package/utils/aiProvider.ts +4 -0
- package/utils/autoUpdateNotice.ts +59 -0
- package/utils/decisionSpec.ts +36 -2
- package/utils/parser.ts +125 -563
- package/utils/questionAnswers.ts +227 -0
|
@@ -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
|
+
};
|
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,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
|
-
?
|
|
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
|
-
|
|
1256
|
-
{
|
|
1257
|
-
|
|
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
|
-
|
|
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
|
};
|