@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,111 @@
1
+ import { useState, useCallback } from "react";
2
+ import { parseCodePath } from "@plannotator/core/code-file";
3
+
4
+ interface CodeFileState {
5
+ filepath: string;
6
+ contents: string;
7
+ prerenderedHTML?: string;
8
+ error?: string;
9
+ requestedPath?: string;
10
+ line?: number;
11
+ lineEnd?: number;
12
+ }
13
+
14
+ interface UseCodeFilePopoutOptions {
15
+ buildUrl: (codePath: string) => string;
16
+ }
17
+
18
+ export interface UseCodeFilePopoutReturn {
19
+ open: (codePath: string) => void;
20
+ close: () => void;
21
+ isLoading: boolean;
22
+ popoutProps: {
23
+ open: boolean;
24
+ onClose: () => void;
25
+ filepath: string;
26
+ contents: string;
27
+ prerenderedHTML?: string;
28
+ error?: string;
29
+ requestedPath?: string;
30
+ line?: number;
31
+ lineEnd?: number;
32
+ } | null;
33
+ }
34
+
35
+ export function useCodeFilePopout(
36
+ options: UseCodeFilePopoutOptions
37
+ ): UseCodeFilePopoutReturn {
38
+ const { buildUrl } = options;
39
+ const [state, setState] = useState<CodeFileState | null>(null);
40
+ const [isLoading, setIsLoading] = useState(false);
41
+
42
+ const close = useCallback(() => {
43
+ setState(null);
44
+ setIsLoading(false);
45
+ }, []);
46
+
47
+ const open = useCallback(
48
+ async (codePath: string) => {
49
+ setIsLoading(true);
50
+ const parsed = parseCodePath(codePath);
51
+ try {
52
+ const res = await fetch(buildUrl(codePath));
53
+ const data = (await res.json()) as {
54
+ codeFile?: boolean;
55
+ contents?: string;
56
+ filepath?: string;
57
+ prerenderedHTML?: string;
58
+ error?: string;
59
+ line?: number;
60
+ lineEnd?: number;
61
+ };
62
+ if (!res.ok || data.error || !data.codeFile || typeof data.contents !== 'string' || !data.filepath) {
63
+ setState({
64
+ filepath: codePath,
65
+ contents: "",
66
+ error: data.error ?? `File not found in repo: ${codePath}`,
67
+ requestedPath: codePath,
68
+ });
69
+ setIsLoading(false);
70
+ return;
71
+ }
72
+ setState({
73
+ filepath: data.filepath,
74
+ contents: data.contents,
75
+ prerenderedHTML: data.prerenderedHTML,
76
+ line: data.line ?? parsed.line,
77
+ lineEnd: data.lineEnd ?? parsed.lineEnd,
78
+ });
79
+ setIsLoading(false);
80
+ } catch {
81
+ setState({
82
+ filepath: codePath,
83
+ contents: "",
84
+ error: `Failed to load: ${codePath}`,
85
+ requestedPath: codePath,
86
+ });
87
+ setIsLoading(false);
88
+ }
89
+ },
90
+ [buildUrl]
91
+ );
92
+
93
+ return {
94
+ open,
95
+ close,
96
+ isLoading,
97
+ popoutProps: state
98
+ ? {
99
+ open: true,
100
+ onClose: close,
101
+ filepath: state.filepath,
102
+ contents: state.contents,
103
+ prerenderedHTML: state.prerenderedHTML,
104
+ error: state.error,
105
+ requestedPath: state.requestedPath,
106
+ line: state.line,
107
+ lineEnd: state.lineEnd,
108
+ }
109
+ : null,
110
+ };
111
+ }
@@ -0,0 +1,532 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { AIContext } from '@plannotator/core';
3
+ import type { AIQuestion, AIResponse } from '../types';
4
+ import { generateId } from '../utils/generateId';
5
+
6
+ export interface AIChatEntry {
7
+ question: AIQuestion;
8
+ response: AIResponse;
9
+ }
10
+
11
+ export interface PendingPermission {
12
+ requestId: string;
13
+ toolName: string;
14
+ toolInput: Record<string, unknown>;
15
+ title?: string;
16
+ displayName?: string;
17
+ description?: string;
18
+ toolUseId: string;
19
+ decided?: 'allow' | 'deny';
20
+ }
21
+
22
+ export interface AIChatThread {
23
+ id: string;
24
+ title: string;
25
+ sessionId: string | null;
26
+ messages: AIChatEntry[];
27
+ permissionRequests: PendingPermission[];
28
+ }
29
+
30
+ export interface AskAIParams {
31
+ prompt: string;
32
+ filePath?: string;
33
+ lineStart?: number;
34
+ lineEnd?: number;
35
+ side?: 'old' | 'new';
36
+ selectedCode?: string;
37
+ scope?: AIQuestion['scope'];
38
+ contextUpdate?: string;
39
+ /** What the user is currently viewing (changes mid-session, so it rides with
40
+ * each question rather than the once-created session context). */
41
+ viewing?: { scope: 'all' | 'file'; filePath?: string };
42
+ /** Context block prepended to the message (e.g. the "changes under review"
43
+ * description). Rides with each question so it reflects the live view. */
44
+ contextPreamble?: string;
45
+ }
46
+
47
+ interface UseAIChatOptions {
48
+ context: AIContext | null;
49
+ providerId?: string | null;
50
+ model?: string | null;
51
+ reasoningEffort?: string | null;
52
+ buildPrompt?: (params: AskAIParams) => string;
53
+ threadTitle?: string;
54
+ }
55
+
56
+ export function buildDefaultPrompt(params: AskAIParams): string {
57
+ // The "changes under review" context (and any other preamble) leads the
58
+ // message, so the agent is oriented to the live view before the question and
59
+ // the existing file/line/viewing notes.
60
+ const pre = params.contextPreamble?.trim() ? `${params.contextPreamble.trim()}\n\n` : '';
61
+
62
+ const base = ((): string => {
63
+ if (params.filePath && params.lineStart != null && params.lineEnd != null) {
64
+ const lineRef = params.lineStart === params.lineEnd
65
+ ? `line ${params.lineStart}`
66
+ : `lines ${params.lineStart}-${params.lineEnd}`;
67
+ const sideLabel = params.side === 'new' ? 'new (added)' : 'old (removed)';
68
+ const codeBlock = params.selectedCode
69
+ ? `\n\`\`\`\n${params.selectedCode}\n\`\`\`\n`
70
+ : '';
71
+ return `Re: ${params.filePath}, ${lineRef} (${sideLabel} side)${codeBlock}\n${params.prompt}`;
72
+ }
73
+
74
+ if (params.filePath) {
75
+ return `Re: ${params.filePath} (entire file)\n\n${params.prompt}`;
76
+ }
77
+
78
+ if (params.scope?.kind === 'selection') {
79
+ const label = params.scope.label ? `Re: ${params.scope.label}` : 'Re: selected text';
80
+ const source = params.scope.sourcePath ? `\nSource: ${params.scope.sourcePath}` : '';
81
+ const selection = params.scope.text ? `\n\nSelected text:\n\`\`\`\n${params.scope.text}\n\`\`\`` : '';
82
+ return `${label}${source}${selection}\n\n${params.prompt}`;
83
+ }
84
+
85
+ // General question (no explicit file/line/selection): tell the agent what the
86
+ // user is currently looking at so it can scope its own investigation.
87
+ if (params.viewing) {
88
+ const note = params.viewing.scope === 'file' && params.viewing.filePath
89
+ ? `[The user is currently viewing ${params.viewing.filePath}]`
90
+ : '[The user is currently viewing all changed files]';
91
+ return `${note}\n${params.prompt}`;
92
+ }
93
+
94
+ return params.prompt;
95
+ })();
96
+
97
+ return pre + base;
98
+ }
99
+
100
+ function createThread(title = 'Chat'): AIChatThread {
101
+ return {
102
+ id: generateId('ai-thread'),
103
+ title,
104
+ sessionId: null,
105
+ messages: [],
106
+ permissionRequests: [],
107
+ };
108
+ }
109
+
110
+ function createAbortError(message: string): Error {
111
+ if (typeof DOMException !== 'undefined') {
112
+ return new DOMException(message, 'AbortError');
113
+ }
114
+ const err = new Error(message);
115
+ err.name = 'AbortError';
116
+ return err;
117
+ }
118
+
119
+ /**
120
+ * Transport for the AI chat wire. Each method maps to one Plannotator
121
+ * `/api/ai/*` endpoint. The default reproduces today's fetches verbatim;
122
+ * a host (e.g. Workspaces) calls `setAITransport` once at startup to route
123
+ * AI traffic through its own backend. The SSE reader loop, epoch guards, and
124
+ * supersede-abort position in the hook are unaffected — only the wire is swapped.
125
+ */
126
+ export interface AITransport {
127
+ /** POST /api/ai/session — create or fork a session. */
128
+ session(body: unknown, signal: AbortSignal): Promise<Response>;
129
+ /** POST /api/ai/query — send a message; returns the streaming SSE response. */
130
+ query(body: unknown, signal: AbortSignal): Promise<Response>;
131
+ /** POST /api/ai/abort — abort a session (supersede + standalone). Resolves once
132
+ the server acknowledges so a following query can await it (best-effort, never rejects). */
133
+ abort(body: unknown): Promise<unknown>;
134
+ /** POST /api/ai/permission — respond to a permission request. Fire-and-forget. */
135
+ permission(body: unknown): void;
136
+ }
137
+
138
+ /** Default transport — Plannotator's local `/api/ai/*` fetches, verbatim. */
139
+ const defaultAITransport: AITransport = {
140
+ session: (body, signal) =>
141
+ fetch('/api/ai/session', {
142
+ method: 'POST',
143
+ headers: { 'Content-Type': 'application/json' },
144
+ body: JSON.stringify(body),
145
+ signal,
146
+ }),
147
+ query: (body, signal) =>
148
+ fetch('/api/ai/query', {
149
+ method: 'POST',
150
+ headers: { 'Content-Type': 'application/json' },
151
+ body: JSON.stringify(body),
152
+ signal,
153
+ }),
154
+ abort: (body) =>
155
+ fetch('/api/ai/abort', {
156
+ method: 'POST',
157
+ headers: { 'Content-Type': 'application/json' },
158
+ body: JSON.stringify(body),
159
+ }).catch(() => {}),
160
+ permission: (body) => {
161
+ fetch('/api/ai/permission', {
162
+ method: 'POST',
163
+ headers: { 'Content-Type': 'application/json' },
164
+ body: JSON.stringify(body),
165
+ }).catch(() => {});
166
+ },
167
+ };
168
+
169
+ // Module-level transport, stable identity. Defaults to Plannotator's behavior so
170
+ // the hook and its callers are unchanged. A host overrides it once at startup.
171
+ let aiTransport: AITransport = defaultAITransport;
172
+
173
+ /** Override the AI chat transport. Call once at app startup. */
174
+ export const setAITransport = (transport: AITransport): void => {
175
+ aiTransport = transport;
176
+ };
177
+
178
+ /** Reset to the default (Plannotator local `/api/ai/*`) transport. Mainly for tests. */
179
+ export const resetAITransport = (): void => {
180
+ aiTransport = defaultAITransport;
181
+ };
182
+
183
+ /**
184
+ * Dispatch the abort transport without ever rejecting. A host transport whose
185
+ * `abort` throws — synchronously (deferred into `.then`) or asynchronously
186
+ * (`.catch`) — must not surface as an unhandled rejection, and the await site in
187
+ * ask() relies on this resolving. Single-sourced so every abort call site stays
188
+ * hardened. Reads `aiTransport` at call time so a late override is honored.
189
+ */
190
+ function safeAbort(sessionId: string): Promise<unknown> {
191
+ return Promise.resolve()
192
+ .then(() => aiTransport.abort({ sessionId }))
193
+ .catch(() => {});
194
+ }
195
+
196
+ export function useAIChat({
197
+ context,
198
+ providerId,
199
+ model,
200
+ reasoningEffort,
201
+ buildPrompt = buildDefaultPrompt,
202
+ threadTitle = 'Chat',
203
+ }: UseAIChatOptions) {
204
+ const [thread, setThread] = useState<AIChatThread>(() => createThread(threadTitle));
205
+ const [isCreatingSession, setIsCreatingSession] = useState(false);
206
+ const [isStreaming, setIsStreaming] = useState(false);
207
+ const [error, setError] = useState<string | null>(null);
208
+
209
+ const abortRef = useRef<AbortController | null>(null);
210
+ // In-flight server abort (from Stop or a superseding question). The next ask()
211
+ // waits on it so a new query can't race the still-active turn into busy.
212
+ const pendingAbortRef = useRef<Promise<unknown> | null>(null);
213
+ const sessionEpochRef = useRef(0);
214
+ const createRequestRef = useRef(0);
215
+ const sessionIdRef = useRef<string | null>(null);
216
+ sessionIdRef.current = thread.sessionId;
217
+
218
+ const updateMessages = useCallback((updater: (messages: AIChatEntry[]) => AIChatEntry[]) => {
219
+ setThread(prev => ({ ...prev, messages: updater(prev.messages) }));
220
+ }, []);
221
+
222
+ const updatePermissions = useCallback((updater: (permissions: PendingPermission[]) => PendingPermission[]) => {
223
+ setThread(prev => ({ ...prev, permissionRequests: updater(prev.permissionRequests) }));
224
+ }, []);
225
+
226
+ const setSessionId = useCallback((sessionId: string | null) => {
227
+ setThread(prev => ({ ...prev, sessionId }));
228
+ }, []);
229
+
230
+ const createSession = useCallback(async (signal: AbortSignal, epoch: number): Promise<string> => {
231
+ if (!context) {
232
+ throw new Error('AI context is unavailable');
233
+ }
234
+
235
+ const requestId = ++createRequestRef.current;
236
+ setIsCreatingSession(true);
237
+ try {
238
+ const res = await aiTransport.session({
239
+ context,
240
+ ...(providerId && { providerId }),
241
+ ...(model && { model }),
242
+ ...(reasoningEffort && { reasoningEffort }),
243
+ }, signal);
244
+
245
+ if (!res.ok) {
246
+ const data = await res.json().catch(() => ({ error: 'Failed to create AI session' }));
247
+ throw new Error(data.error || `HTTP ${res.status}`);
248
+ }
249
+
250
+ const data = await res.json() as { sessionId: string };
251
+ if (signal.aborted || epoch !== sessionEpochRef.current) {
252
+ void safeAbort(data.sessionId); // fire-and-forget cleanup of the orphaned session
253
+ throw createAbortError('AI session creation was superseded');
254
+ }
255
+ setSessionId(data.sessionId);
256
+ return data.sessionId;
257
+ } finally {
258
+ if (createRequestRef.current === requestId) {
259
+ setIsCreatingSession(false);
260
+ }
261
+ }
262
+ }, [context, model, providerId, reasoningEffort, setSessionId]);
263
+
264
+ // Tell the server to stop the current session's in-flight turn, and resolve
265
+ // once it has. Used by the Stop button and when a new question supersedes a
266
+ // streaming one — aborting the browser fetch alone leaves the agent's turn
267
+ // running. The /api/ai/abort handler clears the session's active flag before
268
+ // it responds, so awaiting this guarantees a following query won't race it
269
+ // into a session_busy error. Best-effort (never rejects).
270
+ const postServerAbort = useCallback((): Promise<unknown> => {
271
+ const sessionId = sessionIdRef.current; // capture the session being aborted now
272
+ if (!sessionId) return Promise.resolve();
273
+ return safeAbort(sessionId);
274
+ }, []);
275
+
276
+ const ask = useCallback(async (params: AskAIParams) => {
277
+ if (abortRef.current) {
278
+ abortRef.current.abort();
279
+ // Supersede: stop the server-side turn (not just the browser fetch).
280
+ pendingAbortRef.current = postServerAbort();
281
+ }
282
+ // Wait for any in-flight server abort — from this supersede OR a prior Stop
283
+ // click — so the query below doesn't race the still-active turn into a
284
+ // session_busy error. Stopping still kills the turn; chatting right after
285
+ // just waits the one round-trip for the stop to land.
286
+ if (pendingAbortRef.current) {
287
+ await pendingAbortRef.current;
288
+ pendingAbortRef.current = null;
289
+ }
290
+
291
+ const controller = new AbortController();
292
+ abortRef.current = controller;
293
+ const epoch = sessionEpochRef.current;
294
+ setError(null);
295
+
296
+ const questionId = generateId('ai-question');
297
+ const question: AIQuestion = {
298
+ id: questionId,
299
+ prompt: params.prompt,
300
+ scope: params.scope,
301
+ filePath: params.filePath,
302
+ lineStart: params.lineStart,
303
+ lineEnd: params.lineEnd,
304
+ side: params.side,
305
+ selectedCode: params.selectedCode,
306
+ createdAt: Date.now(),
307
+ };
308
+
309
+ const response: AIResponse = {
310
+ questionId,
311
+ text: '',
312
+ isStreaming: true,
313
+ createdAt: Date.now(),
314
+ };
315
+
316
+ updateMessages(prev => [...prev, { question, response }]);
317
+ setIsStreaming(true);
318
+
319
+ try {
320
+ let sid = sessionIdRef.current;
321
+ if (!sid) {
322
+ sid = await createSession(controller.signal, epoch);
323
+ }
324
+
325
+ if (controller.signal.aborted || epoch !== sessionEpochRef.current) {
326
+ throw createAbortError('AI question was superseded');
327
+ }
328
+
329
+ const fullPrompt = buildPrompt(params);
330
+ const res = await aiTransport.query({
331
+ sessionId: sid,
332
+ prompt: fullPrompt,
333
+ ...(params.contextUpdate && { contextUpdate: params.contextUpdate }),
334
+ }, controller.signal);
335
+
336
+ if (!res.ok || !res.body) {
337
+ const data = await res.json().catch(() => ({ error: 'Query failed' }));
338
+ throw new Error(data.error || `HTTP ${res.status}`);
339
+ }
340
+
341
+ const reader = res.body.getReader();
342
+ const decoder = new TextDecoder();
343
+ let buffer = '';
344
+
345
+ while (true) {
346
+ const { done, value } = await reader.read();
347
+ if (done) break;
348
+ buffer += decoder.decode(value, { stream: true });
349
+
350
+ const lines = buffer.split('\n');
351
+ buffer = lines.pop()!;
352
+
353
+ for (const line of lines) {
354
+ if (!line.startsWith('data: ')) continue;
355
+ const data = line.slice(6);
356
+ if (data === '[DONE]') continue;
357
+
358
+ try {
359
+ const msg = JSON.parse(data);
360
+
361
+ if (msg.type === 'text_delta') {
362
+ updateMessages(prev =>
363
+ prev.map(m =>
364
+ m.question.id === questionId
365
+ ? { ...m, response: { ...m.response, text: m.response.text + msg.delta } }
366
+ : m
367
+ )
368
+ );
369
+ } else if (msg.type === 'text') {
370
+ updateMessages(prev =>
371
+ prev.map(m =>
372
+ m.question.id === questionId && !m.response.text
373
+ ? { ...m, response: { ...m.response, text: msg.text } }
374
+ : m
375
+ )
376
+ );
377
+ } else if (msg.type === 'permission_request') {
378
+ updatePermissions(prev => [...prev, {
379
+ requestId: msg.requestId,
380
+ toolName: msg.toolName,
381
+ toolInput: msg.toolInput,
382
+ title: msg.title,
383
+ displayName: msg.displayName,
384
+ description: msg.description,
385
+ toolUseId: msg.toolUseId,
386
+ }]);
387
+ } else if (msg.type === 'error') {
388
+ updateMessages(prev =>
389
+ prev.map(m =>
390
+ m.question.id === questionId
391
+ ? { ...m, response: { ...m.response, error: msg.error, isStreaming: false } }
392
+ : m
393
+ )
394
+ );
395
+ setError(msg.error);
396
+ } else if (msg.type === 'result') {
397
+ updateMessages(prev =>
398
+ prev.map(m => {
399
+ if (m.question.id !== questionId) return m;
400
+ const resultText = msg.result ?? '';
401
+ return {
402
+ ...m,
403
+ response: {
404
+ ...m.response,
405
+ text: m.response.text || resultText,
406
+ isStreaming: false,
407
+ },
408
+ };
409
+ })
410
+ );
411
+ }
412
+ } catch {
413
+ // Ignore malformed SSE lines.
414
+ }
415
+ }
416
+ }
417
+
418
+ updateMessages(prev =>
419
+ prev.map(m =>
420
+ m.question.id === questionId && m.response.isStreaming
421
+ ? { ...m, response: { ...m.response, isStreaming: false } }
422
+ : m
423
+ )
424
+ );
425
+ } catch (err) {
426
+ if (err instanceof Error && err.name === 'AbortError') {
427
+ updateMessages(prev =>
428
+ prev.map(m =>
429
+ m.question.id === questionId
430
+ ? { ...m, response: { ...m.response, isStreaming: false } }
431
+ : m
432
+ )
433
+ );
434
+ return;
435
+ }
436
+
437
+ const message = err instanceof Error ? err.message : String(err);
438
+ setError(message);
439
+ updateMessages(prev =>
440
+ prev.map(m =>
441
+ m.question.id === questionId
442
+ ? { ...m, response: { ...m.response, error: message, isStreaming: false } }
443
+ : m
444
+ )
445
+ );
446
+ } finally {
447
+ if (abortRef.current === controller) {
448
+ setIsStreaming(false);
449
+ abortRef.current = null;
450
+ }
451
+ }
452
+ }, [buildPrompt, createSession, updateMessages, updatePermissions, postServerAbort]);
453
+
454
+ const abort = useCallback(() => {
455
+ if (abortRef.current) {
456
+ abortRef.current.abort();
457
+ abortRef.current = null;
458
+ setIsStreaming(false);
459
+ }
460
+
461
+ // Drop any still-undecided permission cards: abort cancels them server-side,
462
+ // so leaving them on screen would be a dead Allow/Deny the agent never hears.
463
+ updatePermissions(prev => prev.filter(p => p.decided));
464
+
465
+ // Record the abort so a quick follow-up question waits for it (see ask()).
466
+ pendingAbortRef.current = postServerAbort();
467
+ }, [updatePermissions, postServerAbort]);
468
+
469
+ const respondToPermission = useCallback((requestId: string, allow: boolean) => {
470
+ if (!sessionIdRef.current) return;
471
+
472
+ updatePermissions(prev =>
473
+ prev.map(p => p.requestId === requestId ? { ...p, decided: allow ? 'allow' : 'deny' } : p)
474
+ );
475
+
476
+ aiTransport.permission({
477
+ sessionId: sessionIdRef.current,
478
+ requestId,
479
+ allow,
480
+ });
481
+ }, [updatePermissions]);
482
+
483
+ const resetSession = useCallback(() => {
484
+ sessionEpochRef.current += 1;
485
+ createRequestRef.current += 1;
486
+ if (abortRef.current) {
487
+ abortRef.current.abort();
488
+ abortRef.current = null;
489
+ }
490
+ setSessionId(null);
491
+ setIsCreatingSession(false);
492
+ setIsStreaming(false);
493
+ }, [setSessionId]);
494
+
495
+ const resetThread = useCallback(() => {
496
+ sessionEpochRef.current += 1;
497
+ createRequestRef.current += 1;
498
+ if (abortRef.current) {
499
+ abortRef.current.abort();
500
+ abortRef.current = null;
501
+ }
502
+ setThread(createThread(threadTitle));
503
+ setIsCreatingSession(false);
504
+ setIsStreaming(false);
505
+ setError(null);
506
+ }, [threadTitle]);
507
+
508
+ useEffect(() => {
509
+ return () => {
510
+ sessionEpochRef.current += 1;
511
+ createRequestRef.current += 1;
512
+ if (abortRef.current) {
513
+ abortRef.current.abort();
514
+ }
515
+ };
516
+ }, []);
517
+
518
+ return {
519
+ thread,
520
+ messages: thread.messages,
521
+ isCreatingSession,
522
+ isStreaming,
523
+ error,
524
+ permissionRequests: thread.permissionRequests,
525
+ respondToPermission,
526
+ ask,
527
+ abort,
528
+ resetSession,
529
+ resetThread,
530
+ sessionId: thread.sessionId,
531
+ };
532
+ }