@plannotator/ui 0.36.0 → 0.38.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 (37) hide show
  1. package/HANDOFF.md +6 -4
  2. package/README.md +57 -1
  3. package/components/ActionMenu.tsx +22 -24
  4. package/components/AnnotationToolbar.tsx +3 -1
  5. package/components/ApproveDropdown.tsx +15 -18
  6. package/components/CommentPopover.tsx +9 -4
  7. package/components/DecisionControl.tsx +626 -0
  8. package/components/DocBadges.tsx +9 -6
  9. package/components/FloatingQuickLabelPicker.tsx +4 -1
  10. package/components/KeyboardShortcuts.tsx +1 -0
  11. package/components/PlanHeaderMenu.tsx +1 -1
  12. package/components/Settings.tsx +67 -6
  13. package/components/StickyHeaderLane.tsx +16 -40
  14. package/components/ToolbarButtons.tsx +31 -9
  15. package/components/Viewer.tsx +277 -95
  16. package/components/VimTargetReticle.tsx +1 -1
  17. package/components/blocks/AlertBlock.tsx +51 -4
  18. package/components/compactHeaderLayout.ts +52 -0
  19. package/config/reviewView.ts +1 -0
  20. package/config/settings.ts +48 -3
  21. package/configure.ts +9 -0
  22. package/hooks/useAnnotationHighlighter.ts +31 -19
  23. package/hooks/useDismissablePopover.ts +65 -0
  24. package/hooks/useVimDocumentFocus.ts +6 -0
  25. package/package.json +22 -22
  26. package/shortcuts/decisionControl.shortcuts.ts +31 -0
  27. package/shortcuts/index.ts +1 -0
  28. package/shortcuts/plan-review/documentView.shortcuts.ts +13 -0
  29. package/styles.css +1 -1
  30. package/theme.css +11 -0
  31. package/utils/alertTitle.ts +69 -0
  32. package/utils/decisionSpec.ts +430 -0
  33. package/utils/platform.ts +16 -0
  34. package/utils/vimScroll.ts +1 -1
  35. package/sprite_package_additional/index.html +0 -34
  36. package/sprite_package_new/index.html +0 -34
  37. package/sprite_package_pulluphang/index.html +0 -34
package/HANDOFF.md CHANGED
@@ -94,6 +94,7 @@ Pass any subset of these to `configurePlannotatorUI({ ... })`. Anything omitted
94
94
  | `loadSettingsFromBackend` | `boolean` | After install, re-hydrate settings from your `storageBackend` | off |
95
95
  | `mathRendererLoader` | `() => Promise<MathRenderer>` | How KaTeX is loaded when no renderer is registered before the first math node renders (see "Lazy renderers and eager entries"). Once registered, the package default is never called, not even as a fallback after a rejected load, and `resetMathRenderer()` keeps the registration (0.34.0); a default load already in flight at registration still fills the slot (pre-existing, see `setMathRendererLoader`), so register before the first math render | `utils/math-default-loader`'s `import('katex')`, JS only; CSS stays yours |
96
96
  | `identityGenerator` | `() => string` | The synchronous generator behind the default "tater" display name when no `identityProvider` is installed | A built-in 16 x 16 word pool of the same `adjective-noun-tater` shape; Plannotator registers the full dictionary via `utils/identity-tater` |
97
+ | `alertIconRenderer` | `(name: string) => ReactNode | null` | The icon rendered for a GitHub alert whose title line carries `<!-- icon: name -->` (0.38.0; grammar in `utils/alertTitle`). Called only for a title line with an icon comment and no leading emoji; a null return falls back to the type icon | `null` for every name: the type's own icon, the package bundles no icon set |
97
98
 
98
99
  ### Interface details worth knowing
99
100
 
@@ -190,13 +191,13 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r
190
191
  | `utils/parser` (`parseMarkdownToBlocks`, `exportAnnotations`) | Pure — no backend. |
191
192
  | `components/BlockRenderer` + the block components it renders (`TableBlock`, `HtmlBlock`, `Callout`, `MermaidBlock`, `MathBlock`, …) | Pure rendering. |
192
193
  | `components/InlineMarkdown` | Code-file hover previews route through the `docPreviewFetcher` seam. Wiki-link rendering takes the sync `resolveLinkedDoc` prop (live labels + deleted-doc treatment; see "Wiki-link seams (0.27.0)"). |
