@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.
- package/HANDOFF.md +202 -1
- package/LICENSE +191 -0
- package/README.md +23 -0
- package/components/AISettingsTab.tsx +33 -1
- package/components/AgentToolAnnouncementDialog.tsx +347 -0
- package/components/AgentsTab.tsx +9 -9
- package/components/AskSessionAnnouncementDialog.tsx +329 -0
- package/components/CommentPopover.tsx +32 -18
- package/components/DecisionControl.tsx +2 -7
- package/components/DiagramBlock.tsx +46 -4
- package/components/DiffFileTree.tsx +363 -0
- package/components/DocBadges.tsx +12 -8
- package/components/ImageLightbox.tsx +66 -0
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/ProviderIcons.tsx +15 -3
- package/components/Settings.tsx +73 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/Viewer.tsx +5 -28
- package/components/ai/AIProviderBar.tsx +20 -3
- package/components/ai/DocumentAIChatPanel.tsx +23 -3
- package/components/ai/SessionAskNotice.tsx +101 -0
- package/components/blocks/QuestionBlock.tsx +24 -19
- package/components/blocks/QuestionContext.tsx +81 -0
- package/components/diagram/DiagramComposer.tsx +54 -3
- package/components/diagram/DiagramViewer.tsx +31 -2
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +13 -3
- package/components/html-viewer/bridge-script.asset.js +209 -16
- package/components/html-viewer/bridge-script.ts +209 -16
- package/components/html-viewer/useHtmlAnnotation.ts +36 -55
- package/configure.ts +12 -0
- package/hooks/useAIChat.ts +96 -10
- package/hooks/useAIProviderActivation.ts +13 -0
- package/hooks/useAIProviderConfig.ts +32 -14
- package/hooks/useActiveSection.ts +3 -2
- package/hooks/useAgentToolSetting.ts +33 -0
- package/hooks/useAnnotationDraft.ts +53 -7
- package/hooks/useAnnotationHighlighter.ts +101 -5
- package/hooks/useDiffFileTreeExpansion.ts +92 -0
- package/hooks/useExternalAnnotations.ts +21 -1
- package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
- package/hooks/useLatchedTrue.ts +15 -0
- package/hooks/useLinkedDoc.ts +64 -4
- package/hooks/useModelCatalogs.ts +9 -2
- package/package.json +3 -2
- package/styles.css +1 -1
- package/themes/gruvbox.css +6 -0
- package/types.ts +17 -0
- package/utils/agentClosed.ts +33 -0
- package/utils/agentToolAnnouncement.ts +109 -0
- package/utils/agentToolSetting.ts +108 -0
- package/utils/aiPrompt.ts +31 -0
- package/utils/aiProvider.ts +41 -0
- package/utils/annotationOwnsHighlight.ts +27 -0
- package/utils/annotationScope.ts +15 -0
- package/utils/askSessionAnnouncement.ts +107 -0
- package/utils/blockTargeting.ts +61 -2
- package/utils/diagramAskAI.ts +161 -0
- package/utils/diffFileTree.ts +203 -0
- package/utils/htmlLinkNavigation.ts +87 -3
- package/utils/parser.ts +236 -110
- package/utils/questionAnswers.ts +3 -1
- package/utils/quickLabels.ts +2 -2
- 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
|
|
481
|
-
*
|
|
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
|
-
|
|
914
|
-
|
|
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
|
-
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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
|
}
|
package/hooks/useAIChat.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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
|
-
? {
|
|
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
|
|
64
|
-
providers
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
//
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }).
|
|
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
|
}
|