@plannotator/ui 0.53.0 → 0.54.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/HANDOFF.md +284 -27
- package/README.md +84 -14
- package/components/AgentToolAnnouncementDialog.tsx +1 -1
- package/components/AgentsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +177 -34
- package/components/AnnotationSidebar.tsx +1 -1
- package/components/AnnotationToolbar.tsx +64 -9
- package/components/AnnotationToolstrip.tsx +45 -40
- package/components/ApproveDropdown.tsx +7 -9
- package/components/AskSessionAnnouncementDialog.tsx +1 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +19 -5
- package/components/CodeFilePopout.tsx +140 -5
- package/components/CommentPopover.tsx +46 -8
- package/components/DecisionControl.tsx +31 -5
- package/components/DiagramBlock.tsx +17 -12
- package/components/EditorAnnotationCard.tsx +1 -1
- package/components/ExportModal.tsx +77 -7
- package/components/HostTitle.tsx +0 -4
- package/components/HtmlSurfaceControls.tsx +2 -1
- package/components/ImageAnnotator/Toolbar.tsx +25 -1
- package/components/ImageAnnotator/index.tsx +24 -4
- package/components/ImageLightbox.tsx +658 -120
- package/components/ImagePeek.tsx +38 -4
- package/components/ImageRegions.tsx +369 -0
- package/components/ImageThumbnail.tsx +1 -1
- package/components/ImageZoomStage.tsx +796 -0
- package/components/InboxAnnouncementDialog.tsx +202 -0
- package/components/InlineMarkdown.tsx +152 -24
- package/components/KeyboardShortcuts.tsx +23 -0
- package/components/LazyBlockBoundary.tsx +100 -0
- package/components/ListMarker.tsx +3 -3
- package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
- package/components/MentionPicker.tsx +51 -0
- package/components/MenuVersionSection.tsx +31 -9
- package/components/PlanDecisionMenuExtras.tsx +129 -0
- package/components/PlanHeaderMenu.tsx +15 -1
- package/components/ProviderIcons.tsx +8 -0
- package/components/RefHoverCard.tsx +913 -0
- package/components/SendFailureToast.tsx +126 -0
- package/components/Settings.tsx +617 -126
- package/components/SkillReferenceMenu.tsx +39 -12
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/ThemeProvider.tsx +14 -1
- package/components/ThemeTab.tsx +16 -24
- package/components/Tooltip.tsx +49 -18
- package/components/Viewer.tsx +316 -47
- package/components/ai/DocumentAIChatPanel.tsx +29 -3
- package/components/ai/NewChatButton.tsx +74 -0
- package/components/blocks/CodeBlock.tsx +1 -1
- package/components/blocks/HtmlBlock.tsx +7 -1
- package/components/blocks/QuestionBlock.tsx +522 -62
- package/components/blocks/QuestionContext.tsx +3 -0
- package/components/diagram/DiagramComposer.tsx +5 -1
- package/components/diagram/DiagramPending.tsx +12 -2
- package/components/diagram/DiagramViewer.tsx +19 -3
- package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
- package/components/html-embed/SvgFenceBlock.tsx +46 -0
- package/components/html-embed/index.ts +3 -0
- package/components/html-embed/languages.ts +15 -0
- package/components/html-embed/loadGate.ts +107 -0
- package/components/html-viewer/HtmlViewer.tsx +69 -6
- package/components/html-viewer/bridge-script.asset.js +504 -12
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +545 -12
- package/components/html-viewer/useHtmlAnnotation.ts +125 -0
- package/components/imageZoom.ts +265 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
- package/components/plan-diff/PlanDiffViewer.tsx +13 -2
- package/components/plan-diff/PlanRawDiffView.tsx +1 -1
- package/components/sidebar/FileBrowser.tsx +16 -2
- package/components/sidebar/SidebarTabs.tsx +3 -0
- package/config/index.ts +3 -0
- package/config/reviewView.ts +41 -1
- package/config/settings.ts +155 -4
- package/configure.ts +28 -0
- package/hooks/pfm/useCodeFilePopout.ts +7 -1
- package/hooks/useAIChat.ts +72 -25
- package/hooks/useAnnotationDraft.ts +71 -6
- package/hooks/useAnnotationHighlighter.ts +57 -3
- package/hooks/useCodeAnnotationDraft.ts +113 -17
- package/hooks/useCookieHostSetting.ts +96 -0
- package/hooks/useDocumentFont.ts +17 -0
- package/hooks/useDocumentTextSize.ts +20 -0
- package/hooks/useFileBrowser.ts +10 -1
- package/hooks/useLinkedDoc.ts +226 -13
- package/hooks/useMentionAutocomplete.ts +1 -1
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useSkillReferenceAutocomplete.ts +7 -3
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/hooks/useVimSelection.ts +9 -0
- package/package.json +23 -22
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
- package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
- package/shortcuts/core.ts +63 -4
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
- package/shortcuts/runtime.ts +14 -6
- package/styles.css +1 -1
- package/theme.css +179 -1
- package/themes/plannotator-graphite.css +66 -0
- package/types.ts +26 -9
- package/utils/agentSwitch.ts +41 -20
- package/utils/agentToolSetting.ts +1 -1
- package/utils/aiChatFormat.ts +23 -7
- package/utils/annotationOwnsHighlight.ts +4 -2
- package/utils/asciidocParser.ts +570 -0
- package/utils/autoUpdateNotice.ts +24 -0
- package/utils/belowTitleSlot.ts +174 -0
- package/utils/codeLineScroll.ts +100 -0
- package/utils/commentSubmitKey.ts +48 -0
- package/utils/cssFontFamily.ts +65 -0
- package/utils/decisionSpec.ts +190 -2
- package/utils/defaultNotesApp.ts +1 -1
- package/utils/diffFonts.ts +4 -1
- package/utils/documentFont.ts +68 -0
- package/utils/documentTextSize.ts +54 -0
- package/utils/fontDetect.ts +63 -0
- package/utils/inboxAnnouncement.ts +78 -0
- package/utils/lookAndFeelAnnouncement.ts +82 -5
- package/utils/markdownExtensions.ts +2 -2
- package/utils/mentions.ts +34 -5
- package/utils/mermaid.ts +20 -3
- package/utils/mermaidTheme.ts +3 -51
- package/utils/notion.ts +58 -0
- package/utils/parser.ts +28 -1
- package/utils/platform.ts +46 -0
- package/utils/refCard.ts +82 -0
- package/utils/richClipboard.ts +380 -0
- package/utils/richClipboardLists.ts +57 -0
- package/utils/richCopyPolicy.ts +22 -0
- package/utils/scrollAnchor.ts +82 -0
- package/utils/sectionRefs.ts +123 -0
- package/utils/selectionBoundary.ts +40 -0
- package/utils/sendFailure.ts +85 -0
- package/utils/sharing.ts +5 -2
- package/utils/themeRegistry.ts +10 -0
- package/utils/themeTokens.ts +74 -0
- package/utils/updateNotice.ts +30 -8
- package/utils/vscodePanel.ts +9 -0
package/config/settings.ts
CHANGED
|
@@ -13,8 +13,14 @@ import {
|
|
|
13
13
|
isAnnotateAgentTerminalSide,
|
|
14
14
|
type AnnotateAgentTerminalSide,
|
|
15
15
|
} from '@plannotator/core/agent-terminal';
|
|
16
|
-
import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
|
|
16
|
+
import type { DiffLineBgIntensity, ReviewDefaults } from '@plannotator/core/config-types';
|
|
17
17
|
import { isFaviconStyle, type FaviconStyle } from '@plannotator/core/favicon';
|
|
18
|
+
import {
|
|
19
|
+
agentSwitchConfigValue,
|
|
20
|
+
isAgentSwitchName,
|
|
21
|
+
parseAgentSwitchConfig,
|
|
22
|
+
type AgentSwitchChoice,
|
|
23
|
+
} from '@plannotator/core/agent-switch';
|
|
18
24
|
import {
|
|
19
25
|
DEFAULT_TOKEN_HOVER_DELAY_MS,
|
|
20
26
|
isTokenHoverDelay,
|
|
@@ -23,7 +29,14 @@ import {
|
|
|
23
29
|
type TokenHoverTrigger,
|
|
24
30
|
} from '@plannotator/core/token-hover';
|
|
25
31
|
import { DEFAULT_DIAGRAM_SHADOW, isDiagramShadow } from '../utils/diagramShadow';
|
|
32
|
+
import {
|
|
33
|
+
DEFAULT_DOCUMENT_TEXT_SIZE,
|
|
34
|
+
isDocumentTextSize,
|
|
35
|
+
type DocumentTextSize,
|
|
36
|
+
} from '../utils/documentTextSize';
|
|
37
|
+
import { normalizeFontFamilyName } from '../utils/cssFontFamily';
|
|
26
38
|
import { storage } from '../utils/storage';
|
|
39
|
+
import { isLookChoiceResolved } from '../utils/lookAndFeelAnnouncement';
|
|
27
40
|
import { generateIdentity } from '../utils/generateIdentity';
|
|
28
41
|
import {
|
|
29
42
|
getDefaultThemePair,
|
|
@@ -89,11 +102,45 @@ export function readThemePairCookies(keys?: ThemePairLegacyKeys): ThemePair | un
|
|
|
89
102
|
return normalizeThemePair({ mode, light: light ?? seeded.light, dark: dark ?? seeded.dark }, seeded);
|
|
90
103
|
}
|
|
91
104
|
|
|
105
|
+
/** Pre-registry cookie keys of the agent switch, kept so an upgrade keeps the choice. */
|
|
106
|
+
const AGENT_SWITCH_COOKIE = 'plannotator-agent-switch';
|
|
107
|
+
const AGENT_SWITCH_CUSTOM_COOKIE = 'plannotator-agent-custom';
|
|
108
|
+
|
|
109
|
+
function readAgentSwitchCookies(): AgentSwitchChoice | undefined {
|
|
110
|
+
const choice = parseAgentSwitchConfig(storage.getItem(AGENT_SWITCH_COOKIE));
|
|
111
|
+
if (!choice) return undefined;
|
|
112
|
+
const customName = storage.getItem(AGENT_SWITCH_CUSTOM_COOKIE);
|
|
113
|
+
return isAgentSwitchName(customName) ? { switchTo: choice.switchTo, customName } : choice;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function writeAgentSwitchCookies(choice: AgentSwitchChoice | null): void {
|
|
117
|
+
// null = never chosen: write nothing, so the surface default keeps applying.
|
|
118
|
+
if (!choice) return;
|
|
119
|
+
storage.setItem(AGENT_SWITCH_COOKIE, choice.switchTo);
|
|
120
|
+
if (choice.customName) storage.setItem(AGENT_SWITCH_CUSTOM_COOKIE, choice.customName);
|
|
121
|
+
// Custom with its name cleared: drop the old name, or the next read would
|
|
122
|
+
// pair it with "custom" and switch to that old agent again.
|
|
123
|
+
else if (choice.switchTo === 'custom') storage.removeItem(AGENT_SWITCH_CUSTOM_COOKIE);
|
|
124
|
+
}
|
|
125
|
+
|
|
92
126
|
const DIFF_LINE_BG_INTENSITY_VALUES = ['subtle', 'normal', 'strong'] as const;
|
|
93
127
|
function isDiffLineBgIntensity(v: unknown): v is DiffLineBgIntensity {
|
|
94
128
|
return typeof v === 'string' && (DIFF_LINE_BG_INTENSITY_VALUES as readonly string[]).includes(v);
|
|
95
129
|
}
|
|
96
130
|
|
|
131
|
+
function parseReviewDefaults(value: unknown): ReviewDefaults | undefined {
|
|
132
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
133
|
+
const defaults: ReviewDefaults = {};
|
|
134
|
+
for (const [providerId, providerValue] of Object.entries(value)) {
|
|
135
|
+
if (!providerId || !providerValue || typeof providerValue !== 'object' || Array.isArray(providerValue)) continue;
|
|
136
|
+
const defaultDiffType = (providerValue as Record<string, unknown>).defaultDiffType;
|
|
137
|
+
if (typeof defaultDiffType === 'string' && defaultDiffType) {
|
|
138
|
+
defaults[providerId] = { defaultDiffType };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return defaults;
|
|
142
|
+
}
|
|
143
|
+
|
|
97
144
|
export interface SettingDef<T> {
|
|
98
145
|
defaultValue: T | (() => T);
|
|
99
146
|
fromCookie: () => T | undefined;
|
|
@@ -160,10 +207,18 @@ export const SETTINGS = {
|
|
|
160
207
|
},
|
|
161
208
|
|
|
162
209
|
gridEnabled: {
|
|
163
|
-
// Default
|
|
164
|
-
//
|
|
165
|
-
|
|
210
|
+
// Default OFF: plans open in the Clean look (the document edge to edge).
|
|
211
|
+
// Grid (the classic floating card on grid paper) is a choice, made in the
|
|
212
|
+
// first-run chooser or in Settings.
|
|
213
|
+
defaultValue: false as boolean,
|
|
166
214
|
fromCookie: () => {
|
|
215
|
+
// ConfigStore writes the default into this cookie on first load, so every
|
|
216
|
+
// browser that ran an older build stored 'true' (the old default) without
|
|
217
|
+
// anyone choosing it. The stored value counts only once the person has
|
|
218
|
+
// resolved the Grid/Clean choice under the Clean default (the v2 marker;
|
|
219
|
+
// a v1 marker with Grid stored is moved to Clean once, see
|
|
220
|
+
// utils/lookAndFeelAnnouncement); until then they get Clean.
|
|
221
|
+
if (!isLookChoiceResolved()) return undefined;
|
|
167
222
|
const v = storage.getItem('plannotator-grid-enabled');
|
|
168
223
|
return v === 'true' ? true : v === 'false' ? false : undefined;
|
|
169
224
|
},
|
|
@@ -192,6 +247,54 @@ export const SETTINGS = {
|
|
|
192
247
|
serverKey: undefined, fromServer: undefined, toServer: undefined,
|
|
193
248
|
},
|
|
194
249
|
|
|
250
|
+
/**
|
|
251
|
+
* The document's body text size in plan review and annotate, in px (#2066;
|
|
252
|
+
* see `utils/documentTextSize`). Default 15, today's size. Cookie-only, like
|
|
253
|
+
* the other display knobs; an unknown value reads as the default.
|
|
254
|
+
*/
|
|
255
|
+
documentTextSize: {
|
|
256
|
+
defaultValue: DEFAULT_DOCUMENT_TEXT_SIZE as DocumentTextSize,
|
|
257
|
+
fromCookie: () => {
|
|
258
|
+
const raw = storage.getItem('plannotator-document-text-size');
|
|
259
|
+
if (raw === null || raw.trim() === '') return undefined;
|
|
260
|
+
const parsed = Number(raw);
|
|
261
|
+
return isDocumentTextSize(parsed) ? parsed : undefined;
|
|
262
|
+
},
|
|
263
|
+
toCookie: (value: DocumentTextSize) =>
|
|
264
|
+
storage.setItem('plannotator-document-text-size', String(value)),
|
|
265
|
+
serverKey: undefined, fromServer: undefined, toServer: undefined,
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The document's font in plan review and annotate (#1657; see
|
|
270
|
+
* `utils/documentFont`): a generic preset or an installed family name.
|
|
271
|
+
* Empty = the theme's font. Cookie-only, like Text size.
|
|
272
|
+
*/
|
|
273
|
+
documentFontFamily: {
|
|
274
|
+
defaultValue: '' as string,
|
|
275
|
+
fromCookie: () =>
|
|
276
|
+
normalizeFontFamilyName(storage.getItem('plannotator-document-font-family') ?? '') || undefined,
|
|
277
|
+
toCookie: (value: string) => storage.setItem('plannotator-document-font-family', value),
|
|
278
|
+
serverKey: undefined, fromServer: undefined, toServer: undefined,
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Enter saves a comment and Shift+Enter adds a new line (#1934). Default
|
|
283
|
+
* OFF: comments save with Cmd/Ctrl+Enter only, as they always have, and
|
|
284
|
+
* Cmd/Ctrl+Enter keeps saving with it on. Read by every comment composer
|
|
285
|
+
* through `isCommentSubmitKey` (`utils/commentSubmitKey`). Cookie-only.
|
|
286
|
+
*/
|
|
287
|
+
enterSavesComment: {
|
|
288
|
+
defaultValue: false as boolean,
|
|
289
|
+
fromCookie: () => {
|
|
290
|
+
const value = storage.getItem('plannotator-enter-saves-comment');
|
|
291
|
+
return value === 'true' ? true : value === 'false' ? false : undefined;
|
|
292
|
+
},
|
|
293
|
+
toCookie: (value: boolean) =>
|
|
294
|
+
storage.setItem('plannotator-enter-saves-comment', String(value)),
|
|
295
|
+
serverKey: undefined, fromServer: undefined, toServer: undefined,
|
|
296
|
+
},
|
|
297
|
+
|
|
195
298
|
vimModeEnabled: {
|
|
196
299
|
// Vim bindings deliberately default OFF. Unmodified letter keys must remain
|
|
197
300
|
// inert for existing users until they explicitly opt into modal document
|
|
@@ -360,6 +463,25 @@ export const SETTINGS = {
|
|
|
360
463
|
serverKey: undefined, fromServer: undefined, toServer: undefined,
|
|
361
464
|
},
|
|
362
465
|
|
|
466
|
+
reviewDefaults: {
|
|
467
|
+
defaultValue: {} as ReviewDefaults,
|
|
468
|
+
fromCookie: () => {
|
|
469
|
+
const value = storage.getItem('plannotator-review-defaults');
|
|
470
|
+
if (!value) return undefined;
|
|
471
|
+
try {
|
|
472
|
+
return parseReviewDefaults(JSON.parse(value));
|
|
473
|
+
} catch {
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
toCookie: (value: ReviewDefaults) =>
|
|
478
|
+
storage.setItem('plannotator-review-defaults', JSON.stringify(value)),
|
|
479
|
+
serverKey: 'reviewDefaults',
|
|
480
|
+
fromServer: (serverConfig: Record<string, unknown>) =>
|
|
481
|
+
parseReviewDefaults(serverConfig.reviewDefaults),
|
|
482
|
+
toServer: (value: ReviewDefaults) => ({ reviewDefaults: value }),
|
|
483
|
+
},
|
|
484
|
+
|
|
363
485
|
defaultDiffType: {
|
|
364
486
|
defaultValue: 'since-base' as 'since-base' | 'local-vs-remote' | 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all',
|
|
365
487
|
fromCookie: () => {
|
|
@@ -655,6 +777,35 @@ export const SETTINGS = {
|
|
|
655
777
|
}
|
|
656
778
|
},
|
|
657
779
|
},
|
|
780
|
+
/**
|
|
781
|
+
* OpenCode's agent switch after plan approval / review feedback (#1390).
|
|
782
|
+
* null = never chosen: each surface applies its own default (plan approval
|
|
783
|
+
* switches to "build", review feedback stays), see utils/agentSwitch.
|
|
784
|
+
*
|
|
785
|
+
* Server-synced to `agentSwitch` in ~/.plannotator/config.json because some
|
|
786
|
+
* webviews (Glimpse's WKWebView) keep no cookies between runs, and a lost
|
|
787
|
+
* choice silently hands the session to the build agent. The cookie keys
|
|
788
|
+
* are the pre-registry ones, so an upgrade keeps an existing choice.
|
|
789
|
+
*/
|
|
790
|
+
agentSwitch: {
|
|
791
|
+
defaultValue: null as AgentSwitchChoice | null,
|
|
792
|
+
fromCookie: () => readAgentSwitchCookies(),
|
|
793
|
+
toCookie: (v: AgentSwitchChoice | null) => writeAgentSwitchCookies(v),
|
|
794
|
+
serverKey: 'agentSwitch',
|
|
795
|
+
fromServer: (sc: Record<string, unknown>) => {
|
|
796
|
+
const choice = parseAgentSwitchConfig(sc.agentSwitch);
|
|
797
|
+
// Custom with no name means no switch: never pair it with an old name.
|
|
798
|
+
if (!choice || choice.customName || choice.switchTo === 'custom') return choice;
|
|
799
|
+
// A plain choice keeps the name typed in the Custom field earlier, so
|
|
800
|
+
// picking Custom again shows it (the cookie is the only place it lives).
|
|
801
|
+
const customName = storage.getItem(AGENT_SWITCH_CUSTOM_COOKIE);
|
|
802
|
+
return isAgentSwitchName(customName) ? { ...choice, customName } : choice;
|
|
803
|
+
},
|
|
804
|
+
toServer: (v: AgentSwitchChoice | null) => {
|
|
805
|
+
const value = v ? agentSwitchConfigValue(v) : undefined;
|
|
806
|
+
return value === undefined ? {} : { agentSwitch: value };
|
|
807
|
+
},
|
|
808
|
+
},
|
|
658
809
|
/**
|
|
659
810
|
* Where the annotate-mode Agent TUI docks: 'left' (where it always docked),
|
|
660
811
|
* 'right', or 'hidden' (no slot until the user opens it for the session).
|
package/configure.ts
CHANGED
|
@@ -18,6 +18,9 @@ import { configStore } from './config';
|
|
|
18
18
|
import type { ServerSyncFn } from './config/configStore';
|
|
19
19
|
import type { ExternalAnnotationEvent, VaultNode } from './types';
|
|
20
20
|
import { setComponentTooltips } from './components/HostTitle';
|
|
21
|
+
import { setRichCopyEnabled } from './utils/richCopyPolicy';
|
|
22
|
+
import { setDigitHexIssueRefs } from './components/InlineMarkdown';
|
|
23
|
+
import { setRefCardTransport, type RefCardTransport } from './utils/refCard';
|
|
21
24
|
|
|
22
25
|
// One-stop type barrel: every seam contract a host implements is importable
|
|
23
26
|
// from this module, next to configurePlannotatorUI itself.
|
|
@@ -44,6 +47,7 @@ export type {
|
|
|
44
47
|
IdentityGenerator,
|
|
45
48
|
AlertIconRenderer,
|
|
46
49
|
FenceThemeResolver,
|
|
50
|
+
RefCardTransport,
|
|
47
51
|
};
|
|
48
52
|
|
|
49
53
|
type ExternalAnnotationBase = { id: string; source?: string };
|
|
@@ -127,6 +131,27 @@ export interface PlannotatorUIConfig {
|
|
|
127
131
|
* as before. The Plannotator Inbox passes true.
|
|
128
132
|
*/
|
|
129
133
|
componentTooltips?: boolean;
|
|
134
|
+
/**
|
|
135
|
+
* Render a digit-only 6/8-digit `#123456` (word boundary, no leading zero)
|
|
136
|
+
* as an issue ref instead of a hex colour swatch, when a ref link can be
|
|
137
|
+
* built (a repo on a known forge). Default: false, swatches exactly as
|
|
138
|
+
* before. The Plannotator Inbox passes true (#1932).
|
|
139
|
+
*/
|
|
140
|
+
digitHexIssueRefs?: boolean;
|
|
141
|
+
/**
|
|
142
|
+
* Make the selection toolbar's Copy button (Viewer and HtmlViewer) copy rich
|
|
143
|
+
* text: text/html plus text/plain in one clipboard write, and show Copy
|
|
144
|
+
* where `hideCopyButton` hid it (the Viewer on desktop). Default: false,
|
|
145
|
+
* Copy exactly as before. Plannotator's plan review, annotate, code review
|
|
146
|
+
* and Inbox pass true (#1972, `utils/richCopyPolicy`).
|
|
147
|
+
*/
|
|
148
|
+
richCopy?: boolean;
|
|
149
|
+
/**
|
|
150
|
+
* Fetch an issue / PR / MR hover card's data (`components/RefHoverCard`).
|
|
151
|
+
* Default: none, so no card is drawn and nothing is requested. The
|
|
152
|
+
* Plannotator Inbox passes one over its `/api/inbox/ref` route.
|
|
153
|
+
*/
|
|
154
|
+
refCardTransport?: RefCardTransport;
|
|
130
155
|
/** Re-hydrate settings from the installed (SYNCHRONOUS) storageBackend after install. */
|
|
131
156
|
loadSettingsFromBackend?: boolean;
|
|
132
157
|
}
|
|
@@ -152,6 +177,9 @@ export function configurePlannotatorUI(config: PlannotatorUIConfig): void {
|
|
|
152
177
|
if (config.alertIconRenderer) setAlertIconRenderer(config.alertIconRenderer);
|
|
153
178
|
if (config.fenceTheme) setFenceThemeResolver(config.fenceTheme);
|
|
154
179
|
if (config.componentTooltips !== undefined) setComponentTooltips(config.componentTooltips);
|
|
180
|
+
if (config.digitHexIssueRefs !== undefined) setDigitHexIssueRefs(config.digitHexIssueRefs);
|
|
181
|
+
if (config.richCopy !== undefined) setRichCopyEnabled(config.richCopy);
|
|
182
|
+
if (config.refCardTransport) setRefCardTransport(config.refCardTransport);
|
|
155
183
|
// Re-hydrate AFTER storageBackend is installed (load-bearing order — gated last).
|
|
156
184
|
if (config.loadSettingsFromBackend) configStore.loadFromBackend();
|
|
157
185
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback } from "react";
|
|
1
|
+
import { useState, useCallback, useRef } from "react";
|
|
2
2
|
import { parseCodePath } from "@plannotator/core/code-file";
|
|
3
3
|
|
|
4
4
|
interface CodeFileState {
|
|
@@ -9,6 +9,7 @@ interface CodeFileState {
|
|
|
9
9
|
requestedPath?: string;
|
|
10
10
|
line?: number;
|
|
11
11
|
lineEnd?: number;
|
|
12
|
+
revealKey?: number;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
interface UseCodeFilePopoutOptions {
|
|
@@ -29,6 +30,8 @@ export interface UseCodeFilePopoutReturn {
|
|
|
29
30
|
requestedPath?: string;
|
|
30
31
|
line?: number;
|
|
31
32
|
lineEnd?: number;
|
|
33
|
+
/** Changes on every open, so reopening the same file:line scrolls to it again. */
|
|
34
|
+
revealKey?: number;
|
|
32
35
|
} | null;
|
|
33
36
|
}
|
|
34
37
|
|
|
@@ -38,6 +41,7 @@ export function useCodeFilePopout(
|
|
|
38
41
|
const { buildUrl } = options;
|
|
39
42
|
const [state, setState] = useState<CodeFileState | null>(null);
|
|
40
43
|
const [isLoading, setIsLoading] = useState(false);
|
|
44
|
+
const revealCount = useRef(0);
|
|
41
45
|
|
|
42
46
|
const close = useCallback(() => {
|
|
43
47
|
setState(null);
|
|
@@ -75,6 +79,7 @@ export function useCodeFilePopout(
|
|
|
75
79
|
prerenderedHTML: data.prerenderedHTML,
|
|
76
80
|
line: data.line ?? parsed.line,
|
|
77
81
|
lineEnd: data.lineEnd ?? parsed.lineEnd,
|
|
82
|
+
revealKey: ++revealCount.current,
|
|
78
83
|
});
|
|
79
84
|
setIsLoading(false);
|
|
80
85
|
} catch {
|
|
@@ -105,6 +110,7 @@ export function useCodeFilePopout(
|
|
|
105
110
|
requestedPath: state.requestedPath,
|
|
106
111
|
line: state.line,
|
|
107
112
|
lineEnd: state.lineEnd,
|
|
113
|
+
revealKey: state.revealKey,
|
|
108
114
|
}
|
|
109
115
|
: null,
|
|
110
116
|
};
|
package/hooks/useAIChat.ts
CHANGED
|
@@ -30,6 +30,9 @@ export interface AIChatThread {
|
|
|
30
30
|
|
|
31
31
|
export interface AskAIParams {
|
|
32
32
|
prompt: string;
|
|
33
|
+
/** What the chat shows as the question when it differs from `prompt`, which
|
|
34
|
+
* is what the agent gets (e.g. a short "Explain this finding: …"). */
|
|
35
|
+
displayPrompt?: string;
|
|
33
36
|
filePath?: string;
|
|
34
37
|
lineStart?: number;
|
|
35
38
|
lineEnd?: number;
|
|
@@ -52,6 +55,12 @@ export interface AskAIParams {
|
|
|
52
55
|
* received, and the server frames it as read-only context, never as
|
|
53
56
|
* feedback (#1748). Leave unset for every other provider. */
|
|
54
57
|
draftAnnotations?: string;
|
|
58
|
+
/** Called once, when the stream first shows the question reached the
|
|
59
|
+
* session (an answer started, or the session took it over). A busy, gone
|
|
60
|
+
* or failed ask never calls it; a retry keeps it, so the retried question
|
|
61
|
+
* calls it when it lands. Lets a caller record what the session was told
|
|
62
|
+
* (e.g. which document a note named) only once it was received. */
|
|
63
|
+
onDelivered?: () => void;
|
|
55
64
|
}
|
|
56
65
|
|
|
57
66
|
/** What to send as `draftAnnotations` for one question: the list when this
|
|
@@ -73,6 +82,15 @@ const BRIDGE_DRAFTS_KEY = 'session-bridge';
|
|
|
73
82
|
/** Stream messages that show the session received the question. */
|
|
74
83
|
const DELIVERED_MESSAGE_TYPES = new Set(['text_delta', 'text', 'tool_use', 'tool_result', 'result']);
|
|
75
84
|
|
|
85
|
+
/**
|
|
86
|
+
* A busy "Ask this session" answer still offers its choice ("Ask when it
|
|
87
|
+
* finishes" / "Interrupt and ask now"): the question has not been asked yet,
|
|
88
|
+
* and clearing the thread would drop it without a word.
|
|
89
|
+
*/
|
|
90
|
+
export function hasPendingBusyChoice(messages: AIChatEntry[]): boolean {
|
|
91
|
+
return messages.some(m => m.response.errorCode === SESSION_ASK_ERROR_CODES.agentBusy);
|
|
92
|
+
}
|
|
93
|
+
|
|
76
94
|
/** Overrides for re-asking a question that ended in a busy/gone/blocked answer. */
|
|
77
95
|
export interface AIRetryOptions {
|
|
78
96
|
busyPolicy?: 'wait' | 'interrupt';
|
|
@@ -179,8 +197,9 @@ export interface AITransport {
|
|
|
179
197
|
session(body: unknown, signal: AbortSignal): Promise<Response>;
|
|
180
198
|
/** POST /api/ai/query — send a message; returns the streaming SSE response. */
|
|
181
199
|
query(body: unknown, signal: AbortSignal): Promise<Response>;
|
|
182
|
-
/** POST /api/ai/abort
|
|
183
|
-
|
|
200
|
+
/** POST /api/ai/abort. A superseding question sends `reason: 'supersede'`
|
|
201
|
+
* so session bridges close the answer without stopping the host turn.
|
|
202
|
+
* Resolves once acknowledged, before the old fetch is disconnected. */
|
|
184
203
|
abort(body: unknown): Promise<unknown>;
|
|
185
204
|
/** POST /api/ai/permission — respond to a permission request. Fire-and-forget. */
|
|
186
205
|
permission(body: unknown): void;
|
|
@@ -238,9 +257,9 @@ export const resetAITransport = (): void => {
|
|
|
238
257
|
* ask() relies on this resolving. Single-sourced so every abort call site stays
|
|
239
258
|
* hardened. Reads `aiTransport` at call time so a late override is honored.
|
|
240
259
|
*/
|
|
241
|
-
function safeAbort(sessionId: string): Promise<unknown> {
|
|
260
|
+
function safeAbort(sessionId: string, reason?: 'supersede'): Promise<unknown> {
|
|
242
261
|
return Promise.resolve()
|
|
243
|
-
.then(() => aiTransport.abort({ sessionId }))
|
|
262
|
+
.then(() => aiTransport.abort({ sessionId, ...(reason && { reason }) }))
|
|
244
263
|
.catch(() => {});
|
|
245
264
|
}
|
|
246
265
|
|
|
@@ -261,10 +280,15 @@ export function useAIChat({
|
|
|
261
280
|
// In-flight server abort (from Stop or a superseding question). The next ask()
|
|
262
281
|
// waits on it so a new query can't race the still-active turn into busy.
|
|
263
282
|
const pendingAbortRef = useRef<Promise<unknown> | null>(null);
|
|
283
|
+
// Answers a newer question replaced. Their fetch stays open until the server
|
|
284
|
+
// acknowledges the supersede, but nothing more they stream is shown.
|
|
285
|
+
const supersededRef = useRef(new WeakSet<AbortController>());
|
|
264
286
|
const sessionEpochRef = useRef(0);
|
|
265
287
|
const createRequestRef = useRef(0);
|
|
266
288
|
const sessionIdRef = useRef<string | null>(null);
|
|
267
289
|
sessionIdRef.current = thread.sessionId;
|
|
290
|
+
const threadRef = useRef(thread);
|
|
291
|
+
threadRef.current = thread;
|
|
268
292
|
|
|
269
293
|
const updateMessages = useCallback((updater: (messages: AIChatEntry[]) => AIChatEntry[]) => {
|
|
270
294
|
setThread(prev => ({ ...prev, messages: updater(prev.messages) }));
|
|
@@ -316,16 +340,13 @@ export function useAIChat({
|
|
|
316
340
|
}
|
|
317
341
|
}, [context, model, providerId, reasoningEffort, setSessionId]);
|
|
318
342
|
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
// it responds, so awaiting this guarantees a following query won't race it
|
|
324
|
-
// into a session_busy error. Best-effort (never rejects).
|
|
325
|
-
const postServerAbort = useCallback((): Promise<unknown> => {
|
|
343
|
+
// Stop cancels the turn. Supersede closes the old answer but lets a session
|
|
344
|
+
// bridge's host keep working until the reviewer chooses to interrupt it.
|
|
345
|
+
// Both clear the server's query before the next one. Best-effort (never rejects).
|
|
346
|
+
const postServerAbort = useCallback((reason?: 'supersede'): Promise<unknown> => {
|
|
326
347
|
const sessionId = sessionIdRef.current; // capture the session being aborted now
|
|
327
348
|
if (!sessionId) return Promise.resolve();
|
|
328
|
-
return safeAbort(sessionId);
|
|
349
|
+
return safeAbort(sessionId, reason);
|
|
329
350
|
}, []);
|
|
330
351
|
|
|
331
352
|
// The params each question was asked with, so a busy answer can be
|
|
@@ -341,19 +362,18 @@ export function useAIChat({
|
|
|
341
362
|
const deliveredDraftsRef = useRef<{ sessionId: string; text: string } | null>(null);
|
|
342
363
|
|
|
343
364
|
const ask = useCallback(async (params: AskAIParams, askOptions?: { providerId?: string }) => {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
pendingAbortRef.current = postServerAbort();
|
|
365
|
+
const previous = abortRef.current;
|
|
366
|
+
if (previous) {
|
|
367
|
+
supersededRef.current.add(previous);
|
|
368
|
+
pendingAbortRef.current = postServerAbort('supersede');
|
|
348
369
|
}
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
// session_busy error. Stopping still kills the turn; chatting right after
|
|
352
|
-
// just waits the one round-trip for the stop to land.
|
|
370
|
+
// Let the server distinguish supersede from Stop before disconnecting:
|
|
371
|
+
// cancelling the SSE fetch first would still abort the host's turn.
|
|
353
372
|
if (pendingAbortRef.current) {
|
|
354
373
|
await pendingAbortRef.current;
|
|
355
374
|
pendingAbortRef.current = null;
|
|
356
375
|
}
|
|
376
|
+
previous?.abort();
|
|
357
377
|
|
|
358
378
|
const controller = new AbortController();
|
|
359
379
|
abortRef.current = controller;
|
|
@@ -364,7 +384,7 @@ export function useAIChat({
|
|
|
364
384
|
paramsByQuestionRef.current.set(questionId, params);
|
|
365
385
|
const question: AIQuestion = {
|
|
366
386
|
id: questionId,
|
|
367
|
-
prompt: params.prompt,
|
|
387
|
+
prompt: params.displayPrompt ?? params.prompt,
|
|
368
388
|
scope: params.scope,
|
|
369
389
|
filePath: params.filePath,
|
|
370
390
|
lineStart: params.lineStart,
|
|
@@ -392,7 +412,7 @@ export function useAIChat({
|
|
|
392
412
|
sid = await createSession(controller.signal, epoch);
|
|
393
413
|
}
|
|
394
414
|
|
|
395
|
-
if (controller.signal.aborted || epoch !== sessionEpochRef.current) {
|
|
415
|
+
if (controller.signal.aborted || supersededRef.current.has(controller) || epoch !== sessionEpochRef.current) {
|
|
396
416
|
throw createAbortError('AI question was superseded');
|
|
397
417
|
}
|
|
398
418
|
|
|
@@ -400,11 +420,20 @@ export function useAIChat({
|
|
|
400
420
|
const drafts = draftAnnotationsToSend(deliveredDraftsRef.current, BRIDGE_DRAFTS_KEY, params.draftAnnotations);
|
|
401
421
|
// Recorded once the session shows it received the question, so a busy,
|
|
402
422
|
// gone or failed ask sends the list again next time.
|
|
423
|
+
let deliveredCalled = false;
|
|
403
424
|
const markDraftsDelivered = (msg: { type?: unknown; code?: unknown }) => {
|
|
404
|
-
if (drafts === undefined) return;
|
|
405
425
|
const received = DELIVERED_MESSAGE_TYPES.has(String(msg.type))
|
|
406
426
|
|| (msg.type === 'error' && msg.code === SESSION_ASK_ERROR_CODES.takenOver);
|
|
407
|
-
if (received)
|
|
427
|
+
if (!received) return;
|
|
428
|
+
if (drafts !== undefined) deliveredDraftsRef.current = { sessionId: BRIDGE_DRAFTS_KEY, text: drafts };
|
|
429
|
+
if (!deliveredCalled) {
|
|
430
|
+
deliveredCalled = true;
|
|
431
|
+
try {
|
|
432
|
+
params.onDelivered?.();
|
|
433
|
+
} catch {
|
|
434
|
+
// A caller's bookkeeping must never break the stream.
|
|
435
|
+
}
|
|
436
|
+
}
|
|
408
437
|
};
|
|
409
438
|
const res = await aiTransport.query({
|
|
410
439
|
sessionId: sid,
|
|
@@ -425,6 +454,7 @@ export function useAIChat({
|
|
|
425
454
|
|
|
426
455
|
while (true) {
|
|
427
456
|
const { done, value } = await reader.read();
|
|
457
|
+
if (supersededRef.current.has(controller)) throw createAbortError('AI question was superseded');
|
|
428
458
|
if (done) break;
|
|
429
459
|
buffer += decoder.decode(value, { stream: true });
|
|
430
460
|
|
|
@@ -566,7 +596,7 @@ export function useAIChat({
|
|
|
566
596
|
)
|
|
567
597
|
);
|
|
568
598
|
} finally {
|
|
569
|
-
if (abortRef.current === controller) {
|
|
599
|
+
if (abortRef.current === controller && !supersededRef.current.has(controller)) {
|
|
570
600
|
setIsStreaming(false);
|
|
571
601
|
abortRef.current = null;
|
|
572
602
|
}
|
|
@@ -649,6 +679,22 @@ export function useAIChat({
|
|
|
649
679
|
setError(null);
|
|
650
680
|
}, [postServerAbort, threadTitle]);
|
|
651
681
|
|
|
682
|
+
// The panel's "New chat": clears the thread and forgets the Plannotator AI
|
|
683
|
+
// session, so the next question starts a fresh one (a separate AI then gets
|
|
684
|
+
// the full context again). Unlike `resetThread` (a document switch), it never
|
|
685
|
+
// stops anything: while a question is in flight (streaming, creating its
|
|
686
|
+
// session, or waiting on a busy "Ask this session") or a busy answer still
|
|
687
|
+
// offers its choice, it does nothing and answers false, so a running turn is
|
|
688
|
+
// not aborted and a pending ask is not dropped. What the agent session already received (`deliveredDraftsRef`) is
|
|
689
|
+
// kept: with "Ask this session" a new chat reaches the SAME host session,
|
|
690
|
+
// which still remembers it.
|
|
691
|
+
const newChat = useCallback((): boolean => {
|
|
692
|
+
if (abortRef.current) return false;
|
|
693
|
+
if (hasPendingBusyChoice(threadRef.current.messages)) return false;
|
|
694
|
+
resetThread();
|
|
695
|
+
return true;
|
|
696
|
+
}, [resetThread]);
|
|
697
|
+
|
|
652
698
|
useEffect(() => {
|
|
653
699
|
return () => {
|
|
654
700
|
sessionEpochRef.current += 1;
|
|
@@ -672,6 +718,7 @@ export function useAIChat({
|
|
|
672
718
|
abort,
|
|
673
719
|
resetSession,
|
|
674
720
|
resetThread,
|
|
721
|
+
newChat,
|
|
675
722
|
sessionId: thread.sessionId,
|
|
676
723
|
};
|
|
677
724
|
}
|