@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,171 @@
1
+ /**
2
+ * Archive Hook
3
+ *
4
+ * Manages state and handlers for the archive browser — both standalone
5
+ * archive mode and in-session browsing via the sidebar tab.
6
+ */
7
+
8
+ import { useState, useRef, useMemo, useCallback } from "react";
9
+ import type { ArchivedPlan } from "@plannotator/core/storage-types";
10
+ import type { UseLinkedDocReturn } from "./useLinkedDoc";
11
+ import type { ViewerHandle } from "../components/Viewer";
12
+ import type { Annotation } from "../types";
13
+ import { getPlanSaveSettings } from "../utils/planSave";
14
+
15
+ export interface UseArchiveOptions {
16
+ markdown: string;
17
+ viewerRef: React.RefObject<ViewerHandle | null>;
18
+ linkedDocHook: UseLinkedDocReturn;
19
+ setMarkdown: (md: string) => void;
20
+ setAnnotations: (anns: Annotation[]) => void;
21
+ setSelectedAnnotationId: (id: string | null) => void;
22
+ setSubmitted: (s: "approved" | "denied" | null) => void;
23
+ }
24
+
25
+ export interface UseArchiveReturn {
26
+ /** Whether running in standalone archive mode */
27
+ archiveMode: boolean;
28
+ /** List of archived plans */
29
+ plans: ArchivedPlan[];
30
+ /** Currently selected archive filename */
31
+ selectedFile: string | null;
32
+ /** Whether the plan list is loading */
33
+ isLoading: boolean;
34
+ /** Info about the currently selected archive plan */
35
+ currentInfo: { status: ArchivedPlan["status"]; timestamp: string; title: string } | null;
36
+ /** Initialize archive state from /api/plan response */
37
+ init: (plans: ArchivedPlan[]) => void;
38
+ /** Select an archive plan to view */
39
+ select: (filename: string) => Promise<void>;
40
+ /** Lazy-fetch the plan list (for in-session mode) */
41
+ fetchPlans: () => Promise<void>;
42
+ /** Close the archive browser */
43
+ done: () => Promise<void>;
44
+ /** Copy plan content to clipboard (strips annotations) */
45
+ copy: () => void;
46
+ /** Clear the selected file (used by linked doc back) */
47
+ clearSelection: () => void;
48
+ }
49
+
50
+ export function useArchive(options: UseArchiveOptions): UseArchiveReturn {
51
+ const {
52
+ markdown,
53
+ viewerRef,
54
+ linkedDocHook,
55
+ setMarkdown,
56
+ setAnnotations,
57
+ setSelectedAnnotationId,
58
+ setSubmitted,
59
+ } = options;
60
+
61
+ const [archiveMode, setArchiveMode] = useState(false);
62
+ const [plans, setPlans] = useState<ArchivedPlan[]>([]);
63
+ const [selectedFile, setSelectedFile] = useState<string | null>(null);
64
+ const [isLoading, setIsLoading] = useState(false);
65
+ const hasFetched = useRef(false);
66
+
67
+ const customPath = useMemo(() => getPlanSaveSettings().customPath || undefined, []);
68
+
69
+ const currentInfo = useMemo(() => {
70
+ if (!selectedFile) return null;
71
+ const plan = plans.find(p => p.filename === selectedFile);
72
+ return plan ? { status: plan.status, timestamp: plan.timestamp, title: plan.title } : null;
73
+ }, [selectedFile, plans]);
74
+
75
+ const init = useCallback((archivePlans: ArchivedPlan[]) => {
76
+ setArchiveMode(true);
77
+ setPlans(archivePlans);
78
+ if (archivePlans.length > 0) {
79
+ setSelectedFile(archivePlans[0].filename);
80
+ }
81
+ }, []);
82
+
83
+ const select = useCallback(async (filename: string) => {
84
+ try {
85
+ const params = new URLSearchParams({ filename });
86
+ if (customPath) params.set("customPath", customPath);
87
+ const res = await fetch(`/api/archive/plan?${params}`);
88
+ if (!res.ok) return;
89
+ const data = await res.json() as { markdown: string; filepath: string };
90
+
91
+ if (archiveMode) {
92
+ // Standalone: direct swap
93
+ viewerRef.current?.clearAllHighlights();
94
+ setMarkdown(data.markdown);
95
+ setAnnotations([]);
96
+ setSelectedAnnotationId(null);
97
+ setSelectedFile(filename);
98
+ } else {
99
+ // In-session: use linked doc overlay
100
+ const buildUrl = (f: string) => {
101
+ const p = new URLSearchParams({ filename: f });
102
+ if (customPath) p.set("customPath", customPath);
103
+ return `/api/archive/plan?${p}`;
104
+ };
105
+ linkedDocHook.open(filename, buildUrl, "archive");
106
+ setSelectedFile(filename);
107
+ }
108
+ } catch { /* ignore */ }
109
+ }, [archiveMode, customPath, viewerRef, setMarkdown, setAnnotations, setSelectedAnnotationId, linkedDocHook]);
110
+
111
+ const fetchPlans = useCallback(async () => {
112
+ if (hasFetched.current || isLoading) return;
113
+ hasFetched.current = true;
114
+ setIsLoading(true);
115
+ try {
116
+ const params = new URLSearchParams();
117
+ if (customPath) params.set("customPath", customPath);
118
+ const qs = params.toString();
119
+ const res = await fetch(`/api/archive/plans${qs ? `?${qs}` : ""}`);
120
+ if (!res.ok) return;
121
+ const data = await res.json() as { plans: ArchivedPlan[] };
122
+ setPlans(data.plans);
123
+ // In standalone archive mode, auto-select and load the first plan
124
+ // so the viewer reflects the customPath results, not stale server data
125
+ if (archiveMode && data.plans.length > 0) {
126
+ const first = data.plans[0].filename;
127
+ setSelectedFile(first);
128
+ const fetchParams = new URLSearchParams({ filename: first });
129
+ if (customPath) fetchParams.set("customPath", customPath);
130
+ const planRes = await fetch(`/api/archive/plan?${fetchParams}`);
131
+ if (planRes.ok) {
132
+ const planData = await planRes.json() as { markdown: string };
133
+ setMarkdown(planData.markdown);
134
+ }
135
+ }
136
+ } catch {
137
+ hasFetched.current = false;
138
+ } finally {
139
+ setIsLoading(false);
140
+ }
141
+ }, [archiveMode, customPath, isLoading, setMarkdown]);
142
+
143
+ const done = useCallback(async () => {
144
+ try {
145
+ await fetch("/api/done", { method: "POST" });
146
+ setSubmitted("approved");
147
+ } catch { /* ignore */ }
148
+ }, [setSubmitted]);
149
+
150
+ const copy = useCallback(() => {
151
+ navigator.clipboard.writeText(markdown);
152
+ }, [markdown]);
153
+
154
+ const clearSelection = useCallback(() => {
155
+ setSelectedFile(null);
156
+ }, []);
157
+
158
+ return {
159
+ archiveMode,
160
+ plans,
161
+ selectedFile,
162
+ isLoading,
163
+ currentInfo,
164
+ init,
165
+ select,
166
+ fetchPlans,
167
+ done,
168
+ copy,
169
+ clearSelection,
170
+ };
171
+ }
@@ -0,0 +1,104 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { getAutoCloseDelay, setAutoCloseDelay } from '../utils/storage';
3
+
4
+ /**
5
+ * Phases of the auto-close lifecycle after a form submission.
6
+ *
7
+ * - idle: nothing submitted yet
8
+ * - counting: countdown is ticking (seconds remaining in `remaining`)
9
+ * - prompt: auto-close is disabled; offer the user a checkbox to opt in
10
+ * - closed: window.close() succeeded (terminal state)
11
+ * - closeFailed: window.close() was blocked by the browser
12
+ */
13
+ type AutoClosePhase =
14
+ | { phase: 'idle' }
15
+ | { phase: 'counting'; remaining: number }
16
+ | { phase: 'prompt' }
17
+ | { phase: 'closed' }
18
+ | { phase: 'closeFailed' };
19
+
20
+ interface UseAutoCloseReturn {
21
+ state: AutoClosePhase;
22
+ /** User opted in via the checkbox — persist "3s" and start countdown. */
23
+ enableAndStart: () => void;
24
+ }
25
+
26
+ type GlimpseWindow = Window & {
27
+ glimpse?: {
28
+ close?: () => void;
29
+ };
30
+ };
31
+
32
+ function requestGlimpseClose(): boolean {
33
+ const glimpseClose = (window as GlimpseWindow).glimpse?.close;
34
+ if (typeof glimpseClose === 'function') {
35
+ glimpseClose();
36
+ return true;
37
+ }
38
+
39
+ if (window.parent && window.parent !== window) {
40
+ window.parent.postMessage({ __plannotator_glimpse_close: true }, '*');
41
+ return true;
42
+ }
43
+
44
+ return false;
45
+ }
46
+
47
+ function tryClose(onFail: () => void): void {
48
+ const requestedNativeClose = requestGlimpseClose();
49
+ if (requestedNativeClose) {
50
+ return;
51
+ }
52
+
53
+ window.close();
54
+ // window.close() is silently ignored when the tab wasn't opened by script.
55
+ // Check after a short delay whether we're still alive.
56
+ setTimeout(() => {
57
+ if (!window.closed) onFail();
58
+ }, 300);
59
+ }
60
+
61
+ /**
62
+ * Manages the auto-close countdown that runs after a submission overlay appears.
63
+ *
64
+ * @param active - pass `true` once the overlay should appear (i.e. form was submitted)
65
+ */
66
+ export function useAutoClose(active: boolean): UseAutoCloseReturn {
67
+ const [state, setState] = useState<AutoClosePhase>({ phase: 'idle' });
68
+
69
+ // On activation, read persisted delay and transition to the right phase.
70
+ useEffect(() => {
71
+ if (!active) return;
72
+
73
+ const delay = getAutoCloseDelay();
74
+ if (delay === '0') {
75
+ tryClose(() => setState({ phase: 'closeFailed' }));
76
+ setState({ phase: 'closed' });
77
+ } else if (delay !== 'off') {
78
+ setState({ phase: 'counting', remaining: Number(delay) });
79
+ } else {
80
+ setState({ phase: 'prompt' });
81
+ }
82
+ }, [active]);
83
+
84
+ // Tick the countdown once per second.
85
+ useEffect(() => {
86
+ if (state.phase !== 'counting') return;
87
+ if (state.remaining <= 0) {
88
+ tryClose(() => setState({ phase: 'closeFailed' }));
89
+ return;
90
+ }
91
+ const timer = setTimeout(
92
+ () => setState((prev) => (prev.phase === 'counting' ? { phase: 'counting', remaining: prev.remaining - 1 } : prev)),
93
+ 1000,
94
+ );
95
+ return () => clearTimeout(timer);
96
+ }, [state]);
97
+
98
+ const enableAndStart = useCallback(() => {
99
+ setAutoCloseDelay('3');
100
+ setState({ phase: 'counting', remaining: 3 });
101
+ }, []);
102
+
103
+ return { state, enableAndStart };
104
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Auto-save code review annotation drafts to the server.
3
+ *
4
+ * Similar to useAnnotationDraft but stores CodeAnnotation[] directly
5
+ * (they're already compact — no tuple conversion needed).
6
+ */
7
+
8
+ import { useState, useEffect, useCallback, useRef } from 'react';
9
+ import type { CodeAnnotation, Annotation, CommentAnnotation } from '../types';
10
+ import { getDraftTransport } from './useAnnotationDraft';
11
+
12
+ const DEBOUNCE_MS = 500;
13
+
14
+ interface DraftData {
15
+ codeAnnotations: CodeAnnotation[];
16
+ descriptionAnnotations?: Annotation[];
17
+ commentAnnotations?: CommentAnnotation[];
18
+ viewedFiles?: string[];
19
+ draftGeneration?: number;
20
+ ts: number;
21
+ }
22
+
23
+ interface MissingDraftData {
24
+ found?: false;
25
+ draftGeneration?: number;
26
+ }
27
+
28
+ function readDraftGeneration(value: unknown): number | null {
29
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null;
30
+ }
31
+
32
+ function formatTimeAgo(ts: number): string {
33
+ const seconds = Math.floor((Date.now() - ts) / 1000);
34
+ if (seconds < 60) return 'just now';
35
+ const minutes = Math.floor(seconds / 60);
36
+ if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
37
+ const hours = Math.floor(minutes / 60);
38
+ if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
39
+ const days = Math.floor(hours / 24);
40
+ return `${days} day${days !== 1 ? 's' : ''} ago`;
41
+ }
42
+
43
+ interface UseCodeAnnotationDraftOptions {
44
+ annotations: CodeAnnotation[];
45
+ descriptionAnnotations?: Annotation[];
46
+ commentAnnotations?: CommentAnnotation[];
47
+ viewedFiles: Set<string>;
48
+ isApiMode: boolean;
49
+ submitted: boolean;
50
+ }
51
+
52
+ interface UseCodeAnnotationDraftResult {
53
+ draftBanner: { count: number; viewedCount: number; timeAgo: string } | null;
54
+ restoreDraft: () => { annotations: CodeAnnotation[]; descriptionAnnotations: Annotation[]; commentAnnotations: CommentAnnotation[]; viewedFiles: string[] };
55
+ getDraftGeneration: () => number;
56
+ dismissDraft: () => void;
57
+ }
58
+
59
+ export function useCodeAnnotationDraft({
60
+ annotations,
61
+ descriptionAnnotations = [],
62
+ commentAnnotations = [],
63
+ viewedFiles,
64
+ isApiMode,
65
+ submitted,
66
+ }: UseCodeAnnotationDraftOptions): UseCodeAnnotationDraftResult {
67
+ const [draftBanner, setDraftBanner] = useState<{ count: number; viewedCount: number; timeAgo: string } | null>(null);
68
+ const draftDataRef = useRef<DraftData | null>(null);
69
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
70
+ const hasMountedRef = useRef(false);
71
+ const draftGenerationRef = useRef(0);
72
+ // True once the user has actually had annotations this session. Used to decide
73
+ // whether an empty state is a real "cleared everything" edit (persist it) vs a
74
+ // fresh/unengaged session (leave the server alone). Keyed on annotations only —
75
+ // see the autosave effect for why viewedFiles must not count.
76
+ const hasHadAnnotationsRef = useRef(false);
77
+
78
+ // Load draft on mount
79
+ useEffect(() => {
80
+ if (!isApiMode) return;
81
+
82
+ getDraftTransport().load()
83
+ .then(({ data, generation }) => {
84
+ if (generation !== null) {
85
+ draftGenerationRef.current = Math.max(draftGenerationRef.current, generation);
86
+ }
87
+ return data as DraftData | null;
88
+ })
89
+ .then((data: DraftData | null) => {
90
+ const generation = readDraftGeneration(data?.draftGeneration);
91
+ if (generation !== null) {
92
+ draftGenerationRef.current = Math.max(draftGenerationRef.current, generation);
93
+ }
94
+ const annotationCount = (Array.isArray(data?.codeAnnotations) ? data.codeAnnotations.length : 0)
95
+ + (Array.isArray(data?.descriptionAnnotations) ? data.descriptionAnnotations.length : 0)
96
+ + (Array.isArray(data?.commentAnnotations) ? data.commentAnnotations.length : 0);
97
+ const viewedCount = Array.isArray(data?.viewedFiles) ? data.viewedFiles.length : 0;
98
+ if (annotationCount > 0 || viewedCount > 0) {
99
+ draftDataRef.current = data;
100
+ setDraftBanner({
101
+ count: annotationCount,
102
+ viewedCount,
103
+ timeAgo: formatTimeAgo(data?.ts || 0),
104
+ });
105
+ }
106
+ hasMountedRef.current = true;
107
+ })
108
+ .catch(() => {
109
+ hasMountedRef.current = true;
110
+ });
111
+ }, [isApiMode]);
112
+
113
+ // Debounced auto-save on annotation/viewed changes
114
+ useEffect(() => {
115
+ if (!isApiMode || submitted) return;
116
+ if (!hasMountedRef.current) return;
117
+
118
+ // Track engagement on USER-AUTHORED annotations only. Two things that arrive
119
+ // without user action must NOT count as "had content", or a later empty state
120
+ // would look like the user deleted everything and wrongly delete the draft:
121
+ // - viewedFiles are seeded from GitHub's already-viewed state on mount
122
+ // (review App.tsx) before the user does anything.
123
+ // - external/SSE annotations (source-tagged, e.g. an eslint plugin) arrive
124
+ // via `allAnnotations` and have their own lifecycle, separate from the draft.
125
+ if (annotations.some((a) => !a.source) || descriptionAnnotations.length > 0 || commentAnnotations.length > 0) hasHadAnnotationsRef.current = true;
126
+
127
+ const isEmpty = annotations.length === 0 && descriptionAnnotations.length === 0 && commentAnnotations.length === 0 && viewedFiles.size === 0;
128
+ // Leave the server alone for an empty state until the user has actually had
129
+ // annotations this session. This preserves an unrestored draft sitting on disk
130
+ // at mount (the draft-recovery banner can still offer it).
131
+ if (isEmpty && !hasHadAnnotationsRef.current) return;
132
+
133
+ if (timerRef.current) clearTimeout(timerRef.current);
134
+
135
+ timerRef.current = setTimeout(() => {
136
+ const draftGeneration = draftGenerationRef.current + 1;
137
+ draftGenerationRef.current = draftGeneration;
138
+
139
+ if (isEmpty) {
140
+ // The user cleared everything (#948). Delete the draft with a generation
141
+ // tombstone so it can't resurface on refresh and a late save can't revive
142
+ // it. Mirrors useAnnotationDraft.persistNow — routed through the draft
143
+ // transport seam so a host backend tombstones its own stored draft too.
144
+ getDraftTransport().remove(draftGeneration, { keepalive: false }).catch(() => {});
145
+ return;
146
+ }
147
+
148
+ const payload: DraftData = {
149
+ codeAnnotations: annotations,
150
+ descriptionAnnotations,
151
+ commentAnnotations,
152
+ viewedFiles: [...viewedFiles],
153
+ draftGeneration,
154
+ ts: Date.now(),
155
+ };
156
+
157
+ getDraftTransport().save(payload, { keepalive: false }).catch(() => {});
158
+ }, DEBOUNCE_MS);
159
+
160
+ return () => {
161
+ if (timerRef.current) clearTimeout(timerRef.current);
162
+ };
163
+ }, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, isApiMode, submitted]);
164
+
165
+ const restoreDraft = useCallback(() => {
166
+ // Cancel any pending autosave so it can't fire with pre-restore state and
167
+ // overwrite what we're about to restore.
168
+ if (timerRef.current) clearTimeout(timerRef.current);
169
+ const data = draftDataRef.current;
170
+ setDraftBanner(null);
171
+ draftDataRef.current = null;
172
+ return {
173
+ annotations: data?.codeAnnotations ?? [],
174
+ descriptionAnnotations: data?.descriptionAnnotations ?? [],
175
+ commentAnnotations: data?.commentAnnotations ?? [],
176
+ viewedFiles: data?.viewedFiles ?? [],
177
+ };
178
+ }, []);
179
+
180
+ const getDraftGeneration = useCallback(() => draftGenerationRef.current + 1, []);
181
+
182
+ const dismissDraft = useCallback(() => {
183
+ // Cancel any pending autosave so a late save can't revive the draft the user
184
+ // just dismissed.
185
+ if (timerRef.current) clearTimeout(timerRef.current);
186
+ const deletedGeneration = draftGenerationRef.current + 1;
187
+ draftGenerationRef.current = deletedGeneration;
188
+ setDraftBanner(null);
189
+ draftDataRef.current = null;
190
+ getDraftTransport().remove(deletedGeneration, { keepalive: false }).catch(() => {});
191
+ }, []);
192
+
193
+ return { draftBanner, restoreDraft, getDraftGeneration, dismissDraft };
194
+ }
@@ -0,0 +1 @@
1
+ export { useCodeFilePopout, type UseCodeFilePopoutReturn } from "./pfm/useCodeFilePopout";
@@ -0,0 +1,50 @@
1
+ import { useEffect } from "react";
2
+
3
+ export function useDismissOnOutsideAndEscape({
4
+ enabled,
5
+ ref,
6
+ onDismiss,
7
+ }: {
8
+ enabled: boolean;
9
+ ref: React.RefObject<HTMLElement | null>;
10
+ onDismiss: () => void;
11
+ }) {
12
+ useEffect(() => {
13
+ if (!enabled) return;
14
+
15
+ const handleMouseDown = (event: MouseEvent) => {
16
+ // Don't dismiss on multi-click (double-click, triple-click). These
17
+ // are part of an active selection gesture — the web-highlighter
18
+ // CREATE handler already cleans up any stale pending highlight when
19
+ // a new selection commits. Dismissing here triggers DOM mutations
20
+ // (mark removal + text-node normalisation) that reset the browser's
21
+ // click-count tracking and prevent the triple-click paragraph
22
+ // selection from ever firing.
23
+ // Note: we use mousedown (not pointerdown) because MouseEvent.detail
24
+ // is spec-guaranteed to carry the click count, whereas the Pointer
25
+ // Events spec says PointerEvent.detail SHOULD be 0.
26
+ if (event.detail >= 2) return;
27
+
28
+ const target = event.target as Node | null;
29
+ if (!target) return;
30
+ if (ref.current && ref.current.contains(target)) {
31
+ return;
32
+ }
33
+ onDismiss();
34
+ };
35
+
36
+ const handleKeyDown = (event: KeyboardEvent) => {
37
+ if (event.key === "Escape") {
38
+ onDismiss();
39
+ }
40
+ };
41
+
42
+ document.addEventListener("mousedown", handleMouseDown, true);
43
+ window.addEventListener("keydown", handleKeyDown);
44
+
45
+ return () => {
46
+ document.removeEventListener("mousedown", handleMouseDown, true);
47
+ window.removeEventListener("keydown", handleKeyDown);
48
+ };
49
+ }, [enabled, ref, onDismiss]);
50
+ }
@@ -0,0 +1,108 @@
1
+ import { useState, useRef, useCallback, useEffect } from 'react';
2
+
3
+ const DRAG_THRESHOLD = 3;
4
+ const VISIBLE_MIN = 50;
5
+
6
+ interface DragPosition {
7
+ top: number;
8
+ left: number;
9
+ }
10
+
11
+ /**
12
+ * Makes a fixed-position element draggable by its header/handle.
13
+ * Reads the element's actual rendered position on drag start via getBoundingClientRect,
14
+ * so it works regardless of CSS transforms (flipAbove, translateX, etc.).
15
+ *
16
+ * @param elementRef - Ref to the positioned element (the popover/toolbar container)
17
+ */
18
+ export function useDraggable(elementRef: React.RefObject<HTMLElement | null>) {
19
+ const [dragPosition, setDragPosition] = useState<DragPosition | null>(null);
20
+ const [isDragging, setIsDragging] = useState(false);
21
+ const [wasDragged, setWasDragged] = useState(false);
22
+
23
+ const startPointerRef = useRef({ x: 0, y: 0 });
24
+ const startElRef = useRef({ top: 0, left: 0 });
25
+ const thresholdMetRef = useRef(false);
26
+
27
+ const reset = useCallback(() => {
28
+ setDragPosition(null);
29
+ setIsDragging(false);
30
+ setWasDragged(false);
31
+ }, []);
32
+
33
+ // Document-level pointermove/pointerup while dragging
34
+ useEffect(() => {
35
+ if (!isDragging) return;
36
+
37
+ const onMove = (e: PointerEvent) => {
38
+ const dx = e.clientX - startPointerRef.current.x;
39
+ const dy = e.clientY - startPointerRef.current.y;
40
+
41
+ if (!thresholdMetRef.current) {
42
+ if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
43
+ thresholdMetRef.current = true;
44
+ }
45
+
46
+ // Clamp to keep at least VISIBLE_MIN px visible on screen
47
+ const top = Math.max(
48
+ -((elementRef.current?.offsetHeight ?? 0) - VISIBLE_MIN),
49
+ Math.min(startElRef.current.top + dy, window.innerHeight - VISIBLE_MIN),
50
+ );
51
+ const left = Math.max(
52
+ -((elementRef.current?.offsetWidth ?? 0) - VISIBLE_MIN),
53
+ Math.min(startElRef.current.left + dx, window.innerWidth - VISIBLE_MIN),
54
+ );
55
+
56
+ setDragPosition({ top, left });
57
+ };
58
+
59
+ const onUp = () => {
60
+ setIsDragging(false);
61
+ if (thresholdMetRef.current) {
62
+ setWasDragged(true);
63
+ }
64
+ };
65
+
66
+ document.addEventListener('pointermove', onMove);
67
+ document.addEventListener('pointerup', onUp);
68
+ return () => {
69
+ document.removeEventListener('pointermove', onMove);
70
+ document.removeEventListener('pointerup', onUp);
71
+ };
72
+ }, [isDragging, elementRef]);
73
+
74
+ const onPointerDown = useCallback(
75
+ (e: React.PointerEvent) => {
76
+ // Only primary button (left click / single touch)
77
+ if (e.button !== 0) return;
78
+ // Don't drag if clicking on an interactive element inside the handle
79
+ const target = e.target as HTMLElement;
80
+ if (target.closest('button, a, input, textarea, select')) return;
81
+
82
+ const el = elementRef.current;
83
+ if (!el) return;
84
+
85
+ const rect = el.getBoundingClientRect();
86
+ startPointerRef.current = { x: e.clientX, y: e.clientY };
87
+ startElRef.current = { top: rect.top, left: rect.left };
88
+ thresholdMetRef.current = false;
89
+ setIsDragging(true);
90
+ },
91
+ [elementRef],
92
+ );
93
+
94
+ return {
95
+ dragPosition,
96
+ isDragging,
97
+ wasDragged,
98
+ reset,
99
+ dragHandleProps: {
100
+ onPointerDown,
101
+ style: {
102
+ cursor: isDragging ? 'grabbing' : 'grab',
103
+ userSelect: 'none' as const,
104
+ touchAction: 'none' as const,
105
+ },
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,64 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+ import type { EditorAnnotation } from '../types';
3
+
4
+ const POLL_INTERVAL = 500;
5
+ const IS_VSCODE = typeof window !== 'undefined' && (window as any).__PLANNOTATOR_VSCODE === true;
6
+
7
+ interface UseEditorAnnotationsReturn {
8
+ editorAnnotations: EditorAnnotation[];
9
+ deleteEditorAnnotation: (id: string) => void;
10
+ }
11
+
12
+ /**
13
+ * Polls the server for editor annotations created by the VS Code extension.
14
+ *
15
+ * Only activates when running inside a VS Code webview (detected via
16
+ * window.__PLANNOTATOR_VSCODE set by the theme bridge). In browser/shared URL
17
+ * contexts, returns an empty array with zero network cost.
18
+ */
19
+ export function useEditorAnnotations(): UseEditorAnnotationsReturn {
20
+ const [annotations, setAnnotations] = useState<EditorAnnotation[]>([]);
21
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
22
+
23
+ const fetchAnnotations = useCallback(async () => {
24
+ try {
25
+ const res = await fetch('/api/editor-annotations');
26
+ if (!res.ok) return;
27
+ const data = await res.json();
28
+ const incoming: EditorAnnotation[] = data.annotations ?? [];
29
+ setAnnotations((prev) => {
30
+ if (prev.length === incoming.length && prev.every((a, i) => a.id === incoming[i].id)) return prev;
31
+ return incoming;
32
+ });
33
+ } catch {
34
+ // Silently fail — next poll will retry
35
+ }
36
+ }, []);
37
+
38
+ useEffect(() => {
39
+ if (!IS_VSCODE) return;
40
+
41
+ // Initial fetch + start polling
42
+ fetchAnnotations();
43
+ intervalRef.current = setInterval(fetchAnnotations, POLL_INTERVAL);
44
+
45
+ return () => {
46
+ if (intervalRef.current) {
47
+ clearInterval(intervalRef.current);
48
+ intervalRef.current = null;
49
+ }
50
+ };
51
+ }, [fetchAnnotations]);
52
+
53
+ const deleteEditorAnnotation = useCallback(async (id: string) => {
54
+ if (!IS_VSCODE) return;
55
+ try {
56
+ await fetch(`/api/editor-annotation?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
57
+ setAnnotations((prev) => prev.filter((a) => a.id !== id));
58
+ } catch {
59
+ // Silently fail — next poll will reconcile
60
+ }
61
+ }, []);
62
+
63
+ return { editorAnnotations: annotations, deleteEditorAnnotation };
64
+ }