@plannotator/ui 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +6 -0
  2. package/components/AISettingsTab.tsx +5 -4
  3. package/components/ActionMenu.tsx +5 -1
  4. package/components/AgentsTab.tsx +10 -23
  5. package/components/AnnotationPanel.tsx +9 -5
  6. package/components/AnnotationToolbar.tsx +5 -13
  7. package/components/AnnotationToolstrip.tsx +2 -2
  8. package/components/ApproveDropdown.tsx +1 -1
  9. package/components/BlockRenderer.tsx +1 -1
  10. package/components/CodeFilePopout.tsx +5 -4
  11. package/components/CommentPopover.tsx +391 -65
  12. package/components/DocBadges.tsx +29 -11
  13. package/components/ExportModal.tsx +16 -8
  14. package/components/GraphvizBlock.tsx +1 -1
  15. package/components/InlineMarkdown.tsx +30 -13
  16. package/components/KeyboardShortcuts.tsx +37 -2
  17. package/components/Landing.tsx +7 -7
  18. package/components/MenuVersionSection.tsx +4 -4
  19. package/components/MermaidBlock.tsx +1 -1
  20. package/components/ModeToggle.tsx +7 -6
  21. package/components/OpenInAppButton.tsx +2 -5
  22. package/components/PinpointOverlay.tsx +9 -7
  23. package/components/PlanHeaderMenu.tsx +8 -8
  24. package/components/PopoutDialog.tsx +6 -1
  25. package/components/ResizeHandle.tsx +1 -0
  26. package/components/Settings.tsx +172 -12
  27. package/components/SkillReferenceMenu.tsx +260 -0
  28. package/components/StickyHeaderLane.tsx +7 -0
  29. package/components/ThemeProvider.tsx +131 -32
  30. package/components/ThemeTab.tsx +123 -77
  31. package/components/ToolbarButtons.tsx +29 -8
  32. package/components/Viewer.tsx +396 -130
  33. package/components/VimKeyHud.tsx +695 -0
  34. package/components/VimModeAnnouncementDialog.tsx +557 -0
  35. package/components/VimModeOverlay.tsx +235 -0
  36. package/components/VimTargetReticle.tsx +284 -0
  37. package/components/ai/DocumentAIChatPanel.tsx +1 -1
  38. package/components/blocks/CodeBlock.tsx +18 -18
  39. package/components/blocks/TablePopout.tsx +7 -8
  40. package/components/blocks/TableToolbar.tsx +7 -8
  41. package/components/goal-setup/GoalSetupSurface.tsx +16 -3
  42. package/components/html-viewer/HtmlViewer.tsx +450 -47
  43. package/components/html-viewer/annotationNumbering.ts +37 -0
  44. package/components/html-viewer/bridge-script.ts +4051 -298
  45. package/components/html-viewer/composerYield.ts +51 -0
  46. package/components/html-viewer/srcdoc.ts +18 -3
  47. package/components/html-viewer/useHtmlAnnotation.ts +457 -32
  48. package/components/icons/themeIcons.tsx +1 -1
  49. package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
  50. package/components/plan-diff/PlanDiffBadge.tsx +22 -1
  51. package/components/settings/HooksTab.tsx +12 -8
  52. package/components/sidebar/FileBrowser.tsx +4 -1
  53. package/components/themeModes.tsx +28 -0
  54. package/config/configStore.ts +76 -1
  55. package/config/settings.ts +152 -0
  56. package/configure.ts +9 -0
  57. package/globals.d.ts +7 -1
  58. package/hooks/useAIChat.ts +5 -2
  59. package/hooks/useAIProviderActivation.ts +47 -0
  60. package/hooks/useAIProviderConfig.ts +5 -1
  61. package/hooks/useAgentSettings.ts +64 -23
  62. package/hooks/useAgents.ts +4 -4
  63. package/hooks/useAnnotationHighlighter.ts +100 -3
  64. package/hooks/useArchive.ts +2 -1
  65. package/hooks/useFenceTheme.ts +17 -0
  66. package/hooks/useLinkedDoc.ts +68 -1
  67. package/hooks/usePinpoint.ts +76 -75
  68. package/hooks/usePlanDiff.ts +73 -2
  69. package/hooks/useSkillReferenceAutocomplete.ts +239 -0
  70. package/hooks/useUpdateCheck.ts +1 -2
  71. package/hooks/useVimDocumentFocus.ts +116 -0
  72. package/hooks/useVimSelection.ts +1063 -0
  73. package/package.json +4 -4
  74. package/print.css +14 -13
  75. package/shortcuts/core.ts +38 -13
  76. package/shortcuts/index.ts +10 -0
  77. package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
  78. package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
  79. package/shortcuts/runtime.ts +111 -12
  80. package/styles.css +1 -1
  81. package/theme.css +504 -0
  82. package/themes/colorblind.css +89 -0
  83. package/themes/plannotator.css +2 -2
  84. package/types.ts +93 -10
  85. package/utils/agentSwitch.ts +33 -7
  86. package/utils/blockTargeting.ts +462 -178
  87. package/utils/clipboard.ts +110 -0
  88. package/utils/codeBlockMark.ts +50 -0
  89. package/utils/codeHighlight.ts +293 -0
  90. package/utils/codexModels.ts +79 -0
  91. package/utils/domSelection.ts +84 -0
  92. package/utils/htmlChrome.ts +73 -0
  93. package/utils/inputMethod.ts +79 -6
  94. package/utils/parser.ts +517 -21
  95. package/utils/preferenceTtl.ts +15 -0
  96. package/utils/sharing.ts +0 -1
  97. package/utils/skillCatalog.ts +269 -0
  98. package/utils/skillReferences.ts +475 -0
  99. package/utils/syntaxTheme.ts +83 -0
  100. package/utils/themeRegistry.ts +154 -0
  101. package/utils/vimHud.ts +263 -0
  102. package/utils/vimModeAnnouncement.ts +23 -0
  103. package/utils/vimNavigation.ts +417 -0
  104. package/utils/vimReticle.ts +88 -0
  105. package/utils/vimScroll.ts +162 -0
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { getItem, setItem } from '../utils/storage';
3
+ import { clampCodexReasoning } from '../utils/codexModels';
3
4
 
