@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.
Files changed (62) hide show
  1. package/HANDOFF.md +295 -11
  2. package/README.md +8 -0
  3. package/components/AISettingsTab.tsx +32 -0
  4. package/components/AgentToolAnnouncementDialog.tsx +347 -0
  5. package/components/AgentsTab.tsx +9 -9
  6. package/components/AnnotationToolbar.tsx +2 -2
  7. package/components/AskSessionAnnouncementDialog.tsx +329 -0
  8. package/components/BlockRenderer.tsx +10 -1
  9. package/components/CommentPopover.tsx +36 -18
  10. package/components/CompletionOverlay.tsx +12 -5
  11. package/components/DecisionControl.tsx +2 -7
  12. package/components/DiagramBlock.tsx +46 -4
  13. package/components/DocBadges.tsx +12 -8
  14. package/components/FloatingQuickLabelPicker.tsx +2 -2
  15. package/components/PlanHeaderMenu.tsx +1 -1
  16. package/components/QuickLabelDropdown.tsx +9 -4
  17. package/components/ServerSessionReplacedBanner.tsx +29 -0
  18. package/components/Settings.tsx +76 -192
  19. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  20. package/components/Viewer.tsx +30 -1
  21. package/components/ai/AIProviderBar.tsx +18 -1
  22. package/components/ai/DocumentAIChatPanel.tsx +4 -9
  23. package/components/ai/SessionAskNotice.tsx +33 -21
  24. package/components/blocks/QuestionBlock.tsx +99 -27
  25. package/components/blocks/QuestionContext.tsx +81 -0
  26. package/components/diagram/DiagramComposer.tsx +54 -3
  27. package/components/diagram/DiagramViewer.tsx +31 -2
  28. package/components/diagram/index.ts +1 -1
  29. package/components/html-viewer/HtmlViewer.tsx +13 -3
  30. package/components/html-viewer/bridge-script.asset.js +230 -14
  31. package/components/html-viewer/bridge-script.ts +230 -14
  32. package/components/html-viewer/useHtmlAnnotation.ts +36 -55
  33. package/components/settings/QuickLabelsTab.tsx +378 -0
  34. package/hooks/useAIChat.ts +83 -6
  35. package/hooks/useAIProviderActivation.ts +13 -0
  36. package/hooks/useAIProviderConfig.ts +3 -3
  37. package/hooks/useActiveSection.ts +3 -2
  38. package/hooks/useAgentToolSetting.ts +33 -0
  39. package/hooks/useAnnotationDraft.ts +53 -7
  40. package/hooks/useAnnotationHighlighter.ts +129 -5
  41. package/hooks/useExternalAnnotations.ts +21 -1
  42. package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
  43. package/hooks/useLatchedTrue.ts +15 -0
  44. package/hooks/useLinkedDoc.ts +64 -4
  45. package/hooks/useModelCatalogs.ts +9 -2
  46. package/package.json +2 -2
  47. package/styles.css +1 -1
  48. package/themes/gruvbox.css +6 -0
  49. package/types.ts +19 -0
  50. package/utils/agentClosed.ts +33 -0
  51. package/utils/agentToolAnnouncement.ts +109 -0
  52. package/utils/agentToolSetting.ts +108 -0
  53. package/utils/aiProvider.ts +13 -40
  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/parser.ts +382 -114
  60. package/utils/questionAnswers.ts +3 -1
  61. package/utils/quickLabels.ts +82 -2
  62. package/utils/serverSession.ts +85 -0
@@ -17,6 +17,10 @@ const SKIP_SELECTORS = [
17
17
  ].join(',');
18
18
 
19
19
  const INLINE_TARGET_SELECTOR = 'strong,em,a,code:not(.pn-code)';
20
+ /** A `:::question` card (`QuestionBlock`): one block whose text is in parts. */
21
+ const QUESTION_CARD_SELECTOR = 'fieldset.question-block';
22
+ /** The card's text parts: the prompt, the context, and each choice's text. */
23
+ const QUESTION_PART_SELECTOR = '[data-question-part]';
20
24
  const TABLE_EDGE_ZONE = 22;
21
25
 
22
26
  /** The semantic kind of a document target. */
@@ -133,10 +137,22 @@ function groupLabel(group: HTMLElement): string {
133
137
  }
134
138
 
135
139
  function listContentElement(block: HTMLElement): HTMLElement | null {
140
+ // A question card also starts with a `select-none` child (its eyebrow), so
141
+ // without this its second child, the prompt, stood in for the whole card:
142
+ // every pinpoint hover or click inside it resolved to the prompt.
143
+ if (block.matches(QUESTION_CARD_SELECTOR)) return null;
136
144
  if (!block.querySelector('.select-none')) return null;
137
145
  return block.children[1] instanceof HTMLElement ? block.children[1] : null;
138
146
  }
