@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,115 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import type { Origin } from '@plannotator/core/agents';
3
+ import {
4
+ getAIProviderSettings,
5
+ resolveAIProviderSelection,
6
+ resolveAIModelForProvider,
7
+ saveAIProviderSelection,
8
+ type AIProviderOption,
9
+ } from '../utils/aiProvider';
10
+
11
+ /**
12
+ * Shared AI provider/model/reasoning-effort selection state for the plan and
13
+ * code-review apps. Previously each App.tsx kept its own copy of this logic,
14
+ * which drifted (e.g. a per-model effort fix landed in one app but not the
15
+ * other). This owns the config logic in one place:
16
+ *
17
+ * - initial selection from saved settings,
18
+ * - auto-resolving provider/model once capabilities load,
19
+ * - per-model reasoning effort (a level set on one model never leaks to another),
20
+ * - persistence of the provider/model choice.
21
+ *
22
+ * It deliberately does NOT reset the AI session: `useAIChat` needs `aiConfig`,
23
+ * and its `resetSession` in turn would depend on this hook — a cycle. So the
24
+ * caller composes the reset (`applyConfigChange(c); resetSession()`), keeping
25
+ * session lifecycle with the app and config logic here.
26
+ */
27
+ export interface AIProviderConfig {
28
+ providerId: string | null;
29
+ model: string | null;
30
+ reasoningEffort: string | null;
31
+ }
32
+
33
+ interface UseAIProviderConfigOptions {
34
+ providers: AIProviderOption[];
35
+ defaultProvider: string | null;
36
+ available: boolean;
37
+ origin: Origin | null | undefined;
38
+ }
39
+
40
+ export function useAIProviderConfig({
41
+ providers,
42
+ defaultProvider,
43
+ available,
44
+ origin,
45
+ }: UseAIProviderConfigOptions) {
46
+ const [aiConfig, setAiConfig] = useState(() => {
47
+ const saved = getAIProviderSettings();
48
+ const pid = saved.providerId;
49
+ return {
50
+ providerId: pid,
51
+ model: pid ? (saved.preferredModels[pid] ?? null) : null,
52
+ reasoningEffort: null as string | null,
53
+ // Reasoning effort is tracked per model so switching models can't carry a
54
+ // stale (possibly unsupported) level across to a different model.
55
+ reasoningEffortByModel: {} as Record<string, string>,
56
+ };
57
+ });
58
+
59
+ // Auto-resolve provider/model once capabilities are known.
60
+ useEffect(() => {
61
+ if (!available || providers.length === 0) return;
62
+ setAiConfig(prev => {
63
+ const saved = getAIProviderSettings();
64
+ const selection = resolveAIProviderSelection({
65
+ providers,
66
+ origin,
67
+ settings: saved,
68
+ serverDefaultProvider: defaultProvider,
69
+ });
70
+ if (prev.providerId === selection.providerId && prev.model === selection.model) return prev;
71
+ return {
72
+ ...prev,
73
+ providerId: selection.providerId,
74
+ model: selection.model,
75
+ reasoningEffort: selection.model ? (prev.reasoningEffortByModel[selection.model] ?? null) : null,
76
+ };
77
+ });
78
+ }, [available, providers, defaultProvider, origin]);
79
+
80
+ // Update the selection (provider/model/effort). Does NOT reset the session —
81
+ // the caller composes that (see the cycle note above).
82
+ const applyConfigChange = useCallback(
83
+ (config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => {
84
+ setAiConfig(prev => {
85
+ const saved = getAIProviderSettings();
86
+ const providerId = config.providerId !== undefined ? config.providerId : prev.providerId;
87
+ const providerChanged = config.providerId !== undefined && config.providerId !== prev.providerId;
88
+ const provider = providers.find(p => p.id === providerId) ?? null;
89
+ const model = providerChanged
90
+ ? (config.model !== undefined ? config.model : resolveAIModelForProvider(provider, saved.preferredModels))
91
+ : (config.model !== undefined ? config.model : prev.model);
92
+ // Per-model reasoning effort: record an explicit change against the
93
+ // current model, then derive the effective level from the (possibly new)
94
+ // model — so a provider/model switch never carries a stale level across.
95
+ const reasoningEffortByModel = { ...prev.reasoningEffortByModel };
96
+ if (config.reasoningEffort !== undefined && prev.model) {
97
+ if (config.reasoningEffort === null) delete reasoningEffortByModel[prev.model];
98
+ else reasoningEffortByModel[prev.model] = config.reasoningEffort;
99
+ }
100
+ const reasoningEffort = model ? (reasoningEffortByModel[model] ?? null) : null;
101
+ const next = { ...prev, providerId, model, reasoningEffort, reasoningEffortByModel };
102
+ saveAIProviderSelection({
103
+ providerId: next.providerId,
104
+ model: next.model,
105
+ origin,
106
+ settings: saved,
107
+ });
108
+ return next;
109
+ });
110
+ },
111
+ [providers, origin],
112
+ );
113
+
114
+ return { aiConfig, applyConfigChange };
115
+ }
@@ -0,0 +1,78 @@
1
+ import { useEffect, useState, useRef } from 'react';
2
+
3
+ /**
4
+ * Track which heading section is currently visible in the viewport
5
+ * Uses Intersection Observer to detect when headings enter/leave view
6
+ */
7
+ export function useActiveSection(
8
+ containerRef: React.RefObject<HTMLElement | null>,
9
+ headingCount: number,
10
+ /** Optional explicit scroll element. When provided, takes precedence over
11
+ * containerRef.current and re-runs the effect when it changes — needed
12
+ * for OverlayScrollArea, whose viewport is only available after mount. */
13
+ scrollElement?: HTMLElement | null,
14
+ ) {
15
+ const [activeId, setActiveId] = useState<string | null>(null);
16
+ const observerRef = useRef<IntersectionObserver | null>(null);
17
+
18
+ useEffect(() => {
19
+ const container = scrollElement ?? containerRef.current;
20
+ if (!container) return;
21
+
22
+ // Find all heading elements with data-block-id
23
+ const headings = container.querySelectorAll('[data-block-type="heading"]');
24
+ if (headings.length === 0) return;
25
+
26
+ // Track which headings are currently intersecting
27
+ const intersectingHeadings = new Map<Element, boolean>();
28
+
29
+ // Create observer with configuration to detect headings near top of viewport
30
+ observerRef.current = new IntersectionObserver(
31
+ (entries) => {
32
+ // Update intersection state
33
+ entries.forEach(entry => {
34
+ intersectingHeadings.set(entry.target, entry.isIntersecting);
35
+ });
36
+
37
+ // Find the first intersecting heading (topmost in viewport)
38
+ let topHeading: Element | null = null;
39
+ let topPosition = Infinity;
40
+
41
+ for (const [heading, isIntersecting] of intersectingHeadings) {
42
+ if (isIntersecting) {
43
+ const rect = heading.getBoundingClientRect();
44
+ if (rect.top < topPosition) {
45
+ topPosition = rect.top;
46
+ topHeading = heading;
47
+ }
48
+ }
49
+ }
50
+
51
+ // Update active ID
52
+ if (topHeading) {
53
+ const blockId = topHeading.getAttribute('data-block-id');
54
+ if (blockId) {
55
+ setActiveId(blockId);
56
+ }
57
+ }
58
+ },
59
+ {
60
+ root: container,
61
+ rootMargin: '-80px 0px -80% 0px', // Activate when heading is near top
62
+ threshold: [0, 0.1, 0.5, 1.0],
63
+ }
64
+ );
65
+
66
+ // Observe all headings
67
+ headings.forEach(heading => {
68
+ observerRef.current?.observe(heading);
69
+ });
70
+
71
+ // Cleanup
72
+ return () => {
73
+ observerRef.current?.disconnect();
74
+ };
75
+ }, [containerRef, headingCount, scrollElement]);
76
+
77
+ return activeId;
78
+ }
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Real-time agent job state via SSE with polling fallback.
3
+ *
4
+ * Primary transport: EventSource on /api/agents/jobs/stream.
5
+ * Fallback: version-gated GET polling if SSE fails.
6
+ *
7
+ * Mirrors packages/ui/hooks/useExternalAnnotations.ts in structure.
8
+ *
9
+ * Gated by an `enabled` option — callers pass their API-mode signal
10
+ * to avoid SSE/polling in static or demo contexts where there is no server.
11
+ */
12
+
13
+ import { useState, useEffect, useCallback, useRef } from 'react';
14
+ import type { AgentJobInfo, AgentJobEvent, AgentCapabilities } from '../types';
15
+
16
+ const POLL_INTERVAL_MS = 500;
17
+ const STREAM_URL = '/api/agents/jobs/stream';
18
+ const JOBS_URL = '/api/agents/jobs';
19
+ const CAPABILITIES_URL = '/api/agents/capabilities';
20
+ const DEFAULT_LAUNCH_ERROR = 'Could not start agent job.';
21
+ const AGENT_JOB_STATUSES = new Set(['starting', 'running', 'done', 'failed', 'killed']);
22
+
23
+ export type AgentLaunchParams = {
24
+ provider?: string;
25
+ command?: string[];
26
+ label?: string;
27
+ engine?: string;
28
+ model?: string;
29
+ reasoningEffort?: string;
30
+ effort?: string;
31
+ /** Pi's unified reasoning level (`--thinking off|minimal|low|medium|high|xhigh`). */
32
+ thinking?: string;
33
+ fastMode?: boolean;
34
+ reviewProfileId?: string;
35
+ /** Launches a guide-repair job against a failed guide job's captured output
36
+ * (see GuideEmptyState's failure-recovery panel). The server resolves a
37
+ * schema-capable engine and starts a new, normal guide job rather than
38
+ * mutating the failed one in place. */
39
+ repairOf?: string;
40
+ };
41
+
42
+ /** Does a job belong to the given review context? Jobs launched against a PR
43
+ * are stamped with that PR's url; local-diff jobs carry none. Used to scope
44
+ * guide/tour auto-opens, the guide takeover, and "Open guide" affordances so
45
+ * an artifact from PR A never opens (or offers to open) while reviewing PR B.
46
+ * `currentPrUrl` undefined ⇒ local-diff mode.
47
+ *
48
+ * prUrl + worktreePath define WHERE the review is (the context — matched
49
+ * strictly below); mode/base/scope define WHAT VIEW of that context is on
50
+ * screen. In PR mode only prUrl is compared (a PR checkout has no separate
51
+ * worktree axis). In local-diff mode, `currentWorktreePath` distinguishes a
52
+ * review retargeted at a git worktree (`worktree:<path>:<subType>` diff
53
+ * types — see parseWorktreeDiffType) from the main tree, so a guide/tour
54
+ * launched against worktree A never opens while reviewing worktree B. Jobs
55
+ * launched before this field existed, and jobs from providers that never set
56
+ * `diffContext`, coalesce to the main tree (`null`) — deliberate, so old jobs
57
+ * keep matching the common case instead of going permanently unmatchable.
58
+ *
59
+ * DELIBERATELY matches by prUrl/worktreePath only — NOT diffScope/mode/base,
60
+ * even though jobs carry them. Annotations scope by diffScope because they
61
+ * pin exact line positions in a specific patch; guides/tours reference FILES
62
+ * and degrade per-file ("no longer in the current diff") when the diff
63
+ * shifts underneath them. Layer→full-stack is a superset (the artifact
64
+ * fully resolves — hiding it would be hostile); full-stack→layer and local
65
+ * base/mode switches degrade honestly. Scope-strict matching here would
66
+ * vaporize a useful guide because the reviewer toggled since-base →
67
+ * uncommitted. Do not "tighten" this without a UX decision. */
68
+ export function jobMatchesReviewContext(
69
+ job: Pick<AgentJobInfo, 'prUrl' | 'diffContext'>,
70
+ currentPrUrl: string | undefined,
71
+ currentWorktreePath?: string | null,
72
+ ): boolean {
73
+ if (currentPrUrl) return job.prUrl === currentPrUrl;
74
+ if (job.prUrl) return false;
75
+ return (job.diffContext?.worktreePath ?? null) === (currentWorktreePath ?? null);
76
+ }
77
+
78
+ function isRecord(value: unknown): value is Record<string, unknown> {
79
+ return typeof value === 'object' && value !== null;
80
+ }
81
+
82
+ function isStringArray(value: unknown): value is string[] {
83
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
84
+ }
85
+
86
+ function isAgentJobInfo(value: unknown): value is AgentJobInfo {
87
+ if (!isRecord(value)) return false;
88
+ return (
89
+ typeof value.id === 'string' &&
90
+ typeof value.source === 'string' &&
91
+ typeof value.provider === 'string' &&
92
+ typeof value.label === 'string' &&
93
+ typeof value.status === 'string' &&
94
+ AGENT_JOB_STATUSES.has(value.status) &&
95
+ typeof value.startedAt === 'number' &&
96
+ isStringArray(value.command)
97
+ );
98
+ }
99
+
100
+ function upsertJob(jobs: AgentJobInfo[], job: AgentJobInfo): AgentJobInfo[] {
101
+ const existing = jobs.findIndex((current) => current.id === job.id);
102
+ if (existing === -1) return [...jobs, job];
103
+ const next = [...jobs];
104
+ next[existing] = job;
105
+ return next;
106
+ }
107
+
108
+ async function readResponseError(response: Response): Promise<string> {
109
+ try {
110
+ const body: unknown = await response.json();
111
+ if (body && typeof body === 'object' && 'error' in body) {
112
+ const error = (body as { readonly error?: unknown }).error;
113
+ if (typeof error === 'string' && error.trim()) return error;
114
+ }
115
+ } catch {
116
+ // Fall through to the generic launch message.
117
+ }
118
+ return DEFAULT_LAUNCH_ERROR;
119
+ }
120
+
121
+ function parseLaunchJob(body: unknown): AgentJobInfo | null {
122
+ if (!isRecord(body)) return null;
123
+ const job = body.job;
124
+ return isAgentJobInfo(job) ? job : null;
125
+ }
126
+
127
+ interface UseAgentJobsReturn {
128
+ jobs: AgentJobInfo[];
129
+ jobLogs: Map<string, string>;
130
+ capabilities: AgentCapabilities | null;
131
+ /** Rejects with a user-facing message when the server refuses the launch. */
132
+ launchJob: (params: AgentLaunchParams) => Promise<AgentJobInfo | null>;
133
+ killJob: (id: string) => Promise<void>;
134
+ killAll: () => Promise<void>;
135
+ }
136
+
137
+ export function useAgentJobs(
138
+ options?: { enabled?: boolean },
139
+ ): UseAgentJobsReturn {
140
+ const enabled = options?.enabled ?? true;
141
+ const [jobs, setJobs] = useState<AgentJobInfo[]>([]);
142
+ const [jobLogs, setJobLogs] = useState<Map<string, string>>(new Map());
143
+ const [capabilities, setCapabilities] = useState<AgentCapabilities | null>(null);
144
+ const versionRef = useRef(0);
145
+ const fallbackRef = useRef(false);
146
+ const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
147
+ const receivedSnapshotRef = useRef(false);
148
+
149
+ // Fetch capabilities once on mount
150
+ useEffect(() => {
151
+ if (!enabled) return;
152
+
153
+ fetch(CAPABILITIES_URL)
154
+ .then((res) => res.json())
155
+ .then((data) => {
156
+ if (data && Array.isArray(data.providers)) {
157
+ setCapabilities(data as AgentCapabilities);
158
+ }
159
+ })
160
+ .catch(() => {
161
+ // Silent — capabilities unavailable
162
+ });
163
+ }, [enabled]);
164
+
165
+ // SSE + polling for job state
166
+ useEffect(() => {
167
+ if (!enabled) return;
168
+ let cancelled = false;
169
+ receivedSnapshotRef.current = false;
170
+ fallbackRef.current = false;
171
+
172
+ // --- SSE primary transport ---
173
+ const es = new EventSource(STREAM_URL);
174
+
175
+ es.onmessage = (event) => {
176
+ if (cancelled) return;
177
+
178
+ try {
179
+ const parsed: AgentJobEvent = JSON.parse(event.data);
180
+
181
+ switch (parsed.type) {
182
+ case 'snapshot':
183
+ receivedSnapshotRef.current = true;
184
+ setJobs(parsed.jobs);
185
+ break;
186
+ case 'job:started':
187
+ setJobs((prev) => upsertJob(prev, parsed.job));
188
+ break;
189
+ case 'job:updated':
190
+ case 'job:completed':
191
+ setJobs((prev) =>
192
+ prev.map((j) => (j.id === parsed.job.id ? parsed.job : j)),
193
+ );
194
+ break;
195
+ case 'job:log':
196
+ setJobLogs((prev) => {
197
+ const next = new Map(prev);
198
+ next.set(parsed.jobId, (prev.get(parsed.jobId) ?? '') + parsed.delta);
199
+ return next;
200
+ });
201
+ break;
202
+ case 'jobs:cleared':
203
+ // No-op: killAll() already broadcasts individual job:completed events
204
+ // for each killed job, so the UI updates incrementally.
205
+ break;
206
+ }
207
+ } catch {
208
+ // Ignore malformed events (e.g., heartbeat comments)
209
+ }
210
+ };
211
+
212
+ es.onerror = () => {
213
+ // If we never received a snapshot, SSE isn't working — fall back to polling
214
+ if (!receivedSnapshotRef.current && !fallbackRef.current) {
215
+ fallbackRef.current = true;
216
+ es.close();
217
+ startPolling();
218
+ }
219
+ // Otherwise, EventSource will auto-reconnect and we'll get a fresh snapshot
220
+ };
221
+
222
+ // --- Polling fallback ---
223
+ function startPolling() {
224
+ if (cancelled) return;
225
+
226
+ fetchSnapshot();
227
+
228
+ pollTimerRef.current = setInterval(() => {
229
+ if (cancelled) return;
230
+ fetchSnapshot();
231
+ }, POLL_INTERVAL_MS);
232
+ }
233
+
234
+ async function fetchSnapshot() {
235
+ try {
236
+ const url =
237
+ versionRef.current > 0
238
+ ? `${JOBS_URL}?since=${versionRef.current}`
239
+ : JOBS_URL;
240
+
241
+ const res = await fetch(url);
242
+
243
+ if (res.status === 304) return;
244
+ if (!res.ok) return;
245
+
246
+ const data = await res.json();
247
+ if (Array.isArray(data.jobs)) {
248
+ setJobs(data.jobs);
249
+ }
250
+ if (typeof data.version === 'number') {
251
+ versionRef.current = data.version;
252
+ }
253
+ } catch {
254
+ // Silent — next poll will retry
255
+ }
256
+ }
257
+
258
+ return () => {
259
+ cancelled = true;
260
+ es.close();
261
+ if (pollTimerRef.current) {
262
+ clearInterval(pollTimerRef.current);
263
+ pollTimerRef.current = null;
264
+ }
265
+ };
266
+ }, [enabled]);
267
+
268
+ const launchJob = useCallback(
269
+ async (params: AgentLaunchParams): Promise<AgentJobInfo | null> => {
270
+ const res = await fetch(JOBS_URL, {
271
+ method: 'POST',
272
+ headers: { 'Content-Type': 'application/json' },
273
+ body: JSON.stringify(params),
274
+ });
275
+ if (!res.ok) {
276
+ throw new Error(await readResponseError(res));
277
+ }
278
+
279
+ const data: unknown = await res.json();
280
+ const job = parseLaunchJob(data);
281
+ if (job) setJobs((prev) => upsertJob(prev, job));
282
+ return job;
283
+ },
284
+ [],
285
+ );
286
+
287
+ const killJob = useCallback(async (id: string) => {
288
+ try {
289
+ await fetch(`${JOBS_URL}/${encodeURIComponent(id)}`, {
290
+ method: 'DELETE',
291
+ });
292
+ } catch {
293
+ // SSE will reconcile
294
+ }
295
+ }, []);
296
+
297
+ const killAll = useCallback(async () => {
298
+ try {
299
+ await fetch(JOBS_URL, { method: 'DELETE' });
300
+ } catch {
301
+ // SSE will reconcile
302
+ }
303
+ }, []);
304
+
305
+ return { jobs, jobLogs, capabilities, launchJob, killJob, killAll };
306
+ }