@plannotator/ui 0.53.1 → 0.55.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 (149) hide show
  1. package/HANDOFF.md +274 -14
  2. package/README.md +114 -11
  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 +22 -5
  12. package/components/CodeFilePopout.tsx +139 -4
  13. package/components/CodeSnippetPreview.tsx +174 -0
  14. package/components/CommentPopover.tsx +44 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DeferredMarkdownContentsRail.tsx +38 -0
  17. package/components/DiagramBlock.tsx +17 -12
  18. package/components/DiffFileTree.tsx +7 -6
  19. package/components/DocumentContents.tsx +74 -0
  20. package/components/EditorAnnotationCard.tsx +1 -1
  21. package/components/ExportModal.tsx +77 -7
  22. package/components/HostTitle.tsx +0 -4
  23. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  24. package/components/ImageAnnotator/index.tsx +24 -4
  25. package/components/ImageLightbox.tsx +722 -126
  26. package/components/ImagePeek.tsx +38 -4
  27. package/components/ImageRegions.tsx +369 -0
  28. package/components/ImageThumbnail.tsx +1 -1
  29. package/components/ImageZoomStage.tsx +796 -0
  30. package/components/InboxAnnouncementDialog.tsx +202 -0
  31. package/components/InlineMarkdown.tsx +167 -174
  32. package/components/KeyboardShortcuts.tsx +23 -0
  33. package/components/LazyBlockBoundary.tsx +100 -0
  34. package/components/ListMarker.tsx +3 -3
  35. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  36. package/components/MarkdownContentsRail.tsx +777 -0
  37. package/components/MenuVersionSection.tsx +31 -9
  38. package/components/PlanDecisionMenuExtras.tsx +129 -0
  39. package/components/PlanHeaderMenu.tsx +15 -1
  40. package/components/ProviderIcons.tsx +8 -0
  41. package/components/RefHoverCard.tsx +913 -0
  42. package/components/SendFailureToast.tsx +126 -0
  43. package/components/Settings.tsx +617 -126
  44. package/components/TableOfContents.tsx +20 -6
  45. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  46. package/components/ThemeProvider.tsx +14 -1
  47. package/components/ThemeTab.tsx +16 -24
  48. package/components/Tooltip.tsx +49 -18
  49. package/components/Viewer.tsx +288 -43
  50. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  51. package/components/ai/NewChatButton.tsx +74 -0
  52. package/components/blocks/CodeBlock.tsx +1 -1
  53. package/components/blocks/HtmlBlock.tsx +7 -1
  54. package/components/blocks/QuestionBlock.tsx +532 -62
  55. package/components/diagram/DiagramComposer.tsx +5 -1
  56. package/components/diagram/DiagramPending.tsx +12 -2
  57. package/components/diagram/DiagramViewer.tsx +19 -3
  58. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  59. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  60. package/components/html-embed/index.ts +3 -0
  61. package/components/html-embed/languages.ts +15 -0
  62. package/components/html-embed/loadGate.ts +107 -0
  63. package/components/html-viewer/HtmlViewer.tsx +82 -6
  64. package/components/html-viewer/bridge-script.asset.js +173 -1
  65. package/components/html-viewer/bridge-script.ts +173 -1
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +276 -0
  68. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  69. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  70. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  71. package/components/sidebar/FileBrowser.tsx +16 -2
  72. package/components/sidebar/SidebarTabs.tsx +3 -0
  73. package/config/index.ts +3 -0
  74. package/config/reviewView.ts +41 -1
  75. package/config/settings.ts +155 -4
  76. package/configure.ts +28 -0
  77. package/contents-rail.css +337 -0
  78. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  79. package/hooks/useAIChat.ts +72 -25
  80. package/hooks/useAnnotationDraft.ts +71 -6
  81. package/hooks/useAnnotationHighlighter.ts +57 -3
  82. package/hooks/useCodeAnnotationDraft.ts +113 -17
  83. package/hooks/useCookieHostSetting.ts +96 -0
  84. package/hooks/useDocumentFont.ts +17 -0
  85. package/hooks/useDocumentTextSize.ts +20 -0
  86. package/hooks/useFileBrowser.ts +10 -1
  87. package/hooks/useHeadingScrollSpy.ts +77 -0
  88. package/hooks/useHoverPreview.ts +94 -0
  89. package/hooks/useLinkedDoc.ts +226 -13
  90. package/hooks/usePackageChangesSetting.ts +90 -0
  91. package/hooks/usePlanLookDialog.ts +67 -0
  92. package/hooks/usePrefersReducedMotion.ts +23 -0
  93. package/hooks/useSharing.ts +11 -7
  94. package/hooks/useSwipeStep.ts +597 -0
  95. package/hooks/useThemePaletteHalves.ts +37 -0
  96. package/hooks/useUpdateCheck.ts +22 -4
  97. package/hooks/useUpdateToast.ts +1 -1
  98. package/package.json +26 -23
  99. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  100. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  101. package/shortcuts/core.ts +63 -4
  102. package/shortcuts/index.ts +1 -0
  103. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  104. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  105. package/shortcuts/runtime.ts +4 -3
  106. package/styles.css +1 -1
  107. package/theme.css +179 -1
  108. package/themes/plannotator-graphite.css +66 -0
  109. package/types.ts +26 -9
  110. package/utils/agentSwitch.ts +41 -20
  111. package/utils/agentToolSetting.ts +1 -1
  112. package/utils/aiChatFormat.ts +23 -7
  113. package/utils/annotationHelpers.ts +32 -4
  114. package/utils/annotationOwnsHighlight.ts +4 -2
  115. package/utils/asciidocParser.ts +570 -0
  116. package/utils/autoUpdateNotice.ts +24 -0
  117. package/utils/belowTitleSlot.ts +174 -0
  118. package/utils/blockParse.ts +45 -0
  119. package/utils/codeLineScroll.ts +100 -0
  120. package/utils/commentSubmitKey.ts +48 -0
  121. package/utils/cssFontFamily.ts +65 -0
  122. package/utils/decisionSpec.ts +190 -2
  123. package/utils/defaultNotesApp.ts +1 -1
  124. package/utils/diffFonts.ts +4 -1
  125. package/utils/documentFont.ts +68 -0
  126. package/utils/documentTextSize.ts +54 -0
  127. package/utils/fontDetect.ts +63 -0
  128. package/utils/headingScrollSpy.ts +92 -0
  129. package/utils/inboxAnnouncement.ts +78 -0
  130. package/utils/lookAndFeelAnnouncement.ts +82 -5
  131. package/utils/markdownExtensions.ts +2 -2
  132. package/utils/mermaid.ts +20 -3
  133. package/utils/mermaidTheme.ts +3 -51
  134. package/utils/notion.ts +58 -0
  135. package/utils/parser.ts +28 -1
  136. package/utils/platform.ts +46 -0
  137. package/utils/refCard.ts +82 -0
  138. package/utils/richClipboard.ts +380 -0
  139. package/utils/richClipboardLists.ts +57 -0
  140. package/utils/richCopyPolicy.ts +22 -0
  141. package/utils/scrollAnchor.ts +82 -0
  142. package/utils/sectionRefs.ts +123 -0
  143. package/utils/selectionBoundary.ts +40 -0
  144. package/utils/sendFailure.ts +85 -0
  145. package/utils/sharing.ts +5 -2
  146. package/utils/themeRegistry.ts +10 -0
  147. package/utils/themeTokens.ts +74 -0
  148. package/utils/updateNotice.ts +30 -8
  149. package/utils/vscodePanel.ts +9 -0
