@plannotator/ui 0.42.0 → 0.43.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.
@@ -9,6 +9,9 @@ import { hasUnsavedCommentContent } from '../utils/commentContent';
9
9
  import { useSkillReferenceAutocomplete } from '../hooks/useSkillReferenceAutocomplete';
10
10
  import { HumanOnlySkillNotice, SkillReferenceMenu } from './SkillReferenceMenu';
11
11
  import type { SkillReferenceToken } from '../utils/skillReferences';
12
+ import { useMentionAutocomplete } from '../hooks/useMentionAutocomplete';
13
+ import { MentionPicker } from './MentionPicker';
14
+ import type { MentionSource } from '../utils/mentions';
12
15
  import {
13
16
  hasPrimaryCoarsePointer,
14
17
  shouldUseExpandedComposer,
@@ -51,8 +54,13 @@ interface CommentPopoverProps {
51
54
  isGlobal: boolean;
52
55
  /** Pre-filled text (for type-to-comment) */
53
56
  initialText?: string;
54
- /** Called on submit with comment text and optional images */
55
- onSubmit: (text: string, images?: ImageAttachment[]) => void;
57
+ /**
58
+ * Called on submit with comment text and optional images. The third
59
+ * argument carries the mention ids the body still tags, and is passed ONLY
60
+ * when a `mentionSource` is supplied — without one the call is the two
61
+ * arguments it has always been.
62
+ */
63
+ onSubmit: (text: string, images?: ImageAttachment[], mentions?: readonly string[]) => void;
56
64
  /**
57
65
  * One-click "Looks good" action (comment-only HTML/live surfaces, where
58
66
  * pinpoint clicks open this composer directly and never see the selection
@@ -77,6 +85,13 @@ interface CommentPopoverProps {
77
85
  askAIDisabled?: boolean;
78
86
  /** Opt-in: `/` and `$` skill-reference autocomplete (document UI surfaces). Off by default. */
79
87
  skillReferences?: boolean;
88
+ /**
89
+ * Opt-in host capability: an `@` mention source for this composer. Supplies
90
+ * the people, an optional honest-empty notice, and optional callbacks for
91
+ * the surviving mention ids and for a picked person the host blocks.
92
+ * Absent → no listener, no picker, no extra DOM: byte-identical composer.
93
+ */
94
+ mentionSource?: MentionSource;
80
95
  /** Opt-in (HTML multi-select): selected targets rendered as horizontally
81
96
  * scrollable chips above the textarea. Absent → byte-identical composer. */
82
97
  targetChips?: CommentTargetChip[];
@@ -166,6 +181,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
166
181
  askAIContext,
167
182
  askAIDisabled = false,
168
183
  skillReferences = false,
184
+ mentionSource,
169
185
  targetChips,
170
186
  onRemoveTargetChip,
171
187
  onHoverTargetChip,
@@ -451,14 +467,24 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
451
467
  </div>
452
468
  ) : null;
453
469
 
470
+ const mentionAc = useMentionAutocomplete({
471
+ text,
472
+ setText,
473
+ textareaRef,
474
+ source: mentionSource,
475
+ });
476
+
454
477
  const handleSubmit = useCallback(() => {
455
478
  const canSubmitEmpty = allowEmptySubmit && initialText.trim().length > 0;
456
479
  if (hasUnsavedContent || canSubmitEmpty) {
457
480
  if (draftKey) draftStore.delete(draftKey);
458
- onSubmit(text, allowImages && images.length > 0 ? images : undefined);
481
+ const submitImages = allowImages && images.length > 0 ? images : undefined;
482
+ // Without a mentionSource this is the two-argument call it always was.
483
+ if (mentionSource) onSubmit(text, submitImages, mentionAc.mentionIds);
484
+ else onSubmit(text, submitImages);
459
485
  restoreOpeningFocus();
460
486
  }
461
- }, [text, images, onSubmit, draftKey, allowImages, allowEmptySubmit, initialText, hasUnsavedContent, restoreOpeningFocus]);
487
+ }, [text, images, onSubmit, draftKey, allowImages, allowEmptySubmit, initialText, hasUnsavedContent, restoreOpeningFocus, mentionSource, mentionAc.mentionIds]);
462
488
 
