@plannotator/ui 0.28.0 → 0.29.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.
- package/README.md +6 -0
- package/components/AISettingsTab.tsx +5 -4
- package/components/ActionMenu.tsx +5 -1
- package/components/AgentsTab.tsx +10 -23
- package/components/AnnotationPanel.tsx +9 -5
- package/components/AnnotationToolbar.tsx +5 -13
- package/components/AnnotationToolstrip.tsx +2 -2
- package/components/ApproveDropdown.tsx +1 -1
- package/components/BlockRenderer.tsx +1 -1
- package/components/CodeFilePopout.tsx +5 -4
- package/components/CommentPopover.tsx +391 -65
- package/components/DocBadges.tsx +29 -11
- package/components/ExportModal.tsx +16 -8
- package/components/GraphvizBlock.tsx +1 -1
- package/components/InlineMarkdown.tsx +30 -13
- package/components/KeyboardShortcuts.tsx +37 -2
- package/components/Landing.tsx +7 -7
- package/components/MenuVersionSection.tsx +4 -4
- package/components/MermaidBlock.tsx +1 -1
- package/components/ModeToggle.tsx +7 -6
- package/components/OpenInAppButton.tsx +2 -5
- package/components/PinpointOverlay.tsx +9 -7
- package/components/PlanHeaderMenu.tsx +8 -8
- package/components/PopoutDialog.tsx +6 -1
- package/components/ResizeHandle.tsx +1 -0
- package/components/Settings.tsx +172 -12
- package/components/SkillReferenceMenu.tsx +260 -0
- package/components/StickyHeaderLane.tsx +7 -0
- package/components/ThemeProvider.tsx +131 -32
- package/components/ThemeTab.tsx +123 -77
- package/components/ToolbarButtons.tsx +29 -8
- package/components/Viewer.tsx +396 -130
- package/components/VimKeyHud.tsx +695 -0
- package/components/VimModeAnnouncementDialog.tsx +557 -0
- package/components/VimModeOverlay.tsx +235 -0
- package/components/VimTargetReticle.tsx +284 -0
- package/components/ai/DocumentAIChatPanel.tsx +1 -1
- package/components/blocks/CodeBlock.tsx +18 -18
- package/components/blocks/TablePopout.tsx +7 -8
- package/components/blocks/TableToolbar.tsx +7 -8
- package/components/goal-setup/GoalSetupSurface.tsx +16 -3
- package/components/html-viewer/HtmlViewer.tsx +450 -47
- package/components/html-viewer/annotationNumbering.ts +37 -0
- package/components/html-viewer/bridge-script.ts +4051 -298
- package/components/html-viewer/composerYield.ts +51 -0
- package/components/html-viewer/srcdoc.ts +18 -3
- package/components/html-viewer/useHtmlAnnotation.ts +457 -32
- package/components/icons/themeIcons.tsx +1 -1
- package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
- package/components/plan-diff/PlanDiffBadge.tsx +22 -1
- package/components/settings/HooksTab.tsx +12 -8
- package/components/sidebar/FileBrowser.tsx +4 -1
- package/components/themeModes.tsx +28 -0
- package/config/configStore.ts +76 -1
- package/config/settings.ts +152 -0
- package/configure.ts +9 -0
- package/globals.d.ts +7 -1
- package/hooks/useAIChat.ts +5 -2
- package/hooks/useAIProviderActivation.ts +47 -0
- package/hooks/useAIProviderConfig.ts +5 -1
- package/hooks/useAgentSettings.ts +64 -23
- package/hooks/useAgents.ts +4 -4
- package/hooks/useAnnotationHighlighter.ts +100 -3
- package/hooks/useArchive.ts +2 -1
- package/hooks/useFenceTheme.ts +17 -0
- package/hooks/useLinkedDoc.ts +68 -1
- package/hooks/usePinpoint.ts +76 -75
- package/hooks/usePlanDiff.ts +73 -2
- package/hooks/useSkillReferenceAutocomplete.ts +239 -0
- package/hooks/useUpdateCheck.ts +1 -2
- package/hooks/useVimDocumentFocus.ts +116 -0
- package/hooks/useVimSelection.ts +1063 -0
- package/package.json +4 -4
- package/print.css +14 -13
- package/shortcuts/core.ts +38 -13
- package/shortcuts/index.ts +10 -0
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
- package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
- package/shortcuts/runtime.ts +111 -12
- package/styles.css +1 -1
- package/theme.css +504 -0
- package/themes/colorblind.css +89 -0
- package/themes/plannotator.css +2 -2
- package/types.ts +93 -10
- package/utils/agentSwitch.ts +33 -7
- package/utils/blockTargeting.ts +462 -178
- package/utils/clipboard.ts +110 -0
- package/utils/codeBlockMark.ts +50 -0
- package/utils/codeHighlight.ts +293 -0
- package/utils/codexModels.ts +79 -0
- package/utils/domSelection.ts +84 -0
- package/utils/htmlChrome.ts +73 -0
- package/utils/inputMethod.ts +79 -6
- package/utils/parser.ts +517 -21
- package/utils/preferenceTtl.ts +15 -0
- package/utils/sharing.ts +0 -1
- package/utils/skillCatalog.ts +269 -0
- package/utils/skillReferences.ts +475 -0
- package/utils/syntaxTheme.ts +83 -0
- package/utils/themeRegistry.ts +154 -0
- package/utils/vimHud.ts +263 -0
- package/utils/vimModeAnnouncement.ts +23 -0
- package/utils/vimNavigation.ts +417 -0
- package/utils/vimReticle.ts +88 -0
- package/utils/vimScroll.ts +162 -0
package/hooks/usePinpoint.ts
CHANGED
|
@@ -1,66 +1,103 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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 {
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
|
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 =
|
|
86
|
+
const resolved = resolveSemanticTargetAtPoint(
|
|
87
|
+
liveGraph(),
|
|
88
|
+
target,
|
|
89
|
+
{ clientX, clientY },
|
|
90
|
+
);
|
|
50
91
|
|
|
51
92
|
if (resolved) {
|
|
52
|
-
if (resolved.element !==
|
|
53
|
-
|
|
93
|
+
if (resolved.element !== hoverElementRef.current) {
|
|
94
|
+
hoverElementRef.current?.removeAttribute('data-pinpoint-hover');
|
|
54
95
|
resolved.element.setAttribute('data-pinpoint-hover', '');
|
|
55
|
-
|
|
56
|
-
setHoverTarget(
|
|
96
|
+
hoverElementRef.current = resolved.element;
|
|
97
|
+
setHoverTarget(resolved);
|
|
57
98
|
}
|
|
58
99
|
} else {
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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 =
|
|
168
|
+
const range = createSemanticTargetRange(resolved);
|
|
135
169
|
if (!range) return;
|
|
136
170
|
|
|
137
|
-
|
|
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,
|
|
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
|
-
|
|
179
|
-
range.setStart(firstNode, 0);
|
|
180
|
-
range.setEnd(lastNode, lastNode.length);
|
|
181
|
-
return range;
|
|
182
|
+
return { hoverTarget, clearHover };
|
|
182
183
|
}
|
package/hooks/usePlanDiff.ts
CHANGED
|
@@ -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
|
+
}
|
package/hooks/useUpdateCheck.ts
CHANGED