@plannotator/ui 0.49.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.
- package/HANDOFF.md +68 -0
- package/LICENSE +191 -0
- package/README.md +23 -0
- package/components/AISettingsTab.tsx +1 -1
- package/components/DiffFileTree.tsx +363 -0
- package/components/ImageLightbox.tsx +66 -0
- package/components/ProviderIcons.tsx +15 -3
- package/components/Viewer.tsx +1 -28
- package/components/ai/AIProviderBar.tsx +2 -2
- package/components/ai/DocumentAIChatPanel.tsx +28 -3
- package/components/ai/SessionAskNotice.tsx +89 -0
- package/components/html-viewer/bridge-script.asset.js +85 -8
- package/components/html-viewer/bridge-script.ts +85 -8
- package/configure.ts +12 -0
- package/hooks/useAIChat.ts +74 -9
- package/hooks/useAIProviderConfig.ts +32 -14
- package/hooks/useDiffFileTreeExpansion.ts +92 -0
- package/package.json +3 -2
- package/styles.css +1 -1
- package/types.ts +4 -0
- package/utils/aiPrompt.ts +31 -0
- package/utils/aiProvider.ts +68 -0
- package/utils/diffFileTree.ts +203 -0
- package/utils/htmlLinkNavigation.ts +87 -3
- package/utils/syntaxTheme.ts +33 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { AIResponse } from '../../types';
|
|
3
|
+
import { SESSION_ASK_ERROR_CODES } from '../../utils/aiProvider';
|
|
4
|
+
|
|
5
|
+
/** What the reviewer chose on an "Ask this session" answer that could not run. */
|
|
6
|
+
export type SessionAskAction = 'wait' | 'interrupt' | 'fallback';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* In-progress line for a question waiting on a busy session. Renders nothing
|
|
10
|
+
* for any other response, so providers without a bridge are unaffected.
|
|
11
|
+
*/
|
|
12
|
+
export const SessionAskStatus: React.FC<{ response: AIResponse }> = ({ response }) => {
|
|
13
|
+
if (!response.status) return null;
|
|
14
|
+
return (
|
|
15
|
+
<span className="text-xs text-muted-foreground" data-session-ask-status={response.status}>
|
|
16
|
+
<span className="ai-streaming-cursor" />{' '}
|
|
17
|
+
{response.status === 'waiting'
|
|
18
|
+
? 'Waiting for the session to finish its current turn…'
|
|
19
|
+
: 'Interrupting the session…'}
|
|
20
|
+
</span>
|
|
21
|
+
);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Colour for an answer's error line: a busy session is a choice, not a
|
|
26
|
+
* failure, so it reads muted; every other error keeps the destructive tone.
|
|
27
|
+
*/
|
|
28
|
+
export function sessionAskErrorTone(response: Pick<AIResponse, 'errorCode'>): string {
|
|
29
|
+
return response.errorCode === SESSION_ASK_ERROR_CODES.agentBusy ? 'text-muted-foreground' : 'text-destructive';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const buttonClass =
|
|
33
|
+
'px-2 py-1 rounded-md text-[10px] font-medium transition-colors';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Follow-up actions under an "Ask this session" error:
|
|
37
|
+
* - the session is busy: ask when it finishes, or interrupt it and ask now;
|
|
38
|
+
* - the session is gone or blocked: ask a separate AI instead.
|
|
39
|
+
* Renders nothing for other errors or when the host passes no handler.
|
|
40
|
+
*/
|
|
41
|
+
export const SessionAskActions: React.FC<{
|
|
42
|
+
response: AIResponse;
|
|
43
|
+
onAction?: (action: SessionAskAction) => void;
|
|
44
|
+
/** Label of the provider the fallback would use; null hides the fallback. */
|
|
45
|
+
fallbackLabel?: string | null;
|
|
46
|
+
}> = ({ response, onAction, fallbackLabel }) => {
|
|
47
|
+
if (!onAction || !response.errorCode) return null;
|
|
48
|
+
|
|
49
|
+
if (response.errorCode === SESSION_ASK_ERROR_CODES.agentBusy) {
|
|
50
|
+
return (
|
|
51
|
+
<div className="flex flex-wrap gap-1.5 mt-2" data-session-ask-actions="busy">
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
onClick={() => onAction('wait')}
|
|
55
|
+
className={`${buttonClass} bg-primary text-primary-foreground hover:opacity-90`}
|
|
56
|
+
>
|
|
57
|
+
Ask when it finishes
|
|
58
|
+
</button>
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
onClick={() => onAction('interrupt')}
|
|
62
|
+
className={`${buttonClass} bg-muted text-foreground hover:bg-muted/80`}
|
|
63
|
+
title="Stops the session's current work, then asks your question"
|
|
64
|
+
>
|
|
65
|
+
Interrupt and ask now
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (
|
|
72
|
+
(response.errorCode === SESSION_ASK_ERROR_CODES.gone || response.errorCode === SESSION_ASK_ERROR_CODES.blocked) &&
|
|
73
|
+
fallbackLabel
|
|
74
|
+
) {
|
|
75
|
+
return (
|
|
76
|
+
<div className="flex flex-wrap gap-1.5 mt-2" data-session-ask-actions="fallback">
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
onClick={() => onAction('fallback')}
|
|
80
|
+
className={`${buttonClass} bg-muted text-foreground hover:bg-muted/80`}
|
|
81
|
+
>
|
|
82
|
+
Ask a separate AI instead ({fallbackLabel})
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return null;
|
|
89
|
+
};
|
|
@@ -96,17 +96,89 @@
|
|
|
96
96
|
});
|
|
97
97
|
|
|
98
98
|
// --- Resize ---
|
|
99
|
-
|
|
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 =
|
|
104
|
-
if (h !==
|
|
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
|
-
})
|
|
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
|
-
|
|
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 =
|
|
340
|
-
if (h !==
|
|
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
|
-
})
|
|
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
|
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
|
@@ -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
|
-
|
|
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 (
|
|
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:
|
|
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
|
-
? {
|
|
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
|
|
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
|
},
|