@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
package/README.md CHANGED
@@ -27,6 +27,9 @@ configurePlannotatorUI({
27
27
  skillCatalogTransport, // skill-reference catalog for comment composers
28
28
  skillContentTransport, // human-only skill contents for feedback injection
29
29
  serverSync,
30
+ webmcp, // browser-agent (WebMCP) provider policy: { enabled, namePrefix }
31
+ mathRendererLoader, // how KaTeX loads when no renderer is registered before first math render
32
+ identityGenerator, // sync generator behind the default "tater" name (no identityProvider)
30
33
  });
31
34
  ```
32
35
 
@@ -47,6 +50,16 @@ The sidebar/panel resize handle exposes seams for hosts that want different edge
47
50
 
48
51
  Building your own tooltip and removing the built-in double-click reset are host-side concerns (override `onDoubleClick` where you render the handle).
49
52
 
53
+ ### Lazy renderers and the eager entries (`utils/math`, `utils/generateIdentity`, `utils/mermaid`; 0.32.0)
54
+
55
+ The Mermaid runtime, the Graphviz engine, KaTeX and the username dictionary are off the static import graph of `Viewer`, so a host that bundles by route does not download them for a plain markdown read. Graphviz needs nothing from you (the block imports the engine inside its render effect and shows the source fence until the SVG lands, as it always did). Mermaid, KaTeX and the dictionary sit behind synchronous slots:
56
+
57
+ - **Math.** Without registration, a math node renders its TeX as text in the same wrapper (same `data-math-tex` / `data-math-display` / `aria-label` / class names), loads KaTeX via `import('katex')`, and re-renders typeset. To keep math typeset on the very first commit, as Plannotator does, add one line to your entry: `import "@plannotator/ui/utils/math-eager";`. To put KaTeX and its stylesheet on one lazy chunk instead, pass `mathRendererLoader`. The stylesheet remains your job either way (see "Consuming it", step 3).
58
+ - **Mermaid.** Without registration, the first diagram on a page fetches the runtime through `import('mermaid')`; a failed import is dropped from the memo, re-attempted once after a short delay, and the error panel (with the source) offers Retry, which issues another fresh attempt. Plannotator keeps Mermaid eager by policy so it can never fail separately from the app: `import "@plannotator/ui/utils/mermaid-eager";` in your entry does the same for your bundle. Honest limit of any in-page retry: a browser records a failed module fetch in its module map for the page lifetime, so a fresh `import()` of the same chunk URL rejects without a request; the retry recovers failures after the fetch (engine instantiation, initialize) and hosts that version chunk URLs. A host that needs recovery from a failed first fetch uses versioned chunk URLs or a `vite:preloadError` reload at app level.
59
+ - **Identity.** With an `identityProvider` the generator is never called and the word lists stay out of your bundle. Without one, default names come from a small built-in pool of the same `adjective-noun-tater` shape; `import "@plannotator/ui/utils/identity-tater";` registers the full dictionary, or pass your own `identityGenerator`.
60
+
61
+ Plannotator's own entries import the eager modules (`math-eager` and `identity-tater` in both `packages/editor/App.tsx` and `packages/review-editor/App.tsx`; `mermaid-eager` in the plan editor only, since the review editor never renders a Mermaid block), which is what keeps its single-file builds byte-identical and its portal entry chunk shaped as before; `tests/entry-assets.test.ts` fails if any of them is dropped. See HANDOFF.md "Lazy renderers and eager entries".
62
+
50
63
  ### Markdown editor extensions + wiki links (`MarkdownEditor` / `InlineMarkdown`)
51
64
 
52
65
  - **`MarkdownEditor` takes CM6 extensions.** `extensions?: readonly Extension[]` (from `@codemirror/state`) is forwarded verbatim into the underlying editor — the seam for `wikiLinks(config)`, `y-codemirror.next` collab bindings, custom keymaps.
@@ -60,6 +73,7 @@ Building your own tooltip and removing the built-in double-click reset are host-
60
73
  const editorExtensions = [wikiLinks({ suggest, resolve, onOpen })]; // stable reference!
61
74
  <MarkdownEditor markdown={md} documentId={docId} editorHandleRef={ref} extensions={editorExtensions} />
62
75
  ```
76
+ - **`embedPicker(config)` and `embedSlashItem()` are re-exported from the same surface.** Compose the static item into `slashCommands({ items: [...] })` and pass the picker beside it in the stable `extensions` array. `getTargets`, `buildInsertLine`, optional `uploadTarget`, and optional `getNotice` stay live through callbacks. The host owns embed grammar and upload error UI; the package owns filtering, async anchor mapping, single-flight upload state, and paragraph-safe insertion through the re-exported `planEmbedInsert()`.
63
77
  - **The viewer resolves wiki-links synchronously.** `InlineMarkdown` takes `resolveLinkedDoc?: (target) => { label?; status?: 'active' | 'deleted' } | null` — called with the raw stored target (opaque ids like `doc_01XYZ`, no `.md` normalization). Return a `label` to display live titles (stored label is the fallback, target the last resort); return `status: 'deleted'` for a muted non-link ("Document deleted") instead of a live link. Absent or `null` → rendering is unchanged. Sync-only by design: back it with an in-memory cache.
