@plannotator/ui 0.49.0 → 0.51.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 (64) hide show
  1. package/HANDOFF.md +202 -1
  2. package/LICENSE +191 -0
  3. package/README.md +23 -0
  4. package/components/AISettingsTab.tsx +33 -1
  5. package/components/AgentToolAnnouncementDialog.tsx +347 -0
  6. package/components/AgentsTab.tsx +9 -9
  7. package/components/AskSessionAnnouncementDialog.tsx +329 -0
  8. package/components/CommentPopover.tsx +32 -18
  9. package/components/DecisionControl.tsx +2 -7
  10. package/components/DiagramBlock.tsx +46 -4
  11. package/components/DiffFileTree.tsx +363 -0
  12. package/components/DocBadges.tsx +12 -8
  13. package/components/ImageLightbox.tsx +66 -0
  14. package/components/PlanHeaderMenu.tsx +1 -1
  15. package/components/ProviderIcons.tsx +15 -3
  16. package/components/Settings.tsx +73 -1
  17. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  18. package/components/Viewer.tsx +5 -28
  19. package/components/ai/AIProviderBar.tsx +20 -3
  20. package/components/ai/DocumentAIChatPanel.tsx +23 -3
  21. package/components/ai/SessionAskNotice.tsx +101 -0
  22. package/components/blocks/QuestionBlock.tsx +24 -19
  23. package/components/blocks/QuestionContext.tsx +81 -0
  24. package/components/diagram/DiagramComposer.tsx +54 -3
  25. package/components/diagram/DiagramViewer.tsx +31 -2
  26. package/components/diagram/index.ts +1 -1
  27. package/components/html-viewer/HtmlViewer.tsx +13 -3
  28. package/components/html-viewer/bridge-script.asset.js +209 -16
  29. package/components/html-viewer/bridge-script.ts +209 -16
  30. package/components/html-viewer/useHtmlAnnotation.ts +36 -55
  31. package/configure.ts +12 -0
  32. package/hooks/useAIChat.ts +96 -10
  33. package/hooks/useAIProviderActivation.ts +13 -0
  34. package/hooks/useAIProviderConfig.ts +32 -14
  35. package/hooks/useActiveSection.ts +3 -2
  36. package/hooks/useAgentToolSetting.ts +33 -0
  37. package/hooks/useAnnotationDraft.ts +53 -7
  38. package/hooks/useAnnotationHighlighter.ts +101 -5
  39. package/hooks/useDiffFileTreeExpansion.ts +92 -0
  40. package/hooks/useExternalAnnotations.ts +21 -1
  41. package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
  42. package/hooks/useLatchedTrue.ts +15 -0
  43. package/hooks/useLinkedDoc.ts +64 -4
  44. package/hooks/useModelCatalogs.ts +9 -2
  45. package/package.json +3 -2
  46. package/styles.css +1 -1
  47. package/themes/gruvbox.css +6 -0
  48. package/types.ts +17 -0
  49. package/utils/agentClosed.ts +33 -0
  50. package/utils/agentToolAnnouncement.ts +109 -0
  51. package/utils/agentToolSetting.ts +108 -0
  52. package/utils/aiPrompt.ts +31 -0
  53. package/utils/aiProvider.ts +41 -0
  54. package/utils/annotationOwnsHighlight.ts +27 -0
  55. package/utils/annotationScope.ts +15 -0
  56. package/utils/askSessionAnnouncement.ts +107 -0
  57. package/utils/blockTargeting.ts +61 -2
  58. package/utils/diagramAskAI.ts +161 -0
  59. package/utils/diffFileTree.ts +203 -0
  60. package/utils/htmlLinkNavigation.ts +87 -3
  61. package/utils/parser.ts +236 -110
  62. package/utils/questionAnswers.ts +3 -1
  63. package/utils/quickLabels.ts +2 -2
  64. package/utils/syntaxTheme.ts +33 -0