@@ -0,0 +1,174 @@
1
+ /**
2
+ * The `Viewer` `belowTitle` host slot, and how stored text positions stay
3
+ * independent of it.
4
+ *
5
+ * web-highlighter stores a comment's position as `{ parentTagName,
6
+ * parentIndex, textOffset }`, where `parentIndex` is the index of the parent
7
+ * element among EVERY element of that tag under the annotation root (the
8
+ * Viewer's `<article>`). The slot sits inside that root, right after the
9
+ * title, so whatever a host renders in it (a `<p>`, a `<span>`, an `<a>`)
10
+ * would renumber every later element of the same tag. That would make a
11
+ * comment saved with the slot present resolve onto other text without it (or
12
+ * with different slot contents), and the other way round.
13
+ *
14
+ * So the hook translates at its two edges: a new selection's metas are
15
+ * rewritten to the index the element would have WITHOUT the slot
16
+ * (`metaWithoutBelowTitle`), and a stored meta is mapped back to the live
17
+ * element before `fromStore` (`metaWithBelowTitle`). With no slot in the root
18
+ * both are the identity, so a document without one stores and restores
19
+ * exactly as before.
20
+ */
21
+
22
+ /** Marks the slot's wrapper. Presence is enough for a host's CSS. */
23
+ export const BELOW_TITLE_SLOT_ATTRIBUTE = 'data-viewer-below-title';
24
+
25
+ /**
26
+ * The attribute's value on the wrapper the Viewer draws itself: random per
27
+ * page load, so a `<div data-viewer-below-title>` an author writes as raw
28
+ * HTML in a document (the sanitizer keeps `data-*`) is never taken for the
29
+ * slot. Only a direct child of the root carrying exactly this value counts.
30
+ */
31
+ export const BELOW_TITLE_SLOT_MARKER = `slot-${Math.random().toString(36).slice(2, 12)}`;
32
+
33
+ /** web-highlighter's sentinels (`ROOT_IDX`, `UNKNOWN_IDX`): never translated. */
34
+ const isElementIndex = (index: number): boolean => Number.isInteger(index) && index >= 0;
35
+
36
+ interface TextMeta {
37
+ parentTagName: string;
38
+ parentIndex: number;
39
+ textOffset: number;
40
+ }
41
+
42
+ /** Which end of a selection a meta is; decides where one inside the slot goes. */
43
+ export type MetaEdge = 'start' | 'end';
44
+
45
+ /** The Viewer's own slot wrapper in `root`, or null. */
46
+ const findSlot = (root: Element): Element | null => {
47
+ for (const child of Array.from(root.children)) {
48
+ if (child.getAttribute(BELOW_TITLE_SLOT_ATTRIBUTE) === BELOW_TITLE_SLOT_MARKER) return child;
49
+ }
50
+ return null;
51
+ };
52
+
53
+ /** web-highlighter's own parent rule: skip its highlight wrappers. */
54
+ const originParent = (node: Node): HTMLElement | null => {
55
+ let parent = node.parentElement;
56
+ while (parent && parent.dataset?.highlightId) parent = parent.parentElement;
57
+ return parent;
58
+ };
59
+
60
+ /** Text that is never part of a comment (the highlighter's exclusions). */
61
+ const isExcludedText = (node: Text): boolean =>
62
+ node.parentElement?.closest('.annotation-exclude, .annotation-toolbar, button, .katex, .math-annotatable') != null;
63
+
64
+ /** The text offset of `text`'s end (or start) inside `parent`, counted the
65
+ * way web-highlighter counts it: every text node under `parent` in order. */
66
+ const offsetIn = (parent: Element, text: Text, atEnd: boolean): number => {
67
+ const walker = parent.ownerDocument.createTreeWalker(parent, NodeFilter.SHOW_TEXT);
68
+ let offset = 0;
69
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
70
+ if (node === text) return offset + (atEnd ? text.length : 0);
71
+ offset += (node as Text).length;
72
+ }
73
+ return offset;
74
+ };
75
+
76
+ /**
77
+ * A boundary that landed inside the slot, moved out of it: an end goes back
78
+ * to the end of the last comment text before the slot, a start forward to
79
+ * the beginning of the first after it. Returned as a LIVE meta.
80
+ */
81
+ const snapOutOfSlot = <M extends TextMeta>(root: Element, slot: Element, meta: M, edge: MetaEdge): M => {
82
+ const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT);
83
+ let chosen: Text | null = null;
84
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
85
+ const text = node as Text;
86
+ if (!text.length || slot.contains(text) || isExcludedText(text)) continue;
87
+ const before = (slot.compareDocumentPosition(text) & Node.DOCUMENT_POSITION_PRECEDING) !== 0;
88
+ if (edge === 'end' && before) chosen = text;
89
+ if (edge === 'start' && !before) { chosen = text; break; }
90
+ }
91
+ const parent = chosen ? originParent(chosen) : null;
92
+ if (!chosen || !parent || !root.contains(parent) || parent === root) return meta;
93
+ const index = Array.from(root.getElementsByTagName(parent.tagName)).indexOf(parent);
94
+ if (index < 0) return meta;
95
+ return { ...meta, parentTagName: parent.tagName, parentIndex: index, textOffset: offsetIn(parent, chosen, edge === 'end') };
96
+ };
97
+
98
+ /**
99
+ * Live index → the index the element has with the slot's elements left out.
100
+ * A boundary inside the slot (a selection from the title that ends in it) is
101
+ * first moved out of it, so a stored meta never points into the slot.
102
+ */
103
+ export function metaWithoutBelowTitle<M extends TextMeta>(root: Element | null, meta: M, edge: MetaEdge): M;
104
+ export function metaWithoutBelowTitle<M extends TextMeta>(root: Element | null, meta: M | undefined, edge: MetaEdge): M | undefined;
105
+ export function metaWithoutBelowTitle<M extends TextMeta>(root: Element | null, meta: M | undefined, edge: MetaEdge): M | undefined {
106
+ if (!root || !meta || !isElementIndex(meta.parentIndex)) return meta;
107
+ const slot = findSlot(root);
108
+ if (!slot) return meta;
109
+ let live = meta;
110
+ const parent = root.getElementsByTagName(live.parentTagName)[live.parentIndex];
111
+ if (!parent) return meta;
112
+ if (slot.contains(parent)) {
113
+ live = snapOutOfSlot(root, slot, live, edge);
114
+ if (live === meta) return meta;
115
+ }
116
+ const list = root.getElementsByTagName(live.parentTagName);
117
+ let skipped = 0;
118
+ for (let i = 0; i < live.parentIndex; i++) {
119
+ if (slot.contains(list[i]!)) skipped++;
120
+ }
121
+ return skipped === 0 ? live : { ...live, parentIndex: live.parentIndex - skipped };
122
+ }
123
+
124
+ /** Slot-free index → the live index of that element (the slot's skipped). */
125
+ export function metaWithBelowTitle<M extends TextMeta>(root: Element | null, meta: M): M;
126
+ export function metaWithBelowTitle<M extends TextMeta>(root: Element | null, meta: M | undefined): M | undefined;
127
+ export function metaWithBelowTitle<M extends TextMeta>(root: Element | null, meta: M | undefined): M | undefined {
128
+ if (!root || !meta || !isElementIndex(meta.parentIndex)) return meta;
129
+ const slot = findSlot(root);
130
+ if (!slot) return meta;
131
+ const list = root.getElementsByTagName(meta.parentTagName);
132
+ let seen = 0;
133
+ for (let i = 0; i < list.length; i++) {
134
+ if (slot.contains(list[i]!)) continue;
135
+ if (seen === meta.parentIndex) return i === meta.parentIndex ? meta : { ...meta, parentIndex: i };
136
+ seen++;
137
+ }
138
+ // Past the end: unchanged, so the restore fails exactly as it would have.
139
+ return meta;
140
+ }
141
+
142
+ /**
143
+ * Move a selection's ends out of the Viewer's slot before the highlighter
144
+ * reads it: an end inside the slot goes back to the end of the last comment
145
+ * text before the slot, a start inside it forward to the first after it. A
146
+ * range that lies wholly inside the slot (or would collapse) is left alone;
147
+ * it paints nothing. Returns whether the range changed. No slot in `root`:
148
+ * never changes anything.
149
+ */
150
+ export function snapRangeOutOfBelowTitle(root: Element | null, range: Range): boolean {
151
+ if (!root) return false;
152
+ const slot = findSlot(root);
153
+ if (!slot) return false;
154
+ const startIn = slot.contains(range.startContainer);
155
+ const endIn = slot.contains(range.endContainer);
156
+ if (startIn === endIn) return false;
157
+ const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT);
158
+ let chosen: Text | null = null;
159
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
160
+ const text = node as Text;
161
+ if (!text.length || slot.contains(text) || isExcludedText(text)) continue;
162
+ const before = (slot.compareDocumentPosition(text) & Node.DOCUMENT_POSITION_PRECEDING) !== 0;
163
+ if (endIn && before) chosen = text;
164
+ if (startIn && !before) { chosen = text; break; }
165
+ }
166
+ if (!chosen) return false;
167
+ const next = range.cloneRange();
168
+ if (endIn) next.setEnd(chosen, chosen.length);
169
+ else next.setStart(chosen, 0);
170
+ if (next.collapsed) return false;
171
+ if (endIn) range.setEnd(chosen, chosen.length);
172
+ else range.setStart(chosen, 0);
173
+ return true;
174
+ }
@@ -0,0 +1,45 @@
1
+ // Moved from the Workspaces app's `block-parse.ts` with its contents rail
2
+ // (MarkdownContentsRail, approved 2026-10-03). Port: none; the module-level
3
+ // cache is the same.
4
+
5
+ import type { Block } from '../types';
6
+ import { parseMarkdownToBlocks } from './parser';
7
+
8
+ /**
9
+ * ONE parse per document body, shared across consumers: a host's Viewer and
10
+ * its contents rail render from the same body string, and each would
11
+ * otherwise run its own full `parseMarkdownToBlocks`. This module-level cache
12
+ * keys on the content string (value equality: the call sites derive `content`
13
+ * independently via `extractFrontmatter`), so whoever parses first feeds
14
+ * everyone.
15
+ *
16
+ * Sharing one blocks array is safe: the consumers treat the parser output as
17
+ * immutable.
18
+ *
19
+ * Capacity 4 with hit-refresh recency: a page holds at most a couple of live
20
+ * bodies at once; the cap only exists so navigating across documents cannot
21
+ * grow it without bound.
22
+ */
23
+ const MAX_ENTRIES = 4;
24
+ const cache = new Map<string, Block[]>();
25
+
26
+ /** Test probe: counts real parses (cache misses). */
27
+ export const blockParseProbe = { parses: 0 };
28
+
29
+ export function parseBlocksShared(content: string): Block[] {
30
+ const hit = cache.get(content);
31
+ if (hit !== undefined) {
32
+ // Refresh recency (Map preserves insertion order; oldest is evicted).
33
+ cache.delete(content);
34
+ cache.set(content, hit);
35
+ return hit;
36
+ }
37
+ blockParseProbe.parses += 1;
38
+ const blocks = parseMarkdownToBlocks(content);
39
+ cache.set(content, blocks);
40
+ if (cache.size > MAX_ENTRIES) {
41
+ const oldest = cache.keys().next().value;
42
+ if (oldest !== undefined) cache.delete(oldest);
43
+ }
44
+ return blocks;
45
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Find a rendered code line in a file area drawn by Pierre's `File`, and wait
3
+ * for it when it is not there yet. Pierre draws the lines inside the shadow
4
+ * root of a `diffs-container` it attaches in the element's constructor, and
5
+ * may draw them some time after mount (a worker highlight, a large file), so a
6
+ * fixed delay can fire before the line exists.
7
+ */
8
+
9
+ function lineSelector(line: number): string {
10
+ return `[data-line="${line}"]`;
11
+ }
12
+
13
+ /** The element of code line `line` under `root`, in its own DOM or a Pierre shadow root. */
14
+ export function findCodeLineElement(root: ParentNode, line: number): HTMLElement | null {
15
+ const selector = lineSelector(line);
16
+ const direct = root.querySelector<HTMLElement>(selector);
17
+ if (direct) return direct;
18
+ for (const container of root.querySelectorAll('diffs-container')) {
19
+ const inShadow = container.shadowRoot?.querySelector<HTMLElement>(selector);
20
+ if (inShadow) return inShadow;
21
+ }
22
+ return null;
23
+ }
24
+
25
+ /** How long to wait for a line before giving up (a file that never draws it). */
26
+ export const CODE_LINE_WAIT_MS = 5_000;
27
+
28
+ /**
29
+ * Call `onFound` once with code line `line` under `root`, in the frame after it
30
+ * exists (so it has been laid out). Watches `root` and every Pierre shadow root
31
+ * that appears under it until the line shows up. Gives up at once when the line
32
+ * cannot exist (below 1, or past `lineCount`), and after `timeoutMs`. Returns a
33
+ * function that stops watching; call it on unmount or when the line or file
34
+ * changes.
35
+ */
36
+ export function whenCodeLineRendered(
37
+ root: HTMLElement,
38
+ line: number,
39
+ onFound: (element: HTMLElement) => void,
40
+ options: { lineCount?: number; timeoutMs?: number } = {},
41
+ ): () => void {
42
+ if (!Number.isInteger(line) || line < 1 || (options.lineCount != null && line > options.lineCount)) {
43
+ return () => {};
44
+ }
45
+ let stopped = false;
46
+ let frame: number | null = null;
47
+ const observers: MutationObserver[] = [];
48
+ const watched = new Set<Node>();
49
+
50
+ const stopWatching = () => {
51
+ for (const observer of observers) observer.disconnect();
52
+ observers.length = 0;
53
+ watched.clear();
54
+ };
55
+
56
+ const watch = (node: Node) => {
57
+ if (watched.has(node)) return;
58
+ watched.add(node);
59
+ const observer = new MutationObserver(check);
60
+ observer.observe(node, { childList: true, subtree: true });
61
+ observers.push(observer);
62
+ };
63
+
64
+ function check() {
65
+ if (stopped || frame != null) return;
66
+ if (findCodeLineElement(root, line)) {
67
+ stopWatching();
68
+ frame = requestAnimationFrame(() => {
69
+ frame = null;
70
+ if (stopped) return;
71
+ // Look it up again: a highlight pass may have replaced the element.
72
+ const element = findCodeLineElement(root, line);
73
+ if (element) {
74
+ stop();
75
+ onFound(element);
76
+ return;
77
+ }
78
+ watch(root);
79
+ check();
80
+ });
81
+ return;
82
+ }
83
+ for (const container of root.querySelectorAll('diffs-container')) {
84
+ if (container.shadowRoot) watch(container.shadowRoot);
85
+ }
86
+ }
87
+
88
+ const stop = () => {
89
+ stopped = true;
90
+ stopWatching();
91
+ clearTimeout(giveUp);
92
+ if (frame != null) cancelAnimationFrame(frame);
93
+ };
94
+ const giveUp = setTimeout(stop, options.timeoutMs ?? CODE_LINE_WAIT_MS);
95
+
96
+ watch(root);
97
+ check();
98
+
99
+ return stop;
100
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Which key saves a comment (#1934).
3
+ *
4
+ * Every comment composer (the plan / annotate comment popover, the panel's
5
+ * edit box, code review's line, file and general comments, the Inbox's file
6
+ * comments) asks this one question, so a single setting governs them all.
7
+ *
8
+ * - Cmd/Ctrl+Enter always saves, whatever the setting says.
9
+ * - With `enterSaves` (Settings → General → "Enter saves a comment", the
10
+ * cookie-only `enterSavesComment`, off by default) a plain Enter saves too
11
+ * and Shift+Enter is left to the field, which adds a new line.
12
+ * - An Enter that belongs to an input method (Japanese, Chinese, Korean IME)
13
+ * never saves: `isComposing`, or the legacy `keyCode` 229 Safari reports for
14
+ * the Enter that commits a composition.
15
+ *
16
+ * The skill and mention menus are offered the key BEFORE this check by every
17
+ * caller, so an arrowed-to row still takes Enter.
18
+ */
19
+
20
+ export interface CommentSubmitKeyEvent {
21
+ key: string;
22
+ shiftKey?: boolean;
23
+ altKey?: boolean;
24
+ metaKey?: boolean;
25
+ ctrlKey?: boolean;
26
+ isComposing?: boolean;
27
+ keyCode?: number;
28
+ /** React's synthetic events carry the DOM event here. */
29
+ nativeEvent?: { isComposing?: boolean; keyCode?: number };
30
+ }
31
+
32
+ /** The Enter (or any key) belongs to an input method's composition. */
33
+ export function isImeCompositionKey(event: CommentSubmitKeyEvent): boolean {
34
+ return (
35
+ event.isComposing === true ||
36
+ event.nativeEvent?.isComposing === true ||
37
+ event.keyCode === 229 ||
38
+ event.nativeEvent?.keyCode === 229
39
+ );
40
+ }
41
+
42
+ /** Whether this keydown saves the comment being written. */
43
+ export function isCommentSubmitKey(event: CommentSubmitKeyEvent, enterSaves: boolean): boolean {
44
+ if (event.key !== 'Enter') return false;
45
+ if (isImeCompositionKey(event)) return false;
46
+ if (event.metaKey || event.ctrlKey) return true;
47
+ return enterSaves && !event.shiftKey && !event.altKey;
48
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The code font is free text since custom fonts (#851): a person types the
3
+ * family name of a font installed on their machine. Every place that turns
4
+ * that name into CSS goes through here, so a quote, backslash or line break
5
+ * in it can never end the CSS string early.
6
+ *
7
+ * Only a family NAME is accepted, never a URL or a stack: the name resolves
8
+ * against the fonts installed on the machine showing the page, and nothing
9
+ * is fetched for it.
10
+ */
11
+
12
+ /** Longest family name kept. Real names are far shorter. */
13
+ export const MAX_FONT_FAMILY_LENGTH = 200;
14
+
15
+ /** Control characters (C0, DEL, C1) and the Unicode line and paragraph separators. */
16
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g;
17
+
18
+ /** The name as it is stored: control characters dropped, trimmed, capped. */
19
+ export function normalizeFontFamilyName(name: string): string {
20
+ return name.replace(CONTROL_CHARS, '').trim().slice(0, MAX_FONT_FAMILY_LENGTH).trim();
21
+ }
22
+
23
+ /**
24
+ * The name as a CSS string (`"Iosevka Term"`), with backslashes and double
25
+ * quotes escaped. Empty for an empty name.
26
+ */
27
+ export function quoteFontFamily(name: string): string {
28
+ const clean = normalizeFontFamilyName(name);
29
+ if (!clean) return '';
30
+ return `"${clean.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
31
+ }
32
+
33
+ /**
34
+ * A `font-family` value for code: the quoted name, then the generic
35
+ * `monospace`, so a name that is not installed still renders aligned code.
36
+ * Empty for an empty name.
37
+ */
38
+ export function monoFontStack(name: string | undefined): string {
39
+ const quoted = name ? quoteFontFamily(name) : '';
40
+ return quoted ? `${quoted}, monospace` : '';
41
+ }
42
+
43
+ /** Accepted range per unit for the code font size (roughly 6px to 72px). */
44
+ const FONT_SIZE_RANGE: Record<'px' | 'pt' | 'rem' | 'em', readonly [number, number]> = {
45
+ px: [6, 72],
46
+ pt: [5, 54],
47
+ rem: [0.375, 4.5],
48
+ em: [0.375, 4.5],
49
+ };
50
+
51
+ /**
52
+ * The code font size as CSS, or '' (the default size) when it is not a plain
53
+ * number with a px, pt, rem or em unit inside a sane range. The value comes
54
+ * from a cookie and config.json, which other localhost pages can write, so it
55
+ * is never put into CSS unchecked.
56
+ */
57
+ export function normalizeCssFontSize(size: string | undefined): string {
58
+ if (!size) return '';
59
+ const match = /^(\d{1,3}(?:\.\d{1,3})?)(px|pt|rem|em)$/i.exec(size.trim());
60
+ if (!match) return '';
61
+ const value = Number(match[1]);
62
+ const unit = match[2].toLowerCase() as keyof typeof FONT_SIZE_RANGE;
63
+ const [min, max] = FONT_SIZE_RANGE[unit];
64
+ return value >= min && value <= max ? `${value}${unit}` : '';
65
+ }
@@ -83,8 +83,8 @@ export interface DecisionSpec {
83
83
  }
84
84
 
85
85
  export interface DecisionSpecInput {
86
- app: 'annotate' | 'review';
87
- /** Annotate: `gate`. Review: always true — review's primary decision IS approval. */
86
+ app: 'annotate' | 'review' | 'plan';
87
+ /** Annotate: `gate`. Review and plan: ignored — their primary decision IS approval. */
88
88
  gate: boolean;
89
89
  /** The annotation count interpolated into the menu's discard and note copy (never the primary label). */
90
90
  count: number;
@@ -117,6 +117,38 @@ export interface DecisionSpecInput {
117
117
  * gate only on `selfAuthored`, muted rather than removed.
118
118
  */
119
119
  platform?: DecisionPlatformInput;
120
+ /**
121
+ * #1057: the plan-review arm's extra facts (only read when `app` is
122
+ * `'plan'`). Absent means no answers, no edits, no agent switch.
123
+ */
124
+ plan?: DecisionPlanInput;
125
+ }
126
+
127
+ /**
128
+ * Plan review (#1057). Plan approval is the same decision as review's, with
129
+ * three plan-only facts: `:::question` answers (an answers-only deny reads as
130
+ * answers, not as a rejection), direct edits (they ride the deny and, where
131
+ * supported, the approval), and the agent an OpenCode approval hands off to.
132
+ */
133
+ export interface DecisionPlanInput {
134
+ /** How many of `count` are answers to the plan's questions. */
135
+ answers: number;
136
+ /** Direct edits ride the feedback (they are not in `count`). */
137
+ directEdits: boolean;
138
+ /** The only feedback is answers: the primary reads "Send answers". */
139
+ answersOnly: boolean;
140
+ /** Named where notes on approval would be lost ("Claude Code"). */
141
+ agentName: string;
142
+ /** OpenCode: the agent approval switches to, shown as "Approve → build". */
143
+ approveTarget?: string;
144
+ /**
145
+ * The only feedback is file changes the reviewer already saved to disk
146
+ * (Edit Mode on a source-backed document). Approving drops nothing, the
147
+ * agent just isn't told, so the approve-away row says that instead of
148
+ * "discard". The value is the confirm's sentence ("Your saved file change
149
+ * is already on disk. The agent won't be told about it.").
150
+ */
151
+ savedFilesOnly?: string;
120
152
  }
121
153
 
122
154
  export interface DecisionPlatformInput {
@@ -449,10 +481,166 @@ function buildPlatformSpec(input: DecisionSpecInput, platform: DecisionPlatformI
449
481
  };
450
482
  }
451
483
 
484
+ /** Frozen label (owner-approved mock): the plan primary when only answers are ready. */
485
+ const SEND_ANSWERS = 'Send answers';
486
+ /** The longest agent name the Approve primary carries before it is cut. */
487
+ const APPROVE_TARGET_MAX = 24;
488
+
489
+ function approveTargetSuffix(target: string | undefined): string {
490
+ const name = target?.trim();
491
+ if (!name) return '';
492
+ const cut = name.length > APPROVE_TARGET_MAX ? `${name.slice(0, APPROVE_TARGET_MAX - 1)}…` : name;
493
+ return ` → ${cut}`;
494
+ }
495
+
496
+ /**
497
+ * What the plan feedback is, in the reviewer's words: `2 answers`,
498
+ * `3 annotations`, `1 answer and 2 annotations`, `direct edits`. Mirrors the
499
+ * editor's describeFeedbackLoss so the menu and the close warnings agree;
500
+ * duplicated here because this module imports nothing.
501
+ */
502
+ function planFeedbackPhrase(count: number, plan: DecisionPlanInput): { text: string; plural: boolean } {
503
+ const answers = Math.min(Math.max(plan.answers, 0), Math.max(count, 0));
504
+ const others = Math.max(count, 0) - answers;
505
+ const parts = [
506
+ answers > 0 ? `${answers} answer${answers === 1 ? '' : 's'}` : '',
507
+ others > 0 ? `${others} ${annotationNoun(others)}` : '',
508
+ plan.directEdits ? 'direct edits' : '',
509
+ ].filter(Boolean);
510
+ if (parts.length === 0) return { text: 'feedback', plural: false };
511
+ const text = parts.length === 1
512
+ ? parts[0]
513
+ : `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`;
514
+ return { text, plural: parts.length > 1 || answers + others > 1 || plan.directEdits };
515
+ }
516
+
517
+ /**
518
+ * The plan arm (#1057, approved proposal "plan-review-decision-control").
519
+ * Empty: the approval-flow empty spec (Approve; "Approve with a note…" where
520
+ * the host takes notes on approval; "Request changes…"), the Approve label
521
+ * naming OpenCode's hand-off agent. Feedback: Send Feedback, or Send answers
522
+ * for answers only; then the approve-away alternates, each saying what
523
+ * happens to the feedback on THIS host: "Approve with notes" only where the
524
+ * server advertises delivery, and "Approve, discard …" always (its subtitle
525
+ * and confirm name the agent when that is the only way to approve).
526
+ */
527
+ function buildPlanSpec(input: DecisionSpecInput): DecisionSpec {
528
+ const plan: DecisionPlanInput = input.plan ?? {
529
+ answers: 0,
530
+ directEdits: false,
531
+ answersOnly: false,
532
+ agentName: 'The agent',
533
+ };
534
+ const target = approveTargetSuffix(plan.approveTarget);
535
+
536
+ if (!input.hasFeedback) {
537
+ const empty = buildEmptySpec(input, true);
538
+ return {
539
+ primary: target ? { ...empty.primary, label: `${empty.primary.label}${target}` } : empty.primary,
540
+ items: empty.items,
541
+ };
542
+ }
543
+
544
+ const { text: what, plural } = planFeedbackPhrase(input.count, plan);
545
+ const sendLabel = plan.answersOnly ? SEND_ANSWERS : 'Send Feedback';
546
+ const items: DecisionMenuItem[] = [
547
+ {
548
+ id: 'note-with-feedback',
549
+ label: plan.answersOnly ? 'Send answers with a note…' : 'Send with a note…',
550
+ subtitle: `Add an overall note on top of your ${what}`,
551
+ tone: 'primary',
552
+ icon: 'send',
553
+ composer: {
554
+ title: 'Send with a note',
555
+ actionLabel: plan.answersOnly ? 'Send answers with note' : 'Send feedback with note',
556
+ tone: 'primary',
557
+ icon: 'send',
558
+ placeholder: DECISION_NOTE_PLACEHOLDER,
559
+ },
560
+ },
561
+ ];
562
+
563
+ if (input.approvalNotesSupported) {
564
+ items.push({
565
+ id: 'approve-with-notes',
566
+ // Frozen copy (maintainer-approved): 'Approve with notes'.
567
+ label: `Approve with notes${target}`,
568
+ subtitle: `Approve; your ${what} ${plural ? 'ride' : 'rides'} along as non-blocking guidance`,
569
+ tone: 'success',
570
+ icon: 'check',
571
+ dividerBefore: true,
572
+ });
573
+ }
574
+
575
+ const pronoun = plural ? 'them' : 'it';
576
+ if (plan.savedFilesOnly) {
577
+ // Nothing is lost on approval: the changes stay on disk. Same confirm
578
+ // (one click away from a plain Approve would hide that the agent is not
579
+ // told), different words.
580
+ items.push({
581
+ id: 'discard-and-finish',
582
+ label: 'Approve without telling the agent…',
583
+ subtitle: "Already on disk; the agent isn't told. Asks to confirm",
584
+ tone: 'destructive',
585
+ icon: 'check',
586
+ dividerBefore: !input.approvalNotesSupported,
587
+ confirm: {
588
+ title: 'Approve without telling the agent?',
589
+ message: plan.savedFilesOnly,
590
+ // Frozen copy (maintainer-approved): 'Discard & approve' is the
591
+ // discard confirm's; this one discards nothing.
592
+ confirmText: 'Approve',
593
+ },
594
+ });
595
+ } else items.push({
596
+ id: 'discard-and-finish',
597
+ label: `Approve, discard ${what}…`,
598
+ subtitle: input.approvalNotesSupported
599
+ ? 'Asks to confirm: nothing is sent'
600
+ : `${plan.agentName} can't take notes on approval; asks to confirm`,
601
+ tone: 'destructive',
602
+ icon: 'check',
603
+ dividerBefore: !input.approvalNotesSupported,
604
+ confirm: {
605
+ title: `Discard ${what} and approve?`,
606
+ message: input.approvalNotesSupported
607
+ ? `Approving without ${pronoun} tells the agent no changes are needed.`
608
+ : `${plan.agentName} doesn't take feedback on approval, so your ${what} would be lost. To send ${pronoun}, use ${sendLabel}.`,
609
+ // Frozen copy (maintainer-approved): 'Discard & approve'.
610
+ confirmText: 'Discard & approve',
611
+ },
612
+ });
613
+
614
+ return {
615
+ primary: plan.answersOnly
616
+ ? {
617
+ id: 'primary',
618
+ // Frozen copy (owner-approved mock): 'Send answers'.
619
+ label: SEND_ANSWERS,
620
+ mobileLabel: SEND_ANSWERS,
621
+ title: 'Send your answers to the agent',
622
+ tone: 'primary',
623
+ icon: 'send',
624
+ }
625
+ : {
626
+ id: 'primary',
627
+ // Frozen copy (maintainer-approved): 'Send Feedback'.
628
+ label: 'Send Feedback',
629
+ shortLabel: 'Send',
630
+ mobileLabel: 'Send feedback',
631
+ title: 'Send your feedback to the agent',
632
+ tone: 'primary',
633
+ icon: 'send',
634
+ },
635
+ items,
636
+ };
637
+ }
638
+
452
639
  export function buildDecisionSpec(input: DecisionSpecInput): DecisionSpec {
453
640
  // PR6 (§3.4): the platform destination maps onto the same spec shape with
454
641
  // no composer items; presence of the arm selects it outright.
455
642
  if (input.platform) return buildPlatformSpec(input, input.platform);
643
+ if (input.app === 'plan') return buildPlanSpec(input);
456
644
  // Review's primary positive decision IS approval, gate flag or not.
457
645
  const approvalFlow = input.app === 'review' || input.gate;
458
646
  return input.hasFeedback
@@ -9,7 +9,7 @@ import { storage } from './storage';
9
9
 
10
10
  const STORAGE_KEY = 'plannotator-default-notes-app';
11
11
 
12
- export type DefaultNotesApp = 'obsidian' | 'bear' | 'octarine' | 'download' | 'ask';
12
+ export type DefaultNotesApp = 'obsidian' | 'bear' | 'octarine' | 'notion' | 'download' | 'ask';
13
13
 
14
14
  export function getDefaultNotesApp(): DefaultNotesApp {
15
15
  return (storage.getItem(STORAGE_KEY) as DefaultNotesApp) || 'ask';
@@ -21,8 +21,11 @@ const loaded = new Set<string>();
21
21
 
22
22
  export function loadDiffFont(fontFamily: string): void {
23
23
  if (!fontFamily || loaded.has(fontFamily)) return;
24
+ // Own keys only: a typed custom name such as `constructor` or `__proto__`
25
+ // must never resolve to something inherited from Object.prototype.
26
+ if (!Object.hasOwn(FONT_URLS, fontFamily)) return;
24
27
  const url = FONT_URLS[fontFamily];
25
- if (!url) return;
28
+ if (typeof url !== 'string') return;
26
29
 
27
30
  const link = document.createElement('link');
28
31
  link.rel = 'stylesheet';