64
78
 
65
79
  Requires `@plannotator/markdown-editor ^0.3.2` and `@plannotator/atomic-editor ^0.7.0`. See HANDOFF.md § "Wiki-link seams (0.27.0)".
@@ -84,6 +98,37 @@ Requires `@plannotator/markdown-editor ^0.4.0` and `@plannotator/atomic-editor ^
84
98
 
85
99
  `components/html-viewer` is supported host surface as of 0.29.0: the overlay-projection annotation viewer for raw HTML (placed comment markers, pinpoint element anchors, shift-click multi-target comments). Its contract is **props plus the validated iframe message protocol** — not `configurePlannotatorUI()`, which only governs the backend surfaces around it. Drive the `annotations` prop (marker numbering derives from its array order); `readOnly` keeps markers painted and clickable while disabling all authoring. **0.29.0 also carries a breaking migration:** highlight.js is gone and `.hljs` selectors are inert — style code via the exported `pn-code` class (`CODE_BLOCK_CLASS` in `utils/codeHighlight`). See HANDOFF.md § "Raw-HTML annotation viewer + syntax-highlighting migration (0.29.0)" before upgrading from 0.28.0.
86
100
 
101
+ #### HTML annotation parity seams (0.32.0)
102
+
103
+ Everything a host needs around `HtmlViewer` to match Plannotator's HTML annotation experience, all additive and all defaulting to today's behavior. Requires `@plannotator/core` 0.25.0 (the `html-anchor` subpath), so install and publish core before ui:
104
+
105
+ - **`projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })`** and **`buildPersistedHtmlAnchor(source, { maxBytes?, maxTargets? })`** from `components/html-viewer` (pure, from `@plannotator/core/html-anchor`): project stored rows onto the `annotations` prop in the order that becomes the marker numbering, and trim a composed comment's anchor for persistence with cap drops and size drops reported separately. A row with nothing restorable projects as a document-level `GLOBAL_COMMENT` by default (`documentLevel: 'global'`, never reported as unanchored) or, with `documentLevel: 'unanchored'`, as a textless page `COMMENT` the unanchored report names.
106
+ - **`onUnanchoredChange`** is keyed to the bridge's restore (one complete report per document after the restore batch, the empty set included) and complete over the `annotations` prop: textless page rows are reported without being posted, and a locally minted id the host swapped out of its list is not. It replaces a host's `mark-applied` bookkeeping for the unanchored set; the local-to-server mark swap itself stays host-side.
107
+ - **`hooks/useHtmlRefresh({ fetchSnapshot, onSnapshot, onUnanchored?, onResult? })`**: the refresh cycle with the stale-response and document-change guards, backend behind `fetchSnapshot`.
108
+ - **`components/HtmlSurfaceControls`**: the eye / refresh / pen header controls with Plannotator's markup and `labels` overrides.
109
+ - **`AnnotationPanel` `unanchoredIds`**: an "Unanchored" chip on the listed cards.
110
+ - **`HtmlViewer` `scrollBehavior`** (`'auto'` for reduced motion) and **`maxAdditionalTargets`** (a product cap the bridge honors too).
111
+ - An `ExternalAnnotationTransport` whose `subscribe` emits `snapshot` on a host push keeps `useExternalAnnotations` off its fallback poll.
112
+
113
+ See HANDOFF.md § "HTML annotation parity seams".
114
+
115
+ #### Also blessed in 0.32.0: `shortcuts` and `utils/inputMethod`
116
+
117
+ - **`@plannotator/ui/shortcuts`**: the declarative keyboard-shortcut engine (`defineShortcutScope`, `useShortcutScope`) and the per-surface scopes, including `useHtmlAnnotateShortcuts` for the Mod+Shift+A Annotate/Interact chord on HTML surfaces. Pure React plus `utils/platform`; no backend.
118
+ - **`@plannotator/ui/utils/inputMethod`**: `getInputMethod(surface)` / `saveInputMethod(method, surface)` / `refreshInputMethodStamp(method)`, the per-surface pinpoint-or-drag preference with its TTL, persisted through the `storageBackend` seam.
119
+
120
+ ### WebMCP provider (`@plannotator/ui/webmcp`; 0.32.0)
121
+
122
+ The engine that lets a browser-integrated agent (Chrome/Edge WebMCP, `document.modelContext`) call in-page tools on a document surface. Feature-detected once; a browser without the API sees no registration, no DOM, no network, no timers. Seam: `configurePlannotatorUI({ webmcp: { enabled, namePrefix } })`, default enabled with the `plannotator.` prefix; pass `enabled: false` to keep a host page tool-free, or your own prefix to namespace the tools beside your own. There is deliberately no confirmation seam: the catalog is read-and-comment only (no approve / submit / close tools), and the agent may only edit or remove comments stamped `source: "browser-agent"`.
123
+
124
+ - `modelContext.ts` is the only file that spells the spec surface (local structural types, no `webmcp-types` dependency). A spec rename is a one-file change.
125
+ - `useToolset({ id, active, build, deps, hooks })` attaches a named tool set to the document registry; handlers read through refs, so re-renders never re-register, and `active: false` aborts every registration (what Plannotator's Settings opt-out drives).
126
+ - `AnnotationChangeTracker` / `buildNudges` are pure (no DOM): per-annotation `seq`, tombstones, a per-tab watermark with `since` override, and the nudge vocabulary every response carries.
127
+ - A host with its own document state builds the same adapter-driven catalog Plannotator uses (`packages/editor/webmcp/documentTools.ts`, `buildDocumentTools(adapter, state, options)`) over its own getters and actions; multi-document pages should register one set whose tools take `path` (the folder-session shape) rather than one set per viewer (duplicate names across sets are skipped with a warning, never replaced).
128
+ - Never register tools inside an untrusted iframe: the raw-HTML viewer's `sandbox="allow-scripts"` frame and the live-app frame carry no `allow="tools"`, and that is what keeps a framed page from impersonating the host's tools.
129
+
130
+ The one additive data-model change that rides with it: `Annotation.inReplyTo` (threaded replies; the panel indents them under the parent, the export nests them, share links drop them).
131
+
87
132
  ## Consuming it (e.g. from Workspaces)
88
133
 
89
134
  ```bash