139
147
 
148
+ function questionPartLabel(part: HTMLElement): string {
149
+ const text = truncate(part.textContent?.trim() ?? '', 30);
150
+ const kind = part.dataset.questionPart;
151
+ if (kind === 'choice') return `choice: "${text}"`;
152
+ if (kind === 'context') return `context: "${text}"`;
153
+ return `question: "${text}"`;
154
+ }
155
+
140
156
  function addInlineTargets(
141
157
  targets: SemanticTarget[],
142
158
  byElement: Map<HTMLElement, SemanticTarget>,
@@ -199,6 +215,38 @@ export function buildSemanticTargetGraph(container: HTMLElement): SemanticTarget
199
215
 
200
216
  const group = block.closest<HTMLElement>('[data-pinpoint-group]');
201
217
  const parentKey = group ? groupTargets.get(group)?.key ?? null : null;
218
+
219
+ // First: a card's context can hold a table, a code fence or a formula,
220
+ // and the card is still one question block made of its parts.
221
+ if (block.matches(QUESTION_CARD_SELECTOR)) {
222
+ const prompt = block.querySelector<HTMLElement>('[data-question-part="prompt"]');
223
+ const cardTarget: SemanticTarget = {
224
+ key: `${blockId}:block`,
225
+ blockId,
226
+ element: block,
227
+ label: prompt ? questionPartLabel(prompt) : 'question',
228
+ kind: 'block',
229
+ parentKey,
230
+ };
231
+ targets.push(cardTarget);
232
+ byElement.set(block, cardTarget);
233
+ blockKeys.push(cardTarget.key);
234
+ block.querySelectorAll<HTMLElement>(QUESTION_PART_SELECTOR).forEach((part, index) => {
235
+ const partTarget: SemanticTarget = {
236
+ key: `${blockId}:part:${index}`,
237
+ blockId,
238
+ element: part,
239
+ label: questionPartLabel(part),
240
+ kind: 'inline',
241
+ parentKey: cardTarget.key,
242
+ };
243
+ targets.push(partTarget);
244
+ byElement.set(part, partTarget);
245
+ addInlineTargets(targets, byElement, blockId, partTarget, part, partTarget.key);
246
+ });
247
+ continue;
248
+ }
249
+
202
250
  const codeElement = block.querySelector<HTMLElement>('pre > code.pn-code');
203
251
  const mathElement = block.matches('.math-annotatable,[data-math-tex]')
204
252
  ? block
@@ -455,8 +503,11 @@ export function resolveSemanticTargetAtPoint(
455
503
  if (!block || !graph.container.contains(block) || block.tagName === 'HR') return null;
456
504
  const blockTarget = targetForBlock(graph, block);
457
505
  if (!blockTarget) return null;
506
+ // A question card's code fences and tables live in its context part; the
507
+ // part lookup below resolves them.
508
+ const card = block.matches(QUESTION_CARD_SELECTOR);
458
509
 
459
- const code = block.querySelector<HTMLElement>('pre > code.pn-code');
510
+ const code = card ? null : block.querySelector<HTMLElement>('pre > code.pn-code');
460
511
  if (
461
512
  code
462
513
  && (pointerTarget === code || code.contains(pointerTarget) || pointerTarget.closest('pre'))
@@ -464,7 +515,7 @@ export function resolveSemanticTargetAtPoint(
464
515
  return blockTarget;
465
516
  }
466
517
 
467
- const table = block.querySelector<HTMLTableElement>('table');
518
+ const table = card ? null : block.querySelector<HTMLTableElement>('table');
468
519
  if (table && pointer) {
469
520
  const rect = table.getBoundingClientRect();
470
521
  const nearHorizontalEdge = pointer.clientX - rect.left < TABLE_EDGE_ZONE
@@ -484,6 +535,14 @@ export function resolveSemanticTargetAtPoint(
484
535
  if (inlineTarget) return inlineTarget;
485
536
  }
486
537
 
538
+ // Inside a question card: the part under the pointer. A choice row's
539
+ // padding and marker belong to that choice's text.
540
+ const part = pointerTarget.closest<HTMLElement>(QUESTION_PART_SELECTOR)
541
+ ?? pointerTarget.closest<HTMLElement>('[data-question-option]')?.querySelector<HTMLElement>(QUESTION_PART_SELECTOR);
542
+ if (part && block.contains(part)) {
543
+ return graph.byElement.get(part) ?? blockTarget;
544
+ }
545
+
487
546
  const cell = pointerTarget.closest<HTMLTableCellElement>('td,th');
488
547
  if (cell && block.contains(cell)) {
489
548
  return graph.byElement.get(cell) ?? blockTarget;
@@ -0,0 +1,161 @@
1
+ import {
2
+ diagramAnchorLocationLine,
3
+ diagramTargetName,
4
+ diagramTargetText,
5
+ type DiagramAnchor,
6
+ type DiagramKind,
7
+ type DiagramTarget,
8
+ } from '@plannotator/core/diagram-anchor';
9
+ import type { CommentAskAIContext } from '../components/CommentPopover';
10
+
11
+ /**
12
+ * Ask AI from a diagram comment: the question rides the same
13
+ * `CommentAskAIContext` the markdown and html composers send, so the host's
14
+ * one Ask AI path (an SDK provider, "Ask this session", the agent terminal)
15
+ * carries it unchanged. `text` is the part's label (what the chat shows under
16
+ * the question); `detail` is the agent-facing identity, which
17
+ * `buildDefaultPrompt` puts on the USER message before the question, never in
18
+ * the system prompt:
19
+ *
20
+ * Diagram node Approve? (D), line 7 <- the export's location line
21
+ * Diagram type: Mermaid (flowchart)
22
+ * Diagram source, document lines 6–9:
23
+ * ````mermaid
24
+ * …
25
+ * ````
26
+ *
27
+ * The source is bounded (a window around the part when the diagram is long),
28
+ * so a huge whole-file diagram never floods a turn.
29
+ */
30
+
31
+ const ENGINE_LABELS: Record<DiagramKind, string> = { mermaid: 'Mermaid', graphviz: 'Graphviz' };
32
+ const FENCE_LANGUAGE: Record<DiagramKind, string> = { mermaid: 'mermaid', graphviz: 'dot' };
33
+
34
+ /** Most diagram source lines a question carries. */
35
+ export const MAX_ASK_AI_DIAGRAM_SOURCE_LINES = 40;
36
+ /** Most diagram source characters a question carries. */
37
+ export const MAX_ASK_AI_DIAGRAM_SOURCE_CHARS = 4000;
38
+
39
+ export interface DiagramAskAIInput {
40
+ readonly kind: DiagramKind;
41
+ /** The part the question is about; `sourceLine` names DOCUMENT lines. */
42
+ readonly anchor: DiagramAnchor;
43
+ /** Shift-click targets the draft also covers (absent or empty for most). */
44
+ readonly additionalTargets?: readonly DiagramTarget[];
45
+ /** The diagram text (the fence body, or the whole file). */
46
+ readonly source: string;
47
+ /** Lines to add to a diagram line to name the document line: the fence's
48
+ * opening line for a fence, 0 when the document is the diagram. */
49
+ readonly sourceLineOffset: number;
50
+ /** The document the diagram lives in, as the other composers send it. */
51
+ readonly sourcePath?: string;
52
+ }
53
+
54
+ /** A fence that no backtick run in the source can close early. */
55
+ function fenceFor(source: string): string {
56
+ let longest = 0;
57
+ for (const run of source.match(/`+/g) ?? []) longest = Math.max(longest, run.length);
58
+ return '`'.repeat(Math.max(4, longest + 1));
59
+ }
60
+
61
+ /** A line longer than the whole budget is cut, with a visible marker. */
62
+ const TRUNCATION_MARKER = '…';
63
+
64
+ /**
65
+ * The bounded slice of the source a question carries, grown outward from the
66
+ * part's line (the top of the diagram when the part has no line), one line
67
+ * below then one above, until the line cap or the character cap stops a
68
+ * side. The part's own line is always in it; a part line longer than the
69
+ * whole character budget is cut with a visible marker, so the slice never
70
+ * exceeds MAX_ASK_AI_DIAGRAM_SOURCE_CHARS (newlines included). Returns
71
+ * 1-based DIAGRAM lines and the text to quote.
72
+ */
73
+ function sourceWindow(
74
+ lines: readonly string[],
75
+ anchor: DiagramAnchor,
76
+ offset: number,
77
+ ): { first: number; last: number; body: string; excerpt: boolean } | null {
78
+ let wholeFirst = 1;
79
+ let wholeLast = lines.length;
80
+ while (wholeLast > 0 && (lines[wholeLast - 1] ?? '').trim() === '') wholeLast -= 1;
81
+ while (wholeFirst <= wholeLast && (lines[wholeFirst - 1] ?? '').trim() === '') wholeFirst += 1;
82
+ if (wholeLast === 0 || wholeFirst > wholeLast) return null;
83
+
84
+ const partLine = anchor.kind !== 'diagram' && anchor.sourceLine !== null ? anchor.sourceLine[0] - offset : null;
85
+ const center = partLine !== null && partLine >= wholeFirst && partLine <= wholeLast ? partLine : wholeFirst;
86
+
87
+ let centerText = lines[center - 1] ?? '';
88
+ let truncated = false;
89
+ if (centerText.length > MAX_ASK_AI_DIAGRAM_SOURCE_CHARS) {
90
+ centerText = centerText.slice(0, MAX_ASK_AI_DIAGRAM_SOURCE_CHARS - TRUNCATION_MARKER.length) + TRUNCATION_MARKER;
91
+ truncated = true;
92
+ }
93
+ let chars = centerText.length;
94
+ let count = 1;
95
+ let first = center;
96
+ let last = center;
97
+ let growDown = true;
98
+ let growUp = true;
99
+ while ((growDown || growUp) && count < MAX_ASK_AI_DIAGRAM_SOURCE_LINES) {
100
+ if (growDown) {
101
+ const next = last + 1;
102
+ const cost = (lines[next - 1] ?? '').length + 1;
103
+ if (next > wholeLast || chars + cost > MAX_ASK_AI_DIAGRAM_SOURCE_CHARS) growDown = false;
104
+ else {
105
+ last = next;
106
+ chars += cost;
107
+ count += 1;
108
+ }
109
+ }
110
+ if (growUp && count < MAX_ASK_AI_DIAGRAM_SOURCE_LINES) {
111
+ const next = first - 1;
112
+ const cost = (lines[next - 1] ?? '').length + 1;
113
+ if (next < wholeFirst || chars + cost > MAX_ASK_AI_DIAGRAM_SOURCE_CHARS) growUp = false;
114
+ else {
115
+ first = next;
116
+ chars += cost;
117
+ count += 1;
118
+ }
119
+ }
120
+ }
121
+ const body = [...lines.slice(first - 1, center - 1), centerText, ...lines.slice(center, last)].join('\n');
122
+ return { first, last, body, excerpt: truncated || first > wholeFirst || last < wholeLast };
123
+ }
124
+
125
+ /** The agent-facing identity of a diagram part (the `detail` Ask AI sends). */
126
+ export function diagramIdentityForAskAI(input: DiagramAskAIInput): string {
127
+ const { kind, anchor, source, sourceLineOffset } = input;
128
+ const out: string[] = [diagramAnchorLocationLine(anchor)];
129
+ for (const extra of input.additionalTargets ?? []) {
130
+ const text = diagramTargetText(extra);
131
+ out.push(`Also: ${diagramTargetName(extra)}${text !== '' && text !== extra.id ? ` "${text}"` : ''}`);
132
+ }
133
+ out.push(`Diagram type: ${ENGINE_LABELS[kind]} (${anchor.family})`);
134
+
135
+ const lines = source.split('\n');
136
+ const window = sourceWindow(lines, anchor, sourceLineOffset);
137
+ if (window !== null) {
138
+ const body = window.body;
139
+ const docFirst = window.first + sourceLineOffset;
140
+ const docLast = window.last + sourceLineOffset;
141
+ const range = docLast > docFirst ? `lines ${docFirst}–${docLast}` : `line ${docFirst}`;
142
+ const partial = window.excerpt ? ' (excerpt)' : '';
143
+ const fence = fenceFor(body);
144
+ out.push(`Diagram source${partial}, document ${range}:`);
145
+ out.push(`${fence}${FENCE_LANGUAGE[kind]}\n${body}\n${fence}`);
146
+ }
147
+ return out.join('\n');
148
+ }
149
+
150
+ /** The Ask AI context for a question asked from a diagram comment. */
151
+ export function diagramAskAIContext(input: DiagramAskAIInput): CommentAskAIContext {
152
+ const { kind, anchor } = input;
153
+ const engine = ENGINE_LABELS[kind];
154
+ return {
155
+ kind: 'selection',
156
+ label: anchor.kind === 'diagram' ? `${engine} diagram` : `${engine} diagram ${diagramTargetName(anchor)}`,
157
+ text: diagramTargetText(anchor),
158
+ ...(input.sourcePath !== undefined ? { sourcePath: input.sourcePath } : {}),
159
+ detail: diagramIdentityForAskAI(input),
160
+ };
161
+ }