193
- | `components/Viewer` | The full annotatable document. Required props: `markdown` and `taterMode` (pass `false`). **Pass `disableCodePathValidation` unless you implement `/api/doc/exists`** — code-path validation is a prop-level opt-out, not a `configure` seam. |
194
+ | `components/Viewer` | The full annotatable document. Required props: `markdown` and `taterMode` (pass `false`). **Pass `disableCodePathValidation` unless you implement `/api/doc/exists`** — code-path validation is a prop-level opt-out, not a `configure` seam. `annotationHeader={{ onInputMethodChange, onModeChange, hideQuickLabel? }}` opts into one Viewer-owned, in-flow header containing the compact annotation controls and existing document actions. It reserves its measured responsive height, preserves all document badges, and follows `stickyActions` as one unit; omit it for the legacy action bar. Compact mode contains no help link. `hideQuickLabel` still requires the host to clamp restored mode state away from `'quickLabel'`. A host-owned scroll element must be supplied through `ScrollViewportProvider` (`hooks/useScrollViewport`) so stuck chrome and anchor clearance use the real scroller. |
194
195
  | `components/MarkdownEditor` | Theme-bridging wrapper over `@plannotator/markdown-editor`. Takes CM6 extensions via the `extensions` prop (captured ONCE per `documentId` — see "Wiki-link seams (0.27.0)") and re-exports `wikiLinks`, `embedPicker`, `embedSlashItem`, `planEmbedInsert`, and their public types. |
195
196
  | `components/MarkdownDiff` | Theme-bridging wrapper over `@plannotator/markdown-editor`'s frozen two-revision diff. Same shim pattern as `components/MarkdownEditor` (ThemeProvider bridge, `extensions` passthrough, grid card chrome); never editable. See "Frozen markdown diff (0.28.0)". |
196
197
  | `components/CommentPopover` | Anchor capture + comment entry. Ask-AI UI renders only if you pass `onAskAI`. |
197
198
  | `components/AnnotationPanel` | Renders from your annotation state; no fetches of its own. |
198
199
  | `components/AnnotationToolstrip` | The annotation mode toolstrip (Select / Pinpoint / Markup / Comment / Redline / Label). **Pass `showHelpLink={false}` in a host** — the default help modal embeds Plannotator's own YouTube walkthroughs. `hideQuickLabel` omits only the Label button (`StickyHeaderLane` forwards it, so the pinned scroll header matches); it hides the control, it does **not** clamp the mode — keep host mode state out of `'quickLabel'` (including preferences restored through `utils/editorMode`, which accepts it from storage) or text selection silently opens the quick-label picker with no visible cause. `hideInputMethodSwitch` likewise omits the pinpoint/drag switch. *(Blessed in 0.35.0.)* |
199
- | `components/StickyHeaderLane` | The measured compact toolstrip + badge lane aligned beside `Viewer`'s `[data-sticky-actions]` cluster. Defaults remain hidden/inert at rest and visible only while stuck, including the incumbent hidden chrome during its fade. `visibility="always"` exposes the zero-height, absolutely positioned lane at rest without sticky chrome or document padding; the host must reserve a clear header-height region so its controls do not cover or intercept document content. `sticky={false}` uses normal-flow positioning, creates no intersection observer, and scrolls away; pass the same value to `Viewer.stickyActions` and pair it with `visibility="always"` because the default stuck-only visibility would otherwise stay hidden. The measured Viewer-actions width is intentionally retained because both clusters still share the lane at rest. Wide active-label, tight icon-only, and narrow stacked fallbacks remain measurement-driven, and `hideQuickLabel` still forwards to the compact toolstrip. |
200
+ | `components/StickyHeaderLane` | The backward-compatible standalone ghost lane used by Plannotator beside Viewer's legacy action bar. Defaults remain hidden/inert at rest and visible only while stuck, including the incumbent hidden chrome during its fade. Its `visibility="always"` mode remains a zero-height overlay and therefore requires host-owned clearance. New hosts that need a visible in-flow header should use `Viewer.annotationHeader` instead; it owns both clusters and their clearance. **The `visibility="always"` / `sticky={false}` pair is soft-deprecated as of 0.37.0**: it shipped in 0.36.0, its one intended consumer moved to `Viewer.annotationHeader` before adopting it, and it has no known consumers. It is retained for compatibility and still tested, but do not build new integrations on it. `sticky={false}` uses normal-flow positioning, creates no intersection observer, and must be paired with `visibility="always"`. Wide active-label, tight icon-only, and narrow stacked fallbacks remain measurement-driven, and `hideQuickLabel` still forwards to the compact toolstrip. |
200
201
  | `components/ThemeProvider` | Color-mode context. |
