@plannotator/ui 0.52.1 → 0.53.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/HANDOFF.md +284 -29
  2. package/README.md +45 -5
  3. package/components/AnnotationPanel.tsx +195 -118
  4. package/components/AnnotationToolbar.tsx +17 -14
  5. package/components/AnnotationToolstrip.tsx +54 -40
  6. package/components/BlockRenderer.tsx +20 -14
  7. package/components/CodeFilePopout.tsx +132 -69
  8. package/components/CommentPopover.tsx +8 -349
  9. package/components/ComposerTokenTextarea.tsx +379 -0
  10. package/components/ExportModal.tsx +6 -1
  11. package/components/HostTitle.tsx +108 -0
  12. package/components/HtmlSurfaceControls.tsx +2 -1
  13. package/components/ImageLightbox.tsx +288 -17
  14. package/components/ImagePeek.tsx +255 -0
  15. package/components/ImageResolverContext.tsx +31 -0
  16. package/components/InlineMarkdown.tsx +123 -15
  17. package/components/LocalLinkContext.tsx +49 -0
  18. package/components/MentionPicker.tsx +51 -0
  19. package/components/MenuVersionSection.tsx +51 -25
  20. package/components/OpenInAppButton.tsx +18 -49
  21. package/components/PlanHeaderMenu.tsx +2 -8
  22. package/components/ProviderIcons.tsx +10 -4
  23. package/components/RenderedMarkdown.tsx +40 -4
  24. package/components/Settings.tsx +33 -5
  25. package/components/SkillReferenceMenu.tsx +67 -18
  26. package/components/Tooltip.tsx +51 -4
  27. package/components/Viewer.tsx +220 -49
  28. package/components/ai/AIProviderBar.tsx +11 -0
  29. package/components/ai/AskSessionSetupHint.tsx +12 -0
  30. package/components/ai/DocumentAIChatPanel.tsx +17 -5
  31. package/components/ai/SessionAskNotice.tsx +4 -3
  32. package/components/blocks/BlockquoteBlock.tsx +159 -0
  33. package/components/blocks/HtmlBlock.tsx +121 -9
  34. package/components/blocks/QuestionBlock.tsx +183 -96
  35. package/components/blocks/QuestionContext.tsx +3 -0
  36. package/components/diagram/DiagramViewer.tsx +41 -4
  37. package/components/diagram/index.ts +1 -1
  38. package/components/html-viewer/HtmlViewer.tsx +95 -3
  39. package/components/html-viewer/bridge-script.asset.js +413 -7
  40. package/components/html-viewer/bridge-script.lite.ts +1 -1
  41. package/components/html-viewer/bridge-script.ts +454 -7
  42. package/components/html-viewer/useHtmlAnnotation.ts +51 -3
  43. package/components/icons/AgentIcons.tsx +9 -7
  44. package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
  45. package/components/plan-diff/PlanDiffViewer.tsx +6 -1
  46. package/configure.ts +27 -1
  47. package/hooks/useAgentJobs.ts +8 -3
  48. package/hooks/useAnnotationHighlighter.ts +128 -23
  49. package/hooks/useExternalAnnotations.ts +6 -0
  50. package/hooks/useMentionAutocomplete.ts +1 -1
  51. package/hooks/useOpenInApps.ts +88 -0
  52. package/hooks/useSkillReferenceAutocomplete.ts +7 -3
  53. package/hooks/useUpdateCheck.ts +38 -98
  54. package/hooks/useUpdateCheckSetting.ts +43 -0
  55. package/hooks/useUpdateToast.ts +101 -0
  56. package/hooks/useVimSelection.ts +9 -0
  57. package/package.json +2 -2
  58. package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
  59. package/shortcuts/index.ts +1 -0
  60. package/shortcuts/runtime.ts +10 -3
  61. package/styles.css +1 -1
  62. package/types.ts +48 -0
  63. package/utils/agentClosed.ts +26 -0
  64. package/utils/agentToolSetting.ts +16 -9
  65. package/utils/aiProvider.ts +7 -0
  66. package/utils/askSessionSetupHint.ts +27 -0
  67. package/utils/blockTargeting.ts +14 -3
  68. package/utils/mentions.ts +34 -5
  69. package/utils/parser.ts +65 -51
  70. package/utils/pasteInsert.ts +234 -0
  71. package/utils/planDiffEngine.ts +272 -2
  72. package/utils/skillCatalog.ts +15 -2
  73. package/utils/skillReferences.ts +50 -13
  74. package/utils/storage.ts +5 -0
  75. package/utils/textCut.ts +41 -0
  76. package/utils/updateNotice.ts +120 -0
@@ -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
- const createAnnotationFromSource = (
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
- highlighter.run();
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
- selectionTimer = setTimeout(() => {
1684
- const sel = window.getSelection();
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(
@@ -1,5 +1,6 @@
1
- import { useState, useEffect, useCallback } from 'react';
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
- const cleanLatest = latestVersion.replace(/^v/, '');
59
- const cleanDismissed = dismissed.replace(/^v/, '');
60
- return cleanLatest === cleanDismissed;
32
+ return latestVersion.replace(/^v/, '') === dismissed.replace(/^v/, '');
61
33
  }
62
34
 
63
- export function useUpdateCheck(): UpdateInfo | null {
64
- const [checkResult, setCheckResult] = useState<VersionCheckResult | null>(null);
65
- const [dismissed, setDismissed] = useState(false);
66
-
67
- const dismiss = useCallback(() => {
68
- if (!checkResult?.latestVersion) return;
69
- const clean = checkResult.latestVersion.replace(/^v/, '');
70
- setItem(DISMISSED_VERSION_KEY, clean);
71
- setDismissed(true);
72
- }, [checkResult?.latestVersion]);
73
-
74
- useEffect(() => {
75
- const checkForUpdates = async () => {
76
- try {
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
- if (!checkResult) return null;
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
- ...checkResult,
128
- dismissed,
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
+ }