@plannotator/ui 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/HANDOFF.md +140 -10
  2. package/README.md +9 -5
  3. package/components/AnnotationPanel.tsx +244 -13
  4. package/components/CommentPopover.tsx +91 -2
  5. package/components/DiagramBlock.tsx +376 -0
  6. package/components/GraphvizBlock.tsx +22 -597
  7. package/components/HtmlSurfaceControls.tsx +188 -23
  8. package/components/ListMarker.tsx +10 -1
  9. package/components/MermaidBlock.tsx +17 -630
  10. package/components/TableOfContents.tsx +5 -1
  11. package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
  12. package/components/Viewer.tsx +67 -3
  13. package/components/blocks/AlertBlock.tsx +7 -2
  14. package/components/diagram/DiagramCanvas.tsx +431 -0
  15. package/components/diagram/DiagramComposer.tsx +135 -0
  16. package/components/diagram/DiagramOverlay.tsx +215 -0
  17. package/components/diagram/DiagramPopout.tsx +70 -0
  18. package/components/diagram/DiagramSourcePane.tsx +244 -0
  19. package/components/diagram/DiagramViewer.tsx +276 -0
  20. package/components/diagram/anchorClaims.ts +71 -0
  21. package/components/diagram/index.ts +36 -0
  22. package/components/diagram/useDiagramComments.ts +341 -0
  23. package/components/diagram/useDiagramRender.ts +91 -0
  24. package/components/diagram/useDiagramSourceDraft.ts +143 -0
  25. package/components/diagram/useDiagramViewport.ts +156 -0
  26. package/components/html-viewer/HtmlViewer.tsx +32 -0
  27. package/components/html-viewer/bridge-script.asset.js +121 -9
  28. package/components/html-viewer/bridge-script.lite.ts +1 -1
  29. package/components/html-viewer/bridge-script.ts +133 -9
  30. package/components/html-viewer/useHtmlAnnotation.ts +44 -151
  31. package/hooks/useAnnotationHighlighter.ts +469 -14
  32. package/hooks/useLinkedDoc.ts +100 -9
  33. package/package.json +6 -4
  34. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
  35. package/styles.css +1 -1
  36. package/theme.css +62 -0
  37. package/types.ts +5 -0
  38. package/utils/annotationScope.ts +159 -0
  39. package/utils/cssColor.ts +463 -0
  40. package/utils/diagram-anchor-graphviz.ts +143 -0
  41. package/utils/diagram-anchor.ts +401 -0
  42. package/utils/diagram-projection.ts +66 -0
  43. package/utils/diagram-render.ts +668 -0
  44. package/utils/graphviz.ts +93 -0
  45. package/utils/htmlChrome.ts +70 -5
  46. package/utils/htmlLinkNavigation.ts +196 -0
  47. package/utils/mermaid-eager.ts +13 -11
  48. package/utils/mermaid.ts +19 -10
  49. package/utils/mermaidTheme.ts +732 -0
  50. package/utils/parser.ts +36 -7
  51. package/utils/terminalToolsAnnouncement.ts +76 -0
  52. package/components/mermaidSvg.ts +0 -33
@@ -8,6 +8,8 @@ import { OverlayScrollArea } from './OverlayScrollArea';
8
8
  import { Button } from './ui/button';
9
9
  import { cn } from '../lib/utils';
10
10
  import { resolveReplyParents, resolveThreadRootTimestamps } from '@plannotator/core/annotation-threads';
11
+ import { ROOT_DOCUMENT_GROUP_KEY } from '../utils/annotationScope';
12
+ import type { AnnotationScope, AnnotationDocumentGroup } from '../utils/annotationScope';
11
13
 
12
14
  // Card type-word colors. Deletion uses `destructive` (reliably red on every
13
15
  // theme, matching the in-document .deletion highlight). Comment uses the