201
202
  | `theme-modes` (`THEME_MODES`, `Mode`) | The supported Light/Dark/System catalog and mode type. `Mode` also remains exported from `components/ThemeProvider` for compatibility with existing consumers. |
202
203
  | `components/ImageThumbnail` / `getImageSrc` | Routes through `imageSrcResolver`. |
@@ -237,6 +238,7 @@ Don't import these in a host. Each hits hardcoded Plannotator endpoints:
237
238
  - `utils/sharing` — Plannotator's public paste service (share-URL feature).
238
239
  - `hooks/useUpdateCheck`, `components/MenuVersionSection`, `components/PlanHeaderMenu` — Plannotator release checks.
239
240
  - `utils/planAgentInstructions`, `utils/reviewAgentInstructions` — generate agent instructions that curl Plannotator's local API.
241
+ - `components/DecisionControl`, `utils/decisionSpec`, `hooks/useDismissablePopover` — session decision chrome for Plannotator's own approve/deny/exit endpoints (a host's session decisions are its own outcomes against its own backend).
240
242
 
241
243
  If Workspaces ever wants one of these surfaces, the path is the same as everything else: add a seam to the module in a Plannotator PR, don't fork the component.
242
244
 
@@ -676,8 +678,8 @@ Additive only, but required: `@plannotator/ui` 0.32.0 imports the new `@plannota
676
678
 
677
679
  ## Publishing & versioning
678
680
 
679
- - The current pair is `@plannotator/ui` `0.35.2` on `@plannotator/core` `0.25.1`. Core 0.25.1 publishes the `annotation-threads` subpath already used by `AnnotationPanel` and `utils/parser`; UI 0.35.2 pins that corrected core exactly. The UI behavior remains the `hideQuickLabel` seam introduced in 0.35.0.
680
- - Recent pairs, for the consumer's install matrix: ui 0.31.0 on core 0.24.0 (lockstep), ui 0.32.0 on core 0.25.0 (lockstep, `html-anchor`), ui 0.33.0 and ui 0.34.0 on core 0.25.0 (ui only), and ui 0.35.2 on core 0.25.1. Do not consume ui 0.35.0 externally because its published manifest contains `workspace:*`; do not consume ui 0.35.1 because its exact core 0.25.0 dependency lacks the `annotation-threads` export.
681
+ - The current pair is `@plannotator/ui` `0.38.0` on `@plannotator/core` `0.25.1`. UI 0.38.0 also renders a GitHub alert's bold-only first body line as its title on the icon row (an emoji on that line becomes the icon; `<!-- icon: name -->` is stripped and resolved through the new `alertIconRenderer` seam, null by default; grammar in `utils/alertTitle`, importable by a host editor so it writes the bytes the reader parses; a fenced code block inside an alert body still renders as text, deferred because nesting a `CodeBlock` inside a block interacts with the positional annotation anchors and needs its own design). UI 0.38.0 carries the whole unified decision-control stack: the internal primitives (`DecisionControl`, `utils/decisionSpec`, `hooks/useDismissablePopover` — not host-supported surface, see the unsupported list; `useDismissablePopover` also replaced the hand-rolled dismissal inside `ActionMenu`/`ApproveDropdown`, both likewise unsupported) plus one blessed-barrel addition: `decisionControlShortcuts` on `@plannotator/ui/shortcuts` (pure scope data, fetch-free, same contract as the other scopes). The removal of `ToolbarButtons`' platform-mode `muted` prop is internal — `ToolbarButtons` is not host-supported surface. UI 0.37.0 added the Viewer-owned document-header seam (a new public API, hence the minor bump; 0.36.1 was reserved for it but never published) while retaining the `hideQuickLabel` and `StickyHeaderLane` seams from the 0.35.x and 0.36.0 releases; core 0.25.1 publishes the `annotation-threads` subpath already used by `AnnotationPanel` and `utils/parser`, and UI pins that corrected core exactly.
682
+ - Recent pairs, for the consumer's install matrix: ui 0.32.0 on core 0.25.0 (lockstep, `html-anchor`), ui 0.33.0 and ui 0.34.0 on core 0.25.0 (ui only), and ui 0.35.2, ui 0.36.0, ui 0.37.0, and ui 0.38.0 on core 0.25.1 (0.36.1 was never published). Do not consume ui 0.35.0 externally because its published manifest contains `workspace:*`; do not consume ui 0.35.1 because its exact core 0.25.0 dependency lacks the `annotation-threads` export.
681
683
  - When both packages change, **publish `core` first**: ui 0.32.0 imports the `@plannotator/core/html-anchor` subpath, which no earlier published core (0.24.0 and before) has, just as ui 0.29.0 needed core 0.23.0 for `@plannotator/core/annotatable`. Bump core, update UI's exact core dependency to the same new version, and run `bun install` so `bun.lock` records the new workspace versions before packing either package.
682
684
  - The HTML annotation seams also changed the guides.show viewer **stylesheet** (five utility rules from `HtmlSurfaceControls`; the viewer JS is unchanged), so `packages/core/guide-viewer-manifest.ts` now pins a CSS hash that exists on guides.show only after the deploy workflow has published this build's `/v1/` assets. A guide exported from this build before that deploy would pin a stylesheet the host does not serve yet: **deploy guides.show before any release that ships this manifest.**
683
685
  - UI declares the already published core version exactly in its source manifest. Do not replace it with `workspace:*`: direct publication can preserve that protocol and make the package impossible to install outside this repository. Bun links the local core workspace whenever its version matches the exact dependency. Before publishing, run `bun run --cwd packages/ui smoke:package`; it checks the source and packed manifests, required tarball subpaths, local Bun linking, and a real pnpm install in an external temporary consumer. When both packages change, publish **`core` first, then `ui`**.
package/README.md CHANGED
@@ -30,6 +30,7 @@ configurePlannotatorUI({
30
30
  webmcp, // browser-agent (WebMCP) provider policy: { enabled, namePrefix }
31
31
  mathRendererLoader, // how KaTeX loads when no renderer is registered before first math render
32
32
  identityGenerator, // sync generator behind the default "tater" name (no identityProvider)
33
+ alertIconRenderer, // (name) => ReactNode | null for a GitHub alert title line's <!-- icon: name --> (default: null, the type's icon)
33
34
  });
34
35
  ```
