@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
@@ -15,6 +15,7 @@ import { ROOT_DOCUMENT_GROUP_KEY } from '../utils/annotationScope';
15
15
  import type { AnnotationScope, AnnotationDocumentGroup } from '../utils/annotationScope';
16
16
  import type { QuestionPanelRow } from '../utils/questionAnswers';
17
17
  import { QuestionsPanelSection } from './QuestionsPanelSection';
18
+ import { HostTitle } from './HostTitle';
18
19
 
19
20
  // Card type-word colors. Deletion uses `destructive` (reliably red on every
20
21
  // theme, matching the in-document .deletion highlight). Comment uses the
@@ -101,6 +102,8 @@ interface DirectEditsPanelItem {
101
102
  }
102
103
 
103
104
  interface PanelProps {
105
+ /** The second line of the empty state. Default "Select text to annotate"; a host whose document is an image or code says how to comment there. */
106
+ emptyHint?: string;
104
107
  isOpen: boolean;
105
108
  annotations: Annotation[];
106
109
  blocks: Block[];
@@ -189,9 +192,32 @@ interface PanelProps {
189
192
  questionRows?: readonly QuestionPanelRow[];
190
193
  /** A Questions row was clicked (the host scrolls to the card). */
191
194
  onSelectQuestion?: (row: QuestionPanelRow) => void;
195
+ /**
196
+ * The order of the comment timeline (and of each document group), for a
197
+ * host whose comments have an order of their own (the Inbox's video
198
+ * comments, by the moment they are at). Ties keep creation order. Absent:
199
+ * creation order, exactly as before.
200
+ */
201
+ compareAnnotations?: (a: Annotation, b: Annotation) => number;
202
+ /**
203
+ * The panel's title (default "Annotations") and a quiet note at the right
204
+ * of its header (the Inbox's "By time" for a video's comments). Absent:
205
+ * the header is exactly as before.
206
+ */
207
+ title?: string;
208
+ titleNote?: string;
209
+ /**
210
+ * Which built-in parts a plan-annotation card draws: its quoted text
211
+ * (`quote`) and its attached images under the comment (`images`). A host
212
+ * that draws them itself (the Inbox's video comments show the moment and
213
+ * the frame in `renderCardHeader`) turns them off per card. Absent: both
214
+ * drawn, exactly as before.
215
+ */
216
+ cardParts?: (annotation: Annotation) => { quote?: boolean; images?: boolean };
192
217
  }
193
218
 
194
219
  export const AnnotationPanel: React.FC<PanelProps> = ({
220
+ emptyHint = 'Select text to annotate',
195
221
  isOpen,
196
222
  annotations,
197
223
  onSelect,
@@ -226,6 +252,10 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
226
252
  onEditInDocument,
227
253
  questionRows,
228
254
  onSelectQuestion,
255
+ compareAnnotations,
256
+ title = 'Annotations',
257
+ titleNote,
258
+ cardParts,
229
259
  }) => {
230
260
  const isMobile = useIsMobile();
231
261
  const embedded = presentation === 'embedded';
@@ -237,7 +267,9 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
237
267
  const timelineAnnotations = questionRows !== undefined
238
268
  ? annotations.filter((a) => a.questionAnswer == null)
239
269
  : annotations;
240
- const sortedAnnotations = [...timelineAnnotations].sort((a, b) => a.createdA - b.createdA);
270
+ const byCreation = (a: Annotation, b: Annotation) => a.createdA - b.createdA;
271
+ const byHost = compareAnnotations ? (a: Annotation, b: Annotation) => compareAnnotations(a, b) || byCreation(a, b) : byCreation;
272
+ const sortedAnnotations = [...timelineAnnotations].sort(byHost);
241
273
  const sortedCodeAnnotations = [...codeAnnotations].sort((a, b) => a.createdAt - b.createdAt);
242
274
  // Replies (`inReplyTo`) thread under their parent: each reply is lifted to
243
275
  // sit right after its parent (and the parent's earlier replies) at the
@@ -248,13 +280,15 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
248
280
  // comparator with a linear parent lookup was O(n^2 log n) and froze the
249
281
  // tab on a few thousand threaded comments.
250
282
  const threadRootTs = resolveThreadRootTimestamps(sortedAnnotations);
251
- const timelineEntries = [
252
- ...threadedAnnotations.map(({ annotation, isReply }) => ({ kind: 'plan' as const, ts: annotation.createdA, threadTs: threadRootTs.get(annotation.id) ?? annotation.createdA, annotation, isReply })),
253
- ...sortedCodeAnnotations.map(annotation => ({ kind: 'code' as const, ts: annotation.createdAt, threadTs: annotation.createdAt, annotation, isReply: false })),
254
- ].sort((a, b) => {
255
- if (a.threadTs !== b.threadTs) return a.threadTs - b.threadTs;
256
- return a.ts - b.ts;
257
- });
283
+ const planEntries = threadedAnnotations.map(({ annotation, isReply }) => ({ kind: 'plan' as const, ts: annotation.createdA, threadTs: threadRootTs.get(annotation.id) ?? annotation.createdA, annotation, isReply }));
284
+ const codeEntries = sortedCodeAnnotations.map(annotation => ({ kind: 'code' as const, ts: annotation.createdAt, threadTs: annotation.createdAt, annotation, isReply: false }));
285
+ // A host order keeps the plan comments as the host sorted them (code comments after).
286
+ const timelineEntries = compareAnnotations
287
+ ? [...planEntries, ...codeEntries]
288
+ : [...planEntries, ...codeEntries].sort((a, b) => {
289
+ if (a.threadTs !== b.threadTs) return a.threadTs - b.threadTs;
290
+ return a.ts - b.ts;
291
+ });
258
292
  const totalCount = annotations.length + codeAnnotations.length + (editorAnnotations?.length ?? 0);
259
293
  const timelineCount = timelineAnnotations.length + codeAnnotations.length + (editorAnnotations?.length ?? 0);
260
294
 
@@ -332,7 +366,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
332
366
  <div className="flex h-10 items-center justify-between px-3">
333
367
  <div className="flex items-center gap-2">
334
368
  <h2 className="text-xs font-medium text-foreground">
335
- Annotations
369
+ {title}
336
370
  </h2>
337
371
  {headerCount > 0 && (
338
372
  <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">
@@ -340,17 +374,19 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
340
374
  </span>
341
375
  )}
342
376
  </div>
377
+ {titleNote && <span data-annotation-panel-note="" className="ml-auto mr-1 text-[11px] text-muted-foreground">{titleNote}</span>}
343
378
  {mobilePanel && onClose && (
344
- <button
345
- onClick={onClose}
346
- className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground md:hidden"
347
- title="Close panel"
348
- aria-label="Close panel"
349
- >
350
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
351
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
352
- </svg>
353
- </button>
379
+ <HostTitle title="Close panel">
380
+ <button
381
+ onClick={onClose}
382
+ className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground md:hidden"
383
+ aria-label="Close panel"
384
+ >
385
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
386
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
387
+ </svg>
388
+ </button>
389
+ </HostTitle>
354
390
  )}
355
391
  </div>
356
392
  {scopeEnabled ? (
@@ -364,13 +400,14 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
364
400
  </div>
365
401
  ) : (
366
402
  otherFileAnnotations && otherFileAnnotations.count > 0 && (
367
- <button
368
- onClick={onOtherFileAnnotationsClick}
369
- className="px-3 pb-2 text-[10px] text-primary/70 hover:text-primary transition-colors cursor-pointer"
370
- title="Show annotated files in sidebar"
371
- >
372
- +{otherFileAnnotations.count} in {otherFileAnnotations.files} other file{otherFileAnnotations.files === 1 ? '' : 's'}
373
- </button>
403
+ <HostTitle title="Show annotated files in sidebar">
404
+ <button
405
+ onClick={onOtherFileAnnotationsClick}
406
+ className="px-3 pb-2 text-[10px] text-primary/70 hover:text-primary transition-colors cursor-pointer"
407
+ >
408
+ +{otherFileAnnotations.count} in {otherFileAnnotations.files} other file{otherFileAnnotations.files === 1 ? '' : 's'}
409
+ </button>
410
+ </HostTitle>
374
411
  )
375
412
  )}
376
413
  </div>
@@ -386,15 +423,16 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
386
423
  )}