@@ -107,7 +152,7 @@ npm install @plannotator/ui @plannotator/core
107
152
  - `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
108
153
  - `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on `@plannotator/core` (exact-version lockstep). Published.
109
154
  - `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
110
- - Versioned in lockstep with the repo. Publish `core` then `ui`: build each tarball with **`bun pm pack`** (resolves `workspace:*` to the exact version at pack time), then **`npm publish *.tgz --provenance --access public`** — the repo's existing flow.
155
+ - Versioned in lockstep with the repo (currently `@plannotator/core` 0.25.0 with `@plannotator/ui` 0.32.0). Publish `core` then `ui`: build each tarball with **`bun pm pack`** (resolves `workspace:*` to the exact version at pack time, from `bun.lock`, so run `bun install` after a bump), then **`npm publish *.tgz --provenance --access public`** — the repo's existing flow (`--provenance` needs CI OIDC; local publishes drop it, see HANDOFF.md "Publishing & versioning").
111
156
 
112
157
  ## The one rule
113
158
 
@@ -68,6 +68,7 @@ interface ActionMenuItemProps {
68
68
  label: string;
69
69
  subtitle?: string;
70
70
  badge?: React.ReactNode;
71
+ disabled?: boolean;
71
72
  }
72
73
 
73
74
  export const ActionMenuItem: React.FC<ActionMenuItemProps> = ({
@@ -76,10 +77,14 @@ export const ActionMenuItem: React.FC<ActionMenuItemProps> = ({
76
77
  label,
77
78
  subtitle,
78
79
  badge,
80
+ disabled = false,
79
81
  }) => (
80
82
  <button
83
+ data-pn-touch-target
84
+ type="button"
81
85
  onClick={onClick}
82
- className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted"
86
+ disabled={disabled}
87
+ className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:bg-transparent"
83
88
  >
84
89
  <span className="text-muted-foreground">{icon}</span>
85
90
  {subtitle ? (
@@ -15,6 +15,7 @@ import {
15
15
  } from 'lucide-react';
16
16
  import type { AgentJobInfo, AgentCapabilities } from '../types';
17
17
  import { isTerminalStatus } from '@plannotator/core/agent-jobs';
18
+ import { GUIDE_ENGINE_LABEL } from '@plannotator/core/guide';
18
19
  import { cn } from '../lib/utils';
19
20
  import { ReviewAgentsIcon } from './ReviewAgentsIcon';
20
21
  import { ClaudeIcon, CodexIcon, CopilotIcon, CursorIcon, OpenCodeIcon, PiIcon } from './icons/AgentIcons';
@@ -127,14 +128,7 @@ const ENGINE_ICON: Record<AgentEngine, React.FC<{ className?: string }>> = {
127
128
  // review surface offers the wider set (Cursor/OpenCode). Exported so the guide
128
129
  // takeover surfaces (GuideScreen, GuideEmptyState in packages/review-editor)
129
130
  // share this one source of truth instead of keeping their own copies in sync.
130
- export const REVIEW_ENGINE_LABEL: Record<ReviewEngine, string> = {
131
- claude: 'Claude',
132
- codex: 'Codex',
133
- cursor: 'Cursor',
134
- opencode: 'OpenCode',
135
- pi: 'Pi',
136
- copilot: 'Copilot',
137
- };
131
+ export const REVIEW_ENGINE_LABEL: Record<ReviewEngine, string> = GUIDE_ENGINE_LABEL;
138
132
 
139
133
  // Review-only icon map — the wide set. Tour keeps the narrow ENGINE_ICON.
140
134
  const REVIEW_ENGINE_ICON: Record<ReviewEngine, React.FC<{ className?: string }>> = {
@@ -872,7 +866,12 @@ export const AgentsTab: React.FC<AgentsTabProps> = ({
872
866
  ? { effort: tourClaudeEffort }
873
867
  : { reasoningEffort: tourCodexReasoning, ...(tourCodexFast && { fastMode: true }) }),
874
868
  });
875
- const buildGuideLaunch = (): LaunchParams => {
869
+ // Guide extra instructions (#1265) are server-stored: this surface has no
870
+ // editor, so it sends none and the server applies the stored standing
871
+ // instructions itself, keeping sidebar launches identical to launch-page
872
+ // ones without a second read path.
873
+ const buildGuideLaunch = (): LaunchParams => buildGuideEngineParams();
874
+ const buildGuideEngineParams = (): LaunchParams => {
876
875
  if (guideEngine === 'cursor') {
877
876
  // Same omission rules as buildReviewLaunch: auto/empty ⇒ engine default.
878
877
  // Guide-scoped model — deliberately NOT the shared cursorModel (see
@@ -0,0 +1,48 @@
1
+ import React from 'react';
2
+
3
+ interface AnalysisLayerToggleProps {
4
+ readonly checked: boolean;
5
+ readonly onChange: (checked: boolean) => void;
6
+ readonly label: React.ReactNode;
7
+ readonly description: string;
8
+ readonly disabled?: boolean;
9
+ readonly className?: string;
10
+ }
11
+
12
+ /** Full-row switch shared by Settings and the code-review analysis welcome. */
13
+ export function AnalysisLayerToggle({
14
+ checked,
15
+ onChange,
16
+ label,
17
+ description,
18
+ disabled = false,
19
+ className = '',
20
+ }: AnalysisLayerToggleProps) {
21
+ return (
22
+ <button
23
+ type="button"
24
+ role="switch"
25
+ aria-checked={checked}
26
+ disabled={disabled}
27
+ onClick={() => onChange(!checked)}
28
+ className={`flex min-h-11 w-full items-center justify-between gap-4 bg-transparent text-left disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
29
+ >
30
+ <span className="min-w-0">
31
+ <span className="block text-sm font-medium text-foreground">{label}</span>
32
+ <span className="mt-0.5 block text-xs leading-relaxed text-muted-foreground">{description}</span>
33
+ </span>
34
+ <span
35
+ aria-hidden="true"
36
+ className={`relative h-6 w-11 shrink-0 rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none ${
37
+ checked ? 'bg-primary' : 'bg-muted'
38
+ }`}
39
+ >
40
+ <span
41
+ className={`absolute left-1 top-1 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-150 ease-out motion-reduce:transition-none ${
42
+ checked ? 'translate-x-5' : 'translate-x-0'
43
+ }`}
44
+ />
45
+ </span>
46
+ </button>
47
+ );
48
+ }
@@ -38,6 +38,52 @@ const TrashCardIcon = () => (
38
38
  </svg>
39
39
  );
