@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.
- package/HANDOFF.md +284 -29
- package/README.md +45 -5
- package/components/AnnotationPanel.tsx +195 -118
- package/components/AnnotationToolbar.tsx +17 -14
- package/components/AnnotationToolstrip.tsx +54 -40
- package/components/BlockRenderer.tsx +20 -14
- package/components/CodeFilePopout.tsx +132 -69
- package/components/CommentPopover.tsx +8 -349
- package/components/ComposerTokenTextarea.tsx +379 -0
- package/components/ExportModal.tsx +6 -1
- package/components/HostTitle.tsx +108 -0
- package/components/HtmlSurfaceControls.tsx +2 -1
- package/components/ImageLightbox.tsx +288 -17
- package/components/ImagePeek.tsx +255 -0
- package/components/ImageResolverContext.tsx +31 -0
- package/components/InlineMarkdown.tsx +123 -15
- package/components/LocalLinkContext.tsx +49 -0
- package/components/MentionPicker.tsx +51 -0
- package/components/MenuVersionSection.tsx +51 -25
- package/components/OpenInAppButton.tsx +18 -49
- package/components/PlanHeaderMenu.tsx +2 -8
- package/components/ProviderIcons.tsx +10 -4
- package/components/RenderedMarkdown.tsx +40 -4
- package/components/Settings.tsx +33 -5
- package/components/SkillReferenceMenu.tsx +67 -18
- package/components/Tooltip.tsx +51 -4
- package/components/Viewer.tsx +220 -49
- package/components/ai/AIProviderBar.tsx +11 -0
- package/components/ai/AskSessionSetupHint.tsx +12 -0
- package/components/ai/DocumentAIChatPanel.tsx +17 -5
- package/components/ai/SessionAskNotice.tsx +4 -3
- package/components/blocks/BlockquoteBlock.tsx +159 -0
- package/components/blocks/HtmlBlock.tsx +121 -9
- package/components/blocks/QuestionBlock.tsx +183 -96
- package/components/blocks/QuestionContext.tsx +3 -0
- package/components/diagram/DiagramViewer.tsx +41 -4
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +95 -3
- package/components/html-viewer/bridge-script.asset.js +413 -7
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +454 -7
- package/components/html-viewer/useHtmlAnnotation.ts +51 -3
- package/components/icons/AgentIcons.tsx +9 -7
- package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
- package/components/plan-diff/PlanDiffViewer.tsx +6 -1
- package/configure.ts +27 -1
- package/hooks/useAgentJobs.ts +8 -3
- package/hooks/useAnnotationHighlighter.ts +128 -23
- package/hooks/useExternalAnnotations.ts +6 -0
- package/hooks/useMentionAutocomplete.ts +1 -1
- package/hooks/useOpenInApps.ts +88 -0
- package/hooks/useSkillReferenceAutocomplete.ts +7 -3
- package/hooks/useUpdateCheck.ts +38 -98
- package/hooks/useUpdateCheckSetting.ts +43 -0
- package/hooks/useUpdateToast.ts +101 -0
- package/hooks/useVimSelection.ts +9 -0
- package/package.json +2 -2
- package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
- package/shortcuts/index.ts +1 -0
- package/shortcuts/runtime.ts +10 -3
- package/styles.css +1 -1
- package/types.ts +48 -0
- package/utils/agentClosed.ts +26 -0
- package/utils/agentToolSetting.ts +16 -9
- package/utils/aiProvider.ts +7 -0
- package/utils/askSessionSetupHint.ts +27 -0
- package/utils/blockTargeting.ts +14 -3
- package/utils/mentions.ts +34 -5
- package/utils/parser.ts +65 -51
- package/utils/pasteInsert.ts +234 -0
- package/utils/planDiffEngine.ts +272 -2
- package/utils/skillCatalog.ts +15 -2
- package/utils/skillReferences.ts +50 -13
- package/utils/storage.ts +5 -0
- package/utils/textCut.ts +41 -0
- package/utils/updateNotice.ts +120 -0
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { useEffect, useRef, useState, useCallback, type RefObject } from 'react';
|
|
9
9
|
import Highlighter from '@plannotator/web-highlighter';
|
|
10
|
-
import type { Annotation, EditorMode, ImageAttachment } from '../types';
|
|
10
|
+
import type { Annotation, EditorMode, HostDraft, ImageAttachment } from '../types';
|
|
11
11
|
import { AnnotationType } from '../types';
|
|
12
12
|
import type { QuickLabel } from '../utils/quickLabels';
|
|
13
13
|
import { getIdentity } from '../utils/identity';
|
|
@@ -710,6 +710,13 @@ export interface UseAnnotationHighlighterOptions {
|
|
|
710
710
|
/** Fires once per `applyAnnotations` pass with what that pass tried and what
|
|
711
711
|
* it could not anchor, so a host can mark the leftovers in its panel. */
|
|
712
712
|
onRestoreReport?: (report: AnnotationRestoreReport) => void;
|
|
713
|
+
/**
|
|
714
|
+
* The composer handed to the host (see `HostDraft`). Set: whenever a
|
|
715
|
+
* selection's toolbar or composer would open, the host gets the draft
|
|
716
|
+
* instead (and null when it closes); the host draws no toolbar or composer
|
|
717
|
+
* from this hook's state. Absent: unchanged.
|
|
718
|
+
*/
|
|
719
|
+
onHostDraft?: (draft: HostDraft | null) => void;
|
|
713
720
|
}
|
|
714
721
|
|
|
715
722
|
/** The outcome of one `applyAnnotations` pass. */
|
|
@@ -777,6 +784,8 @@ export interface UseAnnotationHighlighterReturn {
|
|
|
777
784
|
removeHighlight: (id: string) => void;
|
|
778
785
|
clearAllHighlights: () => void;
|
|
779
786
|
applyAnnotations: (annotations: Annotation[]) => void;
|
|
787
|
+
/** For a host that composes on a touch screen: the page selection as a draft now (see `ViewerHandle.takeSelection`). */
|
|
788
|
+
takeSelection?: () => boolean;
|
|
780
789
|
}
|
|
781
790
|
|
|
782
791
|
/**
|
|
@@ -796,6 +805,7 @@ export function useAnnotationHighlighter({
|
|
|
796
805
|
verifyRestoredContent = false,
|
|
797
806
|
onRestoreMismatch,
|
|
798
807
|
onRestoreReport,
|
|
808
|
+
onHostDraft,
|
|
799
809
|
}: UseAnnotationHighlighterOptions): UseAnnotationHighlighterReturn {
|
|
800
810
|
const highlighterRef = useRef<Highlighter | null>(null);
|
|
801
811
|
const modeRef = useRef<EditorMode>(mode);
|
|
@@ -844,6 +854,14 @@ export function useAnnotationHighlighter({
|
|
|
844
854
|
pendingMathElementRef.current = source.element;
|
|
845
855
|
}, []);
|
|
846
856
|
|
|
857
|
+
// A host that composes (onHostDraft) on a touch screen takes the selection
|
|
858
|
+
// itself, from its own Comment action (`takeSelection`): painting it as it
|
|
859
|
+
// settles would replace the system selection, and its edit menu with it.
|
|
860
|
+
const hostComposesRef = useRef(false);
|
|
861
|
+
hostComposesRef.current = onHostDraft !== undefined;
|
|
862
|
+
const takeTouchSelectionRef = useRef<() => boolean>(() => false);
|
|
863
|
+
const takeSelection = useCallback(() => takeTouchSelectionRef.current(), []);
|
|
864
|
+
|
|
847
865
|
// Track mouse position for quick label picker
|
|
848
866
|
useEffect(() => {
|
|
849
867
|
const track = (e: MouseEvent) => { lastMousePosRef.current = { x: e.clientX, y: e.clientY }; };
|
|
@@ -996,7 +1014,8 @@ export function useAnnotationHighlighter({
|
|
|
996
1014
|
return null;
|
|
997
1015
|
}, []);
|
|
998
1016
|
|
|
999
|
-
|
|
1017
|
+
/** The annotation a selection's source makes, or null for a blank quote. No side effects. */
|
|
1018
|
+
const buildAnnotationFromSource = (
|
|
1000
1019
|
highlighter: Highlighter,
|
|
1001
1020
|
source: any,
|
|
1002
1021
|
type: AnnotationType,
|
|
@@ -1005,11 +1024,11 @@ export function useAnnotationHighlighter({
|
|
|
1005
1024
|
isQuickLabel?: boolean,
|
|
1006
1025
|
quickLabelTip?: string,
|
|
1007
1026
|
mentions?: readonly string[],
|
|
1008
|
-
) => {
|
|
1027
|
+
): Annotation | null => {
|
|
1009
1028
|
// #881: the last line before an annotation exists. A quote of pure
|
|
1010
1029
|
// whitespace cannot be re-anchored on the next load, so it would come back
|
|
1011
1030
|
// as an invisible row that is still counted and still exported.
|
|
1012
|
-
if (isBlankQuote(source?.text)) return;
|
|
1031
|
+
if (isBlankQuote(source?.text)) return null;
|
|
1013
1032
|
|
|
1014
1033
|
const doms = highlighter.getDoms(source.id);
|
|
1015
1034
|
let blockId = '';
|
|
@@ -1057,6 +1076,22 @@ export function useAnnotationHighlighter({
|
|
|
1057
1076
|
...(isQuickLabel ? { isQuickLabel: true } : {}),
|
|
1058
1077
|
...(quickLabelTip ? { quickLabelTip } : {}),
|
|
1059
1078
|
};
|
|
1079
|
+
return newAnnotation;
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
const createAnnotationFromSource = (
|
|
1083
|
+
highlighter: Highlighter,
|
|
1084
|
+
source: any,
|
|
1085
|
+
type: AnnotationType,
|
|
1086
|
+
text?: string,
|
|
1087
|
+
images?: ImageAttachment[],
|
|
1088
|
+
isQuickLabel?: boolean,
|
|
1089
|
+
quickLabelTip?: string,
|
|
1090
|
+
mentions?: readonly string[],
|
|
1091
|
+
) => {
|
|
1092
|
+
const newAnnotation = buildAnnotationFromSource(highlighter, source, type, text, images, isQuickLabel, quickLabelTip, mentions);
|
|
1093
|
+
if (!newAnnotation) return;
|
|
1094
|
+
const mathTargets = pendingMathTargetsRef.current;
|
|
1060
1095
|
|
|
1061
1096
|
if (type === AnnotationType.DELETION) {
|
|
1062
1097
|
highlighter.addClass('deletion', source.id);
|
|
@@ -1585,7 +1620,11 @@ export function useAnnotationHighlighter({
|
|
|
1585
1620
|
container.addEventListener('mouseup', handlePointerEndCapture, true);
|
|
1586
1621
|
container.addEventListener('touchend', handlePointerEndCapture, true);
|
|
1587
1622
|
|
|
1588
|
-
|
|
1623
|
+
// On a touch screen a host that composes takes the selection when the
|
|
1624
|
+
// person chooses its Comment (takeSelection); the library painting it at
|
|
1625
|
+
// touchend would end the system selection and its edit menu first.
|
|
1626
|
+
const isTouchPrimary = window.matchMedia('(pointer: coarse)').matches;
|
|
1627
|
+
if (!(isTouchPrimary && hostComposesRef.current)) highlighter.run();
|
|
1589
1628
|
|
|
1590
1629
|
const handleMathMouseDown = (event: MouseEvent) => {
|
|
1591
1630
|
mouseDownMathRef.current = closestMathElement(event.target as Node, containerRef.current);
|
|
@@ -1675,28 +1714,33 @@ export function useAnnotationHighlighter({
|
|
|
1675
1714
|
containerRef.current.addEventListener('mouseup', handleMathMouseUp, true);
|
|
1676
1715
|
|
|
1677
1716
|
// Mobile bridge
|
|
1678
|
-
const isTouchPrimary = window.matchMedia('(pointer: coarse)').matches;
|
|
1679
1717
|
let selectionTimer: ReturnType<typeof setTimeout>;
|
|
1718
|
+
// The page's selection, painted as a pending highlight; false when there is none to take.
|
|
1719
|
+
const takeTouchSelection = (): boolean => {
|
|
1720
|
+
const sel = window.getSelection();
|
|
1721
|
+
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return false;
|
|
1722
|
+
if (!containerRef.current?.contains(sel.anchorNode)) return false;
|
|
1723
|
+
const range = sel.getRangeAt(0).cloneRange();
|
|
1724
|
+
// Same fail-closed rule as the pointer-end guard (#881): touch
|
|
1725
|
+
// selections settle on block boundaries just as often.
|
|
1726
|
+
if (isBlankQuote(sel.toString()) || !isSerializableRange(range)) return false;
|
|
1727
|
+
snapRangeStartPastExcluded(range);
|
|
1728
|
+
pendingRangeRunsRef.current = rangeTextPieces(range);
|
|
1729
|
+
try {
|
|
1730
|
+
highlighter.fromRange(range);
|
|
1731
|
+
return true;
|
|
1732
|
+
} catch (error) {
|
|
1733
|
+
pendingRangeRunsRef.current = null;
|
|
1734
|
+
warnHighlighterFailure('painting a touch selection', error);
|
|
1735
|
+
return false;
|
|
1736
|
+
}
|
|
1737
|
+
};
|
|
1738
|
+
takeTouchSelectionRef.current = takeTouchSelection;
|
|
1680
1739
|
const handleSelectionChange = isTouchPrimary
|
|
1681
1740
|
? () => {
|
|
1682
1741
|
clearTimeout(selectionTimer);
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
|
|
1686
|
-
if (!containerRef.current?.contains(sel.anchorNode)) return;
|
|
1687
|
-
const range = sel.getRangeAt(0).cloneRange();
|
|
1688
|
-
// Same fail-closed rule as the pointer-end guard (#881): touch
|
|
1689
|
-
// selections settle on block boundaries just as often.
|
|
1690
|
-
if (isBlankQuote(sel.toString()) || !isSerializableRange(range)) return;
|
|
1691
|
-
snapRangeStartPastExcluded(range);
|
|
1692
|
-
pendingRangeRunsRef.current = rangeTextPieces(range);
|
|
1693
|
-
try {
|
|
1694
|
-
highlighter.fromRange(range);
|
|
1695
|
-
} catch (error) {
|
|
1696
|
-
pendingRangeRunsRef.current = null;
|
|
1697
|
-
warnHighlighterFailure('painting a touch selection', error);
|
|
1698
|
-
}
|
|
1699
|
-
}, 400);
|
|
1742
|
+
if (hostComposesRef.current) return;
|
|
1743
|
+
selectionTimer = setTimeout(takeTouchSelection, 400);
|
|
1700
1744
|
}
|
|
1701
1745
|
: null;
|
|
1702
1746
|
|
|
@@ -2025,6 +2069,66 @@ export function useAnnotationHighlighter({
|
|
|
2025
2069
|
window.getSelection()?.removeAllRanges();
|
|
2026
2070
|
}, [clearPendingSelection, quickLabelPicker]);
|
|
2027
2071
|
|
|
2072
|
+
// --- The composer handed to the host (onHostDraft) ---
|
|
2073
|
+
// The toolbar and composer states still drive everything here; the host is
|
|
2074
|
+
// told about them instead of seeing them drawn. A math source has no id of
|
|
2075
|
+
// its own until it is committed, so its draft id is minted once per source.
|
|
2076
|
+
const onHostDraftRef = useRef(onHostDraft);
|
|
2077
|
+
onHostDraftRef.current = onHostDraft;
|
|
2078
|
+
const hostDraftIdsRef = useRef(new WeakMap<object, string>());
|
|
2079
|
+
const hostDraftOn = onHostDraft !== undefined;
|
|
2080
|
+
useEffect(() => {
|
|
2081
|
+
const report = onHostDraftRef.current;
|
|
2082
|
+
if (!report) return;
|
|
2083
|
+
const open = commentPopover ?? toolbarState;
|
|
2084
|
+
const source = open?.source;
|
|
2085
|
+
const highlighter = highlighterRef.current;
|
|
2086
|
+
if (!source || !highlighter) {
|
|
2087
|
+
report(null);
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
let annotation: Annotation | null;
|
|
2091
|
+
if (isMathAnnotationSource(source)) {
|
|
2092
|
+
let id = hostDraftIdsRef.current.get(source);
|
|
2093
|
+
if (!id) {
|
|
2094
|
+
id = annotationId();
|
|
2095
|
+
hostDraftIdsRef.current.set(source, id);
|
|
2096
|
+
}
|
|
2097
|
+
annotation = {
|
|
2098
|
+
id,
|
|
2099
|
+
blockId: source.blockId,
|
|
2100
|
+
startOffset: 0,
|
|
2101
|
+
endOffset: source.text.length,
|
|
2102
|
+
type: AnnotationType.COMMENT,
|
|
2103
|
+
text: '',
|
|
2104
|
+
originalText: source.text,
|
|
2105
|
+
createdA: Date.now(),
|
|
2106
|
+
author: getIdentity(),
|
|
2107
|
+
};
|
|
2108
|
+
} else {
|
|
2109
|
+
annotation = buildAnnotationFromSource(highlighter, source, AnnotationType.COMMENT, '');
|
|
2110
|
+
}
|
|
2111
|
+
if (!annotation) {
|
|
2112
|
+
report(null);
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
report({
|
|
2116
|
+
annotation,
|
|
2117
|
+
intent: commentPopover ? 'compose' : 'selection',
|
|
2118
|
+
cancel: () => {
|
|
2119
|
+
// A newer selection owns the pending state now: leave it alone.
|
|
2120
|
+
if (pendingSourceRef.current !== source) return;
|
|
2121
|
+
if (!isMathAnnotationSource(source)) highlighterRef.current?.remove(source.id);
|
|
2122
|
+
clearPendingSelection();
|
|
2123
|
+
setToolbarState(null);
|
|
2124
|
+
setCommentPopover(null);
|
|
2125
|
+
window.getSelection()?.removeAllRanges();
|
|
2126
|
+
},
|
|
2127
|
+
});
|
|
2128
|
+
// buildAnnotationFromSource reads refs only; the states are the inputs.
|
|
2129
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2130
|
+
}, [commentPopover, toolbarState, hostDraftOn, clearPendingSelection]);
|
|
2131
|
+
|
|
2028
2132
|
return {
|
|
2029
2133
|
highlighterRef,
|
|
2030
2134
|
toolbarState,
|
|
@@ -2043,5 +2147,6 @@ export function useAnnotationHighlighter({
|
|
|
2043
2147
|
removeHighlight,
|
|
2044
2148
|
clearAllHighlights,
|
|
2045
2149
|
applyAnnotations: applyAnnotationsInternal,
|
|
2150
|
+
takeSelection,
|
|
2046
2151
|
};
|
|
2047
2152
|
}
|
|
@@ -123,6 +123,11 @@ interface UseExternalAnnotationsReturn<T> {
|
|
|
123
123
|
/** The agent session that opened this review closed it (`session-closed` on the stream). */
|
|
124
124
|
export interface ExternalSessionClosed {
|
|
125
125
|
unsentAnnotations: number;
|
|
126
|
+
/**
|
|
127
|
+
* Who closed it: the agent (`POST /api/host/close`), or `"session"` when plan
|
|
128
|
+
* mode or the agent session itself ended. Anything else reads as `"agent"`.
|
|
129
|
+
*/
|
|
130
|
+
by?: 'agent' | 'session';
|
|
126
131
|
}
|
|
127
132
|
|
|
128
133
|
export function useExternalAnnotations<T extends { id: string; source?: string }>(
|
|
@@ -193,6 +198,7 @@ export function useExternalAnnotations<T extends { id: string; source?: string }
|
|
|
193
198
|
case 'session-closed':
|
|
194
199
|
onSessionClosedRef.current?.({
|
|
195
200
|
unsentAnnotations: typeof parsed.unsentAnnotations === 'number' ? parsed.unsentAnnotations : 0,
|
|
201
|
+
by: parsed.by === 'session' ? 'session' : 'agent',
|
|
196
202
|
});
|
|
197
203
|
break;
|
|
198
204
|
}
|
|
@@ -115,7 +115,7 @@ export function useMentionAutocomplete(options: {
|
|
|
115
115
|
}, [mentionsKey, onMentionsChange]);
|
|
116
116
|
|
|
117
117
|
const items = useMemo(
|
|
118
|
-
() => (source && trigger ? mentionMatches(source.people, trigger, taggedIds) : []),
|
|
118
|
+
() => (source && trigger ? mentionMatches(source.people, trigger, taggedIds, source.kinds, source.matchDetail ?? true) : []),
|
|
119
119
|
[source, trigger, taggedIds],
|
|
120
120
|
);
|
|
121
121
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import type { OpenInKind, OpenInPlatform } from '@plannotator/core/open-in-apps';
|
|
3
|
+
import { getStoredOpenInApp, setLastOpenInApp } from '../utils/storage';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The "Open in" pieces every host shares: the detected-apps list (fetched
|
|
7
|
+
* once per URL and shared by every control on the page) and the remembered
|
|
8
|
+
* app (the `plannotator-open-in-app` cookie, so code review, annotate and the
|
|
9
|
+
* Inbox remember the same editor). OpenInAppButton reads `/api/open-in/apps`;
|
|
10
|
+
* the Inbox reads its own local-only `/api/inbox/open-in/apps`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface DetectedOpenInApp {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
kind: OpenInKind;
|
|
17
|
+
icon: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface OpenInAppsAnswer {
|
|
21
|
+
/** True only when the route says so: false in a remote session, on a route this server does not have, or when the read failed. */
|
|
22
|
+
available: boolean;
|
|
23
|
+
apps: DetectedOpenInApp[];
|
|
24
|
+
/** The host's platform, when the route says (the Inbox's does). */
|
|
25
|
+
platform?: OpenInPlatform;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const loads = new Map<string, Promise<OpenInAppsAnswer>>();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The apps a route answers, fetched once per URL for the page. Only an
|
|
32
|
+
* answer the route gave is kept: a failed read or a non-OK status (a
|
|
33
|
+
* restarting server, a route this server lacks) is not, so the next caller
|
|
34
|
+
* asks again.
|
|
35
|
+
*/
|
|
36
|
+
export function loadOpenInApps(url: string): Promise<OpenInAppsAnswer> {
|
|
37
|
+
let load = loads.get(url);
|
|
38
|
+
if (!load) {
|
|
39
|
+
const unavailable = (): OpenInAppsAnswer => {
|
|
40
|
+
loads.delete(url);
|
|
41
|
+
return { available: false, apps: [] };
|
|
42
|
+
};
|
|
43
|
+
load = fetch(url)
|
|
44
|
+
.then(async (response) => {
|
|
45
|
+
if (!response.ok) return unavailable();
|
|
46
|
+
const data = (await response.json()) as Partial<OpenInAppsAnswer>;
|
|
47
|
+
const platform = data.platform === 'mac' || data.platform === 'win' || data.platform === 'linux' ? data.platform : undefined;
|
|
48
|
+
return { available: data.available === true, apps: Array.isArray(data.apps) ? data.apps : [], ...(platform ? { platform } : {}) };
|
|
49
|
+
})
|
|
50
|
+
.catch(unavailable);
|
|
51
|
+
loads.set(url, load);
|
|
52
|
+
}
|
|
53
|
+
return load;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The detected apps from `url`, or null while they load. `enabled: false`
|
|
58
|
+
* reads nothing (and answers null). A new `retryKey` asks again after a read
|
|
59
|
+
* that did not answer; a kept answer comes back at once.
|
|
60
|
+
*/
|
|
61
|
+
export function useOpenInApps(url: string, enabled = true, retryKey?: string): OpenInAppsAnswer | null {
|
|
62
|
+
const [answer, setAnswer] = useState<OpenInAppsAnswer | null>(null);
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!enabled) return;
|
|
65
|
+
let cancelled = false;
|
|
66
|
+
void loadOpenInApps(url).then((next) => {
|
|
67
|
+
if (!cancelled) setAnswer(next);
|
|
68
|
+
});
|
|
69
|
+
return () => {
|
|
70
|
+
cancelled = true;
|
|
71
|
+
};
|
|
72
|
+
}, [url, enabled, retryKey]);
|
|
73
|
+
return enabled ? answer : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The remembered app id (null when nothing was ever picked, so each host
|
|
78
|
+
* chooses its own first default) and the way to remember another (written
|
|
79
|
+
* to the shared cookie).
|
|
80
|
+
*/
|
|
81
|
+
export function useRememberedOpenInApp(): [string | null, (id: string) => void] {
|
|
82
|
+
const [id, setId] = useState<string | null>(() => getStoredOpenInApp());
|
|
83
|
+
const remember = (next: string) => {
|
|
84
|
+
setLastOpenInApp(next);
|
|
85
|
+
setId(next);
|
|
86
|
+
};
|
|
87
|
+
return [id, remember];
|
|
88
|
+
}
|
|
@@ -59,14 +59,18 @@ export interface UseSkillReferenceAutocompleteResult {
|
|
|
59
59
|
* active row and dismisses the menu when the user has engaged with it (a row
|
|
60
60
|
* is active or a query was typed); on a bare-trigger menu with no engagement
|
|
61
61
|
* it passes through, so Escape still closes the composer in one press.
|
|
62
|
+
* `bareEscapeDismisses` (default off) makes Escape close any open menu first,
|
|
63
|
+
* for a composer where Escape costs more than a press (the Inbox's compose
|
|
64
|
+
* dialog asks whether to keep a draft).
|
|
62
65
|
*/
|
|
63
66
|
export function useSkillReferenceAutocomplete(options: {
|
|
64
67
|
text: string;
|
|
65
68
|
setText: (text: string) => void;
|
|
66
69
|
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
|
67
70
|
enabled: boolean;
|
|
71
|
+
bareEscapeDismisses?: boolean;
|
|
68
72
|
}): UseSkillReferenceAutocompleteResult {
|
|
69
|
-
const { text, setText, textareaRef, enabled } = options;
|
|
73
|
+
const { text, setText, textareaRef, enabled, bareEscapeDismisses = false } = options;
|
|
70
74
|
// Seed from the memory cache only when the surface opted in — a disabled
|
|
71
75
|
// composer must stay inert even after another surface warmed the catalog.
|
|
72
76
|
const [catalog, setCatalog] = useState<SkillCatalogEntry[]>(() =>
|
|
@@ -205,7 +209,7 @@ export function useSkillReferenceAutocomplete(options: {
|
|
|
205
209
|
// Only consume Escape when the user engaged with the menu (typed a
|
|
206
210
|
// query or activated a row). A bare-trigger menu the user ignored
|
|
207
211
|
// must not cost an extra Escape on the way to closing the composer.
|
|
208
|
-
if (boundedActive === null && trigger.query.length === 0) return false;
|
|
212
|
+
if (!bareEscapeDismisses && boundedActive === null && trigger.query.length === 0) return false;
|
|
209
213
|
e.preventDefault();
|
|
210
214
|
e.stopPropagation();
|
|
211
215
|
setActiveIndex(null);
|
|
@@ -215,7 +219,7 @@ export function useSkillReferenceAutocomplete(options: {
|
|
|
215
219
|
return false;
|
|
216
220
|
}
|
|
217
221
|
},
|
|
218
|
-
[boundedActive, items.length, open, select, trigger],
|
|
222
|
+
[bareEscapeDismisses, boundedActive, items.length, open, select, trigger],
|
|
219
223
|
);
|
|
220
224
|
|
|
221
225
|
const humanOnlyReferences = useMemo(
|
package/hooks/useUpdateCheck.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useState,
|
|
1
|
+
import { useState, useCallback, useMemo } from 'react';
|
|
2
2
|
import { getItem, setItem } from '../utils/storage';
|
|
3
|
+
import type { UpdateAction, UpdateAdvert } from '../utils/updateNotice';
|
|
3
4
|
|
|
4
5
|
declare const __APP_VERSION__: string;
|
|
5
6
|
|
|
@@ -12,120 +13,59 @@ export interface UpdateInfo {
|
|
|
12
13
|
currentVersion: string;
|
|
13
14
|
latestVersion: string;
|
|
14
15
|
updateAvailable: boolean;
|
|
16
|
+
/** The Options dot was seen for this version (opening the menu dismisses it). */
|
|
15
17
|
dismissed: boolean;
|
|
16
18
|
releaseUrl: string;
|
|
17
19
|
featureHighlight?: FeatureHighlight;
|
|
20
|
+
/** What updating means here: `script` (Update now), `copy`, `pi`, `opencode`. */
|
|
21
|
+
action?: UpdateAction;
|
|
22
|
+
/** The command for this host, with the remembered install flags. */
|
|
23
|
+
command?: string;
|
|
18
24
|
dismiss: () => void;
|
|
19
25
|
}
|
|
20
26
|
|
|
21
|
-
interface VersionCheckResult {
|
|
22
|
-
currentVersion: string;
|
|
23
|
-
latestVersion: string;
|
|
24
|
-
updateAvailable: boolean;
|
|
25
|
-
releaseUrl: string;
|
|
26
|
-
featureHighlight?: FeatureHighlight;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const GITHUB_API = 'https://api.github.com/repos/backnotprop/plannotator/releases/latest';
|
|
30
|
-
|
|
31
27
|
const DISMISSED_VERSION_KEY = 'update-dismissed-version';
|
|
32
28
|
|
|
33
|
-
// Feature highlights for milestone releases
|
|
34
|
-
const FEATURE_HIGHLIGHTS: Record<string, FeatureHighlight> = {
|
|
35
|
-
'0.5.0': {
|
|
36
|
-
title: 'Code Review is here!',
|
|
37
|
-
description: 'Review git diffs with inline annotations. Run /plannotator-review to try it.',
|
|
38
|
-
},
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
function compareVersions(current: string, latest: string): boolean {
|
|
42
|
-
const cleanVersion = (v: string) => v.replace(/^v/, '');
|
|
43
|
-
const currentParts = cleanVersion(current).split('.').map(Number);
|
|
44
|
-
const latestParts = cleanVersion(latest).split('.').map(Number);
|
|
45
|
-
|
|
46
|
-
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
|
|
47
|
-
const curr = currentParts[i] || 0;
|
|
48
|
-
const lat = latestParts[i] || 0;
|
|
49
|
-
if (lat > curr) return true;
|
|
50
|
-
if (lat < curr) return false;
|
|
51
|
-
}
|
|
52
|
-
return false;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
29
|
function isDismissedVersion(latestVersion: string): boolean {
|
|
56
30
|
const dismissed = getItem(DISMISSED_VERSION_KEY);
|
|
57
31
|
if (!dismissed) return false;
|
|
58
|
-
|
|
59
|
-
const cleanDismissed = dismissed.replace(/^v/, '');
|
|
60
|
-
return cleanLatest === cleanDismissed;
|
|
32
|
+
return latestVersion.replace(/^v/, '') === dismissed.replace(/^v/, '');
|
|
61
33
|
}
|
|
62
34
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
const currentVersion = typeof __APP_VERSION__ !== 'undefined'
|
|
78
|
-
? __APP_VERSION__
|
|
79
|
-
: '0.0.0';
|
|
80
|
-
|
|
81
|
-
// Debug: ?preview-update=0.5.0 simulates an update to that version
|
|
82
|
-
const urlParams = new URLSearchParams(window.location.search);
|
|
83
|
-
const previewVersion = urlParams.get('preview-update');
|
|
84
|
-
|
|
85
|
-
if (previewVersion) {
|
|
86
|
-
const cleanPreview = previewVersion.replace(/^v/, '');
|
|
87
|
-
setDismissed(isDismissedVersion(cleanPreview));
|
|
88
|
-
setCheckResult({
|
|
89
|
-
currentVersion,
|
|
90
|
-
latestVersion: previewVersion,
|
|
91
|
-
updateAvailable: true,
|
|
92
|
-
releaseUrl: `https://github.com/backnotprop/plannotator/releases/tag/v${cleanPreview}`,
|
|
93
|
-
featureHighlight: FEATURE_HIGHLIGHTS[cleanPreview],
|
|
94
|
-
});
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const response = await fetch(GITHUB_API);
|
|
99
|
-
if (!response.ok) return;
|
|
100
|
-
|
|
101
|
-
const release = await response.json();
|
|
102
|
-
const latestVersion = release.tag_name;
|
|
103
|
-
|
|
104
|
-
const updateAvailable = compareVersions(currentVersion, latestVersion);
|
|
105
|
-
|
|
106
|
-
const cleanLatest = latestVersion.replace(/^v/, '');
|
|
107
|
-
const featureHighlight = FEATURE_HIGHLIGHTS[cleanLatest];
|
|
108
|
-
|
|
109
|
-
setDismissed(isDismissedVersion(latestVersion));
|
|
110
|
-
setCheckResult({
|
|
111
|
-
currentVersion,
|
|
112
|
-
latestVersion,
|
|
113
|
-
updateAvailable,
|
|
114
|
-
releaseUrl: release.html_url,
|
|
115
|
-
featureHighlight,
|
|
116
|
-
});
|
|
117
|
-
} catch {
|
|
118
|
-
}
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
checkForUpdates();
|
|
35
|
+
/**
|
|
36
|
+
* The Options menu's update state, from the server's advert (`updateAvailable`
|
|
37
|
+
* on /api/plan and /api/diff). The browser never asks GitHub: the machine
|
|
38
|
+
* checks once a day (packages/shared/update-check.ts) and the server passes
|
|
39
|
+
* the answer on, or nothing when update checks are off, in a dev build, or on
|
|
40
|
+
* a surface with no server.
|
|
41
|
+
*
|
|
42
|
+
* `?preview-update=0.29.0` still simulates an update, for screenshots.
|
|
43
|
+
*/
|
|
44
|
+
export function useUpdateCheck(advert?: UpdateAdvert | null): UpdateInfo | null {
|
|
45
|
+
const preview = useMemo(() => {
|
|
46
|
+
if (typeof window === 'undefined') return null;
|
|
47
|
+
const version = new URLSearchParams(window.location.search).get('preview-update');
|
|
48
|
+
return version ? version.replace(/^v/, '') : null;
|
|
122
49
|
}, []);
|
|
50
|
+
const latestVersion = preview ?? advert?.version ?? null;
|
|
51
|
+
const [dismissedVersion, setDismissedVersion] = useState<string | null>(null);
|
|
123
52
|
|
|
124
|
-
|
|
53
|
+
const dismiss = useCallback(() => {
|
|
54
|
+
if (!latestVersion) return;
|
|
55
|
+
setItem(DISMISSED_VERSION_KEY, latestVersion);
|
|
56
|
+
setDismissedVersion(latestVersion);
|
|
57
|
+
}, [latestVersion]);
|
|
125
58
|
|
|
59
|
+
if (!latestVersion) return null;
|
|
60
|
+
const currentVersion = advert?.current ?? (typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0');
|
|
126
61
|
return {
|
|
127
|
-
|
|
128
|
-
|
|
62
|
+
currentVersion,
|
|
63
|
+
latestVersion,
|
|
64
|
+
updateAvailable: true,
|
|
65
|
+
dismissed: dismissedVersion === latestVersion || isDismissedVersion(latestVersion),
|
|
66
|
+
releaseUrl: advert?.releaseUrl ?? `https://github.com/backnotprop/plannotator/releases/tag/v${latestVersion}`,
|
|
67
|
+
action: advert?.action ?? 'script',
|
|
68
|
+
command: advert?.command ?? 'curl -fsSL https://plannotator.ai/install.sh | bash',
|
|
129
69
|
dismiss,
|
|
130
70
|
};
|
|
131
71
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The update-notice switch (PLANNOTATOR_UPDATE_CHECK / `updateCheck`) for
|
|
5
|
+
* Settings → General: the server's value from serverConfig, written back
|
|
6
|
+
* with POST /api/config. Deliberately not a settings-registry entry: the
|
|
7
|
+
* registry is bundled into the portable guides.show viewer, whose pinned
|
|
8
|
+
* build must not change for a Plannotator-only setting.
|
|
9
|
+
*/
|
|
10
|
+
export interface UpdateCheckSetting {
|
|
11
|
+
/** The effective value: the env var when it decides, else config.json (default true). */
|
|
12
|
+
on: boolean;
|
|
13
|
+
/** PLANNOTATOR_UPDATE_CHECK when it overrides the config file: the switch is locked. */
|
|
14
|
+
env?: boolean;
|
|
15
|
+
onChange: (next: boolean) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useUpdateCheckSetting(): {
|
|
19
|
+
setting: UpdateCheckSetting | undefined;
|
|
20
|
+
adopt: (serverConfig: Record<string, unknown> | undefined) => void;
|
|
21
|
+
} {
|
|
22
|
+
const [state, setState] = useState<{ config: boolean; env?: boolean } | undefined>();
|
|
23
|
+
|
|
24
|
+
const adopt = useCallback((serverConfig: Record<string, unknown> | undefined) => {
|
|
25
|
+
const config = serverConfig?.updateCheck;
|
|
26
|
+
const env = serverConfig?.updateCheckEnv;
|
|
27
|
+
setState(typeof config === 'boolean' ? { config, ...(typeof env === 'boolean' && { env }) } : undefined);
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
const onChange = useCallback((next: boolean) => {
|
|
31
|
+
setState((current) => (current ? { ...current, config: next } : current));
|
|
32
|
+
void fetch('/api/config', {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { 'Content-Type': 'application/json' },
|
|
35
|
+
body: JSON.stringify({ updateCheck: next }),
|
|
36
|
+
}).catch(() => {});
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
setting: state ? { on: state.env ?? state.config, ...(state.env !== undefined && { env: state.env }), onChange } : undefined,
|
|
41
|
+
adopt,
|
|
42
|
+
};
|
|
43
|
+
}
|