@plannotator/ui 0.49.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/HANDOFF.md +202 -1
  2. package/LICENSE +191 -0
  3. package/README.md +23 -0
  4. package/components/AISettingsTab.tsx +33 -1
  5. package/components/AgentToolAnnouncementDialog.tsx +347 -0
  6. package/components/AgentsTab.tsx +9 -9
  7. package/components/AskSessionAnnouncementDialog.tsx +329 -0
  8. package/components/CommentPopover.tsx +32 -18
  9. package/components/DecisionControl.tsx +2 -7
  10. package/components/DiagramBlock.tsx +46 -4
  11. package/components/DiffFileTree.tsx +363 -0
  12. package/components/DocBadges.tsx +12 -8
  13. package/components/ImageLightbox.tsx +66 -0
  14. package/components/PlanHeaderMenu.tsx +1 -1
  15. package/components/ProviderIcons.tsx +15 -3
  16. package/components/Settings.tsx +73 -1
  17. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  18. package/components/Viewer.tsx +5 -28
  19. package/components/ai/AIProviderBar.tsx +20 -3
  20. package/components/ai/DocumentAIChatPanel.tsx +23 -3
  21. package/components/ai/SessionAskNotice.tsx +101 -0
  22. package/components/blocks/QuestionBlock.tsx +24 -19
  23. package/components/blocks/QuestionContext.tsx +81 -0
  24. package/components/diagram/DiagramComposer.tsx +54 -3
  25. package/components/diagram/DiagramViewer.tsx +31 -2
  26. package/components/diagram/index.ts +1 -1
  27. package/components/html-viewer/HtmlViewer.tsx +13 -3
  28. package/components/html-viewer/bridge-script.asset.js +209 -16
  29. package/components/html-viewer/bridge-script.ts +209 -16
  30. package/components/html-viewer/useHtmlAnnotation.ts +36 -55
  31. package/configure.ts +12 -0
  32. package/hooks/useAIChat.ts +96 -10
  33. package/hooks/useAIProviderActivation.ts +13 -0
  34. package/hooks/useAIProviderConfig.ts +32 -14
  35. package/hooks/useActiveSection.ts +3 -2
  36. package/hooks/useAgentToolSetting.ts +33 -0
  37. package/hooks/useAnnotationDraft.ts +53 -7
  38. package/hooks/useAnnotationHighlighter.ts +101 -5
  39. package/hooks/useDiffFileTreeExpansion.ts +92 -0
  40. package/hooks/useExternalAnnotations.ts +21 -1
  41. package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
  42. package/hooks/useLatchedTrue.ts +15 -0
  43. package/hooks/useLinkedDoc.ts +64 -4
  44. package/hooks/useModelCatalogs.ts +9 -2
  45. package/package.json +3 -2
  46. package/styles.css +1 -1
  47. package/themes/gruvbox.css +6 -0
  48. package/types.ts +17 -0
  49. package/utils/agentClosed.ts +33 -0
  50. package/utils/agentToolAnnouncement.ts +109 -0
  51. package/utils/agentToolSetting.ts +108 -0
  52. package/utils/aiPrompt.ts +31 -0
  53. package/utils/aiProvider.ts +41 -0
  54. package/utils/annotationOwnsHighlight.ts +27 -0
  55. package/utils/annotationScope.ts +15 -0
  56. package/utils/askSessionAnnouncement.ts +107 -0
  57. package/utils/blockTargeting.ts +61 -2
  58. package/utils/diagramAskAI.ts +161 -0
  59. package/utils/diffFileTree.ts +203 -0
  60. package/utils/htmlLinkNavigation.ts +87 -3
  61. package/utils/parser.ts +236 -110
  62. package/utils/questionAnswers.ts +3 -1
  63. package/utils/quickLabels.ts +2 -2
  64. package/utils/syntaxTheme.ts +33 -0
@@ -1,6 +1,7 @@
1
1
  import { generateId } from '../utils/generateId';
2
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';
@@ -1433,6 +1434,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1433
1434
  onAddAnnotation={readOnly ? undefined : onAddAnnotation}
1434
1435
  readOnly={readOnly}
1435
1436
  onRestoreReport={onRestoreReport}
