@plannotator/ui 0.52.1 → 0.53.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.
Files changed (76) hide show
  1. package/HANDOFF.md +284 -29
  2. package/README.md +45 -5
  3. package/components/AnnotationPanel.tsx +195 -118
  4. package/components/AnnotationToolbar.tsx +17 -14
  5. package/components/AnnotationToolstrip.tsx +54 -40
  6. package/components/BlockRenderer.tsx +20 -14
  7. package/components/CodeFilePopout.tsx +132 -69
  8. package/components/CommentPopover.tsx +8 -349
  9. package/components/ComposerTokenTextarea.tsx +379 -0
  10. package/components/ExportModal.tsx +6 -1
  11. package/components/HostTitle.tsx +108 -0
  12. package/components/HtmlSurfaceControls.tsx +2 -1
  13. package/components/ImageLightbox.tsx +288 -17
  14. package/components/ImagePeek.tsx +255 -0
  15. package/components/ImageResolverContext.tsx +31 -0
  16. package/components/InlineMarkdown.tsx +123 -15
  17. package/components/LocalLinkContext.tsx +49 -0
  18. package/components/MentionPicker.tsx +51 -0
  19. package/components/MenuVersionSection.tsx +51 -25
  20. package/components/OpenInAppButton.tsx +18 -49
  21. package/components/PlanHeaderMenu.tsx +2 -8
  22. package/components/ProviderIcons.tsx +10 -4
  23. package/components/RenderedMarkdown.tsx +40 -4
  24. package/components/Settings.tsx +33 -5
  25. package/components/SkillReferenceMenu.tsx +67 -18
  26. package/components/Tooltip.tsx +51 -4
  27. package/components/Viewer.tsx +220 -49
  28. package/components/ai/AIProviderBar.tsx +11 -0
  29. package/components/ai/AskSessionSetupHint.tsx +12 -0
  30. package/components/ai/DocumentAIChatPanel.tsx +17 -5
  31. package/components/ai/SessionAskNotice.tsx +4 -3
  32. package/components/blocks/BlockquoteBlock.tsx +159 -0
  33. package/components/blocks/HtmlBlock.tsx +121 -9
  34. package/components/blocks/QuestionBlock.tsx +183 -96
  35. package/components/blocks/QuestionContext.tsx +3 -0
  36. package/components/diagram/DiagramViewer.tsx +41 -4
  37. package/components/diagram/index.ts +1 -1
  38. package/components/html-viewer/HtmlViewer.tsx +95 -3
  39. package/components/html-viewer/bridge-script.asset.js +413 -7
  40. package/components/html-viewer/bridge-script.lite.ts +1 -1
  41. package/components/html-viewer/bridge-script.ts +454 -7
  42. package/components/html-viewer/useHtmlAnnotation.ts +51 -3
  43. package/components/icons/AgentIcons.tsx +9 -7
  44. package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
  45. package/components/plan-diff/PlanDiffViewer.tsx +6 -1
  46. package/configure.ts +27 -1
  47. package/hooks/useAgentJobs.ts +8 -3
  48. package/hooks/useAnnotationHighlighter.ts +128 -23
  49. package/hooks/useExternalAnnotations.ts +6 -0
  50. package/hooks/useMentionAutocomplete.ts +1 -1
  51. package/hooks/useOpenInApps.ts +88 -0
  52. package/hooks/useSkillReferenceAutocomplete.ts +7 -3
  53. package/hooks/useUpdateCheck.ts +38 -98
  54. package/hooks/useUpdateCheckSetting.ts +43 -0
  55. package/hooks/useUpdateToast.ts +101 -0
  56. package/hooks/useVimSelection.ts +9 -0
  57. package/package.json +2 -2
  58. package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
  59. package/shortcuts/index.ts +1 -0
  60. package/shortcuts/runtime.ts +10 -3
  61. package/styles.css +1 -1
  62. package/types.ts +48 -0
  63. package/utils/agentClosed.ts +26 -0
  64. package/utils/agentToolSetting.ts +16 -9
  65. package/utils/aiProvider.ts +7 -0
  66. package/utils/askSessionSetupHint.ts +27 -0
  67. package/utils/blockTargeting.ts +14 -3
  68. package/utils/mentions.ts +34 -5
  69. package/utils/parser.ts +65 -51
  70. package/utils/pasteInsert.ts +234 -0
  71. package/utils/planDiffEngine.ts +272 -2
  72. package/utils/skillCatalog.ts +15 -2
  73. package/utils/skillReferences.ts +50 -13
  74. package/utils/storage.ts +5 -0
  75. package/utils/textCut.ts +41 -0
  76. package/utils/updateNotice.ts +120 -0
