@plannotator/ui 0.28.0 → 0.29.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 (105) hide show
  1. package/README.md +6 -0
  2. package/components/AISettingsTab.tsx +5 -4
  3. package/components/ActionMenu.tsx +5 -1
  4. package/components/AgentsTab.tsx +10 -23
  5. package/components/AnnotationPanel.tsx +9 -5
  6. package/components/AnnotationToolbar.tsx +5 -13
  7. package/components/AnnotationToolstrip.tsx +2 -2
  8. package/components/ApproveDropdown.tsx +1 -1
  9. package/components/BlockRenderer.tsx +1 -1
  10. package/components/CodeFilePopout.tsx +5 -4
  11. package/components/CommentPopover.tsx +391 -65
  12. package/components/DocBadges.tsx +29 -11
  13. package/components/ExportModal.tsx +16 -8
  14. package/components/GraphvizBlock.tsx +1 -1
  15. package/components/InlineMarkdown.tsx +30 -13
  16. package/components/KeyboardShortcuts.tsx +37 -2
  17. package/components/Landing.tsx +7 -7
  18. package/components/MenuVersionSection.tsx +4 -4
  19. package/components/MermaidBlock.tsx +1 -1
  20. package/components/ModeToggle.tsx +7 -6
  21. package/components/OpenInAppButton.tsx +2 -5
  22. package/components/PinpointOverlay.tsx +9 -7
  23. package/components/PlanHeaderMenu.tsx +8 -8
  24. package/components/PopoutDialog.tsx +6 -1
  25. package/components/ResizeHandle.tsx +1 -0
  26. package/components/Settings.tsx +172 -12
  27. package/components/SkillReferenceMenu.tsx +260 -0
  28. package/components/StickyHeaderLane.tsx +7 -0
  29. package/components/ThemeProvider.tsx +131 -32
  30. package/components/ThemeTab.tsx +123 -77
  31. package/components/ToolbarButtons.tsx +29 -8
  32. package/components/Viewer.tsx +396 -130
  33. package/components/VimKeyHud.tsx +695 -0
  34. package/components/VimModeAnnouncementDialog.tsx +557 -0
  35. package/components/VimModeOverlay.tsx +235 -0
  36. package/components/VimTargetReticle.tsx +284 -0
  37. package/components/ai/DocumentAIChatPanel.tsx +1 -1
  38. package/components/blocks/CodeBlock.tsx +18 -18
  39. package/components/blocks/TablePopout.tsx +7 -8
  40. package/components/blocks/TableToolbar.tsx +7 -8
  41. package/components/goal-setup/GoalSetupSurface.tsx +16 -3
  42. package/components/html-viewer/HtmlViewer.tsx +450 -47
  43. package/components/html-viewer/annotationNumbering.ts +37 -0
  44. package/components/html-viewer/bridge-script.ts +4051 -298
  45. package/components/html-viewer/composerYield.ts +51 -0
  46. package/components/html-viewer/srcdoc.ts +18 -3
  47. package/components/html-viewer/useHtmlAnnotation.ts +457 -32
  48. package/components/icons/themeIcons.tsx +1 -1
  49. package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
  50. package/components/plan-diff/PlanDiffBadge.tsx +22 -1
  51. package/components/settings/HooksTab.tsx +12 -8
  52. package/components/sidebar/FileBrowser.tsx +4 -1
  53. package/components/themeModes.tsx +28 -0
  54. package/config/configStore.ts +76 -1
  55. package/config/settings.ts +152 -0
  56. package/configure.ts +9 -0
  57. package/globals.d.ts +7 -1
  58. package/hooks/useAIChat.ts +5 -2
  59. package/hooks/useAIProviderActivation.ts +47 -0
  60. package/hooks/useAIProviderConfig.ts +5 -1
  61. package/hooks/useAgentSettings.ts +64 -23
  62. package/hooks/useAgents.ts +4 -4
  63. package/hooks/useAnnotationHighlighter.ts +100 -3
  64. package/hooks/useArchive.ts +2 -1
  65. package/hooks/useFenceTheme.ts +17 -0
  66. package/hooks/useLinkedDoc.ts +68 -1
  67. package/hooks/usePinpoint.ts +76 -75
  68. package/hooks/usePlanDiff.ts +73 -2
  69. package/hooks/useSkillReferenceAutocomplete.ts +239 -0
  70. package/hooks/useUpdateCheck.ts +1 -2
  71. package/hooks/useVimDocumentFocus.ts +116 -0
  72. package/hooks/useVimSelection.ts +1063 -0
  73. package/package.json +4 -4
  74. package/print.css +14 -13
  75. package/shortcuts/core.ts +38 -13
  76. package/shortcuts/index.ts +10 -0
  77. package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
  78. package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
  79. package/shortcuts/runtime.ts +111 -12
  80. package/styles.css +1 -1
  81. package/theme.css +504 -0
  82. package/themes/colorblind.css +89 -0
  83. package/themes/plannotator.css +2 -2
  84. package/types.ts +93 -10
  85. package/utils/agentSwitch.ts +33 -7
  86. package/utils/blockTargeting.ts +462 -178
  87. package/utils/clipboard.ts +110 -0
  88. package/utils/codeBlockMark.ts +50 -0
  89. package/utils/codeHighlight.ts +293 -0
  90. package/utils/codexModels.ts +79 -0
  91. package/utils/domSelection.ts +84 -0
  92. package/utils/htmlChrome.ts +73 -0
  93. package/utils/inputMethod.ts +79 -6
  94. package/utils/parser.ts +517 -21
  95. package/utils/preferenceTtl.ts +15 -0
  96. package/utils/sharing.ts +0 -1
  97. package/utils/skillCatalog.ts +269 -0
  98. package/utils/skillReferences.ts +475 -0
  99. package/utils/syntaxTheme.ts +83 -0
  100. package/utils/themeRegistry.ts +154 -0
  101. package/utils/vimHud.ts +263 -0
  102. package/utils/vimModeAnnouncement.ts +23 -0
  103. package/utils/vimNavigation.ts +417 -0
  104. package/utils/vimReticle.ts +88 -0
  105. package/utils/vimScroll.ts +162 -0
