@plannotator/ui 0.51.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.
@@ -0,0 +1,378 @@
1
+ import React, { useLayoutEffect, useRef, useState } from 'react';
2
+ import { isMac, isWindows, altKey } from '../../utils/platform';
3
+ import {
4
+ type QuickLabel,
5
+ DEFAULT_QUICK_LABELS,
6
+ LABEL_COLOR_MAP,
7
+ emojiFromFieldInput,
8
+ getLabelColors,
9
+ getQuickLabels,
10
+ moveQuickLabel,
11
+ quickLabelShortcutDigit,
12
+ resetQuickLabels,
13
+ saveQuickLabels,
14
+ } from '../../utils/quickLabels';
15
+
16
+ /** Most labels the tab lets a person keep (only the first ten get a key). */
17
+ const MAX_QUICK_LABELS = 12;
18
+
19
+ /**
20
+ * One editable row. `key` is a session-only identity, never persisted: the
21
+ * saved shape stays exactly `QuickLabel[]`. A label's `id` cannot serve here —
22
+ * it is re-derived from the text on every keystroke and two labels with the
23
+ * same text share it — and position cannot either, because moving a row must
24
+ * not move the open tip editor, an emoji draft, or focus onto a neighbour
25
+ * (the #829 bug class, #1736).
26
+ */
27
+ interface QuickLabelRow {
28
+ key: string;
29
+ label: QuickLabel;
30
+ }
31
+
32
+ let rowKeySeq = 0;
33
+ const nextRowKey = () => `quick-label-row-${++rowKeySeq}`;
34
+ const toRows = (labels: readonly QuickLabel[]): QuickLabelRow[] =>
35
+ labels.map((label) => ({ key: nextRowKey(), label }));
36
+
37
+ const shortcutHint = (digit: string) => `${altKey}${isMac ? '' : '+'}${digit}`;
38
+ const shortcutWords = (digit: string) => `${isMac ? 'Option' : 'Alt'}+${digit}`;
39
+ const labelName = (label: QuickLabel) => label.text.trim() || 'label';
40
+
41
+ const EMOJI_HINT = isMac
42
+ ? 'Enter one emoji. Control-Command-Space opens the emoji picker.'
43
+ : isWindows
44
+ ? 'Enter one emoji. Windows+period opens the emoji picker.'
45
+ : 'Enter one emoji.';
46
+
47
+ const ChevronIcon: React.FC<{ up: boolean }> = ({ up }) => (
48
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
49
+ <path strokeLinecap="round" strokeLinejoin="round" d={up ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7'} />
50
+ </svg>
51
+ );
52
+
53
+ /** Settings → Labels: the Quick Labels editor (plan review and annotate). */
54
+ export const QuickLabelsTab: React.FC = () => {
55
+ const [rows, setRows] = useState<QuickLabelRow[]>(() => toRows(getQuickLabels()));
56
+ const [editingTipKey, setEditingTipKey] = useState<string | null>(null);
57
+ const [editingTipValue, setEditingTipValue] = useState('');
58
+ /** Emoji fields whose current text is not (yet) one emoji, by row key. */
59
+ const [emojiDrafts, setEmojiDrafts] = useState<Record<string, string>>({});
60
+ const [announcement, setAnnouncement] = useState('');
61
+ const listRef = useRef<HTMLDivElement>(null);
62
+ const pendingFocus = useRef<{ key: string; target: 'up' | 'down' | 'text' } | null>(null);
63
+
64
+ const commit = (next: QuickLabelRow[]) => {
65
+ setRows(next);
66
+ saveQuickLabels(next.map((row) => row.label));
67
+ };
68
+
69
+ const updateLabel = (key: string, patch: Partial<QuickLabel>) => {
70
+ commit(rows.map((row) => (row.key === key ? { ...row, label: { ...row.label, ...patch } } : row)));
71
+ };
72
+
73
+ const setEmojiDraft = (key: string, value: string | null) => {
74
+ setEmojiDrafts((drafts) => {
75
+ if (value === null) {
76
+ if (!(key in drafts)) return drafts;
77
+ const { [key]: _dropped, ...rest } = drafts;
78
+ return rest;
79
+ }
80
+ return { ...drafts, [key]: value };
81
+ });
82
+ };
83
+
84
+ const move = (index: number, delta: -1 | 1) => {
85
+ const to = index + delta;
86
+ if (to < 0 || to >= rows.length) return;
87
+ const row = rows[index];
88
+ commit(moveQuickLabel(rows, index, to));
89
+ pendingFocus.current = { key: row.key, target: delta < 0 ? 'up' : 'down' };
90
+ const digit = quickLabelShortcutDigit(to);
91
+ setAnnouncement(
92
+ `Moved ${labelName(row.label)} to position ${to + 1} of ${rows.length}, ${digit ? shortcutWords(digit) : 'no shortcut key'}.`,
93
+ );
94
+ };
95
+
96
+ // Moving a row re-orders DOM nodes, which can drop focus from the button
97
+ // that was pressed; put it back on the same row's button so repeated
98
+ // presses keep walking the same label. At either end the pressed button is
99
+ // disabled, so focus goes to the other direction instead.
100
+ useLayoutEffect(() => {
101
+ const pending = pendingFocus.current;
102
+ if (!pending) return;
103
+ pendingFocus.current = null;
104
+ const row = listRef.current?.querySelector<HTMLElement>(`[data-quick-label-row="${pending.key}"]`);
105
+ if (!row) return;
106
+ if (pending.target === 'text') {
107
+ const input = row.querySelector<HTMLInputElement>('[data-quick-label-text]');
108
+ input?.focus();
109
+ input?.select();
110
+ return;
111
+ }
112
+ const other = pending.target === 'up' ? 'down' : 'up';
113
+ const wanted = row.querySelector<HTMLButtonElement>(`[data-quick-label-move="${pending.target}"]`);
114
+ const fallback = row.querySelector<HTMLButtonElement>(`[data-quick-label-move="${other}"]`);
115
+ (wanted && !wanted.disabled ? wanted : fallback)?.focus();
116
+ }, [rows]);
117
+
118
+ const saveTip = (key: string) => {
119
+ updateLabel(key, { tip: editingTipValue || undefined });
120
+ setEditingTipKey(null);
121
+ };
122
+
123
+ return (
124
+ <>
125
+ <div className="flex items-center justify-between">
126
+ <div>
127
+ <div className="text-sm font-medium flex items-center gap-2">Quick Labels</div>
128
+ <div className="text-xs text-muted-foreground">
129
+ Preset annotations for one-click feedback
130
+ </div>
131
+ </div>
132
+ <button
133
+ onClick={() => {
134
+ resetQuickLabels();
135
+ setRows(toRows(DEFAULT_QUICK_LABELS));
136
+ setEditingTipKey(null);
137
+ setEmojiDrafts({});
138
+ }}
139
+ className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
140
+ >
141
+ Reset to defaults
142
+ </button>
143
+ </div>
144
+
145
+ <style>{`
146
+ @keyframes tip-slide-open {
147
+ from { opacity: 0; transform: translateY(-4px); }
148
+ to { opacity: 1; transform: translateY(0); }
149
+ }
150
+ `}</style>
151
+ <div ref={listRef} className="space-y-1.5">
152
+ {rows.map((row, index) => {
153
+ const { key, label } = row;
154
+ const colors = getLabelColors(label.color);
155
+ const hasTip = !!label.tip;
156
+ const isEditingTip = editingTipKey === key;
157
+ const digit = quickLabelShortcutDigit(index);
158
+ const name = labelName(label);
159
+ const emojiDraft = emojiDrafts[key];
160
+ const emojiInvalid = emojiDraft !== undefined;
161
+ const emojiErrorId = `${key}-emoji-error`;
162
+ return (
163
+ <div
164
+ key={key}
165
+ data-quick-label-row={key}
166
+ className="rounded-lg overflow-hidden"
167
+ style={{ backgroundColor: colors.bg }}
168
+ >
169
+ {/* Main row. On a narrow dialog the controls after the text
170
+ field wrap onto a second line instead of being clipped by
171
+ the row's overflow-hidden. */}
172
+ <div className="flex flex-wrap items-center gap-2 p-2">
173
+ <input
174
+ type="text"
175
+ data-quick-label-emoji
176
+ value={emojiDraft ?? label.emoji}
177
+ aria-label={`Emoji for ${name}`}
178
+ aria-invalid={emojiInvalid || undefined}
179
+ aria-describedby={emojiInvalid ? emojiErrorId : undefined}
180
+ title="Change emoji"
181
+ autoComplete="off"
182
+ spellCheck={false}
183
+ onFocus={(e) => e.target.select()}
184
+ onChange={(e) => {
185
+ const picked = emojiFromFieldInput(e.target.value, label.emoji);
186
+ if (!picked) {
187
+ setEmojiDraft(key, e.target.value);
188
+ return;
189
+ }
190
+ setEmojiDraft(key, null);
191
+ if (picked !== label.emoji) updateLabel(key, { emoji: picked });
192
+ }}
193
+ onBlur={() => setEmojiDraft(key, null)}
194
+ onKeyDown={(e) => {
195
+ if (e.key === 'Escape' && emojiInvalid) {
196
+ e.preventDefault();
197
+ e.stopPropagation();
198
+ setEmojiDraft(key, null);
199
+ }
200
+ }}
201
+ className="w-8 py-1 bg-background/80 rounded text-sm text-center leading-none focus:outline-none focus:ring-1 focus:ring-primary/50 flex-shrink-0"
202
+ style={emojiInvalid ? { boxShadow: '0 0 0 1px var(--destructive)' } : undefined}
203
+ />
204
+ <input
205
+ type="text"
206
+ data-quick-label-text
207
+ value={label.text}
208
+ aria-label={`Label text, position ${index + 1}`}
209
+ onChange={(e) => {
210
+ updateLabel(key, {
211
+ text: e.target.value,
212
+ id: e.target.value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
213
+ });
214
+ }}
215
+ className="flex-1 min-w-0 px-2 py-1 bg-background/80 rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
216
+ // The basis is what makes the controls wrap: a row that
217
+ // cannot give the text this much room moves them below.
218
+ style={{ flexBasis: '8rem' }}
219
+ />
220
+ <div className="flex items-center gap-2 ml-auto">
221
+ {/* Tip indicator button */}
222
+ <button
223
+ data-quick-label-tip-toggle
224
+ onClick={() => {
225
+ if (isEditingTip) {
226
+ setEditingTipKey(null);
227
+ } else {
228
+ setEditingTipKey(key);
229
+ setEditingTipValue(label.tip || '');
230
+ }
231
+ }}
232
+ className={`relative p-1 rounded transition-all flex-shrink-0 ${
233
+ hasTip
234
+ ? 'bg-foreground/10 text-foreground/70 hover:text-foreground border border-foreground/15'
235
+ : 'text-muted-foreground/30 hover:text-muted-foreground/60 border border-dashed border-muted-foreground/20 hover:border-muted-foreground/40'
236
+ }`}
237
+ title={hasTip ? `Tip: ${label.tip}` : 'Add AI instruction tip'}
238
+ >
239
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
240
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
241
+ </svg>
242
+ {hasTip && (
243
+ <span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-foreground/50" />
244
+ )}
245
+ </button>
246
+ <select
247
+ value={label.color}
248
+ aria-label={`Colour for ${name}`}
249
+ onChange={(e) => updateLabel(key, { color: e.target.value })}
250
+ className="px-1.5 py-1 bg-background/80 rounded text-[10px] focus:outline-none focus:ring-1 focus:ring-primary/50"
251
+ >
252
+ {Object.keys(LABEL_COLOR_MAP).map(c => (
253
+ <option key={c} value={c}>{c}</option>
254
+ ))}
255
+ </select>
256
+ <span
257
+ data-quick-label-shortcut
258
+ // Hidden below sm: touch devices that narrow rarely have an Alt key.
259
+ className="hidden sm:block text-[10px] text-muted-foreground/50 font-mono w-8 text-center flex-shrink-0"
260
+ >
261
+ {digit ? shortcutHint(digit) : ''}
262
+ </span>
263
+ {/* Reorder: position decides which Alt/⌥ digit applies the label. */}
264
+ <button
265
+ type="button"
266
+ data-quick-label-move="up"
267
+ disabled={index === 0}
268
+ onClick={() => move(index, -1)}
269
+ aria-label={`Move ${name} up`}
270
+ title="Move up"
271
+ className="p-1 rounded text-muted-foreground hover:text-foreground transition-colors flex-shrink-0 disabled:opacity-30 disabled:pointer-events-none"
272
+ >
273
+ <ChevronIcon up />
274
+ </button>
275
+ <button
276
+ type="button"
277
+ data-quick-label-move="down"
278
+ disabled={index === rows.length - 1}
279
+ onClick={() => move(index, 1)}
280
+ aria-label={`Move ${name} down`}
281
+ title="Move down"
282
+ className="p-1 rounded text-muted-foreground hover:text-foreground transition-colors flex-shrink-0 disabled:opacity-30 disabled:pointer-events-none"
283
+ >
284
+ <ChevronIcon up={false} />
285
+ </button>
286
+ <button
287
+ onClick={() => {
288
+ commit(rows.filter((r) => r.key !== key));
289
+ setEmojiDraft(key, null);
290
+ if (editingTipKey === key) setEditingTipKey(null);
291
+ }}
292
+ aria-label={`Remove ${name}`}
293
+ className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors flex-shrink-0"
294
+ title="Remove label"
295
+ >
296
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
297
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
298
+ </svg>
299
+ </button>
300
+ </div>
301
+ </div>
302
+ {emojiInvalid && (
303
+ <div id={emojiErrorId} className="px-2 pb-2 text-[10px] text-destructive">
304
+ {EMOJI_HINT}
305
+ </div>
306
+ )}
307
+ {/* Tip editor — slides open below the row */}
308
+ {isEditingTip && (
309
+ <div
310
+ className="flex items-center gap-1.5 px-2 pb-2 pt-0"
311
+ style={{ animation: 'tip-slide-open 0.15s ease-out' }}
312
+ >
313
+ <svg className="w-3 h-3 text-muted-foreground/40 flex-shrink-0 ml-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
314
+ <path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
315
+ </svg>
316
+ <input
317
+ type="text"
318
+ data-quick-label-tip
319
+ value={editingTipValue}
320
+ aria-label={`AI instruction tip for ${name}`}
321
+ onChange={(e) => setEditingTipValue(e.target.value)}
322
+ onKeyDown={(e) => {
323
+ if (e.key === 'Enter') saveTip(key);
324
+ if (e.key === 'Escape') {
325
+ e.preventDefault();
326
+ e.stopPropagation();
327
+ setEditingTipKey(null);
328
+ }
329
+ }}
330
+ placeholder="AI instruction tip..."
331
+ className="flex-1 px-2 py-1 bg-background/60 rounded text-[10px] text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-1 focus:ring-primary/50"
332
+ autoFocus
333
+ onFocus={(e) => { e.target.setSelectionRange(0, 0); e.target.scrollLeft = 0; }}
334
+ />
335
+ <button
336
+ onClick={() => saveTip(key)}
337
+ className="p-1 rounded text-muted-foreground/50 hover:text-green-500 hover:bg-green-500/10 transition-colors flex-shrink-0"
338
+ title="Save tip"
339
+ >
340
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
341
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
342
+ </svg>
343
+ </button>
344
+ </div>
345
+ )}
346
+ </div>
347
+ );
348
+ })}
349
+ </div>
350
+ <div role="status" aria-live="polite" className="sr-only">{announcement}</div>
351
+
352
+ {rows.length < MAX_QUICK_LABELS && (
353
+ <button
354
+ onClick={() => {
355
+ const row: QuickLabelRow = {
356
+ key: nextRowKey(),
357
+ label: {
358
+ id: `custom-${Date.now()}`,
359
+ emoji: '📌',
360
+ text: 'New label',
361
+ color: 'blue',
362
+ },
363
+ };
364
+ commit([...rows, row]);
365
+ pendingFocus.current = { key: row.key, target: 'text' };
366
+ }}
367
+ className="w-full py-1.5 text-xs text-muted-foreground hover:text-foreground border border-dashed border-border rounded-lg hover:border-foreground/30 transition-colors"
368
+ >
369
+ + Add label
370
+ </button>
371
+ )}
372
+
373
+ <div className="text-[10px] text-muted-foreground/70">
374
+ Use {shortcutHint('1')} through {shortcutHint('0')} when the annotation toolbar is visible to apply a label instantly. The order of the list decides the key; use the arrows to move a label, and click its emoji to change it.
375
+ </div>
376
+ </>
377
+ );
378
+ };
@@ -46,8 +46,33 @@ export interface AskAIParams {
46
46
  /** "Ask this session" only: what to do when the host session is busy
47
47
  * ("wait" = ask when it finishes, "interrupt" = stop its turn and ask now). */
48
48
  busyPolicy?: 'wait' | 'interrupt';
49
+ /** "Ask this session" only: the reviewer's CURRENT unsubmitted annotations
50
+ * as a plain list (`formatDraftAnnotationsForAsk`; '' when there are none).
51
+ * The hook sends it only when it differs from what this session last
52
+ * received, and the server frames it as read-only context, never as
53
+ * feedback (#1748). Leave unset for every other provider. */
54
+ draftAnnotations?: string;
49
55
  }
50
56
 
57
+ /** What to send as `draftAnnotations` for one question: the list when this
58
+ * session has not received it yet, '' once to clear drafts it saw earlier,
59
+ * nothing when it already has the current list. */
60
+ export function draftAnnotationsToSend(
61
+ delivered: { sessionId: string; text: string } | null,
62
+ sessionId: string,
63
+ current: string | undefined,
64
+ ): string | undefined {
65
+ if (current === undefined) return undefined;
66
+ const seen = delivered?.sessionId === sessionId ? delivered.text : '';
67
+ return current === seen ? undefined : current;
68
+ }
69
+
70
+ /** `draftAnnotationsToSend` key for "Ask this session": one host session per page. */
71
+ const BRIDGE_DRAFTS_KEY = 'session-bridge';
72
+
73
+ /** Stream messages that show the session received the question. */
74
+ const DELIVERED_MESSAGE_TYPES = new Set(['text_delta', 'text', 'tool_use', 'tool_result', 'result']);
75
+
51
76
  /** Overrides for re-asking a question that ended in a busy/gone/blocked answer. */
52
77
  export interface AIRetryOptions {
53
78
  busyPolicy?: 'wait' | 'interrupt';
@@ -66,6 +91,16 @@ interface UseAIChatOptions {
66
91
  threadTitle?: string;
67
92
  }
68
93
 
94
+ /** `line 41` / `lines 41–44` (the export's spelling) for a selection's
95
+ * source lines; `null` when the scope carries no valid line. */
96
+ function selectionLinesLabel(lineStart: unknown, lineEnd: unknown): string | null {
97
+ if (typeof lineStart !== 'number' || !Number.isInteger(lineStart) || lineStart < 1) return null;
98
+ const end = typeof lineEnd === 'number' && Number.isInteger(lineEnd) && lineEnd > lineStart
99
+ ? lineEnd
100
+ : lineStart;
101
+ return end === lineStart ? `line ${lineStart}` : `lines ${lineStart}–${end}`;
102
+ }
103
+
69
104
  export function buildDefaultPrompt(params: AskAIParams): string {
70
105
  // The "changes under review" context (and any other preamble) leads the
71
106
  // message, so the agent is oriented to the live view before the question and
@@ -90,7 +125,9 @@ export function buildDefaultPrompt(params: AskAIParams): string {
90
125
 
91
126
  if (params.scope?.kind === 'selection') {
92
127
  const label = params.scope.label ? `Re: ${params.scope.label}` : 'Re: selected text';
93
- const source = params.scope.sourcePath ? `\nSource: ${params.scope.sourcePath}` : '';
128
+ const lines = selectionLinesLabel(params.scope.lineStart, params.scope.lineEnd);
129
+ const sourceParts = [params.scope.sourcePath, lines].filter(Boolean);
130
+ const source = sourceParts.length > 0 ? `\nSource: ${sourceParts.join(', ')}` : '';
94
131
  const selection = params.scope.text ? `\n\nSelected text:\n\`\`\`\n${params.scope.text}\n\`\`\`` : '';
95
132
  const detail = params.scope.detail?.trim() ? `\n\nSelected element:\n${params.scope.detail.trim()}` : '';
96
133
  return `${label}${source}${selection}${detail}\n\n${params.prompt}`;
@@ -294,6 +331,14 @@ export function useAIChat({
294
331
  // The params each question was asked with, so a busy answer can be
295
332
  // re-asked ("Ask when it finishes", "Interrupt and ask now").
296
333
  const paramsByQuestionRef = useRef(new Map<string, AskAIParams>());
334
+ // The draft list the agent session last RECEIVED, so an unchanged list is
335
+ // not pasted into it again on every question. Kept for the page, not per
336
+ // Plannotator AI session: every Ask AI session (a new thread, a document
337
+ // switch in a folder or bundle) reaches the SAME host agent session through
338
+ // the bridge, which keeps what it already read. Per AI session, a new thread
339
+ // re-sent the whole list, and drafts cleared after one never reached the
340
+ // session as cleared.
341
+ const deliveredDraftsRef = useRef<{ sessionId: string; text: string } | null>(null);
297
342
 
298
343
  const ask = useCallback(async (params: AskAIParams, askOptions?: { providerId?: string }) => {
299
344
  if (abortRef.current) {
@@ -352,11 +397,21 @@ export function useAIChat({
352
397
  }
353
398
 
354
399
  const fullPrompt = buildPrompt(params);
400
+ const drafts = draftAnnotationsToSend(deliveredDraftsRef.current, BRIDGE_DRAFTS_KEY, params.draftAnnotations);
401
+ // Recorded once the session shows it received the question, so a busy,
402
+ // gone or failed ask sends the list again next time.
403
+ const markDraftsDelivered = (msg: { type?: unknown; code?: unknown }) => {
404
+ if (drafts === undefined) return;
405
+ const received = DELIVERED_MESSAGE_TYPES.has(String(msg.type))
406
+ || (msg.type === 'error' && msg.code === SESSION_ASK_ERROR_CODES.takenOver);
407
+ if (received) deliveredDraftsRef.current = { sessionId: BRIDGE_DRAFTS_KEY, text: drafts };
408
+ };
355
409
  const res = await aiTransport.query({
356
410
  sessionId: sid,
357
411
  prompt: fullPrompt,
358
412
  ...(params.contextUpdate && { contextUpdate: params.contextUpdate }),
359
413
  ...(params.busyPolicy && { busyPolicy: params.busyPolicy }),
414
+ ...(drafts !== undefined && { draftAnnotations: drafts }),
360
415
  }, controller.signal);
361
416
 
362
417
  if (!res.ok || !res.body) {
@@ -383,6 +438,7 @@ export function useAIChat({
383
438
 
384
439
  try {
385
440
  const msg = JSON.parse(data);
441
+ markDraftsDelivered(msg);
386
442
 
387
443
  if (msg.type === 'text_delta') {
388
444
  updateMessages(prev =>
@@ -29,6 +29,9 @@ export interface CommentPopoverState {
29
29
  initialText?: string;
30
30
  source?: any;
31
31
  draftKey: string;
32
+ /** `data-block-id`s of the blocks the pending selection covers, in
33
+ * document order (a host maps them to source lines for Ask AI). */
34
+ blockIds?: string[];
32
35
  }
33
36
 
34
37
  export interface QuickLabelPickerState {
@@ -63,6 +66,27 @@ function commentDraftTargetKey(source: any, selectedText: string): string {
63
66
  return `selection:${startKey}:${endKey}:${selectedText}`;
64
67
  }
65
68
 
69
+ /** The blocks a pending selection covers: a math source names its own block;
70
+ * a text selection is read off the highlighter's pending marks, each mapped
71
+ * to its nearest `[data-block-id]` (the block its annotation would carry). */
72
+ function selectionBlockIds(highlighter: any, source: any): string[] {
73
+ if (isMathAnnotationSource(source)) return [source.blockId];
74
+ if (!highlighter || !source?.id) return [];
75
+ let doms: unknown[] = [];
76
+ try {
77
+ doms = highlighter.getDoms(source.id) ?? [];
78
+ } catch {
79
+ return [];
80
+ }
81
+ const ids: string[] = [];
82
+ for (const dom of doms) {
83
+ if (!(dom instanceof HTMLElement)) continue;
84
+ const id = dom.closest<HTMLElement>('[data-block-id]')?.dataset.blockId;
85
+ if (id && !ids.includes(id)) ids.push(id);
86
+ }
87
+ return ids;
88
+ }
89
+
66
90
  type MathAnnotationTarget = {
67
91
  element: HTMLElement;
68
92
  blockId: string;
@@ -1490,6 +1514,7 @@ export function useAnnotationHighlighter({
1490
1514
  selectedText: source.text,
1491
1515
  source,
1492
1516
  draftKey: commentDraftTargetKey(source, source.text),
1517
+ blockIds: selectionBlockIds(highlighter, source),
1493
1518
  });
1494
1519
  } else if (effectiveMode === 'quickLabel') {
1495
1520
  pendingSourceRef.current = source;
@@ -1621,6 +1646,7 @@ export function useAnnotationHighlighter({
1621
1646
  selectedText: source.text,
1622
1647
  source,
1623
1648
  draftKey: commentDraftTargetKey(source, source.text),
1649
+ blockIds: [source.blockId],
1624
1650
  });
1625
1651
  return;
1626
1652
  }
@@ -1767,6 +1793,7 @@ export function useAnnotationHighlighter({
1767
1793
  selectedText: source.text,
1768
1794
  source,
1769
1795
  draftKey: commentDraftTargetKey(source, source.text),
1796
+ blockIds: [source.blockId],
1770
1797
  });
1771
1798
  return;
1772
1799
  }
@@ -1911,6 +1938,7 @@ export function useAnnotationHighlighter({
1911
1938
  initialText: initialChar,
1912
1939
  source: toolbarState.source,
1913
1940
  draftKey: commentDraftTargetKey(toolbarState.source, toolbarState.selectionText),
1941
+ blockIds: selectionBlockIds(highlighterRef.current, toolbarState.source),
1914
1942
  });
1915
1943
  setToolbarState(null);
1916
1944
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -77,7 +77,7 @@
77
77
  "@lezer/highlight": "^1.2.3",
78
78
  "@pierre/diffs": "1.3.6",
79
79
  "@plannotator/atomic-editor": "^0.9.0",
80
- "@plannotator/core": "0.25.11",
80
+ "@plannotator/core": "0.25.12",
81
81
  "@plannotator/markdown-editor": "^0.5.0",
82
82
  "@plannotator/web-highlighter": "^0.8.1",
83
83
  "@tanstack/react-table": "^8.21.3",