@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
@@ -7,16 +7,10 @@ import { SparklesIcon } from './SparklesIcon';
7
7
  import { hasUnsavedCommentContent } from '../utils/commentContent';
8
8
  import { useSkillReferenceAutocomplete } from '../hooks/useSkillReferenceAutocomplete';
9
9
  import { HumanOnlySkillNotice, SkillReferenceMenu } from './SkillReferenceMenu';
10
- import type { SkillReferenceToken } from '../utils/skillReferences';
11
- import {
12
- mentionTokenRanges,
13
- mergeTokenRanges,
14
- skillTokenRanges,
15
- type ComposerTokenRange,
16
- } from '../utils/composerTokens';
10
+ import { ComposerTokenTextarea, type ComposerMentionChips } from './ComposerTokenTextarea';
17
11
  import { useMentionAutocomplete } from '../hooks/useMentionAutocomplete';
18
12
  import { MentionAutocompleteMenu, mentionActiveOptionId } from './MentionAutocomplete';
19
- import type { MentionPerson, MentionSource } from '../utils/mentions';
13
+ import type { MentionSource } from '../utils/mentions';
20
14
  import {
21
15
  hasPrimaryCoarsePointer,
22
16
  shouldUseExpandedComposer,
@@ -24,6 +18,8 @@ import {
24
18
  type VisibleViewportBounds,
25
19
  } from '../hooks/useViewportEnvironment';
26
20
 
21
+ export type { ComposerMentionChips } from './ComposerTokenTextarea';
22
+
27
23
  export interface CommentAskAIContext {
28
24
  kind: 'general' | 'selection';
29
25
  label?: string;
@@ -714,7 +710,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
714
710
  onSelect={skillAc.select}
715
711
  />
716
712
  )}
717
- <ComposerTextarea
713
+ <ComposerTokenTextarea
718
714
  textareaRef={focusOnMountRef}
719
715
  value={text}
720
716
  onChange={(e) => { setText(e.target.value); readComposerCaret(); }}
@@ -730,6 +726,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
730
726
  activeOptionId={activeComposerOptionId}
731
727
  />
732
728
  <HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
729
+ {mentionSource?.renderNotice?.(mentionChipPeople)}
733
730
  <MentionAutocompleteMenu
734
731
  id={mentionListboxId}
735
732
  menu={mentionAc.menu}
@@ -867,7 +864,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
867
864
  onSelect={skillAc.select}
868
865
  />
869
866
  )}
870
- <ComposerTextarea
867
+ <ComposerTokenTextarea
871
868
  textareaRef={focusOnMountRef}
872
869
  value={text}
873
870
  onChange={(e) => { setText(e.target.value); readComposerCaret(); }}
@@ -883,6 +880,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
883
880
  activeOptionId={activeComposerOptionId}
884
881
  />
885
882
  <HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
883
+ {mentionSource?.renderNotice?.(mentionChipPeople)}
886
884
  <MentionAutocompleteMenu
887
885
  id={mentionListboxId}
888
886
  menu={mentionAc.menu}
@@ -930,345 +928,6 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
930
928
  );
931
929
  };
932
930
 