4
5
  const COOKIE_KEY = 'plannotator.agents';
5
6
 
@@ -15,7 +16,7 @@ const COOKIE_KEY = 'plannotator.agents';
15
16
  // would clobber writes exactly like before.
16
17
  const settingsListeners = new Set<(s: AgentSettingsState) => void>();
17
18
 
18
- export const DEFAULT_CLAUDE_MODEL = 'claude-opus-4-7';
19
+ export const DEFAULT_CLAUDE_MODEL = 'claude-opus-5';
19
20
  export const DEFAULT_CLAUDE_EFFORT = 'high';
20
21
  // gpt-5.3-codex is deprecated (ChatGPT-account Codex rejects it outright) —
21
22
  // default to the current flagship everywhere.
@@ -176,7 +177,9 @@ const initialState: AgentSettingsState = {
176
177
 
177
178
  // One-shot migration: drop any cached "none" codex reasoning entries. The
178
179
  // dropdown no longer offers "None" (codex-rs rejects it as a config value);
179
- // fall back to the default instead of shipping an invalid flag.
180
+ // fall back to the default instead of shipping an invalid flag. Saved
181
+ // "minimal" entries migrate to "low": no current Codex model supports
182
+ // minimal, and low is the nearest effort that every model does.
180
183
  export function sanitizeCodexPerModel(
181
184
  perModel: Record<string, { reasoning: string; fast: boolean }> | undefined,
182
185
  ): Record<string, { reasoning: string; fast: boolean }> {
@@ -188,17 +191,50 @@ export function sanitizeCodexPerModel(
188
191
  if (entry.fast) out[model] = { reasoning: DEFAULT_CODEX_REASONING, fast: true };
189
192
  continue;
190
193
  }
194
+ if (entry.reasoning === 'minimal') {
195
+ out[model] = { ...entry, reasoning: 'low' };
196
+ continue;
197
+ }
191
198
  out[model] = entry;
192
199
  }
193
200
  return out;
194
201
  }
195
202
 