@@ -137,6 +139,26 @@ interface PanelProps {
137
139
  * HTML viewer's onUnanchoredChange report after a refresh). Matching
138
140
  * cards show a small "Unanchored" chip. Absent: no chip, DOM unchanged. */
139
141
  unanchoredIds?: ReadonlySet<string>;
142
+ /**
143
+ * Multi-document sessions (annotate folder sessions, and any session whose
144
+ * linked documents carry feedback): which documents the timeline shows.
145
+ * Supplying this together with `onAnnotationScopeChange` turns on the
146
+ * `This file | All files` toggle and the grouped cross-file view. Omitting
147
+ * them — as every host that has not opted in does — leaves the panel exactly
148
+ * as it was, including the legacy `+N in M other files` affordance.
149
+ */
150
+ annotationScope?: AnnotationScope;
151
+ onAnnotationScopeChange?: (scope: AnnotationScope) => void;
152
+ /** One group per document carrying feedback, open document first (see
153
+ * `groupAnnotationsByDocument`). Only read while the scope is `all`. */
154
+ documentGroups?: readonly AnnotationDocumentGroup[];
155
+ /** Select/jump to an annotation by its owning document. A card in another
156
+ * document is the host's cue to navigate there first. */
157
+ onSelectInDocument?: (path: string, id: string) => void;
158
+ /** Delete an annotation from its owning document's store. */
159
+ onDeleteInDocument?: (path: string, id: string) => void;
160
+ /** Edit an annotation in its owning document's store. */
161
+ onEditInDocument?: (path: string, id: string, updates: Partial<Annotation>) => void;
140
162
  }
141
163
 
142
164
  export const AnnotationPanel: React.FC<PanelProps> = ({
@@ -164,6 +186,12 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
164
186
  readOnly = false,
165
187
  presentation = 'panel',
166
188
  unanchoredIds,
189
+ annotationScope,
190
+ onAnnotationScopeChange,
191
+ documentGroups,
192
+ onSelectInDocument,
193
+ onDeleteInDocument,
194
+ onEditInDocument,
167
195
  }) => {
168
196
  const isMobile = useIsMobile();
169
197
  const embedded = presentation === 'embedded';
@@ -190,6 +218,54 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
190
218
  });
191
219
  const totalCount = annotations.length + codeAnnotations.length + (editorAnnotations?.length ?? 0);
192
220
 
221
+ // --- Cross-file scope (opt-in: a host that passes neither prop is unchanged) ---
222
+ const scopeEnabled = annotationScope !== undefined && onAnnotationScopeChange !== undefined;
223
+ const scope: AnnotationScope = scopeEnabled ? annotationScope! : 'current';
224
+ const groups = documentGroups ?? [];
225
+ const otherGroups = groups.filter((group) => !group.isCurrent);
226
+ const otherGroupCount = otherGroups.reduce((n, group) => n + group.annotations.length, 0);
227
+ const groupedTotal = groups.reduce((n, group) => n + group.annotations.length, 0);
228
+ const showGroups = scope === 'all' && groups.length > 0;
229
+ const headerCount = showGroups
230
+ ? groupedTotal + codeAnnotations.length + (editorAnnotations?.length ?? 0)
231
+ : totalCount;
232
+ const [collapsedGroups, setCollapsedGroups] = useState<ReadonlySet<string>>(() => new Set());
233
+ const toggleGroup = (path: string) => {
234
+ setCollapsedGroups((prev) => {
235
+ const next = new Set(prev);
236
+ if (next.has(path)) next.delete(path);
237
+ else next.add(path);
238
+ return next;
239
+ });
240
+ };
241
+ const scopeToggle = scopeEnabled ? (
242
+ <div
243
+ data-annotation-scope-toggle="true"
244
+ role="group"
245
+ aria-label="Annotation scope"
246
+ className="flex items-center gap-0.5 rounded-md bg-surface-1/60 p-0.5"
247
+ >
248
+ {(['current', 'all'] as const).map((value) => (
249
+ <button
250
+ key={value}
251
+ type="button"
252
+ data-pn-touch-target="true"
253
+ data-annotation-scope={value}
254
+ aria-pressed={scope === value}
255
+ onClick={() => onAnnotationScopeChange!(value)}
256
+ className={cn(
257
+ 'cursor-pointer rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors',
258
+ scope === value
259
+ ? 'bg-card text-foreground shadow-sm'
260
+ : 'text-muted-foreground hover:text-foreground',
261
+ )}
262
+ >
263
+ {value === 'current' ? 'This file' : 'All files'}
264
+ </button>
265
+ ))}
266
+ </div>
267
+ ) : null;
268
+
193
269
  // Scroll selected annotation card into view