35
36
 
@@ -160,6 +161,61 @@ behavior: hidden and inert at rest, then visible with card chrome once stuck.
160
161
  wrapper width and the measured action-cluster width. `hideQuickLabel` is still
161
162
  forwarded to the compact toolstrip.
162
163
 
164
+ #### Viewer-owned document header
165
+
166
+ Use `Viewer.annotationHeader` when the compact annotation controls must be
167
+ visible at rest. Viewer then owns one in-flow header containing those controls
168
+ on the left and its existing Global comment / Copy actions on the right:
169
+
170
+ ```tsx
171
+ <Viewer
172
+ mode={mode}
173
+ inputMethod={inputMethod}
174
+ stickyActions={stickyActions}
175
+ annotationHeader={{
176
+ onInputMethodChange: setInputMethod,
177
+ onModeChange: setMode,
178
+ hideQuickLabel: true,
179
+ }}
180
+ // ...the existing Viewer props
181
+ />
182
+ ```
183
+
184
+ The trailing action cluster keeps full-width labels unless you also pass
185
+ `actionsLabelMode` (`'full' | 'short' | 'icon'`); the header measures the real
186
+ cluster either way, so omitting it costs earlier stacking on narrow columns,
187
+ never breakage.
188
+
189
+ The header reserves its real responsive height before document content. It
190
+ keeps active labels in the wide layout, switches the compact toolstrip to
191
+ icons in the tight layout, and stacks the two clusters when narrow or wrapped.
192
+ All Viewer badge context moves into the same measured header. With
193
+ `stickyActions={true}` the complete header pins and gains the existing stuck
194
+ chrome; with `false` it remains in flow and scrolls away. The complete header
195
+ has `data-print-hide`, so it contributes no print layout.
196
+
197
+ As with Viewer's legacy sticky actions and anchor navigation, hosts with a
198
+ custom scroll element must wrap Viewer in `ScrollViewportProvider` from
199
+ `@plannotator/ui/hooks/useScrollViewport` and pass that actual scroll element.
200
+ Without the provider, CSS page stickiness can still apply, but Viewer cannot
201
+ observe the host scroller to add stuck chrome or calculate anchor clearance.
202
+
203
+ The header reuses Viewer's `[data-sticky-actions]` cluster, so host CSS that
204
+ restyled that selector for the legacy floating bar (negative margins are the
205
+ common case) applies inside the header too and pulls the action cluster out of
206
+ its row. Scope such rules away from the header, e.g.
207
+ `[data-viewer-document-header] [data-sticky-actions] { margin-top: 0; margin-right: 0; }`.
208
+
209
+ The config is intentionally typed rather than a React-node slot. Viewer reuses
210
+ its existing `mode`, `inputMethod`, and `taterMode`; the config supplies only
211
+ the state-change callbacks and optional `hideQuickLabel`. Compact toolstrips
212
+ never render the Plannotator help modal. Hiding Quick Label does not clamp the
213
+ mode, so hosts must still prevent stored `'quickLabel'` state from reaching
214
+ Viewer. Omit `annotationHeader` to preserve the legacy floating action bar
215
+ exactly. The standalone `StickyHeaderLane` remains supported for Plannotator's
216
+ hidden-at-rest ghost lane, but its always-visible mode is an overlay and is not
217
+ the in-flow host integration.
218
+
163
219
  ### WebMCP provider (`@plannotator/ui/webmcp`; 0.32.0)