387
424
 
388
425
  {embedded && !scopeEnabled && otherFileAnnotations && otherFileAnnotations.count > 0 && (
389
- <button
390
- type="button"
391
- data-pn-touch-target="true"
392
- onClick={onOtherFileAnnotationsClick}
393
- className="min-h-11 flex-shrink-0 border-b border-border/50 px-3 text-left text-xs text-primary/80 active:bg-muted"
394
- title="Show annotated files in navigator"
395
- >
396
- {otherFileAnnotations.count} more in {otherFileAnnotations.files} other file{otherFileAnnotations.files === 1 ? '' : 's'}
397
- </button>
426
+ <HostTitle title="Show annotated files in navigator">
427
+ <button
428
+ type="button"
429
+ data-pn-touch-target="true"
430
+ onClick={onOtherFileAnnotationsClick}
431
+ className="min-h-11 flex-shrink-0 border-b border-border/50 px-3 text-left text-xs text-primary/80 active:bg-muted"
432
+ >
433
+ {otherFileAnnotations.count} more in {otherFileAnnotations.files} other file{otherFileAnnotations.files === 1 ? '' : 's'}
434
+ </button>
435
+ </HostTitle>
398
436
  )}
399
437
 
400
438
  {/* List */}
@@ -424,7 +462,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
424
462
  const groupAnnotations = group.isCurrent && questionRows !== undefined