196
- // One-shot migration: gpt-5.3-codex is deprecated (ChatGPT-account Codex
197
- // rejects it with a 400), so a saved pick of it silently becomes the current
198
- // default rather than shipping a launch that can never succeed.
199
- function migrateCodexModel(value: unknown, fallback: string): string {
200
- if (typeof value !== 'string' || value === 'gpt-5.3-codex') return fallback;
201
- return value;
203
+ // Saved model IDs that migrate to a direct replacement: the stale gpt-5.6
204
+ // slug (renamed to -sol when the tiered family shipped) and gpt-5.1-codex-mini
205
+ // (API shutdown 2026-07-23; gpt-5.4-mini is OpenAI's recommended replacement).
206
+ const RENAMED_CODEX_MODELS: Record<string, string> = {
207
+ 'gpt-5.6': 'gpt-5.6-sol',
208
+ 'gpt-5.1-codex-mini': 'gpt-5.4-mini',
209
+ };
210
+
211
+ // Saved model IDs with no direct replacement — migrate to the surface's
212
+ // fallback. gpt-5.3-codex is rejected outright by ChatGPT-account Codex;
213
+ // gpt-5.2-codex and gpt-5.1-codex-max hit the API-level shutdown on
214
+ // 2026-07-23 (OpenAI recommends gpt-5.5, which every fallback already is).
215
+ const RETIRED_CODEX_MODELS = new Set(['gpt-5.3-codex', 'gpt-5.2-codex', 'gpt-5.1-codex-max']);
216
+
217
+ // One-shot model migrations for a saved Codex section. Keep its per-model
218
+ // preferences aligned with the canonical model ID while sanitizing them.
219
+ export function migrateCodexSection(
220
+ section: { model?: unknown; perModel?: Record<string, { reasoning: string; fast: boolean }> } | undefined,
221
+ fallback: string,
222
+ ): CodexSection {
223
+ const perModel = sanitizeCodexPerModel(section?.perModel);
224
+ for (const [legacy, replacement] of Object.entries(RENAMED_CODEX_MODELS)) {
225
+ const legacyPreference = perModel[legacy];
226
+ if (legacyPreference) {
227
+ perModel[replacement] ??= legacyPreference;
228
+ delete perModel[legacy];
229
+ }
230
+ }
231
+
232
+ const savedModel = section?.model;
233
+ const model =
234
+ typeof savedModel !== 'string' || RETIRED_CODEX_MODELS.has(savedModel)
235
+ ? fallback
236
+ : (RENAMED_CODEX_MODELS[savedModel] ?? savedModel);
237
+ return { model, perModel };
202
238
  }
203
239
 
204
240
  function parseEngine(value: unknown): AgentEngine {
@@ -248,10 +284,7 @@ function readCookie(): AgentSettingsState {
248
284
  model: typeof parsed.claude?.model === 'string' ? parsed.claude.model : DEFAULT_CLAUDE_MODEL,
249
285
  perModel: parsed.claude?.perModel ?? {},
250
286
  },
251
- codex: {
252
- model: migrateCodexModel(parsed.codex?.model, DEFAULT_CODEX_MODEL),
253
- perModel: sanitizeCodexPerModel(parsed.codex?.perModel),
254
- },
287
+ codex: migrateCodexSection(parsed.codex, DEFAULT_CODEX_MODEL),
255
288
  cursor: {
256
289
  model: typeof parsed.cursor?.model === 'string' ? parsed.cursor.model : DEFAULT_CURSOR_MODEL,
257
290
  },
@@ -269,18 +302,12 @@ function readCookie(): AgentSettingsState {
269
302
  model: typeof parsed.tourClaude?.model === 'string' ? parsed.tourClaude.model : DEFAULT_TOUR_CLAUDE_MODEL,
270
303
  perModel: parsed.tourClaude?.perModel ?? {},
271
304
  },
272
- tourCodex: {
273
- model: migrateCodexModel(parsed.tourCodex?.model, DEFAULT_TOUR_CODEX_MODEL),
274
- perModel: sanitizeCodexPerModel(parsed.tourCodex?.perModel),
275
- },
305
+ tourCodex: migrateCodexSection(parsed.tourCodex, DEFAULT_TOUR_CODEX_MODEL),
276
306
  guideClaude: {
277
307
  model: typeof parsed.guideClaude?.model === 'string' ? parsed.guideClaude.model : DEFAULT_GUIDE_CLAUDE_MODEL,
278
308
  perModel: parsed.guideClaude?.perModel ?? {},
279
309
  },
280
- guideCodex: {
281
- model: migrateCodexModel(parsed.guideCodex?.model, DEFAULT_GUIDE_CODEX_MODEL),
282
- perModel: sanitizeCodexPerModel(parsed.guideCodex?.perModel),
283
- },
310
+ guideCodex: migrateCodexSection(parsed.guideCodex, DEFAULT_GUIDE_CODEX_MODEL),
284
311
  guideCursor: {
285
312
  model: typeof parsed.guideCursor?.model === 'string' ? parsed.guideCursor.model : DEFAULT_GUIDE_CURSOR_MODEL,
286
313
  },
@@ -508,13 +535,27 @@ export function useAgentSettings() {
508
535
  }, []);
509
536
 
510
537
  const claudeEffort = state.claude.perModel[state.claude.model]?.effort ?? DEFAULT_CLAUDE_EFFORT;
511
- const codexReasoning = state.codex.perModel[state.codex.model]?.reasoning ?? DEFAULT_CODEX_REASONING;
538
+ // Codex reasoning is clamped through the model's supported-effort set: a
539
+ // saved (or surface-default) effort the selected model doesn't support
540
+ // snaps to that model's catalog default. Every consumer — the pickers AND
541
+ // the launch payloads — reads these derived values, so an unsupported
542
+ // effort can never reach `-c model_reasoning_effort=`.
543
+ const codexReasoning = clampCodexReasoning(
544
+ state.codex.model,
545
+ state.codex.perModel[state.codex.model]?.reasoning ?? DEFAULT_CODEX_REASONING,
546
+ );
512
547
  const codexFast = state.codex.perModel[state.codex.model]?.fast ?? DEFAULT_CODEX_FAST;
513
548
  const tourClaudeEffort = state.tourClaude.perModel[state.tourClaude.model]?.effort ?? DEFAULT_TOUR_CLAUDE_EFFORT;
514
- const tourCodexReasoning = state.tourCodex.perModel[state.tourCodex.model]?.reasoning ?? DEFAULT_TOUR_CODEX_REASONING;
549
+ const tourCodexReasoning = clampCodexReasoning(
550
+ state.tourCodex.model,
551
+ state.tourCodex.perModel[state.tourCodex.model]?.reasoning ?? DEFAULT_TOUR_CODEX_REASONING,
552
+ );
515
553
  const tourCodexFast = state.tourCodex.perModel[state.tourCodex.model]?.fast ?? DEFAULT_TOUR_CODEX_FAST;
516
554
  const guideClaudeEffort = state.guideClaude.perModel[state.guideClaude.model]?.effort ?? DEFAULT_GUIDE_CLAUDE_EFFORT;
517
- const guideCodexReasoning = state.guideCodex.perModel[state.guideCodex.model]?.reasoning ?? DEFAULT_GUIDE_CODEX_REASONING;
555
+ const guideCodexReasoning = clampCodexReasoning(
556
+ state.guideCodex.model,
557
+ state.guideCodex.perModel[state.guideCodex.model]?.reasoning ?? DEFAULT_GUIDE_CODEX_REASONING,
558
+ );
518
559
 
519
560
  return {
520
561
  selectedMode: state.selectedMode,
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { useState, useEffect, useCallback } from 'react';
6
6
  import type { Origin } from '@plannotator/core/agents';
7
- import { getAgentSwitchSettings } from '../utils/agentSwitch';
7
+ import { getAgentSwitchSettings, type AgentSwitchSurface } from '../utils/agentSwitch';
8
8
 
9
9
  export interface Agent {
10
10
  id: string;
@@ -25,7 +25,7 @@ export interface UseAgentsResult {
25
25
  * Fetch available agents from OpenCode API
26
26
  * Only fetches when origin is 'opencode'
27
27
  */
28
- export function useAgents(origin: Origin | null): UseAgentsResult {
28
+ export function useAgents(origin: Origin | null, surface: AgentSwitchSurface = 'plan'): UseAgentsResult {
29
29
  const [agents, setAgents] = useState<Agent[]>([]);
30
30
  const [isLoading, setIsLoading] = useState(false);
31
31
 
@@ -56,7 +56,7 @@ export function useAgents(origin: Origin | null): UseAgentsResult {
56
56
  const getAgentWarning = useCallback((): string | null => {
57
57
  if (agents.length === 0) return null; // Can't validate without agents
58
58
 
59
- const settings = getAgentSwitchSettings();
59
+ const settings = getAgentSwitchSettings(surface);
60
60
 
61
61
  if (settings.switchTo === 'disabled') {
62
62
  return null;
@@ -79,7 +79,7 @@ export function useAgents(origin: Origin | null): UseAgentsResult {
79
79
  }
80
80
 
81
81
  return null;
82
- }, [agents, validateAgent]);
82
+ }, [agents, validateAgent, surface]);
83
83
 
84
84
  return {
85
85
  agents,
@@ -244,6 +244,7 @@ export interface UseAnnotationHighlighterOptions {
244
244
  onRestoreMismatch?: (annotation: Annotation, restoredText: string) => void;
245
245
  }
246
246
 
247
+ /** Annotation UI state and mutation commands owned by one rendered document. */
247
248
  export interface UseAnnotationHighlighterReturn {
248
249
  highlighterRef: RefObject<Highlighter | null>;
249
250
 
@@ -259,12 +260,22 @@ export interface UseAnnotationHighlighterReturn {
259
260
  handleCommentClose: () => void;
260
261
  handleFloatingQuickLabel: (label: QuickLabel) => void;
261
262
  handleQuickLabelPickerDismiss: () => void;
263
+ /** Paint a caller-created DOM range through the same pipeline as pointer selection. */
264
+ highlightRange: (range: Range, modeOverride?: EditorMode) => void;
265
+ /** Annotate one rendered formula through the same path as a pointer click. */
266
+ highlightMathElement: (element: HTMLElement, modeOverride?: EditorMode) => void;
262
267
 
263
268
  removeHighlight: (id: string) => void;
264
269
  clearAllHighlights: () => void;
265
270
  applyAnnotations: (annotations: Annotation[]) => void;
266
271
  }
267
272
 
273
+ /**
274
+ * Own pointer and caller-supplied range annotations for a Markdown container.
275
+ *
276
+ * Highlight restoration failures are reported through `onRestoreMismatch`
277
+ * when verification is enabled; malformed or stale ranges are ignored.
278
+ */
268
279
  export function useAnnotationHighlighter({
269
280
  containerRef,
270
281
  annotations,
@@ -283,6 +294,7 @@ export function useAnnotationHighlighter({
283
294
  const pendingSourceRef = useRef<any>(null);
284
295
  const pendingMathTargetsRef = useRef<MathAnnotationTarget[]>([]);
285
296
  const pendingMathElementRef = useRef<HTMLElement | null>(null);
297
+ const pendingModeOverrideRef = useRef<EditorMode | null>(null);
286
298
  const justCreatedIdRef = useRef<string | null>(null);
287
299
  const lastMousePosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
288
300
  const mouseDownMathRef = useRef<HTMLElement | null>(null);
@@ -834,10 +846,13 @@ export function useAnnotationHighlighter({
834
846
  setCommentPopover(null);
835
847
  setQuickLabelPicker(null);
836
848
 
837
- if (modeRef.current === 'redline') {
849
+ const effectiveMode = pendingModeOverrideRef.current ?? modeRef.current;
850
+ pendingModeOverrideRef.current = null;
851
+
852
+ if (effectiveMode === 'redline') {
838
853
  createAnnotationFromSource(highlighter, source, AnnotationType.DELETION);
839
854
  window.getSelection()?.removeAllRanges();
840
- } else if (modeRef.current === 'comment') {
855
+ } else if (effectiveMode === 'comment') {
841
856
  pendingSourceRef.current = source;
842
857
  setCommentPopover({
843
858
  anchorEl: doms[0] as HTMLElement,
@@ -845,7 +860,7 @@ export function useAnnotationHighlighter({
845
860
  selectedText: source.text,
846
861
  source,
847
862
  });
848
- } else if (modeRef.current === 'quickLabel') {
863
+ } else if (effectiveMode === 'quickLabel') {
849
864
  pendingSourceRef.current = source;
850
865
  setQuickLabelPicker({
851
866
  anchorEl: doms[0] as HTMLElement,
@@ -986,6 +1001,86 @@ export function useAnnotationHighlighter({
986
1001
  };
987
1002
  }, [clearPendingSelection, enabled]);
988
1003
 
1004
+ const highlightRange = useCallback((range: Range, modeOverride?: EditorMode) => {
1005
+ const highlighter = highlighterRef.current;
1006
+ const container = containerRef.current;
1007
+ if (!highlighter || !container || range.collapsed) return;
1008
+ if (!container.contains(range.commonAncestorContainer)) return;
1009
+
1010
+ const selection = window.getSelection();
1011
+ selection?.removeAllRanges();
1012
+ selection?.addRange(range.cloneRange());
1013
+ pendingModeOverrideRef.current = modeOverride ?? null;
1014
+
1015
+ try {
1016
+ highlighter.fromRange(range);
1017
+ } finally {
1018
+ pendingModeOverrideRef.current = null;
1019
+ selection?.removeAllRanges();
1020
+ }
1021
+ }, [containerRef]);
1022
+
1023
+ const highlightMathElement = useCallback((
1024
+ element: HTMLElement,
1025
+ modeOverride?: EditorMode,
1026
+ ) => {
1027
+ const container = containerRef.current;
1028
+ const mathElement = closestMathElement(element, container);
1029
+ if (!container || !mathElement) return;
1030
+
1031
+ const effectiveMode = modeOverride ?? modeRef.current;
1032
+ const existingId = mathElement.dataset.bindId;
1033
+ if (existingId && effectiveMode !== 'redline') {
1034
+ onSelectAnnotationRef.current?.(existingId);
1035
+ return;
1036
+ }
1037
+
1038
+ const source = mathSourceFromElement(mathElement);
1039
+ if (!source) return;
1040
+
1041
+ const highlighter = highlighterRef.current;
1042
+ if (pendingSourceRef.current && highlighter && !isMathAnnotationSource(pendingSourceRef.current)) {
1043
+ highlighter.remove(pendingSourceRef.current.id);
1044
+ }
1045
+ clearPendingSelection();
1046
+ setToolbarState(null);
1047
+ setCommentPopover(null);
1048
+ setQuickLabelPicker(null);
1049
+
1050
+ if (effectiveMode === 'redline') {
1051
+ createAnnotationFromMathSource(source, AnnotationType.DELETION);
1052
+ return;
1053
+ }
1054
+
1055
+ pendingSourceRef.current = source;
1056
+ showPendingMathPreview(source);
1057
+
1058
+ if (effectiveMode === 'comment') {
1059
+ setCommentPopover({
1060
+ anchorEl: source.element,
1061
+ contextText: source.text.slice(0, 80),
1062
+ selectedText: source.text,
1063
+ source,
1064
+ });
1065
+ return;
1066
+ }
1067
+
1068
+ if (effectiveMode === 'quickLabel') {
1069
+ setQuickLabelPicker({
1070
+ anchorEl: source.element,
1071
+ cursorHint: lastMousePosRef.current,
1072
+ source,
1073
+ });
1074
+ return;
1075
+ }
1076
+
1077
+ setToolbarState({
1078
+ element: source.element,
1079
+ source,
1080
+ selectionText: source.text,
1081
+ });
1082
+ }, [clearPendingSelection, containerRef, showPendingMathPreview]);
1083
+
989
1084
  // Apply CSS classes to existing annotations
990
1085
  useEffect(() => {
991
1086
  const highlighter = highlighterRef.current;
@@ -1201,6 +1296,8 @@ export function useAnnotationHighlighter({
1201
1296
  handleCommentClose,
1202
1297
  handleFloatingQuickLabel,
1203
1298
  handleQuickLabelPickerDismiss,
1299
+ highlightRange,
1300
+ highlightMathElement,
1204
1301
  removeHighlight,
1205
1302
  clearAllHighlights,
1206
1303
  applyAnnotations: applyAnnotationsInternal,
@@ -10,6 +10,7 @@ import type { ArchivedPlan } from "@plannotator/core/storage-types";
10
10
  import type { UseLinkedDocReturn } from "./useLinkedDoc";
11
11
  import type { ViewerHandle } from "../components/Viewer";
12
12
  import type { Annotation } from "../types";
13
+ import { copyTextToClipboard } from "../utils/clipboard";
13
14
  import { getPlanSaveSettings } from "../utils/planSave";
14
15
 
15
16
  export interface UseArchiveOptions {
@@ -148,7 +149,7 @@ export function useArchive(options: UseArchiveOptions): UseArchiveReturn {
148
149
  }, [setSubmitted]);
149
150
 
150
151
  const copy = useCallback(() => {
151
- navigator.clipboard.writeText(markdown);
152
+ void copyTextToClipboard(markdown);
152
153
  }, [markdown]);
153
154
 
154
155
  const clearSelection = useCallback(() => {
@@ -0,0 +1,17 @@
1
+ import { useTheme } from '../components/ThemeProvider';
2
+ import { resolveFenceTheme } from '../utils/syntaxTheme';
3
+
4
+ /**
5
+ * The Shiki theme name that code snippets should render in right now.
6
+ *
7
+ * Same (colorTheme, mode) resolution the code-review diff pane uses, so fences,
8
+ * suggestion cards and diff hunks all agree. Re-renders on palette or mode
9
+ * change, which is what drives the re-highlight in the components below.
10
+ *
11
+ * `ThemeProvider`'s default context supplies the Plannotator palette in dark
12
+ * mode, so this is safe to call outside a provider.
13
+ */
14
+ export function useFenceTheme(): string {
15
+ const { colorTheme, resolvedMode } = useTheme();
16
+ return resolveFenceTheme(colorTheme, resolvedMode ?? 'dark');
17
+ }
@@ -11,6 +11,7 @@ import type { Annotation, ImageAttachment } from "../types";
11
11
  import type { ViewerHandle } from "../components/Viewer";
12
12
  import type { SidebarTab } from "./useSidebar";
13
13
  import type { SourceSaveCapability } from "@plannotator/core/source-save";
14
+ import type { VersionInfo } from "./usePlanDiff";
14
15
 
15
16
  export interface LinkedDocLoadData {
16
17
  markdown?: string;
@@ -20,6 +21,43 @@ export interface LinkedDocLoadData {
20
21
  rawHtml?: string;
21
22
  shareHtml?: string;
22
23
  sourceSave?: SourceSaveCapability;
24
+ /**
25
+ * Per-file version-diff baseline (annotate folder sessions only) — the same
26
+ * field names/shapes /api/plan already returns for single-file sessions.
27
+ * /api/doc only populates these for eligible folder files (local annotatable
28
+ * plain-text files — .md/.mdx/.txt plus the config set, per
29
+ * `isAnnotatableTextPath` — markdown-branch, not converted/HTML); every other
30
+ * document — including
31
+ * plain linked docs outside folder mode — omits them, which is what keeps
32
+ * the diff badge/version browser from appearing for those.
33
+ */
34
+ previousPlan?: string | null;
35
+ versionInfo?: VersionInfo | null;
36
+ }
37
+
38
+ /**
39
+ * Resolve the version-diff baseline for a document being activated: prefer
40
+ * an already-cached baseline (set the first time this document was opened)
41
+ * over the freshly-fetched `data`, so re-opening a document during a session
42
+ * never loses — or needs to re-request — its baseline. Pure so it's testable
43
+ * without mounting the hook; used by activateDocument below.
44
+ *
45
+ * Gated on whether a baseline was EVER captured for this document (versionInfo
46
+ * presence), not on truthiness of the cached previousPlan value — a document
47
+ * at its first-ever version legitimately caches `previousPlan: null` ("no
48
+ * earlier version to diff against yet"), which is a resolved fact, not a
49
+ * cache miss. Falling through to `data` on a plain `??` would silently
50
+ * discard that fact whenever `data` lacks the fields too (e.g. the
51
+ * missing-on-disk openLoaded path, which never fetches /api/doc).
52
+ */
53
+ export function resolveDiffBaseline(
54
+ cached: Pick<CachedDocState, 'previousPlan' | 'versionInfo'> | undefined,
55
+ data: Pick<LinkedDocLoadData, 'previousPlan' | 'versionInfo'>,
56
+ ): { previousPlan: string | null | undefined; versionInfo: VersionInfo | null | undefined } {
57
+ if (cached && cached.versionInfo !== undefined) {
58
+ return { previousPlan: cached.previousPlan, versionInfo: cached.versionInfo };
59
+ }
60
+ return { previousPlan: data.previousPlan, versionInfo: data.versionInfo };
23
61
  }
24
62
 
25
63
  export interface UseLinkedDocOptions {
@@ -73,6 +111,12 @@ export interface CachedDocState {
73
111
  globalAttachments: ImageAttachment[];
74
112
  markdown?: string;
75
113
  isConverted?: boolean;
114
+ /** Version-diff baseline captured the first time this document was
115
+ * activated — see LinkedDocLoadData. Persisted here so re-opening the
116
+ * same document later in the session reuses it instead of losing it (or
117
+ * needing to re-fetch it) once the fresh /api/doc data is gone. */
118
+ previousPlan?: string | null;
119
+ versionInfo?: VersionInfo | null;
76
120
  }
77
121
 
78
122
  export interface LinkedDocSessionState {
@@ -109,6 +153,12 @@ export interface UseLinkedDocReturn {
109
153
  restoreSession: (state: LinkedDocSessionState) => void;
110
154
  /** Reactive count of annotations on non-active documents (updates on open() and back()) */
111
155
  docAnnotationCount: number;
156
+ /** Active document's version-diff baseline (folder annotate only). Null
157
+ * when no document is active, or the active document has no eligible
158
+ * history — including every non-folder linked doc, since /api/doc never
159
+ * populates these fields for those. */
160
+ diffPreviousPlan: string | null;
161
+ diffVersionInfo: VersionInfo | null;
112
162
  }
113
163
 
114
164
  const HIGHLIGHT_REAPPLY_DELAY = 100;
@@ -139,7 +189,13 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
139
189
  onAfterBack,
140
190
  } = options;
141
191
 
142
- const [linkedDoc, setLinkedDoc] = useState<{ filepath: string; isConverted?: boolean; markdown?: string } | null>(null);
192
+ const [linkedDoc, setLinkedDoc] = useState<{
193
+ filepath: string;
194
+ isConverted?: boolean;
195
+ markdown?: string;
196
+ previousPlan?: string | null;
197
+ versionInfo?: VersionInfo | null;
198
+ } | null>(null);
143
199
  const [error, setError] = useState<string | null>(null);
144
200
  const [isLoading, setIsLoading] = useState(false);
145
201
  const [docAnnotationCount, setDocAnnotationCount] = useState(0);
@@ -169,6 +225,8 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
169
225
  globalAttachments: [...globalAttachments],
170
226
  markdown: getDocumentMarkdown?.(linkedDoc.filepath, linkedDoc.markdown) ?? linkedDoc.markdown,
171
227
  isConverted: linkedDoc.isConverted,
228
+ previousPlan: linkedDoc.previousPlan,
229
+ versionInfo: linkedDoc.versionInfo,
172
230
  });
173
231
  // Update reactive count so button labels can respond
174
232
  let total = 0;
@@ -264,6 +322,8 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
264
322
  globalAttachments: [...globalAttachments],
265
323
  markdown: getDocumentMarkdown?.(linkedDoc.filepath, linkedDoc.markdown) ?? linkedDoc.markdown,
266
324
  isConverted: linkedDoc.isConverted,
325
+ previousPlan: linkedDoc.previousPlan,
326
+ versionInfo: linkedDoc.versionInfo,
267
327
  });
268
328
  let total = 0;
269
329
  for (const [fp, cached] of docCache.current.entries()) {
@@ -294,10 +354,13 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
294
354
  setAnnotations(cached?.annotations ?? []);
295
355
  setGlobalAttachments(cached?.globalAttachments ?? []);
296
356
  setSelectedAnnotationId(null);
357
+ const diffBaseline = resolveDiffBaseline(cached, data);
297
358
  setLinkedDoc({
298
359
  filepath: data.filepath,
299
360
  isConverted: !!data.isConverted,
300
361
  markdown: nextMarkdown,
362
+ previousPlan: diffBaseline.previousPlan,
363
+ versionInfo: diffBaseline.versionInfo,
301
364
  });
302
365
  setError(null);
303
366
  sidebar.open(targetTab ?? "toc");
@@ -391,6 +454,8 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
391
454
  globalAttachments: [...globalAttachments],
392
455
  markdown: getDocumentMarkdown?.(linkedDoc.filepath, linkedDoc.markdown) ?? linkedDoc.markdown,
393
456
  isConverted: linkedDoc.isConverted,
457
+ previousPlan: linkedDoc.previousPlan,
458
+ versionInfo: linkedDoc.versionInfo,
394
459
  });
395
460
  }
396
461
 
@@ -490,5 +555,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
490
555
  snapshotSession,
491
556
  restoreSession,
492
557
  docAnnotationCount,
558
+ diffPreviousPlan: linkedDoc?.previousPlan ?? null,
559
+ diffVersionInfo: linkedDoc?.versionInfo ?? null,
493
560
  };
494
561
  }