164
220
 
165
221
  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"`.
@@ -195,7 +251,7 @@ npm install @plannotator/ui @plannotator/core
195
251
  - `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
196
252
  - `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on an exact published `@plannotator/core` version. Published.
197
253
  - `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
198
- - Currently `@plannotator/ui` 0.35.2 depends exactly on `@plannotator/core` 0.25.1. `core` is bumped only when something under `packages/core` changes, so `ui` can advance alone. Keep the published core version exact in `packages/ui/package.json`; do not use a `workspace:` protocol there, because a directly published manifest must remain installable outside this monorepo. Bun still links the matching local workspace during development. When both packages change, publish `core` first, then build and publish the UI tarball. See HANDOFF.md "Publishing & versioning" for the verification command.
254
+ - Currently `@plannotator/ui` 0.38.0 depends exactly on `@plannotator/core` 0.25.1. `core` is bumped only when something under `packages/core` changes, so `ui` can advance alone. Keep the published core version exact in `packages/ui/package.json`; do not use a `workspace:` protocol there, because a directly published manifest must remain installable outside this monorepo. Bun still links the matching local workspace during development. When both packages change, publish `core` first, then build and publish the UI tarball. See HANDOFF.md "Publishing & versioning" for the verification command.
199
255
 
200
256
  ## The one rule
201
257
 
@@ -1,4 +1,5 @@
1
- import React, { useEffect, useRef, useState } from 'react';
1
+ import React, { useCallback, useRef, useState } from 'react';
2
+ import { useDismissablePopover } from '../hooks/useDismissablePopover';
2
3
 
3
4
  interface ActionMenuProps {
4
5
  className?: string;
@@ -21,28 +22,15 @@ export const ActionMenu: React.FC<ActionMenuProps> = ({
21
22
  const [isOpen, setIsOpen] = useState(false);
22
23
  const menuRef = useRef<HTMLDivElement>(null);
23
24
 
24
- useEffect(() => {
25
- if (!isOpen) return;
26
-
27
- const handlePointerDown = (event: PointerEvent) => {
28
- if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
29
- setIsOpen(false);
30
- }
31
- };
32
-
33
- const handleKeyDown = (event: KeyboardEvent) => {
34
- if (event.key === 'Escape') {
35
- setIsOpen(false);
36
- }
37
- };
38
-
39
- document.addEventListener('pointerdown', handlePointerDown);
40
- document.addEventListener('keydown', handleKeyDown);
41
- return () => {
42
- document.removeEventListener('pointerdown', handlePointerDown);
43
- document.removeEventListener('keydown', handleKeyDown);
44
- };
45
- }, [isOpen]);
25
+ // Shared dismissal (outside pointerdown + Escape). The hook consumes the
26
+ // dismissing Escape, so closing an open Options menu no longer also runs
27
+ // the host app's own Escape ladder — one Escape, one rung.
28
+ const dismiss = useCallback(() => setIsOpen(false), []);
29
+ useDismissablePopover({
30
+ enabled: isOpen,
31
+ ref: menuRef,
32
+ onDismiss: dismiss,
33
+ });
46
34
 
47
35
  return (
48
36
  <div ref={menuRef} className={className ? `relative ${className}` : 'relative'}>
@@ -53,6 +41,7 @@ export const ActionMenu: React.FC<ActionMenuProps> = ({
53
41
 
54
42
  {isOpen && (
55
43
  <div
44
+ data-pn-dismissable-popover="true"
56
45
  className={panelClassName ?? `absolute top-full right-0 mt-1 ${panelWidth === 'wide' ? 'w-64' : 'w-56'} rounded-lg border border-border bg-popover py-1 shadow-xl z-[70]`}
57
46
  >
58
47
  {children({ closeMenu: () => setIsOpen(false) })}
@@ -69,6 +58,12 @@ interface ActionMenuItemProps {
69
58
  subtitle?: string;
70
59
  badge?: React.ReactNode;
71
60
  disabled?: boolean;
61
+ /** ARIA menu semantics for hosts that render a real `role="menu"` popover
62
+ * (DecisionControl). Default undefined so existing consumers are unchanged. */
63
+ role?: 'menuitem';
64
+ /** Appended to the row's classes (e.g. a tone token). Default undefined so
65
+ * existing consumers are byte-identical. */
66
+ className?: string;
72
67
  }
73
68
 
74
69
  export const ActionMenuItem: React.FC<ActionMenuItemProps> = ({
@@ -78,13 +73,16 @@ export const ActionMenuItem: React.FC<ActionMenuItemProps> = ({
78
73
  subtitle,
79
74
  badge,
80
75
  disabled = false,
76
+ role,
77
+ className,
81
78
  }) => (
82
79
  <button
83
80
  data-pn-touch-target
84
81
  type="button"
85
82
  onClick={onClick}
86
83
  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"
84
+ role={role}
85
+ 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${className ? ` ${className}` : ''}`}
88
86
  >