933
- // ---------------------------------------------------------------------------
934
- // Composer textarea with skill-reference token highlighting
935
- // ---------------------------------------------------------------------------
936
-
937
- /** Classes shared by the textarea and its highlight mirror — font, size,
938
- * and box metrics MUST stay identical or the overlay drifts out of alignment. */
939
- const COMPOSER_TEXT_CLASSES = 'w-full bg-transparent text-sm px-1 py-0.5';
940
-
941
- /** Per-composer memo for the overlay gutter sync below. */
942
- interface OverlayGutterState {
943
- /** The overlay the applied value belongs to (a remount invalidates it). */
944
- overlay: HTMLElement | null;
945
- /** Last `offsetWidth - clientWidth` seen on the textarea. */
946
- raw: number;
947
- /** Extra right padding currently written inline on the overlay, in px. */
948
- applied: number;
949
- }
950
-
951
- const cssPx = (value: string): number => {
952
- const n = parseFloat(value);
953
- return Number.isFinite(n) ? n : 0;
954
- };
955
-
956
- /**
957
- * Keep the mirror's text column exactly as wide as the textarea's (#1525).
958
- *
959
- * A layout-consuming vertical scrollbar (Windows/Linux classic scrollbars; the
960
- * app's `scrollbar-width: thin` makes them ~12px) narrows the textarea's
961
- * content box once the composer hits its max height — but the overlay is
962
- * `overflow: hidden` and keeps its full width, so from the first line that
963
- * wraps differently the painted glyphs drift away from the native caret, which
964
- * is the one thing the textarea itself still draws. `scrollbar-gutter: stable`
965
- * cannot fix this: it is ignored on an `overflow: hidden` box. Mirroring the
966
- * measured scrollbar width onto the overlay's right padding makes both boxes
967
- * wrap identically again (and stops the overlay painting under the scrollbar).
968
- *
969
- * Overlay scrollbars (macOS, iOS, any touch surface) consume no layout, so the
970
- * measured width is 0 — and then this writes NOTHING at all, leaving the
971
- * class-driven padding untouched exactly as before the fix.
972
- */
973
- function syncOverlayGutter(
974
- el: HTMLTextAreaElement,
975
- overlay: HTMLElement,
976
- state: OverlayGutterState,
977
- raw: number,
978
- ): void {
979
- // Nothing measurable changed since the last sync — the common case.
980
- if (overlay === state.overlay && raw === state.raw) return;
981
- state.overlay = overlay;
982
- state.raw = raw;
983
-
984
- const clear = () => {
985
- if (state.applied === 0) return; // never wrote anything; leave the DOM alone
986
- overlay.style.removeProperty('padding-right');
987
- state.applied = 0;
988
- };
989
-
990
- const style = getComputedStyle(el);
991
- // `offsetWidth - clientWidth` is scrollbar + left/right borders. The borders
992
- // are shared with the overlay (both boxes are border-box), so only the
993
- // scrollbar part is the extra gutter the mirror is missing.
994
- const borders = cssPx(style.borderLeftWidth) + cssPx(style.borderRightWidth);
995
- const scrollbar = Math.max(0, raw - borders);
996
- if (scrollbar === 0) {
997
- clear();
998
- return;
999
- }
1000
- // Base padding is read from the textarea (never the overlay, whose computed
1001
- // value may already include a gutter we wrote), so the two can never drift
1002
- // apart if COMPOSER_TEXT_CLASSES changes.
1003
- const base = parseFloat(style.paddingRight);
1004
- if (!Number.isFinite(base)) {
1005
- clear(); // unmeasurable: fall back to the pre-fix behavior
1006
- return;
1007
- }
1008
- overlay.style.paddingRight = `${base + scrollbar}px`;
1009
- state.applied = scrollbar;
1010
- }
1011
-
1012
- /**
1013
- * The default chip look: the theme's primary at a wash, one shade stronger
1014
- * than a skill reference's so the two token kinds in one overlay read as
1015
- * siblings rather than the same thing. Deliberately no ring: every class here
1016
- * is one the package already emitted, so a host build's CSS (and the portable
1017
- * guide viewer's) is byte-identical to 0.43.2. A host that wants a pill adds
1018
- * `box-shadow: 0 0 0 Npx <background>` through `tokenClassName` — paint, not
1019
- * layout, per the metric rule above.
1020
- */
1021
- const MENTION_CHIP_CLASSES = 'text-primary bg-primary/15 rounded-[3px]';
1022
-
1023
- /**
1024
- * One token's span in the highlight overlay.
1025
- *
1026
- * METRIC RULE, load-bearing for every token kind: a span may change COLOR,
1027
- * BACKGROUND, BORDER-RADIUS, BOX-SHADOW and TEXT-DECORATION only. Anything
1028
- * that moves a glyph — padding, margin, border width, font-weight,
1029
- * letter-spacing, font-size — would shift the overlay's text off the
1030
- * textarea's own layout and drift the caret away from the painted glyphs. A
1031
- * pill's breathing room is faked with a paint-only `box-shadow` ring in the
1032
- * chip's own background color.
1033
- */
1034
- function renderTokenSpan(
1035
- range: ComposerTokenRange,
1036
- text: string,
1037
- mentionTokenClassName?: string,
1038
- ): React.ReactNode {
1039
- if (range.kind === 'mention') {
1040
- // `data-mention-token` / `data-mention-kind` are the host's styling hook;
1041
- // `tokenClassName` is appended verbatim and is the host's to keep
1042
- // metric-safe (see the rule above).
1043
- return (
1044
- <span
1045
- key={`mention-${range.start}`}
1046
- data-mention-token={range.person.id}
1047
- data-mention-kind={range.person.kind}
1048
- className={`${MENTION_CHIP_CLASSES}${
1049
- mentionTokenClassName ? ` ${mentionTokenClassName}` : ''
1050
- }`}
1051
- >
1052
- {text}
1053
- </span>
1054
- );
1055
- }
1056
- // Human-only tokens carry a quiet dotted underline as their inline marker
1057
- // (text-decoration never affects glyph layout, so overlay alignment is
1058
- // safe). The accessible explanation lives in HumanOnlySkillNotice below
1059
- // the textarea — this overlay is aria-hidden.
1060
- return (
1061
- <span
1062
- key={`skill-${range.start}`}
1063
- data-skill-ref-token={range.skill.entry.name}
1064
- data-skill-ref-human-only={range.skill.entry.humanOnly ? 'true' : undefined}
1065
- className={`text-primary bg-primary/10 rounded-[3px] ${
1066
- range.skill.entry.humanOnly
1067
- ? 'underline decoration-dotted decoration-primary/60 underline-offset-2'
1068
- : ''
1069
- }`}
1070
- >
1071
- {text}
1072
- </span>
1073
- );
1074
- }
1075
-
1076
- /** The mention half of the highlight layer. Null → no mention source at all. */
1077
- export interface ComposerMentionChips {
1078
- /** The people whose `@Label` token still survives in the text. */
1079
- readonly people: readonly MentionPerson[];
1080
- /** Host class appended to each chip (see `MentionSource.tokenClassName`). */
1081
- readonly tokenClassName?: string;
1082
- }
1083
-
1084
- interface ComposerTextareaProps {
1085
- value: string;
1086
- onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
1087
- onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
1088
- /** Caret observer (skill autocomplete). */
1089
- onSelectCaret: () => void;
1090
- placeholder: string;
1091
- /** Mode-specific min/max height classes. */
1092
- sizeClassName: string;
1093
- textareaRef: (el: HTMLTextAreaElement | null) => void;
1094
- /** Positioned skill-reference occurrences to highlight. */
1095
- tokens: SkillReferenceToken[];
1096
- /** Off → no skill-reference contribution to the highlight layer. */
1097
- skillReferences: boolean;
1098
- /**
1099
- * The mention contribution, or null with no `mentionSource`. Present (even
1100
- * with nobody tagged yet) it turns the overlay on, so the first pick paints
1101
- * a chip without swapping the textarea for a different element.
1102
- */
1103
- mentionChips: ComposerMentionChips | null;
1104
- /** ARIA relationship to the skill-reference listbox. */
1105
- listboxId: string;
1106
- listboxOpen: boolean;
1107
- activeOptionId?: string;
1108
- }
1109
-
1110
- /**
1111
- * The composer's textarea. With NEITHER token source active this is exactly
1112
- * the pre-feature `<textarea>`; with either one on, its tokens are painted by
1113
- * a mirrored, aria-hidden overlay rendered BEHIND a transparent-text textarea
1114
- * (a textarea cannot style substrings). ONE overlay serves both sources: two
1115
- * mirrored layers could never stay pixel-aligned with each other, and only
1116
- * one of them could own the scroll sync. The overlay shares the exact
1117
- * font/padding/wrapping metrics and mirrors scroll position, and token spans
1118
- * obey the metric rule on `renderTokenSpan` (paint only, never layout), so
1119
- * the glyphs the browser lays out in the textarea and the glyphs the overlay
1120
- * paints coincide. During IME composition the overlay hides and the textarea
1121
- * text becomes visible again (`.pn-ref-composing`), keeping native
1122
- * composition rendering (underlines, candidate highlights) intact.
1123
- */
1124
- const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
1125
- value,
1126
- onChange,
1127
- onKeyDown,
1128
- onSelectCaret,
1129
- placeholder,
1130
- sizeClassName,
1131
- textareaRef,
1132
- tokens,
1133
- skillReferences,
1134
- mentionChips,
1135
- listboxId,
1136
- listboxOpen,
1137
- activeOptionId,
1138
- }) => {
1139
- const overlayRef = useRef<HTMLDivElement>(null);
1140
- const [composing, setComposing] = useState(false);
1141
- const gutterRef = useRef<OverlayGutterState>({ overlay: null, raw: 0, applied: 0 });
1142
-
1143
- const syncScroll = useCallback((el: HTMLTextAreaElement) => {
1144
- const overlay = overlayRef.current;
1145
- if (!overlay) return;
1146
- // Reads before writes so a sync costs at most one forced layout.
1147
- const { scrollTop, scrollLeft } = el;
1148
- const raw = el.offsetWidth - el.clientWidth;
1149
- overlay.scrollTop = scrollTop;
1150
- overlay.scrollLeft = scrollLeft;
1151
- syncOverlayGutter(el, overlay, gutterRef.current, raw);
1152
- }, []);
1153
-
1154
- const innerRef = useRef<HTMLTextAreaElement | null>(null);
1155
- const attachRef = useCallback(
1156
- (el: HTMLTextAreaElement | null) => {
1157
- innerRef.current = el;
1158
- textareaRef(el);
1159
- },
1160
- [textareaRef],
1161
- );
1162
-
1163
- // The overlay exists while EITHER source is active. `mentionChips` turns it
1164
- // on for the whole life of a mention composer, not only once somebody is
1165
- // tagged, so a pick never swaps the textarea element under the caret.
1166
- const mentionPeople = mentionChips?.people;
1167
- const highlight = skillReferences || mentionChips !== null;
1168
-
1169
- // The one list of spans the overlay paints, from every active source.
1170
- // Skill references are the earlier group, so they win a byte both claim.
1171
- const ranges = useMemo(
1172
- () =>
1173
- mergeTokenRanges(value, [
1174
- skillReferences ? skillTokenRanges(tokens) : [],
1175
- mentionPeople ? mentionTokenRanges(value, mentionPeople) : [],
1176
- ]),
1177
- [value, tokens, skillReferences, mentionPeople],
1178
- );
1179
-
1180
- // Keep the mirror aligned when the value changes without a scroll event
1181
- // (e.g. programmatic insertion moving the caret into a scrolled region).
1182
- useEffect(() => {
1183
- if (innerRef.current) syncScroll(innerRef.current);
1184
- }, [value, syncScroll]);
1185
-
1186
- // `syncScroll` otherwise only runs on scroll and on value change, so a
1187
- // window/panel resize that grows or drops the textarea's scrollbar without a
1188
- // keystroke would leave the gutter stale until the next one.
1189
- useEffect(() => {
1190
- const el = innerRef.current;
1191
- if (!el || typeof ResizeObserver === 'undefined') return;
1192
- const observer = new ResizeObserver(() => {
1193
- if (innerRef.current) syncScroll(innerRef.current);
1194
- });
1195
- observer.observe(el);
1196
- return () => observer.disconnect();
1197
- }, [highlight, syncScroll]);
1198
-
1199
- if (!highlight) {
1200
- return (
1201
- <textarea
1202
- data-pn-mobile-editable="true"
1203
- // Absent any open menu these are all undefined, so the plain textarea
1204
- // renders exactly the attributes it always did.
1205
- aria-autocomplete={listboxOpen ? 'list' : undefined}
1206
- aria-controls={listboxOpen ? listboxId : undefined}
1207
- aria-activedescendant={activeOptionId}
1208
- ref={attachRef}
1209
- value={value}
1210
- onChange={onChange}
1211
- onKeyDown={onKeyDown}
1212
- onSelect={onSelectCaret}
1213
- placeholder={placeholder}
1214
- className={`${COMPOSER_TEXT_CLASSES} placeholder:text-muted-foreground resize-none focus:outline-none ${sizeClassName}`}
1215
- style={{ fieldSizing: 'content' } as React.CSSProperties}
1216
- />
1217
- );
1218
- }
1219
-
1220
- const segments: React.ReactNode[] = [];
1221
- let pos = 0;
1222
- for (const range of ranges) {
1223
- if (range.start > pos) segments.push(value.slice(pos, range.start));
1224
- segments.push(
1225
- renderTokenSpan(range, value.slice(range.start, range.end), mentionChips?.tokenClassName),
1226
- );
1227
- pos = range.end;
1228
- }
1229
- segments.push(value.slice(pos));
1230
-
1231
- return (
1232
- <div className="relative">
1233
- <div
1234
- ref={overlayRef}
1235
- aria-hidden="true"
1236
- // Names the SKILL-reference layer, so a skill composer's overlay is
1237
- // byte-identical to the one it rendered before mentions existed; a
1238
- // mentions-only overlay is found by its mirror attribute below.
1239
- data-skill-ref-overlay={skillReferences ? 'true' : undefined}
1240
- data-pn-mobile-editable-mirror="true"
1241
- className={`${COMPOSER_TEXT_CLASSES} ${sizeClassName} pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words`}
1242
- style={composing ? { visibility: 'hidden' } : undefined}
1243
- >
1244
- {segments}
1245
- {'\n'}
1246
- </div>
1247
- <textarea
1248
- data-pn-mobile-editable="true"
1249
- aria-label={placeholder}
1250
- aria-autocomplete="list"
1251
- aria-haspopup="listbox"
1252
- aria-controls={listboxOpen ? listboxId : undefined}
1253
- aria-owns={listboxOpen ? listboxId : undefined}
1254
- aria-activedescendant={activeOptionId}
1255
- ref={attachRef}
1256
- value={value}
1257
- onChange={onChange}
1258
- onKeyDown={onKeyDown}
1259
- onSelect={onSelectCaret}
1260
- onScroll={(e) => syncScroll(e.currentTarget)}
1261
- onCompositionStart={() => setComposing(true)}
1262
- onCompositionEnd={() => setComposing(false)}
1263
- placeholder={placeholder}
1264
- className={`${COMPOSER_TEXT_CLASSES} placeholder:text-muted-foreground resize-none focus:outline-none relative pn-ref-input ${
1265
- composing ? 'pn-ref-composing' : ''
1266
- } ${sizeClassName}`}
1267
- style={{ fieldSizing: 'content' } as React.CSSProperties}
1268
- />
1269
- </div>
1270
- );
1271
- };
1272
931
 
1273
932
  // Icons
1274
933