@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.
Files changed (145) hide show
  1. package/HANDOFF.md +284 -27
  2. package/README.md +84 -14
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +177 -34
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/AnnotationToolstrip.tsx +45 -40
  9. package/components/ApproveDropdown.tsx +7 -9
  10. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  11. package/components/AttachmentsButton.tsx +1 -1
  12. package/components/BlockRenderer.tsx +19 -5
  13. package/components/CodeFilePopout.tsx +140 -5
  14. package/components/CommentPopover.tsx +46 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DiagramBlock.tsx +17 -12
  17. package/components/EditorAnnotationCard.tsx +1 -1
  18. package/components/ExportModal.tsx +77 -7
  19. package/components/HostTitle.tsx +0 -4
  20. package/components/HtmlSurfaceControls.tsx +2 -1
  21. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  22. package/components/ImageAnnotator/index.tsx +24 -4
  23. package/components/ImageLightbox.tsx +658 -120
  24. package/components/ImagePeek.tsx +38 -4
  25. package/components/ImageRegions.tsx +369 -0
  26. package/components/ImageThumbnail.tsx +1 -1
  27. package/components/ImageZoomStage.tsx +796 -0
  28. package/components/InboxAnnouncementDialog.tsx +202 -0
  29. package/components/InlineMarkdown.tsx +152 -24
  30. package/components/KeyboardShortcuts.tsx +23 -0
  31. package/components/LazyBlockBoundary.tsx +100 -0
  32. package/components/ListMarker.tsx +3 -3
  33. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  34. package/components/MentionPicker.tsx +51 -0
  35. package/components/MenuVersionSection.tsx +31 -9
  36. package/components/PlanDecisionMenuExtras.tsx +129 -0
  37. package/components/PlanHeaderMenu.tsx +15 -1
  38. package/components/ProviderIcons.tsx +8 -0
  39. package/components/RefHoverCard.tsx +913 -0
  40. package/components/SendFailureToast.tsx +126 -0
  41. package/components/Settings.tsx +617 -126
  42. package/components/SkillReferenceMenu.tsx +39 -12
  43. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  44. package/components/ThemeProvider.tsx +14 -1
  45. package/components/ThemeTab.tsx +16 -24
  46. package/components/Tooltip.tsx +49 -18
  47. package/components/Viewer.tsx +316 -47
  48. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  49. package/components/ai/NewChatButton.tsx +74 -0
  50. package/components/blocks/CodeBlock.tsx +1 -1
  51. package/components/blocks/HtmlBlock.tsx +7 -1
  52. package/components/blocks/QuestionBlock.tsx +522 -62
  53. package/components/blocks/QuestionContext.tsx +3 -0
  54. package/components/diagram/DiagramComposer.tsx +5 -1
  55. package/components/diagram/DiagramPending.tsx +12 -2
  56. package/components/diagram/DiagramViewer.tsx +19 -3
  57. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  58. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  59. package/components/html-embed/index.ts +3 -0
  60. package/components/html-embed/languages.ts +15 -0
  61. package/components/html-embed/loadGate.ts +107 -0
  62. package/components/html-viewer/HtmlViewer.tsx +69 -6
  63. package/components/html-viewer/bridge-script.asset.js +504 -12
  64. package/components/html-viewer/bridge-script.lite.ts +1 -1
  65. package/components/html-viewer/bridge-script.ts +545 -12
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +265 -0
  68. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  69. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  70. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  71. package/components/sidebar/FileBrowser.tsx +16 -2
  72. package/components/sidebar/SidebarTabs.tsx +3 -0
  73. package/config/index.ts +3 -0
  74. package/config/reviewView.ts +41 -1
  75. package/config/settings.ts +155 -4
  76. package/configure.ts +28 -0
  77. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  78. package/hooks/useAIChat.ts +72 -25
  79. package/hooks/useAnnotationDraft.ts +71 -6
  80. package/hooks/useAnnotationHighlighter.ts +57 -3
  81. package/hooks/useCodeAnnotationDraft.ts +113 -17
  82. package/hooks/useCookieHostSetting.ts +96 -0
  83. package/hooks/useDocumentFont.ts +17 -0
  84. package/hooks/useDocumentTextSize.ts +20 -0
  85. package/hooks/useFileBrowser.ts +10 -1
  86. package/hooks/useLinkedDoc.ts +226 -13
  87. package/hooks/useMentionAutocomplete.ts +1 -1
  88. package/hooks/usePackageChangesSetting.ts +90 -0
  89. package/hooks/usePlanLookDialog.ts +67 -0
  90. package/hooks/useSharing.ts +11 -7
  91. package/hooks/useSkillReferenceAutocomplete.ts +7 -3
  92. package/hooks/useThemePaletteHalves.ts +37 -0
  93. package/hooks/useUpdateCheck.ts +22 -4
  94. package/hooks/useUpdateToast.ts +1 -1
  95. package/hooks/useVimSelection.ts +9 -0
  96. package/package.json +23 -22
  97. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  98. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  99. package/shortcuts/core.ts +63 -4
  100. package/shortcuts/index.ts +1 -0
  101. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  102. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  103. package/shortcuts/runtime.ts +14 -6
  104. package/styles.css +1 -1
  105. package/theme.css +179 -1
  106. package/themes/plannotator-graphite.css +66 -0
  107. package/types.ts +26 -9
  108. package/utils/agentSwitch.ts +41 -20
  109. package/utils/agentToolSetting.ts +1 -1
  110. package/utils/aiChatFormat.ts +23 -7
  111. package/utils/annotationOwnsHighlight.ts +4 -2
  112. package/utils/asciidocParser.ts +570 -0
  113. package/utils/autoUpdateNotice.ts +24 -0
  114. package/utils/belowTitleSlot.ts +174 -0
  115. package/utils/codeLineScroll.ts +100 -0
  116. package/utils/commentSubmitKey.ts +48 -0
  117. package/utils/cssFontFamily.ts +65 -0
  118. package/utils/decisionSpec.ts +190 -2
  119. package/utils/defaultNotesApp.ts +1 -1
  120. package/utils/diffFonts.ts +4 -1
  121. package/utils/documentFont.ts +68 -0
  122. package/utils/documentTextSize.ts +54 -0
  123. package/utils/fontDetect.ts +63 -0
  124. package/utils/inboxAnnouncement.ts +78 -0
  125. package/utils/lookAndFeelAnnouncement.ts +82 -5
  126. package/utils/markdownExtensions.ts +2 -2
  127. package/utils/mentions.ts +34 -5
  128. package/utils/mermaid.ts +20 -3
  129. package/utils/mermaidTheme.ts +3 -51
  130. package/utils/notion.ts +58 -0
  131. package/utils/parser.ts +28 -1
  132. package/utils/platform.ts +46 -0
  133. package/utils/refCard.ts +82 -0
  134. package/utils/richClipboard.ts +380 -0
  135. package/utils/richClipboardLists.ts +57 -0
  136. package/utils/richCopyPolicy.ts +22 -0
  137. package/utils/scrollAnchor.ts +82 -0
  138. package/utils/sectionRefs.ts +123 -0
  139. package/utils/selectionBoundary.ts +40 -0
  140. package/utils/sendFailure.ts +85 -0
  141. package/utils/sharing.ts +5 -2
  142. package/utils/themeRegistry.ts +10 -0
  143. package/utils/themeTokens.ts +74 -0
  144. package/utils/updateNotice.ts +30 -8
  145. package/utils/vscodePanel.ts +9 -0
@@ -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 ON: plans open in the classic grid / floating-card look. The UI 2.0
164
- // flat look is offered as an opt-in via the look-and-feel chooser dialog.
165
- defaultValue: true as boolean,
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
  };
@@ -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 — abort a session (supersede + standalone). Resolves once
183
- the server acknowledges so a following query can await it (best-effort, never rejects). */
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
- // Tell the server to stop the current session's in-flight turn, and resolve
320
- // once it has. Used by the Stop button and when a new question supersedes a
321
- // streaming one — aborting the browser fetch alone leaves the agent's turn
322
- // running. The /api/ai/abort handler clears the session's active flag before
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
- if (abortRef.current) {
345
- abortRef.current.abort();
346
- // Supersede: stop the server-side turn (not just the browser fetch).
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
- // Wait for any in-flight server abort — from this supersede OR a prior Stop
350
- // click — so the query below doesn't race the still-active turn into a
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) deliveredDraftsRef.current = { sessionId: BRIDGE_DRAFTS_KEY, text: drafts };
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
  }