89
87
  <span className="text-muted-foreground">{icon}</span>
90
88
  {subtitle ? (
@@ -179,8 +179,9 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
179
179
  const isCentered = position.left !== undefined;
180
180
  const translateX = isCentered ? ' translateX(-50%)' : '';
181
181
 
182
- const style: React.CSSProperties = {
182
+ const style: React.CSSProperties & { '--pn-annotation-toolbar-anchor-x'?: string } = {
183
183
  top: position.top,
184
+ '--pn-annotation-toolbar-anchor-x': isCentered ? `${position.left}px` : undefined,
184
185
  ...(isCentered
185
186
  ? { left: position.left, transform: 'translateX(-50%)' }
186
187
  : { right: position.right }),
@@ -192,6 +193,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
192
193
  return createPortal(
193
194
  <div
194
195
  ref={toolbarRef}
196
+ data-pn-annotation-toolbar-centered={isCentered ? 'true' : undefined}
195
197
  className="annotation-toolbar fixed z-[100] bg-popover border border-border rounded-lg shadow-2xl"
196
198
  style={style}
197
199
  onMouseDown={(e) => e.stopPropagation()}
@@ -1,5 +1,6 @@
1
- import React, { useState, useRef, useEffect } from 'react';
1
+ import React, { useState, useRef, useCallback } from 'react';
2
2
  import type { Agent } from '../hooks/useAgents';
3
+ import { useDismissablePopover } from '../hooks/useDismissablePopover';
3
4
  import { getAgentSwitchSettings, saveAgentSwitchSettings, type AgentSwitchSettings } from '../utils/agentSwitch';
4
5
 
5
6
  interface ApproveDropdownProps {
@@ -40,22 +41,15 @@ export const ApproveDropdown: React.FC<ApproveDropdownProps> = ({
40
41
  const [isOpen, setIsOpen] = useState(false);
41
42
  const dropdownRef = useRef<HTMLDivElement>(null);
42
43
 
43
- useEffect(() => {
44
- const handleClickOutside = (event: PointerEvent) => {
45
- if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
46
- setIsOpen(false);
47
- }
48
- };
49
- const handleEscape = (event: KeyboardEvent) => {
50
- if (event.key === 'Escape') setIsOpen(false);
51
- };
52
- document.addEventListener('pointerdown', handleClickOutside);
53
- document.addEventListener('keydown', handleEscape);
54
- return () => {
55
- document.removeEventListener('pointerdown', handleClickOutside);
56
- document.removeEventListener('keydown', handleEscape);
57
- };
58
- }, []);
44
+ // Shared dismissal (outside pointerdown + Escape), active only while open —
45
+ // the old always-on listeners were no-ops when closed. The hook consumes the
46
+ // dismissing Escape so it cannot double as a host-ladder Escape.
47
+ const dismiss = useCallback(() => setIsOpen(false), []);
48
+ useDismissablePopover({
49
+ enabled: isOpen,
50
+ ref: dropdownRef,
51
+ onDismiss: dismiss,
52
+ });
59
53
 
60
54
  const handleSelect = (newSetting: AgentSwitchSettings) => {
61
55
  setSetting(newSetting);
@@ -120,7 +114,10 @@ export const ApproveDropdown: React.FC<ApproveDropdownProps> = ({
120
114
 
121
115
  {/* Dropdown */}
122
116
  {isOpen && (
123
- <div className="absolute right-0 top-full mt-1 w-52 rounded-lg border border-border bg-popover shadow-xl z-[70] overflow-hidden py-1">
117
+ <div
118
+ data-pn-dismissable-popover="true"
119
+ className="absolute right-0 top-full mt-1 w-52 rounded-lg border border-border bg-popover shadow-xl z-[70] overflow-hidden py-1"
120
+ >
124
121
  <div className="px-3 py-1.5 text-[10px] uppercase tracking-wider text-muted-foreground/70 font-medium">
125
122
  Switch to agent
126
123
  </div>
@@ -529,6 +529,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
529
529
  hasUnsavedContent ||
530
530
  (allowEmptySubmit && initialText.trim().length > 0);
531
531
  const canAskAI = !!onAskAI && !askAIDisabled && text.trim().length > 0;
532
+ const showsSkillMenu = skillAc.menu !== null;
532
533
 
533
534
  // Shared by both footers. Disabled once anything is typed or attached so a
534
535
  // click can never discard a draft; with content present, Save is the path.
@@ -570,7 +571,9 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
570
571
  aria-modal="true"
571
572
  aria-label={isGlobal ? 'Global comment' : 'Comment'}
572
573
  tabIndex={-1}
573
- className="relative w-full max-w-xl max-h-full min-h-0 bg-popover border border-border rounded-xl shadow-2xl flex flex-col overflow-hidden"
574
+ className={`relative w-full max-w-xl max-h-full min-h-0 bg-popover border border-border rounded-xl shadow-2xl flex flex-col ${
575
+ showsSkillMenu ? 'overflow-visible' : 'overflow-hidden'
576
+ }`}
574
577
  style={{
575
578
  animation: 'comment-dialog-in 0.15s ease-out',
576
579
  }}
@@ -617,7 +620,9 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
617
620
  {chipsRow}
618
621
 
619
622
  {/* Textarea */}
620
- <div className="relative px-4 py-3 min-h-0 flex-1 overflow-y-auto">
623
+ <div className={`relative px-4 py-3 min-h-0 flex-1 ${
624
+ showsSkillMenu ? 'overflow-visible' : 'overflow-y-auto'
625
+ }`}>
621
626
  {skillAc.menu && (
622
627
  <SkillReferenceMenu
623
628
  id={skillListboxId}
@@ -713,14 +718,14 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
713
718
  left: dragPosition.left,
714
719
  width: position.width,
715
720
  maxHeight: visibleBounds.height,
716
- overflowY: 'auto',
721
+ overflowY: showsSkillMenu ? 'visible' : 'auto',
717
722
  }
718
723
  : {
719
724
  top: position.top,
720
725
  left: position.left,
721
726
  width: position.width,
722
727
  maxHeight: position.maxHeight,
723
- overflowY: 'auto',
728
+ overflowY: showsSkillMenu ? 'visible' : 'auto',
724
729
  ...(position.flipAbove ? { transform: 'translateY(-100%)' } : {}),
725
730
  animation: position.flipAbove
726
731
  ? 'comment-popover-in-above 0.15s ease-out'