194
270
  useEffect(() => {
195
271
  if (!selectedId || !listRef.current) return;
@@ -218,9 +294,9 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
218
294
  <h2 className="text-xs font-medium text-foreground">
219
295
  Annotations
220
296
  </h2>
221
- {totalCount > 0 && (
297
+ {headerCount > 0 && (
222
298
  <span className="flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-primary/10 px-1 font-mono text-[10px] font-medium tabular-nums text-primary">
223
- {totalCount}
299
+ {headerCount}
224
300
  </span>
225
301
  )}
226
302
  </div>
@@ -237,19 +313,39 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
237
313
  </button>
238
314
  )}
239
315
  </div>
240
- {otherFileAnnotations && otherFileAnnotations.count > 0 && (
241
- <button
242
- onClick={onOtherFileAnnotationsClick}
243
- className="px-3 pb-2 text-[10px] text-primary/70 hover:text-primary transition-colors cursor-pointer"
244
- title="Show annotated files in sidebar"
245
- >
246
- +{otherFileAnnotations.count} in {otherFileAnnotations.files} other file{otherFileAnnotations.files === 1 ? '' : 's'}
247
- </button>
316
+ {scopeEnabled ? (
317
+ <div className="flex items-center justify-between gap-2 px-3 pb-2">
318
+ {scopeToggle}
319
+ {otherGroupCount > 0 && scope === 'current' && (
320
+ <span className="truncate text-[10px] text-muted-foreground/70">
321
+ +{otherGroupCount} elsewhere
322
+ </span>
323
+ )}
324
+ </div>
325
+ ) : (
326
+ otherFileAnnotations && otherFileAnnotations.count > 0 && (
327
+ <button
328
+ onClick={onOtherFileAnnotationsClick}
329
+ className="px-3 pb-2 text-[10px] text-primary/70 hover:text-primary transition-colors cursor-pointer"
330
+ title="Show annotated files in sidebar"
331
+ >
332
+ +{otherFileAnnotations.count} in {otherFileAnnotations.files} other file{otherFileAnnotations.files === 1 ? '' : 's'}
333
+ </button>
334
+ )
335
+ )}
336
+ </div>
337
+ )}
338
+
339
+ {embedded && scopeEnabled && (
340
+ <div className="flex min-h-11 flex-shrink-0 items-center justify-between gap-2 border-b border-border/50 px-3">
341
+ {scopeToggle}
342
+ {otherGroupCount > 0 && scope === 'current' && (
343
+ <span className="truncate text-[10px] text-muted-foreground/70">+{otherGroupCount} elsewhere</span>
248
344
  )}
249
345
  </div>
250
346
  )}
251
347
 
252
- {embedded && otherFileAnnotations && otherFileAnnotations.count > 0 && (
348
+ {embedded && !scopeEnabled && otherFileAnnotations && otherFileAnnotations.count > 0 && (
253
349
  <button
254
350
  type="button"
255
351
  data-pn-touch-target="true"
@@ -267,7 +363,131 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
267
363
  {directEdits?.map((item) => (
268
364
  <DirectEditsCard key={item.id} {...item} onDiscard={readOnly ? undefined : item.onDiscard} />
269
365
  ))}
270
- {totalCount === 0 ? (
366
+ {showGroups ? (
367
+ <>
368
+ {groups.map((group) => {
369
+ const collapsed = collapsedGroups.has(group.path);
370
+ const sorted = [...group.annotations].sort((a, b) => a.createdA - b.createdA);
371
+ const threaded = threadReplies(sorted);
372
+ return (
373
+ <section key={group.path} data-annotation-group={group.path}>
374
+ <button
375
+ type="button"
376
+ data-pn-touch-target="true"
377
+ onClick={() => toggleGroup(group.path)}
378
+ aria-expanded={!collapsed}
379
+ className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1 text-left transition-colors hover:bg-surface-1/50"
380
+ >
381
+ <svg
382
+ className={cn('h-2.5 w-2.5 flex-shrink-0 text-muted-foreground/60 transition-transform', collapsed ? '' : 'rotate-90')}
383
+ fill="none"
384
+ viewBox="0 0 24 24"
385
+ stroke="currentColor"
386
+ strokeWidth={3}
387
+ >
388
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
389
+ </svg>
390
+ <span
391
+ className={cn('min-w-0 flex-1 truncate font-mono text-[10px]', group.isCurrent ? 'text-foreground' : 'text-muted-foreground')}
392
+ // The pathless root document (plan review) is keyed by a
393
+ // sentinel, which is not something to show a reader.
394
+ title={group.path === ROOT_DOCUMENT_GROUP_KEY ? group.label : group.path}
395
+ >
396
+ {group.label}
397
+ </span>
398
+ {group.isCurrent && (
399
+ <span className="flex-shrink-0 text-[9px] text-muted-foreground/50">open</span>
400
+ )}
401
+ <span className="flex h-[16px] min-w-[16px] flex-shrink-0 items-center justify-center rounded-full bg-primary/10 px-1 font-mono text-[9px] font-medium tabular-nums text-primary">
402
+ {group.annotations.length}
403
+ </span>
404
+ </button>
405
+ {!collapsed && (
406
+ <div className="mt-0.5 flex flex-col gap-1.5">
407
+ {threaded.map(({ annotation, isReply }) => {
408
+ const card = (
409
+ <AnnotationCard
410
+ annotation={annotation}
411
+ isSelected={selectedId === annotation.id}
412
+ isMe={isCurrentUser(annotation.author)}
413
+ onSelect={() => (group.isCurrent
414
+ ? onSelect(annotation.id)
415
+ : onSelectInDocument?.(group.path, annotation.id))}
416
+ onDelete={() => (group.isCurrent
417
+ ? onDelete(annotation.id)
418
+ : onDeleteInDocument?.(group.path, annotation.id))}
419
+ onEdit={group.isCurrent
420
+ ? (onEdit ? (updates: Partial<Annotation>) => onEdit(annotation.id, updates) : undefined)
421
+ : (onEditInDocument ? (updates: Partial<Annotation>) => onEditInDocument(group.path, annotation.id, updates) : undefined)}
422
+ readOnly={readOnly}
423
+ footer={group.isCurrent ? renderCardFooter?.(annotation) : undefined}
424
+ unanchored={group.isCurrent ? (unanchoredIds?.has(annotation.id) ?? false) : false}
425
+ />
426
+ );
427
+ return isReply ? (
428
+ <div key={annotation.id} data-annotation-reply="true" className="ml-3 border-l-2 border-border/40 pl-1.5">
429
+ {card}
430
+ </div>
431
+ ) : (
432
+ <React.Fragment key={annotation.id}>{card}</React.Fragment>
433
+ );
434
+ })}
435
+ </div>
436
+ )}
437
+ </section>
438
+ );
439
+ })}
440
+ {sortedCodeAnnotations.length > 0 && (
441
+ <>
442
+ <div className="flex items-center gap-2 pt-2 pb-1">
443
+ <div className="flex-1 border-t border-border/30" />
444
+ <span className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/60">Code</span>
445
+ <div className="flex-1 border-t border-border/30" />
446
+ </div>
447
+ {sortedCodeAnnotations.map((annotation) => (
448
+ <CodeAnnotationCard
449
+ key={annotation.id}
450
+ annotation={annotation}
451
+ isSelected={selectedId === annotation.id}
452
+ isMe={isCurrentUser(annotation.author)}
453
+ onSelect={() => onSelectCodeAnnotation?.(annotation.id)}
454
+ onDelete={() => onDeleteCodeAnnotation?.(annotation.id)}
455
+ onEdit={onEditCodeAnnotation ? (updates: Partial<CodeAnnotation>) => onEditCodeAnnotation(annotation.id, updates) : undefined}
456
+ readOnly={readOnly}
457
+ />
458
+ ))}
459
+ </>
460
+ )}
461
+ {editorAnnotations && editorAnnotations.length > 0 && (
462
+ <>
463
+ <div className="flex items-center gap-2 pt-2 pb-1">
464
+ <div className="flex-1 border-t border-border/30" />
465
+ <span className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/60">Editor</span>
466
+ <div className="flex-1 border-t border-border/30" />
467
+ </div>
468
+ {editorAnnotations.map(ann => (
469
+ <EditorAnnotationCard
470
+ key={ann.id}
471
+ annotation={ann}
472
+ onDelete={readOnly ? undefined : () => onDeleteEditorAnnotation?.(ann.id)}
473
+ />
474
+ ))}
475
+ </>
476
+ )}
477
+ {otherGroups.length > 0 && onOtherFileAnnotationsClick && (
478
+ <button
479
+ type="button"
480
+ data-pn-touch-target="true"
481
+ data-annotation-show-in-files="true"
482
+ onClick={onOtherFileAnnotationsClick}
483
+ className="mt-1 cursor-pointer self-start px-1.5 text-[10px] text-muted-foreground/60 transition-colors hover:text-primary"
484
+ title="Show annotated files in the navigator"
485
+ >
486
+ Show in files
487
+ </button>
488
+ )}
489
+ </>
490
+ ) : totalCount === 0 ? (
271
491
  (!directEdits || directEdits.length === 0) && (
272
492
  <div className="flex flex-col items-center justify-center px-4 py-16 text-center">
273
493
  <p className="text-xs text-muted-foreground/60">
@@ -276,6 +496,17 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
276
496
  <p className="mt-1 text-[11px] text-muted-foreground/40">
277
497
  Select text to annotate
278
498
  </p>
499
+ {scopeEnabled && otherGroupCount > 0 && (
500
+ <button
501
+ type="button"
502
+ data-pn-touch-target="true"
503
+ data-annotation-view-all="true"
504
+ onClick={() => onAnnotationScopeChange!('all')}
505
+ className="mt-3 cursor-pointer rounded-md px-2 py-1 text-[11px] text-primary/80 transition-colors hover:bg-surface-1 hover:text-primary"
506
+ >
507
+ View all {otherGroupCount} in {otherGroups.length} other file{otherGroups.length === 1 ? '' : 's'}
508
+ </button>
509
+ )}
279
510
  </div>
280
511
  )
281
512
  ) : (
@@ -352,7 +583,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
352
583
  </OverlayScrollArea>
353
584
 
354
585
  {/* Quick Actions Footer */}
355
- {totalCount > 0 && (
586
+ {headerCount > 0 && (
356
587
  <div className="border-t border-border/50 px-3 py-2 flex gap-1.5">
357
588
  {onQuickCopy && (
358
589
  <button
@@ -849,6 +849,77 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
849
849
  * and box metrics MUST stay identical or the overlay drifts out of alignment. */
850
850
  const COMPOSER_TEXT_CLASSES = 'w-full bg-transparent text-sm px-1 py-0.5';
851
851
 
852
+ /** Per-composer memo for the overlay gutter sync below. */
853
+ interface OverlayGutterState {
854
+ /** The overlay the applied value belongs to (a remount invalidates it). */
855
+ overlay: HTMLElement | null;
856
+ /** Last `offsetWidth - clientWidth` seen on the textarea. */
857
+ raw: number;
858
+ /** Extra right padding currently written inline on the overlay, in px. */
859
+ applied: number;
860
+ }
861
+
862
+ const cssPx = (value: string): number => {
863
+ const n = parseFloat(value);
864
+ return Number.isFinite(n) ? n : 0;
865
+ };
866
+
867
+ /**
868
+ * Keep the mirror's text column exactly as wide as the textarea's (#1525).
869
+ *
870
+ * A layout-consuming vertical scrollbar (Windows/Linux classic scrollbars; the
871
+ * app's `scrollbar-width: thin` makes them ~12px) narrows the textarea's
872
+ * content box once the composer hits its max height — but the overlay is
873
+ * `overflow: hidden` and keeps its full width, so from the first line that
874
+ * wraps differently the painted glyphs drift away from the native caret, which
875
+ * is the one thing the textarea itself still draws. `scrollbar-gutter: stable`
876
+ * cannot fix this: it is ignored on an `overflow: hidden` box. Mirroring the
877
+ * measured scrollbar width onto the overlay's right padding makes both boxes
878
+ * wrap identically again (and stops the overlay painting under the scrollbar).
879
+ *
880
+ * Overlay scrollbars (macOS, iOS, any touch surface) consume no layout, so the
881
+ * measured width is 0 — and then this writes NOTHING at all, leaving the
882
+ * class-driven padding untouched exactly as before the fix.
883
+ */
884
+ function syncOverlayGutter(
885
+ el: HTMLTextAreaElement,
886
+ overlay: HTMLElement,
887
+ state: OverlayGutterState,
888
+ raw: number,
889
+ ): void {
890
+ // Nothing measurable changed since the last sync — the common case.
891
+ if (overlay === state.overlay && raw === state.raw) return;
892
+ state.overlay = overlay;
893
+ state.raw = raw;
894
+
895
+ const clear = () => {
896
+ if (state.applied === 0) return; // never wrote anything; leave the DOM alone
897
+ overlay.style.removeProperty('padding-right');
898
+ state.applied = 0;
899
+ };
900
+
901
+ const style = getComputedStyle(el);
902
+ // `offsetWidth - clientWidth` is scrollbar + left/right borders. The borders
903
+ // are shared with the overlay (both boxes are border-box), so only the
904
+ // scrollbar part is the extra gutter the mirror is missing.
905
+ const borders = cssPx(style.borderLeftWidth) + cssPx(style.borderRightWidth);
906
+ const scrollbar = Math.max(0, raw - borders);
907
+ if (scrollbar === 0) {
908
+ clear();
909
+ return;
910
+ }
911
+ // Base padding is read from the textarea (never the overlay, whose computed
912
+ // value may already include a gutter we wrote), so the two can never drift
913
+ // apart if COMPOSER_TEXT_CLASSES changes.
914
+ const base = parseFloat(style.paddingRight);
915
+ if (!Number.isFinite(base)) {
916
+ clear(); // unmeasurable: fall back to the pre-fix behavior
917
+ return;
918
+ }
919
+ overlay.style.paddingRight = `${base + scrollbar}px`;
920
+ state.applied = scrollbar;
921
+ }
922
+
852
923
  interface ComposerTextareaProps {
853
924
  value: string;
854
925
  onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
@@ -897,12 +968,17 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
897
968
  }) => {
898
969
  const overlayRef = useRef<HTMLDivElement>(null);
899
970
  const [composing, setComposing] = useState(false);
971
+ const gutterRef = useRef<OverlayGutterState>({ overlay: null, raw: 0, applied: 0 });
900
972
 
901
973
  const syncScroll = useCallback((el: HTMLTextAreaElement) => {
902
974
  const overlay = overlayRef.current;
903
975
  if (!overlay) return;
904
- overlay.scrollTop = el.scrollTop;
905
- overlay.scrollLeft = el.scrollLeft;
976
+ // Reads before writes so a sync costs at most one forced layout.
977
+ const { scrollTop, scrollLeft } = el;
978
+ const raw = el.offsetWidth - el.clientWidth;
979
+ overlay.scrollTop = scrollTop;
980
+ overlay.scrollLeft = scrollLeft;
981
+ syncOverlayGutter(el, overlay, gutterRef.current, raw);
906
982
  }, []);
907
983
 
908
984
  const innerRef = useRef<HTMLTextAreaElement | null>(null);
@@ -920,6 +996,19 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
920
996
  if (innerRef.current) syncScroll(innerRef.current);
921
997
  }, [value, syncScroll]);
922
998
 
999
+ // `syncScroll` otherwise only runs on scroll and on value change, so a
1000
+ // window/panel resize that grows or drops the textarea's scrollbar without a
1001
+ // keystroke would leave the gutter stale until the next one.
1002
+ useEffect(() => {
1003
+ const el = innerRef.current;
1004
+ if (!el || typeof ResizeObserver === 'undefined') return;
1005
+ const observer = new ResizeObserver(() => {
1006
+ if (innerRef.current) syncScroll(innerRef.current);
1007
+ });
1008
+ observer.observe(el);
1009
+ return () => observer.disconnect();
1010
+ }, [skillReferences, syncScroll]);
1011
+
923
1012
  if (!skillReferences) {
924
1013
  return (
925
1014
  <textarea