425
463
  ? group.annotations.filter((a) => a.questionAnswer == null)
426
464
  : group.annotations;
427
- const sorted = [...groupAnnotations].sort((a, b) => a.createdA - b.createdA);
465
+ const sorted = [...groupAnnotations].sort(byHost);
428
466
  const threaded = threadReplies(sorted);
429
467
  return (
430
468
  <section key={group.path} data-annotation-group={group.path}>
@@ -444,14 +482,13 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
444
482
  >
445
483
  <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
446
484
  </svg>
447
- <span
448
- className={cn('min-w-0 flex-1 truncate font-mono text-[10px]', group.isCurrent ? 'text-foreground' : 'text-muted-foreground')}
449
- // The pathless root document (plan review) is keyed by a
450
- // sentinel, which is not something to show a reader.
451
- title={group.path === ROOT_DOCUMENT_GROUP_KEY ? group.label : group.path}
452
- >
453
- {group.label}
454
- </span>
485
+ {/* The pathless root document (plan review) is keyed by a
486
+ sentinel, which is not something to show a reader. */}
487
+ <HostTitle title={group.path === ROOT_DOCUMENT_GROUP_KEY ? group.label : group.path}>
488
+ <span className={cn('min-w-0 flex-1 truncate font-mono text-[10px]', group.isCurrent ? 'text-foreground' : 'text-muted-foreground')}>
489
+ {group.label}
490
+ </span>
491
+ </HostTitle>
455
492
  {group.isCurrent && (
456
493
  <span className="flex-shrink-0 text-[9px] text-muted-foreground/50">open</span>
457
494
  )}
@@ -479,6 +516,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
479
516
  readOnly={readOnly}
480
517
  footer={group.isCurrent ? renderCardFooter?.(annotation) : undefined}
481
518
  header={group.isCurrent ? renderCardHeader?.(annotation) : undefined}
519
+ parts={group.isCurrent ? cardParts?.(annotation) : undefined}
482
520
  mentionSource={mentionSource}
483
521
  unanchored={group.isCurrent ? (unanchoredIds?.has(annotation.id) ?? false) : false}
484
522
  />
@@ -534,16 +572,17 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
534
572
  </>
535
573
  )}
536
574
  {otherGroups.length > 0 && onOtherFileAnnotationsClick && (
537
- <button
538
- type="button"
539
- data-pn-touch-target="true"
540
- data-annotation-show-in-files="true"
541
- onClick={onOtherFileAnnotationsClick}
542
- className="mt-1 cursor-pointer self-start px-1.5 text-[10px] text-muted-foreground/60 transition-colors hover:text-primary"
543
- title="Show annotated files in the navigator"
544
- >
545
- Show in files
546
- </button>
575
+ <HostTitle title="Show annotated files in the navigator">
576
+ <button
577
+ type="button"
578
+ data-pn-touch-target="true"
579
+ data-annotation-show-in-files="true"
580
+ onClick={onOtherFileAnnotationsClick}
581
+ className="mt-1 cursor-pointer self-start px-1.5 text-[10px] text-muted-foreground/60 transition-colors hover:text-primary"
582
+ >
583
+ Show in files
584
+ </button>
585
+ </HostTitle>
547
586
  )}