1437
+ onAskAI={readOnly ? undefined : onAskAI}
1438
+ askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
1436
1439
  />
1437
1440
  </Suspense>
1438
1441
  ) : group.block.type === 'code' && isGraphvizLanguage(group.block.language) ? (
@@ -1445,6 +1448,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1445
1448
  onAddAnnotation={readOnly ? undefined : onAddAnnotation}
1446
1449
  readOnly={readOnly}
1447
1450
  onRestoreReport={onRestoreReport}
1451
+ onAskAI={readOnly ? undefined : onAskAI}
1452
+ askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
1448
1453
  />
1449
1454
  </Suspense>
1450
1455
  ) : group.block.type === 'table' ? (
@@ -1780,31 +1785,3 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1780
1785
  </CodePathValidationContext.Provider>
1781
1786
  );
1782
1787
  });
1783
-
1784
- /** Simple lightbox overlay for enlarged image viewing. */
1785
- const ImageLightbox: React.FC<{ src: string; alt: string; onClose: () => void }> = ({ src, alt, onClose }) => {
1786
- useEffect(() => {
1787
- const handleKeyDown = (e: KeyboardEvent) => {
1788
- if (e.key === 'Escape') onClose();
1789
- };
1790
- window.addEventListener('keydown', handleKeyDown);
1791
- return () => window.removeEventListener('keydown', handleKeyDown);
1792
- }, [onClose]);
1793
-
1794
- return (
1795
- <div
1796
- className="fixed inset-0 z-[200] flex flex-col items-center justify-center bg-black/80 backdrop-blur-sm cursor-zoom-out"
1797
- onClick={onClose}
1798
- >
1799
- <img
1800
- src={src}
1801
- alt={alt}
1802
- className="max-w-[90vw] max-h-[85vh] object-contain rounded-lg shadow-2xl"
1803
- onClick={(e) => e.stopPropagation()}
1804
- />
1805
- {alt && (
1806
- <div className="mt-3 text-sm text-white/70 max-w-[90vw] text-center truncate">{alt}</div>
1807
- )}
1808
- </div>
1809
- );
1810
- };
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { getProviderMeta } from '../ProviderIcons';
3
- import { type AIProviderOption } from '../../utils/aiProvider';
3
+ import { findSessionBridge, type AIProviderOption } from '../../utils/aiProvider';
4
4
  import { ModelSourceHint, modelSourceToolForProvider } from '../ModelSourceHint';
5
5
 
