@plannotator/ui 0.53.1 → 0.55.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 +274 -14
- package/README.md +114 -11
- package/components/AgentToolAnnouncementDialog.tsx +1 -1
- package/components/AgentsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +142 -24
- package/components/AnnotationSidebar.tsx +1 -1
- package/components/AnnotationToolbar.tsx +64 -9
- package/components/ApproveDropdown.tsx +7 -9
- package/components/AskSessionAnnouncementDialog.tsx +1 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +22 -5
- package/components/CodeFilePopout.tsx +139 -4
- package/components/CodeSnippetPreview.tsx +174 -0
- package/components/CommentPopover.tsx +44 -8
- package/components/DecisionControl.tsx +31 -5
- package/components/DeferredMarkdownContentsRail.tsx +38 -0
- package/components/DiagramBlock.tsx +17 -12
- package/components/DiffFileTree.tsx +7 -6
- package/components/DocumentContents.tsx +74 -0
- package/components/EditorAnnotationCard.tsx +1 -1
- package/components/ExportModal.tsx +77 -7
- package/components/HostTitle.tsx +0 -4
- package/components/ImageAnnotator/Toolbar.tsx +25 -1
- package/components/ImageAnnotator/index.tsx +24 -4
- package/components/ImageLightbox.tsx +722 -126
- package/components/ImagePeek.tsx +38 -4
- package/components/ImageRegions.tsx +369 -0
- package/components/ImageThumbnail.tsx +1 -1
- package/components/ImageZoomStage.tsx +796 -0
- package/components/InboxAnnouncementDialog.tsx +202 -0
- package/components/InlineMarkdown.tsx +167 -174
- package/components/KeyboardShortcuts.tsx +23 -0
- package/components/LazyBlockBoundary.tsx +100 -0
- package/components/ListMarker.tsx +3 -3
- package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
- package/components/MarkdownContentsRail.tsx +777 -0
- package/components/MenuVersionSection.tsx +31 -9
- package/components/PlanDecisionMenuExtras.tsx +129 -0
- package/components/PlanHeaderMenu.tsx +15 -1
- package/components/ProviderIcons.tsx +8 -0
- package/components/RefHoverCard.tsx +913 -0
- package/components/SendFailureToast.tsx +126 -0
- package/components/Settings.tsx +617 -126
- package/components/TableOfContents.tsx +20 -6
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/ThemeProvider.tsx +14 -1
- package/components/ThemeTab.tsx +16 -24
- package/components/Tooltip.tsx +49 -18
- package/components/Viewer.tsx +288 -43
- package/components/ai/DocumentAIChatPanel.tsx +29 -3
- package/components/ai/NewChatButton.tsx +74 -0
- package/components/blocks/CodeBlock.tsx +1 -1
- package/components/blocks/HtmlBlock.tsx +7 -1
- package/components/blocks/QuestionBlock.tsx +532 -62
- package/components/diagram/DiagramComposer.tsx +5 -1
- package/components/diagram/DiagramPending.tsx +12 -2
- package/components/diagram/DiagramViewer.tsx +19 -3
- package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
- package/components/html-embed/SvgFenceBlock.tsx +46 -0
- package/components/html-embed/index.ts +3 -0
- package/components/html-embed/languages.ts +15 -0
- package/components/html-embed/loadGate.ts +107 -0
- package/components/html-viewer/HtmlViewer.tsx +82 -6
- package/components/html-viewer/bridge-script.asset.js +173 -1
- package/components/html-viewer/bridge-script.ts +173 -1
- package/components/html-viewer/useHtmlAnnotation.ts +125 -0
- package/components/imageZoom.ts +276 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
- package/components/plan-diff/PlanDiffViewer.tsx +13 -2
- package/components/plan-diff/PlanRawDiffView.tsx +1 -1
- package/components/sidebar/FileBrowser.tsx +16 -2
- package/components/sidebar/SidebarTabs.tsx +3 -0
- package/config/index.ts +3 -0
- package/config/reviewView.ts +41 -1
- package/config/settings.ts +155 -4
- package/configure.ts +28 -0
- package/contents-rail.css +337 -0
- package/hooks/pfm/useCodeFilePopout.ts +7 -1
- package/hooks/useAIChat.ts +72 -25
- package/hooks/useAnnotationDraft.ts +71 -6
- package/hooks/useAnnotationHighlighter.ts +57 -3
- package/hooks/useCodeAnnotationDraft.ts +113 -17
- package/hooks/useCookieHostSetting.ts +96 -0
- package/hooks/useDocumentFont.ts +17 -0
- package/hooks/useDocumentTextSize.ts +20 -0
- package/hooks/useFileBrowser.ts +10 -1
- package/hooks/useHeadingScrollSpy.ts +77 -0
- package/hooks/useHoverPreview.ts +94 -0
- package/hooks/useLinkedDoc.ts +226 -13
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/usePrefersReducedMotion.ts +23 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useSwipeStep.ts +597 -0
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/package.json +26 -23
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
- package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
- package/shortcuts/core.ts +63 -4
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
- package/shortcuts/runtime.ts +4 -3
- package/styles.css +1 -1
- package/theme.css +179 -1
- package/themes/plannotator-graphite.css +66 -0
- package/types.ts +26 -9
- package/utils/agentSwitch.ts +41 -20
- package/utils/agentToolSetting.ts +1 -1
- package/utils/aiChatFormat.ts +23 -7
- package/utils/annotationHelpers.ts +32 -4
- package/utils/annotationOwnsHighlight.ts +4 -2
- package/utils/asciidocParser.ts +570 -0
- package/utils/autoUpdateNotice.ts +24 -0
- package/utils/belowTitleSlot.ts +174 -0
- package/utils/blockParse.ts +45 -0
- package/utils/codeLineScroll.ts +100 -0
- package/utils/commentSubmitKey.ts +48 -0
- package/utils/cssFontFamily.ts +65 -0
- package/utils/decisionSpec.ts +190 -2
- package/utils/defaultNotesApp.ts +1 -1
- package/utils/diffFonts.ts +4 -1
- package/utils/documentFont.ts +68 -0
- package/utils/documentTextSize.ts +54 -0
- package/utils/fontDetect.ts +63 -0
- package/utils/headingScrollSpy.ts +92 -0
- package/utils/inboxAnnouncement.ts +78 -0
- package/utils/lookAndFeelAnnouncement.ts +82 -5
- package/utils/markdownExtensions.ts +2 -2
- package/utils/mermaid.ts +20 -3
- package/utils/mermaidTheme.ts +3 -51
- package/utils/notion.ts +58 -0
- package/utils/parser.ts +28 -1
- package/utils/platform.ts +46 -0
- package/utils/refCard.ts +82 -0
- package/utils/richClipboard.ts +380 -0
- package/utils/richClipboardLists.ts +57 -0
- package/utils/richCopyPolicy.ts +22 -0
- package/utils/scrollAnchor.ts +82 -0
- package/utils/sectionRefs.ts +123 -0
- package/utils/selectionBoundary.ts +40 -0
- package/utils/sendFailure.ts +85 -0
- package/utils/sharing.ts +5 -2
- package/utils/themeRegistry.ts +10 -0
- package/utils/themeTokens.ts +74 -0
- package/utils/updateNotice.ts +30 -8
- package/utils/vscodePanel.ts +9 -0
package/hooks/useAIChat.ts
CHANGED
|
@@ -30,6 +30,9 @@ export interface AIChatThread {
|
|
|
30
30
|
|
|
31
31
|
export interface AskAIParams {
|
|
32
32
|
prompt: string;
|
|
33
|
+
/** What the chat shows as the question when it differs from `prompt`, which
|
|
34
|
+
* is what the agent gets (e.g. a short "Explain this finding: …"). */
|
|
35
|
+
displayPrompt?: string;
|
|
33
36
|
filePath?: string;
|
|
34
37
|
lineStart?: number;
|
|
35
38
|
lineEnd?: number;
|
|
@@ -52,6 +55,12 @@ export interface AskAIParams {
|
|
|
52
55
|
* received, and the server frames it as read-only context, never as
|
|
53
56
|
* feedback (#1748). Leave unset for every other provider. */
|
|
54
57
|
draftAnnotations?: string;
|
|
58
|
+
/** Called once, when the stream first shows the question reached the
|
|
59
|
+
* session (an answer started, or the session took it over). A busy, gone
|
|
60
|
+
* or failed ask never calls it; a retry keeps it, so the retried question
|
|
61
|
+
* calls it when it lands. Lets a caller record what the session was told
|
|
62
|
+
* (e.g. which document a note named) only once it was received. */
|
|
63
|
+
onDelivered?: () => void;
|
|
55
64
|
}
|
|
56
65
|
|
|
57
66
|
/** What to send as `draftAnnotations` for one question: the list when this
|
|
@@ -73,6 +82,15 @@ const BRIDGE_DRAFTS_KEY = 'session-bridge';
|
|
|
73
82
|
/** Stream messages that show the session received the question. */
|
|
74
83
|
const DELIVERED_MESSAGE_TYPES = new Set(['text_delta', 'text', 'tool_use', 'tool_result', 'result']);
|
|
75
84
|
|
|
85
|
+
/**
|
|
86
|
+
* A busy "Ask this session" answer still offers its choice ("Ask when it
|
|
87
|
+
* finishes" / "Interrupt and ask now"): the question has not been asked yet,
|
|
88
|
+
* and clearing the thread would drop it without a word.
|
|
89
|
+
*/
|
|
90
|
+
export function hasPendingBusyChoice(messages: AIChatEntry[]): boolean {
|
|
91
|
+
return messages.some(m => m.response.errorCode === SESSION_ASK_ERROR_CODES.agentBusy);
|
|
92
|
+
}
|
|
93
|
+
|
|
76
94
|
/** Overrides for re-asking a question that ended in a busy/gone/blocked answer. */
|
|
77
95
|
export interface AIRetryOptions {
|
|
78
96
|
busyPolicy?: 'wait' | 'interrupt';
|
|
@@ -179,8 +197,9 @@ export interface AITransport {
|
|
|
179
197
|
session(body: unknown, signal: AbortSignal): Promise<Response>;
|
|
180
198
|
/** POST /api/ai/query — send a message; returns the streaming SSE response. */
|
|
181
199
|
query(body: unknown, signal: AbortSignal): Promise<Response>;
|
|
182
|
-
/** POST /api/ai/abort
|
|
183
|
-
|
|
200
|
+
/** POST /api/ai/abort. A superseding question sends `reason: 'supersede'`
|
|
201
|
+
* so session bridges close the answer without stopping the host turn.
|
|
202
|
+
* Resolves once acknowledged, before the old fetch is disconnected. */
|
|
184
203
|
abort(body: unknown): Promise<unknown>;
|
|
185
204
|
/** POST /api/ai/permission — respond to a permission request. Fire-and-forget. */
|
|
186
205
|
permission(body: unknown): void;
|
|
@@ -238,9 +257,9 @@ export const resetAITransport = (): void => {
|
|
|
238
257
|
* ask() relies on this resolving. Single-sourced so every abort call site stays
|
|
239
258
|
* hardened. Reads `aiTransport` at call time so a late override is honored.
|
|
240
259
|
*/
|
|
241
|
-
function safeAbort(sessionId: string): Promise<unknown> {
|
|
260
|
+
function safeAbort(sessionId: string, reason?: 'supersede'): Promise<unknown> {
|
|
242
261
|
return Promise.resolve()
|
|
243
|
-
.then(() => aiTransport.abort({ sessionId }))
|
|
262
|
+
.then(() => aiTransport.abort({ sessionId, ...(reason && { reason }) }))
|
|
244
263
|
.catch(() => {});
|
|
245
264
|
}
|
|
246
265
|
|
|
@@ -261,10 +280,15 @@ export function useAIChat({
|
|
|
261
280
|
// In-flight server abort (from Stop or a superseding question). The next ask()
|
|
262
281
|
// waits on it so a new query can't race the still-active turn into busy.
|
|
263
282
|
const pendingAbortRef = useRef<Promise<unknown> | null>(null);
|
|
283
|
+
// Answers a newer question replaced. Their fetch stays open until the server
|
|
284
|
+
// acknowledges the supersede, but nothing more they stream is shown.
|
|
285
|
+
const supersededRef = useRef(new WeakSet<AbortController>());
|
|
264
286
|
const sessionEpochRef = useRef(0);
|
|
265
287
|
const createRequestRef = useRef(0);
|
|
266
288
|
const sessionIdRef = useRef<string | null>(null);
|
|
267
289
|
sessionIdRef.current = thread.sessionId;
|
|
290
|
+
const threadRef = useRef(thread);
|
|
291
|
+
threadRef.current = thread;
|
|
268
292
|
|
|
269
293
|
const updateMessages = useCallback((updater: (messages: AIChatEntry[]) => AIChatEntry[]) => {
|
|
270
294
|
setThread(prev => ({ ...prev, messages: updater(prev.messages) }));
|
|
@@ -316,16 +340,13 @@ export function useAIChat({
|
|
|
316
340
|
}
|
|
317
341
|
}, [context, model, providerId, reasoningEffort, setSessionId]);
|
|
318
342
|
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
// it responds, so awaiting this guarantees a following query won't race it
|
|
324
|
-
// into a session_busy error. Best-effort (never rejects).
|
|
325
|
-
const postServerAbort = useCallback((): Promise<unknown> => {
|
|
343
|
+
// Stop cancels the turn. Supersede closes the old answer but lets a session
|
|
344
|
+
// bridge's host keep working until the reviewer chooses to interrupt it.
|
|
345
|
+
// Both clear the server's query before the next one. Best-effort (never rejects).
|
|
346
|
+
const postServerAbort = useCallback((reason?: 'supersede'): Promise<unknown> => {
|
|
326
347
|
const sessionId = sessionIdRef.current; // capture the session being aborted now
|
|
327
348
|
if (!sessionId) return Promise.resolve();
|
|
328
|
-
return safeAbort(sessionId);
|
|
349
|
+
return safeAbort(sessionId, reason);
|
|
329
350
|
}, []);
|
|
330
351
|
|
|
331
352
|
// The params each question was asked with, so a busy answer can be
|
|
@@ -341,19 +362,18 @@ export function useAIChat({
|
|
|
341
362
|
const deliveredDraftsRef = useRef<{ sessionId: string; text: string } | null>(null);
|
|
342
363
|
|
|
343
364
|
const ask = useCallback(async (params: AskAIParams, askOptions?: { providerId?: string }) => {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
pendingAbortRef.current = postServerAbort();
|
|
365
|
+
const previous = abortRef.current;
|
|
366
|
+
if (previous) {
|
|
367
|
+
supersededRef.current.add(previous);
|
|
368
|
+
pendingAbortRef.current = postServerAbort('supersede');
|
|
348
369
|
}
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
// session_busy error. Stopping still kills the turn; chatting right after
|
|
352
|
-
// just waits the one round-trip for the stop to land.
|
|
370
|
+
// Let the server distinguish supersede from Stop before disconnecting:
|
|
371
|
+
// cancelling the SSE fetch first would still abort the host's turn.
|
|
353
372
|
if (pendingAbortRef.current) {
|
|
354
373
|
await pendingAbortRef.current;
|
|
355
374
|
pendingAbortRef.current = null;
|
|
356
375
|
}
|
|
376
|
+
previous?.abort();
|
|
357
377
|
|
|
358
378
|
const controller = new AbortController();
|
|
359
379
|
abortRef.current = controller;
|
|
@@ -364,7 +384,7 @@ export function useAIChat({
|
|
|
364
384
|
paramsByQuestionRef.current.set(questionId, params);
|
|
365
385
|
const question: AIQuestion = {
|
|
366
386
|
id: questionId,
|
|
367
|
-
prompt: params.prompt,
|
|
387
|
+
prompt: params.displayPrompt ?? params.prompt,
|
|
368
388
|
scope: params.scope,
|
|
369
389
|
filePath: params.filePath,
|
|
370
390
|
lineStart: params.lineStart,
|
|
@@ -392,7 +412,7 @@ export function useAIChat({
|
|
|
392
412
|
sid = await createSession(controller.signal, epoch);
|
|
393
413
|
}
|
|
394
414
|
|
|
395
|
-
if (controller.signal.aborted || epoch !== sessionEpochRef.current) {
|
|
415
|
+
if (controller.signal.aborted || supersededRef.current.has(controller) || epoch !== sessionEpochRef.current) {
|
|
396
416
|
throw createAbortError('AI question was superseded');
|
|
397
417
|
}
|
|
398
418
|
|
|
@@ -400,11 +420,20 @@ export function useAIChat({
|
|
|
400
420
|
const drafts = draftAnnotationsToSend(deliveredDraftsRef.current, BRIDGE_DRAFTS_KEY, params.draftAnnotations);
|
|
401
421
|
// Recorded once the session shows it received the question, so a busy,
|
|
402
422
|
// gone or failed ask sends the list again next time.
|
|
423
|
+
let deliveredCalled = false;
|
|
403
424
|
const markDraftsDelivered = (msg: { type?: unknown; code?: unknown }) => {
|
|
404
|
-
if (drafts === undefined) return;
|
|
405
425
|
const received = DELIVERED_MESSAGE_TYPES.has(String(msg.type))
|
|
406
426
|
|| (msg.type === 'error' && msg.code === SESSION_ASK_ERROR_CODES.takenOver);
|
|
407
|
-
if (received)
|
|
427
|
+
if (!received) return;
|
|
428
|
+
if (drafts !== undefined) deliveredDraftsRef.current = { sessionId: BRIDGE_DRAFTS_KEY, text: drafts };
|
|
429
|
+
if (!deliveredCalled) {
|
|
430
|
+
deliveredCalled = true;
|
|
431
|
+
try {
|
|
432
|
+
params.onDelivered?.();
|
|
433
|
+
} catch {
|
|
434
|
+
// A caller's bookkeeping must never break the stream.
|
|
435
|
+
}
|
|
436
|
+
}
|
|
408
437
|
};
|
|
409
438
|
const res = await aiTransport.query({
|
|
410
439
|
sessionId: sid,
|
|
@@ -425,6 +454,7 @@ export function useAIChat({
|
|
|
425
454
|
|
|
426
455
|
while (true) {
|
|
427
456
|
const { done, value } = await reader.read();
|
|
457
|
+
if (supersededRef.current.has(controller)) throw createAbortError('AI question was superseded');
|
|
428
458
|
if (done) break;
|
|
429
459
|
buffer += decoder.decode(value, { stream: true });
|
|
430
460
|
|
|
@@ -566,7 +596,7 @@ export function useAIChat({
|
|
|
566
596
|
)
|
|
567
597
|
);
|
|
568
598
|
} finally {
|
|
569
|
-
if (abortRef.current === controller) {
|
|
599
|
+
if (abortRef.current === controller && !supersededRef.current.has(controller)) {
|
|
570
600
|
setIsStreaming(false);
|
|
571
601
|
abortRef.current = null;
|
|
572
602
|
}
|
|
@@ -649,6 +679,22 @@ export function useAIChat({
|
|
|
649
679
|
setError(null);
|
|
650
680
|
}, [postServerAbort, threadTitle]);
|
|
651
681
|
|
|
682
|
+
// The panel's "New chat": clears the thread and forgets the Plannotator AI
|
|
683
|
+
// session, so the next question starts a fresh one (a separate AI then gets
|
|
684
|
+
// the full context again). Unlike `resetThread` (a document switch), it never
|
|
685
|
+
// stops anything: while a question is in flight (streaming, creating its
|
|
686
|
+
// session, or waiting on a busy "Ask this session") or a busy answer still
|
|
687
|
+
// offers its choice, it does nothing and answers false, so a running turn is
|
|
688
|
+
// not aborted and a pending ask is not dropped. What the agent session already received (`deliveredDraftsRef`) is
|
|
689
|
+
// kept: with "Ask this session" a new chat reaches the SAME host session,
|
|
690
|
+
// which still remembers it.
|
|
691
|
+
const newChat = useCallback((): boolean => {
|
|
692
|
+
if (abortRef.current) return false;
|
|
693
|
+
if (hasPendingBusyChoice(threadRef.current.messages)) return false;
|
|
694
|
+
resetThread();
|
|
695
|
+
return true;
|
|
696
|
+
}, [resetThread]);
|
|
697
|
+
|
|
652
698
|
useEffect(() => {
|
|
653
699
|
return () => {
|
|
654
700
|
sessionEpochRef.current += 1;
|
|
@@ -672,6 +718,7 @@ export function useAIChat({
|
|
|
672
718
|
abort,
|
|
673
719
|
resetSession,
|
|
674
720
|
resetThread,
|
|
721
|
+
newChat,
|
|
675
722
|
sessionId: thread.sessionId,
|
|
676
723
|
};
|
|
677
724
|
}
|
|
@@ -144,6 +144,19 @@ export interface DraftSavedFileChange {
|
|
|
144
144
|
sourceSave: DraftSourceSaveCapability;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/**
|
|
148
|
+
* A linked document's saved comments, offered with the session draft. The
|
|
149
|
+
* server writes this list (`documents` on GET /api/draft, plan review and
|
|
150
|
+
* annotate sessions on a URL or the last message: packages/shared/annotate-draft.ts);
|
|
151
|
+
* the hook never sends it. Each entry belongs to the document at `path`, not
|
|
152
|
+
* to the session's root.
|
|
153
|
+
*/
|
|
154
|
+
export interface DraftDocumentCopy {
|
|
155
|
+
path: string;
|
|
156
|
+
annotations: Annotation[];
|
|
157
|
+
globalAttachments: ImageAttachment[];
|
|
158
|
+
}
|
|
159
|
+
|
|
147
160
|
/** New format: full objects. */
|
|
148
161
|
interface DraftData {
|
|
149
162
|
annotations: Annotation[];
|
|
@@ -158,6 +171,8 @@ interface DraftData {
|
|
|
158
171
|
savedFileChanges?: DraftSavedFileChange[];
|
|
159
172
|
/** Client-side generation used to ignore stale saves after a draft delete. */
|
|
160
173
|
draftGeneration?: number;
|
|
174
|
+
/** Server-written on load only: other documents' saved comments. */
|
|
175
|
+
documents?: unknown;
|
|
161
176
|
ts: number;
|
|
162
177
|
}
|
|
163
178
|
|
|
@@ -253,6 +268,28 @@ function isDraftSourceSaveCapability(value: unknown): value is DraftSourceSaveCa
|
|
|
253
268
|
);
|
|
254
269
|
}
|
|
255
270
|
|
|
271
|
+
/** The server's `documents` list: entries with a path and at least one comment or attachment. */
|
|
272
|
+
function parseDraftDocuments(value: unknown): DraftDocumentCopy[] {
|
|
273
|
+
if (!Array.isArray(value)) return [];
|
|
274
|
+
const documents: DraftDocumentCopy[] = [];
|
|
275
|
+
for (const entry of value) {
|
|
276
|
+
const doc = entry as Partial<DraftDocumentCopy> | null;
|
|
277
|
+
if (!doc || typeof doc.path !== 'string' || doc.path.length === 0) continue;
|
|
278
|
+
const annotations = Array.isArray(doc.annotations)
|
|
279
|
+
? doc.annotations.filter((a): a is Annotation => !!a && typeof a === 'object' && typeof (a as Annotation).id === 'string')
|
|
280
|
+
: [];
|
|
281
|
+
const globalAttachments = Array.isArray(doc.globalAttachments)
|
|
282
|
+
? doc.globalAttachments.filter((g): g is ImageAttachment => !!g && typeof g === 'object' && typeof (g as ImageAttachment).path === 'string')
|
|
283
|
+
: [];
|
|
284
|
+
if (annotations.length === 0 && globalAttachments.length === 0) continue;
|
|
285
|
+
documents.push({ path: doc.path, annotations, globalAttachments });
|
|
286
|
+
}
|
|
287
|
+
return documents;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const countDraftDocuments = (documents: DraftDocumentCopy[]): number =>
|
|
291
|
+
documents.reduce((total, doc) => total + doc.annotations.length + doc.globalAttachments.length, 0);
|
|
292
|
+
|
|
256
293
|
function readDraftGeneration(value: unknown): number | null {
|
|
257
294
|
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null;
|
|
258
295
|
}
|
|
@@ -291,17 +328,37 @@ interface RestoredDraft {
|
|
|
291
328
|
editedMarkdown: string | null;
|
|
292
329
|
editedDocuments: DraftEditedDocument[];
|
|
293
330
|
savedFileChanges: DraftSavedFileChange[];
|
|
331
|
+
/** Other documents' saved comments, each for the document at its path.
|
|
332
|
+
`annotations` / `globalAttachments` above are the root document's. */
|
|
333
|
+
documents: DraftDocumentCopy[];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** What the Draft Recovered banner says it found. */
|
|
337
|
+
export interface DraftBanner {
|
|
338
|
+
count: number;
|
|
339
|
+
timeAgo: string;
|
|
340
|
+
hasEdits: boolean;
|
|
341
|
+
/** How many other documents hold comments (absent: the root's alone). */
|
|
342
|
+
documents?: number;
|
|
343
|
+
/** How many of `count` are on those other documents. */
|
|
344
|
+
documentComments?: number;
|
|
294
345
|
}
|
|
295
346
|
|
|
296
347
|
interface UseAnnotationDraftResult {
|
|
297
|
-
|
|
348
|
+
/** `count` includes the other documents' comments; `documents` is how many
|
|
349
|
+
other documents hold some and `documentComments` how many of `count`
|
|
350
|
+
they hold (both absent when the draft is the root's alone). */
|
|
351
|
+
draftBanner: DraftBanner | null;
|
|
298
352
|
restoreDraft: () => RestoredDraft;
|
|
299
353
|
/** Debounced save trigger for changes the reactive deps can't see
|
|
300
354
|
(editor keystrokes, edit commit/discard). Stable identity. */
|
|
301
355
|
scheduleDraftSave: () => void;
|
|
302
356
|
scheduleDraftSaveAfterSubmitFailure: () => void;
|
|
303
357
|
getDraftGeneration: () => number;
|
|
304
|
-
|
|
358
|
+
/** Discard the offered draft. Returns the other documents' comments that
|
|
359
|
+
were offered with it (empty for a root-only draft), so the host can
|
|
360
|
+
discard their copies too. */
|
|
361
|
+
dismissDraft: () => DraftDocumentCopy[];
|
|
305
362
|
/** Send a pending (debounced) save now, if there is one. For a session
|
|
306
363
|
that ends WITHOUT a decision body (the agent closed it): the comment
|
|
307
364
|
typed just before must still reach the kept draft. */
|
|
@@ -319,7 +376,7 @@ export function useAnnotationDraft({
|
|
|
319
376
|
isSharedSession,
|
|
320
377
|
submitted,
|
|
321
378
|
}: UseAnnotationDraftOptions): UseAnnotationDraftResult {
|
|
322
|
-
const [draftBanner, setDraftBanner] = useState<
|
|
379
|
+
const [draftBanner, setDraftBanner] = useState<DraftBanner | null>(null);
|
|
323
380
|
const draftDataRef = useRef<RestoredDraft | null>(null);
|
|
324
381
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
325
382
|
const hasMountedRef = useRef(false);
|
|
@@ -394,8 +451,10 @@ export function useAnnotationDraft({
|
|
|
394
451
|
!isLegacyDraft(data) && Array.isArray((data as DraftData).savedFileChanges)
|
|
395
452
|
? (data as DraftData).savedFileChanges!.filter(isDraftSavedFileChange)
|
|
396
453
|
: [];
|
|
454
|
+
const restoredDocuments = !isLegacyDraft(data) ? parseDraftDocuments((data as DraftData).documents) : [];
|
|
397
455
|
|
|
398
|
-
const totalCount =
|
|
456
|
+
const totalCount =
|
|
457
|
+
restoredAnnotations.length + restoredCodeAnnotations.length + restoredGlobal.length + countDraftDocuments(restoredDocuments);
|
|
399
458
|
if (totalCount > 0 || restoredEdited !== null || restoredEditedDocuments.length > 0 || restoredSavedFileChanges.length > 0) {
|
|
400
459
|
draftDataRef.current = {
|
|
401
460
|
annotations: restoredAnnotations,
|
|
@@ -404,11 +463,15 @@ export function useAnnotationDraft({
|
|
|
404
463
|
editedMarkdown: restoredEdited,
|
|
405
464
|
editedDocuments: restoredEditedDocuments,
|
|
406
465
|
savedFileChanges: restoredSavedFileChanges,
|
|
466
|
+
documents: restoredDocuments,
|
|
407
467
|
};
|
|
408
468
|
setDraftBanner({
|
|
409
469
|
count: totalCount,
|
|
410
470
|
timeAgo: formatTimeAgo(data.ts || 0),
|
|
411
471
|
hasEdits: restoredEdited !== null || restoredEditedDocuments.length > 0 || restoredSavedFileChanges.length > 0,
|
|
472
|
+
...(restoredDocuments.length > 0
|
|
473
|
+
? { documents: restoredDocuments.length, documentComments: countDraftDocuments(restoredDocuments) }
|
|
474
|
+
: {}),
|
|
412
475
|
});
|
|
413
476
|
}
|
|
414
477
|
hasMountedRef.current = true;
|
|
@@ -547,12 +610,13 @@ export function useAnnotationDraft({
|
|
|
547
610
|
setDraftBanner(null);
|
|
548
611
|
draftDataRef.current = null;
|
|
549
612
|
|
|
550
|
-
if (!data) return { annotations: [], codeAnnotations: [], globalAttachments: [], editedMarkdown: null, editedDocuments: [], savedFileChanges: [] };
|
|
613
|
+
if (!data) return { annotations: [], codeAnnotations: [], globalAttachments: [], editedMarkdown: null, editedDocuments: [], savedFileChanges: [], documents: [] };
|
|
551
614
|
|
|
552
615
|
return data;
|
|
553
616
|
}, []);
|
|
554
617
|
|
|
555
|
-
const dismissDraft = useCallback(() => {
|
|
618
|
+
const dismissDraft = useCallback((): DraftDocumentCopy[] => {
|
|
619
|
+
const offeredDocuments = draftDataRef.current?.documents ?? [];
|
|
556
620
|
const deletedGeneration = draftGenerationRef.current + 1;
|
|
557
621
|
draftGenerationRef.current = deletedGeneration;
|
|
558
622
|
if (timerRef.current) {
|
|
@@ -563,6 +627,7 @@ export function useAnnotationDraft({
|
|
|
563
627
|
draftDataRef.current = null;
|
|
564
628
|
|
|
565
629
|
getDraftTransport().remove(deletedGeneration, { keepalive: false }).catch(() => {});
|
|
630
|
+
return offeredDocuments;
|
|
566
631
|
}, []);
|
|
567
632
|
|
|
568
633
|
return { draftBanner, restoreDraft, scheduleDraftSave, scheduleDraftSaveAfterSubmitFailure, getDraftGeneration, dismissDraft, flushPendingSave };
|
|
@@ -8,11 +8,17 @@
|
|
|
8
8
|
import { useEffect, useRef, useState, useCallback, type RefObject } from 'react';
|
|
9
9
|
import Highlighter from '@plannotator/web-highlighter';
|
|
10
10
|
import type { Annotation, EditorMode, HostDraft, ImageAttachment } from '../types';
|
|
11
|
+
import { metaWithBelowTitle, metaWithoutBelowTitle, snapRangeOutOfBelowTitle } from '../utils/belowTitleSlot';
|
|
11
12
|
import { AnnotationType } from '../types';
|
|
12
13
|
import type { QuickLabel } from '../utils/quickLabels';
|
|
14
|
+
import {
|
|
15
|
+
trimWhitespaceOnlyBoundaryNodes,
|
|
16
|
+
type SelectedNodeLike,
|
|
17
|
+
} from '../utils/selectionBoundary';
|
|
13
18
|
import { getIdentity } from '../utils/identity';
|
|
14
19
|
import { annotationOwnsHighlight } from '../utils/annotationOwnsHighlight';
|
|
15
20
|
import { transformPlainText } from '../utils/inlineTransforms';
|
|
21
|
+
import { richCopySourceFromRange, type RichCopySource } from '../utils/richClipboard';
|
|
16
22
|
|
|
17
23
|
// --- Exported state types ---
|
|
18
24
|
|
|
@@ -20,6 +26,9 @@ export interface ToolbarState {
|
|
|
20
26
|
element: HTMLElement;
|
|
21
27
|
source: any;
|
|
22
28
|
selectionText: string;
|
|
29
|
+
/** Builds the selection's rich-text copy when the toolbar button is pressed
|
|
30
|
+
* (synchronous in the markdown Viewer, a bridge round trip on HTML surfaces). */
|
|
31
|
+
getRichCopy?: () => RichCopySource | Promise<RichCopySource>;
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
export interface CommentPopoverState {
|
|
@@ -1248,6 +1257,10 @@ export function useAnnotationHighlighter({
|
|
|
1248
1257
|
// verdict, so it is neither attempted nor unanchored here (the same
|
|
1249
1258
|
// rule the raw-HTML pinpoints follow on their surface).
|
|
1250
1259
|
if (ann.diagramAnchor) return;
|
|
1260
|
+
// A comment on part of an image has no text either: the image viewer
|
|
1261
|
+
// draws its box, and the Viewer reports it unanchored when its image is
|
|
1262
|
+
// gone from the document.
|
|
1263
|
+
if (ann.imageRegion != null) return;
|
|
1251
1264
|
// An answer to a `:::question` block quotes the prompt but is not a
|
|
1252
1265
|
// selection: the question block draws it from the annotation, so it is
|
|
1253
1266
|
// never painted as a highlight (and never reported as unanchored here).
|
|
@@ -1286,7 +1299,12 @@ export function useAnnotationHighlighter({
|
|
|
1286
1299
|
|
|
1287
1300
|
if (ann.startMeta && ann.endMeta) {
|
|
1288
1301
|
try {
|
|
1289
|
-
highlighter.fromStore(
|
|
1302
|
+
highlighter.fromStore(
|
|
1303
|
+
metaWithBelowTitle(containerRef.current, ann.startMeta),
|
|
1304
|
+
metaWithBelowTitle(containerRef.current, ann.endMeta),
|
|
1305
|
+
ann.originalText,
|
|
1306
|
+
ann.id,
|
|
1307
|
+
);
|
|
1290
1308
|
const restoredDoms = highlighter.getDoms(ann.id);
|
|
1291
1309
|
if (restoredDoms && restoredDoms.length > 0) {
|
|
1292
1310
|
const restoredText = paintedTextOf(restoredDoms as HTMLElement[]);
|
|
@@ -1480,6 +1498,15 @@ export function useAnnotationHighlighter({
|
|
|
1480
1498
|
style: { className: 'annotation-highlight' },
|
|
1481
1499
|
});
|
|
1482
1500
|
|
|
1501
|
+
// Chromium can extend a triple-clicked line into empty or indentation nodes of the next block.
|
|
1502
|
+
// Trim only boundary whitespace so spacing inside genuine multi-node selections stays highlighted.
|
|
1503
|
+
// The hook's callback type is `(...args: unknown[]) => SelectedNode[]`; a SelectedNode
|
|
1504
|
+
// structurally satisfies SelectedNodeLike, so the trimmed subset goes back through the
|
|
1505
|
+
// hook's own callback type rather than importing SelectedNode from the package's dist/.
|
|
1506
|
+
type SelectedNodesTap = Parameters<typeof highlighter.hooks.Render.SelectedNodes.tap>[0];
|
|
1507
|
+
highlighter.hooks.Render.SelectedNodes.tap(((...args: unknown[]) =>
|
|
1508
|
+
trimWhitespaceOnlyBoundaryNodes(args[1] as SelectedNodeLike[])) as SelectedNodesTap);
|
|
1509
|
+
|
|
1483
1510
|
highlighterRef.current = highlighter;
|
|
1484
1511
|
|
|
1485
1512
|
// Stored positions can resolve into chrome the reviewer could never have
|
|
@@ -1499,6 +1526,15 @@ export function useAnnotationHighlighter({
|
|
|
1499
1526
|
|
|
1500
1527
|
highlighter.on(Highlighter.event.CREATE, ({ sources, type }: { sources: any[]; type?: string }) => {
|
|
1501
1528
|
if (type === 'from-store') return;
|
|
1529
|
+
// Store positions as if a host's `belowTitle` slot were not there, so
|
|
1530
|
+
// its contents never renumber a comment's parent element (identity
|
|
1531
|
+
// when the document has no slot). See utils/belowTitleSlot.
|
|
1532
|
+
for (const created of sources) {
|
|
1533
|
+
const startMeta = metaWithoutBelowTitle(containerRef.current, created.startMeta, 'start');
|
|
1534
|
+
const endMeta = metaWithoutBelowTitle(containerRef.current, created.endMeta, 'end');
|
|
1535
|
+
if (startMeta !== created.startMeta) created.startMeta = startMeta;
|
|
1536
|
+
if (endMeta !== created.endMeta) created.endMeta = endMeta;
|
|
1537
|
+
}
|
|
1502
1538
|
if (sources.length > 0) {
|
|
1503
1539
|
const source = sources[0];
|
|
1504
1540
|
const doms = highlighter.getDoms(source.id);
|
|
@@ -1561,10 +1597,24 @@ export function useAnnotationHighlighter({
|
|
|
1561
1597
|
} else {
|
|
1562
1598
|
// Selection mode — show toolbar
|
|
1563
1599
|
pendingSourceRef.current = source;
|
|
1600
|
+
const marks = [...doms] as HTMLElement[];
|
|
1601
|
+
const selectionText = source.text;
|
|
1564
1602
|
setToolbarState({
|
|
1565
1603
|
element: doms[0] as HTMLElement,
|
|
1566
1604
|
source,
|
|
1567
|
-
selectionText
|
|
1605
|
+
selectionText,
|
|
1606
|
+
// Built only when Copy (rich text) is pressed: the pending
|
|
1607
|
+
// highlight's marks still bound the selected words, and the
|
|
1608
|
+
// sanitizer drops the marks themselves.
|
|
1609
|
+
getRichCopy: () => {
|
|
1610
|
+
const first = marks[0];
|
|
1611
|
+
const last = marks[marks.length - 1];
|
|
1612
|
+
if (!first?.isConnected || !last?.isConnected) return null;
|
|
1613
|
+
const range = document.createRange();
|
|
1614
|
+
range.setStartBefore(first);
|
|
1615
|
+
range.setEndAfter(last);
|
|
1616
|
+
return richCopySourceFromRange(range, selectionText);
|
|
1617
|
+
},
|
|
1568
1618
|
});
|
|
1569
1619
|
}
|
|
1570
1620
|
}
|
|
@@ -1609,7 +1659,9 @@ export function useAnnotationHighlighter({
|
|
|
1609
1659
|
pendingRangeRunsRef.current = null;
|
|
1610
1660
|
return;
|
|
1611
1661
|
}
|
|
1612
|
-
|
|
1662
|
+
const snappedOutOfSlot = snapRangeOutOfBelowTitle(container, range);
|
|
1663
|
+
const snappedPastExcluded = snapRangeStartPastExcluded(range);
|
|
1664
|
+
if (snappedOutOfSlot || snappedPastExcluded) {
|
|
1613
1665
|
selection.removeAllRanges();
|
|
1614
1666
|
selection.addRange(range);
|
|
1615
1667
|
}
|
|
@@ -1724,6 +1776,7 @@ export function useAnnotationHighlighter({
|
|
|
1724
1776
|
// Same fail-closed rule as the pointer-end guard (#881): touch
|
|
1725
1777
|
// selections settle on block boundaries just as often.
|
|
1726
1778
|
if (isBlankQuote(sel.toString()) || !isSerializableRange(range)) return false;
|
|
1779
|
+
snapRangeOutOfBelowTitle(containerRef.current, range);
|
|
1727
1780
|
snapRangeStartPastExcluded(range);
|
|
1728
1781
|
pendingRangeRunsRef.current = rangeTextPieces(range);
|
|
1729
1782
|
try {
|
|
@@ -1776,6 +1829,7 @@ export function useAnnotationHighlighter({
|
|
|
1776
1829
|
// visually hidden type word; snap off it before painting (the caller's own
|
|
1777
1830
|
// range is left untouched).
|
|
1778
1831
|
const painted = range.cloneRange();
|
|
1832
|
+
snapRangeOutOfBelowTitle(container, painted);
|
|
1779
1833
|
snapRangeStartPastExcluded(painted);
|
|
1780
1834
|
pendingRangeRunsRef.current = rangeTextPieces(painted);
|
|
1781
1835
|
|