548
587
  </>
549
588
  ) : timelineCount === 0 && showQuestions ? (
@@ -557,7 +596,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
557
596
  No annotations yet
558
597
  </p>
559
598
  <p className="mt-1 text-[11px] text-muted-foreground/40">
560
- Select text to annotate
599
+ {emptyHint}
561
600
  </p>
562
601
  {scopeEnabled && otherGroupCount > 0 && (
563
602
  <button
@@ -592,6 +631,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
592
631
  readOnly={readOnly}
593
632
  footer={renderCardFooter?.(entry.annotation)}
594
633
  header={renderCardHeader?.(entry.annotation)}
634
+ parts={cardParts?.(entry.annotation)}
595
635
  mentionSource={mentionSource}
596
636
  unanchored={unanchoredIds?.has(entry.annotation.id) ?? false}
597
637
  />
@@ -608,6 +648,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
608
648
  readOnly={readOnly}
609
649
  footer={renderCardFooter?.(entry.annotation)}
610
650
  header={renderCardHeader?.(entry.annotation)}
651
+ parts={cardParts?.(entry.annotation)}
611
652
  mentionSource={mentionSource}
612
653
  unanchored={unanchoredIds?.has(entry.annotation.id) ?? false}
613
654
  />
@@ -725,9 +766,38 @@ function formatTimestamp(ts: number): string {
725
766
  if (hours < 24) return `${hours}h`;
726
767
  if (days < 7) return `${days}d`;
727
768
 
728
- return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
769
+ // An older date names its year: "Oct 20" alone reads as this October.
770
+ const date = new Date(ts);
771
+ const thisYear = date.getFullYear() === new Date(now).getFullYear();
772
+ return date.toLocaleDateString(
773
+ undefined,
774
+ thisYear ? { month: 'short', day: 'numeric' } : { month: 'short', day: 'numeric', year: 'numeric' },
775
+ );
729
776
  }
730
777
 
778
+ /**
779
+ * A card's "author · time" line. The author truncates (its full name in the
780
+ * title) while the time never shrinks, so a long name cannot push the time
781
+ * out; `<bdi>` keeps a right-to-left name from reordering the time around it.
782
+ * The text reads exactly as before: "Alice (me) · 5m".
783
+ */
784
+ const CardByline: React.FC<{ author?: string; isMe: boolean; timestamp: number }> = ({ author, isMe, timestamp }) => (
785
+ <span data-annotation-byline="true" className="flex min-w-0 items-center text-[10px] text-muted-foreground/50">
786
+ {author ? (
787
+ <>
788
+ <HostTitle title={author} cut>
789
+ <span data-annotation-author="true" className="min-w-0 truncate">
790
+ <bdi>{author}</bdi>
791
+ </span>
792
+ </HostTitle>
793
+ {isMe ? <span data-annotation-me="true" className="shrink-0 whitespace-pre"> (me)</span> : null}
794
+ <span className="shrink-0 whitespace-pre"> · </span>
795
+ </>
796
+ ) : null}
797
+ <span data-annotation-time="true" className="shrink-0">{formatTimestamp(timestamp)}</span>
798
+ </span>
799
+ );
800
+
731
801
  /** Pinned card for committed direct edits: +N/−M summary, expandable unified
732
802
  * diff, and a two-step discard. Not part of the annotation timeline — edits
733
803
  * are document state, not a selection-anchored note. */
@@ -764,9 +834,9 @@ const DirectEditsCard: React.FC<{
764
834
  <div className="flex items-center gap-1.5">
765
835
  <span className="text-[11px] font-medium text-primary">{title}</span>
766
836
  {label && (
767
- <span className="min-w-0 truncate text-[10px] text-muted-foreground" title={label}>
768
- {label}
769
- </span>
837
+ <HostTitle title={label} cut>
838
+ <span className="min-w-0 truncate text-[10px] text-muted-foreground">{label}</span>
839
+ </HostTitle>
770
840
  )}
771
841
  <span className="font-mono text-[10px] tabular-nums">
772
842
  <span className="text-success">+{added}</span>
@@ -925,7 +995,9 @@ const AnnotationCard: React.FC<{
925
995
  mentionSource?: MentionSource;
926
996
  /** The annotation has no live location in the document (host-reported). */
927
997
  unanchored?: boolean;
928
- }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer, header, mentionSource, unanchored = false }) => {
998
+ /** Built-in parts to draw (see `cardParts`). */
999
+ parts?: { quote?: boolean; images?: boolean };
1000
+ }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer, header, mentionSource, unanchored = false, parts }) => {
929
1001
  const [isEditing, setIsEditing] = useState(false);
930
1002
  const [editText, setEditText] = useState(annotation.text || '');
931
1003
 
@@ -1000,25 +1072,20 @@ const AnnotationCard: React.FC<{
1000
1072
  </span>
1001
1073
  )}
