@plannotator/ui 0.53.1 → 0.54.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 (135) hide show
  1. package/HANDOFF.md +217 -11
  2. package/README.md +65 -10
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +142 -24
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/ApproveDropdown.tsx +7 -9
  9. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  10. package/components/AttachmentsButton.tsx +1 -1
  11. package/components/BlockRenderer.tsx +19 -5
  12. package/components/CodeFilePopout.tsx +139 -4
  13. package/components/CommentPopover.tsx +44 -8
  14. package/components/DecisionControl.tsx +31 -5
  15. package/components/DiagramBlock.tsx +17 -12
  16. package/components/EditorAnnotationCard.tsx +1 -1
  17. package/components/ExportModal.tsx +77 -7
  18. package/components/HostTitle.tsx +0 -4
  19. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  20. package/components/ImageAnnotator/index.tsx +24 -4
  21. package/components/ImageLightbox.tsx +658 -120
  22. package/components/ImagePeek.tsx +38 -4
  23. package/components/ImageRegions.tsx +369 -0
  24. package/components/ImageThumbnail.tsx +1 -1
  25. package/components/ImageZoomStage.tsx +796 -0
  26. package/components/InboxAnnouncementDialog.tsx +202 -0
  27. package/components/InlineMarkdown.tsx +152 -24
  28. package/components/KeyboardShortcuts.tsx +23 -0
  29. package/components/LazyBlockBoundary.tsx +100 -0
  30. package/components/ListMarker.tsx +3 -3
  31. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  32. package/components/MenuVersionSection.tsx +31 -9
  33. package/components/PlanDecisionMenuExtras.tsx +129 -0
  34. package/components/PlanHeaderMenu.tsx +15 -1
  35. package/components/ProviderIcons.tsx +8 -0
  36. package/components/RefHoverCard.tsx +913 -0
  37. package/components/SendFailureToast.tsx +126 -0
  38. package/components/Settings.tsx +617 -126
  39. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  40. package/components/ThemeProvider.tsx +14 -1
  41. package/components/ThemeTab.tsx +16 -24
  42. package/components/Tooltip.tsx +49 -18
  43. package/components/Viewer.tsx +280 -41
  44. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  45. package/components/ai/NewChatButton.tsx +74 -0
  46. package/components/blocks/CodeBlock.tsx +1 -1
  47. package/components/blocks/HtmlBlock.tsx +7 -1
  48. package/components/blocks/QuestionBlock.tsx +522 -62
  49. package/components/diagram/DiagramComposer.tsx +5 -1
  50. package/components/diagram/DiagramPending.tsx +12 -2
  51. package/components/diagram/DiagramViewer.tsx +19 -3
  52. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  53. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  54. package/components/html-embed/index.ts +3 -0
  55. package/components/html-embed/languages.ts +15 -0
  56. package/components/html-embed/loadGate.ts +107 -0
  57. package/components/html-viewer/HtmlViewer.tsx +47 -6
  58. package/components/html-viewer/bridge-script.asset.js +173 -1
  59. package/components/html-viewer/bridge-script.ts +173 -1
  60. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  61. package/components/imageZoom.ts +265 -0
  62. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  63. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  64. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  65. package/components/sidebar/FileBrowser.tsx +16 -2
  66. package/components/sidebar/SidebarTabs.tsx +3 -0
  67. package/config/index.ts +3 -0
  68. package/config/reviewView.ts +41 -1
  69. package/config/settings.ts +155 -4
  70. package/configure.ts +28 -0
  71. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  72. package/hooks/useAIChat.ts +72 -25
  73. package/hooks/useAnnotationDraft.ts +71 -6
  74. package/hooks/useAnnotationHighlighter.ts +57 -3
  75. package/hooks/useCodeAnnotationDraft.ts +113 -17
  76. package/hooks/useCookieHostSetting.ts +96 -0
  77. package/hooks/useDocumentFont.ts +17 -0
  78. package/hooks/useDocumentTextSize.ts +20 -0
  79. package/hooks/useFileBrowser.ts +10 -1
  80. package/hooks/useLinkedDoc.ts +226 -13
  81. package/hooks/usePackageChangesSetting.ts +90 -0
  82. package/hooks/usePlanLookDialog.ts +67 -0
  83. package/hooks/useSharing.ts +11 -7
  84. package/hooks/useThemePaletteHalves.ts +37 -0
  85. package/hooks/useUpdateCheck.ts +22 -4
  86. package/hooks/useUpdateToast.ts +1 -1
  87. package/package.json +23 -22
  88. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  89. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  90. package/shortcuts/core.ts +63 -4
  91. package/shortcuts/index.ts +1 -0
  92. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  93. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  94. package/shortcuts/runtime.ts +4 -3
  95. package/styles.css +1 -1
  96. package/theme.css +179 -1
  97. package/themes/plannotator-graphite.css +66 -0
  98. package/types.ts +26 -9
  99. package/utils/agentSwitch.ts +41 -20
  100. package/utils/agentToolSetting.ts +1 -1
  101. package/utils/aiChatFormat.ts +23 -7
  102. package/utils/annotationOwnsHighlight.ts +4 -2
  103. package/utils/asciidocParser.ts +570 -0
  104. package/utils/autoUpdateNotice.ts +24 -0
  105. package/utils/belowTitleSlot.ts +174 -0
  106. package/utils/codeLineScroll.ts +100 -0
  107. package/utils/commentSubmitKey.ts +48 -0
  108. package/utils/cssFontFamily.ts +65 -0
  109. package/utils/decisionSpec.ts +190 -2
  110. package/utils/defaultNotesApp.ts +1 -1
  111. package/utils/diffFonts.ts +4 -1
  112. package/utils/documentFont.ts +68 -0
  113. package/utils/documentTextSize.ts +54 -0
  114. package/utils/fontDetect.ts +63 -0
  115. package/utils/inboxAnnouncement.ts +78 -0
  116. package/utils/lookAndFeelAnnouncement.ts +82 -5
  117. package/utils/markdownExtensions.ts +2 -2
  118. package/utils/mermaid.ts +20 -3
  119. package/utils/mermaidTheme.ts +3 -51
  120. package/utils/notion.ts +58 -0
  121. package/utils/parser.ts +28 -1
  122. package/utils/platform.ts +46 -0
  123. package/utils/refCard.ts +82 -0
  124. package/utils/richClipboard.ts +380 -0
  125. package/utils/richClipboardLists.ts +57 -0
  126. package/utils/richCopyPolicy.ts +22 -0
  127. package/utils/scrollAnchor.ts +82 -0
  128. package/utils/sectionRefs.ts +123 -0
  129. package/utils/selectionBoundary.ts +40 -0
  130. package/utils/sendFailure.ts +85 -0
  131. package/utils/sharing.ts +5 -2
  132. package/utils/themeRegistry.ts +10 -0
  133. package/utils/themeTokens.ts +74 -0
  134. package/utils/updateNotice.ts +30 -8
  135. package/utils/vscodePanel.ts +9 -0
