@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.
- package/README.md +60 -0
- package/assets/diff-options.png +0 -0
- package/assets/icon-codex.png +0 -0
- package/assets/look-flat.png +0 -0
- package/assets/look-grid.png +0 -0
- package/assets/review-sections.png +0 -0
- package/assets/review-tree.png +0 -0
- package/assets/workspaces.webp +0 -0
- package/components/AISettingsTab.tsx +145 -0
- package/components/ActionMenu.tsx +101 -0
- package/components/AgentControls.tsx +216 -0
- package/components/AgentsTab.tsx +1294 -0
- package/components/AnnotationPanel.tsx +731 -0
- package/components/AnnotationSidebar.tsx +86 -0
- package/components/AnnotationToolbar.tsx +323 -0
- package/components/AnnotationToolstrip.tsx +355 -0
- package/components/ApproveDropdown.tsx +170 -0
- package/components/AttachmentsButton.tsx +410 -0
- package/components/BlockRenderer.tsx +163 -0
- package/components/BorderTrail.tsx +38 -0
- package/components/CodeFilePicker.tsx +64 -0
- package/components/CodeFilePopout.tsx +602 -0
- package/components/CodePathValidationContext.tsx +14 -0
- package/components/CommentPopover.tsx +512 -0
- package/components/CompletionOverlay.tsx +99 -0
- package/components/ConfirmDialog.tsx +128 -0
- package/components/DocBadges.tsx +231 -0
- package/components/EditorAnnotationCard.tsx +65 -0
- package/components/ExportModal.tsx +565 -0
- package/components/FloatingQuickLabelPicker.tsx +144 -0
- package/components/GitHubIcon.tsx +16 -0
- package/components/GitLabIcon.tsx +30 -0
- package/components/GraphvizBlock.tsx +511 -0
- package/components/ImageAnnotator/Canvas.tsx +118 -0
- package/components/ImageAnnotator/Toolbar.tsx +218 -0
- package/components/ImageAnnotator/index.tsx +272 -0
- package/components/ImageAnnotator/types.ts +39 -0
- package/components/ImageAnnotator/utils.ts +147 -0
- package/components/ImageThumbnail.tsx +126 -0
- package/components/ImportModal.tsx +146 -0
- package/components/InlineMarkdown.tsx +1094 -0
- package/components/KeyboardShortcuts.tsx +208 -0
- package/components/Landing.tsx +496 -0
- package/components/ListItemBody.tsx +62 -0
- package/components/ListMarker.tsx +73 -0
- package/components/LookAndFeelAnnouncementDialog.tsx +230 -0
- package/components/MarkdownEditor.tsx +44 -0
- package/components/MenuVersionSection.tsx +91 -0
- package/components/MermaidBlock.tsx +579 -0
- package/components/ModeToggle.tsx +68 -0
- package/components/OpenInAppButton.tsx +299 -0
- package/components/OverlayScrollArea.tsx +82 -0
- package/components/PermissionModeSetup.tsx +92 -0
- package/components/PinpointOverlay.tsx +99 -0
- package/components/PlanAIAnnouncementDialog.tsx +187 -0
- package/components/PlanHeaderMenu.tsx +299 -0
- package/components/PopoutDialog.tsx +91 -0
- package/components/Popover.tsx +27 -0
- package/components/ProviderIcons.tsx +51 -0
- package/components/PullRequestIcon.tsx +12 -0
- package/components/QuickLabelDropdown.tsx +63 -0
- package/components/RenderedMarkdown.tsx +54 -0
- package/components/RepoIcon.tsx +12 -0
- package/components/ResizeHandle.tsx +86 -0
- package/components/ReviewAgentsIcon.tsx +18 -0
- package/components/SearchableSelect.tsx +157 -0
- package/components/Settings.tsx +2220 -0
- package/components/SparklesIcon.tsx +45 -0
- package/components/StickyHeaderLane.tsx +260 -0
- package/components/TableOfContents.tsx +157 -0
- package/components/TaterSpritePullup.tsx +34 -0
- package/components/TaterSpriteRunning.tsx +54 -0
- package/components/TaterSpriteSitting.tsx +37 -0
- package/components/TextShimmer.tsx +57 -0
- package/components/ThemeProvider.tsx +162 -0
- package/components/ThemeTab.tsx +139 -0
- package/components/ToolbarButtons.tsx +116 -0
- package/components/Tooltip.tsx +45 -0
- package/components/Viewer.tsx +977 -0
- package/components/ai/AIProviderBar.tsx +95 -0
- package/components/ai/DocumentAIChatPanel.tsx +313 -0
- package/components/blocks/AlertBlock.tsx +58 -0
- package/components/blocks/Callout.tsx +60 -0
- package/components/blocks/CodeBlock.tsx +75 -0
- package/components/blocks/HtmlBlock.tsx +122 -0
- package/components/blocks/MathBlock.tsx +36 -0
- package/components/blocks/TableBlock.tsx +146 -0
- package/components/blocks/TablePopout.tsx +279 -0
- package/components/blocks/TableToolbar.tsx +153 -0
- package/components/blocks/proseBody.tsx +102 -0
- package/components/core/button.tsx +44 -0
- package/components/core/textarea.tsx +26 -0
- package/components/diagramLanguages.ts +14 -0
- package/components/goal-setup/GoalSetupSurface.tsx +1354 -0
- package/components/html-viewer/HtmlViewer.tsx +386 -0
- package/components/html-viewer/bridge-script.ts +505 -0
- package/components/html-viewer/index.ts +1 -0
- package/components/html-viewer/useHtmlAnnotation.ts +400 -0
- package/components/icons/AgentIcons.tsx +66 -0
- package/components/icons/AppIcon.tsx +56 -0
- package/components/icons/MessagesIcon.tsx +11 -0
- package/components/icons/ObsidianIcons.tsx +208 -0
- package/components/icons/app/android-studio.svg +369 -0
- package/components/icons/app/antigravity.svg +97 -0
- package/components/icons/app/cursor.svg +16 -0
- package/components/icons/app/file-explorer.svg +20 -0
- package/components/icons/app/finder.png +0 -0
- package/components/icons/app/ghostty.svg +13 -0
- package/components/icons/app/iterm2.svg +13 -0
- package/components/icons/app/powershell.svg +14 -0
- package/components/icons/app/sublime-text.svg +17 -0
- package/components/icons/app/terminal.png +0 -0
- package/components/icons/app/textmate.png +0 -0
- package/components/icons/app/vscode.svg +39 -0
- package/components/icons/app/warp.png +0 -0
- package/components/icons/app/xcode.png +0 -0
- package/components/icons/app/zed-dark.svg +15 -0
- package/components/icons/app/zed.svg +15 -0
- package/components/icons/themeIcons.tsx +47 -0
- package/components/mermaidSvg.ts +33 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +884 -0
- package/components/plan-diff/PlanDiffBadge.tsx +48 -0
- package/components/plan-diff/PlanDiffModeSwitcher.tsx +103 -0
- package/components/plan-diff/PlanDiffViewer.tsx +214 -0
- package/components/plan-diff/PlanRawDiffView.tsx +102 -0
- package/components/plan-diff/VSCodeIcon.tsx +132 -0
- package/components/settings/HooksTab.tsx +208 -0
- package/components/sidebar/ArchiveBrowser.tsx +98 -0
- package/components/sidebar/CountBadge.tsx +12 -0
- package/components/sidebar/FileBrowser.tsx +498 -0
- package/components/sidebar/MessagesBrowser.tsx +109 -0
- package/components/sidebar/SidebarContainer.tsx +353 -0
- package/components/sidebar/SidebarTabs.tsx +155 -0
- package/components/sidebar/VersionBrowser.tsx +141 -0
- package/components/types.d.ts +7 -0
- package/components/ui/badge.tsx +44 -0
- package/components/ui/button.tsx +83 -0
- package/components/ui/card.tsx +58 -0
- package/components/ui/dialog.tsx +106 -0
- package/components/ui/dropdown-menu.tsx +240 -0
- package/components/ui/state-pill.tsx +45 -0
- package/components/ui/tabs.tsx +46 -0
- package/components/ui/textarea.tsx +25 -0
- package/config/configStore.ts +214 -0
- package/config/index.ts +4 -0
- package/config/reviewView.ts +42 -0
- package/config/settings.ts +310 -0
- package/config/useConfig.ts +20 -0
- package/configure.ts +69 -0
- package/globals.d.ts +14 -0
- package/hooks/pfm/useCodeFilePopout.ts +111 -0
- package/hooks/useAIChat.ts +532 -0
- package/hooks/useAIProviderConfig.ts +115 -0
- package/hooks/useActiveSection.ts +78 -0
- package/hooks/useAgentJobs.ts +306 -0
- package/hooks/useAgentSettings.ts +579 -0
- package/hooks/useAgents.ts +90 -0
- package/hooks/useAnnotationDraft.ts +523 -0
- package/hooks/useAnnotationHighlighter.ts +1170 -0
- package/hooks/useArchive.ts +171 -0
- package/hooks/useAutoClose.ts +104 -0
- package/hooks/useCodeAnnotationDraft.ts +194 -0
- package/hooks/useCodeFilePopout.ts +1 -0
- package/hooks/useDismissOnOutsideAndEscape.ts +50 -0
- package/hooks/useDraggable.ts +108 -0
- package/hooks/useEditorAnnotations.ts +64 -0
- package/hooks/useExternalAnnotationHighlights.ts +105 -0
- package/hooks/useExternalAnnotations.ts +268 -0
- package/hooks/useFileBrowser.ts +402 -0
- package/hooks/useInputMethodSwitch.ts +89 -0
- package/hooks/useIsMobile.ts +17 -0
- package/hooks/useLinkedDoc.ts +494 -0
- package/hooks/useOverlayViewport.ts +36 -0
- package/hooks/usePinpoint.ts +182 -0
- package/hooks/usePlanDiff.ts +176 -0
- package/hooks/usePrintMode.ts +27 -0
- package/hooks/useResizablePanel.ts +173 -0
- package/hooks/useScrollViewport.ts +38 -0
- package/hooks/useSharing.ts +441 -0
- package/hooks/useSidebar.ts +55 -0
- package/hooks/useUpdateCheck.ts +132 -0
- package/hooks/useValidatedCodePaths.ts +94 -0
- package/icons/GitUser.tsx +17 -0
- package/lib/utils.ts +11 -0
- package/package.json +117 -0
- package/plannotator.webp +0 -0
- package/print.css +427 -0
- package/shortcuts/code-review/ai.shortcuts.ts +25 -0
- package/shortcuts/code-review/allFilesDiff.shortcuts.ts +46 -0
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +30 -0
- package/shortcuts/code-review/fileTree.shortcuts.ts +35 -0
- package/shortcuts/code-review/prComments.shortcuts.ts +18 -0
- package/shortcuts/code-review/suggestionModal.shortcuts.ts +25 -0
- package/shortcuts/code-review/tourDialog.shortcuts.ts +18 -0
- package/shortcuts/core.ts +399 -0
- package/shortcuts/index.ts +21 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +25 -0
- package/shortcuts/plan-review/annotationToolbar.shortcuts.ts +39 -0
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +21 -0
- package/shortcuts/plan-review/goalSetup.shortcuts.ts +10 -0
- package/shortcuts/plan-review/imageAnnotator.shortcuts.ts +42 -0
- package/shortcuts/plan-review/inputMethod.shortcuts.ts +30 -0
- package/shortcuts/plan-review/sidebar.shortcuts.ts +34 -0
- package/shortcuts/plan-review/viewer.shortcuts.ts +24 -0
- package/shortcuts/runtime.ts +259 -0
- package/sprite_package_additional/index.html +34 -0
- package/sprite_package_additional/sprite.png +0 -0
- package/sprite_package_new/index.html +34 -0
- package/sprite_package_new/sprite.png +0 -0
- package/sprite_package_pulluphang/index.html +34 -0
- package/sprite_package_pulluphang/sprite.png +0 -0
- package/styles.css +1 -0
- package/theme.css +893 -0
- package/themes/adwaita.css +62 -0
- package/themes/andromeeda.css +66 -0
- package/themes/aurora-x.css +66 -0
- package/themes/ayu-dark.css +66 -0
- package/themes/caffeine.css +113 -0
- package/themes/catppuccin.css +62 -0
- package/themes/claude-plus.css +60 -0
- package/themes/cursor-hc.css +34 -0
- package/themes/cursor-midnight.css +34 -0
- package/themes/cursor.css +62 -0
- package/themes/dark-plus.css +66 -0
- package/themes/doom-64.css +109 -0
- package/themes/dracula.css +33 -0
- package/themes/everforest-hard.css +62 -0
- package/themes/everforest-soft.css +62 -0
- package/themes/everforest.css +62 -0
- package/themes/github.css +66 -0
- package/themes/gruvbox.css +62 -0
- package/themes/houston.css +66 -0
- package/themes/kanagawa-dragon.css +34 -0
- package/themes/kanagawa-lotus.css +34 -0
- package/themes/kanagawa-wave.css +34 -0
- package/themes/laserwave.css +66 -0
- package/themes/material.css +62 -0
- package/themes/min.css +66 -0
- package/themes/monokai-pro.css +34 -0
- package/themes/neutral.css +59 -0
- package/themes/night-owl.css +66 -0
- package/themes/nord.css +66 -0
- package/themes/one-dark-pro.css +66 -0
- package/themes/one-light.css +66 -0
- package/themes/paulmillr.css +34 -0
- package/themes/plannotator.css +60 -0
- package/themes/plastic.css +66 -0
- package/themes/poimandres.css +66 -0
- package/themes/quantum-rose.css +109 -0
- package/themes/red.css +66 -0
- package/themes/rose-pine.css +62 -0
- package/themes/simple.css +124 -0
- package/themes/slack.css +66 -0
- package/themes/snazzy-light.css +66 -0
- package/themes/soft-pop.css +60 -0
- package/themes/solar-dusk.css +109 -0
- package/themes/solarized.css +66 -0
- package/themes/synthwave-84.css +34 -0
- package/themes/terminal.css +62 -0
- package/themes/tinacious.css +57 -0
- package/themes/tokyo-night.css +62 -0
- package/themes/vesper.css +62 -0
- package/themes/vitesse-black.css +66 -0
- package/themes/vitesse.css +62 -0
- package/types.ts +264 -0
- package/utils/agentSwitch.ts +71 -0
- package/utils/aiChatFormat.ts +33 -0
- package/utils/aiPrompt.ts +29 -0
- package/utils/aiProvider.ts +217 -0
- package/utils/anchors.ts +11 -0
- package/utils/annotateAgentTerminal.ts +29 -0
- package/utils/annotationHelpers.ts +101 -0
- package/utils/bear.ts +59 -0
- package/utils/blockTargeting.ts +240 -0
- package/utils/callback.ts +99 -0
- package/utils/commentContent.ts +8 -0
- package/utils/defaultNotesApp.ts +20 -0
- package/utils/diffFonts.ts +33 -0
- package/utils/editorMode.ts +31 -0
- package/utils/fileBrowser.ts +40 -0
- package/utils/generateId.ts +7 -0
- package/utils/generateIdentity.ts +29 -0
- package/utils/identity.ts +112 -0
- package/utils/inlineTransforms.ts +41 -0
- package/utils/inputMethod.ts +17 -0
- package/utils/lookAndFeelAnnouncement.ts +18 -0
- package/utils/obsidian.ts +201 -0
- package/utils/octarine.ts +53 -0
- package/utils/parser.ts +991 -0
- package/utils/permissionMode.ts +77 -0
- package/utils/planAIAnnouncement.ts +17 -0
- package/utils/planAgentInstructions.ts +137 -0
- package/utils/planDiffEngine.ts +590 -0
- package/utils/planSave.ts +49 -0
- package/utils/platform.ts +11 -0
- package/utils/quickLabels.ts +77 -0
- package/utils/reviewAgentInstructions.ts +182 -0
- package/utils/sanitizeHtml.ts +45 -0
- package/utils/sharing.ts +350 -0
- package/utils/slugify.ts +37 -0
- package/utils/storage.ts +141 -0
- package/utils/themeRegistry.ts +567 -0
- package/utils/uiPreferences.ts +34 -0
- package/utils/upload.ts +56 -0
- 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
|
+
}
|