@plannotator/ui 0.52.0 → 0.53.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 (67) hide show
  1. package/HANDOFF.md +317 -29
  2. package/README.md +35 -3
  3. package/components/AnnotationPanel.tsx +159 -107
  4. package/components/AnnotationToolbar.tsx +17 -14
  5. package/components/AnnotationToolstrip.tsx +9 -0
  6. package/components/BlockRenderer.tsx +26 -14
  7. package/components/CodeFilePopout.tsx +131 -68
  8. package/components/CommentPopover.tsx +6 -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/ImageLightbox.tsx +288 -17
  13. package/components/ImagePeek.tsx +255 -0
  14. package/components/ImageResolverContext.tsx +31 -0
  15. package/components/InlineMarkdown.tsx +123 -15
  16. package/components/LocalLinkContext.tsx +49 -0
  17. package/components/MenuVersionSection.tsx +51 -25
  18. package/components/OpenInAppButton.tsx +18 -49
  19. package/components/PlanHeaderMenu.tsx +2 -8
  20. package/components/ProviderIcons.tsx +10 -4
  21. package/components/RenderedMarkdown.tsx +40 -4
  22. package/components/Settings.tsx +33 -5
  23. package/components/SkillReferenceMenu.tsx +28 -6
  24. package/components/Tooltip.tsx +51 -4
  25. package/components/Viewer.tsx +214 -55
  26. package/components/ai/AIProviderBar.tsx +11 -0
  27. package/components/ai/AskSessionSetupHint.tsx +12 -0
  28. package/components/ai/DocumentAIChatPanel.tsx +17 -5
  29. package/components/ai/SessionAskNotice.tsx +4 -3
  30. package/components/blocks/BlockquoteBlock.tsx +159 -0
  31. package/components/blocks/HtmlBlock.tsx +121 -9
  32. package/components/blocks/QuestionBlock.tsx +253 -104
  33. package/components/diagram/DiagramViewer.tsx +41 -4
  34. package/components/diagram/index.ts +1 -1
  35. package/components/html-viewer/HtmlViewer.tsx +73 -3
  36. package/components/html-viewer/bridge-script.asset.js +86 -0
  37. package/components/html-viewer/bridge-script.ts +86 -0
  38. package/components/html-viewer/useHtmlAnnotation.ts +51 -3
  39. package/components/icons/AgentIcons.tsx +9 -7
  40. package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
  41. package/components/plan-diff/PlanDiffViewer.tsx +6 -1
  42. package/configure.ts +27 -1
  43. package/hooks/useAgentJobs.ts +8 -3
  44. package/hooks/useAnnotationHighlighter.ts +128 -23
  45. package/hooks/useExternalAnnotations.ts +6 -0
  46. package/hooks/useOpenInApps.ts +88 -0
  47. package/hooks/useUpdateCheck.ts +38 -98
  48. package/hooks/useUpdateCheckSetting.ts +43 -0
  49. package/hooks/useUpdateToast.ts +101 -0
  50. package/package.json +2 -2
  51. package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
  52. package/shortcuts/index.ts +1 -0
  53. package/styles.css +1 -1
  54. package/types.ts +48 -0
  55. package/utils/agentClosed.ts +26 -0
  56. package/utils/agentToolSetting.ts +16 -9
  57. package/utils/aiProvider.ts +7 -0
  58. package/utils/askSessionSetupHint.ts +27 -0
  59. package/utils/blockTargeting.ts +14 -3
  60. package/utils/parser.ts +65 -51
  61. package/utils/pasteInsert.ts +234 -0
  62. package/utils/planDiffEngine.ts +272 -2
  63. package/utils/skillCatalog.ts +15 -2
  64. package/utils/skillReferences.ts +50 -13
  65. package/utils/storage.ts +5 -0
  66. package/utils/textCut.ts +41 -0
  67. 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
  />
