@plannotator/ui 0.50.0 → 0.52.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 +295 -11
- package/README.md +8 -0
- package/components/AISettingsTab.tsx +32 -0
- package/components/AgentToolAnnouncementDialog.tsx +347 -0
- package/components/AgentsTab.tsx +9 -9
- package/components/AnnotationToolbar.tsx +2 -2
- package/components/AskSessionAnnouncementDialog.tsx +329 -0
- package/components/BlockRenderer.tsx +10 -1
- package/components/CommentPopover.tsx +36 -18
- package/components/CompletionOverlay.tsx +12 -5
- package/components/DecisionControl.tsx +2 -7
- package/components/DiagramBlock.tsx +46 -4
- package/components/DocBadges.tsx +12 -8
- package/components/FloatingQuickLabelPicker.tsx +2 -2
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/QuickLabelDropdown.tsx +9 -4
- package/components/ServerSessionReplacedBanner.tsx +29 -0
- package/components/Settings.tsx +76 -192
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/Viewer.tsx +30 -1
- package/components/ai/AIProviderBar.tsx +18 -1
- package/components/ai/DocumentAIChatPanel.tsx +4 -9
- package/components/ai/SessionAskNotice.tsx +33 -21
- package/components/blocks/QuestionBlock.tsx +99 -27
- package/components/blocks/QuestionContext.tsx +81 -0
- package/components/diagram/DiagramComposer.tsx +54 -3
- package/components/diagram/DiagramViewer.tsx +31 -2
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +13 -3
- package/components/html-viewer/bridge-script.asset.js +230 -14
- package/components/html-viewer/bridge-script.ts +230 -14
- package/components/html-viewer/useHtmlAnnotation.ts +36 -55
- package/components/settings/QuickLabelsTab.tsx +378 -0
- package/hooks/useAIChat.ts +83 -6
- package/hooks/useAIProviderActivation.ts +13 -0
- package/hooks/useAIProviderConfig.ts +3 -3
- package/hooks/useActiveSection.ts +3 -2
- package/hooks/useAgentToolSetting.ts +33 -0
- package/hooks/useAnnotationDraft.ts +53 -7
- package/hooks/useAnnotationHighlighter.ts +129 -5
- package/hooks/useExternalAnnotations.ts +21 -1
- package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
- package/hooks/useLatchedTrue.ts +15 -0
- package/hooks/useLinkedDoc.ts +64 -4
- package/hooks/useModelCatalogs.ts +9 -2
- package/package.json +2 -2
- package/styles.css +1 -1
- package/themes/gruvbox.css +6 -0
- package/types.ts +19 -0
- package/utils/agentClosed.ts +33 -0
- package/utils/agentToolAnnouncement.ts +109 -0
- package/utils/agentToolSetting.ts +108 -0
- package/utils/aiProvider.ts +13 -40
- package/utils/annotationOwnsHighlight.ts +27 -0
- package/utils/annotationScope.ts +15 -0
- package/utils/askSessionAnnouncement.ts +107 -0
- package/utils/blockTargeting.ts +61 -2
- package/utils/diagramAskAI.ts +161 -0
- package/utils/parser.ts +382 -114
- package/utils/questionAnswers.ts +3 -1
- package/utils/quickLabels.ts +82 -2
- package/utils/serverSession.ts +85 -0
|
@@ -3,7 +3,7 @@ import type { AIResponse } from '../../types';
|
|
|
3
3
|
import { SESSION_ASK_ERROR_CODES } from '../../utils/aiProvider';
|
|
4
4
|
|
|
5
5
|
/** What the reviewer chose on an "Ask this session" answer that could not run. */
|
|
6
|
-
export type SessionAskAction = 'wait' | 'interrupt'
|
|
6
|
+
export type SessionAskAction = 'wait' | 'interrupt';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* In-progress line for a question waiting on a busy session. Renders nothing
|
|
@@ -33,20 +33,20 @@ const buttonClass =
|
|
|
33
33
|
'px-2 py-1 rounded-md text-[10px] font-medium transition-colors';
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Follow-up
|
|
36
|
+
* Follow-up under an "Ask this session" error:
|
|
37
37
|
* - the session is busy: ask when it finishes, or interrupt it and ask now;
|
|
38
|
-
* - the session is gone or blocked:
|
|
39
|
-
*
|
|
38
|
+
* - the session is gone or blocked: a plain note. Ask AI has no other provider
|
|
39
|
+
* while a session is attached, so there is nothing to switch to.
|
|
40
|
+
* Busy actions render only when the host passes a handler.
|
|
40
41
|
*/
|
|
41
42
|
export const SessionAskActions: React.FC<{
|
|
42
43
|
response: AIResponse;
|
|
43
44
|
onAction?: (action: SessionAskAction) => void;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}> = ({ response, onAction, fallbackLabel }) => {
|
|
47
|
-
if (!onAction || !response.errorCode) return null;
|
|
45
|
+
}> = ({ response, onAction }) => {
|
|
46
|
+
if (!response.errorCode) return null;
|
|
48
47
|
|
|
49
48
|
if (response.errorCode === SESSION_ASK_ERROR_CODES.agentBusy) {
|
|
49
|
+
if (!onAction) return null;
|
|
50
50
|
return (
|
|
51
51
|
<div className="flex flex-wrap gap-1.5 mt-2" data-session-ask-actions="busy">
|
|
52
52
|
<button
|
|
@@ -68,22 +68,34 @@ export const SessionAskActions: React.FC<{
|
|
|
68
68
|
);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
fallbackLabel
|
|
74
|
-
) {
|
|
71
|
+
if (response.errorCode === SESSION_ASK_ERROR_CODES.gone || response.errorCode === SESSION_ASK_ERROR_CODES.blocked) {
|
|
72
|
+
const gone = response.errorCode === SESSION_ASK_ERROR_CODES.gone;
|
|
75
73
|
return (
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
Ask
|
|
83
|
-
|
|
84
|
-
</div>
|
|
74
|
+
<p
|
|
75
|
+
className="mt-1.5 text-[11px] text-muted-foreground"
|
|
76
|
+
data-session-ask-unreachable={gone ? 'gone' : 'blocked'}
|
|
77
|
+
>
|
|
78
|
+
{gone
|
|
79
|
+
? 'Ask AI answers only from the session that opened Plannotator, and that session is gone, so Ask AI can\'t reach it.'
|
|
80
|
+
: 'Ask AI answers only from the session that opened Plannotator, and that session is waiting on this decision, so Ask AI can\'t reach it until you decide.'}
|
|
81
|
+
</p>
|
|
85
82
|
);
|
|
86
83
|
}
|
|
87
84
|
|
|
88
85
|
return null;
|
|
89
86
|
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A muted note under an "Ask this session" answer that stopped early because
|
|
90
|
+
* the person typed into the session while it answered (the rest of that turn
|
|
91
|
+
* went to their prompt). The partial answer above it stands. Renders nothing
|
|
92
|
+
* for any other response.
|
|
93
|
+
*/
|
|
94
|
+
export const SessionAskNote: React.FC<{ response: Pick<AIResponse, 'notice'> }> = ({ response }) => {
|
|
95
|
+
if (!response.notice) return null;
|
|
96
|
+
return (
|
|
97
|
+
<p className="mt-1.5 text-[11px] text-muted-foreground" data-session-ask-note="taken-over">
|
|
98
|
+
{response.notice}
|
|
99
|
+
</p>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import React, { useId, useRef, useState } from 'react';
|
|
1
|
+
import React, { useId, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import {
|
|
3
|
+
canonicalQuestionAnswer,
|
|
3
4
|
emptyQuestionAnswer,
|
|
4
5
|
isQuestionAnswerEmpty,
|
|
5
6
|
questionStatus,
|
|
@@ -9,7 +10,7 @@ import {
|
|
|
9
10
|
type QuestionAnswer,
|
|
10
11
|
} from '@plannotator/core/question-block';
|
|
11
12
|
import { InlineMarkdown } from '../InlineMarkdown';
|
|
12
|
-
import {
|
|
13
|
+
import { QuestionContext } from './QuestionContext';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* A `:::question` block rendered as an answer card (the approved "Card"
|
|
@@ -35,7 +36,10 @@ import { renderProseBody } from './proseBody';
|
|
|
35
36
|
*
|
|
36
37
|
* A host can add its own actions at the right of the footer
|
|
37
38
|
* (`renderFooter`), and a question carrying `Decision: when answered` or a
|
|
38
|
-
* `Decision: [statement](url)` link shows that beside the prompt.
|
|
39
|
+
* `Decision: [statement](url)` link shows that beside the prompt. A host can
|
|
40
|
+
* also let the reviewer switch a `Decision: when answered` question's
|
|
41
|
+
* recording off and on (`decisionRecording` + `onToggleDecisionRecording`),
|
|
42
|
+
* and hide the card's own status tag to draw its own (`statusTag: 'none'`).
|
|
39
43
|
*/
|
|
40
44
|
|
|
41
45
|
/** Pointer travel (px) above which a press on a choice row is a drag. */
|
|
@@ -49,7 +53,7 @@ const now = (): number => (typeof performance !== 'undefined' ? performance.now(
|
|
|
49
53
|
|
|
50
54
|
const cx = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(' ');
|
|
51
55
|
|
|
52
|
-
const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open'; children: React.ReactNode; className?: string }> = ({ tone, children, className }) => (
|
|
56
|
+
const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open'; children: React.ReactNode; className?: string; style?: React.CSSProperties }> = ({ tone, children, className, style }) => (
|
|
53
57
|
<span
|
|
54
58
|
className={cx(
|
|
55
59
|
'annotation-exclude select-none inline-flex items-center gap-1 whitespace-nowrap rounded px-1.5 py-px align-[1px] text-[10.5px] font-semibold tracking-[0.03em]',
|
|
@@ -59,6 +63,7 @@ const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open'; children: React.Reac
|
|
|
59
63
|
tone === 'open' && 'border border-dashed border-border text-muted-foreground',
|
|
60
64
|
className,
|
|
61
65
|
)}
|
|
66
|
+
style={style}
|
|
62
67
|
data-pinpoint-ignore=""
|
|
63
68
|
>
|
|
64
69
|
{children}
|
|
@@ -77,6 +82,19 @@ const DiamondGlyph = () => (
|
|
|
77
82
|
</svg>
|
|
78
83
|
);
|
|
79
84
|
|
|
85
|
+
/** Draws a decision tag in its "off" state: the same words, dimmed, with a
|
|
86
|
+
* dotted outline instead of the tinted fill. Dimmed means the theme's
|
|
87
|
+
* muted-foreground, not opacity: 60% opacity on primary text dropped the
|
|
88
|
+
* default light theme under 3:1, while muted-foreground is the token every
|
|
89
|
+
* theme already tunes for secondary text. Inline so it adds no utility
|
|
90
|
+
* classes to the shared stylesheet and wins over the tag's `text-primary`. */
|
|
91
|
+
const DECISION_OFF_STYLE: React.CSSProperties = {
|
|
92
|
+
background: 'transparent',
|
|
93
|
+
color: 'var(--muted-foreground)',
|
|
94
|
+
outline: '1px dotted currentColor',
|
|
95
|
+
outlineOffset: '-1px',
|
|
96
|
+
};
|
|
97
|
+
|
|
80
98
|
/** Two answers say the same thing (identity and line ignored). */
|
|
81
99
|
const sameAnswer = (a: QuestionAnswer | null | undefined, b: QuestionAnswer | null | undefined): boolean => {
|
|
82
100
|
const norm = (x: QuestionAnswer | null | undefined) =>
|
|
@@ -109,6 +127,21 @@ export interface QuestionBlockProps {
|
|
|
109
127
|
* saved answer (never the unsaved draft). Rendered in read-only cards too;
|
|
110
128
|
* return null for nothing. */
|
|
111
129
|
renderFooter?: (question: IndexedQuestion, answer: QuestionAnswer | undefined) => React.ReactNode;
|
|
130
|
+
/** Whether answering a `Decision: when answered` question records a
|
|
131
|
+
* decision (default true). False draws the "Records a decision" tag dimmed
|
|
132
|
+
* with a dotted outline and hides the "Answering this records a decision"
|
|
133
|
+
* row. Has no effect on a question whose decision is already recorded
|
|
134
|
+
* (`Decision: [statement](url)`) or that carries no decision line. */
|
|
135
|
+
decisionRecording?: boolean;
|
|
136
|
+
/** Makes the "Records a decision" tag a toggle button (`aria-pressed`):
|
|
137
|
+
* a click calls this with the question's key and the next state. The host
|
|
138
|
+
* stores it and passes it back as `decisionRecording`. Not offered in a
|
|
139
|
+
* read-only card (no answer handler), which draws the tag as it stands. */
|
|
140
|
+
onToggleDecisionRecording?: (key: string, next: boolean) => void;
|
|
141
|
+
/** `'none'` hides the card's own status tag (Open / Answered / Settled /
|
|
142
|
+
* Skipped) so the host can draw its own; the decision tags stay. Default
|
|
143
|
+
* `'card'`. */
|
|
144
|
+
statusTag?: 'card' | 'none';
|
|
112
145
|
onOpenLinkedDoc?: (path: string) => void;
|
|
113
146
|
onOpenCodeFile?: (path: string) => void;
|
|
114
147
|
imageBaseDir?: string;
|
|
@@ -122,11 +155,14 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
122
155
|
blockId,
|
|
123
156
|
indexed,
|
|
124
157
|
total,
|
|
125
|
-
answer:
|
|
158
|
+
answer: storedAnswer,
|
|
126
159
|
onAnswer: onLiveAnswer,
|
|
127
160
|
onSaveAnswer,
|
|
128
161
|
saveLabel,
|
|
129
162
|
renderFooter,
|
|
163
|
+
decisionRecording,
|
|
164
|
+
onToggleDecisionRecording,
|
|
165
|
+
statusTag = 'card',
|
|
130
166
|
onOpenLinkedDoc,
|
|
131
167
|
onOpenCodeFile,
|
|
132
168
|
imageBaseDir,
|
|
@@ -136,6 +172,13 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
136
172
|
onNavigateAnchor,
|
|
137
173
|
}) => {
|
|
138
174
|
const { question, number } = indexed;
|
|
175
|
+
// An answer saved under an older label of a choice (0.28.1 read a wrapped
|
|
176
|
+
// plain bullet's first line as its label) shows that choice picked; the
|
|
177
|
+
// next edit stores the current label.
|
|
178
|
+
const savedAnswer = useMemo(
|
|
179
|
+
() => (storedAnswer ? canonicalQuestionAnswer(question, storedAnswer) : storedAnswer),
|
|
180
|
+
[question, storedAnswer],
|
|
181
|
+
);
|
|
139
182
|
const saveMode = !!onSaveAnswer;
|
|
140
183
|
// Save mode: the unsaved draft (null = the reviewer emptied it). Absent
|
|
141
184
|
// means the card shows the host's answer.
|
|
@@ -312,6 +355,17 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
312
355
|
const other = answer?.other ?? '';
|
|
313
356
|
const otherOn = other.trim() !== '';
|
|
314
357
|
|
|
358
|
+
// A `Decision: when answered` question the host may switch off. A recorded
|
|
359
|
+
// decision (`question.decision`) is never affected.
|
|
360
|
+
const decisionCapable = !question.decision && !!question.decisionOnAnswer;
|
|
361
|
+
const recordingOn = decisionRecording ?? true;
|
|
362
|
+
const decisionToggleable = decisionCapable && !readOnly && !!onToggleDecisionRecording;
|
|
363
|
+
// Present only when the host speaks to recording, so a card without these
|
|
364
|
+
// props carries exactly the attributes it always did.
|
|
365
|
+
const recordingAttr = decisionCapable && (decisionRecording !== undefined || !!onToggleDecisionRecording)
|
|
366
|
+
? (recordingOn ? 'on' : 'off')
|
|
367
|
+
: undefined;
|
|
368
|
+
|
|
315
369
|
const describedBy = [question.context ? contextId : null, question.kind === 'text' && question.suggestedText && !answered ? suggestId : null]
|
|
316
370
|
.filter(Boolean)
|
|
317
371
|
.join(' ') || undefined;
|
|
@@ -336,23 +390,44 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
336
390
|
aria-describedby={describedBy}
|
|
337
391
|
aria-disabled={readOnly || undefined}
|
|
338
392
|
data-question-decision={question.decision ? 'recorded' : question.decisionOnAnswer ? 'on-answer' : undefined}
|
|
393
|
+
data-question-decision-recording={recordingAttr}
|
|
339
394
|
>
|
|
340
395
|
<div className="annotation-exclude select-none mb-1.5 flex items-center gap-2" data-pinpoint-ignore="">
|
|
341
396
|
<span className="text-[10.5px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{eyebrow}</span>
|
|
342
397
|
<span className="ml-auto">
|
|
343
398
|
{question.decision && <><Tag tone="rec"><DiamondGlyph />Decision</Tag>{' '}</>}
|
|
344
|
-
{
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
399
|
+
{decisionCapable && (decisionToggleable ? (
|
|
400
|
+
// Focus shows as a ring, not an outline: the dotted outline is
|
|
401
|
+
// what draws the off state.
|
|
402
|
+
<><button
|
|
403
|
+
type="button"
|
|
404
|
+
aria-pressed={recordingOn}
|
|
405
|
+
onClick={() => onToggleDecisionRecording!(question.key, !recordingOn)}
|
|
406
|
+
className="annotation-exclude select-none inline-flex cursor-pointer items-center gap-1 whitespace-nowrap rounded px-1.5 py-px align-[1px] text-[10.5px] font-semibold tracking-[0.03em] bg-primary/15 text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
407
|
+
style={recordingOn ? undefined : DECISION_OFF_STYLE}
|
|
408
|
+
data-pinpoint-ignore=""
|
|
409
|
+
data-question-decision-toggle=""
|
|
410
|
+
><DiamondGlyph />Records a decision</button>{' '}</>
|
|
411
|
+
) : recordingOn ? (
|
|
412
|
+
<><Tag tone="rec"><DiamondGlyph />Records a decision</Tag>{' '}</>
|
|
413
|
+
) : (
|
|
414
|
+
<><Tag tone="rec" style={DECISION_OFF_STYLE}><DiamondGlyph />Records a decision</Tag>{' '}</>
|
|
415
|
+
))}
|
|
416
|
+
{statusTag !== 'none' && (
|
|
417
|
+
<>
|
|
418
|
+
{status === 'answered' && <Tag tone="ok"><CheckGlyph />Answered</Tag>}
|
|
419
|
+
{status === 'settled' && <Tag tone="ok"><CheckGlyph />Settled</Tag>}
|
|
420
|
+
{status === 'skipped' && <Tag tone="skip">Skipped</Tag>}
|
|
421
|
+
{status === 'open' && <Tag tone="open">Open</Tag>}
|
|
422
|
+
</>
|
|
423
|
+
)}
|
|
349
424
|
</span>
|
|
350
425
|
</div>
|
|
351
426
|
|
|
352
|
-
<p id={promptId} className="m-0 text-[15px] font-semibold leading-[1.45] text-foreground">
|
|
427
|
+
<p id={promptId} className="m-0 text-[15px] font-semibold leading-[1.45] text-foreground" data-question-part="prompt">
|
|
353
428
|
{inline(question.prompt)}
|
|
354
429
|
</p>
|
|
355
|
-
{(question.decision || question.decisionOnAnswer) && (
|
|
430
|
+
{(question.decision || (question.decisionOnAnswer && recordingOn)) && (
|
|
356
431
|
<div
|
|
357
432
|
className="annotation-exclude mt-2 mb-0.5 flex items-center gap-2 rounded-lg bg-primary/8 px-[9px] py-1.5 text-[12.5px] leading-[18px] text-foreground [&>svg]:text-primary"
|
|
358
433
|
data-pinpoint-ignore=""
|
|
@@ -378,20 +453,17 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
378
453
|
</div>
|
|
379
454
|
)}
|
|
380
455
|
{question.context && (
|
|
381
|
-
<
|
|
382
|
-
{
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
repoHost,
|
|
393
|
-
})}
|
|
394
|
-
</div>
|
|
456
|
+
<QuestionContext
|
|
457
|
+
id={contextId}
|
|
458
|
+
markdown={question.context}
|
|
459
|
+
imageBaseDir={imageBaseDir}
|
|
460
|
+
onImageClick={onImageClick}
|
|
461
|
+
onOpenLinkedDoc={onOpenLinkedDoc}
|
|
462
|
+
onOpenCodeFile={onOpenCodeFile}
|
|
463
|
+
onNavigateAnchor={onNavigateAnchor}
|
|
464
|
+
githubRepo={githubRepo}
|
|
465
|
+
repoHost={repoHost}
|
|
466
|
+
/>
|
|
395
467
|
)}
|
|
396
468
|
|
|
397
469
|
{question.kind === 'text' ? (
|
|
@@ -454,7 +526,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
454
526
|
className="mt-[3px] h-[15px] w-[15px] shrink-0 cursor-pointer accent-primary disabled:cursor-default"
|
|
455
527
|
disabled={readOnly}
|
|
456
528
|
/>
|
|
457
|
-
<span className="min-w-0 flex-1 select-text text-[13.5px] leading-[1.45] text-foreground">
|
|
529
|
+
<span className="min-w-0 flex-1 select-text text-[13.5px] leading-[1.45] text-foreground" data-question-part="choice">
|
|
458
530
|
<span className="font-[550]">{inline(choice.label)}</span>
|
|
459
531
|
{choice.description && (
|
|
460
532
|
<span className="text-muted-foreground"> — {inline(choice.description)}</span>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import type { Block } from '../../types';
|
|
3
|
+
import { computeListIndices, groupBlocks, parseMarkdownToBlocks } from '../../utils/parser';
|
|
4
|
+
// BlockRenderer renders QuestionBlock, which renders this: a cycle that only
|
|
5
|
+
// meets at render time, after both modules have loaded.
|
|
6
|
+
import { BlockRenderer } from '../BlockRenderer';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The document renderers at the card's scale and tone. A stylesheet rather
|
|
10
|
+
* than Tailwind arbitrary variants on purpose: those would add utilities to
|
|
11
|
+
* every bundle whose Tailwind scans `packages/ui/components` (the guides.show
|
|
12
|
+
* viewer among them, which never renders a question card), and a host gets
|
|
13
|
+
* these rules without any Tailwind setup. Unlayered, so it outranks the
|
|
14
|
+
* renderers' utility classes.
|
|
15
|
+
*/
|
|
16
|
+
const QUESTION_CONTEXT_CSS = `
|
|
17
|
+
.question-context > * { margin-top: 0.5rem; margin-bottom: 0.5rem; }
|
|
18
|
+
.question-context > :first-child { margin-top: 0; }
|
|
19
|
+
.question-context > :last-child { margin-bottom: 0; }
|
|
20
|
+
.question-context p, .question-context .text-sm { font-size: 13px; line-height: 1.5; }
|
|
21
|
+
.question-context p, .question-context [data-question-context-list] span { color: inherit; }
|
|
22
|
+
.question-context h1, .question-context h2, .question-context h3 { margin: 0.75rem 0 0.25rem; font-size: 14px; }
|
|
23
|
+
.question-context h3 { font-size: 13.5px; }
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A question card's context, rendered as the document renders markdown: the
|
|
28
|
+
* same block parser and the same block renderers, so a table, a list, a code
|
|
29
|
+
* fence, a quote or an image in the context looks the way it does in the
|
|
30
|
+
* document instead of collapsing into one paragraph.
|
|
31
|
+
*
|
|
32
|
+
* The blocks belong to the card: they carry no `data-block-id` of their own,
|
|
33
|
+
* so a selection or pinpoint inside them resolves to the card (and its
|
|
34
|
+
* `data-question-part="context"` part), never to a block id that also names a
|
|
35
|
+
* real document block. The card sets the scale: muted 13px prose, tight
|
|
36
|
+
* spacing.
|
|
37
|
+
*/
|
|
38
|
+
export const QuestionContext: React.FC<{
|
|
39
|
+
id: string;
|
|
40
|
+
markdown: string;
|
|
41
|
+
imageBaseDir?: string;
|
|
42
|
+
onImageClick?: (src: string, alt: string) => void;
|
|
43
|
+
onOpenLinkedDoc?: (path: string) => void;
|
|
44
|
+
onOpenCodeFile?: (path: string) => void;
|
|
45
|
+
onNavigateAnchor?: (hash: string) => void;
|
|
46
|
+
githubRepo?: string;
|
|
47
|
+
repoHost?: string;
|
|
48
|
+
}> = ({ id, markdown, ...inlineProps }) => {
|
|
49
|
+
const groups = useMemo((): Array<{ list: Block[]; indices: (number | null)[] } | { block: Block }> => {
|
|
50
|
+
// Ids leave after grouping (grouping keys on them); undefined makes the
|
|
51
|
+
// renderers omit `data-block-id`.
|
|
52
|
+
const anonymous = (block: Block): Block => ({ ...block, id: undefined as unknown as string });
|
|
53
|
+
return groupBlocks(parseMarkdownToBlocks(markdown, { frontmatter: false })).map((group) =>
|
|
54
|
+
group.type === 'list-group'
|
|
55
|
+
? { list: group.blocks.map(anonymous), indices: computeListIndices(group.blocks) }
|
|
56
|
+
: { block: anonymous(group.block) },
|
|
57
|
+
);
|
|
58
|
+
}, [markdown]);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
id={id}
|
|
63
|
+
className="question-context mt-1 text-[13px] leading-normal text-muted-foreground"
|
|
64
|
+
data-question-part="context"
|
|
65
|
+
>
|
|
66
|
+
{/* React hoists this into <head> once for every card. */}
|
|
67
|
+
<style href="plannotator-question-context" precedence="default">{QUESTION_CONTEXT_CSS}</style>
|
|
68
|
+
{groups.map((group, i) =>
|
|
69
|
+
'list' in group ? (
|
|
70
|
+
<div key={`l-${i}`} data-question-context-list="">
|
|
71
|
+
{group.list.map((block, j) => (
|
|
72
|
+
<BlockRenderer key={j} block={block} orderedIndex={group.indices[j]} {...inlineProps} />
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
) : (
|
|
76
|
+
<BlockRenderer key={`b-${i}`} block={group.block} {...inlineProps} />
|
|
77
|
+
),
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
};
|
|
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
|
|
|
2
2
|
import { diagramTargetName, diagramTargetText } from '@plannotator/core/diagram-anchor';
|
|
3
3
|
import { cn } from '../../lib/utils';
|
|
4
4
|
import type { ScreenRect } from '../../utils/diagram-projection';
|
|
5
|
+
import { SparklesIcon } from '../SparklesIcon';
|
|
5
6
|
import { Button } from '../ui/button';
|
|
6
7
|
import type { DiagramComposerDraft } from './useDiagramComments';
|
|
7
8
|
|
|
@@ -12,6 +13,12 @@ import type { DiagramComposerDraft } from './useDiagramComments';
|
|
|
12
13
|
* ruling; the markdown composer shows no hint either). It lives here
|
|
13
14
|
* because the markdown composer is bound to web-highlighter's selection and
|
|
14
15
|
* the html composer to the sandbox bridge; neither reaches an app-owned svg.
|
|
16
|
+
*
|
|
17
|
+
* With `onAskAI` it also offers the markdown composer's "Ask AI": the typed
|
|
18
|
+
* text is asked as a question about the part instead of saved as a comment.
|
|
19
|
+
* Disabled until there is text, never bound to a key (Enter stays Comment,
|
|
20
|
+
* as Mod+Enter stays Save in the markdown composer), and the composer closes
|
|
21
|
+
* once the host accepts the question.
|
|
15
22
|
*/
|
|
16
23
|
|
|
17
24
|
/** The composer's width; it sits to the right of the ring and flips left
|
|
@@ -28,6 +35,7 @@ export function DiagramComposer({
|
|
|
28
35
|
disabledReason,
|
|
29
36
|
onSubmit,
|
|
30
37
|
onCancel,
|
|
38
|
+
onAskAI,
|
|
31
39
|
}: {
|
|
32
40
|
draft: DiagramComposerDraft;
|
|
33
41
|
anchorRect: ScreenRect;
|
|
@@ -41,12 +49,23 @@ export function DiagramComposer({
|
|
|
41
49
|
disabledReason?: string;
|
|
42
50
|
onSubmit: (body: string) => void;
|
|
43
51
|
onCancel: () => void;
|
|
52
|
+
/** Ask the typed text as a question about this part. Resolves true when
|
|
53
|
+
* the host took it (the composer then closes), false to keep the draft.
|
|
54
|
+
* Absent: no Ask AI button. */
|
|
55
|
+
onAskAI?: (question: string) => Promise<boolean>;
|
|
44
56
|
}) {
|
|
45
57
|
const [text, setText] = useState('');
|
|
58
|
+
const [asking, setAsking] = useState(false);
|
|
46
59
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
47
60
|
useEffect(() => {
|
|
48
61
|
textareaRef.current?.focus();
|
|
49
62
|
}, []);
|
|
63
|
+
// A refused question hands the keyboard back to the textarea, once it is
|
|
64
|
+
// enabled again (it is disabled while the question is pending).
|
|
65
|
+
const [refocus, setRefocus] = useState(0);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (refocus > 0) textareaRef.current?.focus();
|
|
68
|
+
}, [refocus]);
|
|
50
69
|
|
|
51
70
|
const target = draft.primary.target;
|
|
52
71
|
const unsavedPart = draft.sourceLine === null && sourceDirty;
|
|
@@ -54,6 +73,25 @@ export function DiagramComposer({
|
|
|
54
73
|
const rightFits = anchorRect.left + anchorRect.width + 12 + COMPOSER_WIDTH_PX <= hostWidth;
|
|
55
74
|
const left = rightFits ? anchorRect.left + anchorRect.width + 12 : Math.max(0, anchorRect.left - COMPOSER_WIDTH_PX - 12);
|
|
56
75
|
|
|
76
|
+
const canAskAI = onAskAI !== undefined && !asking && !submitting && text.trim() !== '';
|
|
77
|
+
const askAI = async () => {
|
|
78
|
+
const question = text.trim();
|
|
79
|
+
if (onAskAI === undefined || question === '' || asking) {
|
|
80
|
+
textareaRef.current?.focus();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
setAsking(true);
|
|
84
|
+
let accepted = false;
|
|
85
|
+
try {
|
|
86
|
+
accepted = await onAskAI(question);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error('Ask AI action failed:', error);
|
|
89
|
+
} finally {
|
|
90
|
+
setAsking(false);
|
|
91
|
+
}
|
|
92
|
+
if (!accepted) setRefocus((n) => n + 1);
|
|
93
|
+
};
|
|
94
|
+
|
|
57
95
|
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
58
96
|
if (event.key === 'Escape') {
|
|
59
97
|
// Ours: the canvas and whatever holds it (a popout) must not also
|
|
@@ -65,7 +103,7 @@ export function DiagramComposer({
|
|
|
65
103
|
}
|
|
66
104
|
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
|
|
67
105
|
event.preventDefault();
|
|
68
|
-
if (canWrite && text.trim() !== '') onSubmit(text);
|
|
106
|
+
if (canWrite && !asking && text.trim() !== '') onSubmit(text);
|
|
69
107
|
}
|
|
70
108
|
};
|
|
71
109
|
|
|
@@ -100,7 +138,7 @@ export function DiagramComposer({
|
|
|
100
138
|
onKeyDown={onKeyDown}
|
|
101
139
|
placeholder="Add a comment..."
|
|
102
140
|
rows={3}
|
|
103
|
-
disabled={submitting}
|
|
141
|
+
disabled={submitting || asking}
|
|
104
142
|
className={cn(
|
|
105
143
|
'w-full resize-none rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground placeholder:text-muted-foreground',
|
|
106
144
|
'outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
|
@@ -112,10 +150,23 @@ export function DiagramComposer({
|
|
|
112
150
|
</p>
|
|
113
151
|
)}
|
|
114
152
|
<div className="flex items-center justify-end gap-1">
|
|
153
|
+
{onAskAI !== undefined && (
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
data-diagram-ask-ai=""
|
|
157
|
+
onClick={() => void askAI()}
|
|
158
|
+
disabled={!canAskAI}
|
|
159
|
+
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-primary disabled:cursor-not-allowed disabled:opacity-50"
|
|
160
|
+
title={canAskAI ? 'Ask AI this question' : 'Type a question to ask AI'}
|
|
161
|
+
>
|
|
162
|
+
<SparklesIcon className="h-3 w-3" />
|
|
163
|
+
Ask AI
|
|
164
|
+
</button>
|
|
165
|
+
)}
|
|
115
166
|
<Button type="button" variant="ghost" size="xs" onClick={onCancel}>
|
|
116
167
|
Cancel
|
|
117
168
|
</Button>
|
|
118
|
-
<Button type="button" size="xs" disabled={!canWrite || submitting || text.trim() === ''} onClick={() => onSubmit(text)}>
|
|
169
|
+
<Button type="button" size="xs" disabled={!canWrite || submitting || asking || text.trim() === ''} onClick={() => onSubmit(text)}>
|
|
119
170
|
Comment
|
|
120
171
|
</Button>
|
|
121
172
|
</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
2
|
-
import type
|
|
2
|
+
import { buildDiagramAnchorValue, type DiagramAnchor, type DiagramKind, type DiagramTarget } from '@plannotator/core/diagram-anchor';
|
|
3
3
|
import { cn } from '../../lib/utils';
|
|
4
4
|
import { diagramFamilyOf } from '../../utils/diagram-anchor';
|
|
5
5
|
import { diagramFinder, type DiagramTheme } from '../../utils/diagram-render';
|
|
@@ -33,6 +33,16 @@ const DiagramSourcePane = lazy(async () => ({ default: (await import('./DiagramS
|
|
|
33
33
|
* Never `null` here — that would collapse the row back onto the canvas. */
|
|
34
34
|
const PANE_CLASS = 'order-last min-h-0 shrink-0 basis-2/5 border-t border-border md:order-first md:w-80 md:basis-auto md:border-r md:border-t-0';
|
|
35
35
|
|
|
36
|
+
/** A question typed in the composer, asked about the part instead of saved
|
|
37
|
+
* as a comment. The anchor is the one a comment there would carry (its
|
|
38
|
+
* `sourceLine` already in document lines). Resolve false (or throw) to keep
|
|
39
|
+
* the draft open; anything else closes it. */
|
|
40
|
+
export type DiagramAskAI = (
|
|
41
|
+
question: string,
|
|
42
|
+
anchor: DiagramAnchor,
|
|
43
|
+
additionalTargets: readonly DiagramTarget[],
|
|
44
|
+
) => boolean | void | Promise<boolean | void>;
|
|
45
|
+
|
|
36
46
|
export interface DiagramViewerProps {
|
|
37
47
|
readonly kind: DiagramKind;
|
|
38
48
|
/** The diagram text. With `onSave` this is the saved baseline the pane's
|
|
@@ -42,6 +52,8 @@ export interface DiagramViewerProps {
|
|
|
42
52
|
readonly comments: readonly DiagramComment[];
|
|
43
53
|
/** A comment composed on a part. Absent: clicks open nothing. */
|
|
44
54
|
readonly onCreateComment?: DiagramCreateComment;
|
|
55
|
+
/** Ask AI from the composer. Absent: the composer offers no Ask AI. */
|
|
56
|
+
readonly onAskAI?: DiagramAskAI;
|
|
45
57
|
/** Save the pane's text. Absent: there is no Source pane. */
|
|
46
58
|
readonly onSave?: (source: string) => Promise<SaveResult>;
|
|
47
59
|
/** With `onSave`: show the pane read-only (no edit access). */
|
|
@@ -92,6 +104,7 @@ export function DiagramViewer({
|
|
|
92
104
|
theme,
|
|
93
105
|
comments,
|
|
94
106
|
onCreateComment,
|
|
107
|
+
onAskAI,
|
|
95
108
|
onSave,
|
|
96
109
|
readOnlySource = false,
|
|
97
110
|
sourceOpen = false,
|
|
@@ -177,6 +190,21 @@ export function DiagramViewer({
|
|
|
177
190
|
if (!sourceOpen) cancelComposer();
|
|
178
191
|
}, [cancelComposer, sourceOpen]);
|
|
179
192
|
|
|
193
|
+
// Ask AI asks about the part the draft is on; the host's handler decides
|
|
194
|
+
// whether the question was taken, and a taken question closes the draft
|
|
195
|
+
// exactly as the markdown composer closes.
|
|
196
|
+
const composerDraft = commentsState.composer;
|
|
197
|
+
const askFromComposer = useMemo(() => {
|
|
198
|
+
if (onAskAI === undefined || composerDraft === null || onCreateComment === undefined) return undefined;
|
|
199
|
+
return async (question: string): Promise<boolean> => {
|
|
200
|
+
const anchor = buildDiagramAnchorValue(composerDraft.primary.target, composerDraft.sourceLine);
|
|
201
|
+
const accepted = await onAskAI(question, anchor, composerDraft.additional.map((extra) => extra.target));
|
|
202
|
+
if (accepted === false) return false;
|
|
203
|
+
cancelComposer();
|
|
204
|
+
return true;
|
|
205
|
+
};
|
|
206
|
+
}, [cancelComposer, composerDraft, onAskAI, onCreateComment]);
|
|
207
|
+
|
|
180
208
|
const overlay = useCallback(
|
|
181
209
|
(handle: DiagramCanvasHandle) => (
|
|
182
210
|
<DiagramOverlay
|
|
@@ -199,12 +227,13 @@ export function DiagramViewer({
|
|
|
199
227
|
disabledReason={commentingDisabledReason}
|
|
200
228
|
onSubmit={(text) => void commentsState.submit(text)}
|
|
201
229
|
onCancel={commentsState.cancel}
|
|
230
|
+
onAskAI={askFromComposer}
|
|
202
231
|
/>
|
|
203
232
|
)
|
|
204
233
|
}
|
|
205
234
|
/>
|
|
206
235
|
),
|
|
207
|
-
[commentingDisabledReason, commentsState, onSelectComment, selectedCommentId, sourceDirty],
|
|
236
|
+
[askFromComposer, commentingDisabledReason, commentsState, onSelectComment, selectedCommentId, sourceDirty],
|
|
208
237
|
);
|
|
209
238
|
|
|
210
239
|
const showFallback = render.svgNode === null;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `graphviz`, `mermaid`, `diagram-anchor`, `diagram-anchor-graphviz`,
|
|
7
7
|
* `diagram-projection`).
|
|
8
8
|
*/
|
|
9
|
-
export { DiagramViewer, type DiagramViewerProps } from './DiagramViewer';
|
|
9
|
+
export { DiagramViewer, type DiagramAskAI, type DiagramViewerProps } from './DiagramViewer';
|
|
10
10
|
export { DiagramPopout } from './DiagramPopout';
|
|
11
11
|
export { DiagramPending, DiagramInlineSource, DiagramBlockPending } from './DiagramPending';
|
|
12
12
|
export { isDiagramControlEvent, DIAGRAM_CONTROL_SELECTOR } from './diagramControls';
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import type { Annotation, EditorMode, ImageAttachment, InputMethod } from "../../types";
|
|
18
18
|
import { AnnotationType } from "../../types";
|
|
19
19
|
import { copyTextPreservingFocus } from "../../utils/clipboard";
|
|
20
|
+
import { elementIdentityForAskAI } from "../../utils/parser";
|
|
20
21
|
import { getIdentity } from "../../utils/identity";
|
|
21
22
|
import { THUMBS_UP_LABEL } from "../../utils/quickLabels";
|
|
22
23
|
import {
|
|
@@ -674,6 +675,13 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
674
675
|
}));
|
|
675
676
|
}, [hook.draftTargets]);
|
|
676
677
|
|
|
678
|
+
// Ask AI from a pinpoint draft: which element(s) the question is about,
|
|
679
|
+
// in the identity lines the feedback export prints (no outline).
|
|
680
|
+
const askAIElementDetail = useMemo(
|
|
681
|
+
() => elementIdentityForAskAI(hook.draftTargets) || undefined,
|
|
682
|
+
[hook.draftTargets],
|
|
683
|
+
);
|
|
684
|
+
|
|
677
685
|
useEffect(() => {
|
|
678
686
|
function handler(e: MessageEvent<unknown>) {
|
|
679
687
|
if (e.source !== iframeRef.current?.contentWindow) return;
|
|
@@ -1244,9 +1252,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
1244
1252
|
draftKey={`html:${hook.commentPopover.draftKey}`}
|
|
1245
1253
|
onSubmit={hook.handleCommentSubmit}
|
|
1246
1254
|
mentionSource={mentionSource}
|
|
1247
|
-
//
|
|
1248
|
-
// the surface's one-click
|
|
1249
|
-
//
|
|
1255
|
+
// A pinpoint click opens this composer directly (never the
|
|
1256
|
+
// selection toolbar), so it carries the surface's one-click 👍.
|
|
1257
|
+
// It also shows when the composer opens from a text selection's
|
|
1258
|
+
// Comment button: a harmless duplicate of the toolbar 👍.
|
|
1250
1259
|
onQuickLookGood={hook.handleCommentLooksGood}
|
|
1251
1260
|
onClose={hook.handleCommentClose}
|
|
1252
1261
|
skillReferences
|
|
@@ -1255,6 +1264,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
1255
1264
|
kind: "selection",
|
|
1256
1265
|
label: "Selected HTML",
|
|
1257
1266
|
text: hook.commentPopover.selectedText ?? hook.commentPopover.contextText,
|
|
1267
|
+
...(askAIElementDetail ? { detail: askAIElementDetail } : {}),
|
|
1258
1268
|
}}
|
|
1259
1269
|
targetChips={targetChips}
|
|
1260
1270
|
onRemoveTargetChip={targetChips ? hook.removeDraftTarget : undefined}
|