40
40
 
41
+ /**
42
+ * Order annotations so every reply follows its parent (replies among
43
+ * themselves stay in creation order). A reply whose parent is absent renders
44
+ * as a top-level card. Without any `inReplyTo` the input order is returned
45
+ * unchanged, so annotations without replies render exactly as before.
46
+ */
47
+ export function threadReplies(sorted: Annotation[]): Array<{ annotation: Annotation; isReply: boolean }> {
48
+ if (!sorted.some((a) => a.inReplyTo)) return sorted.map((annotation) => ({ annotation, isReply: false }));
49
+ const ids = new Set(sorted.map((a) => a.id));
50
+ const byParent = new Map<string, Annotation[]>();
51
+ for (const a of sorted) {
52
+ if (a.inReplyTo && ids.has(a.inReplyTo) && a.inReplyTo !== a.id) {
53
+ const list = byParent.get(a.inReplyTo) ?? [];
54
+ list.push(a);
55
+ byParent.set(a.inReplyTo, list);
56
+ }
57
+ }
58
+ const out: Array<{ annotation: Annotation; isReply: boolean }> = [];
59
+ const emitted = new Set<string>();
60
+ const emit = (a: Annotation, isReply: boolean) => {
61
+ if (emitted.has(a.id)) return;
62
+ emitted.add(a.id);
63
+ out.push({ annotation: a, isReply });
64
+ for (const reply of byParent.get(a.id) ?? []) emit(reply, true);
65
+ };
66
+ for (const a of sorted) {
67
+ if (a.inReplyTo && ids.has(a.inReplyTo) && a.inReplyTo !== a.id) continue;
68
+ emit(a, false);
69
+ }
70
+ for (const a of sorted) emit(a, false);
71
+ return out;
72
+ }
73
+
74
+ /** Timeline position of an annotation: its own time, or its thread root's for replies. */
75
+ function threadTs(annotation: Annotation, all: Annotation[]): number {
76
+ let current = annotation;
77
+ const seen = new Set<string>();
78
+ while (current.inReplyTo && !seen.has(current.id)) {
79
+ seen.add(current.id);
80
+ const parent = all.find((a) => a.id === current.inReplyTo);
81
+ if (!parent) break;
82
+ current = parent;
83
+ }
84
+ return current.createdA;
85
+ }
86
+
41
87
  interface DirectEditsPanelItem {
42
88
  id: string;
43
89
  title?: string;
@@ -86,6 +132,13 @@ interface PanelProps {
86
132
  * gates what belongs in it. Selection and scrolling still work.
87
133
  * Default false — today's behavior. */
88
134
  readOnly?: boolean;
135
+ /** Embed only the timeline body in a host-owned stage. The host owns the
136
+ * title, close control, visible-viewport geometry, and focus boundary. */
137
+ presentation?: 'panel' | 'embedded';
138
+ /** Ids of annotations with no live location in the document (e.g. the
139
+ * HTML viewer's onUnanchoredChange report after a refresh). Matching
140
+ * cards show a small "Unanchored" chip. Absent: no chip, DOM unchanged. */
141
+ unanchoredIds?: ReadonlySet<string>;
89
142
  }
90
143
 
91
144
  export const AnnotationPanel: React.FC<PanelProps> = ({
@@ -111,16 +164,29 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
111
164
  directEdits = null,
112
165
  renderCardFooter,
113
166
  readOnly = false,
167
+ presentation = 'panel',
168
+ unanchoredIds,
114
169
  }) => {
115
170
  const isMobile = useIsMobile();
171
+ const embedded = presentation === 'embedded';
172
+ const mobilePanel = isMobile && !embedded;
116
173
  const [copiedText, setCopiedText] = useState(false);
117
174
  const listRef = useRef<HTMLDivElement>(null);
118
175
  const sortedAnnotations = [...annotations].sort((a, b) => a.createdA - b.createdA);
119
176
  const sortedCodeAnnotations = [...codeAnnotations].sort((a, b) => a.createdAt - b.createdAt);
177
+ // Replies (`inReplyTo`) thread under their parent: each reply is lifted to
178
+ // sit right after its parent (and the parent's earlier replies) at the
179
+ // parent's timeline position. With no replies the order is untouched.
180
+ const threadedAnnotations = threadReplies(sortedAnnotations);
120
181
  const timelineEntries = [
121
- ...sortedAnnotations.map(annotation => ({ kind: 'plan' as const, ts: annotation.createdA, annotation })),
122
- ...sortedCodeAnnotations.map(annotation => ({ kind: 'code' as const, ts: annotation.createdAt, annotation })),
123
- ].sort((a, b) => a.ts - b.ts);
182
+ ...threadedAnnotations.map(({ annotation, isReply }) => ({ kind: 'plan' as const, ts: annotation.createdA, annotation, isReply })),
183
+ ...sortedCodeAnnotations.map(annotation => ({ kind: 'code' as const, ts: annotation.createdAt, annotation, isReply: false })),
184
+ ].sort((a, b) => {
185
+ const ta = a.kind === 'plan' ? threadTs(a.annotation, sortedAnnotations) : a.ts;
186
+ const tb = b.kind === 'plan' ? threadTs(b.annotation, sortedAnnotations) : b.ts;
187
+ if (ta !== tb) return ta - tb;
188
+ return a.ts - b.ts;
189
+ });
124
190
  const totalCount = annotations.length + codeAnnotations.length + (editorAnnotations?.length ?? 0);
125
191
 
126
192
  // Scroll selected annotation card into view
@@ -138,47 +204,61 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
138
204
  <aside
139
205
  data-annotation-panel="true"
140
206
  data-plan-sidebar="right"
141
- className={`border-l border-border/50 bg-card flex flex-col flex-shrink-0 ${
142
- isMobile ? 'fixed top-12 bottom-0 right-0 z-[60] w-full max-w-sm shadow-2xl bg-card' : ''
207
+ className={`bg-card flex flex-col ${embedded ? 'h-full min-h-0 w-full flex-1' : 'flex-shrink-0 border-l border-border/50'} ${
208
+ mobilePanel ? 'fixed top-12 bottom-0 right-0 z-[60] w-full max-w-sm shadow-2xl bg-card' : ''
143
209
  }`}
144
- style={isMobile ? undefined : { width: width ?? 288 }}
210
+ style={embedded || mobilePanel ? undefined : { width: width ?? 288 }}
145
211
  >
146
212
  {/* Header */}
147
- <div className="border-b border-border/50">
148
- <div className="flex h-10 items-center justify-between px-3">
149
- <div className="flex items-center gap-2">
150
- <h2 className="text-xs font-medium text-foreground">
151
- Annotations
152
- </h2>
153
- {totalCount > 0 && (
154
- <span className="flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-primary/10 px-1 font-mono text-[10px] font-medium tabular-nums text-primary">
155
- {totalCount}
156
- </span>
213
+ {!embedded && (
214
+ <div className="border-b border-border/50">
215
+ <div className="flex h-10 items-center justify-between px-3">
216
+ <div className="flex items-center gap-2">
217
+ <h2 className="text-xs font-medium text-foreground">
218
+ Annotations
219
+ </h2>
220
+ {totalCount > 0 && (
221
+ <span className="flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-primary/10 px-1 font-mono text-[10px] font-medium tabular-nums text-primary">
222
+ {totalCount}
223
+ </span>
224
+ )}
225
+ </div>
226
+ {mobilePanel && onClose && (
227
+ <button
228
+ onClick={onClose}
229
+ className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground md:hidden"
230
+ title="Close panel"
231
+ aria-label="Close panel"
232
+ >
233
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
234
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
235
+ </svg>
236
+ </button>
157
237
  )}
158
238
  </div>
159
- {isMobile && onClose && (
239
+ {otherFileAnnotations && otherFileAnnotations.count > 0 && (
160
240
  <button
161
- onClick={onClose}
162
- className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground md:hidden"
163
- title="Close panel"
164
- aria-label="Close panel"
241
+ onClick={onOtherFileAnnotationsClick}
242
+ className="px-3 pb-2 text-[10px] text-primary/70 hover:text-primary transition-colors cursor-pointer"
243
+ title="Show annotated files in sidebar"
165
244
  >
166
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
167
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
168
- </svg>
245
+ +{otherFileAnnotations.count} in {otherFileAnnotations.files} other file{otherFileAnnotations.files === 1 ? '' : 's'}
169
246
  </button>
170
247
  )}
171
248
  </div>
172
- {otherFileAnnotations && otherFileAnnotations.count > 0 && (
173
- <button
174
- onClick={onOtherFileAnnotationsClick}
175
- className="px-3 pb-2 text-[10px] text-primary/70 hover:text-primary transition-colors cursor-pointer"
176
- title="Show annotated files in sidebar"
177
- >
178
- +{otherFileAnnotations.count} in {otherFileAnnotations.files} other file{otherFileAnnotations.files === 1 ? '' : 's'}
179
- </button>
180
- )}
181
- </div>
249
+ )}
250
+
251
+ {embedded && otherFileAnnotations && otherFileAnnotations.count > 0 && (
252
+ <button
253
+ type="button"
254
+ data-pn-touch-target="true"
255
+ onClick={onOtherFileAnnotationsClick}
256
+ className="min-h-11 flex-shrink-0 border-b border-border/50 px-3 text-left text-xs text-primary/80 active:bg-muted"
257
+ title="Show annotated files in navigator"
258
+ >
259
+ {otherFileAnnotations.count} more in {otherFileAnnotations.files} other file{otherFileAnnotations.files === 1 ? '' : 's'}
260
+ </button>
261
+ )}
182
262
 
183
263
  {/* List */}
184
264
  <OverlayScrollArea className="flex-1 min-h-0">
@@ -201,6 +281,25 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
201
281
  <>
202
282
  {timelineEntries.map(entry => (
203
283
  entry.kind === 'plan' ? (
284
+ entry.isReply ? (
285
+ <div
286
+ key={entry.annotation.id}
287
+ data-annotation-reply="true"
288
+ className="ml-3 border-l-2 border-border/40 pl-1.5"
289
+ >
290
+ <AnnotationCard
291
+ annotation={entry.annotation}
292
+ isSelected={selectedId === entry.annotation.id}
293
+ isMe={isCurrentUser(entry.annotation.author)}
294
+ onSelect={() => onSelect(entry.annotation.id)}
295
+ onDelete={() => onDelete(entry.annotation.id)}
296
+ onEdit={onEdit ? (updates: Partial<Annotation>) => onEdit(entry.annotation.id, updates) : undefined}
297
+ readOnly={readOnly}
298
+ footer={renderCardFooter?.(entry.annotation)}
299
+ unanchored={unanchoredIds?.has(entry.annotation.id) ?? false}
300
+ />
301
+ </div>
302
+ ) : (
204
303
  <AnnotationCard
205
304
  key={entry.annotation.id}
206
305
  annotation={entry.annotation}
@@ -211,7 +310,9 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
211
310
  onEdit={onEdit ? (updates: Partial<Annotation>) => onEdit(entry.annotation.id, updates) : undefined}
212
311
  readOnly={readOnly}
213
312
  footer={renderCardFooter?.(entry.annotation)}
313
+ unanchored={unanchoredIds?.has(entry.annotation.id) ?? false}
214
314
  />
315
+ )
215
316
  ) : (
216
317
  <CodeAnnotationCard
217
318
  key={entry.annotation.id}
@@ -297,7 +398,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
297
398
  </aside>
298
399
  );
299
400
 
300
- if (isMobile) {
401
+ if (mobilePanel) {
301
402
  return (
302
403
  <>
303
404
  <div
@@ -438,7 +539,9 @@ const AnnotationCard: React.FC<{
438
539
  onEdit?: (updates: Partial<Annotation>) => void;
439
540
  readOnly?: boolean;
440
541
  footer?: React.ReactNode;
441
- }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer }) => {
542
+ /** The annotation has no live location in the document (host-reported). */
543
+ unanchored?: boolean;
544
+ }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer, unanchored = false }) => {
442
545
  const [isEditing, setIsEditing] = useState(false);
443
546
  const [editText, setEditText] = useState(annotation.text || '');
444
547
  const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -493,6 +596,7 @@ const AnnotationCard: React.FC<{
493
596
  const editComposer = (
494
597
  <div onClick={(e: React.MouseEvent) => e.stopPropagation()}>
495
598
  <textarea
599
+ data-pn-mobile-editable="true"
496
600
  ref={textareaRef}
497
601
  value={editText}
498
602
  onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setEditText(e.target.value)}
@@ -534,6 +638,23 @@ const AnnotationCard: React.FC<{
534
638
  diff
535
639
  </span>
536
640
  )}
641
+ {annotation.pageUrl && (
642
+ <span
643
+ className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground truncate max-w-[10rem]"
644
+ title={annotation.pageUrl}
645
+ >
646
+ {annotation.pageUrl}
647
+ </span>
648
+ )}
649
+ {unanchored && (
650
+ <span
651
+ data-annotation-unanchored="true"
652
+ className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground"
653
+ title="This comment no longer matches a location in the document"
654
+ >
655
+ Unanchored
656
+ </span>
657
+ )}
537
658
  <span className="text-[10px] text-muted-foreground/50 truncate">
538
659
  {annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdA)}
539
660
  </span>
@@ -724,6 +845,7 @@ const CodeAnnotationCard: React.FC<{
724
845
  {isEditing ? (
725
846
  <div className="mt-1.5" onClick={(e) => e.stopPropagation()}>
726
847
  <textarea
848
+ data-pn-mobile-editable="true"
727
849
  ref={textareaRef}
728
850
  value={editText}
729
851
  onChange={(e) => setEditText(e.target.value)}
@@ -2,19 +2,13 @@ import React, { useState, useEffect, useRef, useMemo } from "react";
2
2
  import { AnnotationType } from "../types";
3
3
  import { createPortal } from "react-dom";
4
4
  import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape";
5
- import { type QuickLabel, getQuickLabels } from "../utils/quickLabels";
5
+ import { type QuickLabel, getQuickLabels, THUMBS_UP_LABEL } from "../utils/quickLabels";
6
6
  import { copyTextToClipboard } from "../utils/clipboard";
7
+ import { acquireTypeToCommentCapture } from "../shortcuts/plan-review/annotationMode.shortcuts";
7
8
  import { FloatingQuickLabelPicker } from "./FloatingQuickLabelPicker";
8
9
 
9
10
  type PositionMode = 'center-above' | 'top-right';
10
11
 
11
- const THUMBS_UP_LABEL: QuickLabel = {
12
- id: 'thumbs-up',
13
- emoji: '👍',
14
- text: 'Looks good',
15
- color: 'green',
16
- };
17
-
18
12
  const isEditableElement = (node: EventTarget | Element | null): boolean => {
19
13
  if (!(node instanceof Element)) return false;
20
14
  if (node.matches('input, textarea, select, [role="textbox"]')) return true;
@@ -33,6 +27,12 @@ interface AnnotationToolbarProps {
33
27
  onQuickLabel?: (label: QuickLabel) => void;
34
28
  /** Text to copy when the button is clicked */
35
29
  copyText?: string;
30
+ /** Comment-only surfaces (HTML / live-app viewer): hide the Delete action,
31
+ * the quick-label picker, and the Alt+digit label shortcuts. A provided
32
+ * onQuickLabel then renders ONLY the hardcoded 👍 "Looks good" button —
33
+ * the one label affordance restored to these surfaces. Markdown surfaces
34
+ * keep the full toolbar. */
35
+ commentOnly?: boolean;
36
36
  /** Hide the copy button (set when a keyboard copy handler exists) */
37
37
  hideCopyButton?: boolean;
38
38
  /** Close toolbar when element scrolls out of viewport */
@@ -52,6 +52,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
52
52
  onRequestComment,
53
53
  onQuickLabel,
54
54
  copyText,
55
+ commentOnly = false,
55
56
  hideCopyButton = false,
56
57
  closeOnScrollOut = false,
57
58
  isExiting = false,
@@ -126,14 +127,17 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
126
127
  return;
127
128
  }
128
129
 
129
- // Alt+N applies quick label (picker closed)
130
+ // Alt+N applies quick label (picker closed). Comment-only surfaces
131
+ // suppress this path: their only label affordance is the 👍 button.
130
132
  const isDigit = (e.code >= 'Digit1' && e.code <= 'Digit9') || e.code === 'Digit0';
131
133
  if (isDigit && !e.ctrlKey && !e.metaKey && e.altKey) {
132
134
  e.preventDefault();
133
- const digit = parseInt(e.code.slice(5), 10);
134
- const index = digit === 0 ? 9 : digit - 1;
135
- if (index < quickLabels.length) {
136
- onQuickLabel?.(quickLabels[index]);
135
+ if (!commentOnly) {
136
+ const digit = parseInt(e.code.slice(5), 10);
137
+ const index = digit === 0 ? 9 : digit - 1;
138
+ if (index < quickLabels.length) {
139
+ onQuickLabel?.(quickLabels[index]);
140
+ }
137
141
  }
138
142
  return;
139
143
  }
@@ -146,8 +150,15 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
146
150
  };
147
151
 
148
152
  window.addEventListener("keydown", handleKeyDown);
149
- return () => window.removeEventListener("keydown", handleKeyDown);
150
- }, [onClose, onRequestComment, onQuickLabel, quickLabels, showQuickLabels]);
153
+ // While this listener owns printable keys, the Shift+1..4 annotation-mode
154
+ // shortcuts must not fire: Shift+3 is "#", and a user typing "#" into a
155
+ // starting comment must not silently arm Redline (#1244 follow-up).
156
+ const releaseCapture = acquireTypeToCommentCapture();
157
+ return () => {
158
+ window.removeEventListener("keydown", handleKeyDown);
159
+ releaseCapture();
160
+ };
161
+ }, [onClose, onRequestComment, onQuickLabel, quickLabels, showQuickLabels, commentOnly]);
151
162
 
152
163
  useDismissOnOutsideAndEscape({
153
164
  enabled: !showQuickLabels,
@@ -197,7 +208,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
197
208
  to { opacity: 0; transform: translateY(8px)${translateX}; }
198
209
  }
199
210
  `}</style>
200
- <div className="flex items-center p-1 gap-0.5">
211
+ <div data-pn-annotation-toolbar-row="true" className="flex items-center p-1 gap-0.5">
201
212
  {!hideCopyButton && (
202
213
  <>
203
214
  <ToolbarButton
@@ -209,12 +220,14 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
209
220
  <div className="w-px h-5 bg-border mx-0.5" />
210
221
  </>
211
222
  )}
212
- <ToolbarButton
213
- onClick={() => handleTypeSelect(AnnotationType.DELETION)}
214
- icon={<TrashIcon />}
215
- label="Delete"
216
- className="text-destructive hover:bg-destructive/10"
217
- />
223
+ {!commentOnly && (
224
+ <ToolbarButton
225
+ onClick={() => handleTypeSelect(AnnotationType.DELETION)}
226
+ icon={<TrashIcon />}
227
+ label="Delete"
228
+ className="text-destructive hover:bg-destructive/10"
229
+ />
230
+ )}
218
231
  <ToolbarButton
219
232
  onClick={() => handleTypeSelect(AnnotationType.COMMENT)}
220
233
  icon={<CommentIcon />}
@@ -223,20 +236,22 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
223
236
  />
224
237
  {onQuickLabel && (
225
238
  <>
226
- <ToolbarButton
227
- ref={zapButtonRef}
228
- onClick={() => setShowQuickLabels(prev => !prev)}
229
- icon={<ZapIcon />}
230
- label="Quick label"
231
- className={showQuickLabels ? "text-amber-500 bg-amber-500/10" : "text-amber-500 hover:bg-amber-500/10"}
232
- />
239
+ {!commentOnly && (
240
+ <ToolbarButton
241
+ ref={zapButtonRef}
242
+ onClick={() => setShowQuickLabels(prev => !prev)}
243
+ icon={<ZapIcon />}
244
+ label="Quick label"
245
+ className={showQuickLabels ? "text-amber-500 bg-amber-500/10" : "text-amber-500 hover:bg-amber-500/10"}
246
+ />
247
+ )}
233
248
  <ToolbarButton
234
249
  onClick={() => onQuickLabel(THUMBS_UP_LABEL)}
235
250
  icon={<span className="block w-4 h-4 text-sm leading-4 text-center">👍</span>}
236
251
  label="Looks good"
237
252
  className="hover:bg-green-500/10"
238
253
  />
239
- {showQuickLabels && zapButtonRef.current && (
254
+ {!commentOnly && showQuickLabels && zapButtonRef.current && (
240
255
  <FloatingQuickLabelPicker
241
256
  anchorEl={zapButtonRef.current}
242
257
  onSelect={(label) => {
@@ -306,6 +321,11 @@ const ToolbarButton = React.forwardRef<HTMLButtonElement, {
306
321
  }>(({ onClick, icon, label, className }, ref) => (
307
322
  <button
308
323
  ref={ref}
324
+ // Icon-only controls: the markers are inert outside the compact touch
325
+ // scope, where theme.css grows them to var(--pn-touch-target). Desktop
326
+ // geometry is unchanged.
327
+ data-pn-touch-target="true"
328
+ data-pn-touch-target-icon="true"
309
329
  onClick={onClick}
310
330
  title={label}
311
331
  className={`p-1.5 rounded-md transition-colors ${className}`}