@@ -764,9 +805,9 @@ const DirectEditsCard: React.FC<{
764
805
  <div className="flex items-center gap-1.5">
765
806
  <span className="text-[11px] font-medium text-primary">{title}</span>
766
807
  {label && (
767
- <span className="min-w-0 truncate text-[10px] text-muted-foreground" title={label}>
768
- {label}
769
- </span>
808
+ <HostTitle title={label} cut>
809
+ <span className="min-w-0 truncate text-[10px] text-muted-foreground">{label}</span>
810
+ </HostTitle>
770
811
  )}
771
812
  <span className="font-mono text-[10px] tabular-nums">
772
813
  <span className="text-success">+{added}</span>
@@ -925,7 +966,9 @@ const AnnotationCard: React.FC<{
925
966
  mentionSource?: MentionSource;
926
967
  /** The annotation has no live location in the document (host-reported). */
927
968
  unanchored?: boolean;
928
- }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer, header, mentionSource, unanchored = false }) => {
969
+ /** Built-in parts to draw (see `cardParts`). */
970
+ parts?: { quote?: boolean; images?: boolean };
971
+ }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer, header, mentionSource, unanchored = false, parts }) => {
929
972
  const [isEditing, setIsEditing] = useState(false);
930
973
  const [editText, setEditText] = useState(annotation.text || '');
931
974
 
@@ -1000,21 +1043,18 @@ const AnnotationCard: React.FC<{
1000
1043
  </span>
1001
1044
  )}
1002
1045
  {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>
1046
+ <HostTitle title={annotation.pageUrl}>
1047
+ <span className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground truncate max-w-[10rem]">
1048
+ {annotation.pageUrl}
1049
+ </span>
1050
+ </HostTitle>
1009
1051
  )}
1010
1052
  {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>
1053
+ <HostTitle title="This comment no longer matches a location in the document">
1054
+ <span data-annotation-unanchored="true" className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground">
1055
+ Unanchored
1056
+ </span>
1057
+ </HostTitle>
1018
1058
  )}
1019
1059
  <span className="text-[10px] text-muted-foreground/50 truncate">
1020
1060
  {annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdA)}
