@plannotator/ui 0.52.1 → 0.53.1

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 (76) hide show
  1. package/HANDOFF.md +284 -29
  2. package/README.md +45 -5
  3. package/components/AnnotationPanel.tsx +195 -118
  4. package/components/AnnotationToolbar.tsx +17 -14
  5. package/components/AnnotationToolstrip.tsx +54 -40
  6. package/components/BlockRenderer.tsx +20 -14
  7. package/components/CodeFilePopout.tsx +132 -69
  8. package/components/CommentPopover.tsx +8 -349
  9. package/components/ComposerTokenTextarea.tsx +379 -0
  10. package/components/ExportModal.tsx +6 -1
  11. package/components/HostTitle.tsx +108 -0
  12. package/components/HtmlSurfaceControls.tsx +2 -1
  13. package/components/ImageLightbox.tsx +288 -17
  14. package/components/ImagePeek.tsx +255 -0
  15. package/components/ImageResolverContext.tsx +31 -0
  16. package/components/InlineMarkdown.tsx +123 -15
  17. package/components/LocalLinkContext.tsx +49 -0
  18. package/components/MentionPicker.tsx +51 -0
  19. package/components/MenuVersionSection.tsx +51 -25
  20. package/components/OpenInAppButton.tsx +18 -49
  21. package/components/PlanHeaderMenu.tsx +2 -8
  22. package/components/ProviderIcons.tsx +10 -4
  23. package/components/RenderedMarkdown.tsx +40 -4
  24. package/components/Settings.tsx +33 -5
  25. package/components/SkillReferenceMenu.tsx +67 -18
  26. package/components/Tooltip.tsx +51 -4
  27. package/components/Viewer.tsx +220 -49
  28. package/components/ai/AIProviderBar.tsx +11 -0
  29. package/components/ai/AskSessionSetupHint.tsx +12 -0
  30. package/components/ai/DocumentAIChatPanel.tsx +17 -5
  31. package/components/ai/SessionAskNotice.tsx +4 -3
  32. package/components/blocks/BlockquoteBlock.tsx +159 -0
  33. package/components/blocks/HtmlBlock.tsx +121 -9
  34. package/components/blocks/QuestionBlock.tsx +183 -96
  35. package/components/blocks/QuestionContext.tsx +3 -0
  36. package/components/diagram/DiagramViewer.tsx +41 -4
  37. package/components/diagram/index.ts +1 -1
  38. package/components/html-viewer/HtmlViewer.tsx +95 -3
  39. package/components/html-viewer/bridge-script.asset.js +413 -7
  40. package/components/html-viewer/bridge-script.lite.ts +1 -1
  41. package/components/html-viewer/bridge-script.ts +454 -7
  42. package/components/html-viewer/useHtmlAnnotation.ts +51 -3
  43. package/components/icons/AgentIcons.tsx +9 -7
  44. package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
  45. package/components/plan-diff/PlanDiffViewer.tsx +6 -1
  46. package/configure.ts +27 -1
  47. package/hooks/useAgentJobs.ts +8 -3
  48. package/hooks/useAnnotationHighlighter.ts +128 -23
  49. package/hooks/useExternalAnnotations.ts +6 -0
  50. package/hooks/useMentionAutocomplete.ts +1 -1
  51. package/hooks/useOpenInApps.ts +88 -0
  52. package/hooks/useSkillReferenceAutocomplete.ts +7 -3
  53. package/hooks/useUpdateCheck.ts +38 -98
  54. package/hooks/useUpdateCheckSetting.ts +43 -0
  55. package/hooks/useUpdateToast.ts +101 -0
  56. package/hooks/useVimSelection.ts +9 -0
  57. package/package.json +2 -2
  58. package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
  59. package/shortcuts/index.ts +1 -0
  60. package/shortcuts/runtime.ts +10 -3
  61. package/styles.css +1 -1
  62. package/types.ts +48 -0
  63. package/utils/agentClosed.ts +26 -0
  64. package/utils/agentToolSetting.ts +16 -9
  65. package/utils/aiProvider.ts +7 -0
  66. package/utils/askSessionSetupHint.ts +27 -0
  67. package/utils/blockTargeting.ts +14 -3
  68. package/utils/mentions.ts +34 -5
  69. package/utils/parser.ts +65 -51
  70. package/utils/pasteInsert.ts +234 -0
  71. package/utils/planDiffEngine.ts +272 -2
  72. package/utils/skillCatalog.ts +15 -2
  73. package/utils/skillReferences.ts +50 -13
  74. package/utils/storage.ts +5 -0
  75. package/utils/textCut.ts +41 -0
  76. package/utils/updateNotice.ts +120 -0