463
489
  const handleAskAI = useCallback(async () => {
464
490
  const question = text.trim();
@@ -492,14 +518,31 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
492
518
  textareaRef,
493
519
  enabled: skillReferences,
494
520
  });
521
+ const readComposerCaret = useCallback(() => {
522
+ skillAc.onSelect();
523
+ mentionAc.onSelect();
524
+ }, [mentionAc, skillAc]);
495
525
  const skillListboxId = `skill-reference-listbox-${useId().replace(/:/g, '')}`;
526
+ const mentionListboxId = `${skillListboxId}-mentions`;
496
527
  const activeSkillOptionId =
497
528
  skillAc.menu?.activeIndex === null || skillAc.menu?.activeIndex === undefined
498
529
  ? undefined
499
530
  : `${skillListboxId}-option-${skillAc.menu.activeIndex}`;
531
+ // At most one of the two menus can be open (their triggers are disjoint), so
532
+ // the textarea's ARIA relationship points at whichever one is. All three are
533
+ // `undefined`/false with no menu open, which is every render Plannotator
534
+ // makes without a `mentionSource`.
535
+ const activeMentionOptionId =
536
+ mentionAc.menu === null || mentionAc.menu.activeIndex === null
537
+ ? undefined
538
+ : `${mentionListboxId}-option-${mentionAc.menu.activeIndex}`;
539
+ const composerListboxId = mentionAc.menu !== null ? mentionListboxId : skillListboxId;
540
+ const composerListboxOpen = skillAc.menu !== null || mentionAc.menu !== null;
541
+ const activeComposerOptionId = activeSkillOptionId ?? activeMentionOptionId;
500
542
 
501
543
  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
502
544
  if (skillAc.onKeyDown(e)) return;