1002
1074
  {annotation.pageUrl && (
1003
- <span
1004
- className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground truncate max-w-[10rem]"
1005
- title={annotation.pageUrl}
1006
- >
1007
- {annotation.pageUrl}
1008
- </span>
1075
+ <HostTitle title={annotation.pageUrl}>
1076
+ <span className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground truncate max-w-[10rem]">
1077
+ {annotation.pageUrl}
1078
+ </span>
1079
+ </HostTitle>
1009
1080
  )}
1010
1081
  {unanchored && (
1011
- <span
1012
- data-annotation-unanchored="true"
1013
- className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground"
1014
- title="This comment no longer matches a location in the document"
1015
- >
1016
- Unanchored
1017
- </span>
1082
+ <HostTitle title="This comment no longer matches a location in the document">
1083
+ <span data-annotation-unanchored="true" className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground">
1084
+ Unanchored
1085
+ </span>
1086
+ </HostTitle>
1018
1087
  )}
1019
- <span className="text-[10px] text-muted-foreground/50 truncate">
1020
- {annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdA)}
1021
- </span>
1088
+ <CardByline author={annotation.author} isMe={isMe} timestamp={annotation.createdA} />
1022
1089
  {/* Host header slot (a status stamp etc.) — interactions inside it
1023
1090
  must not toggle card selection, exactly like the footer slot.
1024
1091
  Rendered under readOnly too: its contents are host-owned. */}
@@ -1035,23 +1102,25 @@ const AnnotationCard: React.FC<{
1035
1102
  {!readOnly && (
1036
1103
  <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
1037
1104
  {onEdit && annotation.type !== AnnotationType.DELETION && !isEditing && (
1105
+ <HostTitle title="Edit annotation" name>
1106
+ <button
1107
+ type="button"
1108
+ onClick={handleStartEdit}
1109
+ className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground"
1110
+ >
1111
+ <PencilIcon />
1112
+ </button>
1113
+ </HostTitle>
1114
+ )}
1115
+ <HostTitle title="Delete annotation" name>
1038
1116
  <button
1039
1117
  type="button"
1040
- onClick={handleStartEdit}
1041
- className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground"
1042
- title="Edit annotation"
1118
+ onClick={(e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onDelete(); }}
1119
+ className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-destructive"
1043
1120
  >
1044
- <PencilIcon />
1121
+ <TrashCardIcon />
1045
1122
  </button>
1046
- )}
1047
- <button
1048
- type="button"
1049
- onClick={(e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onDelete(); }}
1050
- className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-destructive"
1051
- title="Delete annotation"
1052
- >
1053
- <TrashCardIcon />
1054
- </button>
1123
+ </HostTitle>
1055
1124
  </div>
1056
1125
  )}
1057
1126
  </div>