@@ -1035,23 +1075,25 @@ const AnnotationCard: React.FC<{
1035
1075
  {!readOnly && (
1036
1076
  <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
1037
1077
  {onEdit && annotation.type !== AnnotationType.DELETION && !isEditing && (
1078
+ <HostTitle title="Edit annotation" name>
1079
+ <button
1080
+ type="button"
1081
+ onClick={handleStartEdit}
1082
+ className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground"
1083
+ >
1084
+ <PencilIcon />
1085
+ </button>
1086
+ </HostTitle>
1087
+ )}
1088
+ <HostTitle title="Delete annotation" name>
1038
1089
  <button
1039
1090
  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"
1091
+ onClick={(e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onDelete(); }}
1092
+ className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-destructive"
1043
1093
  >
1044
- <PencilIcon />
1094
+ <TrashCardIcon />
1045
1095
  </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>
1096
+ </HostTitle>
1055
1097
  </div>
1056
1098
  )}
1057
1099
  </div>
@@ -1068,9 +1110,11 @@ const AnnotationCard: React.FC<{
1068
1110
  ) : (
1069
1111
  <>
1070
1112
  {/* 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>
1113
+ {parts?.quote !== false && (
1114
+ <p className="mb-1.5 line-clamp-2 whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-muted-foreground/80">
1115
+ "{annotation.originalText}"
1116
+ </p>
1117
+ )}
1074
1118
 
1075
1119
  {/* Comment/Replacement Text */}
1076
1120
  {annotation.type !== AnnotationType.DELETION && (
@@ -1088,7 +1132,7 @@ const AnnotationCard: React.FC<{
1088
1132
  )}
1089
1133
 
1090
1134
  {/* Attached Images */}
1091
- {annotation.images && annotation.images.length > 0 && (
1135
+ {parts?.images !== false && annotation.images && annotation.images.length > 0 && (
1092
1136
  <div className="mt-2 flex flex-wrap gap-1.5">
1093
1137
  {annotation.images.map((img, idx) => (
1094
1138
  <div key={idx} className="text-center">
@@ -1097,7 +1141,9 @@ const AnnotationCard: React.FC<{
1097
1141
  size="sm"
1098
1142
  showRemove={false}
1099
1143
  />
1100
- <div className="text-[9px] text-muted-foreground truncate max-w-[3rem]" title={img.name}>{img.name}</div>
1144
+ <HostTitle title={img.name} cut>
1145
+ <div className="text-[9px] text-muted-foreground truncate max-w-[3rem]">{img.name}</div>
1146
+ </HostTitle>
1101
1147
  </div>
1102
1148
  ))}
1103
1149
  </div>
@@ -1184,31 +1230,35 @@ const CodeAnnotationCard: React.FC<{
1184
1230
  {!readOnly && (
1185
1231
  <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
1186
1232
  {onEdit && !isEditing && (
1233
+ <HostTitle title="Edit annotation" name>
1234
+ <button
1235
+ type="button"
1236
+ onClick={(e) => { e.stopPropagation(); setIsEditing(true); }}
1237
+ className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground"
1238
+ >
1239
+ <PencilIcon />
1240
+ </button>
1241
+ </HostTitle>
1242
+ )}
1243
+ <HostTitle title="Delete annotation" name>
1187
1244
  <button
1188
1245
  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"
1246
+ onClick={(e) => { e.stopPropagation(); onDelete(); }}
1247
+ className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-destructive"
1192
1248
  >
1193
- <PencilIcon />
1249
+ <TrashCardIcon />
1194
1250
  </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>
1251
+ </HostTitle>
1204
1252
  </div>
1205
1253
  )}
1206
1254
  </div>
1207
1255
 
1208
1256
  {/* 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>
1257
+ <HostTitle title={annotation.filePath}>
1258
+ <div className="rounded px-2 py-1 bg-surface-1 font-mono text-[11px] text-muted-foreground truncate">
1259
+ {fileName} · {lineRange}
1260
+ </div>
1261
+ </HostTitle>
1212
1262
 
1213
1263
  {annotation.originalCode && (
1214
1264
  <p className="mt-1.5 line-clamp-2 whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-muted-foreground/80">
@@ -1255,7 +1305,9 @@ const CodeAnnotationCard: React.FC<{
1255
1305
  {annotation.images.map((img) => (
1256
1306
  <div key={img.path} className="text-center">
1257
1307
  <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>
1308
+ <HostTitle title={img.name} cut>
1309
+ <div className="text-[9px] text-muted-foreground truncate max-w-[3rem]">{img.name}</div>
1310
+ </HostTitle>
1259
1311
  </div>
1260
1312
  ))}
1261
1313
  </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
  ));
@@ -36,6 +36,12 @@ export interface AnnotationToolstripProps {
36
36
  * retain the complete action-mode group.
37
37
  */
38
38
  hideQuickLabel?: boolean;
39
+ /**
40
+ * Omit only the Redline action. Defaults to false. For a host whose
41
+ * composer is its own (the Inbox surface on a phone draws Select, Pinpoint,
42
+ * Markup and Comment).
43
+ */
44
+ hideRedline?: boolean;
39
45
  }
40
46
 
41
47
  /** Render the shared input-method and annotation-mode controls. */
@@ -50,6 +56,7 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
50
56
  iconOnly = false,
51
57
  hideInputMethodSwitch = false,
52
58
  hideQuickLabel = false,
59
+ hideRedline = false,
53
60
  }) => {
54
61
  const [showHelp, setShowHelp] = useState(false);
55
62
  const [helpTab, setHelpTab] = useState<'selection' | 'plannotator'>('selection');
@@ -136,6 +143,7 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
136
143
  </svg>
137
144
  }
138
145
  />
146
+ {!hideRedline && (
139
147
  <ToolstripButton
140
148
  active={mode === 'redline'}
141
149
  onClick={() => onModeChange('redline')}
@@ -150,6 +158,7 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
150
158
  </svg>
151
159
  }
152
160
  />
161
+ )}
153
162
  {!hideQuickLabel && (
154
163
  <ToolstripButton
155
164
  active={mode === 'quickLabel'}
@@ -6,6 +6,7 @@ import { CodeBlock } from "./blocks/CodeBlock";
6
6
  import { HtmlBlock } from "./blocks/HtmlBlock";
7
7
  import { Callout } from "./blocks/Callout";
8
8
  import { AlertBlock } from "./blocks/AlertBlock";
9
+ import { BlockquoteBlock } from "./blocks/BlockquoteBlock";
9
10
  import { TableBlock } from "./blocks/TableBlock";
10
11
  import { MathBlock } from "./blocks/MathBlock";
11
12
  import { QuestionBlock, type QuestionBlockProps } from "./blocks/QuestionBlock";
@@ -53,11 +54,19 @@ export const BlockRenderer: React.FC<{
53
54
  renderQuestionFooter?: QuestionBlockProps['renderFooter'];
54
55
  /** Whether answering this question records a decision (see `QuestionBlock`). */
55
56
  questionDecisionRecording?: QuestionBlockProps['decisionRecording'];
57
+ /** `'any'` lets a question without a decision line carry the tag (see `QuestionBlock`). */
58
+ questionDecisionScope?: QuestionBlockProps['decisionScope'];
56
59
  /** Makes the "Records a decision" tag a toggle (see `QuestionBlock`). */
57
60
  onToggleQuestionDecisionRecording?: QuestionBlockProps['onToggleDecisionRecording'];
61
+ /** Opens the host's decision card from the tag's words (see `QuestionBlock`). */
62
+ onOpenQuestionDecision?: QuestionBlockProps['onOpenDecision'];
58
63
  /** `'none'` hides the card's own status tag (see `QuestionBlock`). */
59
64
  questionStatusTag?: QuestionBlockProps['statusTag'];
60
- }> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, repoHost, headingAnchorId, onNavigateAnchor, question, questionTotal, questionAnswer, onAnswerQuestion, onSaveQuestionAnswer, saveQuestionAnswerLabel, renderQuestionFooter, questionDecisionRecording, onToggleQuestionDecisionRecording, questionStatusTag }) => {
65
+ /** The host's words for a question's status tag (see `QuestionBlock`). */
66
+ questionStatusLabel?: QuestionBlockProps['statusLabel'];
67
+ /** Host content under a question's prompt (see `QuestionBlock`). */
68
+ renderQuestionBelowPrompt?: QuestionBlockProps['renderBelowPrompt'];
69
+ }> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, repoHost, headingAnchorId, onNavigateAnchor, question, questionTotal, questionAnswer, onAnswerQuestion, onSaveQuestionAnswer, saveQuestionAnswerLabel, renderQuestionFooter, questionDecisionRecording, questionDecisionScope, onToggleQuestionDecisionRecording, onOpenQuestionDecision, questionStatusTag, questionStatusLabel, renderQuestionBelowPrompt }) => {
61
70
  switch (block.type) {
62
71
  case 'heading': {
63
72
  const Tag = `h${block.level || 1}` as React.ElementType;
@@ -95,20 +104,19 @@ export const BlockRenderer: React.FC<{
95
104
  />
96
105
  );
97
106
  }
98
- // Content may span multiple merged `>` lines. Split on blank-line
99
- // paragraph breaks so `> a\n>\n> b` renders as two <p> children.
100
- const paragraphs = block.content.split(/\n\n+/);
107
+ // One quote whatever it holds: its content is the quote's own markdown
108
+ // (paragraphs, code fences, lists, nested quotes), see BlockquoteBlock.
101
109
  return (
102
- <blockquote
103
- className="border-l-2 border-primary/50 pl-4 my-4 text-muted-foreground italic"
104
- data-block-id={block.id}
105
- >
106
- {paragraphs.map((para, i) => (
107
- <p key={i} className={i > 0 ? 'mt-2' : ''}>
108
- <InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={para} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} githubRepo={githubRepo} repoHost={repoHost} onNavigateAnchor={onNavigateAnchor} />
109
- </p>
110
- ))}
111
- </blockquote>
110
+ <BlockquoteBlock
111
+ block={block}
112
+ onOpenLinkedDoc={onOpenLinkedDoc}
113
+ onOpenCodeFile={onOpenCodeFile}
114
+ imageBaseDir={imageBaseDir}
115
+ onImageClick={onImageClick}
116
+ githubRepo={githubRepo}
117
+ repoHost={repoHost}
118
+ onNavigateAnchor={onNavigateAnchor}
119
+ />
112
120
  );
113
121
  }
114
122
 
@@ -185,8 +193,12 @@ export const BlockRenderer: React.FC<{
185
193
  saveLabel={saveQuestionAnswerLabel}
186
194
  renderFooter={renderQuestionFooter}
187
195
  decisionRecording={questionDecisionRecording}
196
+ decisionScope={questionDecisionScope}
188
197
  onToggleDecisionRecording={onToggleQuestionDecisionRecording}
198
+ onOpenDecision={onOpenQuestionDecision}
189
199
  statusTag={questionStatusTag}
200
+ statusLabel={questionStatusLabel}
201
+ renderBelowPrompt={renderQuestionBelowPrompt}
190
202
  onOpenLinkedDoc={onOpenLinkedDoc}
191
203
  onOpenCodeFile={onOpenCodeFile}
192
204
  imageBaseDir={imageBaseDir}