@plannotator/ui 0.47.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HANDOFF.md +147 -0
- package/LICENSE +191 -0
- package/README.md +56 -1
- package/components/AISettingsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +44 -3
- package/components/BlockRenderer.tsx +55 -1
- package/components/CompletionOverlay.tsx +23 -1
- package/components/DiffFileTree.tsx +363 -0
- package/components/ImageLightbox.tsx +66 -0
- package/components/PRPlatformIcon.tsx +22 -0
- package/components/ProviderIcons.tsx +15 -3
- package/components/QuestionProgressChip.tsx +66 -0
- package/components/QuestionsPanelSection.tsx +121 -0
- package/components/Settings.tsx +25 -1
- package/components/Viewer.tsx +170 -34
- package/components/ai/AIProviderBar.tsx +5 -5
- package/components/ai/DocumentAIChatPanel.tsx +28 -3
- package/components/ai/SessionAskNotice.tsx +89 -0
- package/components/blocks/QuestionBlock.tsx +591 -0
- package/components/html-viewer/bridge-script.asset.js +85 -8
- package/components/html-viewer/bridge-script.ts +85 -8
- package/config/settings.ts +17 -0
- package/configure.ts +12 -0
- package/hooks/useAIChat.ts +74 -9
- package/hooks/useAIProviderConfig.ts +32 -14
- package/hooks/useAnnotationHighlighter.ts +4 -0
- package/hooks/useDiffFileTreeExpansion.ts +92 -0
- package/hooks/usePinpoint.ts +10 -0
- package/hooks/useScrollKeyRouting.ts +157 -0
- package/hooks/useUndoHistory.ts +17 -0
- package/hooks/useVimDocumentFocus.ts +2 -1
- package/package.json +3 -2
- package/styles.css +1 -1
- package/types.ts +7 -0
- package/utils/aiPrompt.ts +31 -0
- package/utils/aiProvider.ts +68 -0
- package/utils/autoUpdateNotice.ts +59 -0
- package/utils/diffFileTree.ts +203 -0
- package/utils/htmlLinkNavigation.ts +87 -3
- package/utils/parser.ts +125 -563
- package/utils/questionAnswers.ts +227 -0
- package/utils/syntaxTheme.ts +33 -0
|
@@ -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/config/settings.ts
CHANGED
|
@@ -599,6 +599,23 @@ export const SETTINGS = {
|
|
|
599
599
|
},
|
|
600
600
|
toServer: (value: boolean) => ({ reviewAnalysis: { callFlow: value } }),
|
|
601
601
|
},
|
|
602
|
+
/**
|
|
603
|
+
* Opt-in background auto-update of the compiled CLI (#1634). The server
|
|
604
|
+
* always sends the config-file value (default false), so the cookie is only
|
|
605
|
+
* a mirror and can never turn this on by itself.
|
|
606
|
+
*/
|
|
607
|
+
autoUpdate: {
|
|
608
|
+
defaultValue: false as boolean,
|
|
609
|
+
fromCookie: () => {
|
|
610
|
+
const v = storage.getItem('plannotator-auto-update');
|
|
611
|
+
return v === 'true' ? true : v === 'false' ? false : undefined;
|
|
612
|
+
},
|
|
613
|
+
toCookie: (v: boolean) => storage.setItem('plannotator-auto-update', String(v)),
|
|
614
|
+
serverKey: 'autoUpdate',
|
|
615
|
+
fromServer: (sc: Record<string, unknown>) =>
|
|
616
|
+
typeof sc.autoUpdate === 'boolean' ? sc.autoUpdate : undefined,
|
|
617
|
+
toServer: (v: boolean) => ({ autoUpdate: v }),
|
|
618
|
+
},
|
|
602
619
|
conventionalComments: {
|
|
603
620
|
defaultValue: false as boolean,
|
|
604
621
|
fromCookie: () => {
|
package/configure.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { setWebMcpPolicy, type WebMcpPolicy } from './webmcp/policy';
|
|
|
12
12
|
import { setMathRendererLoader, type MathRenderer, type MathRendererLoader } from './utils/math';
|
|
13
13
|
import { setIdentityGenerator, type IdentityGenerator } from './utils/generateIdentity';
|
|
14
14
|
import { setAlertIconRenderer, type AlertIconRenderer } from './components/blocks/AlertBlock';
|
|
15
|
+
import { setFenceThemeResolver, type FenceThemeResolver } from './utils/syntaxTheme';
|
|
15
16
|
import { configStore } from './config';
|
|
16
17
|
import type { ServerSyncFn } from './config/configStore';
|
|
17
18
|
import type { ExternalAnnotationEvent, VaultNode } from './types';
|
|
@@ -40,6 +41,7 @@ export type {
|
|
|
40
41
|
MathRendererLoader,
|
|
41
42
|
IdentityGenerator,
|
|
42
43
|
AlertIconRenderer,
|
|
44
|
+
FenceThemeResolver,
|
|
43
45
|
};
|
|
44
46
|
|
|
45
47
|
type ExternalAnnotationBase = { id: string; source?: string };
|
|
@@ -93,6 +95,15 @@ export interface PlannotatorUIConfig {
|
|
|
93
95
|
* today; the package bundles no icon set. A host with one registers a renderer.
|
|
94
96
|
*/
|
|
95
97
|
alertIconRenderer?: AlertIconRenderer;
|
|
98
|
+
/**
|
|
99
|
+
* Pick the Shiki theme code fences render in, per (palette id, resolved
|
|
100
|
+
* mode). Return `undefined` to keep the default resolution (the palette's
|
|
101
|
+
* `SHIKI_THEME_MAP` entry, else `pierre-dark` / `pierre-light`). Applies to
|
|
102
|
+
* every fence-style surface (Viewer fences, the code-file hover preview, the
|
|
103
|
+
* plan diff view, code review suggestion snippets); the code-review diff
|
|
104
|
+
* pane keeps its own pair. An unknown theme name renders plain text.
|
|
105
|
+
*/
|
|
106
|
+
fenceTheme?: FenceThemeResolver;
|
|
96
107
|
/** Re-hydrate settings from the installed (SYNCHRONOUS) storageBackend after install. */
|
|
97
108
|
loadSettingsFromBackend?: boolean;
|
|
98
109
|
}
|
|
@@ -114,6 +125,7 @@ export function configurePlannotatorUI(config: PlannotatorUIConfig): void {
|
|
|
114
125
|
if (config.mathRendererLoader) setMathRendererLoader(config.mathRendererLoader);
|
|
115
126
|
if (config.identityGenerator) setIdentityGenerator(config.identityGenerator);
|
|
116
127
|
if (config.alertIconRenderer) setAlertIconRenderer(config.alertIconRenderer);
|
|
128
|
+
if (config.fenceTheme) setFenceThemeResolver(config.fenceTheme);
|
|
117
129
|
// Re-hydrate AFTER storageBackend is installed (load-bearing order — gated last).
|
|
118
130
|
if (config.loadSettingsFromBackend) configStore.loadFromBackend();
|
|
119
131
|
}
|
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
|
},
|
|
@@ -1105,6 +1105,10 @@ export function useAnnotationHighlighter({
|
|
|
1105
1105
|
// verdict, so it is neither attempted nor unanchored here (the same
|
|
1106
1106
|
// rule the raw-HTML pinpoints follow on their surface).
|
|
1107
1107
|
if (ann.diagramAnchor) return;
|
|
1108
|
+
// An answer to a `:::question` block quotes the prompt but is not a
|
|
1109
|
+
// selection: the question block draws it from the annotation, so it is
|
|
1110
|
+
// never painted as a highlight (and never reported as unanchored here).
|
|
1111
|
+
if (ann.questionAnswer) return;
|
|
1108
1112
|
// #881: a row whose quote is only whitespace has nothing to anchor to.
|
|
1109
1113
|
// Older drafts carry such rows (a double-click at a block boundary used
|
|
1110
1114
|
// to create one), and the stored positions still resolve, so restoring
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
getAllFolderPaths,
|
|
4
|
+
getAncestorPaths,
|
|
5
|
+
type DiffFileTreeFile,
|
|
6
|
+
type DiffFileTreeNode,
|
|
7
|
+
} from '../utils/diffFileTree';
|
|
8
|
+
|
|
9
|
+
/** Which folders start open: every folder (the review's behavior), none, or
|
|
10
|
+
* an explicit list of folder paths (`DiffFileTreeNode.path`). */
|
|
11
|
+
export type DiffFileTreeDefaultExpanded = 'all' | 'none' | readonly string[];
|
|
12
|
+
|
|
13
|
+
function initialSet(allFolderPaths: string[], defaultExpanded: DiffFileTreeDefaultExpanded): Set<string> {
|
|
14
|
+
if (defaultExpanded === 'all') return new Set(allFolderPaths);
|
|
15
|
+
if (defaultExpanded === 'none') return new Set();
|
|
16
|
+
return new Set(defaultExpanded);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Folder open/closed state for a diff file tree, shared by the review's
|
|
21
|
+
* FileTree and the embeddable DiffFileTree:
|
|
22
|
+
* - starts from `defaultExpanded` and resets to it whenever `tree` changes
|
|
23
|
+
* identity (a new diff), so pass a memoized tree;
|
|
24
|
+
* - keeps every ancestor of `activePath` open, so moving the selection always
|
|
25
|
+
* reveals the selected file.
|
|
26
|
+
*/
|
|
27
|
+
export function useDiffFileTreeExpansion<F extends DiffFileTreeFile>(
|
|
28
|
+
tree: DiffFileTreeNode<F>[],
|
|
29
|
+
activePath: string | null | undefined,
|
|
30
|
+
defaultExpanded: DiffFileTreeDefaultExpanded = 'all',
|
|
31
|
+
) {
|
|
32
|
+
const allFolderPaths = useMemo(() => getAllFolderPaths(tree), [tree]);
|
|
33
|
+
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => initialSet(allFolderPaths, defaultExpanded));
|
|
34
|
+
const [prevTree, setPrevTree] = useState(tree);
|
|
35
|
+
|
|
36
|
+
// Reset when the tree changes (initial render + diff switch).
|
|
37
|
+
if (tree !== prevTree) {
|
|
38
|
+
setPrevTree(tree);
|
|
39
|
+
setExpandedFolders(initialSet(allFolderPaths, defaultExpanded));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Auto-expand ancestors of the active file so keyboard nav always reveals the target.
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (activePath) {
|
|
45
|
+
const ancestors = getAncestorPaths(activePath);
|
|
46
|
+
setExpandedFolders((prev) => {
|
|
47
|
+
const missing = ancestors.filter((p) => !prev.has(p));
|
|
48
|
+
if (missing.length === 0) return prev;
|
|
49
|
+
const next = new Set(prev);
|
|
50
|
+
for (const p of missing) next.add(p);
|
|
51
|
+
return next;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}, [activePath, tree]);
|
|
55
|
+
|
|
56
|
+
const toggleFolder = useCallback((path: string) => {
|
|
57
|
+
setExpandedFolders((prev) => {
|
|
58
|
+
const next = new Set(prev);
|
|
59
|
+
if (next.has(path)) {
|
|
60
|
+
next.delete(path);
|
|
61
|
+
} else {
|
|
62
|
+
next.add(path);
|
|
63
|
+
}
|
|
64
|
+
return next;
|
|
65
|
+
});
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const setFolderExpanded = useCallback((path: string, expanded: boolean) => {
|
|
69
|
+
setExpandedFolders((prev) => {
|
|
70
|
+
if (prev.has(path) === expanded) return prev;
|
|
71
|
+
const next = new Set(prev);
|
|
72
|
+
if (expanded) next.add(path);
|
|
73
|
+
else next.delete(path);
|
|
74
|
+
return next;
|
|
75
|
+
});
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
const areAllFoldersExpanded = allFolderPaths.length > 0 && allFolderPaths.every((path) => expandedFolders.has(path));
|
|
79
|
+
|
|
80
|
+
const toggleAllFolders = useCallback(() => {
|
|
81
|
+
setExpandedFolders(areAllFoldersExpanded ? new Set() : new Set(allFolderPaths));
|
|
82
|
+
}, [allFolderPaths, areAllFoldersExpanded]);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
expandedFolders,
|
|
86
|
+
allFolderPaths,
|
|
87
|
+
areAllFoldersExpanded,
|
|
88
|
+
toggleFolder,
|
|
89
|
+
setFolderExpanded,
|
|
90
|
+
toggleAllFolders,
|
|
91
|
+
};
|
|
92
|
+
}
|