@@ -1,66 +1,103 @@
1
- import { useEffect, useState, type RefObject } from 'react';
2
- import type Highlighter from '@plannotator/web-highlighter';
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ type RefObject,
7
+ } from 'react';
3
8
  import type { InputMethod } from '../types';
4
- import { resolvePinpointTarget } from '../utils/blockTargeting';
5
-
9
+ import {
10
+ buildSemanticTargetGraph,
11
+ createSemanticTargetRange,
12
+ resolveSemanticTargetAtPoint,
13
+ type SemanticTarget,
14
+ } from '../utils/blockTargeting';
15
+
16
+ /** Inputs for pointer Pinpoint targeting within one rendered document. */
6
17
  export interface UsePinpointOptions {
7
18
  containerRef: RefObject<HTMLElement | null>;
8
- highlighterRef: RefObject<Highlighter | null>;
9
19
  inputMethod: InputMethod;
10
20
  /** Disable when toolbar/popover/diff is active */
11
21
  enabled: boolean;
22
+ /** Submit a text range through the shared annotation pipeline. */
23
+ onSelectRange: (range: Range) => void;
12
24
  /** Handle code block clicks (needs special annotation path) */
13
25
  onCodeBlockClick: (blockId: string, element: HTMLElement) => void;
14
26
  }
15
27
 
28
+ /** Live pointer-owned state returned by `usePinpoint`. */
16
29
  export interface UsePinpointReturn {
17
- hoverTarget: { element: HTMLElement; label: string } | null;
30
+ /** Live target from the canonical semantic graph under the pointer. */
31
+ hoverTarget: SemanticTarget | null;
32
+ /** Release pointer-owned feedback so keyboard navigation can take over. */
33
+ clearHover: () => void;
18
34
  }
19
35
 