@@ -1,5 +1,5 @@
1
- import { useState, useEffect, useCallback, useRef, type RefObject } from "react";
2
- import { AnnotationType, type Annotation, type EditorMode, type HtmlAnnotationTarget, type HtmlElementAnchor, type HtmlElementContext, type ImageAttachment } from "../../types";
1
+ import { useState, useEffect, useLayoutEffect, useCallback, useRef, type RefObject } from "react";
2
+ import { AnnotationType, type Annotation, type EditorMode, type HostDraft, type HtmlAnnotationTarget, type HtmlElementAnchor, type HtmlElementContext, type ImageAttachment } from "../../types";
3
3
  import { THUMBS_UP_LABEL, type QuickLabel } from "../../utils/quickLabels";
4
4
  import { getIdentity } from "../../utils/identity";
5
5
  import type {
@@ -199,6 +199,10 @@ export interface UseHtmlAnnotationOptions {
199
199
  /** scrollIntoView behavior for scroll-to (selecting an annotation).
200
200
  * Absent: smooth, as before; pass 'auto' to honor reduced motion. */
201
201
  scrollBehavior?: 'smooth' | 'auto';
202
+ /** The composer handed to the host (see `HostDraft` and HtmlViewer's
203
+ * `onHostDraft`): a selection or a pin reports a draft instead of opening
204
+ * the toolbar or the composer. Absent: unchanged. */
205
+ onHostDraft?: (draft: HostDraft | null) => void;
202
206
  }
203
207
 
204
208
  /** Clamp a host cap into the package's bound; anything unusable is the default. */
@@ -465,6 +469,7 @@ export function useHtmlAnnotation({
465
469
  onUnanchoredChange,
466
470
  maxAdditionalTargets,
467
471
  scrollBehavior,
472
+ onHostDraft,
468
473
  }: UseHtmlAnnotationOptions): Omit<
469
474
  UseAnnotationHighlighterReturn,
470
475
  "highlighterRef" | "highlightRange" | "highlightMathElement"
@@ -536,6 +541,10 @@ export function useHtmlAnnotation({
536
541
  onPageChangeRef.current = onPageChange;
537
542
  const onLinkClickRef = useRef(onLinkClick);
538
543
  onLinkClickRef.current = onLinkClick;
544
+ const onHostDraftRef = useRef(onHostDraft);
545
+ onHostDraftRef.current = onHostDraft;
546
+ // The id of the draft the host holds, so a stale cancel cannot drop a newer one.
547
+ const hostDraftIdRef = useRef<string | null>(null);
539
548
  // The effective cap and whether the host set one: only an explicit cap
540
549
  // rides on arm-multi-select, so an unconfigured viewer posts today's message.
541
550
  const maxTargetsRef = useRef(resolveMaxAdditionalTargets(maxAdditionalTargets));
@@ -628,7 +637,9 @@ export function useHtmlAnnotation({
628
637
  [iframeRef, getOrCreateAnchor],
629
638
  );
630
639
 
631
- useEffect(() => {
640
+ // A layout effect, like HtmlViewer's own listener: listening from the
641
+ // commit that inserts the iframe, before its document can post anything.
642
+ useLayoutEffect(() => {
632
643
  function handler(e: MessageEvent<unknown>) {
633
644
  if (e.source !== iframeRef.current?.contentWindow) return;
634
645
  // Live sessions verify origin + session token BEFORE parsing. The
@@ -659,6 +670,39 @@ export function useHtmlAnnotation({
659
670
  pendingAnchorRef.current = message.anchor ?? null;
660
671
  pendingContextRef.current = message.context ?? null;
661
672
  setDraftTargets([]); // a new selection always starts a fresh draft
673
+ const reportDraft = onHostDraftRef.current;
674
+ if (reportDraft) {
675
+ // The host composes: HTML is comment-only, so every selection or
676
+ // pin is a comment draft, and nothing of ours opens.
677
+ const id = nextHtmlAnnId();
678
+ hostDraftIdRef.current = id;
679
+ reportDraft({
680
+ annotation: {
681
+ id,
682
+ blockId: "",
683
+ startOffset: 0,
684
+ endOffset: 0,
685
+ type: AnnotationType.COMMENT,
686
+ text: "",
687
+ originalText: message.text,
688
+ author: getIdentity(),
689
+ createdA: Date.now(),
690
+ htmlAnchor: message.anchor,
691
+ elementContext: message.context,
692
+ },
693
+ intent: message.pinpoint ? "compose" : "selection",
694
+ label: message.targetLabel,
695
+ cancel: () => {
696
+ if (hostDraftIdRef.current !== id) return;
697
+ hostDraftIdRef.current = null;
698
+ post({ type: `${PREFIX}cancel-selection` });
699
+ pendingTextRef.current = "";
700
+ pendingAnchorRef.current = null;
701
+ pendingContextRef.current = null;
702
+ },
703
+ });
704
+ return;
705
+ }
662
706
  const anchor = positionAnchor(message.rect);
663
707
  if (!anchor) return;
664
708
 
@@ -754,6 +798,10 @@ export function useHtmlAnnotation({
754
798
  }
755
799
 
756
800
  if (type === `${PREFIX}selection-clear`) {
801
+ if (onHostDraftRef.current && hostDraftIdRef.current) {
802
+ hostDraftIdRef.current = null;
803
+ onHostDraftRef.current(null);
804
+ }
757
805
  setToolbarState(null);
758
806
  // Keep the captured text alive while a comment/quick-label is open: the user
759
807
  // is composing, and the selection collapsing or scrolling out of view must
@@ -19,14 +19,16 @@ export function CodexIcon({ className }: { className?: string }) {
19
19
  return <img src={CODEX_DATA_URI} className={className} alt="" aria-hidden="true" draggable={false} />;
20
20
  }
21
21
 
22
- // Pi brand mark — path data from packages/ui/components/ProviderIcons.tsx's
23
- // PiIcon (itself extracted from apps/marketing/public/assets/icon-pi.svg).
24
- // currentColor so it adapts to theme, like OpenCodeIcon below.
22
+ // Pi brand mark: the pi.dev logo (https://pi.dev/logo.svg), the same paths as
23
+ // ProviderIcons.tsx's PiIcon and apps/marketing/public/assets/icon-pi.svg. Its
24
+ // own three colours in every theme; the viewBox crops pi.dev's 800-unit canvas
25
+ // to the mark plus an even margin so it sits level with the other marks.
25
26
  export function PiIcon({ className }: { className?: string }) {
26
27
  return (
27
- <svg viewBox="0 0 800 800" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
28
- <path fill="currentColor" fillRule="evenodd" d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z" />
29
- <path fill="currentColor" d="M517.36 400H634.72V634.72H517.36Z" />
28
+ <svg viewBox="130 130 540 540" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
29
+ <path fill="#F09082" d="M165.29 165.29H517.36V400H400V282.65H165.29Z" />
30
+ <path fill="#4D9ABF" d="M165.29 282.65H282.65V400H400V517.36H282.65V634.72H165.29Z" />
31
+ <path fill="#F1BE58" d="M517.36 400H634.72V634.72H517.36Z" />
30
32
  </svg>
31
33
  );
32
34
  }
@@ -34,7 +36,7 @@ export function PiIcon({ className }: { className?: string }) {
34
36
  // GitHub Copilot brand mark — path from the official agent icon set
35
37
  // (welcome.developers.workers.dev/icons/agents/copilot); the dark/light
36
38
  // variants there differ only by fill, so one currentColor component covers
37
- // both themes, like PiIcon/OpenCodeIcon.
39
+ // both themes, like OpenCodeIcon.
38
40
  export function CopilotIcon({ className }: { className?: string }) {
39
41
  return (
40
42
  <svg viewBox="0 0 256 208" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
@@ -6,17 +6,20 @@
6
6
  * hover in Viewer) — no text selection, no web-highlighter.
7
7
  */
8
8
 
9
- import React, { useEffect, useRef, useState, useCallback } from "react";
9
+ import React, { useEffect, useRef, useState, useCallback, createContext, useContext, useMemo } from "react";
10
10
  import { parseMarkdownToBlocks, computeListIndices } from "../../utils/parser";
11
11
  import { applyHighlight, codeBlockClassName, codeBlockKeepsLayout } from "../../utils/codeHighlight";
12
12
  import { useFenceTheme } from "../../hooks/useFenceTheme";
13
13
  import { ListItemBody } from "../ListItemBody";
14
+ import { BlockquoteBlock, BLOCKQUOTE_CLASS_NAME } from "../blocks/BlockquoteBlock";
14
15
  import type { Block, Annotation, EditorMode, ImageAttachment } from "../../types";
15
16
  import { AnnotationType } from "../../types";
16
- import type {
17
- PlanDiffBlock,
18
- InlineDiffToken,
19
- InlineDiffWrap,
17
+ import {
18
+ foldUnchangedPlanDiff,
19
+ type PlanDiffBlock,
20
+ type PlanDiffFold,
21
+ type InlineDiffToken,
22
+ type InlineDiffWrap,
20
23
  } from "../../utils/planDiffEngine";
21
24
  import type { QuickLabel } from "../../utils/quickLabels";
22
25
  import { AnnotationToolbar } from "../AnnotationToolbar";
@@ -24,6 +27,22 @@ import { CommentPopover } from "../CommentPopover";
24
27
  import { FloatingQuickLabelPicker } from "../FloatingQuickLabelPicker";
25
28
  import { getIdentity } from "../../utils/identity";
26
29
 
30
+ /**
31
+ * Which version a rendered chunk comes from: 'old' for a removed block and
32
+ * the old half of a modified block, 'new' for everything else.
33
+ */
34
+ export type PlanDiffSide = "old" | "new";
35
+
36
+ /** Host renderer for one parsed markdown block (see `renderBlock`). */
37
+ export type PlanDiffBlockRenderer = (
38
+ block: Block,
39
+ orderedIndex: number | null,
40
+ side: PlanDiffSide,
41
+ ) => React.ReactNode;
42
+
43
+ /** Provided only when a host passes `renderBlock`; absent, chunks render as always. */
44
+ const RenderBlockContext = createContext<PlanDiffBlockRenderer | null>(null);
45
+
27
46
  interface PlanCleanDiffViewProps {
28
47
  blocks: PlanDiffBlock[];
29
48
  annotations?: Annotation[];
@@ -38,8 +57,33 @@ interface PlanCleanDiffViewProps {
38
57
  * "Classic" diff view exposed in the mode switcher.
39
58
  */
40
59
  wordLevel?: boolean;
60
+ /**
61
+ * Optional: render each parsed markdown block (headings, paragraphs, code,
62
+ * tables, …) with the host's renderer instead of the view's built-in
63
+ * simple one. Not used for inline word-level modified blocks. Absent, the
64
+ * view renders exactly as before.
65
+ */
66
+ renderBlock?: PlanDiffBlockRenderer;
67
+ /**
68
+ * Optional: a left gutter on every block naming its source lines
69
+ * ("11–12", "old 15–16"; a modified block shows both). Needs the line
70
+ * ranges `computePlanDiff` fills. Absent, no gutter.
71
+ */
72
+ lineGutter?: boolean;
73
+ /**
74
+ * Optional: fold long runs of unchanged sections behind a "N unchanged
75
+ * sections" row that expands in place (see `foldUnchangedPlanDiff`).
76
+ */
77
+ foldUnchanged?: boolean;
78
+ /**
79
+ * Optional: classes for unchanged blocks, replacing the default dimming
80
+ * (`opacity-60 hover:opacity-100 transition-opacity`).
81
+ */
82
+ unchangedClassName?: string;
41
83
  }
42
84
 
85
+ const DEFAULT_UNCHANGED_CLASS = "opacity-60 hover:opacity-100 transition-opacity";
86
+
43
87
  export const PlanCleanDiffView: React.FC<PlanCleanDiffViewProps> = ({
44
88
  blocks,
45
89
  annotations = [],
@@ -48,6 +92,10 @@ export const PlanCleanDiffView: React.FC<PlanCleanDiffViewProps> = ({
48
92
  selectedAnnotationId = null,
49
93
  mode = "selection",
50
94
  wordLevel = true,
95
+ renderBlock,
96
+ lineGutter = false,
97
+ foldUnchanged = false,
98
+ unchangedClassName,
51
99
  }) => {
52
100
  const modeRef = useRef<EditorMode>(mode);
53
101
  const onAddAnnotationRef = useRef(onAddAnnotation);
@@ -285,22 +333,104 @@ export const PlanCleanDiffView: React.FC<PlanCleanDiffViewProps> = ({
285
333
  // Check if a block index has been annotated (for highlight ring)
286
334
  const isBlockAnnotated = (index: number) => annotatedBlockIds.has(`diff-block-${index}`);
287
335
 
336
+ // Fold state per fold key (expanded folds render their pieces in place).
337
+ const [expandedFolds, setExpandedFolds] = useState<ReadonlySet<string>>(() => new Set());
338
+ const toggleFold = useCallback((key: string) => {
339
+ setExpandedFolds((prev) => {
340
+ const next = new Set(prev);
341
+ if (next.has(key)) next.delete(key);
342
+ else next.add(key);
343
+ return next;
344
+ });
345
+ }, []);
346
+ const segments = useMemo(
347
+ () => (foldUnchanged ? foldUnchangedPlanDiff(blocks) : null),
348
+ [blocks, foldUnchanged],
349
+ );
350
+
351
+ const renderDiffBlock = (block: PlanDiffBlock, index: number, key: React.Key) => {
352
+ const rendered = (
353
+ <DiffBlockRenderer
354
+ key={lineGutter ? undefined : key}
355
+ block={block}
356
+ index={index}
357
+ hoveredIndex={hoveredBlock?.index ?? null}
358
+ hoveredDiffContext={hoveredBlock?.diffContext}
359
+ isBlockAnnotated={isBlockAnnotated}
360
+ wordLevel={wordLevel}
361
+ unchangedClassName={unchangedClassName}
362
+ onHover={onAddAnnotation ? (el, diffContext) => handleHover(el, block, index, diffContext) : undefined}
363
+ onLeave={onAddAnnotation ? handleLeave : undefined}
364
+ onClick={onAddAnnotation ? (el, diffContext) => handleBlockClick(block, index, el, diffContext) : undefined}
365
+ />
366
+ );
367
+ if (!lineGutter) return rendered;
368
+ return (
369
+ <GutterRow key={key} labels={blockGutterLabels(block)}>
370
+ {rendered}
371
+ </GutterRow>
372
+ );
373
+ };
374
+
375
+ const renderFold = (fold: PlanDiffFold, index: number) => {
376
+ const expanded = expandedFolds.has(fold.key);
377
+ const noun = fold.sectionCount === 1 ? "section" : "sections";
378
+ const row = expanded ? (
379
+ <button
380
+ type="button"
381
+ data-plan-diff-fold=""
382
+ aria-expanded
383
+ onClick={() => toggleFold(fold.key)}
384
+ className="annotation-exclude flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
385
+ >
386
+ <FoldChevron expanded />
387
+ Hide {fold.sectionCount} unchanged {noun}
388
+ </button>
389
+ ) : (
390
+ <button
391
+ type="button"
392
+ data-plan-diff-fold=""
393
+ aria-expanded={false}
394
+ onClick={() => toggleFold(fold.key)}
395
+ className="annotation-exclude flex w-full min-w-0 items-center gap-2 rounded-md bg-muted/40 px-3 py-1.5 text-left text-xs text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
396
+ >
397
+ <FoldChevron expanded={false} />
398
+ <span className="flex-none">{fold.sectionCount} unchanged {noun}</span>
399
+ {fold.headings.length > 0 && (
400
+ <span className="min-w-0 truncate text-muted-foreground/70">
401
+ · {fold.headings.join(" · ")}
402
+ </span>
403
+ )}
404
+ <span className="ml-auto flex-none pl-2">Show</span>
405
+ </button>
406
+ );
407
+ return (
408
+ <React.Fragment key={fold.key}>
409
+ {lineGutter ? (
410
+ <GutterRow labels={expanded ? [] : [rangeLabel(fold.newStart, fold.newEnd) ?? ""]}>{row}</GutterRow>
411
+ ) : (
412
+ row
413
+ )}
414
+ {expanded && fold.pieces.map((piece, i) => renderDiffBlock(piece, index, `${fold.key}-${i}`))}
415
+ </React.Fragment>
416
+ );
417
+ };
418
+
419
+ const body = segments
420
+ ? segments.map((segment, i) =>
421
+ segment.kind === "fold"
422
+ ? renderFold(segment, i)
423
+ : renderDiffBlock(segment.block, segment.index, `seg-${i}`),
424
+ )
425
+ : blocks.map((block, index) => renderDiffBlock(block, index, index));
426
+
288
427
  return (
289
428
  <div className="space-y-1">
290
- {blocks.map((block, index) => (
291
- <DiffBlockRenderer
292
- key={index}
293
- block={block}
294
- index={index}
295
- hoveredIndex={hoveredBlock?.index ?? null}
296
- hoveredDiffContext={hoveredBlock?.diffContext}
297
- isBlockAnnotated={isBlockAnnotated}
298
- wordLevel={wordLevel}
299
- onHover={onAddAnnotation ? (el, diffContext) => handleHover(el, block, index, diffContext) : undefined}
300
- onLeave={onAddAnnotation ? handleLeave : undefined}
301
- onClick={onAddAnnotation ? (el, diffContext) => handleBlockClick(block, index, el, diffContext) : undefined}
302
- />
303
- ))}
429
+ {renderBlock ? (
430
+ <RenderBlockContext.Provider value={renderBlock}>{body}</RenderBlockContext.Provider>
431
+ ) : (
432
+ body
433
+ )}
304
434
 
305
435
  {/* Block hover toolbar (selection mode) */}
306
436
  {hoveredBlock && !commentPopover && !quickLabelPicker && (
@@ -349,6 +479,61 @@ export const PlanCleanDiffView: React.FC<PlanCleanDiffViewProps> = ({
349
479
  );
350
480
  };
351
481
 
482
+ // --- Line gutter + fold row (optional seams) ---
483
+
484
+ /** "11–12", or "14" for one line; null when the side has no lines. */
485
+ function rangeLabel(start?: number, end?: number): string | null {
486
+ if (start === undefined) return null;
487
+ return end !== undefined && end !== start ? `${start}–${end}` : `${start}`;
488
+ }
489
+
490
+ /** The gutter lines for one rendered diff block. */
491
+ function blockGutterLabels(block: PlanDiffBlock): string[] {
492
+ const newLabel = rangeLabel(block.newStart, block.newEnd);
493
+ const oldLabel = rangeLabel(block.oldStart, block.oldEnd);
494
+ switch (block.type) {
495
+ case "removed":
496
+ return oldLabel ? [`old ${oldLabel}`] : [];
497
+ case "modified":
498
+ return [newLabel, oldLabel ? `old ${oldLabel}` : null].filter((l): l is string => !!l);
499
+ default:
500
+ return newLabel ? [newLabel] : [];
501
+ }
502
+ }
503
+
504
+ /** A block beside its source-line gutter. The gutter is never selectable or part of a quote. */
505
+ // Baseline alignment puts the label beside the first line of text, past a
506
+ // heading's top margin.
507
+ const GutterRow: React.FC<{ labels: string[]; children: React.ReactNode }> = ({ labels, children }) => (
508
+ <div className="flex items-baseline gap-3">
509
+ <div
510
+ data-plan-diff-gutter=""
511
+ aria-hidden="true"
512
+ className="annotation-exclude w-[4.5rem] flex-none select-none text-right font-mono text-[11px] leading-4 tabular-nums text-muted-foreground/70"
513
+ >
514
+ {labels.map((label, i) => (
515
+ <div key={i}>{label}</div>
516
+ ))}
517
+ </div>
518
+ <div className="min-w-0 flex-1">{children}</div>
519
+ </div>
520
+ );
521
+
522
+ const FoldChevron: React.FC<{ expanded: boolean }> = ({ expanded }) => (
523
+ <svg
524
+ aria-hidden="true"
525
+ className={`h-3 w-3 flex-none transition-transform ${expanded ? "rotate-90" : ""}`}
526
+ viewBox="0 0 24 24"
527
+ fill="none"
528
+ stroke="currentColor"
529
+ strokeWidth={2.5}
530
+ strokeLinecap="round"
531
+ strokeLinejoin="round"
532
+ >
533
+ <path d="M9 6l6 6-6 6" />
534
+ </svg>
535
+ );
536
+
352
537
  // --- DiffBlockRenderer with hover support ---
353
538
 
354
539
  interface DiffBlockRendererProps {
@@ -359,13 +544,15 @@ interface DiffBlockRendererProps {
359
544
  isBlockAnnotated: (index: number) => boolean;
360
545
  /** When false, force block-level fallback even if inlineTokens is populated. */
361
546
  wordLevel: boolean;
547
+ /** Replaces the default unchanged-block dimming classes when set. */
548
+ unchangedClassName?: string;
362
549
  onHover?: (element: HTMLElement, diffContext: Annotation['diffContext']) => void;
363
550
  onLeave?: () => void;
364
551
  onClick?: (element: HTMLElement, diffContext: Annotation['diffContext']) => void;
365
552
  }
366
553
 
367
554
  const DiffBlockRenderer: React.FC<DiffBlockRendererProps> = ({
368
- block, index, hoveredIndex, hoveredDiffContext, isBlockAnnotated, wordLevel, onHover, onLeave, onClick,
555
+ block, index, hoveredIndex, hoveredDiffContext, isBlockAnnotated, wordLevel, unchangedClassName, onHover, onLeave, onClick,
369
556
  }) => {
370
557
  const hoverProps = (diffContext: Annotation['diffContext']) => onHover ? {
371
558
  onMouseEnter: (e: React.MouseEvent<HTMLElement>) => onHover(e.currentTarget, diffContext),
@@ -386,7 +573,7 @@ const DiffBlockRenderer: React.FC<DiffBlockRendererProps> = ({
386
573
  switch (block.type) {
387
574
  case "unchanged":
388
575
  return (
389
- <div className="plan-diff-unchanged opacity-60 hover:opacity-100 transition-opacity">
576
+ <div className={`plan-diff-unchanged ${unchangedClassName ?? DEFAULT_UNCHANGED_CLASS}`}>
390
577
  <MarkdownChunk content={block.content} />
391
578
  </div>
392
579
  );
@@ -409,7 +596,7 @@ const DiffBlockRenderer: React.FC<DiffBlockRendererProps> = ({
409
596
  data-diff-block-index={index}
410
597
  {...hoverProps('removed')}
411
598
  >
412
- <MarkdownChunk content={block.content} />
599
+ <MarkdownChunk content={block.content} side="old" />
413
600
  </div>
414
601
  );
415
602
 
@@ -435,7 +622,7 @@ const DiffBlockRenderer: React.FC<DiffBlockRendererProps> = ({
435
622
  className={`plan-diff-removed line-through decoration-destructive/30 opacity-60 transition-shadow ${ringClass('removed')}`}
436
623
  {...hoverProps('removed')}
437
624
  >
438
- <MarkdownChunk content={block.oldContent!} />
625
+ <MarkdownChunk content={block.oldContent!} side="old" />
439
626
  </div>
440
627
  <div
441
628
  className={`plan-diff-added transition-shadow ${ringClass('modified')}`}
@@ -566,7 +753,8 @@ const InlineModifiedBlock: React.FC<InlineModifiedBlockProps> = ({
566
753
 
567
754
  // --- Rendering components (unchanged from main) ---
568
755
 
569
- const MarkdownChunk: React.FC<{ content: string }> = ({ content }) => {
756
+ const MarkdownChunk: React.FC<{ content: string; side?: PlanDiffSide }> = ({ content, side = "new" }) => {
757
+ const renderBlock = useContext(RenderBlockContext);
570
758
  const blocks = React.useMemo(
571
759
  () => parseMarkdownToBlocks(content),
572
760
  [content]
@@ -580,6 +768,16 @@ const MarkdownChunk: React.FC<{ content: string }> = ({ content }) => {
580
768
  [blocks]
581
769
  );
582
770
 
771
+ if (renderBlock) {
772
+ return (
773
+ <>
774
+ {blocks.map((block, i) => (
775
+ <React.Fragment key={block.id}>{renderBlock(block, orderedIndices[i], side)}</React.Fragment>
776
+ ))}
777
+ </>
778
+ );
779
+ }
780
+
583
781
  return (
584
782
  <>
585
783
  {blocks.map((block, i) => (
@@ -606,18 +804,23 @@ const SimpleBlockRenderer: React.FC<{ block: Block; orderedIndex?: number | null
606
804
  }
607
805
 
608
806
  case "blockquote": {
609
- // Split on blank-line paragraph breaks so merged `> a\n>\n> b`
610
- // renders as two <p> children instead of collapsing to one line.
611
- const paragraphs = block.content.split(/\n\n+/);
612
- return (
613
- <blockquote className="border-l-2 border-primary/50 pl-4 my-4 text-muted-foreground italic">
614
- {paragraphs.map((para, i) => (
615
- <p key={i} className={i > 0 ? "mt-2" : ""}>
616
- <InlineMarkdown text={para} />
617
- </p>
618
- ))}
619
- </blockquote>
620
- );
807
+ // The document's quote renderer, without a block id (the diff view
808
+ // keeps none): a prose quote renders as it always has, and a quote
809
+ // with a code fence, a list or a nested quote renders them inside one
810
+ // quote. Alerts keep the plain quote look they have always had here.
811
+ if (block.alertKind) {
812
+ const paragraphs = block.content.split(/\n\n+/);
813
+ return (
814
+ <blockquote className={BLOCKQUOTE_CLASS_NAME}>
815
+ {paragraphs.map((para, i) => (
816
+ <p key={i} className={i > 0 ? "mt-2" : ""}>
817
+ <InlineMarkdown text={para} />
818
+ </p>
819
+ ))}
820
+ </blockquote>
821
+ );
822
+ }
823
+ return <BlockquoteBlock block={{ ...block, id: undefined as unknown as string }} />;
621
824
  }
622
825
 
623
826
  case "list-item": {
@@ -17,6 +17,7 @@ import { PlanCleanDiffView } from "./PlanCleanDiffView";
17
17
  import { PlanRawDiffView } from "./PlanRawDiffView";
18
18
  import { PlanDiffBadge } from "./PlanDiffBadge";
19
19
  import { VSCodeIcon } from "./VSCodeIcon";
20
+ import { noteServerSessionMismatch, withServerSession } from "../../utils/serverSession";
20
21
 
21
22
  interface PlanDiffViewerProps {
22
23
  diffBlocks: PlanDiffBlock[];
@@ -44,8 +45,12 @@ const defaultOpenVscodeDiff = async (
44
45
  const res = await fetch("/api/plan/vscode-diff", {
45
46
  method: "POST",
46
47
  headers: { "Content-Type": "application/json" },
47
- body: JSON.stringify({ baseVersion }),
48
+ // The stale-tab guard (a no-op for a host that never adopted a session).
49
+ body: JSON.stringify(withServerSession({ baseVersion })),
48
50
  });
51
+ if (await noteServerSessionMismatch(res)) {
52
+ return { error: "This review was replaced. Reload the page." };
53
+ }
49
54
  const data = (await res.json()) as { ok?: boolean; error?: string };
50
55
  if (!res.ok || data.error) {
51
56
  return { error: data.error || "Failed to open VS Code diff" };
package/configure.ts CHANGED
@@ -7,15 +7,17 @@ import { setFileTreeBackend, type FileTreeBackend } from './hooks/useFileBrowser
7
7
  import { setDraftTransport, type DraftTransport } from './hooks/useAnnotationDraft';
8
8
  import { setExternalAnnotationTransport, type ExternalAnnotationTransport } from './hooks/useExternalAnnotations';
9
9
  import { setAITransport, type AITransport } from './hooks/useAIChat';
10
- import { setSkillCatalogTransport, setSkillContentTransport, type SkillCatalogTransport, type SkillContentTransport } from './utils/skillCatalog';
10
+ import { setSkillCatalogExportRegistration, setSkillCatalogTransport, setSkillContentTransport, type SkillCatalogTransport, type SkillContentTransport } from './utils/skillCatalog';
11
11
  import { setWebMcpPolicy, type WebMcpPolicy } from './webmcp/policy';
12
12
  import { setMathRendererLoader, type MathRenderer, type MathRendererLoader } from './utils/math';
13
13
  import { setIdentityGenerator, type IdentityGenerator } from './utils/generateIdentity';
14
14
  import { setAlertIconRenderer, type AlertIconRenderer } from './components/blocks/AlertBlock';
15
+ import { setSkillInstructionsDestination } from './components/SkillReferenceMenu';
15
16
  import { setFenceThemeResolver, type FenceThemeResolver } from './utils/syntaxTheme';
16
17
  import { configStore } from './config';
17
18
  import type { ServerSyncFn } from './config/configStore';
18
19
  import type { ExternalAnnotationEvent, VaultNode } from './types';
20
+ import { setComponentTooltips } from './components/HostTitle';
19
21
 
20
22
  // One-stop type barrel: every seam contract a host implements is importable
21
23
  // from this module, next to configurePlannotatorUI itself.
@@ -66,6 +68,19 @@ export interface PlannotatorUIConfig {
66
68
  skillCatalogTransport?: SkillCatalogTransport;
67
69
  /** Human-only skill contents request for feedback injection. Default: `GET /api/skills/content?name=` on the page origin. */
68
70
  skillContentTransport?: SkillContentTransport;
71
+ /**
72
+ * Register the fetched skill catalog for the per-comment feedback export
73
+ * (each comment's "Skills referenced" list). Default: true. A host that
74
+ * composes one block per message itself passes false, so a comment's
75
+ * `$name` stays plain text in its export.
76
+ */
77
+ skillReferenceExport?: boolean;
78
+ /**
79
+ * What a composer's "Includes skill instructions" line says a human-only
80
+ * skill's instructions go with. Default: "your feedback". The Plannotator
81
+ * Inbox, where a comment rides a reply, says "your reply".
82
+ */
83
+ skillInstructionsDestination?: string;
69
84
  serverSync?: ServerSyncFn;
70
85
  /**
71
86
  * WebMCP provider policy: `{ enabled, namePrefix }`. Default: enabled
@@ -104,6 +119,14 @@ export interface PlannotatorUIConfig {
104
119
  * pane keeps its own pair. An unknown theme name renders plain text.
105
120
  */
106
121
  fenceTheme?: FenceThemeResolver;
122
+ /**
123
+ * Draw ui's own hover text (the annotation panel's and toolbar's buttons, a
124
+ * question card's decision tag, a host-resolved local link's
125
+ * `LocalLinkResolution.title`) as ui's `Tooltip` instead of a native
126
+ * `title` (`components/HostTitle`). Default: false, native titles exactly
127
+ * as before. The Plannotator Inbox passes true.
128
+ */
129
+ componentTooltips?: boolean;
107
130
  /** Re-hydrate settings from the installed (SYNCHRONOUS) storageBackend after install. */
108
131
  loadSettingsFromBackend?: boolean;
109
132
  }
@@ -120,12 +143,15 @@ export function configurePlannotatorUI(config: PlannotatorUIConfig): void {
120
143
  if (config.aiTransport) setAITransport(config.aiTransport);
121
144
  if (config.skillCatalogTransport) setSkillCatalogTransport(config.skillCatalogTransport);
122
145
  if (config.skillContentTransport) setSkillContentTransport(config.skillContentTransport);
146
+ if (config.skillReferenceExport !== undefined) setSkillCatalogExportRegistration(config.skillReferenceExport);
147
+ if (config.skillInstructionsDestination) setSkillInstructionsDestination(config.skillInstructionsDestination);
123
148
  if (config.serverSync) configStore.setServerSync(config.serverSync);
124
149
  if (config.webmcp) setWebMcpPolicy(config.webmcp);
125
150
  if (config.mathRendererLoader) setMathRendererLoader(config.mathRendererLoader);
126
151
  if (config.identityGenerator) setIdentityGenerator(config.identityGenerator);
127
152
  if (config.alertIconRenderer) setAlertIconRenderer(config.alertIconRenderer);
128
153
  if (config.fenceTheme) setFenceThemeResolver(config.fenceTheme);
154
+ if (config.componentTooltips !== undefined) setComponentTooltips(config.componentTooltips);
129
155
  // Re-hydrate AFTER storageBackend is installed (load-bearing order — gated last).
130
156
  if (config.loadSettingsFromBackend) configStore.loadFromBackend();
131
157
  }
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { useState, useEffect, useCallback, useRef } from 'react';
14
14
  import type { AgentJobInfo, AgentJobEvent, AgentCapabilities } from '../types';
15
+ import { noteServerSessionMismatch, withServerSession, withServerSessionQuery } from '../utils/serverSession';
15
16
 
16
17
  const POLL_INTERVAL_MS = 500;
17
18
  const STREAM_URL = '/api/agents/jobs/stream';
@@ -273,9 +274,11 @@ export function useAgentJobs(
273
274
  const res = await fetch(JOBS_URL, {
274
275
  method: 'POST',
275
276
  headers: { 'Content-Type': 'application/json' },
276
- body: JSON.stringify(params),
277
+ // The stale-tab guard (a no-op for a host that never adopted a session).
278
+ body: JSON.stringify(withServerSession(params)),
277
279
  });
278
280
  if (!res.ok) {
281
+ await noteServerSessionMismatch(res);
279
282
  throw new Error(await readResponseError(res));
280
283
  }
281
284
 
@@ -289,9 +292,10 @@ export function useAgentJobs(
289
292
 
290
293
  const killJob = useCallback(async (id: string) => {
291
294
  try {
292
- await fetch(`${JOBS_URL}/${encodeURIComponent(id)}`, {
295
+ const res = await fetch(withServerSessionQuery(`${JOBS_URL}/${encodeURIComponent(id)}`), {
293
296
  method: 'DELETE',
294
297
  });
298
+ await noteServerSessionMismatch(res);
295
299
  } catch {
296
300
  // SSE will reconcile
297
301
  }
@@ -299,7 +303,8 @@ export function useAgentJobs(
299
303
 
300
304
  const killAll = useCallback(async () => {
301
305
  try {
302
- await fetch(JOBS_URL, { method: 'DELETE' });
306
+ const res = await fetch(withServerSessionQuery(JOBS_URL), { method: 'DELETE' });
307
+ await noteServerSessionMismatch(res);
303
308
  } catch {
304
309
  // SSE will reconcile
305
310
  }