@plannotator/ui 0.47.0 → 0.50.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 (42) hide show
  1. package/HANDOFF.md +147 -0
  2. package/LICENSE +191 -0
  3. package/README.md +56 -1
  4. package/components/AISettingsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +44 -3
  6. package/components/BlockRenderer.tsx +55 -1
  7. package/components/CompletionOverlay.tsx +23 -1
  8. package/components/DiffFileTree.tsx +363 -0
  9. package/components/ImageLightbox.tsx +66 -0
  10. package/components/PRPlatformIcon.tsx +22 -0
  11. package/components/ProviderIcons.tsx +15 -3
  12. package/components/QuestionProgressChip.tsx +66 -0
  13. package/components/QuestionsPanelSection.tsx +121 -0
  14. package/components/Settings.tsx +25 -1
  15. package/components/Viewer.tsx +170 -34
  16. package/components/ai/AIProviderBar.tsx +5 -5
  17. package/components/ai/DocumentAIChatPanel.tsx +28 -3
  18. package/components/ai/SessionAskNotice.tsx +89 -0
  19. package/components/blocks/QuestionBlock.tsx +591 -0
  20. package/components/html-viewer/bridge-script.asset.js +85 -8
  21. package/components/html-viewer/bridge-script.ts +85 -8
  22. package/config/settings.ts +17 -0
  23. package/configure.ts +12 -0
  24. package/hooks/useAIChat.ts +74 -9
  25. package/hooks/useAIProviderConfig.ts +32 -14
  26. package/hooks/useAnnotationHighlighter.ts +4 -0
  27. package/hooks/useDiffFileTreeExpansion.ts +92 -0
  28. package/hooks/usePinpoint.ts +10 -0
  29. package/hooks/useScrollKeyRouting.ts +157 -0
  30. package/hooks/useUndoHistory.ts +17 -0
  31. package/hooks/useVimDocumentFocus.ts +2 -1
  32. package/package.json +3 -2
  33. package/styles.css +1 -1
  34. package/types.ts +7 -0
  35. package/utils/aiPrompt.ts +31 -0
  36. package/utils/aiProvider.ts +68 -0
  37. package/utils/autoUpdateNotice.ts +59 -0
  38. package/utils/diffFileTree.ts +203 -0
  39. package/utils/htmlLinkNavigation.ts +87 -3
  40. package/utils/parser.ts +125 -563
  41. package/utils/questionAnswers.ts +227 -0
  42. package/utils/syntaxTheme.ts +33 -0
@@ -96,17 +96,89 @@
96
96
  });
97
97
 
98
98
  // --- Resize ---
