@plannotator/ui 0.43.0 → 0.43.2

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 CHANGED
@@ -214,7 +214,7 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r
214
214
  | `hooks/useHtmlRefresh` | Re-fetch a rendered HTML document through a host-supplied `fetchSnapshot`, remount the viewer on a reload generation, acknowledge the restore report once. See "HTML annotation parity seams". |
215
215
  | `shortcuts` (`useHtmlAnnotateShortcuts`, `defineShortcutScope`, the scope registry) | The declarative keyboard-shortcut engine and the per-surface scopes, including the HTML annotate scope (Mod+Shift+A toggles annotate mode, Mod+Shift+X shows/hides the tools). Pure: React plus `utils/platform`; no backend. |
216
216
  | `utils/selectionActions` + `components/SelectionActionsDropdown` | The host selection-actions seam: `SelectionAction`, `SelectionActionContext`, the pure `buildSelectionActionContext`, and the dropdown `AnnotationToolbar` opens. Pure React; no backend. *(Blessed in 0.43.0.)* |
217
- | `utils/mentions` + `components/MentionPicker` + `hooks/useMentionAutocomplete` | The `@` mention seam behind `CommentPopover`'s `mentionSource`: the pure grammar (`mentionTrigger`, `mentionMatches`, `applyMentionPick`, `survivingMentions`), the portaled picker, and the keyboard state machine. Pure React; no backend. *(Blessed in 0.43.0.)* |
217
+ | `utils/mentions` + `components/MentionPicker` + `hooks/useMentionAutocomplete` | The `@` mention seam behind `CommentPopover`'s `mentionSource` (and, since 0.43.1, `Viewer`'s and `HtmlViewer`'s): the pure grammar (`mentionTrigger`, `mentionMatches`, `applyMentionPick`, `survivingMentions`), the portaled picker, and the keyboard state machine. Pure React; no backend. *(Blessed in 0.43.0.)* |
218
218
  | `utils/inputMethod` (`getInputMethod`, `saveInputMethod`, `refreshInputMethodStamp`) | The per-surface pinpoint/drag input-method preference with its TTL. Persists through the `storageBackend` seam; no backend of its own. |
219
219
  | `utils/codeHighlight` / `utils/codeBlockMark` / `utils/syntaxTheme` | The Shiki-based fence highlighter, swap-surviving annotation marks, and palette→Shiki theme mapping. Replaces all `.hljs` styling. *(Blessed in 0.29.0.)* |
220
220
  | `utils/math` (`loadMathRenderer`, `getMathRenderer`, `getMathRendererSource`, `setMathRenderer`, `setMathRendererLoader`, `getMathRendererLoader`, `resetMathRenderer`) and `utils/math-eager` | The math renderer slot and its eager KaTeX registration. Import `utils/math-eager` for synchronous typesetting on the first commit; call `loadMathRenderer()` to pre-warm the lazy path. `resetMathRenderer()` empties the slot and keeps the registered loader; `setMathRendererLoader(null)` drops it. See "Lazy renderers and eager entries". |
@@ -956,6 +956,8 @@ that state out of `Viewer`, exactly as with `AnnotationToolstrip`'s
956
956
 
957
957
  ### 3. `CommentPopover` `mentionSource` — an `@` mention source for the composer
958
958
 
