@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,512 @@
1
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import type { ImageAttachment } from '../types';
4
+ import { AttachmentsButton } from './AttachmentsButton';
5
+ import { submitHint } from '../utils/platform';
6
+ import { useDraggable } from '../hooks/useDraggable';
7
+ import { SparklesIcon } from './SparklesIcon';
8
+ import { hasUnsavedCommentContent } from '../utils/commentContent';
9
+
10
+ export interface CommentAskAIContext {
11
+ kind: 'general' | 'selection';
12
+ label?: string;
13
+ text?: string;
14
+ sourcePath?: string;
15
+ }
16
+
17
+ export type CommentAskAIHandler = (
18
+ question: string,
19
+ context: CommentAskAIContext,
20
+ ) => boolean | void | Promise<boolean | void>;
21
+
22
+ interface CommentPopoverProps {
23
+ /** Element to anchor the popover near (re-reads position on scroll) */
24
+ anchorEl?: HTMLElement;
25
+ /** Static viewport rect to anchor near when no stable DOM element exists */
26
+ anchorRect?: DOMRect;
27
+ /** Truncated selected text shown in header, or empty for global */
28
+ contextText: string;
29
+ /** Whether this is a global comment */
30
+ isGlobal: boolean;
31
+ /** Pre-filled text (for type-to-comment) */
32
+ initialText?: string;
33
+ /** Called on submit with comment text and optional images */
34
+ onSubmit: (text: string, images?: ImageAttachment[]) => void;
35
+ /** Optional live draft observer for submit paths outside the popover. */
36
+ onDraftChange?: (text: string, images?: ImageAttachment[]) => void;
37
+ /** Called when popover is closed/cancelled */
38
+ onClose: () => void;
39
+ /** Opt-in: persist text + images across close/reopen, keyed by this string. Cleared on submit. */
40
+ draftKey?: string;
41
+ /** Whether image attachments are available in this comment surface. */
42
+ allowImages?: boolean;
43
+ /** Whether submitting empty text is allowed, for editors that support clearing. */
44
+ allowEmptySubmit?: boolean;
45
+ /** Optional Ask AI action. Absent by default so existing comment surfaces are unchanged. */
46
+ onAskAI?: CommentAskAIHandler;
47
+ askAIContext?: CommentAskAIContext;
48
+ askAIDisabled?: boolean;
49
+ }
50
+
51
+ const MAX_POPOVER_WIDTH = 384;
52
+ const GAP = 8;
53
+
54
+ // Module-level draft store: survives popover unmount so reopening the same key restores in-progress text.
55
+ const draftStore = new Map<string, { text: string; images: ImageAttachment[] }>();
56
+
57
+ /** Mirrors the latest text + images into `draftStore[draftKey]` so they outlive popover unmount. No-op without a key. */
58
+ function useCommentDraftSync(draftKey: string | undefined, text: string, images: ImageAttachment[]) {
59
+ useEffect(() => {
60
+ if (!draftKey) return;
61
+ if (hasUnsavedCommentContent(text, images)) {
62
+ draftStore.set(draftKey, { text, images });
63
+ } else {
64
+ draftStore.delete(draftKey);
65
+ }
66
+ }, [draftKey, text, images]);
67
+ }
68
+
69
+ function computePosition(anchorRect: DOMRect): { top: number; left: number; flipAbove: boolean; width: number } {
70
+ const spaceBelow = window.innerHeight - anchorRect.bottom;
71
+ const flipAbove = spaceBelow < 280;
72
+ const width = Math.min(MAX_POPOVER_WIDTH, window.innerWidth - 32);
73
+
74
+ const top = flipAbove
75
+ ? anchorRect.top - GAP
76
+ : anchorRect.bottom + GAP;
77
+
78
+ let left = anchorRect.left + anchorRect.width / 2 - width / 2;
79
+ left = Math.max(16, Math.min(left, window.innerWidth - width - 16));
80
+
81
+ return { top, left, flipAbove, width };
82
+ }
83
+
84
+ export const CommentPopover: React.FC<CommentPopoverProps> = ({
85
+ anchorEl,
86
+ anchorRect,
87
+ contextText,
88
+ isGlobal,
89
+ initialText = '',
90
+ onSubmit,
91
+ onDraftChange,
92
+ onClose,
93
+ draftKey,
94
+ allowImages = true,
95
+ allowEmptySubmit = false,
96
+ onAskAI,
97
+ askAIContext,
98
+ askAIDisabled = false,
99
+ }) => {
100
+ const [mode, setMode] = useState<'popover' | 'dialog'>('popover');
101
+ const initialDraft = draftKey ? draftStore.get(draftKey) : undefined;
102
+ const [text, setText] = useState(initialDraft?.text ?? initialText);
103
+ const [images, setImages] = useState<ImageAttachment[]>(allowImages ? initialDraft?.images ?? [] : []);
104
+ const [position, setPosition] = useState<{ top: number; left: number; flipAbove: boolean; width: number } | null>(null);
105
+ // Direction of an open popover that has scrolled out of view, or null when on-screen.
106
+ const [offscreen, setOffscreen] = useState<'above' | 'below' | null>(null);
107
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
108
+ const popoverRef = useRef<HTMLDivElement>(null);
109
+ const hasUnsavedContent = hasUnsavedCommentContent(text, allowImages ? images : []);
110
+ const hasUnsavedContentRef = useRef(hasUnsavedContent);
111
+ hasUnsavedContentRef.current = hasUnsavedContent;
112
+ const { dragPosition, dragHandleProps, wasDragged, reset: resetDrag } = useDraggable(popoverRef);
113
+
114
+ useEffect(() => {
115
+ const nextDraft = draftKey ? draftStore.get(draftKey) : undefined;
116
+ setText(nextDraft?.text ?? initialText);
117
+ setImages(allowImages ? nextDraft?.images ?? [] : []);
118
+ }, [draftKey, initialText, allowImages]);
119
+
120
+ useCommentDraftSync(draftKey, text, allowImages ? images : []);
121
+
122
+ useEffect(() => {
123
+ onDraftChange?.(text, allowImages ? images : undefined);
124
+ }, [allowImages, images, onDraftChange, text]);
125
+
126
+ // Reset drag when anchor changes (new annotation) or mode switches
127
+ useEffect(() => { resetDrag(); }, [anchorEl, anchorRect, resetDrag]);
128
+ useEffect(() => { if (mode === 'popover') resetDrag(); }, [mode, resetDrag]);
129
+
130
+ // Track anchor position on scroll/resize (popover mode only, not after user drag)
131
+ useEffect(() => {
132
+ if (mode !== 'popover' || wasDragged) return;
133
+
134
+ const update = () => {
135
+ const rect = anchorEl?.getBoundingClientRect() ?? anchorRect;
136
+ if (rect) setPosition(computePosition(rect));
137
+ };
138
+
139
+ update();
140
+ window.addEventListener('scroll', update, true);
141
+ window.addEventListener('resize', update);
142
+ return () => {
143
+ window.removeEventListener('scroll', update, true);
144
+ window.removeEventListener('resize', update);
145
+ };
146
+ }, [anchorEl, anchorRect, mode, wasDragged]);
147
+
148
+ // Surface a "jump back" arrow when an open popover scrolls out of view.
149
+ // Re-measures whenever the popover repositions (position updates every scroll
150
+ // step in tracked mode) so the indicator is accurate at rest, plus on resize.
151
+ useEffect(() => {
152
+ if (mode !== 'popover') { setOffscreen(null); return; }
153
+ const measure = () => {
154
+ const el = popoverRef.current;
155
+ if (!el) return;
156
+ const rect = el.getBoundingClientRect();
157
+ if (rect.bottom < 8) setOffscreen('above');
158
+ else if (rect.top > window.innerHeight - 8) setOffscreen('below');
159
+ else setOffscreen(null);
160
+ };
161
+ measure();
162
+ window.addEventListener('resize', measure);
163
+ return () => window.removeEventListener('resize', measure);
164
+ }, [position, dragPosition, mode]);
165
+
166
+ const scrollToPopover = useCallback(() => {
167
+ anchorEl?.scrollIntoView({ block: 'center', behavior: 'smooth' });
168
+ }, [anchorEl]);
169
+
170
+ // Focus textarea on mount and mode changes
171
+ useEffect(() => {
172
+ const id = setTimeout(() => {
173
+ const el = textareaRef.current;
174
+ if (el) {
175
+ el.focus();
176
+ el.selectionStart = el.selectionEnd = el.value.length;
177
+ }
178
+ }, 0);
179
+ return () => clearTimeout(id);
180
+ }, [mode]);
181
+
182
+ // Click-outside for popover mode
183
+ useEffect(() => {
184
+ if (mode !== 'popover') return;
185
+
186
+ const handlePointerDown = (e: PointerEvent) => {
187
+ const target = e.target as Node | null;
188
+ if (!target) return;
189
+ if (popoverRef.current?.contains(target)) return;
190
+ // Don't close if clicking inside a child portal (AttachmentsButton, ImageAnnotator, etc.)
191
+ const el = target as HTMLElement;
192
+ if (el.closest?.('[data-popover-layer]')) return;
193
+ if (hasUnsavedContentRef.current) return;
194
+ onClose();
195
+ };
196
+
197
+ document.addEventListener('pointerdown', handlePointerDown, true);
198
+ return () => document.removeEventListener('pointerdown', handlePointerDown, true);
199
+ }, [mode, onClose]);
200
+
201
+ const handleSubmit = useCallback(() => {
202
+ const canSubmitEmpty = allowEmptySubmit && initialText.trim().length > 0;
203
+ if (hasUnsavedContent || canSubmitEmpty) {
204
+ if (draftKey) draftStore.delete(draftKey);
205
+ onSubmit(text, allowImages && images.length > 0 ? images : undefined);
206
+ }
207
+ }, [text, images, onSubmit, draftKey, allowImages, allowEmptySubmit, initialText, hasUnsavedContent]);
208
+
209
+ const handleAskAI = useCallback(async () => {
210
+ const question = text.trim();
211
+ if (!question || !onAskAI) {
212
+ textareaRef.current?.focus();
213
+ return;
214
+ }
215
+ let accepted: boolean | void;
216
+ try {
217
+ accepted = await onAskAI(question, askAIContext ?? {
218
+ kind: isGlobal ? 'general' : 'selection',
219
+ text: contextText,
220
+ });
221
+ } catch (error) {
222
+ console.error('Ask AI action failed:', error);
223
+ textareaRef.current?.focus();
224
+ return;
225
+ }
226
+ if (accepted === false) {
227
+ textareaRef.current?.focus();
228
+ return;
229
+ }
230
+ if (draftKey) draftStore.delete(draftKey);
231
+ onDraftChange?.('', allowImages ? [] : undefined);
232
+ onClose();
233
+ }, [allowImages, askAIContext, contextText, draftKey, isGlobal, onAskAI, onClose, onDraftChange, text]);
234
+
235
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
236
+ if (e.key === 'Escape') {
237
+ e.stopPropagation();
238
+ if (mode === 'dialog') {
239
+ setMode('popover');
240
+ } else {
241
+ onClose();
242
+ }
243
+ return;
244
+ }
245
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {
246
+ e.preventDefault();
247
+ handleSubmit();
248
+ }
249
+ };
250
+
251
+ const headerLabel = isGlobal
252
+ ? 'Global Comment'
253
+ : contextText
254
+ ? `"${contextText.length > 50 ? contextText.slice(0, 50) + '...' : contextText}"`
255
+ : 'Comment';
256
+
257
+ const canSubmit =
258
+ hasUnsavedContent ||
259
+ (allowEmptySubmit && initialText.trim().length > 0);
260
+ const canAskAI = !!onAskAI && !askAIDisabled && text.trim().length > 0;
261
+
262
+ if (mode === 'dialog') {
263
+ return createPortal(
264
+ <div data-comment-popover="true" className="fixed inset-0 z-[100] flex items-center justify-center p-4">
265
+ {/* Backdrop */}
266
+ <div className="absolute inset-0 bg-background/80 backdrop-blur-sm" />
267
+
268
+ {/* Dialog card */}
269
+ <div
270
+ ref={popoverRef}
271
+ className="relative w-full max-w-xl bg-popover border border-border rounded-xl shadow-2xl flex flex-col"
272
+ style={{
273
+ animation: 'comment-dialog-in 0.15s ease-out',
274
+ }}
275
+ onPointerDown={(e) => e.stopPropagation()}
276
+ >
277
+ <style>{`
278
+ @keyframes comment-dialog-in {
279
+ from { opacity: 0; transform: scale(0.95); }
280
+ to { opacity: 1; transform: scale(1); }
281
+ }
282
+ `}</style>
283
+
284
+ {/* Header */}
285
+ <div className="flex items-center justify-between px-4 py-3 border-b border-border/50">
286
+ <span className="text-xs text-muted-foreground truncate max-w-[400px]">
287
+ {headerLabel}
288
+ </span>
289
+ <div className="flex items-center gap-1">
290
+ <button
291
+ onClick={() => setMode('popover')}
292
+ className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
293
+ title="Collapse"
294
+ >
295
+ <CollapseIcon />
296
+ </button>
297
+ <button
298
+ onClick={onClose}
299
+ className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
300
+ title="Close"
301
+ >
302
+ <CloseIcon />
303
+ </button>
304
+ </div>
305
+ </div>
306
+
307
+ {/* Textarea */}
308
+ <div className="px-4 py-3 flex-1">
309
+ <textarea
310
+ ref={textareaRef}
311
+ value={text}
312
+ onChange={(e) => setText(e.target.value)}
313
+ onKeyDown={handleKeyDown}
314
+ placeholder={isGlobal ? 'Add a global comment...' : 'Add a comment...'}
315
+ className="w-full bg-transparent text-sm placeholder:text-muted-foreground resize-none focus:outline-none min-h-48 max-h-96 px-1 py-0.5"
316
+ style={{ fieldSizing: 'content' } as React.CSSProperties}
317
+ />
318
+ </div>
319
+
320
+ {/* Footer */}
321
+ <div className="flex items-center justify-between px-4 py-3 border-t border-border/50">
322
+ <div className="flex items-center gap-2">
323
+ {allowImages && (
324
+ <AttachmentsButton
325
+ images={images}
326
+ onAdd={(img) => setImages((prev) => [...prev, img])}
327
+ onRemove={(path) => setImages((prev) => prev.filter((i) => i.path !== path))}
328
+ variant="inline"
329
+ />
330
+ )}
331
+ </div>
332
+ <div className="flex items-center gap-3">
333
+ {onAskAI && (
334
+ <button
335
+ onClick={handleAskAI}
336
+ disabled={!canAskAI}
337
+ className="inline-flex items-center gap-1 px-2 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:text-primary hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
338
+ title={canAskAI ? 'Ask AI this question' : 'Type a question to ask AI'}
339
+ >
340
+ <SparklesIcon className="w-3 h-3" />
341
+ Ask AI
342
+ </button>
343
+ )}
344
+ <span className="text-[10px] text-muted-foreground">{submitHint}</span>
345
+ <button
346
+ onClick={handleSubmit}
347
+ disabled={!canSubmit}
348
+ className="px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
349
+ >
350
+ {isGlobal ? 'Add' : 'Save'}
351
+ </button>
352
+ </div>
353
+ </div>
354
+ </div>
355
+ </div>,
356
+ document.body
357
+ );
358
+ }
359
+
360
+ // Popover mode
361
+ if (!position) return null;
362
+
363
+ return createPortal(
364
+ <>
365
+ {offscreen && (
366
+ <button
367
+ type="button"
368
+ data-popover-layer="true"
369
+ onClick={scrollToPopover}
370
+ title="Scroll back to your open comment"
371
+ className={`fixed left-1/2 -translate-x-1/2 z-[101] flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-popover border border-border shadow-lg text-xs text-muted-foreground hover:text-foreground transition-colors ${offscreen === 'above' ? 'top-3' : 'bottom-3'}`}
372
+ >
373
+ {offscreen === 'above' ? <ChevronUpIcon /> : <ChevronDownIcon />}
374
+ <span>Open comment</span>
375
+ </button>
376
+ )}
377
+ <div
378
+ ref={popoverRef}
379
+ data-comment-popover="true"
380
+ className="fixed z-[100] bg-popover border border-border rounded-xl shadow-2xl flex flex-col"
381
+ style={dragPosition
382
+ ? { top: dragPosition.top, left: dragPosition.left, width: position.width }
383
+ : {
384
+ top: position.top,
385
+ left: position.left,
386
+ width: position.width,
387
+ ...(position.flipAbove ? { transform: 'translateY(-100%)' } : {}),
388
+ animation: position.flipAbove
389
+ ? 'comment-popover-in-above 0.15s ease-out'
390
+ : 'comment-popover-in 0.15s ease-out',
391
+ }
392
+ }
393
+ onPointerDown={(e) => e.stopPropagation()}
394
+ >
395
+ <style>{`
396
+ @keyframes comment-popover-in {
397
+ from { opacity: 0; transform: translateY(-8px); }
398
+ to { opacity: 1; transform: translateY(0); }
399
+ }
400
+ @keyframes comment-popover-in-above {
401
+ from { opacity: 0; transform: translateY(-100%) translateY(8px); }
402
+ to { opacity: 1; transform: translateY(-100%); }
403
+ }
404
+ `}</style>
405
+
406
+ {/* Header (draggable) */}
407
+ <div className="flex items-center justify-between px-3 py-2 border-b border-border/50" {...dragHandleProps}>
408
+ <span className="text-xs text-muted-foreground truncate max-w-[260px]">
409
+ {headerLabel}
410
+ </span>
411
+ <div className="flex items-center gap-1">
412
+ <button
413
+ onClick={() => setMode('dialog')}
414
+ className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
415
+ title="Expand"
416
+ >
417
+ <ExpandIcon />
418
+ </button>
419
+ <button
420
+ onClick={onClose}
421
+ className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
422
+ title="Close"
423
+ >
424
+ <CloseIcon />
425
+ </button>
426
+ </div>
427
+ </div>
428
+
429
+ {/* Textarea */}
430
+ <div className="px-3 py-2">
431
+ <textarea
432
+ ref={textareaRef}
433
+ value={text}
434
+ onChange={(e) => setText(e.target.value)}
435
+ onKeyDown={handleKeyDown}
436
+ placeholder={isGlobal ? 'Add a global comment...' : 'Add a comment...'}
437
+ className="w-full bg-transparent text-sm placeholder:text-muted-foreground resize-none focus:outline-none max-h-64 min-h-[4.5rem] px-1 py-0.5"
438
+ style={{ fieldSizing: 'content' } as React.CSSProperties}
439
+ />
440
+ </div>
441
+
442
+ {/* Footer */}
443
+ <div className="flex items-center justify-between px-3 py-2 border-t border-border/50">
444
+ <div className="flex items-center gap-2">
445
+ {allowImages && (
446
+ <AttachmentsButton
447
+ images={images}
448
+ onAdd={(img) => setImages((prev) => [...prev, img])}
449
+ onRemove={(path) => setImages((prev) => prev.filter((i) => i.path !== path))}
450
+ variant="inline"
451
+ />
452
+ )}
453
+ </div>
454
+ <div className="flex items-center gap-3">
455
+ {onAskAI && (
456
+ <button
457
+ onClick={handleAskAI}
458
+ disabled={!canAskAI}
459
+ className="inline-flex items-center gap-1 px-2 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:text-primary hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
460
+ title={canAskAI ? 'Ask AI this question' : 'Type a question to ask AI'}
461
+ >
462
+ <SparklesIcon className="w-3 h-3" />
463
+ Ask AI
464
+ </button>
465
+ )}
466
+ <span className="text-[10px] text-muted-foreground">{submitHint}</span>
467
+ <button
468
+ onClick={handleSubmit}
469
+ disabled={!canSubmit}
470
+ className="px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
471
+ >
472
+ {isGlobal ? 'Add' : 'Save'}
473
+ </button>
474
+ </div>
475
+ </div>
476
+ </div>
477
+ </>,
478
+ document.body
479
+ );
480
+ };
481
+
482
+ // Icons
483
+
484
+ const ChevronUpIcon = () => (
485
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
486
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
487
+ </svg>
488
+ );
489
+
490
+ const ChevronDownIcon = () => (
491
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
492
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
493
+ </svg>
494
+ );
495
+
496
+ const ExpandIcon = () => (
497
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
498
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
499
+ </svg>
500
+ );
501
+
502
+ const CollapseIcon = () => (
503
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
504
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />
505
+ </svg>
506
+ );
507
+
508
+ const CloseIcon = () => (
509
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
510
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
511
+ </svg>
512
+ );
@@ -0,0 +1,99 @@
1
+ import { useAutoClose } from '../hooks/useAutoClose';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Icons
5
+ // ---------------------------------------------------------------------------
6
+
7
+ const CheckIcon = () => (
8
+ <svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
9
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
10
+ </svg>
11
+ );
12
+
13
+ const ChatBubbleIcon = () => (
14
+ <svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
15
+ <path
16
+ strokeLinecap="round"
17
+ strokeLinejoin="round"
18
+ d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
19
+ />
20
+ </svg>
21
+ );
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Component
25
+ // ---------------------------------------------------------------------------
26
+
27
+ interface CompletionOverlayProps {
28
+ submitted: 'approved' | 'denied' | 'feedback' | 'exited' | null | false;
29
+ title: string;
30
+ subtitle: string;
31
+ agentLabel: string;
32
+ }
33
+
34
+ export function CompletionOverlay({ submitted, title, subtitle, agentLabel }: CompletionOverlayProps) {
35
+ const { state, enableAndStart } = useAutoClose(!!submitted);
36
+
37
+ if (!submitted) return null;
38
+
39
+ const isApproved = submitted === 'approved';
40
+
41
+ return (
42
+ <div className="fixed inset-0 z-[100] bg-background flex items-center justify-center">
43
+ <div className="text-center space-y-6 max-w-md px-8">
44
+ <div
45
+ className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center ${
46
+ isApproved ? 'bg-success/20 text-success' : 'bg-accent/20 text-accent'
47
+ }`}
48
+ >
49
+ {isApproved ? <CheckIcon /> : <ChatBubbleIcon />}
50
+ </div>
51
+
52
+ <div className="space-y-2">
53
+ <h2 className="text-xl font-semibold text-foreground">{title}</h2>
54
+ <p className="text-muted-foreground">{subtitle}</p>
55
+ </div>
56
+
57
+ <div className="pt-4 border-t border-border space-y-2">
58
+ {state.phase === 'counting' ? (
59
+ <>
60
+ <p className="text-sm text-muted-foreground">
61
+ This tab will close in <span className="text-foreground font-medium">{state.remaining}</span> second
62
+ {state.remaining !== 1 ? 's' : ''}...
63
+ </p>
64
+ <p className="text-xs text-muted-foreground/60">You can change this in Settings.</p>
65
+ </>
66
+ ) : state.phase === 'closeFailed' ? (
67
+ <>
68
+ <p className="text-sm text-muted-foreground">
69
+ Could not close this tab automatically. Please close it manually.
70
+ </p>
71
+ <p className="text-xs text-muted-foreground/60">
72
+ Auto-close works when the tab is opened by {agentLabel}.
73
+ </p>
74
+ </>
75
+ ) : (
76
+ <>
77
+ <p className="text-sm text-muted-foreground">
78
+ You can close this tab and return to <span className="text-foreground font-medium">{agentLabel}</span>.
79
+ </p>
80
+ {state.phase === 'prompt' ? (
81
+ <>
82
+ <label className="flex items-center justify-center gap-2 cursor-pointer group">
83
+ <input type="checkbox" checked={false} onChange={enableAndStart} className="accent-primary" />
84
+ <span className="text-xs text-muted-foreground group-hover:text-foreground transition-colors">
85
+ Auto-close this tab after 3 seconds
86
+ </span>
87
+ </label>
88
+ <p className="text-xs text-muted-foreground/60">You can change the delay in Settings.</p>
89
+ </>
90
+ ) : (
91
+ <p className="text-xs text-muted-foreground/60">Your response has been sent.</p>
92
+ )}
93
+ </>
94
+ )}
95
+ </div>
96
+ </div>
97
+ </div>
98
+ );
99
+ }