545
+ if (mentionAc.onKeyDown(e)) return;
503
546
  if (e.key === 'Escape') {
504
547
  e.stopPropagation();
505
548
  if (mode === 'dialog') {
@@ -634,18 +677,32 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
634
677
  <ComposerTextarea
635
678
  textareaRef={focusOnMountRef}
636
679
  value={text}
637
- onChange={(e) => { setText(e.target.value); skillAc.onSelect(); }}
680
+ onChange={(e) => { setText(e.target.value); readComposerCaret(); }}
638
681
  onKeyDown={handleKeyDown}
639
- onSelectCaret={skillAc.onSelect}
682
+ onSelectCaret={readComposerCaret}
640
683
  placeholder={isGlobal ? 'Add a global comment...' : 'Add a comment...'}
641
684
  sizeClassName="min-h-32 max-h-full"
642
685
  skillReferences={skillReferences}
643
686
  tokens={skillAc.referenceTokens}
644
- listboxId={skillListboxId}
645
- listboxOpen={skillAc.menu !== null}
646
- activeOptionId={activeSkillOptionId}
687
+ listboxId={composerListboxId}
688
+ listboxOpen={composerListboxOpen}
689
+ activeOptionId={activeComposerOptionId}
647
690
  />
648
691
  <HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
692
+ {mentionAc.menu && (
693
+ <MentionPicker
694
+ id={mentionListboxId}
695
+ people={mentionAc.menu.items}
696
+ emptyNotice={mentionAc.menu.emptyNotice}
697
+ active={mentionAc.menu.activeIndex}
698
+ anchor={mentionAc.menu.anchor}
699
+ onPick={(person) => {
700
+ const index = mentionAc.menu?.items.findIndex((p) => p.id === person.id) ?? -1;
701
+ if (index >= 0) mentionAc.select(index);
702
+ }}
703
+ onHover={() => {}}
704
+ />
705
+ )}
649
706
  </div>
650
707
 
651
708
  {/* Footer — DOM order sets tab order (Save first); row-reverse keeps the visual layout unchanged */}
@@ -784,18 +841,32 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
784
841
  <ComposerTextarea
785
842
  textareaRef={focusOnMountRef}
786
843
  value={text}
787
- onChange={(e) => { setText(e.target.value); skillAc.onSelect(); }}
844
+ onChange={(e) => { setText(e.target.value); readComposerCaret(); }}
788
845
  onKeyDown={handleKeyDown}
789
- onSelectCaret={skillAc.onSelect}
846
+ onSelectCaret={readComposerCaret}
790
847
  placeholder={isGlobal ? 'Add a global comment...' : 'Add a comment...'}
791
848
  sizeClassName="max-h-64 min-h-[4.5rem]"
792
849
  skillReferences={skillReferences}
793
850
  tokens={skillAc.referenceTokens}
794
- listboxId={skillListboxId}
795
- listboxOpen={skillAc.menu !== null}
796
- activeOptionId={activeSkillOptionId}
851
+ listboxId={composerListboxId}
852
+ listboxOpen={composerListboxOpen}
853
+ activeOptionId={activeComposerOptionId}
797
854
  />
798
855
  <HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
856
+ {mentionAc.menu && (
857
+ <MentionPicker
858
+ id={mentionListboxId}
859
+ people={mentionAc.menu.items}
860
+ emptyNotice={mentionAc.menu.emptyNotice}
861
+ active={mentionAc.menu.activeIndex}
862
+ anchor={mentionAc.menu.anchor}
863
+ onPick={(person) => {
864
+ const index = mentionAc.menu?.items.findIndex((p) => p.id === person.id) ?? -1;
865
+ if (index >= 0) mentionAc.select(index);
866
+ }}
867
+ onHover={() => {}}
868
+ />
869
+ )}
799
870
  </div>
800
871
 
801
872
  {/* Footer — same DOM-order/row-reverse pattern as the dialog footer above */}
@@ -1013,6 +1084,11 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
1013
1084
  return (
1014
1085
  <textarea
1015
1086
  data-pn-mobile-editable="true"
1087
+ // Absent any open menu these are all undefined, so the plain textarea
1088
+ // renders exactly the attributes it always did.
1089
+ aria-autocomplete={listboxOpen ? 'list' : undefined}
1090
+ aria-controls={listboxOpen ? listboxId : undefined}
1091
+ aria-activedescendant={activeOptionId}
1016
1092
  ref={attachRef}
1017
1093
  value={value}
1018
1094
  onChange={onChange}
@@ -0,0 +1,110 @@
1
+ import { createPortal } from 'react-dom';
2
+ import type { MentionPerson } from '../utils/mentions';
3
+
4
+ /**
5
+ * The `@` menu over the comment composer's textarea.
6
+ *
7
+ * NOTHING IS PRESELECTED (`active === null` until the person arrows into the
8
+ * list), the same rule `useSkillReferenceAutocomplete` keeps for `/` and `$`:
9
+ * until then Enter and Tab mean what they always meant in a comment box.
10
+ *
11
+ * PORTALED AND FIXED, not absolute inside the composer: the popover clips its
12
+ * own box and the expanded dialog scrolls, so a menu positioned inside the
13
+ * textarea's wrapper is cut off at the card's edge. It is placed from the
14
+ * textarea's measured rect instead — above it by default, below when the
15
+ * composer sits too close to the top of the window for the menu to open
16
+ * upward.
17
+ */
18
+
19
+ export interface MentionAnchor {
20
+ readonly top: number;
21
+ readonly bottom: number;
22
+ readonly left: number;
23
+ readonly width: number;
24
+ }
25
+
26
+ /** The menu's own height cap (max-h-48 = 12rem), in px. */
27
+ const MENU_MAX_PX = 192;
28
+ const MENU_GAP_PX = 4;
29
+
30
+ /** Measure the composer textarea for {@link MentionPicker}. Null closes the menu. */
31
+ export function mentionAnchorOf(element: HTMLElement | null): MentionAnchor | null {
32
+ if (element === null) return null;
33
+ const rect = element.getBoundingClientRect();
34
+ return { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width };
35
+ }
36
+
37
+ export function MentionPicker({
38
+ id,
39
+ people,
40
+ emptyNotice,
41
+ active,
42
+ anchor,
43
+ onPick,
44
+ onHover,
45
+ }: {
46
+ /** Id referenced by the focused textarea's aria-controls attribute. */
47
+ readonly id?: string;
48
+ readonly people: readonly MentionPerson[];
49
+ /** Shown as one non-selectable row when `people` is empty. */
50
+ readonly emptyNotice?: string | null;
51
+ /** Index of the arrow-focused row, or null for "nothing preselected". */
52
+ readonly active: number | null;
53
+ readonly anchor: MentionAnchor | null;
54
+ readonly onPick: (person: MentionPerson) => void;
55
+ readonly onHover: (index: number) => void;
56
+ }) {
57
+ if (anchor === null || typeof document === 'undefined') return null;
58
+ if (people.length === 0 && !emptyNotice) return null;
59
+ const above = anchor.top >= MENU_MAX_PX + MENU_GAP_PX;
60
+ const style = above
61
+ ? { bottom: window.innerHeight - anchor.top + MENU_GAP_PX }
62
+ : { top: anchor.bottom + MENU_GAP_PX };
63
+ return createPortal(
64
+ <div
65
+ id={id}
66
+ role="listbox"
67
+ aria-label="People"
68
+ data-mention-picker
69
+ data-placement={above ? 'above' : 'below'}
70
+ style={{ position: 'fixed', left: anchor.left, width: anchor.width, ...style }}
71
+ className="z-[120] max-h-48 overflow-y-auto rounded-md border border-border bg-popover p-1 shadow-xl"
72
+ >
73
+ {people.length === 0 ? (
74
+ <p data-mention-empty className="px-2 py-1 text-[11px] text-muted-foreground">
75
+ {emptyNotice}
76
+ </p>
77
+ ) : (
78
+ people.map((person, index) => (
79
+ <button
80
+ key={person.id}
81
+ type="button"
82
+ role="option"
83
+ id={id ? `${id}-option-${index}` : undefined}
84
+ aria-selected={active === index}
85
+ data-mention-option={person.id}
86
+ // The pick must run before the textarea loses focus, or the blur
87
+ // would close the menu and swallow the click.
88
+ onMouseDown={(event) => {
89
+ event.preventDefault();
90
+ onPick(person);
91
+ }}
92
+ onMouseEnter={() => onHover(index)}
93
+ className={`flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-left text-xs transition-colors ${
94
+ active === index ? 'bg-muted text-foreground' : 'text-foreground/85 hover:bg-muted/60'
95
+ }`}
96
+ >
97
+ <span className="min-w-0 flex-1 truncate">{person.label}</span>
98
+ {person.detail && (
99
+ // The NAME is what the person reads; the detail gives way first.
100
+ <span className="min-w-0 max-w-[44%] shrink-0 truncate text-[10px] text-muted-foreground">
101
+ {person.detail}
102
+ </span>
103
+ )}
104
+ </button>
105
+ ))
106
+ )}
107
+ </div>,
108
+ document.body,
109
+ );
110
+ }
@@ -0,0 +1,173 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import type { SelectionAction } from '../utils/selectionActions';
4
+
5
+ /**
6
+ * The host-actions dropdown: the same context-menu shape `QuickLabelDropdown`
7
+ * draws (single column, accent bar, full-width rows) and the same floating
8
+ * placement `FloatingQuickLabelPicker` computes, opened directly below the
9
+ * toolbar's wand button.
10
+ *
11
+ * Keyboard here is arrows + Enter + Escape rather than the quick labels'
12
+ * digit keys: a host's list is not a fixed ten with memorized numbers.
13
+ * Nothing is preselected until the first arrow, so a stray Enter while the
14
+ * dropdown is open never invokes a host command.
15
+ */
16
+
17
+ const PICKER_WIDTH = 224;
18
+ const GAP = 6;
19
+ const VIEWPORT_PADDING = 12;
20
+ /** Room the dropdown wants below the anchor before it flips above. */
21
+ const FLIP_THRESHOLD = 220;
22
+
23
+ function computePosition(anchorEl: HTMLElement): { top: number; left: number; flipAbove: boolean } {
24
+ const rect = anchorEl.getBoundingClientRect();
25
+ const flipAbove = window.innerHeight - rect.bottom < FLIP_THRESHOLD;
26
+ const top = flipAbove ? rect.top - GAP : rect.bottom + GAP;
27
+ let left = rect.left + rect.width / 2 - PICKER_WIDTH / 2;
28
+ left = Math.max(
29
+ VIEWPORT_PADDING,
30
+ Math.min(left, window.innerWidth - PICKER_WIDTH - VIEWPORT_PADDING),
31
+ );
32
+ return { top, left, flipAbove };
33
+ }
34
+
35
+ export const SelectionActionsDropdown: React.FC<{
36
+ actions: SelectionAction[];
37
+ activeIndex: number | null;
38
+ onSelect: (action: SelectionAction) => void;
39
+ onHover: (index: number) => void;
40
+ }> = ({ actions, activeIndex, onSelect, onHover }) => (
41
+ <div className="py-1" onMouseDown={(e) => e.stopPropagation()}>
42
+ {actions.map((action, index) => (
43
+ <button
44
+ key={action.id}
45
+ type="button"
46
+ role="option"
47
+ aria-selected={activeIndex === index}
48
+ data-selection-action={action.id}
49
+ onClick={() => onSelect(action)}
50
+ onMouseEnter={() => onHover(index)}
51
+ className={`group w-full flex items-center gap-2 px-2 py-[5px] text-left transition-colors ${
52
+ activeIndex === index ? 'bg-muted' : 'hover:bg-muted/60 active:bg-muted'
53
+ }`}
54
+ >
55
+ {action.icon ? (
56
+ <span className="flex-shrink-0 w-4 h-4 flex items-center justify-center text-muted-foreground">
57
+ {action.icon}
58
+ </span>
59
+ ) : (
60
+ <span className="w-[3px] self-stretch rounded-full flex-shrink-0 bg-primary/70" />
61
+ )}
62
+ <span className="min-w-0 flex-1">
63
+ <span className="block text-[11px] leading-tight text-foreground/85 group-hover:text-foreground truncate">
64
+ {action.label}
65
+ </span>
66
+ {action.detail && (
67
+ <span className="block text-[9px] leading-tight text-muted-foreground/70 truncate">
68
+ {action.detail}
69
+ </span>
70
+ )}
71
+ </span>
72
+ </button>
73
+ ))}
74
+ </div>
75
+ );
76
+
77
+ export const FloatingSelectionActionsPicker: React.FC<{
78
+ anchorEl: HTMLElement;
79
+ actions: SelectionAction[];
80
+ onSelect: (action: SelectionAction) => void;
81
+ onDismiss: () => void;
82
+ }> = ({ anchorEl, actions, onSelect, onDismiss }) => {
83
+ const [position, setPosition] = useState<{ top: number; left: number; flipAbove: boolean } | null>(null);
84
+ const [activeIndex, setActiveIndex] = useState<number | null>(null);
85
+ const ref = useRef<HTMLDivElement>(null);
86
+
87
+ useEffect(() => {
88
+ const update = () => setPosition(computePosition(anchorEl));
89
+ update();
90
+ window.addEventListener('scroll', update, true);
91
+ window.addEventListener('resize', update);
92
+ return () => {
93
+ window.removeEventListener('scroll', update, true);
94
+ window.removeEventListener('resize', update);
95
+ };
96
+ }, [anchorEl]);
97
+
98
+ useEffect(() => {
99
+ const handleKeyDown = (e: KeyboardEvent) => {
100
+ if (e.isComposing) return;
101
+ if (e.key === 'Escape') {
102
+ e.preventDefault();
103
+ onDismiss();
104
+ return;
105
+ }
106
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
107
+ e.preventDefault();
108
+ setActiveIndex((prev) => {
109
+ if (actions.length === 0) return null;
110
+ if (prev === null) return e.key === 'ArrowDown' ? 0 : actions.length - 1;
111
+ return e.key === 'ArrowDown'
112
+ ? (prev + 1) % actions.length
113
+ : (prev - 1 + actions.length) % actions.length;
114
+ });
115
+ return;
116
+ }
117
+ if (e.key === 'Enter') {
118
+ // Nothing preselected: Enter before the first arrow is not aimed here.
119
+ if (activeIndex === null) return;
120
+ e.preventDefault();
121
+ const action = actions[activeIndex];
122
+ if (action) onSelect(action);
123
+ }
124
+ };
125
+ window.addEventListener('keydown', handleKeyDown, true);
126
+ return () => window.removeEventListener('keydown', handleKeyDown, true);
127
+ }, [actions, activeIndex, onDismiss, onSelect]);
128
+
129
+ // Click outside dismisses. Deferred capture-phase registration, so the click
130
+ // that opened the dropdown does not immediately close it.
131
+ useEffect(() => {
132
+ const handlePointerDown = (e: PointerEvent) => {
133
+ if (ref.current && !ref.current.contains(e.target as Node)) onDismiss();
134
+ };
135
+ const timer = setTimeout(() => {
136
+ document.addEventListener('pointerdown', handlePointerDown, true);
137
+ }, 0);
138
+ return () => {
139
+ clearTimeout(timer);
140
+ document.removeEventListener('pointerdown', handlePointerDown, true);
141
+ };
142
+ }, [onDismiss]);
143
+
144
+ if (!position) return null;
145
+
146
+ return createPortal(
147
+ <div
148
+ ref={ref}
149
+ role="listbox"
150
+ aria-label="Actions"
151
+ data-selection-actions-picker
152
+ data-floating-picker="true"
153
+ className="fixed z-[100]"
154
+ style={{
155
+ top: position.top,
156
+ left: position.left,
157
+ width: PICKER_WIDTH,
158
+ ...(position.flipAbove ? { transform: 'translateY(-100%)' } : {}),
159
+ }}
160
+ onMouseDown={(e) => e.stopPropagation()}
161
+ >
162
+ <div className="bg-popover border border-border/60 rounded-lg shadow-xl overflow-hidden">
163
+ <SelectionActionsDropdown
164
+ actions={actions}
165
+ activeIndex={activeIndex}
166
+ onSelect={onSelect}
167
+ onHover={setActiveIndex}
168
+ />
169
+ </div>
170
+ </div>,
171
+ document.body,
172
+ );
173
+ };
@@ -59,6 +59,8 @@ import { DiagramBlockPending } from './diagram/DiagramPending';
59
59
  import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages';
60
60
  import { getIdentity } from '../utils/identity';
61
61
  import { type QuickLabel } from '../utils/quickLabels';
62
+ import type { SelectionAction } from '../utils/selectionActions';
63
+ import type { MentionSource } from '../utils/mentions';
62
64
  import { DocBadges, type DocBadgesProps, type LinkedDocBadgeInfo } from './DocBadges';
63
65
  import { PinpointOverlay } from './PinpointOverlay';
64
66
  import { usePinpoint } from '../hooks/usePinpoint';
@@ -91,6 +93,28 @@ export interface ViewerAnnotationHeaderConfig {
91
93
 
92
94
  /** Public properties for the Markdown document Viewer. */
93
95
  export interface ViewerProps {
96
+ /**
97
+ * Opt-in host capability, passed straight through to the selection
98
+ * toolbars: the host's own commands for the current selection, rendered as
99
+ * one wand button that opens the package's dropdown. Absent → unchanged.
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;
105
+ /**
106
+ * Whether the package's quick labels are offered on the selection toolbars
107
+ * (default true). `false` hides the Zap picker and the Alt+digit label
108
+ * shortcuts; the 👍 button is unaffected.
109
+ */
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;
94
118
  blocks: Block[];
95
119
  markdown: string;
96
120
  frontmatter?: Frontmatter | null;
@@ -360,6 +384,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
360
384
  mode,
361
385
  inputMethod = 'drag',
362
386
  taterMode,
387
+ selectionActions,
388
+ selectionActionsIcon,
389
+ quickLabels,
390
+ mentionSource,
363
391
  globalAttachments = [],
364
392
  onAddGlobalAttachment,
365
393
  onRemoveGlobalAttachment,
@@ -543,6 +571,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
543
571
  images?: ImageAttachment[],
544
572
  isQuickLabel?: boolean,
545
573
  quickLabelTip?: string,
574
+ mentions?: readonly string[],
546
575
  ) => {
547
576
  if (readOnlyRef.current) return;
548
577
 
@@ -562,6 +591,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
562
591
  createdA: Date.now(),
563
592
  author: getIdentity(),
564
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 } : {}),
565
597
  ...(isQuickLabel ? { isQuickLabel: true } : {}),
566
598
  ...(quickLabelTip ? { quickLabelTip } : {}),
567
599
  };
@@ -942,7 +974,11 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
942
974
  setCodeBlockToolbar(null);
943
975
  };
