@plannotator/ui 0.51.0 → 0.52.1
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 +235 -0
- package/README.md +15 -0
- package/components/AnnotationToolbar.tsx +2 -2
- package/components/BlockRenderer.tsx +16 -1
- package/components/CommentPopover.tsx +4 -0
- package/components/CompletionOverlay.tsx +12 -5
- package/components/FloatingQuickLabelPicker.tsx +2 -2
- package/components/QuickLabelDropdown.tsx +9 -4
- package/components/ServerSessionReplacedBanner.tsx +29 -0
- package/components/Settings.tsx +3 -191
- package/components/Viewer.tsx +44 -1
- package/components/blocks/QuestionBlock.tsx +137 -8
- package/components/html-viewer/bridge-script.asset.js +106 -6
- package/components/html-viewer/bridge-script.ts +106 -6
- package/components/settings/QuickLabelsTab.tsx +378 -0
- package/hooks/useAIChat.ts +57 -1
- package/hooks/useAnnotationHighlighter.ts +28 -0
- package/package.json +2 -2
- package/styles.css +1 -1
- package/types.ts +6 -0
- package/utils/parser.ts +149 -7
- package/utils/quickLabels.ts +80 -0
- package/utils/serverSession.ts +85 -0
package/hooks/useAIChat.ts
CHANGED
|
@@ -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
|
|
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.
|
|
3
|
+
"version": "0.52.1",
|
|
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.
|
|
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",
|