36
+ /**
37
+ * Resolve pointer hover and clicks through the canonical semantic target graph.
38
+ *
39
+ * The hook mutates only its transient hover marker and removes it when disabled
40
+ * or unmounted.
41
+ */
20
42
  export function usePinpoint({
21
43
  containerRef,
22
- highlighterRef,
23
44
  inputMethod,
24
45
  enabled,
46
+ onSelectRange,
25
47
  onCodeBlockClick,
26
48
  }: UsePinpointOptions): UsePinpointReturn {
27
- const [hoverTarget, setHoverTarget] = useState<{ element: HTMLElement; label: string } | null>(null);
49
+ const [hoverTarget, setHoverTarget] = useState<SemanticTarget | null>(null);
50
+ const hoverElementRef = useRef<HTMLElement | null>(null);
28
51
 
29
52
  const isActive = inputMethod === 'pinpoint' && enabled;
53
+ const clearHover = useCallback(() => {
54
+ hoverElementRef.current?.removeAttribute('data-pinpoint-hover');
55
+ hoverElementRef.current = null;
56
+ setHoverTarget(null);
57
+ }, []);
30
58
 
31
59
  // Clear hover when deactivated
32
60
  useEffect(() => {
33
61
  if (!isActive) {
34
- setHoverTarget((prev) => {
35
- if (prev) prev.element.removeAttribute('data-pinpoint-hover');
36
- return null;
37
- });
62
+ clearHover();
38
63
  }
39
- }, [isActive]);
64
+ }, [clearHover, isActive]);
40
65
 
41
66
  // Mousemove / touchstart — resolve target and update hover state