944
976
 
945
- const handleViewerCommentSubmit = (text: string, images?: ImageAttachment[]) => {
977
+ const handleViewerCommentSubmit = (
978
+ text: string,
979
+ images?: ImageAttachment[],
980
+ mentions?: readonly string[],
981
+ ) => {
946
982
  if (readOnlyRef.current || !viewerCommentPopover) return;
947
983
 
948
984
  if (viewerCommentPopover.isGlobal) {
@@ -960,12 +996,22 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
960
996
  createdA: Date.now(),
961
997
  author: getIdentity(),
962
998
  images,
999
+ ...(mentions && mentions.length > 0 ? { mentions } : {}),
963
1000
  };
964
1001
  onAddAnnotation(newAnnotation);
965
1002
  } else if (viewerCommentPopover.codeBlock) {
966
1003
  const codeEl = viewerCommentPopover.codeBlock.element.querySelector('code');
967
1004
  if (codeEl) {
968
- 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
+ );
969
1015
  }
970
1016
  }
971
1017
 
@@ -1296,6 +1342,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1296
1342
  onClose={handleToolbarClose}
1297
1343
  onRequestComment={handleRequestComment}
1298
1344
  onQuickLabel={handleQuickLabel}
1345
+ selectionActions={selectionActions}
1346
+ selectionActionsIcon={selectionActionsIcon}
1347
+ quickLabels={quickLabels}
1299
1348
  copyText={toolbarState.selectionText}
