@plannotator/ui 0.22.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 (305) hide show
  1. package/README.md +60 -0
  2. package/assets/diff-options.png +0 -0
  3. package/assets/icon-codex.png +0 -0
  4. package/assets/look-flat.png +0 -0
  5. package/assets/look-grid.png +0 -0
  6. package/assets/review-sections.png +0 -0
  7. package/assets/review-tree.png +0 -0
  8. package/assets/workspaces.webp +0 -0
  9. package/components/AISettingsTab.tsx +145 -0
  10. package/components/ActionMenu.tsx +101 -0
  11. package/components/AgentControls.tsx +216 -0
  12. package/components/AgentsTab.tsx +1294 -0
  13. package/components/AnnotationPanel.tsx +731 -0
  14. package/components/AnnotationSidebar.tsx +86 -0
  15. package/components/AnnotationToolbar.tsx +323 -0
  16. package/components/AnnotationToolstrip.tsx +355 -0
  17. package/components/ApproveDropdown.tsx +170 -0
  18. package/components/AttachmentsButton.tsx +410 -0
  19. package/components/BlockRenderer.tsx +163 -0
  20. package/components/BorderTrail.tsx +38 -0
  21. package/components/CodeFilePicker.tsx +64 -0
  22. package/components/CodeFilePopout.tsx +602 -0
  23. package/components/CodePathValidationContext.tsx +14 -0
  24. package/components/CommentPopover.tsx +512 -0
  25. package/components/CompletionOverlay.tsx +99 -0
  26. package/components/ConfirmDialog.tsx +128 -0
  27. package/components/DocBadges.tsx +231 -0
  28. package/components/EditorAnnotationCard.tsx +65 -0
  29. package/components/ExportModal.tsx +565 -0
  30. package/components/FloatingQuickLabelPicker.tsx +144 -0
  31. package/components/GitHubIcon.tsx +16 -0
  32. package/components/GitLabIcon.tsx +30 -0
  33. package/components/GraphvizBlock.tsx +511 -0
  34. package/components/ImageAnnotator/Canvas.tsx +118 -0
  35. package/components/ImageAnnotator/Toolbar.tsx +218 -0
  36. package/components/ImageAnnotator/index.tsx +272 -0
  37. package/components/ImageAnnotator/types.ts +39 -0
  38. package/components/ImageAnnotator/utils.ts +147 -0
  39. package/components/ImageThumbnail.tsx +126 -0
  40. package/components/ImportModal.tsx +146 -0
  41. package/components/InlineMarkdown.tsx +1094 -0
  42. package/components/KeyboardShortcuts.tsx +208 -0
  43. package/components/Landing.tsx +496 -0
  44. package/components/ListItemBody.tsx +62 -0
  45. package/components/ListMarker.tsx +73 -0
  46. package/components/LookAndFeelAnnouncementDialog.tsx +230 -0
  47. package/components/MarkdownEditor.tsx +44 -0
  48. package/components/MenuVersionSection.tsx +91 -0
  49. package/components/MermaidBlock.tsx +579 -0
  50. package/components/ModeToggle.tsx +68 -0
  51. package/components/OpenInAppButton.tsx +299 -0
  52. package/components/OverlayScrollArea.tsx +82 -0
  53. package/components/PermissionModeSetup.tsx +92 -0
  54. package/components/PinpointOverlay.tsx +99 -0
  55. package/components/PlanAIAnnouncementDialog.tsx +187 -0
  56. package/components/PlanHeaderMenu.tsx +299 -0
  57. package/components/PopoutDialog.tsx +91 -0
  58. package/components/Popover.tsx +27 -0
  59. package/components/ProviderIcons.tsx +51 -0
  60. package/components/PullRequestIcon.tsx +12 -0
  61. package/components/QuickLabelDropdown.tsx +63 -0
  62. package/components/RenderedMarkdown.tsx +54 -0
  63. package/components/RepoIcon.tsx +12 -0
  64. package/components/ResizeHandle.tsx +86 -0
  65. package/components/ReviewAgentsIcon.tsx +18 -0
  66. package/components/SearchableSelect.tsx +157 -0
  67. package/components/Settings.tsx +2220 -0
  68. package/components/SparklesIcon.tsx +45 -0
  69. package/components/StickyHeaderLane.tsx +260 -0
  70. package/components/TableOfContents.tsx +157 -0
  71. package/components/TaterSpritePullup.tsx +34 -0
  72. package/components/TaterSpriteRunning.tsx +54 -0
  73. package/components/TaterSpriteSitting.tsx +37 -0
  74. package/components/TextShimmer.tsx +57 -0
  75. package/components/ThemeProvider.tsx +162 -0
  76. package/components/ThemeTab.tsx +139 -0
  77. package/components/ToolbarButtons.tsx +116 -0
  78. package/components/Tooltip.tsx +45 -0
  79. package/components/Viewer.tsx +977 -0
  80. package/components/ai/AIProviderBar.tsx +95 -0
  81. package/components/ai/DocumentAIChatPanel.tsx +313 -0
  82. package/components/blocks/AlertBlock.tsx +58 -0
  83. package/components/blocks/Callout.tsx +60 -0
  84. package/components/blocks/CodeBlock.tsx +75 -0
  85. package/components/blocks/HtmlBlock.tsx +122 -0
  86. package/components/blocks/MathBlock.tsx +36 -0
  87. package/components/blocks/TableBlock.tsx +146 -0
  88. package/components/blocks/TablePopout.tsx +279 -0
  89. package/components/blocks/TableToolbar.tsx +153 -0
  90. package/components/blocks/proseBody.tsx +102 -0
  91. package/components/core/button.tsx +44 -0
  92. package/components/core/textarea.tsx +26 -0
  93. package/components/diagramLanguages.ts +14 -0
  94. package/components/goal-setup/GoalSetupSurface.tsx +1354 -0
  95. package/components/html-viewer/HtmlViewer.tsx +386 -0
  96. package/components/html-viewer/bridge-script.ts +505 -0
  97. package/components/html-viewer/index.ts +1 -0
  98. package/components/html-viewer/useHtmlAnnotation.ts +400 -0
  99. package/components/icons/AgentIcons.tsx +66 -0
  100. package/components/icons/AppIcon.tsx +56 -0
  101. package/components/icons/MessagesIcon.tsx +11 -0
  102. package/components/icons/ObsidianIcons.tsx +208 -0
  103. package/components/icons/app/android-studio.svg +369 -0
  104. package/components/icons/app/antigravity.svg +97 -0
  105. package/components/icons/app/cursor.svg +16 -0
  106. package/components/icons/app/file-explorer.svg +20 -0
  107. package/components/icons/app/finder.png +0 -0
  108. package/components/icons/app/ghostty.svg +13 -0
  109. package/components/icons/app/iterm2.svg +13 -0
  110. package/components/icons/app/powershell.svg +14 -0
  111. package/components/icons/app/sublime-text.svg +17 -0
  112. package/components/icons/app/terminal.png +0 -0
  113. package/components/icons/app/textmate.png +0 -0
  114. package/components/icons/app/vscode.svg +39 -0
  115. package/components/icons/app/warp.png +0 -0
  116. package/components/icons/app/xcode.png +0 -0
  117. package/components/icons/app/zed-dark.svg +15 -0
  118. package/components/icons/app/zed.svg +15 -0
  119. package/components/icons/themeIcons.tsx +47 -0
  120. package/components/mermaidSvg.ts +33 -0
  121. package/components/plan-diff/PlanCleanDiffView.tsx +884 -0
  122. package/components/plan-diff/PlanDiffBadge.tsx +48 -0
  123. package/components/plan-diff/PlanDiffModeSwitcher.tsx +103 -0
  124. package/components/plan-diff/PlanDiffViewer.tsx +214 -0
  125. package/components/plan-diff/PlanRawDiffView.tsx +102 -0
  126. package/components/plan-diff/VSCodeIcon.tsx +132 -0
  127. package/components/settings/HooksTab.tsx +208 -0
  128. package/components/sidebar/ArchiveBrowser.tsx +98 -0
  129. package/components/sidebar/CountBadge.tsx +12 -0
  130. package/components/sidebar/FileBrowser.tsx +498 -0
  131. package/components/sidebar/MessagesBrowser.tsx +109 -0
  132. package/components/sidebar/SidebarContainer.tsx +353 -0
  133. package/components/sidebar/SidebarTabs.tsx +155 -0
  134. package/components/sidebar/VersionBrowser.tsx +141 -0
  135. package/components/types.d.ts +7 -0
  136. package/components/ui/badge.tsx +44 -0
  137. package/components/ui/button.tsx +83 -0
  138. package/components/ui/card.tsx +58 -0
  139. package/components/ui/dialog.tsx +106 -0
  140. package/components/ui/dropdown-menu.tsx +240 -0
  141. package/components/ui/state-pill.tsx +45 -0
  142. package/components/ui/tabs.tsx +46 -0
  143. package/components/ui/textarea.tsx +25 -0
  144. package/config/configStore.ts +214 -0
  145. package/config/index.ts +4 -0
  146. package/config/reviewView.ts +42 -0
  147. package/config/settings.ts +310 -0
  148. package/config/useConfig.ts +20 -0
  149. package/configure.ts +69 -0
  150. package/globals.d.ts +14 -0
  151. package/hooks/pfm/useCodeFilePopout.ts +111 -0
  152. package/hooks/useAIChat.ts +532 -0
  153. package/hooks/useAIProviderConfig.ts +115 -0
  154. package/hooks/useActiveSection.ts +78 -0
  155. package/hooks/useAgentJobs.ts +306 -0
  156. package/hooks/useAgentSettings.ts +579 -0
  157. package/hooks/useAgents.ts +90 -0
  158. package/hooks/useAnnotationDraft.ts +523 -0
  159. package/hooks/useAnnotationHighlighter.ts +1170 -0
  160. package/hooks/useArchive.ts +171 -0
  161. package/hooks/useAutoClose.ts +104 -0
  162. package/hooks/useCodeAnnotationDraft.ts +194 -0
  163. package/hooks/useCodeFilePopout.ts +1 -0
  164. package/hooks/useDismissOnOutsideAndEscape.ts +50 -0
  165. package/hooks/useDraggable.ts +108 -0
  166. package/hooks/useEditorAnnotations.ts +64 -0
  167. package/hooks/useExternalAnnotationHighlights.ts +105 -0
  168. package/hooks/useExternalAnnotations.ts +268 -0
  169. package/hooks/useFileBrowser.ts +402 -0
  170. package/hooks/useInputMethodSwitch.ts +89 -0
  171. package/hooks/useIsMobile.ts +17 -0
  172. package/hooks/useLinkedDoc.ts +494 -0
  173. package/hooks/useOverlayViewport.ts +36 -0
  174. package/hooks/usePinpoint.ts +182 -0
  175. package/hooks/usePlanDiff.ts +176 -0
  176. package/hooks/usePrintMode.ts +27 -0
  177. package/hooks/useResizablePanel.ts +173 -0
  178. package/hooks/useScrollViewport.ts +38 -0
  179. package/hooks/useSharing.ts +441 -0
  180. package/hooks/useSidebar.ts +55 -0
  181. package/hooks/useUpdateCheck.ts +132 -0
  182. package/hooks/useValidatedCodePaths.ts +94 -0
  183. package/icons/GitUser.tsx +17 -0
  184. package/lib/utils.ts +11 -0
  185. package/package.json +117 -0
  186. package/plannotator.webp +0 -0
  187. package/print.css +427 -0
  188. package/shortcuts/code-review/ai.shortcuts.ts +25 -0
  189. package/shortcuts/code-review/allFilesDiff.shortcuts.ts +46 -0
  190. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +30 -0
  191. package/shortcuts/code-review/fileTree.shortcuts.ts +35 -0
  192. package/shortcuts/code-review/prComments.shortcuts.ts +18 -0
  193. package/shortcuts/code-review/suggestionModal.shortcuts.ts +25 -0
  194. package/shortcuts/code-review/tourDialog.shortcuts.ts +18 -0
  195. package/shortcuts/core.ts +399 -0
  196. package/shortcuts/index.ts +21 -0
  197. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +25 -0
  198. package/shortcuts/plan-review/annotationToolbar.shortcuts.ts +39 -0
  199. package/shortcuts/plan-review/commentPopover.shortcuts.ts +21 -0
  200. package/shortcuts/plan-review/goalSetup.shortcuts.ts +10 -0
  201. package/shortcuts/plan-review/imageAnnotator.shortcuts.ts +42 -0
  202. package/shortcuts/plan-review/inputMethod.shortcuts.ts +30 -0
  203. package/shortcuts/plan-review/sidebar.shortcuts.ts +34 -0
  204. package/shortcuts/plan-review/viewer.shortcuts.ts +24 -0
  205. package/shortcuts/runtime.ts +259 -0
  206. package/sprite_package_additional/index.html +34 -0
  207. package/sprite_package_additional/sprite.png +0 -0
  208. package/sprite_package_new/index.html +34 -0
  209. package/sprite_package_new/sprite.png +0 -0
  210. package/sprite_package_pulluphang/index.html +34 -0
  211. package/sprite_package_pulluphang/sprite.png +0 -0
  212. package/styles.css +1 -0
  213. package/theme.css +893 -0
  214. package/themes/adwaita.css +62 -0
  215. package/themes/andromeeda.css +66 -0
  216. package/themes/aurora-x.css +66 -0
  217. package/themes/ayu-dark.css +66 -0
  218. package/themes/caffeine.css +113 -0
  219. package/themes/catppuccin.css +62 -0
  220. package/themes/claude-plus.css +60 -0
  221. package/themes/cursor-hc.css +34 -0
  222. package/themes/cursor-midnight.css +34 -0
  223. package/themes/cursor.css +62 -0
  224. package/themes/dark-plus.css +66 -0
  225. package/themes/doom-64.css +109 -0
  226. package/themes/dracula.css +33 -0
  227. package/themes/everforest-hard.css +62 -0
  228. package/themes/everforest-soft.css +62 -0
  229. package/themes/everforest.css +62 -0
  230. package/themes/github.css +66 -0
  231. package/themes/gruvbox.css +62 -0
  232. package/themes/houston.css +66 -0
  233. package/themes/kanagawa-dragon.css +34 -0
  234. package/themes/kanagawa-lotus.css +34 -0
  235. package/themes/kanagawa-wave.css +34 -0
  236. package/themes/laserwave.css +66 -0
  237. package/themes/material.css +62 -0
  238. package/themes/min.css +66 -0
  239. package/themes/monokai-pro.css +34 -0
  240. package/themes/neutral.css +59 -0
  241. package/themes/night-owl.css +66 -0
  242. package/themes/nord.css +66 -0
  243. package/themes/one-dark-pro.css +66 -0
  244. package/themes/one-light.css +66 -0
  245. package/themes/paulmillr.css +34 -0
  246. package/themes/plannotator.css +60 -0
  247. package/themes/plastic.css +66 -0
  248. package/themes/poimandres.css +66 -0
  249. package/themes/quantum-rose.css +109 -0
  250. package/themes/red.css +66 -0
  251. package/themes/rose-pine.css +62 -0
  252. package/themes/simple.css +124 -0
  253. package/themes/slack.css +66 -0
  254. package/themes/snazzy-light.css +66 -0
  255. package/themes/soft-pop.css +60 -0
  256. package/themes/solar-dusk.css +109 -0
  257. package/themes/solarized.css +66 -0
  258. package/themes/synthwave-84.css +34 -0
  259. package/themes/terminal.css +62 -0
  260. package/themes/tinacious.css +57 -0
  261. package/themes/tokyo-night.css +62 -0
  262. package/themes/vesper.css +62 -0
  263. package/themes/vitesse-black.css +66 -0
  264. package/themes/vitesse.css +62 -0
  265. package/types.ts +264 -0
  266. package/utils/agentSwitch.ts +71 -0
  267. package/utils/aiChatFormat.ts +33 -0
  268. package/utils/aiPrompt.ts +29 -0
  269. package/utils/aiProvider.ts +217 -0
  270. package/utils/anchors.ts +11 -0
  271. package/utils/annotateAgentTerminal.ts +29 -0
  272. package/utils/annotationHelpers.ts +101 -0
  273. package/utils/bear.ts +59 -0
  274. package/utils/blockTargeting.ts +240 -0
  275. package/utils/callback.ts +99 -0
  276. package/utils/commentContent.ts +8 -0
  277. package/utils/defaultNotesApp.ts +20 -0
  278. package/utils/diffFonts.ts +33 -0
  279. package/utils/editorMode.ts +31 -0
  280. package/utils/fileBrowser.ts +40 -0
  281. package/utils/generateId.ts +7 -0
  282. package/utils/generateIdentity.ts +29 -0
  283. package/utils/identity.ts +112 -0
  284. package/utils/inlineTransforms.ts +41 -0
  285. package/utils/inputMethod.ts +17 -0
  286. package/utils/lookAndFeelAnnouncement.ts +18 -0
  287. package/utils/obsidian.ts +201 -0
  288. package/utils/octarine.ts +53 -0
  289. package/utils/parser.ts +991 -0
  290. package/utils/permissionMode.ts +77 -0
  291. package/utils/planAIAnnouncement.ts +17 -0
  292. package/utils/planAgentInstructions.ts +137 -0
  293. package/utils/planDiffEngine.ts +590 -0
  294. package/utils/planSave.ts +49 -0
  295. package/utils/platform.ts +11 -0
  296. package/utils/quickLabels.ts +77 -0
  297. package/utils/reviewAgentInstructions.ts +182 -0
  298. package/utils/sanitizeHtml.ts +45 -0
  299. package/utils/sharing.ts +350 -0
  300. package/utils/slugify.ts +37 -0
  301. package/utils/storage.ts +141 -0
  302. package/utils/themeRegistry.ts +567 -0
  303. package/utils/uiPreferences.ts +34 -0
  304. package/utils/upload.ts +56 -0
  305. package/utils/wideMode.ts +48 -0
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Auto-save annotation drafts to the server.
3
+ *
4
+ * Stores full Annotation[] objects directly (preserving all fields
5
+ * including `source`, `id`, offsets, and meta). On mount, checks for
6
+ * an existing draft and exposes banner state for the UI to offer restoration.
7
+ *
8
+ * Direct edits persist alongside annotations: the host supplies a
9
+ * `getEditedMarkdown` getter (the live editor buffer or last committed edit,
10
+ * null when none) and calls `scheduleDraftSave()` on edit activity. The
11
+ * getter is read at save time, not reactively, so per-keystroke saves don't
12
+ * require pushing the full document through React state.
13
+ *
14
+ * Backward compatible: loads old tuple-serialized drafts via fromShareable().
15
+ */
16
+
17
+ import { useState, useEffect, useCallback, useRef } from 'react';
18
+ import type { SourceSaveCapability } from '@plannotator/core/source-save';
19
+ import type { Annotation, CodeAnnotation, ImageAttachment } from '../types';
20
+ import { fromShareable, parseShareableImages } from '../utils/sharing';
21
+ import type { ShareableAnnotation } from '../utils/sharing';
22
+
23
+ const DEBOUNCE_MS = 500;
24
+
25
+ /**
26
+ * Transport for persisting annotation/edit drafts. The default reproduces
27
+ * Plannotator's `/api/draft` server protocol verbatim. A host (e.g. Workspaces)
28
+ * may override it to persist drafts through its own backend.
29
+ *
30
+ * CONTRACT — a host overriding this MUST preserve the 3-party generation
31
+ * protocol or ghost drafts resurrect:
32
+ * - `save` must be best-effort on page close (the default uses `keepalive`
33
+ * with a retry-without-keepalive on failure, gated by a generation match).
34
+ * - `remove(generation)` is a generation-gated TOMBSTONE: the host's store
35
+ * must reject any later `save` whose `draftGeneration` is <= the deleted
36
+ * generation (delete-on-submit + tombstoning). The hook pre-increments
37
+ * `draftGeneration` and threads `getDraftGeneration()` out to the host,
38
+ * which sends it on approve/deny/feedback/exit so the server deletes the
39
+ * draft with the right generation. Drop this and a debounced save landing
40
+ * after submit re-creates a draft the server just deleted.
41
+ * - `load` returns the raw stored body (or null) plus the generation the
42
+ * store reports when there is NO draft (the default reads `draftGeneration`
43
+ * from the 404 body) so the client can resume past a tombstone.
44
+ */
45
+ export interface DraftTransport {
46
+ /** GET the draft. `data` is the raw stored body (null if none). `generation`
47
+ is the store's reported generation when there is no draft (null otherwise). */
48
+ load(): Promise<{ data: unknown | null; generation: number | null }>;
49
+ /** Persist the draft body. `keepalive` requests best-effort delivery on close. */
50
+ save(body: object, opts: { keepalive: boolean }): Promise<void>;
51
+ /** Generation-gated tombstone delete. */
52
+ remove(generation: number, opts: { keepalive: boolean }): Promise<void>;
53
+ }
54
+
55
+ /**
56
+ * Default transport — Plannotator's `/api/draft` fetches, moved verbatim.
57
+ * `save` rejects on failure (the keepalive retry stays in the hook so its
58
+ * generation-match gate is preserved); `remove` always resolves.
59
+ */
60
+ const defaultDraftTransport: DraftTransport = {
61
+ async load() {
62
+ const res = await fetch('/api/draft');
63
+ const data = (await res.json().catch(() => null)) as unknown;
64
+ if (!res.ok) {
65
+ const generation = readDraftGeneration(
66
+ (data as MissingDraftData | null)?.draftGeneration,
67
+ );
68
+ return { data: null, generation };
69
+ }
70
+ return { data, generation: null };
71
+ },
72
+ save(body, { keepalive }) {
73
+ const payload = JSON.stringify(body);
74
+ const headers = { 'Content-Type': 'application/json' };
75
+ return fetch('/api/draft', { method: 'POST', headers, body: payload, keepalive }).then(
76
+ () => {},
77
+ );
78
+ },
79
+ remove(generation, { keepalive }) {
80
+ return fetch(`/api/draft?generation=${generation}`, { method: 'DELETE', keepalive }).then(
81
+ () => {},
82
+ () => {},
83
+ );
84
+ },
85
+ };
86
+
87
+ let draftTransport: DraftTransport = defaultDraftTransport;
88
+
89
+ /** Read the active draft transport at call time (so a late override is honored). */
90
+ export function getDraftTransport(): DraftTransport {
91
+ return draftTransport;
92
+ }
93
+
94
+ /** Override the draft transport. Call once at app startup. */
95
+ export function setDraftTransport(t: DraftTransport): void {
96
+ draftTransport = t;
97
+ }
98
+
99
+ /** Reset to the default `/api/draft` transport. Mainly for tests. */
100
+ export function resetDraftTransport(): void {
101
+ draftTransport = defaultDraftTransport;
102
+ }
103
+
104
+ type DraftSourceSaveCapability = Extract<SourceSaveCapability, { enabled: true }>;
105
+
106
+ export interface DraftEditedDocument {
107
+ key: string;
108
+ sourceSave: DraftSourceSaveCapability;
109
+ sessionOpenText: string;
110
+ diskBaseline: string;
111
+ currentText: string;
112
+ savedChange?: DraftSavedFileChange;
113
+ }
114
+
115
+ export interface DraftSavedFileChange {
116
+ key: string;
117
+ path: string;
118
+ basename: string;
119
+ beforeText: string;
120
+ afterText: string;
121
+ beforeHash?: string;
122
+ afterHash?: string;
123
+ sourceSave: DraftSourceSaveCapability;
124
+ }
125
+
126
+ /** New format: full objects. */
127
+ interface DraftData {
128
+ annotations: Annotation[];
129
+ codeAnnotations?: CodeAnnotation[];
130
+ globalAttachments: ImageAttachment[];
131
+ /** Direct-edit document text. Present only when it differs from the
132
+ as-submitted baseline ('' is a real value: a committed emptied doc). */
133
+ editedMarkdown?: string;
134
+ /** Source-backed direct edits for folder/single-file annotate sessions. */
135
+ editedDocuments?: DraftEditedDocument[];
136
+ /** Source-backed edits that were already saved to disk but not sent yet. */
137
+ savedFileChanges?: DraftSavedFileChange[];
138
+ /** Client-side generation used to ignore stale saves after a draft delete. */
139
+ draftGeneration?: number;
140
+ ts: number;
141
+ }
142
+
143
+ interface MissingDraftData {
144
+ found?: false;
145
+ draftGeneration?: number;
146
+ }
147
+
148
+ /** Old format: compact tuples (for backward compat on load). */
149
+ interface LegacyDraftData {
150
+ a: ShareableAnnotation[];
151
+ g?: unknown[];
152
+ d?: (string | null)[];
153
+ ts: number;
154
+ }
155
+
156
+ function isLegacyDraft(data: unknown): data is LegacyDraftData {
157
+ return !!data && typeof data === 'object' && 'a' in data && Array.isArray((data as LegacyDraftData).a);
158
+ }
159
+
160
+ function parseDraftEditedDocument(value: unknown): DraftEditedDocument | null {
161
+ if (!value || typeof value !== 'object') return null;
162
+ const doc = value as Partial<DraftEditedDocument>;
163
+ const sourceSave = doc.sourceSave as Partial<DraftSourceSaveCapability> | undefined;
164
+ if (!(
165
+ typeof doc.key === 'string' &&
166
+ typeof doc.sessionOpenText === 'string' &&
167
+ typeof doc.diskBaseline === 'string' &&
168
+ typeof doc.currentText === 'string' &&
169
+ isDraftSourceSaveCapability(sourceSave)
170
+ )) {
171
+ return null;
172
+ }
173
+ const savedChange = parseDraftSavedFileChange(doc.savedChange, sourceSave);
174
+ return {
175
+ key: doc.key,
176
+ sourceSave,
177
+ sessionOpenText: doc.sessionOpenText,
178
+ diskBaseline: doc.diskBaseline,
179
+ currentText: doc.currentText,
180
+ ...(savedChange ? { savedChange } : {}),
181
+ };
182
+ }
183
+
184
+ function isDraftSavedFileChange(value: unknown): value is DraftSavedFileChange {
185
+ return parseDraftSavedFileChange(value) !== null;
186
+ }
187
+
188
+ function parseDraftSavedFileChange(
189
+ value: unknown,
190
+ fallbackSourceSave?: DraftSourceSaveCapability,
191
+ ): DraftSavedFileChange | null {
192
+ if (!value || typeof value !== 'object') return null;
193
+ const change = value as Partial<DraftSavedFileChange>;
194
+ if (!(
195
+ typeof change.key === 'string' &&
196
+ typeof change.path === 'string' &&
197
+ typeof change.basename === 'string' &&
198
+ typeof change.beforeText === 'string' &&
199
+ typeof change.afterText === 'string' &&
200
+ (change.beforeHash === undefined || typeof change.beforeHash === 'string') &&
201
+ (change.afterHash === undefined || typeof change.afterHash === 'string')
202
+ )) {
203
+ return null;
204
+ }
205
+ const sourceSave = isDraftSourceSaveCapability(change.sourceSave)
206
+ ? change.sourceSave
207
+ : fallbackSourceSave;
208
+ if (!sourceSave) return null;
209
+ return {
210
+ key: change.key,
211
+ path: change.path,
212
+ basename: change.basename,
213
+ beforeText: change.beforeText,
214
+ afterText: change.afterText,
215
+ beforeHash: change.beforeHash,
216
+ afterHash: change.afterHash,
217
+ sourceSave,
218
+ };
219
+ }
220
+
221
+ function isDraftSourceSaveCapability(value: unknown): value is DraftSourceSaveCapability {
222
+ const sourceSave = value as Partial<DraftSourceSaveCapability> | undefined;
223
+ return (
224
+ !!sourceSave &&
225
+ sourceSave.enabled === true &&
226
+ typeof sourceSave.path === 'string' &&
227
+ typeof sourceSave.basename === 'string' &&
228
+ typeof sourceSave.hash === 'string' &&
229
+ typeof sourceSave.mtimeMs === 'number' &&
230
+ typeof sourceSave.size === 'number' &&
231
+ typeof sourceSave.eol === 'string'
232
+ );
233
+ }
234
+
235
+ function readDraftGeneration(value: unknown): number | null {
236
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null;
237
+ }
238
+
239
+ function formatTimeAgo(ts: number): string {
240
+ const seconds = Math.floor((Date.now() - ts) / 1000);
241
+ if (seconds < 60) return 'just now';
242
+ const minutes = Math.floor(seconds / 60);
243
+ if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
244
+ const hours = Math.floor(minutes / 60);
245
+ if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
246
+ const days = Math.floor(hours / 24);
247
+ return `${days} day${days !== 1 ? 's' : ''} ago`;
248
+ }
249
+
250
+ interface UseAnnotationDraftOptions {
251
+ annotations: Annotation[];
252
+ codeAnnotations?: CodeAnnotation[];
253
+ globalAttachments: ImageAttachment[];
254
+ /** Current direct-edit text (live buffer or last commit), or null when the
255
+ document matches the as-submitted baseline. Read at save time. */
256
+ getEditedMarkdown?: () => string | null;
257
+ /** Current dirty source-backed documents. Read at save time. */
258
+ getEditedDocuments?: () => DraftEditedDocument[];
259
+ /** Current saved source-backed edits. Read at save time. */
260
+ getSavedFileChanges?: () => DraftSavedFileChange[];
261
+ isApiMode: boolean;
262
+ isSharedSession: boolean;
263
+ submitted: boolean;
264
+ }
265
+
266
+ interface RestoredDraft {
267
+ annotations: Annotation[];
268
+ codeAnnotations: CodeAnnotation[];
269
+ globalAttachments: ImageAttachment[];
270
+ editedMarkdown: string | null;
271
+ editedDocuments: DraftEditedDocument[];
272
+ savedFileChanges: DraftSavedFileChange[];
273
+ }
274
+
275
+ interface UseAnnotationDraftResult {
276
+ draftBanner: { count: number; timeAgo: string; hasEdits: boolean } | null;
277
+ restoreDraft: () => RestoredDraft;
278
+ /** Debounced save trigger for changes the reactive deps can't see
279
+ (editor keystrokes, edit commit/discard). Stable identity. */
280
+ scheduleDraftSave: () => void;
281
+ scheduleDraftSaveAfterSubmitFailure: () => void;
282
+ getDraftGeneration: () => number;
283
+ dismissDraft: () => void;
284
+ }
285
+
286
+ export function useAnnotationDraft({
287
+ annotations,
288
+ codeAnnotations = [],
289
+ globalAttachments,
290
+ getEditedMarkdown,
291
+ getEditedDocuments,
292
+ getSavedFileChanges,
293
+ isApiMode,
294
+ isSharedSession,
295
+ submitted,
296
+ }: UseAnnotationDraftOptions): UseAnnotationDraftResult {
297
+ const [draftBanner, setDraftBanner] = useState<{ count: number; timeAgo: string; hasEdits: boolean } | null>(null);
298
+ const draftDataRef = useRef<RestoredDraft | null>(null);
299
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
300
+ const hasMountedRef = useRef(false);
301
+ const draftGenerationRef = useRef(0);
302
+
303
+ // Latest-values ref so the stable scheduleDraftSave reads current data when
304
+ // the debounce fires, without re-creating callbacks per keystroke.
305
+ const latestRef = useRef({ annotations, codeAnnotations, globalAttachments, getEditedMarkdown, getEditedDocuments, getSavedFileChanges });
306
+ latestRef.current = { annotations, codeAnnotations, globalAttachments, getEditedMarkdown, getEditedDocuments, getSavedFileChanges };
307
+ const canPersist = isApiMode && !isSharedSession && !submitted;
308
+ const canPersistRef = useRef(canPersist);
309
+ canPersistRef.current = canPersist;
310
+
311
+ // Load draft on mount
312
+ useEffect(() => {
313
+ if (!isApiMode || isSharedSession) return;
314
+
315
+ getDraftTransport().load()
316
+ .then(({ data, generation }) => {
317
+ if (generation !== null) {
318
+ draftGenerationRef.current = Math.max(draftGenerationRef.current, generation);
319
+ }
320
+ return data as DraftData | LegacyDraftData | null;
321
+ })
322
+ .then((data: DraftData | LegacyDraftData | null) => {
323
+ if (!data) {
324
+ hasMountedRef.current = true;
325
+ return;
326
+ }
327
+
328
+ let restoredAnnotations: Annotation[];
329
+ let restoredCodeAnnotations: CodeAnnotation[] = [];
330
+ let restoredGlobal: ImageAttachment[];
331
+
332
+ if (isLegacyDraft(data)) {
333
+ // Old tuple format — deserialize via fromShareable
334
+ restoredAnnotations = data.a.length > 0 ? fromShareable(data.a, data.d) : [];
335
+ restoredGlobal = data.g ? (parseShareableImages(data.g as Parameters<typeof parseShareableImages>[0]) ?? []) : [];
336
+ } else if (Array.isArray(data.annotations)) {
337
+ // New direct-object format
338
+ const generation = readDraftGeneration(data.draftGeneration);
339
+ if (generation !== null) {
340
+ draftGenerationRef.current = Math.max(draftGenerationRef.current, generation);
341
+ }
342
+ restoredAnnotations = data.annotations;
343
+ restoredCodeAnnotations = Array.isArray(data.codeAnnotations) ? data.codeAnnotations : [];
344
+ restoredGlobal = Array.isArray(data.globalAttachments) ? data.globalAttachments : [];
345
+ } else if (Array.isArray((data as DraftData).codeAnnotations) && (data as DraftData).codeAnnotations!.length > 0) {
346
+ const generation = readDraftGeneration((data as DraftData).draftGeneration);
347
+ if (generation !== null) {
348
+ draftGenerationRef.current = Math.max(draftGenerationRef.current, generation);
349
+ }
350
+ restoredAnnotations = [];
351
+ restoredCodeAnnotations = (data as DraftData).codeAnnotations!;
352
+ restoredGlobal = Array.isArray((data as DraftData).globalAttachments) ? (data as DraftData).globalAttachments : [];
353
+ } else {
354
+ hasMountedRef.current = true;
355
+ return;
356
+ }
357
+
358
+ const restoredEdited =
359
+ !isLegacyDraft(data) && typeof (data as DraftData).editedMarkdown === 'string'
360
+ ? (data as DraftData).editedMarkdown!
361
+ : null;
362
+ const restoredEditedDocuments =
363
+ !isLegacyDraft(data) && Array.isArray((data as DraftData).editedDocuments)
364
+ ? (data as DraftData).editedDocuments!
365
+ .map(parseDraftEditedDocument)
366
+ .filter((doc): doc is DraftEditedDocument => doc !== null)
367
+ : [];
368
+ const restoredSavedFileChanges =
369
+ !isLegacyDraft(data) && Array.isArray((data as DraftData).savedFileChanges)
370
+ ? (data as DraftData).savedFileChanges!.filter(isDraftSavedFileChange)
371
+ : [];
372
+
373
+ const totalCount = restoredAnnotations.length + restoredCodeAnnotations.length + restoredGlobal.length;
374
+ if (totalCount > 0 || restoredEdited !== null || restoredEditedDocuments.length > 0 || restoredSavedFileChanges.length > 0) {
375
+ draftDataRef.current = {
376
+ annotations: restoredAnnotations,
377
+ codeAnnotations: restoredCodeAnnotations,
378
+ globalAttachments: restoredGlobal,
379
+ editedMarkdown: restoredEdited,
380
+ editedDocuments: restoredEditedDocuments,
381
+ savedFileChanges: restoredSavedFileChanges,
382
+ };
383
+ setDraftBanner({
384
+ count: totalCount,
385
+ timeAgo: formatTimeAgo(data.ts || 0),
386
+ hasEdits: restoredEdited !== null || restoredEditedDocuments.length > 0 || restoredSavedFileChanges.length > 0,
387
+ });
388
+ }
389
+ hasMountedRef.current = true;
390
+ })
391
+ .catch(() => {
392
+ hasMountedRef.current = true;
393
+ });
394
+ }, [isApiMode, isSharedSession]);
395
+
396
+ const persistNow = useCallback((keepalive: boolean) => {
397
+ // Re-check: the session may have been submitted while the debounce was
398
+ // pending — a save landing after submit would resurrect a draft the
399
+ // server just deleted, ghosting it into the next session for this plan.
400
+ if (!canPersistRef.current) return;
401
+ const { annotations, codeAnnotations, globalAttachments, getEditedMarkdown, getEditedDocuments, getSavedFileChanges } = latestRef.current;
402
+ const editedMarkdown = getEditedMarkdown?.() ?? null;
403
+ const editedDocuments = getEditedDocuments?.() ?? [];
404
+ const savedFileChanges = getSavedFileChanges?.() ?? [];
405
+
406
+ if (annotations.length === 0 && codeAnnotations.length === 0 && globalAttachments.length === 0 && editedMarkdown === null && editedDocuments.length === 0 && savedFileChanges.length === 0) {
407
+ // Everything was cleared (last annotation removed, edits discarded).
408
+ // A stale draft left on disk would offer back content the user
409
+ // explicitly threw away.
410
+ const deletedGeneration = draftGenerationRef.current + 1;
411
+ draftGenerationRef.current = deletedGeneration;
412
+ getDraftTransport().remove(deletedGeneration, { keepalive }).catch(() => {});
413
+ return;
414
+ }
415
+
416
+ const draftGeneration = draftGenerationRef.current + 1;
417
+ draftGenerationRef.current = draftGeneration;
418
+ const payload: DraftData = {
419
+ annotations,
420
+ codeAnnotations,
421
+ globalAttachments,
422
+ ...(editedMarkdown !== null ? { editedMarkdown } : {}),
423
+ ...(editedDocuments.length > 0 ? { editedDocuments } : {}),
424
+ ...(savedFileChanges.length > 0 ? { savedFileChanges } : {}),
425
+ draftGeneration,
426
+ ts: Date.now(),
427
+ };
428
+
429
+ // The transport moves the POST behind the seam; the keepalive retry-on-failure
430
+ // gate stays in the hook verbatim so a host transport that resolves/rejects on
431
+ // failure still won't resurrect a superseded save.
432
+ getDraftTransport().save(payload, { keepalive }).catch(() => {
433
+ // Chromium caps keepalive bodies (~64KB); retry without it. Completes
434
+ // fine when the page was only backgrounded, best-effort on close.
435
+ if (keepalive && canPersistRef.current && draftGenerationRef.current === draftGeneration) {
436
+ getDraftTransport().save(payload, { keepalive: false }).catch(() => {});
437
+ }
438
+ // Otherwise silent failure — draft is best-effort.
439
+ });
440
+ }, []);
441
+
442
+ const scheduleDraftSave = useCallback(() => {
443
+ if (!canPersistRef.current || !hasMountedRef.current) return;
444
+ if (timerRef.current) clearTimeout(timerRef.current);
445
+
446
+ timerRef.current = setTimeout(() => {
447
+ timerRef.current = null;
448
+ persistNow(false);
449
+ }, DEBOUNCE_MS);
450
+ }, [persistNow]);
451
+
452
+ const scheduleDraftSaveAfterSubmitFailure = useCallback(() => {
453
+ setTimeout(() => {
454
+ scheduleDraftSave();
455
+ }, 0);
456
+ }, [scheduleDraftSave]);
457
+
458
+ const getDraftGeneration = useCallback(() => draftGenerationRef.current + 1, []);
459
+
460
+ // Flush a pending save when the page is backgrounded or closed — otherwise
461
+ // the last debounce window of typing is lost on tab close, and reopening
462
+ // the (still-running) session would restore a draft missing those
463
+ // keystrokes. Only fires when a save is actually pending.
464
+ useEffect(() => {
465
+ const flush = () => {
466
+ if (timerRef.current === null) return;
467
+ clearTimeout(timerRef.current);
468
+ timerRef.current = null;
469
+ persistNow(true);
470
+ };
471
+ const onVisibility = () => {
472
+ if (document.visibilityState === 'hidden') flush();
473
+ };
474
+ document.addEventListener('visibilitychange', onVisibility);
475
+ window.addEventListener('pagehide', flush);
476
+ return () => {
477
+ document.removeEventListener('visibilitychange', onVisibility);
478
+ window.removeEventListener('pagehide', flush);
479
+ };
480
+ }, [persistNow]);
481
+
482
+ // Debounced auto-save on content changes. The submitted/isSubmitting gate is
483
+ // read inside the effect, but it is deliberately not a trigger: when a submit
484
+ // attempt finishes and flips the gate back open, we should not recreate a
485
+ // draft that was just deleted unless the user actually changes feedback again.
486
+ useEffect(() => {
487
+ if (!isApiMode || isSharedSession || submitted) return;
488
+ if (!hasMountedRef.current) return;
489
+ scheduleDraftSave();
490
+ }, [annotations, codeAnnotations, globalAttachments, isApiMode, isSharedSession, scheduleDraftSave]);
491
+
492
+ // Clear any pending save on unmount.
493
+ useEffect(() => {
494
+ return () => {
495
+ if (timerRef.current) clearTimeout(timerRef.current);
496
+ };
497
+ }, []);
498
+
499
+ const restoreDraft = useCallback((): RestoredDraft => {
500
+ const data = draftDataRef.current;
501
+ setDraftBanner(null);
502
+ draftDataRef.current = null;
503
+
504
+ if (!data) return { annotations: [], codeAnnotations: [], globalAttachments: [], editedMarkdown: null, editedDocuments: [], savedFileChanges: [] };
505
+
506
+ return data;
507
+ }, []);
508
+
509
+ const dismissDraft = useCallback(() => {
510
+ const deletedGeneration = draftGenerationRef.current + 1;
511
+ draftGenerationRef.current = deletedGeneration;
512
+ if (timerRef.current) {
513
+ clearTimeout(timerRef.current);
514
+ timerRef.current = null;
515
+ }
516
+ setDraftBanner(null);
517
+ draftDataRef.current = null;
518
+
519
+ getDraftTransport().remove(deletedGeneration, { keepalive: false }).catch(() => {});
520
+ }, []);
521
+
522
+ return { draftBanner, restoreDraft, scheduleDraftSave, scheduleDraftSaveAfterSubmitFailure, getDraftGeneration, dismissDraft };
523
+ }