@@ -477,8 +477,8 @@ export function useHtmlAnnotation({
477
477
  flashDraftTarget: (key: string) => void;
478
478
  /** Bumped after every target add/remove so the composer can refocus its textarea. */
479
479
  composerFocusToken: number;
480
- /** Composer one-click "Looks good": submits the hardcoded positive label
481
- * with the same anchor and multi-select targets a typed comment would carry. */
480
+ /** Composer one-click 👍: submits the hardcoded positive label with the
481
+ * same anchor and multi-select targets a typed comment would carry. */
482
482
  handleCommentLooksGood: () => void;
483
483
  /** Ids this module minted for locally created annotations (create-mark),
484
484
  * for the unanchored union: a minted id the host never listed is a
@@ -910,8 +910,14 @@ export function useHtmlAnnotation({
910
910
  [getOrCreateAnchor],
911
911
  );
912
912
 
913
- const handleCommentSubmit = useCallback(
914
- (comment: string, images?: ImageAttachment[], mentions?: readonly string[]) => {
913
+ // One commit path for everything the pinpoint composer submits (a typed
914
+ // comment or the one-click 👍), so both carry the same anchor, element
915
+ // context and multi-select targets.
916
+ const commitComposerDraft = useCallback(
917
+ (
918
+ body: Pick<Annotation, "text"> &
919
+ Partial<Pick<Annotation, "images" | "mentions" | "isQuickLabel" | "quickLabelTip">>,
920
+ ) => {
915
921
  if (!enabledRef.current) return;
916
922
  // Prefer the text captured when the popover opened — it can't be clobbered by
917
923
  // a later selection change or clear while the user is composing the comment.
@@ -939,14 +945,10 @@ export function useHtmlAnnotation({
939
945
  startOffset: 0,
940
946
  endOffset: 0,
941
947
  type: AnnotationType.COMMENT,
942
- text: comment,
943
948
  originalText: text,
944
949
  author: getIdentity(),
945
950
  createdA: Date.now(),
946
- images,
947
- // Host capability: present only when a mentionSource was supplied AND
948
- // a token survived, so a comment without one is unchanged.
949
- ...(mentions && mentions.length > 0 ? { mentions } : {}),
951
+ ...body,
950
952
  htmlAnchor: pendingAnchorRef.current ?? undefined,
951
953
  elementContext: pendingContextRef.current ?? undefined,
952
954
  htmlAdditionalTargets: additionalTargets,
@@ -961,53 +963,32 @@ export function useHtmlAnnotation({
961
963
  [post],
962
964
  );
963
965
 
964
- // The composer's one-click "Looks good" (the restored thumbs-up for
965
- // comment-only surfaces, where pinpoint clicks land straight in the
966
- // composer and never see the selection toolbar). Mirrors
967
- // handleCommentSubmit — same anchor, same multi-select targets — but
968
- // emits the hardcoded positive label instead of typed prose.
969
- const handleCommentLooksGood = useCallback(() => {
970
- if (!enabledRef.current) return;
971
- const text = commentPopoverRef.current?.selectedText || pendingTextRef.current;
972
- if (!text) return;
973
-
974
- const targets = draftTargetsRef.current;
975
- const additionalTargets: HtmlAnnotationTarget[] | undefined =
976
- targets.length > 1
977
- ? targets.slice(1, 1 + maxTargetsRef.current).map((t) => ({
978
- label: t.label,
979
- text: t.text,
980
- anchor: t.anchor ?? undefined,
981
- ...(t.context ? { context: t.context } : {}),
982
- }))
983
- : undefined;
984
-
985
- const id = nextHtmlAnnId();
986
- mintedIdsRef.current.add(id);
987
- post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
988
- onAddRef.current?.({
989
- id,
990
- blockId: "",
991
- startOffset: 0,
992
- endOffset: 0,
993
- type: AnnotationType.COMMENT,
994
- text: THUMBS_UP_LABEL.text,
995
- originalText: text,
996
- isQuickLabel: true,
997
- quickLabelTip: THUMBS_UP_LABEL.tip,
998
- author: getIdentity(),
999
- createdA: Date.now(),
1000
- htmlAnchor: pendingAnchorRef.current ?? undefined,
1001
- elementContext: pendingContextRef.current ?? undefined,
1002
- htmlAdditionalTargets: additionalTargets,
1003
- });
966
+ const handleCommentSubmit = useCallback(
967
+ (comment: string, images?: ImageAttachment[], mentions?: readonly string[]) =>
968
+ commitComposerDraft({
969
+ text: comment,
970
+ images,
971
+ // Host capability: present only when a mentionSource was supplied AND
972
+ // a token survived, so a comment without one is unchanged.
973
+ ...(mentions && mentions.length > 0 ? { mentions } : {}),
974
+ }),
975
+ [commitComposerDraft],
976
+ );
1004
977
 
1005
- setCommentPopover(null);
1006
- setDraftTargets([]);
1007
- pendingTextRef.current = "";
1008
- pendingAnchorRef.current = null;
1009
- pendingContextRef.current = null;
1010
- }, [post]);
978
+ // The composer's one-click 👍. A pinpoint click opens this composer
979
+ // directly and never shows the selection toolbar, so without it a reviewer
980
+ // could not mark an element "looks good" in one click. Emits the same
981
+ // hardcoded positive label as the toolbar's 👍 (the only label comment-only
982
+ // surfaces may create), on the pinned element(s).
983
+ const handleCommentLooksGood = useCallback(
984
+ () =>
985
+ commitComposerDraft({
986
+ text: THUMBS_UP_LABEL.text,
987
+ isQuickLabel: true,
988
+ quickLabelTip: THUMBS_UP_LABEL.tip,
989
+ }),
990
+ [commitComposerDraft],
991
+ );
1011
992
 
1012
993
  const handleCommentClose = useCallback(() => {
1013
994
  post({ type: `${PREFIX}cancel-selection` });
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
  }
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import type { AIContext } from '@plannotator/core';
3
3
  import type { AIQuestion, AIResponse } from '../types';
4
4
  import { generateId } from '../utils/generateId';
5
+ import { SESSION_ASK_ERROR_CODES } from '../utils/aiProvider';
5
6
 
6
7
  export interface AIChatEntry {
7
8
  question: AIQuestion;
@@ -42,6 +43,18 @@ export interface AskAIParams {
42
43
  /** Context block prepended to the message (e.g. the "changes under review"
43
44
  * description). Rides with each question so it reflects the live view. */
44
45
  contextPreamble?: string;
46
+ /** "Ask this session" only: what to do when the host session is busy
47
+ * ("wait" = ask when it finishes, "interrupt" = stop its turn and ask now). */
48
+ busyPolicy?: 'wait' | 'interrupt';
49
+ }
50
+
51
+ /** Overrides for re-asking a question that ended in a busy/gone/blocked answer. */
52
+ export interface AIRetryOptions {
53
+ busyPolicy?: 'wait' | 'interrupt';
54
+ /** Ask a different provider instead (a fresh session is created for it). */
55
+ providerId?: string;
56
+ /** Replace parts of the original question (e.g. a provider-specific preamble). */
57
+ params?: Partial<AskAIParams>;
45
58
  }
46
59
 
47
60
  interface UseAIChatOptions {
@@ -79,7 +92,8 @@ export function buildDefaultPrompt(params: AskAIParams): string {
79
92
  const label = params.scope.label ? `Re: ${params.scope.label}` : 'Re: selected text';
80
93
  const source = params.scope.sourcePath ? `\nSource: ${params.scope.sourcePath}` : '';
81
94
  const selection = params.scope.text ? `\n\nSelected text:\n\`\`\`\n${params.scope.text}\n\`\`\`` : '';
82
- return `${label}${source}${selection}\n\n${params.prompt}`;
95
+ const detail = params.scope.detail?.trim() ? `\n\nSelected element:\n${params.scope.detail.trim()}` : '';
96
+ return `${label}${source}${selection}${detail}\n\n${params.prompt}`;
83
97
  }
84
98
 
85
99
  // General question (no explicit file/line/selection): tell the agent what the
@@ -228,7 +242,7 @@ export function useAIChat({
228
242
  setThread(prev => ({ ...prev, sessionId }));
229
243
  }, []);
230
244
 
231
- const createSession = useCallback(async (signal: AbortSignal, epoch: number): Promise<string> => {
245
+ const createSession = useCallback(async (signal: AbortSignal, epoch: number, providerOverride?: string): Promise<string> => {
232
246
  if (!context) {
233
247
  throw new Error('AI context is unavailable');
234
248
  }
@@ -236,7 +250,10 @@ export function useAIChat({
236
250
  const requestId = ++createRequestRef.current;
237
251
  setIsCreatingSession(true);
238
252
  try {
239
- const res = await aiTransport.session({
253
+ const res = await aiTransport.session(providerOverride ? {
254
+ context,
255
+ providerId: providerOverride,
256
+ } : {
240
257
  context,
241
258
  ...(providerId && { providerId }),
242
259
  ...(model && { model }),
@@ -274,7 +291,11 @@ export function useAIChat({
274
291
  return safeAbort(sessionId);
275
292
  }, []);
276
293
 
277
- const ask = useCallback(async (params: AskAIParams) => {
294
+ // The params each question was asked with, so a busy answer can be
295
+ // re-asked ("Ask when it finishes", "Interrupt and ask now").
296
+ const paramsByQuestionRef = useRef(new Map<string, AskAIParams>());
297
+
298
+ const ask = useCallback(async (params: AskAIParams, askOptions?: { providerId?: string }) => {
278
299
  if (abortRef.current) {
279
300
  abortRef.current.abort();
280
301
  // Supersede: stop the server-side turn (not just the browser fetch).
@@ -295,6 +316,7 @@ export function useAIChat({
295
316
  setError(null);
296
317
 
297
318
  const questionId = generateId('ai-question');
319
+ paramsByQuestionRef.current.set(questionId, params);
298
320
  const question: AIQuestion = {
299
321
  id: questionId,
300
322
  prompt: params.prompt,
@@ -319,7 +341,9 @@ export function useAIChat({
319
341
 
320
342
  try {
321
343
  let sid = sessionIdRef.current;
322
- if (!sid) {
344
+ if (askOptions?.providerId) {
345
+ sid = await createSession(controller.signal, epoch, askOptions.providerId);
346
+ } else if (!sid) {
323
347
  sid = await createSession(controller.signal, epoch);
324
348
  }
325
349
 
@@ -332,6 +356,7 @@ export function useAIChat({
332
356
  sessionId: sid,
333
357
  prompt: fullPrompt,
334
358
  ...(params.contextUpdate && { contextUpdate: params.contextUpdate }),
359
+ ...(params.busyPolicy && { busyPolicy: params.busyPolicy }),
335
360
  }, controller.signal);
336
361
 
337
362
  if (!res.ok || !res.body) {
@@ -363,7 +388,19 @@ export function useAIChat({
363
388
  updateMessages(prev =>
364
389
  prev.map(m =>
365
390
  m.question.id === questionId
366
- ? { ...m, response: { ...m.response, text: m.response.text + msg.delta } }
391
+ ? { ...m, response: m.response.status
392
+ ? { ...m.response, text: m.response.text + msg.delta, status: undefined }
393
+ : { ...m.response, text: m.response.text + msg.delta } }
394
+ : m
395
+ )
396
+ );
397
+ } else if (msg.type === 'status') {
398
+ // "Ask this session": waiting on / interrupting a busy session.
399
+ const status = msg.status === 'waiting' || msg.status === 'interrupting' ? msg.status : undefined;
400
+ updateMessages(prev =>
401
+ prev.map(m =>
402
+ m.question.id === questionId
403
+ ? { ...m, response: { ...m.response, status } }
367
404
  : m
368
405
  )
369
406
  );
@@ -385,11 +422,39 @@ export function useAIChat({
385
422
  description: msg.description,
386
423
  toolUseId: msg.toolUseId,
387
424
  }]);
425
+ } else if (msg.type === 'error' && msg.code === SESSION_ASK_ERROR_CODES.takenOver) {
426
+ // "Ask this session": the person typed into the session while it
427
+ // answered. The answer so far stands; the reason is a note under
428
+ // it, not an error that would replace it.
429
+ updateMessages(prev =>
430
+ prev.map(m =>
431
+ m.question.id === questionId
432
+ ? {
433
+ ...m,
434
+ response: {
435
+ ...m.response,
436
+ notice: typeof msg.error === 'string' ? msg.error : undefined,
437
+ ...(m.response.status && { status: undefined }),
438
+ isStreaming: false,
439
+ },
440
+ }
441
+ : m
442
+ )
443
+ );
388
444
  } else if (msg.type === 'error') {
389
445
  updateMessages(prev =>
390
446
  prev.map(m =>
391
447
  m.question.id === questionId
392
- ? { ...m, response: { ...m.response, error: msg.error, isStreaming: false } }
448
+ ? {
449
+ ...m,
450
+ response: {
451
+ ...m.response,
452
+ error: msg.error,
453
+ ...(typeof msg.code === 'string' && { errorCode: msg.code }),
454
+ ...(m.response.status && { status: undefined }),
455
+ isStreaming: false,
456
+ },
457
+ }
393
458
  : m
394
459
  )
395
460
  );
@@ -418,8 +483,8 @@ export function useAIChat({
418
483
 
419
484
  updateMessages(prev =>
420
485
  prev.map(m =>
421
- m.question.id === questionId && m.response.isStreaming
422
- ? { ...m, response: { ...m.response, isStreaming: false } }
486
+ m.question.id === questionId && (m.response.isStreaming || m.response.status)
487
+ ? { ...m, response: { ...m.response, isStreaming: false, ...(m.response.status && { status: undefined }) } }
423
488
  : m
424
489
  )
425
490
  );
@@ -428,7 +493,7 @@ export function useAIChat({
428
493
  updateMessages(prev =>
429
494
  prev.map(m =>
430
495
  m.question.id === questionId
431
- ? { ...m, response: { ...m.response, isStreaming: false } }
496
+ ? { ...m, response: { ...m.response, isStreaming: false, ...(m.response.status && { status: undefined }) } }
432
497
  : m
433
498
  )
434
499
  );
@@ -452,6 +517,25 @@ export function useAIChat({
452
517
  }
453
518
  }, [buildPrompt, createSession, updateMessages, updatePermissions, postServerAbort]);
454
519
 
520
+ /**
521
+ * Re-ask an earlier question in place of its answer: the busy-session choice
522
+ * ("Ask when it finishes" / "Interrupt and ask now"). `providerId` re-asks
523
+ * on another provider for hosts that offer one.
524
+ */
525
+ const retry = useCallback((questionId: string, options: AIRetryOptions = {}) => {
526
+ const original = paramsByQuestionRef.current.get(questionId);
527
+ if (!original) return;
528
+ paramsByQuestionRef.current.delete(questionId);
529
+ updateMessages(prev => prev.filter(m => m.question.id !== questionId));
530
+ const { busyPolicy: _previousPolicy, ...base } = original;
531
+ const params: AskAIParams = {
532
+ ...base,
533
+ ...options.params,
534
+ ...(options.busyPolicy && { busyPolicy: options.busyPolicy }),
535
+ };
536
+ return ask(params, options.providerId ? { providerId: options.providerId } : undefined);
537
+ }, [ask, updateMessages]);
538
+
455
539
  const abort = useCallback(() => {
456
540
  if (abortRef.current) {
457
541
  abortRef.current.abort();
@@ -502,6 +586,7 @@ export function useAIChat({
502
586
  abortRef.current = null;
503
587
  pendingAbortRef.current = postServerAbort();
504
588
  }
589
+ paramsByQuestionRef.current.clear();
505
590
  setThread(createThread(threadTitle));
506
591
  setIsCreatingSession(false);
507
592
  setIsStreaming(false);
@@ -527,6 +612,7 @@ export function useAIChat({
527
612
  permissionRequests: thread.permissionRequests,
528
613
  respondToPermission,
529
614
  ask,
615
+ retry,
530
616
  abort,
531
617
  resetSession,
532
618
  resetThread,
@@ -23,13 +23,26 @@ export interface ActivatedAIProvider extends AIProviderOption {
23
23
 
24
24
  export function useAIProviderActivation(options: {
25
25
  onCapabilities: (providers: ActivatedAIProvider[], defaultProvider: string | null) => void;
26
+ /**
27
+ * The Ask AI providers the server currently lists. When given, an id not in
28
+ * it is never activated: at page load the selection can still hold a saved
29
+ * provider id (e.g. `codex-sdk`) for one render before the resolver moves it
30
+ * to what the server offers, and activating that id would start its model
31
+ * discovery even though Ask AI does not offer it (a session-bridge server
32
+ * lists only the bridge). Omitted keeps the old behavior.
33
+ */
34
+ providers?: ReadonlyArray<Pick<AIProviderOption, 'id'>>;
26
35
  }) {
27
36
  const activatedRef = useRef<Set<string>>(new Set());
28
37
  const onCapabilitiesRef = useRef(options.onCapabilities);
29
38
  onCapabilitiesRef.current = options.onCapabilities;
39
+ const providersRef = useRef(options.providers);
40
+ providersRef.current = options.providers;
30
41
 
31
42
  return useCallback((providerId: string | null | undefined) => {
32
43
  if (!providerId || activatedRef.current.has(providerId)) return;
44
+ const listed = providersRef.current;
45
+ if (listed && !listed.some(provider => provider.id === providerId)) return;
33
46
  activatedRef.current.add(providerId);
34
47
  fetch(`/api/ai/capabilities?activate=${encodeURIComponent(providerId)}`)
35
48
  .then(res => (res.ok ? res.json() : null))
@@ -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 (`applyConfigChange(..., { persist:
60
+ // false })`): never written to the cookie, but kept across capability
61
+ // 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
  },
@@ -21,8 +21,9 @@ export function useActiveSection(
21
21
  const viewport = scrollElement ?? contentContainer;
22
22
  if (!contentContainer || !viewport) return;
23
23
 
24
- // Find all heading elements with data-block-id
25
- const headings = contentContainer.querySelectorAll('[data-block-type="heading"]');
24
+ // Only document headings: a heading inside a question card's context has
25
+ // no block id and is not a TOC section.
26
+ const headings = contentContainer.querySelectorAll('[data-block-type="heading"][data-block-id]');
26
27
  if (headings.length === 0) return;
27
28
 
28
29
  // Track which headings are currently intersecting
@@ -0,0 +1,33 @@
1
+ import { useCallback, useState } from 'react';
2
+ import {
3
+ parseAgentToolSetting,
4
+ saveAgentToolSetting,
5
+ withSavedAgentTool,
6
+ type AgentToolSetting,
7
+ } from '../utils/agentToolSetting';
8
+
9
+ export interface AgentToolSettingState {
10
+ /** Undefined until a serverConfig reporting a tool host is adopted. */
11
+ readonly setting: AgentToolSetting | undefined;
12
+ /** Takes the agent tool fields from a `/api/plan` or `/api/diff` serverConfig. */
13
+ readonly adopt: (serverConfig: unknown) => void;
14
+ /** Writes the switch; resolves once config.json holds it, rejects with a readable Error. */
15
+ readonly save: (enabled: boolean) => Promise<void>;
16
+ }
17
+
18
+ /**
19
+ * The agent tool switch for an App: the Settings row and the one-time offer
20
+ * read the same state, so turning the tool on from the offer shows in
21
+ * Settings at once, and vice versa.
22
+ */
23
+ export function useAgentToolSetting(): AgentToolSettingState {
24
+ const [setting, setSetting] = useState<AgentToolSetting | undefined>(undefined);
25
+ const adopt = useCallback((serverConfig: unknown) => {
26
+ setSetting(parseAgentToolSetting(serverConfig));
27
+ }, []);
28
+ const save = useCallback(async (enabled: boolean) => {
29
+ await saveAgentToolSetting(enabled);
30
+ setSetting((current) => (current ? withSavedAgentTool(current, enabled) : current));
31
+ }, []);
32
+ return { setting, adopt, save };
33
+ }
@@ -41,17 +41,30 @@ const DEBOUNCE_MS = 500;
41
41
  * - `load` returns the raw stored body (or null) plus the generation the
42
42
  * store reports when there is NO draft (the default reads `draftGeneration`
43
43
  * from the 404 body) so the client can resume past a tombstone.
44
+ * - `save` MAY resolve `{ staleGeneration }` when the store refused the body
45
+ * as stale (the default reads it from a `409 { draftGeneration }` that is
46
+ * not marked `decided: true`, which
47
+ * annotate servers answer when the file's path copy was deleted or moved
48
+ * ahead by another session). The hook then raises its counter above that
49
+ * generation and saves again, once. Resolving nothing keeps the old
50
+ * behavior, so a host transport that never refuses needs no change.
44
51
  */
45
52
  export interface DraftTransport {
46
53
  /** GET the draft. `data` is the raw stored body (null if none). `generation`
47
54
  is the store's reported generation when there is no draft (null otherwise). */
48
55
  load(): Promise<{ data: unknown | null; generation: number | null }>;
49
- /** Persist the draft body. `keepalive` requests best-effort delivery on close. */
50
- save(body: object, opts: { keepalive: boolean }): Promise<void>;
56
+ /** Persist the draft body. `keepalive` requests best-effort delivery on close.
57
+ Resolves `{ staleGeneration }` when the store refused the body as stale. */
58
+ save(body: object, opts: { keepalive: boolean }): Promise<void | DraftSaveRefused>;
51
59
  /** Generation-gated tombstone delete. */
52
60
  remove(generation: number, opts: { keepalive: boolean }): Promise<void>;
53
61
  }
54
62
 
63
+ /** A save the store refused as stale: the highest generation it knows. */
64
+ export interface DraftSaveRefused {
65
+ staleGeneration: number;
66
+ }
67
+
55
68
  /**
56
69
  * Default transport — Plannotator's `/api/draft` fetches, moved verbatim.
57
70
  * `save` rejects on failure (the keepalive retry stays in the hook so its
@@ -73,7 +86,15 @@ const defaultDraftTransport: DraftTransport = {
73
86
  const payload = JSON.stringify(body);
74
87
  const headers = { 'Content-Type': 'application/json' };
75
88
  return fetch('/api/draft', { method: 'POST', headers, body: payload, keepalive }).then(
76
- () => {},
89
+ async (res): Promise<void | DraftSaveRefused> => {
90
+ if (res.status !== 409) return;
91
+ const data = (await res.json().catch(() => null)) as (MissingDraftData & { decided?: boolean }) | null;
92
+ // The review was decided: saving again would put back comments that
93
+ // were just sent, so a decided refusal is never retried.
94
+ if (data?.decided === true) return;
95
+ const staleGeneration = readDraftGeneration(data?.draftGeneration);
96
+ return staleGeneration !== null ? { staleGeneration } : undefined;
97
+ },
77
98
  );
78
99
  },
79
100
  remove(generation, { keepalive }) {
@@ -281,6 +302,10 @@ interface UseAnnotationDraftResult {
281
302
  scheduleDraftSaveAfterSubmitFailure: () => void;
282
303
  getDraftGeneration: () => number;
283
304
  dismissDraft: () => void;
305
+ /** Send a pending (debounced) save now, if there is one. For a session
306
+ that ends WITHOUT a decision body (the agent closed it): the comment
307
+ typed just before must still reach the kept draft. */
308
+ flushPendingSave: () => void;
284
309
  }
285
310
 
286
311
  export function useAnnotationDraft({
@@ -393,7 +418,7 @@ export function useAnnotationDraft({
393
418
  });
394
419
  }, [isApiMode, isSharedSession]);
395
420
 
396
- const persistNow = useCallback((keepalive: boolean) => {
421
+ const persistNow = useCallback((keepalive: boolean, retried = false) => {
397
422
  // Re-check: the session may have been submitted while the debounce was
398
423
  // pending — a save landing after submit would resurrect a draft the
399
424
  // server just deleted, ghosting it into the next session for this plan.
@@ -429,15 +454,29 @@ export function useAnnotationDraft({
429
454
  // The transport moves the POST behind the seam; the keepalive retry-on-failure
430
455
  // gate stays in the hook verbatim so a host transport that resolves/rejects on
431
456
  // failure still won't resurrect a superseded save.
432
- getDraftTransport().save(payload, { keepalive }).catch(() => {
457
+ // A refused (stale) save means the store moved past this tab's counter:
458
+ // another session on the same file decided or saved ahead of it. Adopt
459
+ // the store's generation and save again, so the reviewer's comments are
460
+ // not silently left unsaved. Only while this is still the latest save,
461
+ // and only once per save.
462
+ const retryIfRefused = (result: void | DraftSaveRefused) => {
463
+ if (!result || retried) return;
464
+ if (!canPersistRef.current || draftGenerationRef.current !== draftGeneration) return;
465
+ draftGenerationRef.current = Math.max(draftGenerationRef.current, result.staleGeneration);
466
+ persistNowRef.current(false, true);
467
+ };
468
+
469
+ getDraftTransport().save(payload, { keepalive }).then(retryIfRefused, () => {
433
470
  // Chromium caps keepalive bodies (~64KB); retry without it. Completes
434
471
  // fine when the page was only backgrounded, best-effort on close.
435
472
  if (keepalive && canPersistRef.current && draftGenerationRef.current === draftGeneration) {
436
- getDraftTransport().save(payload, { keepalive: false }).catch(() => {});
473
+ getDraftTransport().save(payload, { keepalive: false }).then(retryIfRefused, () => {});
437
474
  }
438
475
  // Otherwise silent failure — draft is best-effort.
439
476
  });
440
477
  }, []);
478
+ const persistNowRef = useRef(persistNow);
479
+ persistNowRef.current = persistNow;
441
480
 
442
481
  const scheduleDraftSave = useCallback(() => {
443
482
  if (!canPersistRef.current || !hasMountedRef.current) return;
@@ -457,6 +496,13 @@ export function useAnnotationDraft({
457
496
 
458
497
  const getDraftGeneration = useCallback(() => draftGenerationRef.current + 1, []);
459
498
 
499
+ const flushPendingSave = useCallback(() => {
500
+ if (timerRef.current === null) return;
501
+ clearTimeout(timerRef.current);
502
+ timerRef.current = null;
503
+ persistNow(false);
504
+ }, [persistNow]);
505
+
460
506
  // Flush a pending save when the page is backgrounded or closed — otherwise
461
507
  // the last debounce window of typing is lost on tab close, and reopening
462
508
  // the (still-running) session would restore a draft missing those
@@ -519,5 +565,5 @@ export function useAnnotationDraft({
519
565
  getDraftTransport().remove(deletedGeneration, { keepalive: false }).catch(() => {});
520
566
  }, []);
521
567
 
522
- return { draftBanner, restoreDraft, scheduleDraftSave, scheduleDraftSaveAfterSubmitFailure, getDraftGeneration, dismissDraft };
568
+ return { draftBanner, restoreDraft, scheduleDraftSave, scheduleDraftSaveAfterSubmitFailure, getDraftGeneration, dismissDraft, flushPendingSave };
523
569
  }