1300
1349
  hideCopyButton={!isTouchDevice}
1301
1350
  closeOnScrollOut
@@ -1351,6 +1400,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1351
1400
  onClose={handleCodeBlockToolbarClose}
1352
1401
  onRequestComment={handleCodeBlockRequestComment}
1353
1402
  onQuickLabel={handleCodeBlockQuickLabel}
1403
+ selectionActions={selectionActions}
1404
+ selectionActionsIcon={selectionActionsIcon}
1405
+ quickLabels={quickLabels}
1354
1406
  isExiting={isCodeBlockToolbarExiting}
1355
1407
  onMouseEnter={() => {
1356
1408
  if (hoverTimeoutRef.current) {
@@ -1428,6 +1480,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1428
1480
  draftKey={`plan:${commentDraftScope}:${hookCommentPopover.draftKey}`}
1429
1481
  onSubmit={hookCommentSubmit}
1430
1482
  onClose={hookCommentClose}
1483
+ mentionSource={mentionSource}
1431
1484
  allowImages={allowImages}
1432
1485
  skillReferences
1433
1486
  onAskAI={onAskAI}
@@ -1452,6 +1505,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1452
1505
  }`}
1453
1506
  onSubmit={handleViewerCommentSubmit}
1454
1507
  onClose={handleViewerCommentClose}
1508
+ mentionSource={mentionSource}
1455
1509
  allowImages={allowImages}
1456
1510
  skillReferences
1457
1511
  onAskAI={onAskAI}