6
6
  interface AIProviderBarProps {
@@ -30,6 +30,23 @@ export const AIProviderBar: React.FC<AIProviderBarProps> = ({
30
30
  );
31
31
  }
32
32
 
33
+ // "Ask this session": the session that opened Plannotator is the only thing
34
+ // Ask AI talks to, so there is no provider or model to choose. Show its label.
35
+ const sessionBridge = findSessionBridge(providers);
36
+ if (sessionBridge) {
37
+ const bridgeMeta = getProviderMeta(sessionBridge.name, sessionBridge.label);
38
+ const BridgeIcon = bridgeMeta.icon;
39
+ return (
40
+ <div
41
+ className="border-t border-border/50 px-2 py-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground"
42
+ data-ai-provider-session-bridge
43
+ >
44
+ <BridgeIcon className="w-3.5 h-3.5 flex-shrink-0" />
45
+ <span className="min-w-0 truncate text-foreground">{bridgeMeta.label}</span>
46
+ </div>
47
+ );
48
+ }
49
+
33
50
  const currentProvider = providers.find(p => p.id === selectedProviderId) ?? providers[0];
34
51
  const effectiveProviderId = currentProvider?.id ?? '';
35
52
  const models = currentProvider?.models ?? [];
@@ -37,7 +54,7 @@ export const AIProviderBar: React.FC<AIProviderBarProps> = ({
37
54
  const effectiveModel = selectedModel ?? defaultModel?.id ?? '';
38
55
  const currentModel = models.find(m => m.id === effectiveModel) ?? defaultModel;
39
56
  const reasoningEfforts = currentModel?.reasoningEfforts ?? [];
40
- const meta = getProviderMeta(currentProvider?.name ?? 'AI');
57
+ const meta = getProviderMeta(currentProvider?.name ?? 'AI', currentProvider?.label);
41
58
  const Icon = meta.icon;
42
59
  // Show only when the selected model actually reports reasoning efforts.
43
60
  const showReasoningEffort = !!onReasoningEffortChange && reasoningEfforts.length > 0;
@@ -53,7 +70,7 @@ export const AIProviderBar: React.FC<AIProviderBarProps> = ({
53
70
  aria-label="AI provider"
54
71
  >
55
72
  {providers.map(provider => {
56
- const providerMeta = getProviderMeta(provider.name);
73
+ const providerMeta = getProviderMeta(provider.name, provider.label);
57
74
  return (
58
75
  <option key={provider.id} value={provider.id}>
59
76
  {providerMeta.label}
@@ -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, SessionAskNote, SessionAskStatus, sessionAskErrorTone, type SessionAskAction } from './SessionAskNotice';
8
9
  import { submitHint } from '../../utils/platform';
9
10
 
10
11
  interface DocumentAIChatPanelProps {
@@ -19,6 +20,8 @@ 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 answer. */
24
+ onSessionAskAction?: (questionId: string, action: SessionAskAction) => void;
22
25
  }
23
26
 
24
27
  function truncate(text: string, max = 180): string {
@@ -64,6 +67,7 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
64
67
  aiProviders = [],
65
68
  aiConfig,
66
69
  onAIConfigChange,
70
+ onSessionAskAction,
67
71
  }) => {
68
72
  const scrollRef = useRef<HTMLDivElement>(null);
69
73
  const [generalInput, setGeneralInput] = useState('');
@@ -109,7 +113,11 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
109
113
  )}
110
114
 
111
115
  {messages.map(entry => (
112
- <DocumentQAPair key={entry.question.id} entry={entry} />
116
+ <DocumentQAPair
117
+ key={entry.question.id}
118
+ entry={entry}
119
+ onSessionAskAction={onSessionAskAction}
120
+ />
113
121
  ))}
114
122
  </div>
115
123
  </OverlayScrollArea>
@@ -152,7 +160,10 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
152
160
  );
153
161
  };
154
162
 
155
- const DocumentQAPair = memo<{ entry: AIChatEntry }>(({ entry }) => {
163
+ const DocumentQAPair = memo<{
164
+ entry: AIChatEntry;
165
+ onSessionAskAction?: (questionId: string, action: SessionAskAction) => void;
166
+ }>(({ entry, onSessionAskAction }) => {
156
167
  const { question, response } = entry;
157
168
  const renderedResponse = useMemo(
158
169
  () => response.text ? renderChatMarkdown(response.text) : null,
@@ -190,17 +201,26 @@ const DocumentQAPair = memo<{ entry: AIChatEntry }>(({ entry }) => {
190
201
 
191
202
  <div className="group relative p-2.5 rounded-lg border border-border/50 bg-popover/50 hover:bg-muted/30 transition-colors">
192
203
  {response.error ? (
193
- <p className="text-xs text-destructive">{response.error}</p>
204
+ <>
205
+ <p className={`text-xs ${sessionAskErrorTone(response)}`}>{response.error}</p>
206
+ <SessionAskActions
207
+ response={response}
208
+ onAction={onSessionAskAction ? (action) => onSessionAskAction(question.id, action) : undefined}
209
+ />
210
+ </>
194
211
  ) : response.text ? (
195
212
  <div className="text-xs">
196
213
  {renderedResponse}
197
214
  {response.isStreaming && <span className="ai-streaming-cursor inline-block ml-0.5" />}
198
215
  </div>
216
+ ) : response.status ? (
217
+ <SessionAskStatus response={response} />
199
218
  ) : response.isStreaming ? (
200
219
  <span className="text-xs text-muted-foreground">
201
220
  <span className="ai-streaming-cursor" /> Thinking...
202
221
  </span>
203
222
  ) : null}
223
+ {!response.error && <SessionAskNote response={response} />}
204
224
  </div>
205
225
  </div>
206
226
  );
@@ -0,0 +1,101 @@
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';
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 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: 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.
41
+ */
42
+ export const SessionAskActions: React.FC<{
43
+ response: AIResponse;
44
+ onAction?: (action: SessionAskAction) => void;
45
+ }> = ({ response, onAction }) => {
46
+ if (!response.errorCode) return null;
47
+
48
+ if (response.errorCode === SESSION_ASK_ERROR_CODES.agentBusy) {
49
+ if (!onAction) return null;
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 (response.errorCode === SESSION_ASK_ERROR_CODES.gone || response.errorCode === SESSION_ASK_ERROR_CODES.blocked) {
72
+ const gone = response.errorCode === SESSION_ASK_ERROR_CODES.gone;
73
+ return (
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>
82
+ );
83
+ }
84
+
85
+ return null;
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 { renderProseBody } from './proseBody';
13
+ import { QuestionContext } from './QuestionContext';
13
14
 
14
15
  /**
15
16
  * A `:::question` block rendered as an answer card (the approved "Card"
@@ -122,7 +123,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
122
123
  blockId,
123
124
  indexed,
124
125
  total,
125
- answer: savedAnswer,
126
+ answer: storedAnswer,
126
127
  onAnswer: onLiveAnswer,
127
128
  onSaveAnswer,
128
129
  saveLabel,
@@ -136,6 +137,13 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
136
137
  onNavigateAnchor,
137
138
  }) => {
138
139
  const { question, number } = indexed;
140
+ // An answer saved under an older label of a choice (0.28.1 read a wrapped
141
+ // plain bullet's first line as its label) shows that choice picked; the
142
+ // next edit stores the current label.
143
+ const savedAnswer = useMemo(
144
+ () => (storedAnswer ? canonicalQuestionAnswer(question, storedAnswer) : storedAnswer),
145
+ [question, storedAnswer],
146
+ );
139
147
  const saveMode = !!onSaveAnswer;
140
148
  // Save mode: the unsaved draft (null = the reviewer emptied it). Absent
141
149
  // means the card shows the host's answer.
@@ -349,7 +357,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
349
357
  </span>
350
358
  </div>
351
359
 
352
- <p id={promptId} className="m-0 text-[15px] font-semibold leading-[1.45] text-foreground">
360
+ <p id={promptId} className="m-0 text-[15px] font-semibold leading-[1.45] text-foreground" data-question-part="prompt">
353
361
  {inline(question.prompt)}
354
362
  </p>
355
363
  {(question.decision || question.decisionOnAnswer) && (
@@ -378,20 +386,17 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
378
386
  </div>
379
387
  )}
380
388
  {question.context && (
381
- <div id={contextId} className="mt-1 text-[13px] leading-normal text-muted-foreground">
382
- {renderProseBody({
383
- body: question.context,
384
- paragraphClassName: 'text-[13px] leading-normal',
385
- listClassName: 'text-[13px] leading-normal',
386
- imageBaseDir,
387
- onImageClick,
388
- onOpenLinkedDoc,
389
- onOpenCodeFile,
390
- onNavigateAnchor,
391
- githubRepo,
392
- repoHost,
393
- })}
394
- </div>
389
+ <QuestionContext
390
+ id={contextId}
391
+ markdown={question.context}
392
+ imageBaseDir={imageBaseDir}
393
+ onImageClick={onImageClick}
394
+ onOpenLinkedDoc={onOpenLinkedDoc}
395
+ onOpenCodeFile={onOpenCodeFile}
396
+ onNavigateAnchor={onNavigateAnchor}
397
+ githubRepo={githubRepo}
398
+ repoHost={repoHost}
399
+ />
395
400
  )}
396
401
 
397
402
  {question.kind === 'text' ? (
@@ -454,7 +459,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
454
459
  className="mt-[3px] h-[15px] w-[15px] shrink-0 cursor-pointer accent-primary disabled:cursor-default"
455
460
  disabled={readOnly}
456
461
  />
457
- <span className="min-w-0 flex-1 select-text text-[13.5px] leading-[1.45] text-foreground">
462
+ <span className="min-w-0 flex-1 select-text text-[13.5px] leading-[1.45] text-foreground" data-question-part="choice">
458
463
  <span className="font-[550]">{inline(choice.label)}</span>
459
464
  {choice.description && (
460
465
  <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 { DiagramKind } from '@plannotator/core/diagram-anchor';
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';