@@ -1061,16 +1130,18 @@ const AnnotationCard: React.FC<{
1061
1130
  isEditing ? (
1062
1131
  editComposer
1063
1132
  ) : (
1064
- <p className="whitespace-pre-wrap text-[13px] leading-relaxed text-foreground/90">
1133
+ <p className="whitespace-pre-wrap [overflow-wrap:anywhere] text-[13px] leading-relaxed text-foreground/90">
1065
1134
  {annotation.text}
1066
1135
  </p>
1067
1136
  )
1068
1137
  ) : (
1069
1138
  <>
1070
1139
  {/* Quote — the annotated text */}
1071
- <p className="mb-1.5 line-clamp-2 whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-muted-foreground/80">
1072
- "{annotation.originalText}"
1073
- </p>
1140
+ {parts?.quote !== false && (
1141
+ <p className="mb-1.5 line-clamp-2 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-[11px] leading-relaxed text-muted-foreground/80">
1142
+ "{annotation.originalText}"
1143
+ </p>
1144
+ )}
1074
1145
 
1075
1146
  {/* Comment/Replacement Text */}
1076
1147
  {annotation.type !== AnnotationType.DELETION && (
@@ -1078,7 +1149,7 @@ const AnnotationCard: React.FC<{
1078
1149
  editComposer
1079
1150
  ) : (
1080
1151
  annotation.text && (
1081
- <p className="whitespace-pre-wrap text-[13px] leading-relaxed text-foreground/90">
1152
+ <p className="whitespace-pre-wrap [overflow-wrap:anywhere] text-[13px] leading-relaxed text-foreground/90">
1082
1153
  {annotation.text}
1083
1154
  </p>
1084
1155
  )
@@ -1088,7 +1159,7 @@ const AnnotationCard: React.FC<{
1088
1159
  )}
1089
1160
 
1090
1161
  {/* Attached Images */}
1091
- {annotation.images && annotation.images.length > 0 && (
1162
+ {parts?.images !== false && annotation.images && annotation.images.length > 0 && (
1092
1163
  <div className="mt-2 flex flex-wrap gap-1.5">
1093
1164
  {annotation.images.map((img, idx) => (
1094
1165
  <div key={idx} className="text-center">
@@ -1097,7 +1168,9 @@ const AnnotationCard: React.FC<{
1097
1168
  size="sm"
1098
1169
  showRemove={false}
1099
1170
  />
1100
- <div className="text-[9px] text-muted-foreground truncate max-w-[3rem]" title={img.name}>{img.name}</div>
1171
+ <HostTitle title={img.name} cut>
1172
+ <div className="text-[9px] text-muted-foreground truncate max-w-[3rem]">{img.name}</div>
1173
+ </HostTitle>
1101
1174
  </div>
1102
1175
  ))}
1103
1176
  </div>
@@ -1178,40 +1251,42 @@ const CodeAnnotationCard: React.FC<{
1178
1251
  {/* Header: type word + author · time + actions */}
1179
1252
  <div className="mb-1.5 flex items-center gap-1.5">
1180
1253
  <span className="text-[11px] font-medium text-primary">Code</span>
1181
- <span className="text-[10px] text-muted-foreground/50 truncate">
1182
- {annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdAt)}
1183
- </span>
1254
+ <CardByline author={annotation.author} isMe={isMe} timestamp={annotation.createdAt} />
1184
1255
  {!readOnly && (
1185
1256
  <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
1186
1257
  {onEdit && !isEditing && (
1258
+ <HostTitle title="Edit annotation" name>
1259
+ <button
1260
+ type="button"
1261
+ onClick={(e) => { e.stopPropagation(); setIsEditing(true); }}
1262
+ className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground"
1263
+ >
1264
+ <PencilIcon />
1265
+ </button>
1266
+ </HostTitle>
1267
+ )}
1268
+ <HostTitle title="Delete annotation" name>
1187
1269
  <button
1188
1270
  type="button"
1189
- onClick={(e) => { e.stopPropagation(); setIsEditing(true); }}
1190
- className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground"
1191
- title="Edit annotation"
1271
+ onClick={(e) => { e.stopPropagation(); onDelete(); }}
1272
+ className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-destructive"
1192
1273
  >
1193
- <PencilIcon />
1274
+ <TrashCardIcon />
1194
1275
  </button>
1195
- )}
1196
- <button
1197
- type="button"
1198
- onClick={(e) => { e.stopPropagation(); onDelete(); }}
1199
- className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-destructive"
1200
- title="Delete annotation"
1201
- >
1202
- <TrashCardIcon />
1203
- </button>
1276
+ </HostTitle>
1204
1277
  </div>
1205
1278
  )}
1206
1279
  </div>
1207
1280
 
1208
1281
  {/* File / line meta */}
1209
- <div className="rounded px-2 py-1 bg-surface-1 font-mono text-[11px] text-muted-foreground truncate" title={annotation.filePath}>
1210
- {fileName} · {lineRange}
1211
- </div>
1282
+ <HostTitle title={annotation.filePath}>
1283
+ <div className="rounded px-2 py-1 bg-surface-1 font-mono text-[11px] text-muted-foreground truncate">
1284
+ {fileName} · {lineRange}
1285
+ </div>
1286
+ </HostTitle>
1212
1287
 
1213
1288
  {annotation.originalCode && (
1214
- <p className="mt-1.5 line-clamp-2 whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-muted-foreground/80">
1289
+ <p className="mt-1.5 line-clamp-2 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-[11px] leading-relaxed text-muted-foreground/80">
1215
1290
  {annotation.originalCode}
1216
1291
  </p>
1217
1292
  )}
@@ -1244,7 +1319,7 @@ const CodeAnnotationCard: React.FC<{
1244
1319
  </div>
1245
1320
  ) : (
1246
1321
  annotation.text && (
1247
- <p className="mt-1.5 whitespace-pre-wrap text-[13px] leading-relaxed text-foreground/90">
1322
+ <p className="mt-1.5 whitespace-pre-wrap [overflow-wrap:anywhere] text-[13px] leading-relaxed text-foreground/90">
1248
1323
  {annotation.text}
1249
1324
  </p>
1250
1325
  )
@@ -1255,7 +1330,9 @@ const CodeAnnotationCard: React.FC<{
1255
1330
  {annotation.images.map((img) => (
1256
1331
  <div key={img.path} className="text-center">
1257
1332
  <ImageThumbnail path={img.path} size="sm" showRemove={false} />
1258
- <div className="text-[9px] text-muted-foreground truncate max-w-[3rem]" title={img.name}>{img.name}</div>
1333
+ <HostTitle title={img.name} cut>
1334
+ <div className="text-[9px] text-muted-foreground truncate max-w-[3rem]">{img.name}</div>
1335
+ </HostTitle>
1259
1336
  </div>
1260
1337
  ))}
1261
1338
  </div>
@@ -8,6 +8,7 @@ import { acquireTypeToCommentCapture } from "../shortcuts/plan-review/annotation
8
8
  import { FloatingQuickLabelPicker } from "./FloatingQuickLabelPicker";
9
9
  import { FloatingSelectionActionsPicker } from "./SelectionActionsDropdown";
10
10
  import { buildSelectionActionContext, type SelectionAction } from "../utils/selectionActions";
11
+ import { HostTitle } from './HostTitle';
11
12
 
12
13
  type PositionMode = 'center-above' | 'top-right';
13
14
 
@@ -399,18 +400,20 @@ const ToolbarButton = React.forwardRef<HTMLButtonElement, {
399
400
  /** Extra DOM attributes (e.g. data-selection-actions). Absent adds nothing. */
400
401
  dataAttributes?: Record<string, string>;
401
402
  }>(({ onClick, icon, label, className, dataAttributes }, ref) => (
402
- <button
403
- ref={ref}
404
- // Icon-only controls: the markers are inert outside the compact touch
405
- // scope, where theme.css grows them to var(--pn-touch-target). Desktop
406
- // geometry is unchanged.
407
- data-pn-touch-target="true"
408
- data-pn-touch-target-icon="true"
409
- onClick={onClick}
410
- title={label}
411
- className={`p-1.5 rounded-md transition-colors ${className}`}
412
- {...dataAttributes}
413
- >
414
- {icon}
415
- </button>
403
+ // The label is the button's only name: as a component tooltip (HostTitle) it also becomes its aria-label.
404
+ <HostTitle title={label} name>
405
+ <button
406
+ ref={ref}
407
+ // Icon-only controls: the markers are inert outside the compact touch
408
+ // scope, where theme.css grows them to var(--pn-touch-target). Desktop
409
+ // geometry is unchanged.
410
+ data-pn-touch-target="true"
411
+ data-pn-touch-target-icon="true"
412
+ onClick={onClick}
413
+ className={`p-1.5 rounded-md transition-colors ${className}`}
414
+ {...dataAttributes}
415
+ >
416
+ {icon}
417
+ </button>
418
+ </HostTitle>
416
419
  ));