959
+ > **0.43.2 additions (opt-in, byte-identical when absent):** `MentionSource.heading?: string | null` draws one non-selectable heading row (`data-mention-heading`, e.g. "People in this workspace") above the list; `MentionPerson.avatar?: { url?, initials?, tint? }` draws an avatar (`data-mention-avatar="image" | "initials"`) before the label — an `<img>` when `url` is set, else `initials` (defaulting to the label's first letter) on a disc tinted with `tint` (any CSS color; absent means the muted surface). Neither the grammar, the keyboard state machine, `onMentionsChange`, `onPickBlocked` nor `Annotation.mentions` changes.
960
+
959
961
  ```ts
960
962
  import type { MentionPerson, MentionSource } from "@plannotator/ui/types";
961
963
  // (also @plannotator/ui/utils/mentions)
@@ -971,6 +973,7 @@ interface MentionPerson {
971
973
  interface MentionSource {
972
974
  readonly people: readonly MentionPerson[];
973
975
  readonly emptyNotice?: string | null; // honest-empty row
976
+ readonly heading?: string | null; // 0.43.2: heading row above the list, absent → none
974
977
  readonly onMentionsChange?: (ids: readonly string[]) => void;
975
978
  readonly onPickBlocked?: (person: MentionPerson) => void;
976
979
  }
@@ -1026,7 +1029,7 @@ the people and what a mention means.
1026
1029
 
1027
1030
  ### Threading points
1028
1031
 
1029
- - `AnnotationToolbar` (`selectionActions`, `quickLabels`) — the props live here.
1032
+ - `AnnotationToolbar` (`selectionActions`, `selectionActionsIcon`, `quickLabels`) — the props live here. `selectionActionsIcon?: React.ReactNode` (0.43.1) is the glyph on the wand button, forwarded by `Viewer` (both toolbars) and `HtmlViewer`; absent → the package's own wand, which 0.43.1 also simplified to one thick diagonal with a single star (the six-spark glyph read as noise at 16px). Name, `data-selection-actions`, size and behavior of the button are untouched either way.
1030
1033
  - `Viewer` forwards both to BOTH of its toolbars (the text-selection toolbar
1031
1034
  and the code-block hover toolbar).
1032
1035
  - `HtmlViewer` forwards `selectionActions` to its selection toolbar. It does
@@ -1038,6 +1041,7 @@ the people and what a mention means.
1038
1041
  - `CommentPopover` (`mentionSource`). `Viewer` does NOT forward it: the viewer
1039
1042
  owns several composers and a per-composer decision belongs to the host that
1040
1043
  renders them. Ask if you would rather pass it once on `Viewer`.
1044
+ **Answered in 0.43.1 — both viewers now forward it; see the next section.**
1041
1045
 
1042
1046
  ### The no-op guarantee, and how it is pinned
1043
1047
 
@@ -1058,8 +1062,118 @@ Tests: `utils/mentions.test.ts` (11, DOM-free),
1058
1062
  `components/AnnotationToolbar.selectionActions.test.tsx` (8, DOM-gated),
1059
1063
  `components/CommentPopover.mentionSource.test.tsx` (11, DOM-gated).
1060
1064
 
1065
+ ## `mentionSource` on the viewers (0.43.1)
1066
+
1067
+ 0.43.0 left `mentionSource` on `CommentPopover` alone, with an open question
1068
+ ("ask if you would rather pass it once on `Viewer`"). The answer is yes, so
1069
+ 0.43.1 threads it one level up and gives the picked ids somewhere to land.
1070
+ Same ruling as 0.43.0: an opt-in host capability that changes nothing for
1071
+ Plannotator's own users when it is not supplied. `packages/editor` and
1072
+ `packages/review-editor` are untouched by this release; core is UNCHANGED at
1073
+ `0.25.5`, so **ui 0.43.1 publishes alone**.
1074
+
1075
+ ### 1. The prop
1076
+
1077
+ `Viewer` and `HtmlViewer` each gain `mentionSource?: MentionSource` — the same
1078
+ type, unchanged, from `@plannotator/ui/types` (also `utils/mentions`) — and
1079
+ each forwards it to EVERY comment composer it mounts:
1080
+
1081
+ - `Viewer` → the text-selection composer (`useAnnotationHighlighter`'s) and the
1082
+ global / code-block one.
1083
+ - `HtmlViewer` → the pinpoint (selection) composer and the global one.
1084
+
1085
+ A host that wants mentions on a surface passes one prop instead of reaching
1086
+ into the viewer's composers. Passing it directly to a `CommentPopover` you
1087
+ mount yourself still works and is unchanged.
1088
+
1089
+ Deliberately NOT threaded: `plan-diff/PlanCleanDiffView`, `CodeFilePopout` and
1090
+ `goal-setup/GoalSetupSurface` mount composers too, but they are Plannotator-only
1091
+ surfaces off the supported-import list — the same line 0.43.0 drew for
1092
+ `selectionActions`. Ask if a host needs one.
1093
+
1094
+ ### 2. `Annotation.mentions` — where the ids go
1095
+
1096
+ ```ts
1097
+ interface Annotation {
1098
+ // …
1099
+ mentions?: readonly string[]; // opaque host ids, additive
1100
+ }
1101
+ ```
1102
+
1103
+ `onSubmit(text, images?, mentions?)` used to stop at the viewer: the third
1104
+ argument was received and dropped. It now rides onto the annotation the viewer
1105
+ hands `onAddAnnotation`, on every creation path behind those composers
1106
+ (`createAnnotationFromSource`, `createAnnotationFromMathSource`, the code-block
1107
+ path, both global comments, and the HTML pinpoint comment).
1108
+
1109
+ **The presence rule** is the whole no-op guarantee, so it is worth stating
1110
+ exactly: the key exists only when a `mentionSource` was supplied AND at least
1111
+ one id survived to submit. No source → no third argument → no key. A source
1112
+ whose tokens the author deleted before submitting → `[]` from the composer →
1113
+ still no key, never an empty array. Every write is a conditional spread
1114
+ (`...(mentions && mentions.length > 0 ? { mentions } : {})`), not `mentions,`,
1115
+ because the bare shorthand would put the key on the object with an `undefined`
1116
+ value and `'mentions' in ann` would start answering true for Plannotator.
1117
+
1118
+ The ids are opaque to the package. Mapping one to a person, notifying them, or
1119
+ rendering an avatar is entirely the host's business — `MentionPerson.id` is
1120
+ reported back verbatim, exactly as it was handed in.
1121
+
1122
+ ### 3. What the field does NOT touch
1123
+
1124
+ An id from a host's directory means nothing outside that host, so the field
1125
+ stays out of everything Plannotator produces:
1126
+
1127
+ - **Export.** `exportAnnotations`, `exportAnnotationEntry` and
1128
+ `exportLinkedDocAnnotations` never print it: an annotation carrying
1129
+ `mentions` exports byte-identically to the same annotation without it
1130
+ (`utils/parser.mentions.test.ts`). The readable `@Label` token is in the
1131
+ comment body, which is what the coding agent reads.
1132
+ - **Share links.** Dropped exactly like `htmlAnchor`, `elementContext` and
1133
+ `diagramAnchor` — the compact tuple format never carried extra fields, and a
1134
+ round trip restores `mentions: undefined` (pinned in
1135
+ `utils/sharing.multiTarget.test.ts`).
1136
+ - **External annotations.** `POST /api/external-annotations` builds its rows
1137
+ from an explicit field list and `PATCH` from an allowlist, so a `mentions`
1138
+ key on the wire is dropped as any unknown key is. No change was needed in
1139
+ `@plannotator/core/external-annotation`, in either runtime.
1140
+ - **The feedback archive.** `packages/shared/feedback-archive.ts` copies named
1141
+ fields into its record; `mentions` is not one of them and never reaches
1142
+ `index.jsonl` or a sidecar. No change needed.
1143
+ - **Drafts** carry it for free (annotations are opaque JSON to the draft
1144
+ transport), which is the behavior a host wants: a restored draft still knows
1145
+ who was named.
1146
+
1147
+ ### 4. Edit and reply paths
1148
+
1149
+ There is none to thread in these two viewers: both `Viewer` composers and both
1150
+ `HtmlViewer` composers are CREATION composers. Editing an existing comment
1151
+ happens in `AnnotationPanel`'s card, a plain textarea that has never had an
1152
+ `@` picker and takes no `mentionSource`; replies (`inReplyTo`) are created by
1153
+ the WebMCP catalog, not by a composer. So no annotation's `mentions` is
1154
+ rewritten after creation by this package — a host that edits a comment owns
1155
+ the field from then on. If you want the panel editor to pick people too, that
1156
+ is a separate prop on `AnnotationPanel` and worth asking for.
1157
+
1158
+ ### No-op guarantee, and how it is pinned
1159
+
1160
+ With no `mentionSource`, both viewers mount the composers they always did, no
1161
+ `@` listener is registered, no picker DOM exists, and the annotation object
1162
+ handed to `onAddAnnotation` has no `mentions` key at all (`'mentions' in ann`
1163
+ is false, asserted rather than `toBeUndefined()` — the difference between the
1164
+ conditional spread and the bare shorthand is invisible to the latter).
1165
+
1166
+ Tests (both DOM-gated, both in the workflow's DOM_TESTS step):
1167
+ `components/Viewer.mentionSource.test.tsx` (5) drives the real Viewer — the
1168
+ selection composer through the Vim toolbar's type-to-comment gesture and the
1169
+ global composer through its button — and
1170
+ `components/html-viewer/HtmlViewer.mentionSource.test.tsx` (3) drives the real
1171
+ HtmlViewer through a bridge pinpoint message and its global button. Plus the two DOM-free pins in
1172
+ §3 above.
1173
+
1061
1174
  ## Publishing & versioning
1062
1175
 
1176
+ - **ui 0.43.1 (`mentionSource` on the viewers): `@plannotator/ui` only — `@plannotator/core` is UNCHANGED at `0.25.5`, so this publishes alone** (core 0.25.5 must already be published). Purely additive over 0.43.0: `mentionSource` on `Viewer` and `HtmlViewer` (forwarded to every comment composer each mounts) and the optional `Annotation.mentions` field the picked ids land on, set only when a source was supplied and a token survived. Nothing is removed, no new modules, no export-, share- or archive-visible change, and Plannotator passes none of it. See "`mentionSource` on the viewers (0.43.1)".
1063
1177
  - **ui 0.43.0 (host toolbar seams): `@plannotator/ui` only — `@plannotator/core` is UNCHANGED at `0.25.5`, so this publishes alone** (core 0.25.5 must already be published). Purely additive: `selectionActions` + `quickLabels` on `AnnotationToolbar` (forwarded by `Viewer`; `selectionActions` also by `HtmlViewer`), `mentionSource` on `CommentPopover`, an optional third `mentions` argument on that component's `onSubmit`, and the new supported modules `utils/selectionActions`, `utils/mentions`, `components/SelectionActionsDropdown`, `components/MentionPicker`, `hooks/useMentionAutocomplete`. Nothing is removed and Plannotator passes none of it. See "Host toolbar seams (0.43.0)".
1064
1178
  - **core 0.25.5 / ui 0.42.0 (diagram FILES, `.mmd`/`.mermaid`/`.dot`/`.gv`): additive on both packages, so the next publish is core-first.** `@plannotator/core/annotatable` gains `DiagramRenderKind`, `diagramRenderKindForPath`, `isDiagramRenderKind` and `annotateDiagramRenderKind`, and its built-in annotatable sets now include the four diagram extensions (`shouldStripFrontmatter` returns false for them — Mermaid's `--- … ---` config block is content — and they can no longer be registered through `markdownExtensions`). `@plannotator/ui` gains `diagramDocumentBlocks(text, kind)` on `utils/parser` (the ONE `code` block a whole-file diagram source renders as), `shareableDocumentMarkdown(markdown, renderAs)` on `utils/sharing`, the `DocumentRenderAs` type on `types` (`'markdown' | 'html' | DiagramRenderKind`, re-exporting core's kind), and an optional `Block.diagramSourceLineOffset` that `DiagramBlock` prefers over `Block.startLine` when resolving a diagram comment's `sourceLine` (unset on every parser-produced fence, so fences are byte-identical). `useLinkedDoc`'s `renderAs`/`setRenderAs`/`LinkedDocLoadData.renderAs` widen from `'markdown' | 'html'` to `DocumentRenderAs` — source-compatible for a host that only ever passes the old two, but a host whose own state is typed `'markdown' | 'html'` must widen its setter. Since core changes, **publish `core` first** and update UI's exact core dependency before packing ui.
1065
1179
  - **The current pair is `@plannotator/ui` `0.41.2` on `@plannotator/core` `0.25.4`.** Core is UNCHANGED from 0.41.1, so 0.41.2 publishes alone (`ui` only; core 0.25.4 must already be published). 0.41.2 ships the palette-derived Mermaid node shadow at a default of 70% (`DEFAULT_MERMAID_SHADOW_AMOUNT`) and the Settings → Display "Diagram Shadow" control (0 / 40 / 70 / 100); see "Node shadow (the one thing that is not a colour)" under "Theme-aware Mermaid diagrams (0.40.0)" for the mapping — no API removal, only additive exports (`buildMermaidShadow`, `DEFAULT_MERMAID_SHADOW_AMOUNT`, `utils/diagramShadow`).
package/README.md CHANGED
@@ -238,10 +238,16 @@ in 0.42.0 (proven by diffing the mounted `outerHTML` against the base commit).
238
238
  `startOffset: 0` on a surface with no blocks, such as raw HTML) — and the
239
239
  toolbar closes. **The package creates no annotation:** what an action does is yours.
240
240
  An empty array renders no button.
241
+ - **`AnnotationToolbar` `selectionActionsIcon`** (forwarded by `Viewer` and
242
+ `HtmlViewer`): the glyph on the `selectionActions` button, so a host can match
243
+ the wand it draws elsewhere. Absent means the package's own wand; nothing else
244
+ about the button changes.
241
245
  - **`AnnotationToolbar` `quickLabels`** (default `true`): `false` hides the Zap
242
246
  picker and makes the Alt+digit label shortcuts inert on that toolbar. The
243
247
  one-click 👍 is unaffected, and mode state is still yours to clamp.
244
- - **`CommentPopover` `mentionSource`**: `{ people, emptyNotice?,
248
+ - **`CommentPopover` `mentionSource`** (0.43.2 adds `heading?` above the list
249
+ and an optional `avatar?: { url?, initials?, tint? }` per person, drawn before
250
+ the label; both absent → the 0.43.1 rows): `{ people, emptyNotice?,
245
251
  onMentionsChange?, onPickBlocked? }` over `MentionPerson`
246
252
  (`{ id, kind, label, detail, canOpen }`). Typing `@` at a word boundary opens
247
253
  a portaled picker measured from the textarea; picking inserts the readable
@@ -252,9 +258,17 @@ in 0.42.0 (proven by diffing the mounted `outerHTML` against the base commit).
252
258
  no-access dialog) and inserts normally when you do not. Not wired to any
253
259
  Plannotator data and not a `configurePlannotatorUI` seam — pass it where you
254
260
  render the composer.
261
+ - **`Viewer` / `HtmlViewer` `mentionSource`** (0.43.1): the same prop on the two
262
+ viewers, forwarded to every comment composer each of them mounts, so a host
263
+ wires mentions once per surface instead of per composer. The ids the author
264
+ kept ride onto the created annotation as `Annotation.mentions`
265
+ (`readonly string[]`, present only when a source was supplied and at least
266
+ one token survived). It is host data: the package never renders, exports,
267
+ shares or archives it.
255
268
 
256
269
  See HANDOFF.md § "Host toolbar seams (0.43.0)" for the grammar, the keyboard
257
- rules and the threading points.
270
+ rules and the threading points, and § "mentionSource on the viewers (0.43.1)"
271
+ for the two viewer props and the annotation field.
258
272
 
259
273
  ### WebMCP provider (`@plannotator/ui/webmcp`; 0.32.0)
260
274
 
@@ -44,6 +44,12 @@ interface AnnotationToolbarProps {
44
44
  * Absent or empty → no button, no dropdown: today's toolbar.
45
45
  */
46
46
  selectionActions?: SelectionAction[];
47
+ /**
48
+ * Opt-in host capability: the glyph on the `selectionActions` button, so a
49
+ * host can match the wand it draws elsewhere. Absent → the package's own
50
+ * wand. Nothing else about the button changes (name, data attribute, size).
51
+ */
52
+ selectionActionsIcon?: React.ReactNode;
47
53
  /**
48
54
  * Whether the package's own quick labels are offered (default true).
49
55
  * `false` hides the Zap picker button and the Alt+digit label shortcuts on
@@ -69,6 +75,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
69
75
  onRequestComment,
70
76
  onQuickLabel,
71
77
  selectionActions,
78
+ selectionActionsIcon,
72
79
  quickLabels: quickLabelsEnabled = true,
73
80
  copyText,
74
81
  commentOnly = false,
@@ -273,7 +280,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
273
280
  <ToolbarButton
274
281
  ref={actionsButtonRef}
275
282
  onClick={() => setShowSelectionActions(prev => !prev)}
276
- icon={<WandIcon />}
283
+ icon={selectionActionsIcon ?? <WandIcon />}
277
284
  label="Actions"
278
285
  className={selectionActionsOpen ? "text-primary bg-primary/10" : "text-primary hover:bg-primary/10"}
279
286
  dataAttributes={{ 'data-selection-actions': 'true' }}
@@ -370,9 +377,11 @@ const ZapIcon = () => (
370
377
  </svg>
371
378
  );
372
379
 
380
+ // One thick diagonal wand with a single four-point star at its tip: the
381
+ // earlier glyph carried six sparks and read as noise at 16px.
373
382
  const WandIcon = () => (
374
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
375
- <path strokeLinecap="round" strokeLinejoin="round" d="M15 4V2m0 16v-2M8 9h2m10 0h2M17.8 11.8L19 13M15 9h0M17.8 6.2L19 5M3 21l9-9M12.2 6.2L11 5" />
383
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
384
+ <path strokeLinecap="round" strokeLinejoin="round" d="M3 21L13.5 10.5M17 3v7M13.5 6.5h7" />
376
385
  </svg>
377
386
  );
378
387
 
@@ -694,6 +694,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
694
694
  id={mentionListboxId}
695
695
  people={mentionAc.menu.items}
696
696
  emptyNotice={mentionAc.menu.emptyNotice}
697
+ heading={mentionAc.menu.heading}
697
698
  active={mentionAc.menu.activeIndex}
698
699
  anchor={mentionAc.menu.anchor}
699
700
  onPick={(person) => {
@@ -858,6 +859,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
858
859
  id={mentionListboxId}
859
860
  people={mentionAc.menu.items}
860
861
  emptyNotice={mentionAc.menu.emptyNotice}
862
+ heading={mentionAc.menu.heading}
861
863
  active={mentionAc.menu.activeIndex}
862
864
  anchor={mentionAc.menu.anchor}
863
865
  onPick={(person) => {
@@ -34,10 +34,45 @@ export function mentionAnchorOf(element: HTMLElement | null): MentionAnchor | nu
34
34
  return { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width };
35
35
  }
36
36
 
37
+ /** The optional avatar column: an image, else initials on a tinted disc. */
38
+ function MentionAvatar({
39
+ avatar,
40
+ label,
41
+ }: {
42
+ readonly avatar: NonNullable<MentionPerson['avatar']>;
43
+ readonly label: string;
44
+ }) {
45
+ if (avatar.url) {
46
+ return (
47
+ <img
48
+ data-mention-avatar="image"
49
+ src={avatar.url}
50
+ alt=""
51
+ aria-hidden="true"
52
+ className="h-4 w-4 shrink-0 rounded-full object-cover"
53
+ />
54
+ );
55
+ }
56
+ const initials = (avatar.initials ?? label.trim().charAt(0)).slice(0, 2).toUpperCase();
57
+ return (
58
+ <span
59
+ data-mention-avatar="initials"
60
+ aria-hidden="true"
61
+ style={avatar.tint ? { backgroundColor: avatar.tint } : undefined}
62
+ className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[8px] font-semibold leading-none ${
63
+ avatar.tint ? 'text-white' : 'bg-muted text-foreground/80'
64
+ }`}
65
+ >
66
+ {initials}
67
+ </span>
68
+ );
69
+ }
70
+
37
71
  export function MentionPicker({
38
72
  id,
39
73
  people,
40
74
  emptyNotice,
75
+ heading,
41
76
  active,
42
77
  anchor,
43
78
  onPick,
@@ -48,6 +83,8 @@ export function MentionPicker({
48
83
  readonly people: readonly MentionPerson[];
49
84
  /** Shown as one non-selectable row when `people` is empty. */
50
85
  readonly emptyNotice?: string | null;
86
+ /** Optional heading above the list. Absent → nothing rendered. */
87
+ readonly heading?: string | null;
51
88
  /** Index of the arrow-focused row, or null for "nothing preselected". */
52
89
  readonly active: number | null;
53
90
  readonly anchor: MentionAnchor | null;
@@ -70,6 +107,11 @@ export function MentionPicker({
70
107
  style={{ position: 'fixed', left: anchor.left, width: anchor.width, ...style }}
71
108
  className="z-[120] max-h-48 overflow-y-auto rounded-md border border-border bg-popover p-1 shadow-xl"
72
109
  >
110
+ {heading && (
111
+ <p data-mention-heading className="px-2 pb-1 pt-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
112
+ {heading}
113
+ </p>
114
+ )}
73
115
  {people.length === 0 ? (
74
116
  <p data-mention-empty className="px-2 py-1 text-[11px] text-muted-foreground">
75
117
  {emptyNotice}
@@ -94,6 +136,7 @@ export function MentionPicker({
94
136
  active === index ? 'bg-muted text-foreground' : 'text-foreground/85 hover:bg-muted/60'
95
137
  }`}
96
138
  >
139
+ {person.avatar && <MentionAvatar avatar={person.avatar} label={person.label} />}
97
140
  <span className="min-w-0 flex-1 truncate">{person.label}</span>
98
141
  {person.detail && (
99
142
  // The NAME is what the person reads; the detail gives way first.
@@ -60,6 +60,7 @@ import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages';
60
60
  import { getIdentity } from '../utils/identity';
61
61
  import { type QuickLabel } from '../utils/quickLabels';
62
62
  import type { SelectionAction } from '../utils/selectionActions';
63
+ import type { MentionSource } from '../utils/mentions';
63
64
  import { DocBadges, type DocBadgesProps, type LinkedDocBadgeInfo } from './DocBadges';
64
65
  import { PinpointOverlay } from './PinpointOverlay';
65
66
  import { usePinpoint } from '../hooks/usePinpoint';
@@ -98,12 +99,22 @@ export interface ViewerProps {
98
99
  * one wand button that opens the package's dropdown. Absent → unchanged.
99
100
  */
100
101
  selectionActions?: SelectionAction[];
102
+ /** Opt-in host capability: the glyph on the `selectionActions` button on
103
+ * both toolbars. Absent → the package's own wand. */
104
+ selectionActionsIcon?: React.ReactNode;
101
105
  /**
102
106
  * Whether the package's quick labels are offered on the selection toolbars
103
107
  * (default true). `false` hides the Zap picker and the Alt+digit label
104
108
  * shortcuts; the 👍 button is unaffected.
105
109
  */
106
110
  quickLabels?: boolean;
111
+ /**
112
+ * Opt-in host capability, forwarded to BOTH comment composers this viewer
113
+ * mounts (the text-selection composer and the global / code-block one): the
114
+ * `@` mention source for the composer's picker. The picked ids ride onto the
115
+ * created annotation as `Annotation.mentions`. Absent → unchanged.
116
+ */
117
+ mentionSource?: MentionSource;
107
118
  blocks: Block[];
108
119
  markdown: string;
109
120
  frontmatter?: Frontmatter | null;
@@ -374,7 +385,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
374
385
  inputMethod = 'drag',
375
386
  taterMode,
376
387
  selectionActions,
388
+ selectionActionsIcon,
377
389
  quickLabels,
390
+ mentionSource,
378
391
  globalAttachments = [],
379
392
  onAddGlobalAttachment,
380
393
  onRemoveGlobalAttachment,
@@ -558,6 +571,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
558
571
  images?: ImageAttachment[],
559
572
  isQuickLabel?: boolean,
560
573
  quickLabelTip?: string,
574
+ mentions?: readonly string[],
561
575
  ) => {
562
576
  if (readOnlyRef.current) return;
563
577
 
@@ -577,6 +591,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
577
591
  createdA: Date.now(),
578
592
  author: getIdentity(),
579
593
  images,
594
+ // Host capability: present only when a mentionSource was supplied AND a
595
+ // token survived, so a code-block comment without one is unchanged.
596
+ ...(mentions && mentions.length > 0 ? { mentions } : {}),
580
597
  ...(isQuickLabel ? { isQuickLabel: true } : {}),
581
598
  ...(quickLabelTip ? { quickLabelTip } : {}),
582
599
  };
@@ -957,7 +974,11 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
957
974
  setCodeBlockToolbar(null);
958
975
  };
959
976
 
960
- const handleViewerCommentSubmit = (text: string, images?: ImageAttachment[]) => {
977
+ const handleViewerCommentSubmit = (
978
+ text: string,
979
+ images?: ImageAttachment[],
980
+ mentions?: readonly string[],
981
+ ) => {
961
982
  if (readOnlyRef.current || !viewerCommentPopover) return;
962
983
 
963
984
  if (viewerCommentPopover.isGlobal) {
@@ -975,12 +996,22 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
975
996
  createdA: Date.now(),
976
997
  author: getIdentity(),
977
998
  images,
999
+ ...(mentions && mentions.length > 0 ? { mentions } : {}),
978
1000
  };
979
1001
  onAddAnnotation(newAnnotation);
980
1002
  } else if (viewerCommentPopover.codeBlock) {
981
1003
  const codeEl = viewerCommentPopover.codeBlock.element.querySelector('code');
982
1004
  if (codeEl) {
983
- applyCodeBlockAnnotation(viewerCommentPopover.codeBlock.block.id, codeEl, AnnotationType.COMMENT, text, images);
1005
+ applyCodeBlockAnnotation(
1006
+ viewerCommentPopover.codeBlock.block.id,
1007
+ codeEl,
1008
+ AnnotationType.COMMENT,
1009
+ text,
1010
+ images,
1011
+ undefined,
1012
+ undefined,
1013
+ mentions,
1014
+ );
984
1015
  }
985
1016
  }
986
1017
 
@@ -1312,6 +1343,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1312
1343
  onRequestComment={handleRequestComment}
1313
1344
  onQuickLabel={handleQuickLabel}
1314
1345
  selectionActions={selectionActions}
1346
+ selectionActionsIcon={selectionActionsIcon}
1315
1347
  quickLabels={quickLabels}
1316
1348
  copyText={toolbarState.selectionText}
1317
1349
  hideCopyButton={!isTouchDevice}
@@ -1369,6 +1401,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1369
1401
  onRequestComment={handleCodeBlockRequestComment}
1370
1402
  onQuickLabel={handleCodeBlockQuickLabel}
1371
1403
  selectionActions={selectionActions}
1404
+ selectionActionsIcon={selectionActionsIcon}
1372
1405
  quickLabels={quickLabels}
1373
1406
  isExiting={isCodeBlockToolbarExiting}
1374
1407
  onMouseEnter={() => {
@@ -1447,6 +1480,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1447
1480
  draftKey={`plan:${commentDraftScope}:${hookCommentPopover.draftKey}`}
1448
1481
  onSubmit={hookCommentSubmit}
1449
1482
  onClose={hookCommentClose}
1483
+ mentionSource={mentionSource}
1450
1484
  allowImages={allowImages}
1451
1485
  skillReferences
1452
1486
  onAskAI={onAskAI}
@@ -1471,6 +1505,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1471
1505
  }`}
1472
1506
  onSubmit={handleViewerCommentSubmit}
1473
1507
  onClose={handleViewerCommentClose}
1508
+ mentionSource={mentionSource}
1474
1509
  allowImages={allowImages}
1475
1510
  skillReferences
1476
1511
  onAskAI={onAskAI}
@@ -6,6 +6,7 @@ import {
6
6
  useMemo,
7
7
  useRef,
8
8
  useState,
9
+ type ReactNode,
9
10
  } from "react";
10
11
  import { createPortal } from "react-dom";
11
12
  import { useVimDocumentFocus } from "../../hooks/useVimDocumentFocus";
@@ -25,6 +26,7 @@ import {
25
26
  } from "../../utils/vimHud";
26
27
  import { AnnotationToolbar } from "../AnnotationToolbar";
27
28
  import type { SelectionAction } from "../../utils/selectionActions";
29
+ import type { MentionSource } from "../../utils/mentions";
28
30
  import { AttachmentsButton } from "../AttachmentsButton";
29
31
  import {
30
32
  CommentPopover,
@@ -270,6 +272,14 @@ export interface HtmlViewerProps {
270
272
  * toolbar: the host's own commands for the current selection, rendered as
271
273
  * one wand button that opens the package's dropdown. Absent → unchanged. */
272
274
  selectionActions?: SelectionAction[];
275
+ /** Opt-in host capability: the glyph on the `selectionActions` button.
276
+ * Absent → the package's own wand. */
277
+ selectionActionsIcon?: ReactNode;
278
+ /** Opt-in host capability, forwarded to BOTH comment composers this viewer
279
+ * mounts (the pinpoint/selection composer and the global one): the `@`
280
+ * mention source for the composer's picker. The picked ids ride onto the
281
+ * created annotation as `Annotation.mentions`. Absent → unchanged. */
282
+ mentionSource?: MentionSource;
273
283
  /** scrollIntoView behavior when a selected annotation is scrolled into
274
284
  * view inside the page. Default 'smooth'; pass 'auto' to carry the
275
285
  * parent's reduced-motion preference across the iframe boundary. */
@@ -358,6 +368,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
358
368
  onUnanchoredChange,
359
369
  maxAdditionalTargets,
360
370
  selectionActions,
371
+ selectionActionsIcon,
372
+ mentionSource,
361
373
  scrollBehavior,
362
374
  title = "HTML Plan Viewer",
363
375
  bridgeScriptUrl,
@@ -995,7 +1007,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
995
1007
  }));
996
1008
 
997
1009
  const handleGlobalCommentSubmit = useCallback(
998
- (text: string, images?: ImageAttachment[]) => {
1010
+ (text: string, images?: ImageAttachment[], mentions?: readonly string[]) => {
999
1011
  if (readOnly) return;
1000
1012
  onAddAnnotation({
1001
1013
  id: `global-${Date.now()}`,
@@ -1008,6 +1020,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1008
1020
  author: getIdentity(),
1009
1021
  createdA: Date.now(),
1010
1022
  images,
1023
+ ...(mentions && mentions.length > 0 ? { mentions } : {}),
1011
1024
  });
1012
1025
  setGlobalCommentPopover(null);
1013
1026
  },
@@ -1209,6 +1222,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1209
1222
  // future toolbar path can emit an arbitrary label here.
1210
1223
  commentOnly
1211
1224
  selectionActions={selectionActions}
1225
+ selectionActionsIcon={selectionActionsIcon}
1212
1226
  onQuickLabel={(label) => {
1213
1227
  if (label.id === THUMBS_UP_LABEL.id) hook.handleQuickLabel(label);
1214
1228
  }}
@@ -1229,6 +1243,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1229
1243
  isGlobal={false}
1230
1244
  draftKey={`html:${hook.commentPopover.draftKey}`}
1231
1245
  onSubmit={hook.handleCommentSubmit}
1246
+ mentionSource={mentionSource}
1232
1247
  // Pinpoint clicks open this composer directly, so it carries
1233
1248
  // the surface's one-click "Looks good" (the global composer
1234
1249
  // does not: a document-wide thumbs-up is not a thing).
@@ -1260,6 +1275,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1260
1275
  isGlobal={true}
1261
1276
  onSubmit={handleGlobalCommentSubmit}
1262
1277
  onClose={() => setGlobalCommentPopover(null)}
1278
+ mentionSource={mentionSource}
1263
1279
  skillReferences
1264
1280
  onAskAI={onAskAI}
1265
1281
  askAIContext={{ kind: "general", label: "Document" }}
@@ -911,7 +911,7 @@ export function useHtmlAnnotation({
911
911
  );
912
912
 
913
913
  const handleCommentSubmit = useCallback(
914
- (comment: string, images?: ImageAttachment[]) => {
914
+ (comment: string, images?: ImageAttachment[], mentions?: readonly string[]) => {
915
915
  if (!enabledRef.current) return;
916
916
  // Prefer the text captured when the popover opened — it can't be clobbered by
917
917
  // a later selection change or clear while the user is composing the comment.
@@ -944,6 +944,9 @@ export function useHtmlAnnotation({
944
944
  author: getIdentity(),
945
945
  createdA: Date.now(),
946
946
  images,
947
+ // Host capability: present only when a mentionSource was supplied AND
948
+ // a token survived, so a comment without one is unchanged.
949
+ ...(mentions && mentions.length > 0 ? { mentions } : {}),
947
950
  htmlAnchor: pendingAnchorRef.current ?? undefined,
948
951
  elementContext: pendingContextRef.current ?? undefined,
949
952
  htmlAdditionalTargets: additionalTargets,
@@ -631,7 +631,13 @@ export interface UseAnnotationHighlighterReturn {
631
631
  handleQuickLabel: (label: QuickLabel) => void;
632
632
  handleToolbarClose: () => void;
633
633
  handleRequestComment: (initialChar?: string) => void;
634
- handleCommentSubmit: (text: string, images?: ImageAttachment[]) => void;
634
+ /** The composer's submit. `mentions` arrives only from a `CommentPopover`
635
+ * the host gave a `mentionSource`; the ids ride onto the new annotation. */
636
+ handleCommentSubmit: (
637
+ text: string,
638
+ images?: ImageAttachment[],
639
+ mentions?: readonly string[],
640
+ ) => void;
635
641
  handleCommentClose: () => void;
636
642
  handleFloatingQuickLabel: (label: QuickLabel) => void;
637
643
  handleQuickLabelPickerDismiss: () => void;
@@ -870,6 +876,7 @@ export function useAnnotationHighlighter({
870
876
  images?: ImageAttachment[],
871
877
  isQuickLabel?: boolean,
872
878
  quickLabelTip?: string,
879
+ mentions?: readonly string[],
873
880
  ) => {
874
881
  const doms = highlighter.getDoms(source.id);
875
882
  let blockId = '';
@@ -904,6 +911,9 @@ export function useAnnotationHighlighter({
904
911
  startMeta: source.startMeta,
905
912
  endMeta: source.endMeta,
906
913
  images,
914
+ // Host capability: present only when a mentionSource was supplied AND a
915
+ // token survived, so an annotation created without one is unchanged.
916
+ ...(mentions && mentions.length > 0 ? { mentions } : {}),
907
917
  ...(mathTargets.length > 0 ? {
908
918
  mathTargets: mathTargets.map(target => ({
909
919
  blockId: target.blockId,
@@ -933,6 +943,7 @@ export function useAnnotationHighlighter({
933
943
  images?: ImageAttachment[],
934
944
  isQuickLabel?: boolean,
935
945
  quickLabelTip?: string,
946
+ mentions?: readonly string[],
936
947
  ) => {
937
948
  const id = annotationId();
938
949
  applyMathAnnotationClass(source.element, id, type, source.displayMode);
@@ -951,6 +962,7 @@ export function useAnnotationHighlighter({
951
962
  createdA: Date.now(),
952
963
  author: getIdentity(),
953
964
  images,
965
+ ...(mentions && mentions.length > 0 ? { mentions } : {}),
954
966
  ...(isQuickLabel ? { isQuickLabel: true } : {}),
955
967
  ...(quickLabelTip ? { quickLabelTip } : {}),
956
968
  };
@@ -1695,7 +1707,11 @@ export function useAnnotationHighlighter({
1695
1707
  setToolbarState(null);
1696
1708
  };
1697
1709
 
1698
- const handleCommentSubmit = (text: string, images?: ImageAttachment[]) => {
1710
+ const handleCommentSubmit = (
1711
+ text: string,
1712
+ images?: ImageAttachment[],
1713
+ mentions?: readonly string[],
1714
+ ) => {
1699
1715
  if (!commentPopover) return;
1700
1716
  if (isMathAnnotationSource(commentPopover.source)) {
1701
1717
  createAnnotationFromMathSource(
@@ -1703,6 +1719,9 @@ export function useAnnotationHighlighter({
1703
1719
  AnnotationType.COMMENT,
1704
1720
  text,
1705
1721
  images,
1722
+ undefined,
1723
+ undefined,
1724
+ mentions,
1706
1725
  );
1707
1726
  clearPendingSelection();
1708
1727
  window.getSelection()?.removeAllRanges();
@@ -1712,7 +1731,7 @@ export function useAnnotationHighlighter({
1712
1731
  if (commentPopover.source && highlighterRef.current) {
1713
1732
  createAnnotationFromSource(
1714
1733
  highlighterRef.current, commentPopover.source,
1715
- AnnotationType.COMMENT, text, images
1734
+ AnnotationType.COMMENT, text, images, undefined, undefined, mentions
1716
1735
  );
1717
1736
  clearPendingSelection();
1718
1737
  window.getSelection()?.removeAllRanges();