@@ -1,3 +1,4 @@
1
+ import type { UpdateCheckSetting } from '../hooks/useUpdateCheckSetting';
1
2
  import React, { useState, useEffect, useMemo, useRef } from 'react';
2
3
  import { createPortal } from 'react-dom';
3
4
  import type { AnnotateAgentTerminalSide } from '@plannotator/core/agent-terminal';
@@ -112,6 +113,10 @@ interface SettingsProps {
112
113
  * `env` is PLANNOTATOR_AUTO_UPDATE when it overrides the config file.
113
114
  * Hosts that omit it get no toggle. */
114
115
  autoUpdateSetting?: { env?: boolean };
116
+ /** Present when the server reports the update-notice switch (serverConfig.updateCheck,
117
+ * useUpdateCheckSetting); `env` is PLANNOTATOR_UPDATE_CHECK when it overrides the
118
+ * config file. Hosts that omit it get no row. */
119
+ updateCheckSetting?: UpdateCheckSetting;
115
120
  /** The `plannotator` agent tool switch, as the server reports it
116
121
  * (parseAgentToolSetting of serverConfig: present only when the session's
117
122
  * host registers the tool). Shown only together with onAgentToolChange;
@@ -1002,9 +1007,10 @@ const CommentsTab: React.FC = () => {
1002
1007
  );
1003
1008
  };
1004
1009
 
1005
- export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', annotateParity = false, onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, autoUpdateSetting, agentToolSetting, onAgentToolChange, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false, webmcpAvailable = false }) => {
1010
+ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', annotateParity = false, onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, autoUpdateSetting, updateCheckSetting, agentToolSetting, onAgentToolChange, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false, webmcpAvailable = false }) => {
1006
1011
  const webmcpTools = useWebMcpToolsEnabled();
1007
1012
  const autoUpdate = useConfigValue('autoUpdate');
1013
+ const updateChecksOn = updateCheckSetting ? updateCheckSetting.on : true;
1008
1014
  const [showDialog, setShowDialog] = useState(false);
1009
1015
  const settingsWasOpenRef = useRef(false);
1010
1016
  const [themePreview, setThemePreview] = useState(false);
@@ -1441,20 +1447,42 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1441
1447
  </div>
1442
1448
  </div>
1443
1449
 
1450
+ {/* Update notices: on by default. Writes updateCheck to
1451
+ ~/.plannotator/config.json; off makes no request to GitHub. */}
1452
+ {updateCheckSetting && (
1453
+ <>
1454
+ <div className="border-t border-border" />
1455
+ <ToggleSwitch
1456
+ checked={updateChecksOn}
1457
+ onChange={(v) => updateCheckSetting.onChange(v)}
1458
+ disabled={updateCheckSetting.env !== undefined}
1459
+ label="Tell me when a new version is out"
1460
+ description={
1461
+ updateCheckSetting.env !== undefined
1462
+ ? 'Set by PLANNOTATOR_UPDATE_CHECK in your environment.'
1463
+ : 'This computer asks GitHub once a day, never your browser. Off makes no request at all.'
1464
+ }
1465
+ />
1466
+ </>
1467
+ )}
1468
+
1444
1469
  {/* Auto-update (#1634): opt-in, default off. Writes
1445
- autoUpdate to ~/.plannotator/config.json. */}
1470
+ autoUpdate to ~/.plannotator/config.json. It needs
1471
+ update checks, so it is locked while they are off. */}
1446
1472
  {autoUpdateSetting && (
1447
1473
  <>
1448
1474
  <div className="border-t border-border" />
1449
1475
  <ToggleSwitch
1450
- checked={autoUpdateSetting.env ?? autoUpdate}
1476
+ checked={(autoUpdateSetting.env ?? autoUpdate) && updateChecksOn}
1451
1477
  onChange={(v) => configStore.set('autoUpdate', v)}
1452
- disabled={autoUpdateSetting.env !== undefined}
1478
+ disabled={autoUpdateSetting.env !== undefined || !updateChecksOn}
1453
1479
  label="Keep Plannotator up to date"
1454
1480
  description={
1455
1481
  autoUpdateSetting.env !== undefined
1456
1482
  ? 'Set by PLANNOTATOR_AUTO_UPDATE in your environment.'
1457
- : 'Installs new releases in the background, at most once a day, never while another review is open.'
1483
+ : !updateChecksOn
1484
+ ? 'Needs update checks (above).'
1485
+ : 'Installs new releases in the background, at most once a day, never while another review is open.'
1458
1486
  }
1459
1487
  />
1460
1488
  </>
@@ -15,6 +15,16 @@ interface SkillReferenceMenuProps {
15
15
  /** Explicitly activated row, or null — the menu opens with nothing active. */
16
16
  activeIndex: number | null;
17
17
  onSelect: (index: number) => void;
18
+ /**
19
+ * The box the menu must stay inside, in viewport coordinates, read at each
20
+ * measure. Default: the viewport. A host whose composer sits in a dialog
21
+ * with a footer passes the dialog's area above the footer.
22
+ */
23
+ boundary?: () => { top: number; bottom: number } | null;
24
+ /** Which side of the anchor the menu tries first. Default: above. */
25
+ prefer?: 'above' | 'below';
26
+ /** Upper bound on the list's height, in px. Default: 256. */
27
+ maxListHeight?: number;
18
28
  }
19
29
 
20
30
  /** Vertical gap between the composer wrapper and the menu (mb-1.5 / mt-1.5 = 6px). */
@@ -40,24 +50,29 @@ interface MenuPlacement {
40
50
  * When neither side fits the whole list, the side with more room wins and the
41
51
  * list is clamped to it, so the menu never extends past a viewport edge.
42
52
  */
43
- function computeMenuPlacement(
53
+ export function computeMenuPlacement(
44
54
  anchorRect: Pick<DOMRect, 'top' | 'bottom'>,
45
55
  naturalListHeight: number,
46
56
  chromeHeight: number,
47
57
  viewportHeight: number,
58
+ options: { boundaryTop?: number; prefer?: 'above' | 'below'; maxListHeight?: number } = {},
48
59
  ): MenuPlacement {
49
- const spaceAbove = anchorRect.top - MENU_GAP - MENU_VIEWPORT_MARGIN - chromeHeight;
60
+ const boundaryTop = options.boundaryTop ?? 0;
61
+ const cap = options.maxListHeight ?? MAX_LIST_HEIGHT;
62
+ const spaceAbove = anchorRect.top - boundaryTop - MENU_GAP - MENU_VIEWPORT_MARGIN - chromeHeight;
50
63
  const spaceBelow = viewportHeight - anchorRect.bottom - MENU_GAP - MENU_VIEWPORT_MARGIN - chromeHeight;
51
- const needed = Math.min(naturalListHeight, MAX_LIST_HEIGHT);
64
+ const needed = Math.min(naturalListHeight, cap);
65
+ const [first, second] = options.prefer === 'below' ? (['below', 'above'] as const) : (['above', 'below'] as const);
66
+ const space = (side: MenuPlacement['direction']) => (side === 'above' ? spaceAbove : spaceBelow);
52
67
  const direction: MenuPlacement['direction'] =
53
- needed <= spaceAbove ? 'above'
54
- : needed <= spaceBelow ? 'below'
55
- : spaceAbove >= spaceBelow ? 'above'
56
- : 'below';
57
- const available = direction === 'above' ? spaceAbove : spaceBelow;
68
+ needed <= space(first) ? first
69
+ : needed <= space(second) ? second
70
+ : space(first) >= space(second) ? first
71
+ : second;
72
+ const available = space(direction);
58
73
  return {
59
74
  direction,
60
- maxListHeight: Math.max(0, Math.min(MAX_LIST_HEIGHT, Math.round(available))),
75
+ maxListHeight: Math.max(0, Math.min(cap, Math.round(available))),
61
76
  };
62
77
  }
63
78
 
@@ -85,13 +100,19 @@ export const SkillReferenceMenu: React.FC<SkillReferenceMenuProps> = ({
85
100
  items,
86
101
  activeIndex,
87
102
  onSelect,
103
+ boundary,
104
+ prefer,
105
+ maxListHeight,
88
106
  }) => {
89
107
  const menuRef = useRef<HTMLDivElement>(null);
90
108
  const listRef = useRef<HTMLDivElement>(null);
91
109
  const [placement, setPlacement] = useState<MenuPlacement>({
92
- direction: 'above',
93
- maxListHeight: MAX_LIST_HEIGHT,
110
+ direction: prefer ?? 'above',
111
+ maxListHeight: maxListHeight ?? MAX_LIST_HEIGHT,
94
112
  });
113
+ // The optional seams, read through a ref so `measure` stays stable for its listeners.
114
+ const optionsRef = useRef({ boundary, prefer, maxListHeight });
115
+ optionsRef.current = { boundary, prefer, maxListHeight };
95
116
 
96
117
  const measure = useCallback(() => {
97
118
  const menuEl = menuRef.current;
@@ -106,7 +127,13 @@ export const SkillReferenceMenu: React.FC<SkillReferenceMenuProps> = ({
106
127
  // jitter loop this component once shipped.
107
128
  const chrome = Math.max(0, menuEl.offsetHeight - listEl.offsetHeight);
108
129
  // scrollHeight reports full content height even while clamped.
109
- const next = computeMenuPlacement(rect, listEl.scrollHeight, chrome, window.innerHeight);
130
+ const { boundary: readBoundary, prefer: side, maxListHeight: cap } = optionsRef.current;
131
+ const box = readBoundary?.() ?? null;
132
+ const next = computeMenuPlacement(rect, listEl.scrollHeight, chrome, box ? box.bottom : window.innerHeight, {
133
+ boundaryTop: box ? box.top : 0,
134
+ prefer: side,
135
+ maxListHeight: cap,
136
+ });
110
137
  setPlacement((prev) =>
111
138
  prev.direction === next.direction && prev.maxListHeight === next.maxListHeight
112
139
  ? prev
@@ -182,11 +209,12 @@ export const SkillReferenceMenu: React.FC<SkillReferenceMenuProps> = ({
182
209
  }`}
183
210
  >
184
211
  <SkillIcon />
185
- <span className="shrink-0 font-semibold text-foreground">{item.name}</span>
186
- <span className="min-w-0 flex-1 truncate text-muted-foreground">
212
+ {/* data-skill-item-part: a host's styling hook (the Inbox lays a row out in two lines on a phone). */}
213
+ <span data-skill-item-part="name" className="shrink-0 font-semibold text-foreground">{item.name}</span>
214
+ <span data-skill-item-part="description" className="min-w-0 flex-1 truncate text-muted-foreground">
187
215
  {item.description ?? ''}
188
216
  </span>
189
- <span className="shrink-0 pl-3 text-xs text-muted-foreground/80">
217
+ <span data-skill-item-part="root" className="shrink-0 pl-3 text-xs text-muted-foreground/80">
190
218
  {ROOT_LABELS[item.root]}
191
219
  </span>
192
220
  </button>
@@ -215,6 +243,26 @@ const SkillIcon: React.FC = () => (
215
243
 
216
244
  interface HumanOnlySkillNoticeProps {
217
245
  skills: SkillCatalogEntry[];
246
+ /**
247
+ * What the instructions go with. Default: the host's (`setSkillInstructionsDestination`),
248
+ * else "your feedback". The Inbox says "your reply" or "your message".
249
+ */
250
+ destination?: string;
251
+ }
252
+
253
+ // Module-level seam (see packages/ui/CLAUDE.md): what every composer's
254
+ // human-only line says the instructions go with, unless the composer says.
255
+ // Plannotator's comments go with "your feedback"; the Inbox's comments on
256
+ // attached files ride a reply.
257
+ const DEFAULT_DESTINATION = 'your feedback';
258
+ let defaultDestination = DEFAULT_DESTINATION;
259
+
260
+ export function setSkillInstructionsDestination(destination: string): void {
261
+ defaultDestination = destination || DEFAULT_DESTINATION;
262
+ }
263
+
264
+ export function resetSkillInstructionsDestination(): void {
265
+ defaultDestination = DEFAULT_DESTINATION;
218
266
  }
219
267
 
220
268
  /**
@@ -227,8 +275,9 @@ interface HumanOnlySkillNoticeProps {
227
275
  * toggles), and assistive tech alike. It pairs with the dotted-underline
228
276
  * marker the composer overlay paints on human-only tokens.
229
277
  */
230
- export const HumanOnlySkillNotice: React.FC<HumanOnlySkillNoticeProps> = ({ skills }) => {
278
+ export const HumanOnlySkillNotice: React.FC<HumanOnlySkillNoticeProps> = ({ skills, destination }) => {
231
279
  if (skills.length === 0) return null;
280
+ destination ??= defaultDestination;
232
281
  const names = skills.map((s) => s.name).join(', ');
233
282
  return (
234
283
  <details data-skill-human-only-notice="true" className="group mt-1 px-1">
@@ -240,12 +289,12 @@ export const HumanOnlySkillNotice: React.FC<HumanOnlySkillNoticeProps> = ({ skil
240
289
  {skills.length === 1 ? (
241
290
  <>
242
291
  <span className="font-mono">{names}</span> cannot be invoked by a model, so
243
- its instructions will be included with your feedback.
292
+ its instructions will be included with {destination}.
244
293
  </>
245
294
  ) : (
246
295
  <>
247
296
  <span className="font-mono">{names}</span> cannot be invoked by a model, so
248
- their instructions will be included with your feedback.
297
+ their instructions will be included with {destination}.
249
298
  </>
250
299
  )}
251
300
  </p>
@@ -44,6 +44,25 @@ interface TooltipProps {
44
44
  * that use it for short button labels.
45
45
  */
46
46
  wide?: boolean;
47
+ /**
48
+ * Asked each time the tooltip would open, with the trigger element; `false`
49
+ * keeps it closed this time (e.g. open only while the trigger's text is cut
50
+ * off). Unset: it always opens, as before.
51
+ */
52
+ shouldOpen?: (trigger: Element) => boolean;
53
+ /**
54
+ * Where the popup is portaled. Unset: the document body. A trigger inside a
55
+ * native modal `<dialog>` needs the dialog here, or the popup is drawn
56
+ * under the dialog's top layer.
57
+ */
58
+ container?: HTMLElement | null;
59
+ /**
60
+ * When true, Escape closes the tooltip without using the key up: it still
61
+ * reaches the page (not default-prevented, not stopped), so the page's own
62
+ * Escape (closing a pane) works while a tooltip is open. Unset: Base UI's
63
+ * default, where the Escape that closes a tooltip goes no further.
64
+ */
65
+ escapePassesThrough?: boolean;
47
66
  }
48
67
 
49
68
  export const Tooltip: React.FC<TooltipProps> = ({
@@ -54,15 +73,43 @@ export const Tooltip: React.FC<TooltipProps> = ({
54
73
  delayDuration,
55
74
  sideOffset = 8,
56
75
  wide = false,
76
+ shouldOpen,
77
+ container,
78
+ escapePassesThrough = false,
57
79
  }) => {
58
80
  const disableHoverablePopup = React.useContext(DisableHoverablePopupContext);
81
+ // Controlled only for escapePassesThrough: the Escape close is canceled (so Base UI neither prevents nor stops
82
+ // the key) and the tooltip is closed here instead.
83
+ const [controlledOpen, setControlledOpen] = React.useState(false);
84
+ const controlled = escapePassesThrough ? { open: controlledOpen } : {};
59
85
  return (
60
- <BaseTooltip.Root disableHoverablePopup={disableHoverablePopup}>
86
+ <BaseTooltip.Root
87
+ disableHoverablePopup={disableHoverablePopup}
88
+ {...controlled}
89
+ onOpenChange={
90
+ shouldOpen || escapePassesThrough
91
+ ? (open, details) => {
92
+ if (open && shouldOpen && !(details.trigger && shouldOpen(details.trigger))) {
93
+ details.cancel();
94
+ return;
95
+ }
96
+ if (!escapePassesThrough) return;
97
+ if (!open && details.reason === 'escape-key') {
98
+ details.cancel();
99
+ details.allowPropagation();
100
+ }
101
+ setControlledOpen(open);
102
+ }
103
+ : undefined
104
+ }
105
+ >
61
106
  <BaseTooltip.Trigger render={children} delay={delayDuration} />
62
- <BaseTooltip.Portal>
63
- <BaseTooltip.Positioner side={side} align={align} sideOffset={sideOffset} className="isolate z-50">
107
+ <BaseTooltip.Portal container={container ?? undefined}>
108
+ {/* Above the annotation toolbar (z-[100]), so a tooltip that flips toward it is never tucked under it. */}
109
+ <BaseTooltip.Positioner side={side} align={align} sideOffset={sideOffset} className="isolate z-[110]">
64
110
  <BaseTooltip.Popup
65
- className={`z-50 px-2 py-1 text-xs bg-popover text-popover-foreground border border-border rounded shadow-md origin-[var(--transform-origin)] transition-[opacity,scale] duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 ${
111
+ data-pn-tooltip=""
112
+ className={`z-[110] px-2 py-1 text-xs bg-popover text-popover-foreground border border-border rounded shadow-md origin-[var(--transform-origin)] transition-[opacity,scale] duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 ${
66
113
  wide
67
114
  ? 'max-w-[min(260px,calc(100vw-1rem))] leading-snug whitespace-normal [overflow-wrap:anywhere]'
68
115
  : 'whitespace-nowrap'