@plannotator/ui 0.30.0 β†’ 0.32.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 (102) hide show
  1. package/README.md +46 -1
  2. package/components/ActionMenu.tsx +6 -1
  3. package/components/AgentsTab.tsx +8 -9
  4. package/components/AnalysisLayerToggle.tsx +48 -0
  5. package/components/AnnotationPanel.tsx +158 -36
  6. package/components/AnnotationToolbar.tsx +50 -30
  7. package/components/AnnotationToolstrip.tsx +9 -0
  8. package/components/CommentPopover.tsx +238 -46
  9. package/components/ConfirmDialog.tsx +42 -28
  10. package/components/GraphvizBlock.tsx +86 -7
  11. package/components/HtmlSurfaceControls.tsx +170 -0
  12. package/components/InlineMarkdown.tsx +25 -4
  13. package/components/KeyboardShortcuts.tsx +9 -0
  14. package/components/Landing.tsx +1 -1
  15. package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
  16. package/components/MarkdownEditor/embedPicker.ts +349 -0
  17. package/components/MarkdownEditor.tsx +12 -0
  18. package/components/MermaidBlock.tsx +60 -26
  19. package/components/ModeToggle.tsx +2 -1
  20. package/components/PermissionModeSetup.tsx +24 -5
  21. package/components/PinpointOverlay.tsx +11 -6
  22. package/components/PlanHeaderMenu.tsx +140 -1
  23. package/components/SearchableSelect.tsx +2 -0
  24. package/components/Settings.tsx +175 -10
  25. package/components/SkillReferenceMenu.tsx +9 -0
  26. package/components/StickyHeaderLane.tsx +9 -2
  27. package/components/TableOfContents.tsx +9 -4
  28. package/components/TextShimmer.tsx +8 -5
  29. package/components/ThemeProvider.tsx +43 -1
  30. package/components/ThemeTab.tsx +52 -1
  31. package/components/Tooltip.tsx +3 -1
  32. package/components/Viewer.tsx +19 -5
  33. package/components/VimTargetReticle.tsx +12 -4
  34. package/components/ai/DocumentAIChatPanel.tsx +1 -0
  35. package/components/blocks/MathBlock.tsx +26 -14
  36. package/components/core/button.tsx +14 -6
  37. package/components/html-viewer/HtmlViewer.tsx +320 -41
  38. package/components/html-viewer/bridge-script.ts +360 -72
  39. package/components/html-viewer/composerYield.ts +1 -51
  40. package/components/html-viewer/hostThreads.ts +37 -0
  41. package/components/html-viewer/index.ts +9 -0
  42. package/components/html-viewer/unanchored.ts +47 -0
  43. package/components/html-viewer/useHtmlAnnotation.ts +240 -61
  44. package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
  45. package/components/sidebar/FileBrowser.tsx +17 -5
  46. package/components/sidebar/SidebarContainer.tsx +124 -28
  47. package/components/ui/button.tsx +10 -8
  48. package/components/ui/dialog.tsx +35 -25
  49. package/config/index.ts +6 -1
  50. package/config/reviewView.ts +42 -9
  51. package/config/settings.ts +141 -0
  52. package/configure.ts +32 -0
  53. package/hooks/useAIProviderConfig.ts +8 -7
  54. package/hooks/useActiveSection.ts +6 -4
  55. package/hooks/useAgentJobs.ts +3 -0
  56. package/hooks/useAnnotationHighlighter.ts +20 -0
  57. package/hooks/useHtmlRefresh.ts +149 -0
  58. package/hooks/useIsMobile.ts +37 -0
  59. package/hooks/useLinkedDoc.ts +7 -0
  60. package/hooks/useMathRenderer.ts +30 -0
  61. package/hooks/useScrollViewport.ts +74 -0
  62. package/hooks/useSharing.ts +31 -5
  63. package/hooks/useViewportEnvironment.ts +350 -0
  64. package/package.json +5 -2
  65. package/shortcuts/index.ts +3 -0
  66. package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
  67. package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
  68. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
  69. package/styles.css +1 -1
  70. package/theme.css +229 -0
  71. package/types.ts +35 -0
  72. package/utils/annotateAgentTerminal.ts +36 -5
  73. package/utils/blockTargeting.ts +6 -3
  74. package/utils/composerYield.ts +45 -0
  75. package/utils/generateIdentity.ts +64 -14
  76. package/utils/htmlChrome.ts +20 -16
  77. package/utils/identity-tater.ts +36 -0
  78. package/utils/lookAndFeelAnnouncement.ts +12 -8
  79. package/utils/markdownExtensions.ts +57 -0
  80. package/utils/math-eager.ts +25 -0
  81. package/utils/math.ts +146 -0
  82. package/utils/mermaid-eager.ts +28 -0
  83. package/utils/mermaid.ts +132 -0
  84. package/utils/parser.ts +75 -2
  85. package/utils/quickLabels.ts +13 -0
  86. package/utils/vimNavigation.ts +4 -1
  87. package/utils/vimScroll.ts +9 -4
  88. package/utils/wideMode.ts +20 -0
  89. package/webmcp/activity.ts +46 -0
  90. package/webmcp/changes.ts +227 -0
  91. package/webmcp/index.ts +72 -0
  92. package/webmcp/modelContext.ts +103 -0
  93. package/webmcp/nudges.ts +174 -0
  94. package/webmcp/policy.ts +50 -0
  95. package/webmcp/preference.ts +50 -0
  96. package/webmcp/schema.ts +81 -0
  97. package/webmcp/toolset.ts +337 -0
  98. package/webmcp/useToolset.ts +74 -0
  99. package/components/PlanAIAnnouncementDialog.tsx +0 -187
  100. package/components/VimModeAnnouncementDialog.tsx +0 -557
  101. package/utils/planAIAnnouncement.ts +0 -17
  102. package/utils/vimModeAnnouncement.ts +0 -23
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Extra markdown extensions, renderer side (#1307).
3
+ *
4
+ * The server resolves `markdownExtensions` from `~/.plannotator/config.json`
5
+ * and ships the normalized list with the annotate payload. The renderer needs
6
+ * it for one job: deciding whether a relative link or a wiki-link target names
7
+ * a local document it should open in the linked-doc overlay (`/api/doc`) or a
8
+ * plain external link. Without it, `[notes](notes.livemd)` renders as a dead
9
+ * external link even though the server would happily serve it.
10
+ *
11
+ * Module-level registry seam, like `skillReferences.ts`: a host (or the app's
12
+ * own boot code) registers the list once, everything else reads it. Empty by
13
+ * default, so nothing changes for a user with no config.
14
+ *
15
+ * The built-in set here is deliberately NARROWER than the annotatable set on
16
+ * the server (`.md`/`.mdx`/`.txt`/`.html`/`.htm` only) β€” widening it is a
17
+ * separate decision. Extras are added on top of it.
18
+ */
19
+
20
+ import { normalizeMarkdownExtensions } from "@plannotator/core/annotatable";
21
+
22
+ /** Built-in extensions the renderer treats as openable local documents. */
23
+ const BUILTIN_LINKED_DOC_REGEX = /\.(mdx?|txt|html?)$/i;
24
+
25
+ let extraExtensions: string[] = [];
26
+
27
+ /**
28
+ * Register the extra markdown extensions for this page. Values are normalized
29
+ * with the same rules the server applies (dot-led, lowercased, `.env` denied),
30
+ * so a hostile or malformed payload cannot inject regex or path fragments.
31
+ */
32
+ export function setExtraMarkdownExtensions(value: unknown): void {
33
+ extraExtensions = normalizeMarkdownExtensions(value);
34
+ }
35
+
36
+ /** The registered extra extensions (normalized, possibly empty). */
37
+ export function getExtraMarkdownExtensions(): string[] {
38
+ return extraExtensions;
39
+ }
40
+
41
+ /**
42
+ * Does this link target name a local document the linked-doc overlay can open?
43
+ *
44
+ * `allowFragment` mirrors the two call sites this replaced: markdown links
45
+ * accept a trailing `#fragment` (stripped by the caller before navigating),
46
+ * wiki-link targets do not.
47
+ */
48
+ export function hasLinkedDocExtension(
49
+ target: string,
50
+ options?: { allowFragment?: boolean },
51
+ ): boolean {
52
+ const trimmed = target.trim();
53
+ const path = options?.allowFragment ? trimmed.replace(/#.*$/, "") : trimmed;
54
+ if (BUILTIN_LINKED_DOC_REGEX.test(path)) return true;
55
+ const lower = path.toLowerCase();
56
+ return extraExtensions.some((ext) => lower.endsWith(ext));
57
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Eager math registration: fills the renderer slot in `./math` with KaTeX at
3
+ * module evaluation, before any component renders.
4
+ *
5
+ * Every Plannotator entry (`packages/editor/App.tsx`, `packages/review-editor/App.tsx`;
6
+ * the hook, review, portal, OpenCode and Pi builds all flow from those two)
7
+ * imports this module for its side effect, which is what keeps math typeset
8
+ * on the first commit exactly as it was with a static `katex` import. A host
9
+ * that wants the same synchronous behavior imports it too:
10
+ *
11
+ * import '@plannotator/ui/utils/math-eager';
12
+ *
13
+ * A host that does not import it gets lazy math: the TeX source in the same
14
+ * wrapper for one frame, then the typeset markup once the chunk lands.
15
+ *
16
+ * The source tag passed below doubles as a build marker: the literal only
17
+ * reaches a bundle when this module is evaluated in it, so a dropped or
18
+ * tree-shaken side-effect import is caught by the built-HTML check in
19
+ * tests/entry-assets.test.ts (KaTeX itself stays inlined either way through
20
+ * the loader's import(), so a KaTeX class name cannot prove registration).
21
+ */
22
+ import katex from 'katex';
23
+ import { setMathRenderer } from './math';
24
+
25
+ setMathRenderer(katex, 'plannotator-math-eager');
package/utils/math.ts ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Math renderer slot.
3
+ *
4
+ * `MathBlock` and inline math read their renderer from this module instead of
5
+ * importing `katex` statically, so a host that bundles by route does not carry
6
+ * KaTeX in every document read. The slot is SYNCHRONOUS: when it is filled
7
+ * before the first render (which is what `./math-eager` does, and what every
8
+ * Plannotator entry imports) the typeset HTML is in the DOM on the first
9
+ * commit, exactly as it was when the import was static. When it is empty the
10
+ * components render the same wrapper element with the TeX source as text,
11
+ * call `loadMathRenderer()`, and re-render typeset once it resolves.
12
+ *
13
+ * This module deliberately has NO runtime import of `katex`: the only place
14
+ * the dependency is named is the default loader's `import('katex')`, which a
15
+ * chunking bundler turns into a lazy chunk and Plannotator's single-file
16
+ * builds inline (the eager entry keeps it in the entry either way).
17
+ */
18
+
19
+ import type { KatexOptions } from 'katex';
20
+
21
+ /** The subset of KaTeX's API the renderer needs. `katex` itself satisfies it. */
22
+ export interface MathRenderer {
23
+ renderToString(tex: string, options?: KatexOptions): string;
24
+ }
25
+
26
+ export type MathRendererLoader = () => Promise<MathRenderer>;
27
+
28
+ /**
29
+ * Default loader: KaTeX's JS only. The stylesheet is deliberately NOT imported
30
+ * here; CSS loading stays the host's job (see HANDOFF.md "Math rendering"),
31
+ * and a host that already serves `katex.min.css` would otherwise load it twice.
32
+ */
33
+ const defaultMathRendererLoader: MathRendererLoader = () => import('katex').then((m) => m.default);
34
+
35
+ /**
36
+ * Who filled the slot: the eager entry (`./math-eager`), the lazy loader, or a
37
+ * host calling `setMathRenderer` directly. Diagnostic for a host chasing a TeX
38
+ * flash, and the eager value is a build marker: it only reaches a bundle when
39
+ * `./math-eager` is evaluated, which is what `tests/entry-assets.test.ts`
40
+ * asserts on the built single-file HTML.
41
+ */
42
+ export type MathRendererSource = 'plannotator-math-eager' | 'loader' | 'host';
43
+
44
+ let renderer: MathRenderer | null = null;
45
+ let rendererSource: MathRendererSource | null = null;
46
+ let loader: MathRendererLoader = defaultMathRendererLoader;
47
+ let pending: Promise<MathRenderer> | null = null;
48
+ const listeners = new Set<() => void>();
49
+
50
+ function notify(): void {
51
+ for (const listener of listeners) listener();
52
+ }
53
+
54
+ /** Current renderer, or `null` while none is registered. Safe to call during render. */
55
+ export function getMathRenderer(): MathRenderer | null {
56
+ return renderer;
57
+ }
58
+
59
+ /** How the current renderer was registered, or `null` while the slot is empty. */
60
+ export function getMathRendererSource(): MathRendererSource | null {
61
+ return rendererSource;
62
+ }
63
+
64
+ /** Register a renderer synchronously (what `./math-eager` does with `katex`). */
65
+ export function setMathRenderer(next: MathRenderer, source: MathRendererSource = 'host'): void {
66
+ if (renderer === next) return;
67
+ renderer = next;
68
+ rendererSource = source;
69
+ notify();
70
+ }
71
+
72
+ /** Subscribe to slot changes. Shaped for `useSyncExternalStore`. */
73
+ export function subscribeMathRenderer(listener: () => void): () => void {
74
+ listeners.add(listener);
75
+ return () => {
76
+ listeners.delete(listener);
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Swap the loader `loadMathRenderer()` uses. Host seam
82
+ * (`configurePlannotatorUI({ mathRendererLoader })`): a host may return a
83
+ * module that imports katex AND its stylesheet in one chunk. A load already in
84
+ * flight keeps going; the new loader is used from the next `loadMathRenderer()`
85
+ * call that finds the slot empty.
86
+ */
87
+ export function setMathRendererLoader(next: MathRendererLoader): void {
88
+ loader = next;
89
+ pending = null;
90
+ }
91
+
92
+ /**
93
+ * Load and register the renderer. Idempotent: a filled slot resolves at once,
94
+ * a load in flight is shared, and a rejected load is dropped so the next call
95
+ * retries instead of failing forever on a transient chunk error.
96
+ */
97
+ export function loadMathRenderer(): Promise<MathRenderer> {
98
+ if (renderer) return Promise.resolve(renderer);
99
+ if (!pending) {
100
+ const attempt = loader().then(
101
+ (loaded) => {
102
+ setMathRenderer(loaded, 'loader');
103
+ return loaded;
104
+ },
105
+ (err: unknown) => {
106
+ if (pending === attempt) pending = null;
107
+ throw err;
108
+ },
109
+ );
110
+ pending = attempt;
111
+ }
112
+ return pending;
113
+ }
114
+
115
+ /** Test hook: clear the slot, the loader override and any pending load. */
116
+ export function resetMathRenderer(): void {
117
+ renderer = null;
118
+ rendererSource = null;
119
+ loader = defaultMathRendererLoader;
120
+ pending = null;
121
+ notify();
122
+ }
123
+
124
+ export const normalizeMathTex = (tex: string): string => tex.trim();
125
+
126
+ /**
127
+ * Render TeX with the pinned option set. `throwOnError: false` and
128
+ * `trust: false` are a deliberate security pin applied to EVERY renderer,
129
+ * including one a host registered: a registered module never widens what
130
+ * document-supplied TeX may do. Returns `null` while no renderer is
131
+ * registered so callers can fall back to the text placeholder.
132
+ */
133
+ export function renderMathToHtml(
134
+ tex: string,
135
+ displayMode: boolean,
136
+ activeRenderer: MathRenderer | null = renderer,
137
+ ): string | null {
138
+ if (!activeRenderer) return null;
139
+ return activeRenderer.renderToString(tex, {
140
+ displayMode,
141
+ throwOnError: false,
142
+ strict: 'warn',
143
+ trust: false,
144
+ output: 'html',
145
+ });
146
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Eager Mermaid registration: imports the runtime statically, initializes it
3
+ * at module evaluation (exactly where the old module-scope
4
+ * `mermaid.initialize` ran) and fills the slot in `./mermaid`.
5
+ *
6
+ * `packages/editor/App.tsx` imports this module for its side effect, by
7
+ * policy: Plannotator's own surfaces keep Mermaid in their entry chunk so it
8
+ * can never fail separately from the app (on the share portal this is what
9
+ * keeps `mermaid.core` out of a lazy chunk). The review editor does not import
10
+ * it because it never renders a Mermaid block; adding the runtime there would
11
+ * grow that bundle. A host that wants the same import adds:
12
+ *
13
+ * import '@plannotator/ui/utils/mermaid-eager';
14
+ *
15
+ * A host that does not import it gets the lazy path in `./mermaid`.
16
+ *
17
+ * The source tag passed below doubles as a build marker: the literal only
18
+ * reaches a bundle when this module is evaluated in it, so a dropped or
19
+ * tree-shaken side-effect import is caught by the built-HTML check in
20
+ * tests/entry-assets.test.ts (the runtime itself stays inlined in a
21
+ * single-file build through the loader's import(), so a Mermaid diagram id
22
+ * cannot prove registration).
23
+ */
24
+ import mermaid from 'mermaid';
25
+ import { MERMAID_CONFIG, setMermaidRuntime } from './mermaid';
26
+
27
+ mermaid.initialize(MERMAID_CONFIG);
28
+ setMermaidRuntime(mermaid, 'plannotator-mermaid-eager');
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Mermaid runtime slot.
3
+ *
4
+ * ONE code path feeds `MermaidBlock`: `loadMermaidRuntime()`. It resolves at
5
+ * once from a filled slot and otherwise imports the runtime lazily. Plannotator
6
+ * fills the slot at module evaluation through `./mermaid-eager` (imported by
7
+ * `packages/editor/App.tsx`), which keeps the runtime in its entry chunk on the
8
+ * share portal exactly as it was with the static import, so it cannot fail
9
+ * separately from the app. A host that does not import the eager entry gets
10
+ * the lazy path: the runtime is fetched on the first diagram, a failed import
11
+ * is dropped from the memo so the next call issues a fresh `import()`, and
12
+ * the block re-attempts once and offers Retry.
13
+ *
14
+ * This module has NO static import of `mermaid`; the only place the
15
+ * dependency is named at runtime is the default loader's `import('mermaid')`.
16
+ */
17
+ import type { Mermaid, MermaidConfig } from 'mermaid';
18
+
19
+ /**
20
+ * Hoisted verbatim from the former module-scope `mermaid.initialize(...)` in
21
+ * MermaidBlock. Nothing in it reads a CSS token or the resolved mode.
22
+ * `securityLevel: 'strict'` is a deliberate security pin (see MermaidBlock.test.ts).
23
+ */
24
+ export const MERMAID_CONFIG: MermaidConfig = {
25
+ startOnLoad: false,
26
+ securityLevel: 'strict',
27
+ theme: 'dark',
28
+ themeVariables: {
29
+ primaryColor: '#3b82f6',
30
+ primaryTextColor: '#f8fafc',
31
+ primaryBorderColor: '#475569',
32
+ lineColor: '#64748b',
33
+ secondaryColor: '#1e293b',
34
+ tertiaryColor: '#0f172a',
35
+ background: '#1e293b',
36
+ mainBkg: '#1e293b',
37
+ nodeBorder: '#475569',
38
+ clusterBkg: '#1e293b',
39
+ clusterBorder: '#475569',
40
+ titleColor: '#f8fafc',
41
+ edgeLabelBackground: '#1e293b',
42
+ },
43
+ flowchart: {
44
+ htmlLabels: true,
45
+ curve: 'basis',
46
+ },
47
+ };
48
+
49
+ /**
50
+ * Who filled the slot. The eager value doubles as a build marker: the literal
51
+ * only reaches a bundle when `./mermaid-eager` is evaluated in it, which is
52
+ * what `tests/entry-assets.test.ts` asserts on the built HTML.
53
+ */
54
+ export type MermaidRuntimeSource = 'plannotator-mermaid-eager' | 'loader' | 'host';
55
+
56
+ export type MermaidRuntimeLoader = () => Promise<Mermaid>;
57
+
58
+ /** Default lazy loader: import the runtime and initialize it once. */
59
+ const defaultMermaidLoader: MermaidRuntimeLoader = () =>
60
+ import('mermaid').then(({ default: mermaid }) => {
61
+ mermaid.initialize(MERMAID_CONFIG);
62
+ return mermaid;
63
+ });
64
+
65
+ let runtime: Mermaid | null = null;
66
+ let runtimeSource: MermaidRuntimeSource | null = null;
67
+ let loader: MermaidRuntimeLoader = defaultMermaidLoader;
68
+ let pending: Promise<Mermaid> | null = null;
69
+
70
+ /**
71
+ * Delay before the block's one automatic re-attempt after a failed lazy
72
+ * import. Only chunking hosts can fail here; a filled slot never loads.
73
+ */
74
+ let retryDelayMs = 750;
75
+
76
+ /** Current runtime, or `null` while the slot is empty. */
77
+ export function getMermaidRuntime(): Mermaid | null {
78
+ return runtime;
79
+ }
80
+
81
+ /** How the current runtime was registered, or `null` while the slot is empty. */
82
+ export function getMermaidRuntimeSource(): MermaidRuntimeSource | null {
83
+ return runtimeSource;
84
+ }
85
+
86
+ /** Register an already-initialized runtime (what `./mermaid-eager` does). */
87
+ export function setMermaidRuntime(next: Mermaid, source: MermaidRuntimeSource = 'host'): void {
88
+ runtime = next;
89
+ runtimeSource = source;
90
+ pending = null;
91
+ }
92
+
93
+ /** The block's retry delay for the lazy path. */
94
+ export function getMermaidRetryDelayMs(): number {
95
+ return retryDelayMs;
96
+ }
97
+
98
+ /**
99
+ * Resolve the runtime: at once from a filled slot, otherwise through the
100
+ * loader. A rejected load is dropped from the memo so the next call (the
101
+ * block's automatic re-attempt, a later mount, or the Retry button) issues a
102
+ * fresh `import()` instead of replaying the cached rejection.
103
+ */
104
+ export function loadMermaidRuntime(): Promise<Mermaid> {
105
+ if (runtime) return Promise.resolve(runtime);
106
+ if (!pending) {
107
+ const attempt = loader().then(
108
+ (loaded) => {
109
+ setMermaidRuntime(loaded, 'loader');
110
+ return loaded;
111
+ },
112
+ (err: unknown) => {
113
+ if (pending === attempt) pending = null;
114
+ throw err;
115
+ },
116
+ );
117
+ pending = attempt;
118
+ }
119
+ return pending;
120
+ }
121
+
122
+ /** Test hook: empty the slot, stand in for the lazy import, shorten the retry delay. */
123
+ export function __setMermaidRuntimeLoaderForTests(
124
+ next: MermaidRuntimeLoader | undefined,
125
+ options?: { retryDelayMs?: number },
126
+ ): void {
127
+ runtime = null;
128
+ runtimeSource = null;
129
+ pending = null;
130
+ loader = next ?? defaultMermaidLoader;
131
+ retryDelayMs = options?.retryDelayMs ?? 750;
132
+ }
package/utils/parser.ts CHANGED
@@ -1178,8 +1178,75 @@ export const exportAnnotations = (
1178
1178
  output += `I've reviewed this ${subject} and have ${annotations.length} piece${annotations.length > 1 ? 's' : ''} of feedback:\n\n`;
1179
1179
  }
1180
1180
 
1181
- sortedAnns.forEach((ann, index) => {
1182
- output += `## ${index + 1}. `;
1181
+ // Live app sessions stamp annotations with the page they were made on.
1182
+ // When any exported annotation carries a pageUrl, entries are grouped under
1183
+ // per-page `## Page:` headings in order of first appearance and every entry
1184
+ // demotes to `###` so it nests BELOW its page header (a `### Page:` header
1185
+ // over `##` entries would invert the hierarchy); annotations without a page
1186
+ // (e.g. globals) come first under no heading, at the same `###` level so
1187
+ // entries render uniformly. Numbers stay GLOBAL: each entry keeps the
1188
+ // number of its position in the ungrouped order, matching the on-page
1189
+ // marker numbering, so grouped sections may show non-contiguous numbers.
1190
+ // With no pageUrl anywhere the output is byte-identical to the ungrouped
1191
+ // export (`## N.` entries, no page headers).
1192
+ const hasPageGroups = sortedAnns.some(
1193
+ (a: any) => typeof a.pageUrl === 'string' && a.pageUrl.length > 0,
1194
+ );
1195
+ const annotationNumbers = new Map<any, number>(
1196
+ sortedAnns.map((ann, index) => [ann, index + 1]),
1197
+ );
1198
+ let emitOrder = sortedAnns;
1199
+ if (hasPageGroups) {
1200
+ const unpaged = sortedAnns.filter((a: any) => !a.pageUrl);
1201
+ const pageOrder: string[] = [];
1202
+ for (const ann of sortedAnns) {
1203
+ if (ann.pageUrl && !pageOrder.includes(ann.pageUrl)) pageOrder.push(ann.pageUrl);
1204
+ }
1205
+ emitOrder = [
1206
+ ...unpaged,
1207
+ ...pageOrder.flatMap((page) => sortedAnns.filter((a: any) => a.pageUrl === page)),
1208
+ ];
1209
+ }
1210
+
1211
+ // Threaded replies (`inReplyTo`): a reply is emitted as a nested exchange
1212
+ // under its parent's entry rather than as its own numbered entry, so the
1213
+ // coding agent reads the conversation in order. Replies whose parent is
1214
+ // not in the export render as ordinary entries. With no `inReplyTo`
1215
+ // anywhere the output is byte-identical to the ungrouped export.
1216
+ const exportedIds = new Set(sortedAnns.map((a: any) => a.id));
1217
+ const isReply = (a: any) => typeof a.inReplyTo === 'string' && a.inReplyTo !== a.id && exportedIds.has(a.inReplyTo);
1218
+ const hasReplies = sortedAnns.some(isReply);
1219
+ const repliesOf = (parent: any): any[] =>
1220
+ hasReplies ? sortedAnns.filter((a: any) => isReply(a) && a.inReplyTo === parent.id).sort((a: any, b: any) => a.createdA - b.createdA) : [];
1221
+ if (hasReplies) {
1222
+ emitOrder = emitOrder.filter((a) => !isReply(a));
1223
+ // Numbers stay consecutive over the entries that are actually emitted.
1224
+ annotationNumbers.clear();
1225
+ emitOrder.forEach((ann, index) => annotationNumbers.set(ann, index + 1));
1226
+ }
1227
+ const replyBlock = (parent: any, depth = 0): string => {
1228
+ let block = '';
1229
+ for (const reply of repliesOf(parent)) {
1230
+ const who = reply.author ? `${reply.author}` : 'reply';
1231
+ const indent = ' '.repeat(depth);
1232
+ block += `${indent}- **Reply (${who}):** ${String(reply.text ?? '').replace(/\r?\n/g, `\n${indent} `)}\n`;
1233
+ if (reply.images && reply.images.length > 0) {
1234
+ reply.images.forEach((img: ImageAttachment) => {
1235
+ block += `${indent} - [${img.name}] \`${img.path}\`\n`;
1236
+ });
1237
+ }
1238
+ block += replyBlock(reply, depth + 1);
1239
+ }
1240
+ return block;
1241
+ };
1242
+
1243
+ let lastEmittedPage: string | null = null;
1244
+ emitOrder.forEach((ann) => {
1245
+ if (hasPageGroups && ann.pageUrl && ann.pageUrl !== lastEmittedPage) {
1246
+ output += `## Page: ${ann.pageUrl}\n\n`;
1247
+ lastEmittedPage = ann.pageUrl;
1248
+ }
1249
+ output += `${hasPageGroups ? '###' : '##'} ${annotationNumbers.get(ann)}. `;
1183
1250
 
1184
1251
  // Add diff context label if annotation was created in diff view
1185
1252
  if (ann.diffContext) {
@@ -1233,6 +1300,12 @@ export const exportAnnotations = (
1233
1300
  });
1234
1301
  }
1235
1302
 
1303
+ // Threaded replies nest under the entry they answer.
1304
+ if (hasReplies) {
1305
+ const thread = replyBlock(ann);
1306
+ if (thread) output += `**Replies:**\n${thread}`;
1307
+ }
1308
+
1236
1309
  output += '\n';
1237
1310
  });
1238
1311
 
@@ -31,6 +31,19 @@ export const LABEL_COLOR_MAP: Record<string, { bg: string; text: string; darkTex
31
31
  amber: { bg: 'rgba(180,83,9,0.15)', text: '#b45309', darkText: '#fbbf24' },
32
32
  };
33
33
 
34
+ /**
35
+ * The hardcoded one-click positive label behind the toolbar's πŸ‘ button and
36
+ * the composer's "Looks good" action. Deliberately NOT part of the
37
+ * configurable set: it is the ONLY label comment-only surfaces (HTML /
38
+ * live-app) may emit β€” their restricted handlers filter on this id.
39
+ */
40
+ export const THUMBS_UP_LABEL: QuickLabel = {
41
+ id: 'thumbs-up',
42
+ emoji: 'πŸ‘',
43
+ text: 'Looks good',
44
+ color: 'green',
45
+ };
46
+
34
47
  export const DEFAULT_QUICK_LABELS: QuickLabel[] = [
35
48
  { id: 'clarify-this', emoji: '❓', text: 'Clarify this', color: 'yellow' },
36
49
  { id: 'missing-overview', emoji: 'πŸ—ΊοΈ', text: 'Missing overview', color: 'purple', tip: 'Provide a narrative overview of what is being built, why it is being built, and how it will be built. Add this before the implementation details.' },
@@ -1,3 +1,4 @@
1
+ import { getScrollViewportRect } from '../hooks/useScrollViewport';
1
2
  import { getAnnotatableTextNodes } from './domSelection';
2
3
 
3
4
  /** A durable cursor location measured within one rendered Markdown block. */
@@ -132,7 +133,9 @@ function getViewportCenterY(
132
133
  container: HTMLElement,
133
134
  scrollViewport?: HTMLElement | null,
134
135
  ): number {
135
- const rect = (scrollViewport ?? container).getBoundingClientRect();
136
+ const rect = scrollViewport
137
+ ? getScrollViewportRect(scrollViewport)
138
+ : container.getBoundingClientRect();
136
139
  return rect.top + rect.height / 2;
137
140
  }
138
141
 
@@ -1,3 +1,8 @@
1
+ import {
2
+ getScrollViewportRect,
3
+ offsetScrollViewport,
4
+ } from '../hooks/useScrollViewport';
5
+
1
6
  /**
2
7
  * Keep the Vim cursor clear of the HUD bands that hug the viewport edges.
3
8
  *
@@ -131,11 +136,11 @@ export function scrollVimTargetIntoView(
131
136
  return;
132
137
  }
133
138
 
134
- const viewportRect = viewport.getBoundingClientRect();
139
+ const viewportRect = getScrollViewportRect(viewport);
135
140
  const targetRect = element.getBoundingClientRect();
136
141
  if (targetRect.height === 0 && targetRect.width === 0) return;
137
142
 
138
- const margin = resolveVimScrollMargin(viewport.clientHeight);
143
+ const margin = resolveVimScrollMargin(viewportRect.height);
139
144
  const stickyBottom = viewport
140
145
  .querySelector<HTMLElement>('[data-sticky-actions]')
141
146
  ?.getBoundingClientRect().bottom;
@@ -153,10 +158,10 @@ export function scrollVimTargetIntoView(
153
158
  : margin;
154
159
 
155
160
  const delta = computeVimScrollDelta(
156
- { top: viewportRect.top, height: viewport.clientHeight },
161
+ { top: viewportRect.top, height: viewportRect.height },
157
162
  { top: targetRect.top, bottom: targetRect.bottom },
158
163
  { topMargin, bottomMargin },
159
164
  );
160
165
  if (delta === 0) return;
161
- viewport.scrollTop += delta;
166
+ offsetScrollViewport(viewport, delta);
162
167
  }
package/utils/wideMode.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { SidebarTab } from '@plannotator/ui/hooks/useSidebar';
2
+ import type { WideModeType } from '@plannotator/ui/types';
2
3
  export type { WideModeType } from '@plannotator/ui/types';
3
4
 
4
5
  export type WideModeLayoutSnapshot = {
@@ -26,6 +27,25 @@ export function canUseAnnotateWideMode(options: {
26
27
  return !options.archiveMode && !options.isPlanDiffActive;
27
28
  }
28
29
 
30
+ /** What the focus-mode keyboard shortcut should do on this press. */
31
+ export type FocusShortcutAction = 'enter-focus' | 'exit' | 'none';
32
+
33
+ /**
34
+ * Decide the next step for the focus-mode keyboard shortcut.
35
+ *
36
+ * The shortcut is a single "hide the chrome / give it back" toggle, so ANY
37
+ * active panel-hiding view mode restores the remembered layout β€” including
38
+ * `wide`, which the toolbar control would instead swap for `focus`. A press
39
+ * that could only re-hide already-hidden panels would look like a dead key.
40
+ */
41
+ export function resolveFocusShortcutAction(state: {
42
+ canUseWideMode: boolean;
43
+ wideModeType: WideModeType | null;
44
+ }): FocusShortcutAction {
45
+ if (state.wideModeType !== null) return 'exit';
46
+ return state.canUseWideMode ? 'enter-focus' : 'none';
47
+ }
48
+
29
49
  export function resolveWideModeExitLayout(
30
50
  snapshot: WideModeLayoutSnapshot | null,
31
51
  options?: WideModeExitOptions,
@@ -0,0 +1,46 @@
1
+ /**
2
+ * "An agent has acted" signal for the indicator policy: NOTHING visible may
3
+ * appear merely because `document.modelContext` exists. Only after the first
4
+ * successful tool call in a session does the header show its unobtrusive
5
+ * affordance, which subscribes here.
6
+ *
7
+ * Module-level and dependency-free; a browser without WebMCP never records a
8
+ * call, so subscribers render nothing and the store never changes.
9
+ */
10
+ import { useSyncExternalStore } from 'react';
11
+
12
+ export interface WebMcpActivity {
13
+ /** Successful tool calls in this page load. */
14
+ calls: number;
15
+ /** Prefixed name of the last successful tool call. */
16
+ lastTool: string | null;
17
+ }
18
+
19
+ let activity: WebMcpActivity = { calls: 0, lastTool: null };
20
+ const listeners = new Set<() => void>();
21
+
22
+ export function recordToolCall(tool: string): void {
23
+ activity = { calls: activity.calls + 1, lastTool: tool };
24
+ for (const listener of listeners) listener();
25
+ }
26
+
27
+ export function getWebMcpActivity(): WebMcpActivity {
28
+ return activity;
29
+ }
30
+
31
+ export function subscribeWebMcpActivity(listener: () => void): () => void {
32
+ listeners.add(listener);
33
+ return () => {
34
+ listeners.delete(listener);
35
+ };
36
+ }
37
+
38
+ /** Mainly for tests. */
39
+ export function resetWebMcpActivity(): void {
40
+ activity = { calls: 0, lastTool: null };
41
+ for (const listener of listeners) listener();
42
+ }
43
+
44
+ export function useWebMcpActivity(): WebMcpActivity {
45
+ return useSyncExternalStore(subscribeWebMcpActivity, getWebMcpActivity, getWebMcpActivity);
46
+ }