99
- var lastHeight = 0;
99
+ // The last child of el whose bottom margin could collapse through el's
100
+ // bottom edge: out-of-flow children (display:none, absolute/fixed, floats)
101
+ // never do, which also skips the bridge's own fixed overlay elements and a
102
+ // trailing script element.
103
+ function lastInFlowChild(el) {
104
+ for (var child = el.lastElementChild; child; child = child.previousElementSibling) {
105
+ var cs = window.getComputedStyle(child);
106
+ if (cs.display === 'none' || cs.display === 'contents') continue;
107
+ if (cs.position === 'absolute' || cs.position === 'fixed') continue;
108
+ if (cs.float && cs.float !== 'none') continue;
109
+ return child;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ // Height the auto-sized frame needs to show the whole document.
115
+ // Not body.scrollHeight alone: when body has no top padding or border, a
116
+ // first child's top margin collapses THROUGH body and moves body's box down
117
+ // without making it taller (the same holds at the bottom), so the page was
118
+ // cut short by those margins. Not documentElement.scrollHeight either: it
119
+ // never reports less than the frame's own viewport, so the frame could grow
120
+ // but never shrink back, and an html{height:100%} page would pin it at
121
+ // whatever height it had. Instead body's box is placed in document
122
+ // coordinates (its top already includes a collapsed top margin), and the
123
+ // bottom margins that can collapse through body's bottom edge are added by
124
+ // walking the last in-flow child chain. Nothing here reads the frame's own
125
+ // height, so the measure cannot feed back on itself.
126
+ function measureContentHeight() {
127
+ var body = document.body;
128
+ var scrollY = window.pageYOffset || 0;
129
+ var bodyRect = body.getBoundingClientRect();
130
+ var bottom = Math.max(bodyRect.bottom, bodyRect.top + (body.clientTop || 0) + body.scrollHeight) + scrollY;
131
+ var el = body;
132
+ for (var depth = 0; el && depth < 64; depth++) {
133
+ var cs = window.getComputedStyle(el);
134
+ var marginBottom = parseFloat(cs.marginBottom) || 0;
135
+ if (marginBottom > 0) bottom = Math.max(bottom, el.getBoundingClientRect().bottom + scrollY + marginBottom);
136
+ // A child's bottom margin only collapses through a plain block parent
137
+ // with no bottom padding or border that does not clip (flex/grid
138
+ // containers, flow-root and scroll containers keep it inside).
139
+ if (cs.display !== 'block' && cs.display !== 'list-item') break;
140
+ if (cs.overflowY && cs.overflowY !== 'visible') break;
141
+ if ((parseFloat(cs.paddingBottom) || 0) > 0 || (parseFloat(cs.borderBottomWidth) || 0) > 0) break;
142
+ el = lastInFlowChild(el);
143
+ }
144
+ var rootStyle = window.getComputedStyle(document.documentElement);
145
+ bottom += (parseFloat(rootStyle.paddingBottom) || 0) + (parseFloat(rootStyle.borderBottomWidth) || 0);
146
+ return Math.max(0, Math.ceil(bottom));
147
+ }
148
+
149
+ // Feedback guard. Content sized by the frame itself (html,body{height:100%},
150
+ // min-height:100vh, a 100vh section) plus anything around it (a collapsed
151
+ // margin, body's own margin) always measures taller than the frame, so every
152
+ // grow would ask for another: the frame would grow forever. The first
153
+ // measure after the parent applied a GROW we asked for decides: if the page
154
+ // still overflows by at least as much as it did before the grow, it grew
155
+ // with the frame, and the frame stays where it is. Later content changes
156
+ // post as usual (and re-run the check on their own grow). Pure, so the
157
+ // test can drive it: returns the height to post, or null for none.
158
+ function nextResizeHeight(state, h, viewport) {
159
+ var applied = state.awaiting && Math.abs(viewport - state.last) <= 1;
160
+ if (applied) state.awaiting = false;
161
+ if (h === state.last) return null;
162
+ if (applied && state.excess > 0 && h > viewport && h - viewport >= state.excess - 1) {
163
+ state.last = h; // held: report nothing until the content measures differently
164
+ return null;
165
+ }
166
+ state.excess = h - viewport;
167
+ state.awaiting = true;
168
+ state.last = h;
169
+ return h;
170
+ }
171
+
172
+ var resizeState = { last: 0, excess: 0, awaiting: false };
100
173
  function postResize() {
101
174
  if (LIVE) return; // live surfaces render full-viewport; the parent ignores height
102
175
  if (!document.body) return;
103
- var h = document.body.scrollHeight;
104
- if (h !== lastHeight) {
105
- lastHeight = h;
106
- postToParent({ type: PREFIX + 'resize', height: h });
107
- }
176
+ var h = nextResizeHeight(resizeState, measureContentHeight(), window.innerHeight);
177
+ if (h !== null) postToParent({ type: PREFIX + 'resize', height: h });
108
178
  }
109
179
  window.addEventListener('load', postResize);
180
+ // The frame resizing is what tells the guard its last grow was applied.
181
+ window.addEventListener('resize', postResize, { passive: true });
110
182
 
111
183
  // --- Selection ---
112
184
  var pendingSelection = null;
@@ -4898,11 +4970,16 @@
4898
4970
 
4899
4971
  function onReady() {
4900
4972
  if (typeof ResizeObserver !== 'undefined' && document.body) {
4901
- new ResizeObserver(function() {
4973
+ var resizeObserver = new ResizeObserver(function() {
4902
4974
  postResize();
4903
4975
  scheduleVimUiUpdate();
4904
4976
  schedulePinpointReconcile();
4905
- }).observe(document.body);
4977
+ });
4978
+ resizeObserver.observe(document.body);
4979
+ // A collapsed margin changing moves body without resizing it; the root
4980
+ // box does resize then. (An html{height:100%} root resizes with the
4981
+ // frame instead, which re-measures the same height and posts nothing.)
4982
+ resizeObserver.observe(document.documentElement);
4906
4983
  }
4907
4984
  watchPageMutations();
4908
4985
  // Armed is the default on both surfaces, and live sessions default to
@@ -332,17 +332,89 @@ export const BRIDGE_SCRIPT = `(function() {
332
332
  });
333
333
 
334
334
  // --- Resize ---
335
- var lastHeight = 0;
335
+ // The last child of el whose bottom margin could collapse through el's
336
+ // bottom edge: out-of-flow children (display:none, absolute/fixed, floats)
337
+ // never do, which also skips the bridge's own fixed overlay elements and a
338
+ // trailing script element.
339
+ function lastInFlowChild(el) {
340
+ for (var child = el.lastElementChild; child; child = child.previousElementSibling) {
341
+ var cs = window.getComputedStyle(child);
342
+ if (cs.display === 'none' || cs.display === 'contents') continue;
343
+ if (cs.position === 'absolute' || cs.position === 'fixed') continue;
344
+ if (cs.float && cs.float !== 'none') continue;
345
+ return child;
346
+ }
347
+ return null;
348
+ }
349
+
350
+ // Height the auto-sized frame needs to show the whole document.
351
+ // Not body.scrollHeight alone: when body has no top padding or border, a
352
+ // first child's top margin collapses THROUGH body and moves body's box down
353
+ // without making it taller (the same holds at the bottom), so the page was
354
+ // cut short by those margins. Not documentElement.scrollHeight either: it
355
+ // never reports less than the frame's own viewport, so the frame could grow
356
+ // but never shrink back, and an html{height:100%} page would pin it at
357
+ // whatever height it had. Instead body's box is placed in document
358
+ // coordinates (its top already includes a collapsed top margin), and the
359
+ // bottom margins that can collapse through body's bottom edge are added by
360
+ // walking the last in-flow child chain. Nothing here reads the frame's own
361
+ // height, so the measure cannot feed back on itself.
362
+ function measureContentHeight() {
363
+ var body = document.body;
364
+ var scrollY = window.pageYOffset || 0;
365
+ var bodyRect = body.getBoundingClientRect();
366
+ var bottom = Math.max(bodyRect.bottom, bodyRect.top + (body.clientTop || 0) + body.scrollHeight) + scrollY;
367
+ var el = body;
368
+ for (var depth = 0; el && depth < 64; depth++) {
369
+ var cs = window.getComputedStyle(el);
370
+ var marginBottom = parseFloat(cs.marginBottom) || 0;
371
+ if (marginBottom > 0) bottom = Math.max(bottom, el.getBoundingClientRect().bottom + scrollY + marginBottom);
372
+ // A child's bottom margin only collapses through a plain block parent
373
+ // with no bottom padding or border that does not clip (flex/grid
374
+ // containers, flow-root and scroll containers keep it inside).
375
+ if (cs.display !== 'block' && cs.display !== 'list-item') break;
376
+ if (cs.overflowY && cs.overflowY !== 'visible') break;
377
+ if ((parseFloat(cs.paddingBottom) || 0) > 0 || (parseFloat(cs.borderBottomWidth) || 0) > 0) break;
378
+ el = lastInFlowChild(el);
379
+ }
380
+ var rootStyle = window.getComputedStyle(document.documentElement);
381
+ bottom += (parseFloat(rootStyle.paddingBottom) || 0) + (parseFloat(rootStyle.borderBottomWidth) || 0);
382
+ return Math.max(0, Math.ceil(bottom));
383
+ }
384
+
385
+ // Feedback guard. Content sized by the frame itself (html,body{height:100%},
386
+ // min-height:100vh, a 100vh section) plus anything around it (a collapsed
387
+ // margin, body's own margin) always measures taller than the frame, so every
388
+ // grow would ask for another: the frame would grow forever. The first
389
+ // measure after the parent applied a GROW we asked for decides: if the page
390
+ // still overflows by at least as much as it did before the grow, it grew
391
+ // with the frame, and the frame stays where it is. Later content changes
392
+ // post as usual (and re-run the check on their own grow). Pure, so the
393
+ // test can drive it: returns the height to post, or null for none.
394
+ function nextResizeHeight(state, h, viewport) {
395
+ var applied = state.awaiting && Math.abs(viewport - state.last) <= 1;
396
+ if (applied) state.awaiting = false;
397
+ if (h === state.last) return null;
398
+ if (applied && state.excess > 0 && h > viewport && h - viewport >= state.excess - 1) {
399
+ state.last = h; // held: report nothing until the content measures differently
400
+ return null;
401
+ }
402
+ state.excess = h - viewport;
403
+ state.awaiting = true;
404
+ state.last = h;
405
+ return h;
406
+ }
407
+
408
+ var resizeState = { last: 0, excess: 0, awaiting: false };
336
409
  function postResize() {
337
410
  if (LIVE) return; // live surfaces render full-viewport; the parent ignores height
338
411
  if (!document.body) return;
339
- var h = document.body.scrollHeight;
340
- if (h !== lastHeight) {
341
- lastHeight = h;
342
- postToParent({ type: PREFIX + 'resize', height: h });
343
- }
412
+ var h = nextResizeHeight(resizeState, measureContentHeight(), window.innerHeight);
413
+ if (h !== null) postToParent({ type: PREFIX + 'resize', height: h });
344
414
  }
345
415
  window.addEventListener('load', postResize);
416
+ // The frame resizing is what tells the guard its last grow was applied.
417
+ window.addEventListener('resize', postResize, { passive: true });
346
418
 
347
419
  // --- Selection ---
348
420
  var pendingSelection = null;
@@ -5134,11 +5206,16 @@ export const BRIDGE_SCRIPT = `(function() {
5134
5206
 
5135
5207
  function onReady() {
5136
5208
  if (typeof ResizeObserver !== 'undefined' && document.body) {
5137
- new ResizeObserver(function() {
5209
+ var resizeObserver = new ResizeObserver(function() {
5138
5210
  postResize();
5139
5211
  scheduleVimUiUpdate();
5140
5212
  schedulePinpointReconcile();
5141
- }).observe(document.body);
5213
+ });
5214
+ resizeObserver.observe(document.body);
5215
+ // A collapsed margin changing moves body without resizing it; the root
5216
+ // box does resize then. (An html{height:100%} root resizes with the
5217
+ // frame instead, which re-measures the same height and posts nothing.)
5218
+ resizeObserver.observe(document.documentElement);
5142
5219
  }
5143
5220
  watchPageMutations();
5144
5221
  // Armed is the default on both surfaces, and live sessions default to
@@ -599,6 +599,23 @@ export const SETTINGS = {
599
599
  },
600
600
  toServer: (value: boolean) => ({ reviewAnalysis: { callFlow: value } }),
601
601
  },
602
+ /**
603
+ * Opt-in background auto-update of the compiled CLI (#1634). The server
604
+ * always sends the config-file value (default false), so the cookie is only
605
+ * a mirror and can never turn this on by itself.
606
+ */
607
+ autoUpdate: {
608
+ defaultValue: false as boolean,
609
+ fromCookie: () => {
610
+ const v = storage.getItem('plannotator-auto-update');
611
+ return v === 'true' ? true : v === 'false' ? false : undefined;
612
+ },
613
+ toCookie: (v: boolean) => storage.setItem('plannotator-auto-update', String(v)),
614
+ serverKey: 'autoUpdate',
615
+ fromServer: (sc: Record<string, unknown>) =>
616
+ typeof sc.autoUpdate === 'boolean' ? sc.autoUpdate : undefined,
617
+ toServer: (v: boolean) => ({ autoUpdate: v }),
618
+ },
602
619
  conventionalComments: {
603
620
  defaultValue: false as boolean,
604
621
  fromCookie: () => {
package/configure.ts CHANGED
@@ -12,6 +12,7 @@ import { setWebMcpPolicy, type WebMcpPolicy } from './webmcp/policy';
12
12
  import { setMathRendererLoader, type MathRenderer, type MathRendererLoader } from './utils/math';
13
13
  import { setIdentityGenerator, type IdentityGenerator } from './utils/generateIdentity';
14
14
  import { setAlertIconRenderer, type AlertIconRenderer } from './components/blocks/AlertBlock';
15
+ import { setFenceThemeResolver, type FenceThemeResolver } from './utils/syntaxTheme';
15
16
  import { configStore } from './config';
16
17
  import type { ServerSyncFn } from './config/configStore';
17
18
  import type { ExternalAnnotationEvent, VaultNode } from './types';
@@ -40,6 +41,7 @@ export type {
40
41
  MathRendererLoader,
41
42
  IdentityGenerator,
42
43
  AlertIconRenderer,
44
+ FenceThemeResolver,
43
45
  };
44
46
 
45
47
  type ExternalAnnotationBase = { id: string; source?: string };
@@ -93,6 +95,15 @@ export interface PlannotatorUIConfig {
93
95
  * today; the package bundles no icon set. A host with one registers a renderer.
94
96
  */
95
97
  alertIconRenderer?: AlertIconRenderer;
98
+ /**
99
+ * Pick the Shiki theme code fences render in, per (palette id, resolved
100
+ * mode). Return `undefined` to keep the default resolution (the palette's
101
+ * `SHIKI_THEME_MAP` entry, else `pierre-dark` / `pierre-light`). Applies to
102
+ * every fence-style surface (Viewer fences, the code-file hover preview, the
103
+ * plan diff view, code review suggestion snippets); the code-review diff
104
+ * pane keeps its own pair. An unknown theme name renders plain text.
105
+ */
106
+ fenceTheme?: FenceThemeResolver;
96
107
  /** Re-hydrate settings from the installed (SYNCHRONOUS) storageBackend after install. */
97
108
  loadSettingsFromBackend?: boolean;
98
109
  }
@@ -114,6 +125,7 @@ export function configurePlannotatorUI(config: PlannotatorUIConfig): void {
114
125
  if (config.mathRendererLoader) setMathRendererLoader(config.mathRendererLoader);
115
126
  if (config.identityGenerator) setIdentityGenerator(config.identityGenerator);
116
127
  if (config.alertIconRenderer) setAlertIconRenderer(config.alertIconRenderer);
128
+ if (config.fenceTheme) setFenceThemeResolver(config.fenceTheme);
117
129
  // Re-hydrate AFTER storageBackend is installed (load-bearing order — gated last).
118
130
  if (config.loadSettingsFromBackend) configStore.loadFromBackend();
119
131
  }
@@ -42,6 +42,18 @@ export interface AskAIParams {
42
42
  /** Context block prepended to the message (e.g. the "changes under review"
43
43
  * description). Rides with each question so it reflects the live view. */
44
44
  contextPreamble?: string;
45
+ /** "Ask this session" only: what to do when the host session is busy
46
+ * ("wait" = ask when it finishes, "interrupt" = stop its turn and ask now). */
47
+ busyPolicy?: 'wait' | 'interrupt';
48
+ }
49
+
50
+ /** Overrides for re-asking a question that ended in a busy/gone/blocked answer. */
51
+ export interface AIRetryOptions {
52
+ busyPolicy?: 'wait' | 'interrupt';
53
+ /** Ask a different provider instead (a fresh session is created for it). */
54
+ providerId?: string;
55
+ /** Replace parts of the original question (e.g. a provider-specific preamble). */
56
+ params?: Partial<AskAIParams>;
45
57
  }
46
58
 
47
59
  interface UseAIChatOptions {
@@ -228,7 +240,7 @@ export function useAIChat({
228
240
  setThread(prev => ({ ...prev, sessionId }));
229
241
  }, []);
230
242
 
231
- const createSession = useCallback(async (signal: AbortSignal, epoch: number): Promise<string> => {
243
+ const createSession = useCallback(async (signal: AbortSignal, epoch: number, providerOverride?: string): Promise<string> => {
232
244
  if (!context) {
233
245
  throw new Error('AI context is unavailable');
234
246
  }
@@ -236,7 +248,10 @@ export function useAIChat({
236
248
  const requestId = ++createRequestRef.current;
237
249
  setIsCreatingSession(true);
238
250
  try {
239
- const res = await aiTransport.session({
251
+ const res = await aiTransport.session(providerOverride ? {
252
+ context,
253
+ providerId: providerOverride,
254
+ } : {
240
255
  context,
241
256
  ...(providerId && { providerId }),
242
257
  ...(model && { model }),
@@ -274,7 +289,11 @@ export function useAIChat({
274
289
  return safeAbort(sessionId);
275
290
  }, []);
276
291
 
277
- const ask = useCallback(async (params: AskAIParams) => {
292
+ // The params each question was asked with, so a busy/gone answer can be
293
+ // re-asked ("Ask when it finishes", "Ask a separate AI instead").
294
+ const paramsByQuestionRef = useRef(new Map<string, AskAIParams>());
295
+
296
+ const ask = useCallback(async (params: AskAIParams, askOptions?: { providerId?: string }) => {
278
297
  if (abortRef.current) {
279
298
  abortRef.current.abort();
280
299
  // Supersede: stop the server-side turn (not just the browser fetch).
@@ -295,6 +314,7 @@ export function useAIChat({
295
314
  setError(null);
296
315
 
297
316
  const questionId = generateId('ai-question');
317
+ paramsByQuestionRef.current.set(questionId, params);
298
318
  const question: AIQuestion = {
299
319
  id: questionId,
300
320
  prompt: params.prompt,
@@ -319,7 +339,9 @@ export function useAIChat({
319
339
 
320
340
  try {
321
341
  let sid = sessionIdRef.current;
322
- if (!sid) {
342
+ if (askOptions?.providerId) {
343
+ sid = await createSession(controller.signal, epoch, askOptions.providerId);
344
+ } else if (!sid) {
323
345
  sid = await createSession(controller.signal, epoch);
324
346
  }
325
347
 
@@ -332,6 +354,7 @@ export function useAIChat({
332
354
  sessionId: sid,
333
355
  prompt: fullPrompt,
334
356
  ...(params.contextUpdate && { contextUpdate: params.contextUpdate }),
357
+ ...(params.busyPolicy && { busyPolicy: params.busyPolicy }),
335
358
  }, controller.signal);
336
359
 
337
360
  if (!res.ok || !res.body) {
@@ -363,7 +386,19 @@ export function useAIChat({
363
386
  updateMessages(prev =>
364
387
  prev.map(m =>
365
388
  m.question.id === questionId
366
- ? { ...m, response: { ...m.response, text: m.response.text + msg.delta } }
389
+ ? { ...m, response: m.response.status
390
+ ? { ...m.response, text: m.response.text + msg.delta, status: undefined }
391
+ : { ...m.response, text: m.response.text + msg.delta } }
392
+ : m
393
+ )
394
+ );
395
+ } else if (msg.type === 'status') {
396
+ // "Ask this session": waiting on / interrupting a busy session.
397
+ const status = msg.status === 'waiting' || msg.status === 'interrupting' ? msg.status : undefined;
398
+ updateMessages(prev =>
399
+ prev.map(m =>
400
+ m.question.id === questionId
401
+ ? { ...m, response: { ...m.response, status } }
367
402
  : m
368
403
  )
369
404
  );
@@ -389,7 +424,16 @@ export function useAIChat({
389
424
  updateMessages(prev =>
390
425
  prev.map(m =>
391
426
  m.question.id === questionId
392
- ? { ...m, response: { ...m.response, error: msg.error, isStreaming: false } }
427
+ ? {
428
+ ...m,
429
+ response: {
430
+ ...m.response,
431
+ error: msg.error,
432
+ ...(typeof msg.code === 'string' && { errorCode: msg.code }),
433
+ ...(m.response.status && { status: undefined }),
434
+ isStreaming: false,
435
+ },
436
+ }
393
437
  : m
394
438
  )
395
439
  );
@@ -418,8 +462,8 @@ export function useAIChat({
418
462
 
419
463
  updateMessages(prev =>
420
464
  prev.map(m =>
421
- m.question.id === questionId && m.response.isStreaming
422
- ? { ...m, response: { ...m.response, isStreaming: false } }
465
+ m.question.id === questionId && (m.response.isStreaming || m.response.status)
466
+ ? { ...m, response: { ...m.response, isStreaming: false, ...(m.response.status && { status: undefined }) } }
423
467
  : m
424
468
  )
425
469
  );
@@ -428,7 +472,7 @@ export function useAIChat({
428
472
  updateMessages(prev =>
429
473
  prev.map(m =>
430
474
  m.question.id === questionId
431
- ? { ...m, response: { ...m.response, isStreaming: false } }
475
+ ? { ...m, response: { ...m.response, isStreaming: false, ...(m.response.status && { status: undefined }) } }
432
476
  : m
433
477
  )
434
478
  );
@@ -452,6 +496,25 @@ export function useAIChat({
452
496
  }
453
497
  }, [buildPrompt, createSession, updateMessages, updatePermissions, postServerAbort]);
454
498
 
499
+ /**
500
+ * Re-ask an earlier question in place of its answer: the busy-session choice
501
+ * ("Ask when it finishes" / "Interrupt and ask now") and the fallback to a
502
+ * separate AI after "Ask this session" reports the session gone or blocked.
503
+ */
504
+ const retry = useCallback((questionId: string, options: AIRetryOptions = {}) => {
505
+ const original = paramsByQuestionRef.current.get(questionId);
506
+ if (!original) return;
507
+ paramsByQuestionRef.current.delete(questionId);
508
+ updateMessages(prev => prev.filter(m => m.question.id !== questionId));
509
+ const { busyPolicy: _previousPolicy, ...base } = original;
510
+ const params: AskAIParams = {
511
+ ...base,
512
+ ...options.params,
513
+ ...(options.busyPolicy && { busyPolicy: options.busyPolicy }),
514
+ };
515
+ return ask(params, options.providerId ? { providerId: options.providerId } : undefined);
516
+ }, [ask, updateMessages]);
517
+
455
518
  const abort = useCallback(() => {
456
519
  if (abortRef.current) {
457
520
  abortRef.current.abort();
@@ -502,6 +565,7 @@ export function useAIChat({
502
565
  abortRef.current = null;
503
566
  pendingAbortRef.current = postServerAbort();
504
567
  }
568
+ paramsByQuestionRef.current.clear();
505
569
  setThread(createThread(threadTitle));
506
570
  setIsCreatingSession(false);
507
571
  setIsStreaming(false);
@@ -527,6 +591,7 @@ export function useAIChat({
527
591
  permissionRequests: thread.permissionRequests,
528
592
  respondToPermission,
529
593
  ask,
594
+ retry,
530
595
  abort,
531
596
  resetSession,
532
597
  resetThread,
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import type { Origin } from '@plannotator/core/agents';
3
3
  import {
4
4
  getAIProviderSettings,
@@ -56,16 +56,26 @@ export function useAIProviderConfig({
56
56
  };
57
57
  });
58
58
 
59
+ // A provider picked for this page only (the "Ask a separate AI instead"
60
+ // fallback from "Ask this session"): never written to the cookie, but kept
61
+ // across capability refreshes so the resolver does not flip back.
62
+ const sessionPickRef = useRef<string | null>(null);
63
+
59
64
  // Auto-resolve provider/model once capabilities are known.
60
65
  useEffect(() => {
61
66
  if (!available || providers.length === 0) return;
62
67
  const saved = getAIProviderSettings();
63
- const selection = resolveAIProviderSelection({
64
- providers,
65
- origin,
66
- settings: saved,
67
- serverDefaultProvider: defaultProvider,
68
- });
68
+ const sessionPick = sessionPickRef.current
69
+ ? providers.find(p => p.id === sessionPickRef.current) ?? null
70
+ : null;
71
+ const selection = sessionPick
72
+ ? { providerId: sessionPick.id, model: resolveAIModelForProvider(sessionPick, saved.preferredModels) }
73
+ : resolveAIProviderSelection({
74
+ providers,
75
+ origin,
76
+ settings: saved,
77
+ serverDefaultProvider: defaultProvider,
78
+ });
69
79
 
70
80
  setAiConfig(prev => {
71
81
  if (prev.providerId === selection.providerId && prev.model === selection.model) return prev;
@@ -81,7 +91,13 @@ export function useAIProviderConfig({
81
91
  // Update the selection (provider/model/effort). Does NOT reset the session —
82
92
  // the caller composes that (see the cycle note above).
83
93
  const applyConfigChange = useCallback(
84
- (config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => {
94
+ (
95
+ config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null },
96
+ options?: { persist?: boolean },
97
+ ) => {
98
+ const persist = options?.persist !== false;
99
+ if (!persist && config.providerId) sessionPickRef.current = config.providerId;
100
+ else if (persist && config.providerId !== undefined) sessionPickRef.current = null;
85
101
  setAiConfig(prev => {
86
102
  const saved = getAIProviderSettings();
87
103
  const providerId = config.providerId !== undefined ? config.providerId : prev.providerId;
@@ -104,12 +120,14 @@ export function useAIProviderConfig({
104
120
  // (e.g. the static fallback a lazily-discovered provider advertises
105
121
  // before activation) must never overwrite the user's saved preference
106
122
  // — the session request may fall back, the cookie may not.
107
- saveAIProviderSelection({
108
- providerId: next.providerId,
109
- model: config.model !== undefined ? next.model : null,
110
- origin,
111
- settings: saved,
112
- });
123
+ if (persist) {
124
+ saveAIProviderSelection({
125
+ providerId: next.providerId,
126
+ model: config.model !== undefined ? next.model : null,
127
+ origin,
128
+ settings: saved,
129
+ });
130
+ }
113
131
  return next;
114
132
  });
115
133
  },
@@ -1105,6 +1105,10 @@ export function useAnnotationHighlighter({
1105
1105
  // verdict, so it is neither attempted nor unanchored here (the same
1106
1106
  // rule the raw-HTML pinpoints follow on their surface).
1107
1107
  if (ann.diagramAnchor) return;
1108
+ // An answer to a `:::question` block quotes the prompt but is not a
1109
+ // selection: the question block draws it from the annotation, so it is
1110
+ // never painted as a highlight (and never reported as unanchored here).
1111
+ if (ann.questionAnswer) return;
1108
1112
  // #881: a row whose quote is only whitespace has nothing to anchor to.
1109
1113
  // Older drafts carry such rows (a double-click at a block boundary used
1110
1114
  // to create one), and the stored positions still resolve, so restoring
@@ -0,0 +1,92 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import {
3
+ getAllFolderPaths,
4
+ getAncestorPaths,
5
+ type DiffFileTreeFile,
6
+ type DiffFileTreeNode,
7
+ } from '../utils/diffFileTree';
8
+
9
+ /** Which folders start open: every folder (the review's behavior), none, or
10
+ * an explicit list of folder paths (`DiffFileTreeNode.path`). */
11
+ export type DiffFileTreeDefaultExpanded = 'all' | 'none' | readonly string[];
12
+
13
+ function initialSet(allFolderPaths: string[], defaultExpanded: DiffFileTreeDefaultExpanded): Set<string> {
14
+ if (defaultExpanded === 'all') return new Set(allFolderPaths);
15
+ if (defaultExpanded === 'none') return new Set();
16
+ return new Set(defaultExpanded);
17
+ }
18
+
19
+ /**
20
+ * Folder open/closed state for a diff file tree, shared by the review's
21
+ * FileTree and the embeddable DiffFileTree:
22
+ * - starts from `defaultExpanded` and resets to it whenever `tree` changes
23
+ * identity (a new diff), so pass a memoized tree;
24
+ * - keeps every ancestor of `activePath` open, so moving the selection always
25
+ * reveals the selected file.
26
+ */
27
+ export function useDiffFileTreeExpansion<F extends DiffFileTreeFile>(
28
+ tree: DiffFileTreeNode<F>[],
29
+ activePath: string | null | undefined,
30
+ defaultExpanded: DiffFileTreeDefaultExpanded = 'all',
31
+ ) {
32
+ const allFolderPaths = useMemo(() => getAllFolderPaths(tree), [tree]);
33
+ const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => initialSet(allFolderPaths, defaultExpanded));
34
+ const [prevTree, setPrevTree] = useState(tree);
35
+
36
+ // Reset when the tree changes (initial render + diff switch).
37
+ if (tree !== prevTree) {
38
+ setPrevTree(tree);
39
+ setExpandedFolders(initialSet(allFolderPaths, defaultExpanded));
40
+ }
41
+
42
+ // Auto-expand ancestors of the active file so keyboard nav always reveals the target.
43
+ useEffect(() => {
44
+ if (activePath) {
45
+ const ancestors = getAncestorPaths(activePath);
46
+ setExpandedFolders((prev) => {
47
+ const missing = ancestors.filter((p) => !prev.has(p));
48
+ if (missing.length === 0) return prev;
49
+ const next = new Set(prev);
50
+ for (const p of missing) next.add(p);
51
+ return next;
52
+ });
53
+ }
54
+ }, [activePath, tree]);
55
+
56
+ const toggleFolder = useCallback((path: string) => {
57
+ setExpandedFolders((prev) => {
58
+ const next = new Set(prev);
59
+ if (next.has(path)) {
60
+ next.delete(path);
61
+ } else {
62
+ next.add(path);
63
+ }
64
+ return next;
65
+ });
66
+ }, []);
67
+
68
+ const setFolderExpanded = useCallback((path: string, expanded: boolean) => {
69
+ setExpandedFolders((prev) => {
70
+ if (prev.has(path) === expanded) return prev;
71
+ const next = new Set(prev);
72
+ if (expanded) next.add(path);
73
+ else next.delete(path);
74
+ return next;
75
+ });
76
+ }, []);
77
+
78
+ const areAllFoldersExpanded = allFolderPaths.length > 0 && allFolderPaths.every((path) => expandedFolders.has(path));
79
+
80
+ const toggleAllFolders = useCallback(() => {
81
+ setExpandedFolders(areAllFoldersExpanded ? new Set() : new Set(allFolderPaths));
82
+ }, [allFolderPaths, areAllFoldersExpanded]);
83
+
84
+ return {
85
+ expandedFolders,
86
+ allFolderPaths,
87
+ areAllFoldersExpanded,
88
+ toggleFolder,
89
+ setFolderExpanded,
90
+ toggleAllFolders,
91
+ };
92
+ }