42
67
  useEffect(() => {
43
68
  const container = containerRef.current;
44
69
  if (!isActive || !container) return;
45
70
 
46
- let prevElement: HTMLElement | null = null;
71
+ let graph = buildSemanticTargetGraph(container);
72
+ let graphIsDirty = false;
73
+ const liveGraph = () => {
74
+ if (graphIsDirty) {
75
+ graph = buildSemanticTargetGraph(container);
76
+ graphIsDirty = false;
77
+ }
78
+ return graph;
79
+ };
80
+ const observer = new MutationObserver(() => {
81
+ graphIsDirty = true;
82
+ });
83
+ observer.observe(container, { childList: true, subtree: true });
47
84
 
48
85
  const updateHover = (clientX: number, clientY: number, target: HTMLElement) => {
49
- const resolved = resolvePinpointTarget(target, container, { clientX, clientY });
86
+ const resolved = resolveSemanticTargetAtPoint(
87
+ liveGraph(),
88
+ target,
89
+ { clientX, clientY },
90
+ );
50
91
 
51
92
  if (resolved) {
52
- if (resolved.element !== prevElement) {
53
- prevElement?.removeAttribute('data-pinpoint-hover');
93
+ if (resolved.element !== hoverElementRef.current) {
94
+ hoverElementRef.current?.removeAttribute('data-pinpoint-hover');
54
95
  resolved.element.setAttribute('data-pinpoint-hover', '');
55
- prevElement = resolved.element;
56
- setHoverTarget({ element: resolved.element, label: resolved.label });
96
+ hoverElementRef.current = resolved.element;
97
+ setHoverTarget(resolved);
57
98
  }
58
99
  } else {
59
- if (prevElement) {
60
- prevElement.removeAttribute('data-pinpoint-hover');
61
- prevElement = null;
62
- setHoverTarget(null);
63
- }
100
+ clearHover();
64
101
  }
65
102
  };
66
103
 
@@ -76,9 +113,7 @@ export function usePinpoint({
76
113
  };
77
114
 
78
115
  const handleMouseLeave = () => {
79
- prevElement?.removeAttribute('data-pinpoint-hover');
80
- prevElement = null;
81
- setHoverTarget(null);
116
+ clearHover();
82
117
  };
83
118
 
84
119
  container.addEventListener('mousemove', handleMouseMove);
@@ -86,12 +121,14 @@ export function usePinpoint({
86
121
  container.addEventListener('touchstart', handleTouchStart, { passive: true });
87
122
 
88
123
  return () => {
89
- prevElement?.removeAttribute('data-pinpoint-hover');
124
+ observer.disconnect();
125
+ hoverElementRef.current?.removeAttribute('data-pinpoint-hover');
126
+ hoverElementRef.current = null;
90
127
  container.removeEventListener('mousemove', handleMouseMove);
91
128
  container.removeEventListener('mouseleave', handleMouseLeave);
92
129
  container.removeEventListener('touchstart', handleTouchStart);
93
130
  };
94
- }, [isActive, containerRef]);
131
+ }, [clearHover, isActive, containerRef]);
95
132
 
96
133
  // Click — create Range and trigger web-highlighter
97
134
  useEffect(() => {
@@ -99,15 +136,12 @@ export function usePinpoint({
99
136
  if (!isActive || !container) return;
100
137
 
101
138
  const handleClick = (e: MouseEvent) => {
102
- // Read highlighter at click time, not effect setup time.
103
- // On remount (e.g. after exiting plan diff), the highlighter init effect
104
- // may not have run yet when this effect sets up, but it will be ready by
105
- // the time the user clicks.
106
- const highlighter = highlighterRef.current;
107
- if (!highlighter) return;
108
-
109
139
  const target = e.target as HTMLElement;
110
- const resolved = resolvePinpointTarget(target, container, { clientX: e.clientX, clientY: e.clientY });
140
+ const resolved = resolveSemanticTargetAtPoint(
141
+ buildSemanticTargetGraph(container),
142
+ target,
143
+ { clientX: e.clientX, clientY: e.clientY },
144
+ );
111
145
  if (!resolved) return;
112
146
 
113
147
  // Prevent link navigation in pinpoint mode
@@ -120,7 +154,7 @@ export function usePinpoint({
120
154
  resolved.element.removeAttribute('data-pinpoint-hover');
121
155
  setHoverTarget(null);
122
156
 
123
- if (resolved.isCodeBlock) {
157
+ if (resolved.kind === 'code') {
124
158
  // Route to existing code block annotation path
125
159
  const codeBlockContainer = container.querySelector(`[data-block-id="${resolved.blockId}"]`) as HTMLElement;
126
160
  if (codeBlockContainer) {
@@ -131,19 +165,10 @@ export function usePinpoint({
131
165
 
132
166
  // Create a text-level Range spanning the target element's content.
133
167
  // web-highlighter needs ranges anchored to text nodes, not elements.
134
- const range = createTextRange(resolved.element);
168
+ const range = createSemanticTargetRange(resolved);
135
169
  if (!range) return;
136
170
 
137
- // Set browser selection so web-highlighter's mouseup handler picks it up
138
- const sel = window.getSelection();
139
- sel?.removeAllRanges();
140
- sel?.addRange(range);
141
-
142
- // Drive web-highlighter programmatically — fires CREATE event
143
- highlighter.fromRange(range);
144
-
145
- // Clean up browser selection
146
- window.getSelection()?.removeAllRanges();
171
+ onSelectRange(range);
147
172
  };
148
173
 
149
174
  // Use capture phase so we get the click before links navigate
@@ -152,31 +177,7 @@ export function usePinpoint({
152
177
  return () => {
153
178
  container.removeEventListener('click', handleClick, true);
154
179
  };
155
- }, [isActive, containerRef, highlighterRef, onCodeBlockClick]);
156
-
157
- return { hoverTarget };
158
- }
159
-
160
- /**
161
- * Create a Range anchored to text nodes (not elements).
162
- * web-highlighter's painter expects text-node-level ranges.
163
- */
164
- function createTextRange(element: HTMLElement): Range | null {
165
- const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null);
166
-
167
- let firstNode: Text | null = null;
168
- let lastNode: Text | null = null;
169
-
170
- let node: Text | null;
171
- while ((node = walker.nextNode() as Text | null)) {
172
- if (!firstNode) firstNode = node;
173
- lastNode = node;
174
- }
175
-
176
- if (!firstNode || !lastNode) return null;
180
+ }, [isActive, containerRef, onCodeBlockClick, onSelectRange]);
177
181
 
178
- const range = document.createRange();
179
- range.setStart(firstNode, 0);
180
- range.setEnd(lastNode, lastNode.length);
181
- return range;
182
+ return { hoverTarget, clearHover };
182
183
  }
@@ -5,7 +5,7 @@
5
5
  * Consumes the version history API endpoints.
6
6
  */
7
7
 
8
- import { useState, useMemo, useCallback, useEffect } from "react";
8
+ import { useState, useMemo, useCallback, useEffect, useRef } from "react";
9
9
  import {
10
10
  computePlanDiff,
11
11
  type PlanDiffBlock,
@@ -89,7 +89,22 @@ export function usePlanDiff(
89
89
  currentPlan: string,
90
90
  initialPreviousPlan: string | null,
91
91
  versionInfo: VersionInfo | null,
92
- fetchers?: PlanDiffFetchers
92
+ fetchers?: PlanDiffFetchers,
93
+ /**
94
+ * Identity of the document `initialPreviousPlan`/`versionInfo` belong to
95
+ * (e.g. a folder/linked document's filepath). When this changes between
96
+ * renders, the diff-base state below switches to that document's own
97
+ * remembered base selection (if this document was visited earlier in the
98
+ * session — see baseSelectionsByDocKeyRef below) or, the first time a
99
+ * document is seen, seeds from its `initialPreviousPlan`/`versionInfo` —
100
+ * either way, never keeping whatever the previously active document had
101
+ * selected, since that would silently keep diffing the new document's text
102
+ * against the old document's base plan. Omit (or keep it referentially
103
+ * stable, as the root document does for the life of a session) to preserve
104
+ * exactly today's one-time-hydration behavior via the two sync effects
105
+ * below.
106
+ */
107
+ docKey?: string | null
93
108
  ): UsePlanDiffReturn {
94
109
  const fetchVersionImpl = fetchers?.fetchVersion ?? defaultFetchVersion;
95
110
  const fetchVersionsImpl = fetchers?.fetchVersions ?? defaultFetchVersions;
@@ -118,6 +133,62 @@ export function usePlanDiff(
118
133
  }
119
134
  }, [versionInfo]);
120
135
 
136
+ // Remember each document's diff-base selection across navigation, keyed by
137
+ // docKey (including `null`/`undefined`) — so returning to a previously
138
+ // visited document (e.g. the root doc after a linked-doc detour) restores
139
+ // whatever base version the user had picked there instead of re-seeding
140
+ // defaults. A plain ref (not state) since writing it must never itself
141
+ // trigger a render — it's only read/written from inside the docKey-change
142
+ // effect below. Never shared across keys: each key's entry is only ever
143
+ // populated from that same key's own state at the moment it stops being
144
+ // active.
145
+ const baseSelectionsByDocKeyRef = useRef(
146
+ new Map<
147
+ string | null | undefined,
148
+ { diffBasePlan: string | null; diffBaseVersion: number | null }
149
+ >()
150
+ );
151
+
152
+ // Reset (or restore) diff-base state whenever the active document identity
153
+ // changes (e.g. folder/linked-doc navigation). docKey is undefined/stable
154
+ // for a session with no per-doc identity (the root document), so this
155
+ // never fires there and the sync effects above keep owning its one-time
156
+ // hydration — byte-identical to before this seam existed.
157
+ const prevDocKeyRef = useRef(docKey);
158
+ useEffect(() => {
159
+ if (prevDocKeyRef.current === docKey) return;
160
+
161
+ // Persist the outgoing document's current selection before switching, so
162
+ // coming back to it later (even after other documents were visited in
163
+ // between) restores it rather than re-seeding defaults.
164
+ baseSelectionsByDocKeyRef.current.set(prevDocKeyRef.current, {
165
+ diffBasePlan,
166
+ diffBaseVersion,
167
+ });
168
+ prevDocKeyRef.current = docKey;
169
+
170
+ const remembered = baseSelectionsByDocKeyRef.current.get(docKey);
171
+ if (remembered) {
172
+ setDiffBasePlan(remembered.diffBasePlan);
173
+ setDiffBaseVersion(remembered.diffBaseVersion);
174
+ } else {
175
+ setDiffBasePlan(initialPreviousPlan);
176
+ setDiffBaseVersion(
177
+ versionInfo && versionInfo.version > 1 ? versionInfo.version - 1 : null
178
+ );
179
+ }
180
+ setVersions([]);
181
+ setIsLoadingVersions(false);
182
+ setIsSelectingVersion(false);
183
+ setFetchingVersion(null);
184
+ // Only the identity change (docKey) should trigger this reset — the sync
185
+ // effects above already handle initialPreviousPlan/versionInfo arriving
186
+ // late for a stable identity, and re-running this on every value change
187
+ // would fight version selection (selectBaseVersion intentionally leaves
188
+ // docKey unchanged while it swaps diffBasePlan/diffBaseVersion).
189
+ // eslint-disable-next-line react-hooks/exhaustive-deps
190
+ }, [docKey]);
191
+
121
192
  const hasPreviousVersion =
122
193
  versionInfo !== null && versionInfo.totalVersions > 1 && diffBasePlan !== null;
123
194
 
@@ -0,0 +1,239 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import type React from 'react';
3
+ import {
4
+ extractSkillReferences,
5
+ filterSkillCatalog,
6
+ findSkillReferenceTokens,
7
+ findSkillTrigger,
8
+ insertSkillReference,
9
+ type SkillCatalogEntry,
10
+ type SkillReferenceToken,
11
+ type SkillTriggerContext,
12
+ } from '../utils/skillReferences';
13
+ import { fetchSkillCatalog, getCachedSkillCatalog } from '../utils/skillCatalog';
14
+
15
+ export interface SkillReferenceMenuState {
16
+ items: SkillCatalogEntry[];
17
+ /** Explicitly activated row, or null — the menu opens with NOTHING active. */
18
+ activeIndex: number | null;
19
+ query: string;
20
+ }
21
+
22
+ export interface UseSkillReferenceAutocompleteResult {
23
+ /** Open menu state, or null. Render SkillReferenceMenu from this. */
24
+ menu: SkillReferenceMenuState | null;
25
+ /** Call FIRST in the textarea's onKeyDown; true means the event was consumed. */
26
+ onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => boolean;
27
+ /** Call from the textarea's onSelect (fires on every caret move + input). */
28
+ onSelect: () => void;
29
+ /** Insert the given menu item at the active trigger. */
30
+ select: (index: number) => void;
31
+ /** Human-only skills currently referenced in the text (drives the composer warning). */
32
+ humanOnlyReferences: SkillCatalogEntry[];
33
+ /** Positioned reference occurrences in the text (drives the composer highlight overlay). */
34
+ referenceTokens: SkillReferenceToken[];
35
+ }
36
+
37
+ /**
38
+ * Skill-reference autocomplete for a comment textarea. Typing `/` or `$` at
39
+ * the start of a word opens a menu of the user's global agent skills; the
40
+ * catalog is fetched lazily (memory-cached, never persisted). With no catalog
41
+ * (endpoint absent, discovery failed, no skills installed) every path here is
42
+ * inert and the textarea behaves exactly as before.
43
+ *
44
+ * NO-PRESELECTION INVARIANT (do not weaken — an adversarial review proved the
45
+ * failure): the menu opens with no row active, and while no row is active
46
+ * Enter, Tab, and every other typing key behave exactly as if the menu were
47
+ * not open — "This costs $" + Enter is a newline, "cd /" + Tab leaves the
48
+ * field. A row becomes active ONLY via explicit keyboard navigation
49
+ * (ArrowDown from none lands on the FIRST row, ArrowUp from none on the
50
+ * LAST) — and on a BARE trigger (zero query characters) even the arrows pass
51
+ * through to the textarea and dismiss the menu, because in a multi-line
52
+ * composer "cost: $" + ArrowUp means caret navigation, not menu navigation;
53
+ * arrows engage the menu only once a query character was typed. Only with an
54
+ * active row do Enter and Tab insert. Pointer hover never activates a
55
+ * row (a menu rendered over the composer sits exactly where the mouse rests
56
+ * while typing); a pointer CLICK inserts directly and never arms Enter.
57
+ * Continuing to type re-filters the list and DISARMS any active row, so an
58
+ * activation always refers to the exact list the user saw. Escape clears the
59
+ * active row and dismisses the menu when the user has engaged with it (a row
60
+ * is active or a query was typed); on a bare-trigger menu with no engagement
61
+ * it passes through, so Escape still closes the composer in one press.
62
+ */
63
+ export function useSkillReferenceAutocomplete(options: {
64
+ text: string;
65
+ setText: (text: string) => void;
66
+ textareaRef: React.RefObject<HTMLTextAreaElement | null>;
67
+ enabled: boolean;
68
+ }): UseSkillReferenceAutocompleteResult {
69
+ const { text, setText, textareaRef, enabled } = options;
70
+ // Seed from the memory cache only when the surface opted in — a disabled
71
+ // composer must stay inert even after another surface warmed the catalog.
72
+ const [catalog, setCatalog] = useState<SkillCatalogEntry[]>(() =>
73
+ enabled ? getCachedSkillCatalog() : [],
74
+ );
75
+ const [caret, setCaret] = useState<number | null>(null);
76
+ const [activeIndex, setActiveIndex] = useState<number | null>(null);
77
+ // Escape dismisses the menu for the trigger it was open on; the same trigger
78
+ // does not reopen until the user leaves it (new trigger start clears this).
79
+ const [dismissedStart, setDismissedStart] = useState<number | null>(null);
80
+
81
+ useEffect(() => {
82
+ if (!enabled) return;
83
+ let cancelled = false;
84
+ fetchSkillCatalog().then((skills) => {
85
+ if (!cancelled) setCatalog(skills);
86
+ });
87
+ return () => {
88
+ cancelled = true;
89
+ };
90
+ }, [enabled]);
91
+
92
+ const trigger: SkillTriggerContext | null = useMemo(() => {
93
+ if (!enabled || catalog.length === 0 || caret === null) return null;
94
+ return findSkillTrigger(text, caret);
95
+ }, [enabled, catalog, text, caret]);
96
+
97
+ const items = useMemo(
98
+ () => (trigger ? filterSkillCatalog(catalog, trigger.query) : []),
99
+ [catalog, trigger],
100
+ );
101
+
102
+ const open = trigger !== null && items.length > 0 && trigger.start !== dismissedStart;
103
+
104
+ // A new trigger start clears the dismissal memory.
105
+ const lastTriggerStart = useRef<number | null>(null);
106
+ useEffect(() => {
107
+ const start = trigger?.start ?? null;
108
+ if (start !== lastTriggerStart.current) {
109
+ lastTriggerStart.current = start;
110
+ setDismissedStart(null);
111
+ }
112
+ }, [trigger]);
113
+
114
+ // Any trigger change — a new trigger OR more typing re-filtering the same
115
+ // one — disarms the active row. An activation must always refer to the
116
+ // exact list the user was looking at when they pressed the arrow key.
117
+ const triggerStart = trigger?.start ?? null;
118
+ const triggerQuery = trigger?.query ?? null;
119
+ useEffect(() => {
120
+ setActiveIndex(null);
121
+ }, [triggerStart, triggerQuery]);
122
+
123
+ // Never let a stale activation point past the list (catalog refreshes can
124
+ // shrink it without a query change). Out of range reads as "nothing active".
125
+ const boundedActive =
126
+ activeIndex !== null && activeIndex >= 0 && activeIndex < items.length ? activeIndex : null;
127
+
128
+ const readCaret = useCallback(() => {
129
+ const el = textareaRef.current;
130
+ setCaret(el ? el.selectionStart : null);
131
+ }, [textareaRef]);
132
+
133
+ const select = useCallback(
134
+ (index: number) => {
135
+ const el = textareaRef.current;
136
+ if (!trigger || !el) return;
137
+ const item = items[index];
138
+ if (!item) return;
139
+ const result = insertSkillReference(text, el.selectionStart, trigger, item);
140
+ setText(result.text);
141
+ setCaret(result.caret);
142
+ setActiveIndex(null);
143
+ // Close deterministically. The DOM caret only moves in the 0ms timer
144
+ // below, and React's select plugin can re-read the STALE caret before
145
+ // then (mousedown/keydown fire onSelect), which would transiently
146
+ // re-open the menu on the just-replaced query. Dismissing the trigger
147
+ // start makes the close ordering-safe; the dismissal clears itself as
148
+ // soon as the trigger changes (including to null when the caret lands).
149
+ setDismissedStart(trigger.start);
150
+ // Restore focus + caret after React commits the new value.
151
+ setTimeout(() => {
152
+ if (!el.isConnected) return;
153
+ el.focus();
154
+ el.setSelectionRange(result.caret, result.caret);
155
+ }, 0);
156
+ },
157
+ [items, setText, text, textareaRef, trigger],
158
+ );
159
+
160
+ const onKeyDown = useCallback(
161
+ (e: React.KeyboardEvent<HTMLTextAreaElement>): boolean => {
162
+ if (!open) return false;
163
+ // IME composition: for Pinyin, Telex, 2-set Korean and friends the
164
+ // composition buffer is ASCII, so the menu can be open exactly when
165
+ // Enter means "commit this candidate" and the arrows drive the
166
+ // candidate list. Never consume keys mid-composition. With bare
167
+ // triggers opening the menu, this guard is MORE load-bearing than
168
+ // before: the menu is open during more compositions.
169
+ if (e.nativeEvent.isComposing) return false;
170
+ if (e.metaKey || e.ctrlKey || e.altKey) return false;
171
+ switch (e.key) {
172
+ case 'ArrowDown':
173
+ case 'ArrowUp':
174
+ // BARE trigger, zero query, nothing active: the arrows were aimed
175
+ // at the textarea (proven regression: "first line\ncost: $" +
176
+ // ArrowUp must move the caret up, not arm the menu's last row so
177
+ // the next Enter inserts a skill). Dismiss the menu and pass the
178
+ // key through. Once the user has typed a query character — or
179
+ // engaged a row via a query — the arrows navigate the menu.
180
+ if (boundedActive === null && trigger.query.length === 0) {
181
+ setDismissedStart(trigger.start);
182
+ return false;
183
+ }
184
+ e.preventDefault();
185
+ if (e.key === 'ArrowDown') {
186
+ setActiveIndex(boundedActive === null ? 0 : (boundedActive + 1) % items.length);
187
+ } else {
188
+ setActiveIndex(
189
+ boundedActive === null
190
+ ? items.length - 1
191
+ : (boundedActive - 1 + items.length) % items.length,
192
+ );
193
+ }
194
+ return true;
195
+ case 'Enter':
196
+ case 'Tab':
197
+ // NO row active means these keys were NOT aimed at the menu:
198
+ // Enter stays a newline, Tab still leaves the field. This is the
199
+ // proven regression ("This costs $" + Enter); never consume here.
200
+ if (boundedActive === null) return false;
201
+ e.preventDefault();
202
+ select(boundedActive);
203
+ return true;
204
+ case 'Escape':
205
+ // Only consume Escape when the user engaged with the menu (typed a
206
+ // query or activated a row). A bare-trigger menu the user ignored
207
+ // must not cost an extra Escape on the way to closing the composer.
208
+ if (boundedActive === null && trigger.query.length === 0) return false;
209
+ e.preventDefault();
210
+ e.stopPropagation();
211
+ setActiveIndex(null);
212
+ setDismissedStart(trigger.start);
213
+ return true;
214
+ default:
215
+ return false;
216
+ }
217
+ },
218
+ [boundedActive, items.length, open, select, trigger],
219
+ );
220
+
221
+ const humanOnlyReferences = useMemo(
222
+ () => (enabled ? extractSkillReferences(text, catalog).filter((s) => s.humanOnly) : []),
223
+ [enabled, text, catalog],
224
+ );
225
+
226
+ const referenceTokens = useMemo(
227
+ () => (enabled ? findSkillReferenceTokens(text, catalog) : []),
228
+ [enabled, text, catalog],
229
+ );
230
+
231
+ return {
232
+ menu: open ? { items, activeIndex: boundedActive, query: trigger.query } : null,
233
+ onKeyDown,
234
+ onSelect: readCaret,
235
+ select,
236
+ humanOnlyReferences,
237
+ referenceTokens,
238
+ };
239
+ }
@@ -114,8 +114,7 @@ export function useUpdateCheck(): UpdateInfo | null {
114
114
  releaseUrl: release.html_url,
115
115
  featureHighlight,
116
116
  });
117
- } catch (e) {
118
- console.debug('Update check failed:', e);
117
+ } catch {
119
118
  }
120
119
  };
121
120