@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.
- package/README.md +46 -1
- package/components/ActionMenu.tsx +6 -1
- package/components/AgentsTab.tsx +8 -9
- package/components/AnalysisLayerToggle.tsx +48 -0
- package/components/AnnotationPanel.tsx +158 -36
- package/components/AnnotationToolbar.tsx +50 -30
- package/components/AnnotationToolstrip.tsx +9 -0
- package/components/CommentPopover.tsx +238 -46
- package/components/ConfirmDialog.tsx +42 -28
- package/components/GraphvizBlock.tsx +86 -7
- package/components/HtmlSurfaceControls.tsx +170 -0
- package/components/InlineMarkdown.tsx +25 -4
- package/components/KeyboardShortcuts.tsx +9 -0
- package/components/Landing.tsx +1 -1
- package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
- package/components/MarkdownEditor/embedPicker.ts +349 -0
- package/components/MarkdownEditor.tsx +12 -0
- package/components/MermaidBlock.tsx +60 -26
- package/components/ModeToggle.tsx +2 -1
- package/components/PermissionModeSetup.tsx +24 -5
- package/components/PinpointOverlay.tsx +11 -6
- package/components/PlanHeaderMenu.tsx +140 -1
- package/components/SearchableSelect.tsx +2 -0
- package/components/Settings.tsx +175 -10
- package/components/SkillReferenceMenu.tsx +9 -0
- package/components/StickyHeaderLane.tsx +9 -2
- package/components/TableOfContents.tsx +9 -4
- package/components/TextShimmer.tsx +8 -5
- package/components/ThemeProvider.tsx +43 -1
- package/components/ThemeTab.tsx +52 -1
- package/components/Tooltip.tsx +3 -1
- package/components/Viewer.tsx +19 -5
- package/components/VimTargetReticle.tsx +12 -4
- package/components/ai/DocumentAIChatPanel.tsx +1 -0
- package/components/blocks/MathBlock.tsx +26 -14
- package/components/core/button.tsx +14 -6
- package/components/html-viewer/HtmlViewer.tsx +320 -41
- package/components/html-viewer/bridge-script.ts +360 -72
- package/components/html-viewer/composerYield.ts +1 -51
- package/components/html-viewer/hostThreads.ts +37 -0
- package/components/html-viewer/index.ts +9 -0
- package/components/html-viewer/unanchored.ts +47 -0
- package/components/html-viewer/useHtmlAnnotation.ts +240 -61
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
- package/components/sidebar/FileBrowser.tsx +17 -5
- package/components/sidebar/SidebarContainer.tsx +124 -28
- package/components/ui/button.tsx +10 -8
- package/components/ui/dialog.tsx +35 -25
- package/config/index.ts +6 -1
- package/config/reviewView.ts +42 -9
- package/config/settings.ts +141 -0
- package/configure.ts +32 -0
- package/hooks/useAIProviderConfig.ts +8 -7
- package/hooks/useActiveSection.ts +6 -4
- package/hooks/useAgentJobs.ts +3 -0
- package/hooks/useAnnotationHighlighter.ts +20 -0
- package/hooks/useHtmlRefresh.ts +149 -0
- package/hooks/useIsMobile.ts +37 -0
- package/hooks/useLinkedDoc.ts +7 -0
- package/hooks/useMathRenderer.ts +30 -0
- package/hooks/useScrollViewport.ts +74 -0
- package/hooks/useSharing.ts +31 -5
- package/hooks/useViewportEnvironment.ts +350 -0
- package/package.json +5 -2
- package/shortcuts/index.ts +3 -0
- package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
- package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
- package/styles.css +1 -1
- package/theme.css +229 -0
- package/types.ts +35 -0
- package/utils/annotateAgentTerminal.ts +36 -5
- package/utils/blockTargeting.ts +6 -3
- package/utils/composerYield.ts +45 -0
- package/utils/generateIdentity.ts +64 -14
- package/utils/htmlChrome.ts +20 -16
- package/utils/identity-tater.ts +36 -0
- package/utils/lookAndFeelAnnouncement.ts +12 -8
- package/utils/markdownExtensions.ts +57 -0
- package/utils/math-eager.ts +25 -0
- package/utils/math.ts +146 -0
- package/utils/mermaid-eager.ts +28 -0
- package/utils/mermaid.ts +132 -0
- package/utils/parser.ts +75 -2
- package/utils/quickLabels.ts +13 -0
- package/utils/vimNavigation.ts +4 -1
- package/utils/vimScroll.ts +9 -4
- package/utils/wideMode.ts +20 -0
- package/webmcp/activity.ts +46 -0
- package/webmcp/changes.ts +227 -0
- package/webmcp/index.ts +72 -0
- package/webmcp/modelContext.ts +103 -0
- package/webmcp/nudges.ts +174 -0
- package/webmcp/policy.ts +50 -0
- package/webmcp/preference.ts +50 -0
- package/webmcp/schema.ts +81 -0
- package/webmcp/toolset.ts +337 -0
- package/webmcp/useToolset.ts +74 -0
- package/components/PlanAIAnnouncementDialog.tsx +0 -187
- package/components/VimModeAnnouncementDialog.tsx +0 -557
- package/utils/planAIAnnouncement.ts +0 -17
- 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');
|
package/utils/mermaid.ts
ADDED
|
@@ -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
|
-
|
|
1182
|
-
|
|
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
|
|
package/utils/quickLabels.ts
CHANGED
|
@@ -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.' },
|
package/utils/vimNavigation.ts
CHANGED
|
@@ -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 =
|
|
136
|
+
const rect = scrollViewport
|
|
137
|
+
? getScrollViewportRect(scrollViewport)
|
|
138
|
+
: container.getBoundingClientRect();
|
|
136
139
|
return rect.top + rect.height / 2;
|
|
137
140
|
}
|
|
138
141
|
|
package/utils/vimScroll.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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:
|
|
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
|
|
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
|
+
}
|