@@ -9,6 +9,12 @@ import type {
9
9
  UseAnnotationHighlighterReturn,
10
10
  } from "../../hooks/useAnnotationHighlighter";
11
11
  import { BRIDGE_PROTOCOL_VERSION } from "./bridge-script";
12
+ import {
13
+ MAX_RICH_CLIPBOARD_HTML_LENGTH,
14
+ MAX_RICH_CLIPBOARD_OUTPUT_LENGTH,
15
+ sanitizeRichHtml,
16
+ type RichCopySource,
17
+ } from "../../utils/richClipboard";
12
18
  import {
13
19
  parseHtmlElementContext,
14
20
  MAX_ELEMENT_CONTEXT_BYTES,
@@ -90,8 +96,27 @@ interface BridgeSelectionMessage {
90
96
  targetLabel?: string;
91
97
  /** Agent-facing element description (pinpoint clicks) — validated, size-capped. */
92
98
  context?: HtmlElementContext;
99
+ /** The bridge answers `rich-copy-request` for this text selection. */
100
+ richCopy?: boolean;
101
+ /** `text` was cut to MAX_SELECTION_TEXT_LENGTH (the page's selection is longer). */
102
+ textCut?: boolean;
93
103
  }
94
104
 
105
+ /** The bridge's answer to `rich-copy-request`: the pending range's markup
106
+ * (sanitized again here) and its text up to 256 KiB (`truncated` past it),
107
+ * or too large for rich text. */
108
+ interface BridgeRichCopyMessage {
109
+ type: `${typeof PREFIX}rich-copy`;
110
+ id: string;
111
+ html: string;
112
+ text: string;
113
+ truncated: boolean;
114
+ tooLarge: boolean;
115
+ }
116
+
117
+ /** How long the rich-text Copy waits for the bridge before copying the quote as plain text. */
118
+ export const RICH_COPY_TIMEOUT_MS = 1500;
119
+
95
120
  /** One draft target in an in-flight multi-select comment. Index 0 is primary. */
96
121
  export interface HtmlDraftTarget {
97
122
  key: string;
@@ -119,10 +144,12 @@ interface BridgeRect {
119
144
 
120
145
  type BridgeMessage =
121
146
  | BridgeSelectionMessage
147
+ | BridgeRichCopyMessage
122
148
  | BridgeMultiTargetAddedMessage
123
149
  | { type: `${typeof PREFIX}multi-target-removed`; key: string }
124
150
  | { type: `${typeof PREFIX}pointer`; x: number; y: number; shift: boolean }
125
151
  | { type: `${typeof PREFIX}selection-clear` }
152
+ | { type: `${typeof PREFIX}pin-visibility`; visible: boolean }
126
153
  | { type: `${typeof PREFIX}selection-rect`; rect: BridgeRect }
127
154
  | { type: `${typeof PREFIX}keytype`; key: string }
128
155
  | { type: `${typeof PREFIX}mark-click`; id: string }
@@ -363,6 +390,8 @@ export function parseBridgeMessage(value: unknown): BridgeMessage | null {
363
390
  return {
364
391
  type: value.type,
365
392
  text: capSelectionText(value.text),
393
+ ...(value.richCopy === true ? { richCopy: true } : {}),
394
+ ...(value.textCut === true ? { textCut: true } : {}),
366
395
  rect,
367
396
  modeOverride: parseEditorMode(value.modeOverride),
368
397
  anchor: parseHtmlElementAnchor(value.anchor) ?? undefined,
@@ -395,6 +424,24 @@ export function parseBridgeMessage(value: unknown): BridgeMessage | null {
395
424
  : null;
396
425
  case `${PREFIX}selection-clear`:
397
426
  return { type: value.type };
427
+ case `${PREFIX}rich-copy`: {
428
+ // Oversized or malformed markup reads as none (plain text is copied).
429
+ if (typeof value.id !== "string" || value.id.length === 0 || value.id.length > 64) return null;
430
+ const text = typeof value.text === "string" && value.text.length <= MAX_RICH_CLIPBOARD_HTML_LENGTH
431
+ ? value.text : "";
432
+ const html = typeof value.html === "string" && value.html.length <= MAX_RICH_CLIPBOARD_HTML_LENGTH
433
+ ? value.html : "";
434
+ return {
435
+ type: value.type,
436
+ id: value.id,
437
+ html,
438
+ text,
439
+ truncated: value.truncated === true,
440
+ tooLarge: value.tooLarge === true,
441
+ };
442
+ }
443
+ case `${PREFIX}pin-visibility`:
444
+ return typeof value.visible === 'boolean' ? { type: value.type, visible: value.visible } : null;
398
445
  case `${PREFIX}selection-rect`: {
399
446
  const rect = parseBridgeRect(value.rect);
400
447
  return rect ? { type: value.type, rect } : null;
@@ -474,6 +521,12 @@ export function useHtmlAnnotation({
474
521
  UseAnnotationHighlighterReturn,
475
522
  "highlighterRef" | "highlightRange" | "highlightMathElement"
476
523
  > & {
524
+ /** Whether any pinned draft target is still displayed by the page. Always
525
+ * true while no composer is open. */
526
+ pinVisible: boolean;
527
+ /** False once this draft's composer was hidden with its target: shown
528
+ * again, it must not take focus from where the person is. */
529
+ composerAutoFocus: boolean;
477
530
  /** In-flight multi-select targets (index 0 = primary); empty outside pinpoint drafts. */
478
531
  draftTargets: HtmlDraftTarget[];
479
532
  /** Remove one draft target (chip X). Removing the last cancels the draft. */
@@ -495,6 +548,17 @@ export function useHtmlAnnotation({
495
548
  const [quickLabelPicker, setQuickLabelPicker] = useState<QuickLabelPickerState | null>(null);
496
549
  const [draftTargets, setDraftTargets] = useState<HtmlDraftTarget[]>([]);
497
550
  const [composerFocusToken, setComposerFocusToken] = useState(0);
551
+ const [pinVisible, setPinVisible] = useState(true);
552
+ const [composerWasHidden, setComposerWasHidden] = useState(false);
553
+ const pinVisibleRef = useRef(pinVisible);
554
+ pinVisibleRef.current = pinVisible;
555
+ // Visibility belongs to one draft: whatever closes the composer (save, 👍,
556
+ // close, Escape, a cancelled multi-select, disabling) resets it.
557
+ useEffect(() => {
558
+ if (commentPopover) return;
559
+ setPinVisible(true);
560
+ setComposerWasHidden(false);
561
+ }, [commentPopover]);
498
562
 
499
563
  const pendingTextRef = useRef<string>("");
500
564
  // Element anchor for the pending pinpoint selection — committed onto the
@@ -553,6 +617,9 @@ export function useHtmlAnnotation({
553
617
  hostCapRef.current = maxAdditionalTargets !== undefined;
554
618
 
555
619
  const anchorRef = useRef<HTMLDivElement | null>(null);
620
+ /** Rich-text Copy requests waiting for the bridge's markup, by id. An
621
+ * answer with an id not in here (unsolicited, or after its timeout) is ignored. */
622
+ const richCopyRequestsRef = useRef(new Map<string, (answer: BridgeRichCopyMessage) => void>());
556
623
 
557
624
  /** Post into the iframe with the live token + targetOrigin when a live
558
625
  * session is active; srcdoc posts keep targetOrigin "*" and no token. */
@@ -665,7 +732,22 @@ export function useHtmlAnnotation({
665
732
  return;
666
733
  }
667
734
 
735
+ if (type === `${PREFIX}rich-copy`) {
736
+ const answer = richCopyRequestsRef.current.get(message.id);
737
+ if (!answer) return;
738
+ richCopyRequestsRef.current.delete(message.id);
739
+ answer(message);
740
+ return;
741
+ }
742
+
743
+ if (type === `${PREFIX}pin-visibility`) {
744
+ setPinVisible(message.visible);
745
+ if (!message.visible && commentPopoverRef.current) setComposerWasHidden(true);
746
+ }
747
+
668
748
  if (type === `${PREFIX}selection`) {
749
+ setPinVisible(true);
750
+ setComposerWasHidden(false);
669
751
  pendingTextRef.current = message.text;
670
752
  pendingAnchorRef.current = message.anchor ?? null;
671
753
  pendingContextRef.current = message.context ?? null;
@@ -760,6 +842,35 @@ export function useHtmlAnnotation({
760
842
  element: anchor,
761
843
  source: null,
762
844
  selectionText: message.text,
845
+ // The markup is built only when Copy is pressed: ask
846
+ // the bridge for the pending range's HTML and wait a moment.
847
+ ...(message.richCopy ? {
848
+ getRichCopy: () => new Promise<RichCopySource>((resolve) => {
849
+ const id = `rc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
850
+ const requests = richCopyRequestsRef.current;
851
+ // Without an answer only the quote is here: plain text, marked
852
+ // as cut when the bridge cut it.
853
+ const quote = { html: "", text: message.text, truncated: message.textCut === true };
854
+ requests.set(id, (answer) => {
855
+ const text = answer.text || quote.text;
856
+ const truncated = answer.text ? answer.truncated : quote.truncated;
857
+ if (answer.tooLarge) {
858
+ resolve({ tooLarge: true, text, truncated });
859
+ return;
860
+ }
861
+ const html = answer.html ? sanitizeRichHtml(answer.html) : null;
862
+ if (html && html.length > MAX_RICH_CLIPBOARD_OUTPUT_LENGTH) {
863
+ resolve({ tooLarge: true, text, truncated });
864
+ return;
865
+ }
866
+ resolve({ html: html ?? "", text, truncated });
867
+ });
868
+ window.setTimeout(() => {
869
+ if (requests.delete(id)) resolve(quote);
870
+ }, RICH_COPY_TIMEOUT_MS);
871
+ post({ type: `${PREFIX}rich-copy-request`, id });
872
+ }),
873
+ } : {}),
763
874
  });
764
875
  }
765
876
  }
@@ -803,6 +914,18 @@ export function useHtmlAnnotation({
803
914
  onHostDraftRef.current(null);
804
915
  }
805
916
  setToolbarState(null);
917
+ // The bridge closed the draft (its Escape rung) while the composer was
918
+ // hidden with its target: close the hidden composer too, or nothing on
919
+ // screen could ever close it. Its text stays in the draft store.
920
+ if (commentPopoverRef.current && !pinVisibleRef.current) {
921
+ post({ type: `${PREFIX}cancel-selection` });
922
+ setCommentPopover(null);
923
+ setDraftTargets([]);
924
+ pendingTextRef.current = "";
925
+ pendingAnchorRef.current = null;
926
+ pendingContextRef.current = null;
927
+ return;
928
+ }
806
929
  // Keep the captured text alive while a comment/quick-label is open: the user
807
930
  // is composing, and the selection collapsing or scrolling out of view must
808
931
  // not drop the annotation on submit. It's overwritten on the next selection.
@@ -1157,6 +1280,8 @@ export function useHtmlAnnotation({
1157
1280
 
1158
1281
  return {
1159
1282
  toolbarState,
1283
+ pinVisible: pinVisible || !commentPopover,
1284
+ composerAutoFocus: !composerWasHidden,
1160
1285
  commentPopover,
1161
1286
  quickLabelPicker,
1162
1287
  handleAnnotate,
@@ -0,0 +1,265 @@
1
+ /**
2
+ * The image lightbox's zoom math, kept pure so it can be tested without a
3
+ * layout engine. A viewport is a translate plus a scale on the image, where
4
+ * scale 1 is the image's own pixels (Actual size) and the translate is the
5
+ * image's top-left corner inside the stage, in CSS pixels.
6
+ *
7
+ * The rules: the smallest scale is Fit (never above 1, so a small image is
8
+ * not blown up), the largest is 8 (800%); an axis on which the image is
9
+ * smaller than the stage is centred, and an axis on which it is larger is
10
+ * kept covering the stage, so the image can never be panned out of sight.
11
+ */
12
+
13
+ export interface ZoomSize {
14
+ readonly width: number;
15
+ readonly height: number;
16
+ }
17
+
18
+ export interface ZoomViewport {
19
+ readonly x: number;
20
+ readonly y: number;
21
+ readonly scale: number;
22
+ }
23
+
24
+ export interface ZoomLimits {
25
+ readonly min: number;
26
+ readonly max: number;
27
+ }
28
+
29
+ /** The share of the stage Fit may fill on each axis (1 = edge to edge). */
30
+ export interface ZoomInset {
31
+ readonly x: number;
32
+ readonly y: number;
33
+ }
34
+
35
+ /** 800%: an image pixel is then 8 screen pixels, enough to read any screenshot. */
36
+ export const LIGHTBOX_ZOOM_MAX = 8;
37
+ /** Wheel zoom: factor = exp(-deltaY * sensitivity), with deltaY capped so a
38
+ * mouse notch (100 px) is about 1.28x, close to the diagram canvas's step,
39
+ * while a trackpad pinch (a few px per event) stays smooth. */
40
+ export const WHEEL_ZOOM_SENSITIVITY = 0.01;
41
+ export const WHEEL_ZOOM_DELTA_CAP = 25;
42
+ /** A plain-wheel gesture keeps the meaning it started with (zoom or scroll)
43
+ * until the wheel rests this long, so wheeling in past the screen's edge
44
+ * does not flip from zooming to scrolling halfway through. */
45
+ export const WHEEL_LATCH_MS = 250;
46
+ /** A press that travels less than this is a tap (a click), not a pan. */
47
+ export const TAP_SLOP_MOUSE_PX = 4;
48
+ export const TAP_SLOP_TOUCH_PX = 10;
49
+ /** Two taps this close in time and space are a double tap (or double click). */
50
+ export const DOUBLE_TAP_MS = 300;
51
+ export const DOUBLE_TAP_SLOP_PX = 24;
52
+ /** A double click at Fit zooms to twice the Fit scale. */
53
+ export const DOUBLE_TAP_ZOOM = 2;
54
+
55
+ /**
56
+ * Which way Fit fits: the whole image (`contain`, the default), or its width
57
+ * (a tall screenshot read from the top) or height (a panorama read from the
58
+ * left). The smallest scale is always `contain`, so a reader can zoom out to
59
+ * the whole image from either.
60
+ */
61
+ export type FitAxis = 'contain' | 'width' | 'height';
62
+
63
+ const FULL: ZoomInset = { x: 1, y: 1 };
64
+
65
+ function valid(size: ZoomSize | null | undefined): size is ZoomSize {
66
+ return !!size && size.width > 0 && size.height > 0;
67
+ }
68
+
69
+ export function clampNumber(value: number, min: number, max: number): number {
70
+ return Math.min(max, Math.max(min, value));
71
+ }
72
+
73
+ /** The scale at which the whole image (or its width, or its height) fits the stage, never above 1. */
74
+ export function fitScale(image: ZoomSize, stage: ZoomSize, inset: ZoomInset = FULL, axis: FitAxis = 'contain'): number {
75
+ if (!valid(image) || !valid(stage)) return 1;
76
+ const across = (stage.width * inset.x) / image.width;
77
+ const down = (stage.height * inset.y) / image.height;
78
+ if (axis === 'width') return Math.min(1, across);
79
+ if (axis === 'height') return Math.min(1, down);
80
+ return Math.min(1, across, down);
81
+ }
82
+
83
+ export function zoomLimits(image: ZoomSize, stage: ZoomSize, inset: ZoomInset = FULL): ZoomLimits {
84
+ const min = fitScale(image, stage, inset);
85
+ return { min, max: Math.max(min, LIGHTBOX_ZOOM_MAX) };
86
+ }
87
+
88
+ function placeAxis(position: number, content: number, room: number): number {
89
+ if (content <= room) return (room - content) / 2;
90
+ return clampNumber(position, room - content, 0);
91
+ }
92
+
93
+ /** The scale within the limits, and the image centred or covering the stage on each axis. */
94
+ export function clampViewport(view: ZoomViewport, image: ZoomSize, stage: ZoomSize, limits: ZoomLimits): ZoomViewport {
95
+ const scale = clampNumber(view.scale, limits.min, limits.max);
96
+ return {
97
+ scale,
98
+ x: placeAxis(view.x, image.width * scale, stage.width),
99
+ y: placeAxis(view.y, image.height * scale, stage.height),
100
+ };
101
+ }
102
+
103
+ /** Fit: the whole image, centred (or its width from the top, or its height from the left). */
104
+ export function fitViewport(image: ZoomSize, stage: ZoomSize, inset: ZoomInset = FULL, axis: FitAxis = 'contain'): ZoomViewport {
105
+ const limits = zoomLimits(image, stage, inset);
106
+ return clampViewport({ x: 0, y: 0, scale: fitScale(image, stage, inset, axis) }, image, stage, limits);
107
+ }
108
+
109
+ /**
110
+ * Actual size: the image's own pixels. An axis on which it is larger than the
111
+ * stage starts at its top or left edge (a tall screenshot is read from the
112
+ * top); an axis on which it is smaller is centred.
113
+ */
114
+ export function actualViewport(image: ZoomSize, stage: ZoomSize, inset: ZoomInset = FULL): ZoomViewport {
115
+ const limits = zoomLimits(image, stage, inset);
116
+ return clampViewport({ x: 0, y: 0, scale: 1 }, image, stage, limits);
117
+ }
118
+
119
+ /** Zoom by `factor` about a point of the stage, so the pixel under it stays under it. */
120
+ export function zoomAt(
121
+ view: ZoomViewport,
122
+ factor: number,
123
+ px: number,
124
+ py: number,
125
+ image: ZoomSize,
126
+ stage: ZoomSize,
127
+ limits: ZoomLimits,
128
+ ): ZoomViewport {
129
+ const scale = clampNumber(view.scale * factor, limits.min, limits.max);
130
+ const ratio = scale / view.scale;
131
+ return clampViewport({ x: px - (px - view.x) * ratio, y: py - (py - view.y) * ratio, scale }, image, stage, limits);
132
+ }
133
+
134
+ /** Move the image by a pointer's travel (or a scroll's, negated by the caller). */
135
+ export function panBy(view: ZoomViewport, dx: number, dy: number, image: ZoomSize, stage: ZoomSize, limits: ZoomLimits): ZoomViewport {
136
+ return clampViewport({ x: view.x + dx, y: view.y + dy, scale: view.scale }, image, stage, limits);
137
+ }
138
+
139
+ /** Whether the image is larger than the stage on either axis (so there is something to pan). */
140
+ export function overflows(view: ZoomViewport, image: ZoomSize, stage: ZoomSize): boolean {
141
+ return image.width * view.scale > stage.width + 0.5 || image.height * view.scale > stage.height + 0.5;
142
+ }
143
+
144
+ /** Whether a viewport is at the Fit scale (`limits.min`, or a given Fit scale). */
145
+ export function atFit(view: ZoomViewport, limits: ZoomLimits | number): boolean {
146
+ const fit = typeof limits === 'number' ? limits : limits.min;
147
+ return Math.abs(view.scale - fit) < 1e-3;
148
+ }
149
+
150
+ /** The share of a stage that leaves `padding` CSS pixels on every side (at least a tenth). */
151
+ export function paddingInset(stage: ZoomSize, padding: number): ZoomInset {
152
+ if (!valid(stage) || padding <= 0) return FULL;
153
+ return { x: Math.max(0.1, (stage.width - 2 * padding) / stage.width), y: Math.max(0.1, (stage.height - 2 * padding) / stage.height) };
154
+ }
155
+
156
+ /**
157
+ * A wheel event's travel in CSS pixels. Lines (Firefox's mouse wheel) count
158
+ * 16 px and pages the stage's height. Shift turns a vertical wheel sideways,
159
+ * as it does on a page (macOS already reports it as deltaX).
160
+ */
161
+ export function wheelTravel(
162
+ event: { deltaX: number; deltaY: number; deltaMode: number; shiftKey: boolean },
163
+ pageHeight: number,
164
+ ): { dx: number; dy: number } {
165
+ const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? pageHeight : 1;
166
+ let dx = event.deltaX * unit;
167
+ let dy = event.deltaY * unit;
168
+ if (event.shiftKey && dx === 0) {
169
+ dx = dy;
170
+ dy = 0;
171
+ }
172
+ return { dx, dy };
173
+ }
174
+
175
+ /** The zoom factor for a wheel's vertical travel: up (negative) zooms in. */
176
+ export function wheelZoomFactor(dy: number): number {
177
+ return Math.exp(-clampNumber(dy, -WHEEL_ZOOM_DELTA_CAP, WHEEL_ZOOM_DELTA_CAP) * WHEEL_ZOOM_SENSITIVITY);
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Region boxes (#2100): a box drawn on the image is kept in the image's own
182
+ // (natural) pixels, so it is the same box at every zoom and pan, and the
183
+ // agent can crop exactly that spot. These convert between the stage (CSS
184
+ // pixels, relative to the stage's top-left) and the image.
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /** A rectangle in the image's own pixels (integers once made by `naturalBoxFromPoints`). */
188
+ export interface NaturalBox {
189
+ readonly x: number;
190
+ readonly y: number;
191
+ readonly w: number;
192
+ readonly h: number;
193
+ }
194
+
195
+ /** A rectangle in stage pixels. */
196
+ export interface StageRect {
197
+ readonly left: number;
198
+ readonly top: number;
199
+ readonly width: number;
200
+ readonly height: number;
201
+ }
202
+
203
+ /** A stage point (CSS pixels from the stage's top-left) in the image's own pixels. Not clamped. */
204
+ export function stageToNatural(view: ZoomViewport, px: number, py: number): { x: number; y: number } {
205
+ return { x: (px - view.x) / view.scale, y: (py - view.y) / view.scale };
206
+ }
207
+
208
+ /**
209
+ * The box two stage points span (a drag's start and end, in either
210
+ * direction), in the image's own pixels: clamped to the image, widened to
211
+ * whole pixels (floor the near edges, ceil the far ones), so the box always
212
+ * covers what the reviewer drew. Null when nothing of it is on the image.
213
+ */
214
+ export function naturalBoxFromPoints(
215
+ view: ZoomViewport,
216
+ a: { x: number; y: number },
217
+ b: { x: number; y: number },
218
+ image: ZoomSize,
219
+ ): NaturalBox | null {
220
+ const p = stageToNatural(view, a.x, a.y);
221
+ const q = stageToNatural(view, b.x, b.y);
222
+ const x0 = clampNumber(Math.floor(Math.min(p.x, q.x)), 0, image.width);
223
+ const y0 = clampNumber(Math.floor(Math.min(p.y, q.y)), 0, image.height);
224
+ const x1 = clampNumber(Math.ceil(Math.max(p.x, q.x)), 0, image.width);
225
+ const y1 = clampNumber(Math.ceil(Math.max(p.y, q.y)), 0, image.height);
226
+ if (x1 - x0 < 1 || y1 - y0 < 1) return null;
227
+ return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
228
+ }
229
+
230
+ /** Where a natural-pixel box is on the stage at a viewport. */
231
+ export function naturalBoxToStage(view: ZoomViewport, box: NaturalBox): StageRect {
232
+ return { left: view.x + box.x * view.scale, top: view.y + box.y * view.scale, width: box.w * view.scale, height: box.h * view.scale };
233
+ }
234
+
235
+ /**
236
+ * The part of the image the stage shows now, in the image's own pixels:
237
+ * what Enter comments on in Region mode (the keyboard's way to a box: frame
238
+ * the spot with Actual size and the scroll keys, then press Enter). Null when
239
+ * nothing of the image is on the stage.
240
+ */
241
+ export function visibleNaturalBox(view: ZoomViewport, image: ZoomSize, stage: ZoomSize): NaturalBox | null {
242
+ return naturalBoxFromPoints(view, { x: 0, y: 0 }, { x: stage.width, y: stage.height }, image);
243
+ }
244
+
245
+ /** The share of the stage a box fills when the viewer zooms to it. */
246
+ export const ZOOM_TO_BOX_SHARE = 0.5;
247
+
248
+ /**
249
+ * The viewport that shows a box large and centred: it fills `share` of the
250
+ * stage on its tighter axis, within the zoom limits (a box larger than that
251
+ * share at Fit stays at Fit), then the usual clamp keeps the image covering
252
+ * the stage, so a box at an edge sits as near the centre as the image allows.
253
+ */
254
+ export function viewportForBox(
255
+ box: NaturalBox,
256
+ image: ZoomSize,
257
+ stage: ZoomSize,
258
+ limits: ZoomLimits,
259
+ share = ZOOM_TO_BOX_SHARE,
260
+ ): ZoomViewport {
261
+ const scale = clampNumber(Math.min((stage.width * share) / Math.max(1, box.w), (stage.height * share) / Math.max(1, box.h)), limits.min, limits.max);
262
+ const cx = box.x + box.w / 2;
263
+ const cy = box.y + box.h / 2;
264
+ return clampViewport({ scale, x: stage.width / 2 - cx * scale, y: stage.height / 2 - cy * scale }, image, stage, limits);
265
+ }
@@ -573,7 +573,7 @@ const DiffBlockRenderer: React.FC<DiffBlockRendererProps> = ({
573
573
  switch (block.type) {
574
574
  case "unchanged":
575
575
  return (
576
- <div className={`plan-diff-unchanged ${unchangedClassName ?? DEFAULT_UNCHANGED_CLASS}`}>
576
+ <div className={`plan-diff-unchanged ${unchangedClassName ?? DEFAULT_UNCHANGED_CLASS}`} data-doc-text="">
577
577
  <MarkdownChunk content={block.content} />
578
578
  </div>
579
579
  );
@@ -36,6 +36,12 @@ interface PlanDiffViewerProps {
36
36
  selectedAnnotationId?: string | null;
37
37
  mode?: EditorMode;
38
38
  onOpenVscodeDiff?: (baseVersion: number) => Promise<{ ok?: boolean; error?: string }>;
39
+ /**
40
+ * Grow the diff card to the full height the parent gives it, like Viewer's
41
+ * `fillHeight`, so a short diff's card still runs to the bottom of the view.
42
+ * Default false: the card sizes to its content.
43
+ */
44
+ fillHeight?: boolean;
39
45
  }
40
46
 
41
47
  const defaultOpenVscodeDiff = async (
@@ -77,6 +83,7 @@ export const PlanDiffViewer: React.FC<PlanDiffViewerProps> = ({
77
83
  selectedAnnotationId,
78
84
  mode,
79
85
  onOpenVscodeDiff,
86
+ fillHeight = false,
80
87
  }) => {
81
88
  const [vscodeDiffLoading, setVscodeDiffLoading] = useState(false);
82
89
  const [vscodeDiffError, setVscodeDiffError] = useState<string | null>(null);
@@ -102,8 +109,12 @@ export const PlanDiffViewer: React.FC<PlanDiffViewerProps> = ({
102
109
  };
103
110
 
104
111
  return (
105
- <div className="relative z-50 w-full" style={maxWidth ? { maxWidth } : { maxWidth: 832 }}>
106
- <article className="w-full bg-card border border-border/50 rounded-xl shadow-xl p-5 md:p-8 lg:p-10 xl:p-12 relative">
112
+ <div
113
+ className={`relative z-50 w-full${fillHeight ? ' flex flex-col print:block' : ''}`}
114
+ data-viewer-fill-height={fillHeight ? '' : undefined}
115
+ style={maxWidth ? { maxWidth } : { maxWidth: 832 }}
116
+ >
117
+ <article className={`w-full ${fillHeight ? 'flex-1 ' : ''}bg-card border border-border/50 rounded-xl shadow-xl p-5 md:p-8 lg:p-10 xl:p-12 relative`} data-doc-text-root="">
107
118
  {/* Top-left: repo info + diff badge — matches Viewer layout (flex-col) so badge doesn't jump position */}
108
119
  <div className="absolute top-3 left-3 md:top-4 md:left-5 flex flex-col items-start gap-1 text-[9px] text-muted-foreground/50 font-mono">
109
120
  {repoInfo && (
@@ -67,7 +67,7 @@ export const PlanRawDiffView: React.FC<PlanRawDiffViewProps> = ({ blocks }) => {
67
67
  }, [blocks]);
68
68
 
69
69
  return (
70
- <div className="font-mono text-[13px] leading-relaxed bg-muted/30 rounded-lg border border-border/30 overflow-x-auto">
70
+ <div className="font-mono text-[13px] leading-relaxed bg-muted/30 rounded-lg border border-border/30 overflow-x-auto" data-doc-text="">
71
71
  <div>
72
72
  {lines.map((line, index) => (
73
73
  <div
@@ -10,6 +10,7 @@ import { Search, X } from "lucide-react";
10
10
  import type { VaultNode } from "../../types";
11
11
  import type { DirState } from "../../hooks/useFileBrowser";
12
12
  import { CountBadge } from "./CountBadge";
13
+ import { HostTitle } from "../HostTitle";
13
14
  import { ObsidianIconRaw } from "../icons/ObsidianIcons";
14
15
  import type { WorkspaceFileChange, WorkspaceStatusPayload } from "@plannotator/core/workspace-status-types";
15
16
  import { normalizeBrowserPath } from "@plannotator/core/browser-paths";
@@ -282,7 +283,9 @@ const TreeNode: React.FC<{
282
283
  <svg className="w-3 h-3 flex-shrink-0 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
283
284
  <path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
284
285
  </svg>
285
- <span className="truncate">{node.name}</span>
286
+ <HostTitle title={node.name} cut>
287
+ <span className="truncate">{node.name}</span>
288
+ </HostTitle>
286
289
  <div className="ml-auto flex flex-shrink-0 items-center gap-1.5 text-[10px]">
287
290
  {(aggregateChange.additions > 0 || aggregateChange.deletions > 0) && (
288
291
  <>
@@ -360,7 +363,9 @@ const TreeNode: React.FC<{
360
363
  <svg className="w-3 h-3 flex-shrink-0 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
361
364
  <path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
362
365
  </svg>
363
- <span className={`truncate flex-1 min-w-0 ${isDeleted ? "line-through" : ""}`}>{displayName}</span>
366
+ <HostTitle title={node.name} cut>
367
+ <span className={`truncate flex-1 min-w-0 ${isDeleted ? "line-through" : ""}`}>{displayName}</span>
368
+ </HostTitle>
364
369
  <div className="ml-auto flex flex-shrink-0 items-center gap-1.5 text-[10px]">
365
370
  {isActive && selectionPending && (
366
371
  <span className="text-primary">Opening…</span>
@@ -455,10 +460,19 @@ const DirSection: React.FC<{
455
460
  selectionPending={selectionPending}
456
461
  />
457
462
  ))}
463
+ {dir.truncated && <FileLimitNote limit={dir.fileLimit} />}
458
464
  </div>
459
465
  );
460
466
  };
461
467
 
468
+ /** The folder holds more supported files than the server lists; say so once, quietly, at the foot of its tree. */
469
+ export const FileLimitNote: React.FC<{ limit?: number }> = ({ limit }) => (
470
+ <div data-file-limit-note className="px-2 pt-2 pb-1 text-[10px] leading-snug text-muted-foreground">
471
+ {limit ? `Showing the first ${limit.toLocaleString("en-US")} files.` : "Showing some of the files."}{" "}
472
+ Raise <code className="font-mono">PLANNOTATOR_FILE_BROWSER_MAX_FILES</code> to see more.
473
+ </div>
474
+ );
475
+
462
476
  export const FileBrowser: React.FC<FileBrowserProps> = ({
463
477
  dirs,
464
478
  expandedFolders,
@@ -24,6 +24,7 @@ interface SidebarTabsProps {
24
24
  hasFileAnnotations?: boolean;
25
25
  hasMessageAnnotations?: boolean;
26
26
  className?: string;
27
+ style?: React.CSSProperties;
27
28
  }
28
29
 
29
30
  export const SidebarTabs: React.FC<SidebarTabsProps> = ({
@@ -40,11 +41,13 @@ export const SidebarTabs: React.FC<SidebarTabsProps> = ({
40
41
  hasFileAnnotations,
41
42
  hasMessageAnnotations,
42
43
  className,
44
+ style,
43
45
  }) => {
44
46
  return (
45
47
  <div
46
48
  data-sidebar-tabs="true"
47
49
  className={`flex flex-col gap-1 pt-3 pl-0.5 flex-shrink-0 ${className ?? ""}`}
50
+ style={style}
48
51
  >
49
52
  {showAgentTerminalTab && onToggleAgentTerminal && (
50
53
  <button
package/config/index.ts CHANGED
@@ -4,9 +4,12 @@ export { useConfigValue } from './useConfig';
4
4
  export {
5
5
  setReviewPanelView,
6
6
  setReviewDefaultDiffType,
7
+ getProviderReviewDefaultDiffType,
8
+ setProviderReviewDefaultDiffType,
7
9
  getPersistedReviewPanelView,
8
10
  setReviewAutoViewed,
9
11
  needsAutoViewedNotice,
10
12
  markAutoViewedNoticeSeen,
11
13
  type ReviewDefaultDiffType,
14
+ type VcsReviewSettingsDescriptor,
12
15
  } from './reviewView';
@@ -2,6 +2,20 @@ import { configStore } from './configStore';
2
2
  import { SETTINGS } from './settings';
3
3
  import { storage } from '../utils/storage';
4
4
 
5
+ export interface ReviewSettingsDiffOption {
6
+ id: string;
7
+ label: string;
8
+ description: string;
9
+ }
10
+
11
+ /** The default-diff choices Settings → Review offers for one VCS. */
12
+ export interface VcsReviewSettingsDescriptor {
13
+ id: string;
14
+ label: string;
15
+ defaultDiffType: string;
16
+ diffOptions: ReviewSettingsDiffOption[];
17
+ }
18
+
5
19
  /**
6
20
  * The ONLY writers for the coupled setting pair (reviewPanelView,
7
21
  * defaultDiffType).
@@ -81,6 +95,32 @@ export function setReviewDefaultDiffType(
81
95
  }
82
96
  }
83
97
 
98
+ /**
99
+ * The saved default diff for a VCS other than Git (today: Jujutsu), read from
100
+ * `reviewDefaults[id]`. Git keeps its original `defaultDiffType` setting and
101
+ * its coupled setter above; a value this VCS does not offer reads as its own
102
+ * default, never as a Git mode.
103
+ */
104
+ export function getProviderReviewDefaultDiffType(
105
+ descriptor: VcsReviewSettingsDescriptor,
106
+ store: PanelViewConfigStore = configStore,
107
+ ): string {
108
+ const configured = store.get('reviewDefaults')[descriptor.id]?.defaultDiffType;
109
+ return configured && descriptor.diffOptions.some((option) => option.id === configured)
110
+ ? configured
111
+ : descriptor.defaultDiffType;
112
+ }
113
+
114
+ export function setProviderReviewDefaultDiffType(
115
+ providerId: string,
116
+ value: string,
117
+ store: PanelViewConfigStore = configStore,
118
+ ): void {
119
+ store.set('reviewDefaults', {
120
+ ...store.get('reviewDefaults'),
121
+ [providerId]: { defaultDiffType: value },
122
+ });
123
+ }
84
124
 
85
125
  /**
86
126
  * One-time gate for the auto-mark-viewed notice — the toast that fires the
@@ -90,7 +130,7 @@ export function setReviewDefaultDiffType(
90
130
  * meaningful revision can re-show it.
91
131
  *
92
132
  * It lives beside the setting rather than in the review app because BOTH
93
- * writers of the setting (Settings > Git here, and the file-list gear in the
133
+ * writers of the setting (Settings → Review here, and the file-list gear in the
94
134
  * review app) must consume the gate: someone who found the switch has
95
135
  * demonstrably discovered the feature and must never be told about it.
96
136
  */