@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,363 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
buildDiffFileTree,
|
|
4
|
+
type DiffFileTreeFile,
|
|
5
|
+
type DiffFileTreeNode,
|
|
6
|
+
type DiffFileTreeStatus,
|
|
7
|
+
} from '../utils/diffFileTree';
|
|
8
|
+
import { useDiffFileTreeExpansion, type DiffFileTreeDefaultExpanded } from '../hooks/useDiffFileTreeExpansion';
|
|
9
|
+
|
|
10
|
+
export type { DiffFileTreeFile, DiffFileTreeNode, DiffFileTreeStatus } from '../utils/diffFileTree';
|
|
11
|
+
export type { DiffFileTreeDefaultExpanded } from '../hooks/useDiffFileTreeExpansion';
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* The diff file tree's presentational parts. Plannotator's code review renders
|
|
15
|
+
* its tree rows from the atoms below (folder row, file-row content, change
|
|
16
|
+
* letter, +/- counts, indentation), adding its review-only decorations
|
|
17
|
+
* (viewed/stage controls, annotation badge, context menu) around them; the
|
|
18
|
+
* `DiffFileTree` component at the bottom is the read-only, embeddable tree a
|
|
19
|
+
* host drops beside its own diff list.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Left padding of a row at `depth` (px). */
|
|
23
|
+
export function diffFileTreeIndent(depth: number): number {
|
|
24
|
+
return 4 + depth * 8;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Leading change-type letter — A/D/R/U carry weight and color; modified gets
|
|
28
|
+
* a whisper-quiet M so the column has no holes. Fixed slot keeps names aligned. */
|
|
29
|
+
export const ChangeTypeLetter: React.FC<{
|
|
30
|
+
status: DiffFileTreeStatus;
|
|
31
|
+
oldPath?: string;
|
|
32
|
+
untracked?: boolean;
|
|
33
|
+
}> = ({ status, oldPath, untracked }) => (
|
|
34
|
+
<span className="w-3 text-center text-[10px] flex-shrink-0">
|
|
35
|
+
{untracked ? (
|
|
36
|
+
<span className="font-semibold text-muted-foreground/70" title="Untracked file">U</span>
|
|
37
|
+
) : status === 'added' ? (
|
|
38
|
+
<span className="font-semibold text-success" title="Added file">A</span>
|
|
39
|
+
) : status === 'deleted' ? (
|
|
40
|
+
<span className="font-semibold text-destructive" title="Deleted file">D</span>
|
|
41
|
+
) : status === 'renamed' ? (
|
|
42
|
+
<span className="font-semibold text-[#007aff]" title={oldPath ? `Renamed from ${oldPath}` : 'Renamed file'}>R</span>
|
|
43
|
+
) : status === 'binary' ? (
|
|
44
|
+
<span className="font-semibold text-muted-foreground/70" title="Binary file">B</span>
|
|
45
|
+
) : (
|
|
46
|
+
<span className="text-muted-foreground/40" title="Modified file">M</span>
|
|
47
|
+
)}
|
|
48
|
+
</span>
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
/** Right-anchored +/- pair — one fixed-width block so the numbers always end
|
|
52
|
+
* flush at the row edge, stay tight together, and add-only rows leave no
|
|
53
|
+
* phantom gap. */
|
|
54
|
+
export const DiffCounts: React.FC<{ additions: number; deletions: number }> = ({ additions, deletions }) => (
|
|
55
|
+
<span className="min-w-[7ch] text-right whitespace-nowrap flex-shrink-0 text-[10px] tabular-nums">
|
|
56
|
+
{additions > 0 && <span className="additions">+{additions}</span>}
|
|
57
|
+
{additions > 0 && deletions > 0 && <span> </span>}
|
|
58
|
+
{deletions > 0 && <span className="deletions">-{deletions}</span>}
|
|
59
|
+
</span>
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
type FolderRowProps = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick' | 'className' | 'style' | 'children'> & {
|
|
63
|
+
node: Pick<DiffFileTreeNode, 'name' | 'depth' | 'additions' | 'deletions'>;
|
|
64
|
+
isExpanded: boolean;
|
|
65
|
+
onToggle: () => void;
|
|
66
|
+
ref?: React.Ref<HTMLButtonElement>;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** A folder row: chevron, (possibly merged) folder name, summed +/- counts. */
|
|
70
|
+
export const DiffFileTreeFolderRow: React.FC<FolderRowProps> = ({ node, isExpanded, onToggle, ...buttonProps }) => (
|
|
71
|
+
<button
|
|
72
|
+
{...buttonProps}
|
|
73
|
+
onClick={onToggle}
|
|
74
|
+
className="w-full flex items-center gap-1.5 py-1 px-2 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors rounded-sm"
|
|
75
|
+
style={{ paddingLeft: diffFileTreeIndent(node.depth) }}
|
|
76
|
+
>
|
|
77
|
+
<svg
|
|
78
|
+
className={`w-3 h-3 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
|
79
|
+
fill="none"
|
|
80
|
+
viewBox="0 0 24 24"
|
|
81
|
+
stroke="currentColor"
|
|
82
|
+
strokeWidth={2}
|
|
83
|
+
>
|
|
84
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
85
|
+
</svg>
|
|
86
|
+
<span className="truncate">{node.name}</span>
|
|
87
|
+
{(node.additions > 0 || node.deletions > 0) && (
|
|
88
|
+
<div className="flex items-center gap-1.5 ml-auto flex-shrink-0 text-[10px]">
|
|
89
|
+
{node.additions > 0 && (
|
|
90
|
+
<span className="additions">+{node.additions}</span>
|
|
91
|
+
)}
|
|
92
|
+
{node.deletions > 0 && (
|
|
93
|
+
<span className="deletions">-{node.deletions}</span>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
</button>
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The inside of a file row (render it in a `file-tree-item` row element):
|
|
102
|
+
* `[leading][letter][name][afterName]` then the right-anchored counts.
|
|
103
|
+
* `leading` and `afterName` are where the review puts its viewed/stage
|
|
104
|
+
* controls and annotation badge.
|
|
105
|
+
*/
|
|
106
|
+
export const DiffFileTreeFileRowContent: React.FC<{
|
|
107
|
+
file: Pick<DiffFileTreeFile, 'status' | 'oldPath' | 'additions' | 'deletions'>;
|
|
108
|
+
name: string;
|
|
109
|
+
untracked?: boolean;
|
|
110
|
+
leading?: React.ReactNode;
|
|
111
|
+
afterName?: React.ReactNode;
|
|
112
|
+
}> = ({ file, name, untracked, leading, afterName }) => (
|
|
113
|
+
<>
|
|
114
|
+
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
|
115
|
+
{leading}
|
|
116
|
+
<ChangeTypeLetter status={file.status} oldPath={file.oldPath} untracked={untracked} />
|
|
117
|
+
<span className="truncate">{name}</span>
|
|
118
|
+
{afterName}
|
|
119
|
+
</div>
|
|
120
|
+
<DiffCounts additions={file.additions} deletions={file.deletions} />
|
|
121
|
+
</>
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// --- Embeddable read-only tree ---
|
|
125
|
+
|
|
126
|
+
export interface DiffFileTreeProps {
|
|
127
|
+
/** The changed files. Order does not matter; the tree sorts. */
|
|
128
|
+
files: readonly DiffFileTreeFile[];
|
|
129
|
+
/** Path of the selected file (controlled), or null. */
|
|
130
|
+
selectedPath: string | null;
|
|
131
|
+
/** Called with a file's `path` on click, Enter/Space, or arrow/j/k/Home/End. */
|
|
132
|
+
onSelect: (path: string) => void;
|
|
133
|
+
/** Which folders start open; re-applied when the set of files changes. Default 'all'. */
|
|
134
|
+
defaultExpanded?: DiffFileTreeDefaultExpanded;
|
|
135
|
+
/** Accessible name of the tree. Default "Changed files". */
|
|
136
|
+
label?: string;
|
|
137
|
+
className?: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface VisibleRow {
|
|
141
|
+
key: string;
|
|
142
|
+
node: DiffFileTreeNode;
|
|
143
|
+
parentKey: string | null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function rowKey(node: DiffFileTreeNode): string {
|
|
147
|
+
return node.type === 'file' ? `file:${node.path}` : `folder:${node.path}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function collectVisibleRows(
|
|
151
|
+
nodes: DiffFileTreeNode[],
|
|
152
|
+
expanded: Set<string>,
|
|
153
|
+
parentKey: string | null,
|
|
154
|
+
out: VisibleRow[],
|
|
155
|
+
): VisibleRow[] {
|
|
156
|
+
for (const node of nodes) {
|
|
157
|
+
const key = rowKey(node);
|
|
158
|
+
out.push({ key, node, parentKey });
|
|
159
|
+
if (node.type === 'folder' && expanded.has(node.path) && node.children) {
|
|
160
|
+
collectVisibleRows(node.children, expanded, key, out);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** File paths in the order the tree renders them (collapse state ignored). */
|
|
167
|
+
function visualFilePaths(nodes: DiffFileTreeNode[], out: string[] = []): string[] {
|
|
168
|
+
for (const node of nodes) {
|
|
169
|
+
if (node.type === 'file') out.push(node.path);
|
|
170
|
+
else if (node.children) visualFilePaths(node.children, out);
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Content signature, so a host that rebuilds an equal `files` array each
|
|
176
|
+
* render does not reset the folders it opened or closed. */
|
|
177
|
+
function filesSignature(files: readonly DiffFileTreeFile[]): string {
|
|
178
|
+
return files.map((f) => `${f.path}\u0000${f.oldPath ?? ''}\u0000${f.status}\u0000${f.additions}\u0000${f.deletions}`).join('\u0001');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Read-only diff file tree: the same folder collapsing, change letters, +/-
|
|
183
|
+
* counts, icons and indentation as Plannotator's code-review tree, with WAI-ARIA
|
|
184
|
+
* tree semantics. Keyboard (while focus is in the tree, never window-wide):
|
|
185
|
+
* ArrowDown/`j` and ArrowUp/`k` select the next/previous file in tree order,
|
|
186
|
+
* Home/End the first/last (the review's keys); ArrowRight/ArrowLeft open/close
|
|
187
|
+
* a focused folder, and ArrowLeft from a file moves focus to its folder.
|
|
188
|
+
* Theme comes from the ThemeProvider tokens via `@plannotator/ui/styles.css`.
|
|
189
|
+
*/
|
|
190
|
+
export const DiffFileTree: React.FC<DiffFileTreeProps> = ({
|
|
191
|
+
files,
|
|
192
|
+
selectedPath,
|
|
193
|
+
onSelect,
|
|
194
|
+
defaultExpanded = 'all',
|
|
195
|
+
label = 'Changed files',
|
|
196
|
+
className,
|
|
197
|
+
}) => {
|
|
198
|
+
const signature = filesSignature(files);
|
|
199
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on content, not identity
|
|
200
|
+
const tree = useMemo(() => buildDiffFileTree(files), [signature]);
|
|
201
|
+
const filesInOrder = useMemo(() => visualFilePaths(tree), [tree]);
|
|
202
|
+
|
|
203
|
+
const { expandedFolders, setFolderExpanded, toggleFolder } = useDiffFileTreeExpansion(tree, selectedPath, defaultExpanded);
|
|
204
|
+
const rows = useMemo(() => collectVisibleRows(tree, expandedFolders, null, []), [tree, expandedFolders]);
|
|
205
|
+
|
|
206
|
+
const [focusedKey, setFocusedKey] = useState<string | null>(null);
|
|
207
|
+
const pendingFocus = useRef<string | null>(null);
|
|
208
|
+
const rowRefs = useRef(new Map<string, HTMLButtonElement>());
|
|
209
|
+
|
|
210
|
+
const selectedKey = selectedPath ? `file:${selectedPath}` : null;
|
|
211
|
+
const rowKeys = useMemo(() => new Set(rows.map((r) => r.key)), [rows]);
|
|
212
|
+
const tabStopKey =
|
|
213
|
+
focusedKey && rowKeys.has(focusedKey)
|
|
214
|
+
? focusedKey
|
|
215
|
+
: selectedKey && rowKeys.has(selectedKey)
|
|
216
|
+
? selectedKey
|
|
217
|
+
: rows[0]?.key ?? null;
|
|
218
|
+
|
|
219
|
+
// Move DOM focus once the target row exists (selecting a file inside a
|
|
220
|
+
// collapsed folder opens the folder first, one render later).
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
const key = pendingFocus.current;
|
|
223
|
+
if (!key) return;
|
|
224
|
+
const el = rowRefs.current.get(key);
|
|
225
|
+
if (el) {
|
|
226
|
+
pendingFocus.current = null;
|
|
227
|
+
el.focus();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const focusRow = useCallback((key: string) => {
|
|
232
|
+
setFocusedKey(key);
|
|
233
|
+
pendingFocus.current = key;
|
|
234
|
+
const el = rowRefs.current.get(key);
|
|
235
|
+
if (el) {
|
|
236
|
+
pendingFocus.current = null;
|
|
237
|
+
el.focus();
|
|
238
|
+
}
|
|
239
|
+
}, []);
|
|
240
|
+
|
|
241
|
+
const selectFile = useCallback(
|
|
242
|
+
(path: string) => {
|
|
243
|
+
onSelect(path);
|
|
244
|
+
focusRow(`file:${path}`);
|
|
245
|
+
},
|
|
246
|
+
[onSelect, focusRow],
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const handleKeyDown = useCallback(
|
|
250
|
+
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
251
|
+
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
252
|
+
if (filesInOrder.length === 0) return;
|
|
253
|
+
const pos = selectedPath ? filesInOrder.indexOf(selectedPath) : -1;
|
|
254
|
+
|
|
255
|
+
if (e.key === 'j' || e.key === 'ArrowDown') {
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
if (pos < filesInOrder.length - 1) selectFile(filesInOrder[pos + 1]);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (e.key === 'k' || e.key === 'ArrowUp') {
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
if (pos > 0) selectFile(filesInOrder[pos - 1]);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (e.key === 'Home') {
|
|
266
|
+
e.preventDefault();
|
|
267
|
+
selectFile(filesInOrder[0]);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (e.key === 'End') {
|
|
271
|
+
e.preventDefault();
|
|
272
|
+
selectFile(filesInOrder[filesInOrder.length - 1]);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
|
|
276
|
+
|
|
277
|
+
const current = rows.find((r) => r.key === tabStopKey);
|
|
278
|
+
if (!current) return;
|
|
279
|
+
e.preventDefault();
|
|
280
|
+
const { node } = current;
|
|
281
|
+
if (e.key === 'ArrowRight') {
|
|
282
|
+
if (node.type !== 'folder') return;
|
|
283
|
+
if (!expandedFolders.has(node.path)) {
|
|
284
|
+
setFolderExpanded(node.path, true);
|
|
285
|
+
} else {
|
|
286
|
+
const index = rows.indexOf(current);
|
|
287
|
+
const child = rows[index + 1];
|
|
288
|
+
if (child && child.parentKey === current.key) focusRow(child.key);
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (node.type === 'folder' && expandedFolders.has(node.path)) {
|
|
293
|
+
setFolderExpanded(node.path, false);
|
|
294
|
+
} else if (current.parentKey) {
|
|
295
|
+
focusRow(current.parentKey);
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
[filesInOrder, selectedPath, selectFile, rows, tabStopKey, expandedFolders, setFolderExpanded, focusRow],
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const setRef = (key: string) => (el: HTMLButtonElement | null) => {
|
|
302
|
+
if (el) rowRefs.current.set(key, el);
|
|
303
|
+
else rowRefs.current.delete(key);
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<div
|
|
308
|
+
role="tree"
|
|
309
|
+
aria-label={label}
|
|
310
|
+
className={className ? `px-1 py-1 ${className}` : 'px-1 py-1'}
|
|
311
|
+
onKeyDown={handleKeyDown}
|
|
312
|
+
data-diff-file-tree=""
|
|
313
|
+
>
|
|
314
|
+
{rows.map(({ key, node }) => {
|
|
315
|
+
const level = node.depth + 1;
|
|
316
|
+
const tabIndex = key === tabStopKey ? 0 : -1;
|
|
317
|
+
if (node.type === 'folder') {
|
|
318
|
+
const isExpanded = expandedFolders.has(node.path);
|
|
319
|
+
return (
|
|
320
|
+
<DiffFileTreeFolderRow
|
|
321
|
+
key={key}
|
|
322
|
+
ref={setRef(key)}
|
|
323
|
+
type="button"
|
|
324
|
+
role="treeitem"
|
|
325
|
+
aria-level={level}
|
|
326
|
+
aria-expanded={isExpanded}
|
|
327
|
+
tabIndex={tabIndex}
|
|
328
|
+
data-path={node.path}
|
|
329
|
+
onFocus={() => setFocusedKey(key)}
|
|
330
|
+
node={node}
|
|
331
|
+
isExpanded={isExpanded}
|
|
332
|
+
onToggle={() => {
|
|
333
|
+
toggleFolder(node.path);
|
|
334
|
+
setFocusedKey(key);
|
|
335
|
+
}}
|
|
336
|
+
/>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
const file = node.file!;
|
|
340
|
+
const isActive = node.path === selectedPath;
|
|
341
|
+
return (
|
|
342
|
+
<button
|
|
343
|
+
key={key}
|
|
344
|
+
ref={setRef(key)}
|
|
345
|
+
type="button"
|
|
346
|
+
role="treeitem"
|
|
347
|
+
aria-level={level}
|
|
348
|
+
aria-selected={isActive}
|
|
349
|
+
tabIndex={tabIndex}
|
|
350
|
+
data-path={node.path}
|
|
351
|
+
title={node.path}
|
|
352
|
+
onFocus={() => setFocusedKey(key)}
|
|
353
|
+
onClick={() => selectFile(node.path)}
|
|
354
|
+
className={`file-tree-item w-full text-left group${isActive ? ' active' : ''}`}
|
|
355
|
+
style={{ paddingLeft: diffFileTreeIndent(node.depth) }}
|
|
356
|
+
>
|
|
357
|
+
<DiffFileTreeFileRowContent file={file} name={node.name} />
|
|
358
|
+
</button>
|
|
359
|
+
);
|
|
360
|
+
})}
|
|
361
|
+
</div>
|
|
362
|
+
);
|
|
363
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Overlay for viewing one image enlarged. Shared by the markdown Viewer
|
|
5
|
+
* (clicking an image in a document) and the raw-HTML surface (a link to a
|
|
6
|
+
* local image inside the annotated page).
|
|
7
|
+
*
|
|
8
|
+
* The image is only ever shown through `<img src>`, so an SVG runs no script
|
|
9
|
+
* and cannot reach the page.
|
|
10
|
+
*
|
|
11
|
+
* It is modal for the keyboard. Its keydown listener runs on the CAPTURE phase
|
|
12
|
+
* at the window and stops every key there, so nothing behind it reacts:
|
|
13
|
+
* Escape closes the lightbox before the HTML surface's Esc ladder sees it,
|
|
14
|
+
* and Mod+Enter cannot submit a decision behind it. Focus moves to the
|
|
15
|
+
* overlay when it opens, which also pulls focus out of an HTML page's iframe
|
|
16
|
+
* (whose keys the parent would otherwise never see), and goes back to where
|
|
17
|
+
* it was when it closes.
|
|
18
|
+
*/
|
|
19
|
+
export const ImageLightbox: React.FC<{ src: string; alt: string; onClose: () => void }> = ({ src, alt, onClose }) => {
|
|
20
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
21
|
+
const onCloseRef = useRef(onClose);
|
|
22
|
+
onCloseRef.current = onClose;
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
26
|
+
overlayRef.current?.focus({ preventScroll: true });
|
|
27
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
28
|
+
// Tab stays native: the overlay is the only focusable thing that matters
|
|
29
|
+
// here, and trapping it buys nothing for a click-to-close surface.
|
|
30
|
+
if (e.key === 'Tab') return;
|
|
31
|
+
e.stopImmediatePropagation();
|
|
32
|
+
if (e.key === 'Escape' || (e.key === 'Enter' && (e.metaKey || e.ctrlKey))) {
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
}
|
|
35
|
+
if (e.key === 'Escape') onCloseRef.current();
|
|
36
|
+
};
|
|
37
|
+
window.addEventListener('keydown', handleKeyDown, true);
|
|
38
|
+
return () => {
|
|
39
|
+
window.removeEventListener('keydown', handleKeyDown, true);
|
|
40
|
+
if (previous && previous.isConnected) previous.focus({ preventScroll: true });
|
|
41
|
+
};
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
ref={overlayRef}
|
|
47
|
+
tabIndex={-1}
|
|
48
|
+
role="dialog"
|
|
49
|
+
aria-modal="true"
|
|
50
|
+
aria-label={alt || 'Image'}
|
|
51
|
+
data-image-lightbox=""
|
|
52
|
+
className="fixed inset-0 z-[200] flex flex-col items-center justify-center bg-black/80 backdrop-blur-sm cursor-zoom-out outline-none"
|
|
53
|
+
onClick={onClose}
|
|
54
|
+
>
|
|
55
|
+
<img
|
|
56
|
+
src={src}
|
|
57
|
+
alt={alt}
|
|
58
|
+
className="max-w-[90vw] max-h-[85vh] object-contain rounded-lg shadow-2xl"
|
|
59
|
+
onClick={(e) => e.stopPropagation()}
|
|
60
|
+
/>
|
|
61
|
+
{alt && (
|
|
62
|
+
<div className="mt-3 text-sm text-white/70 max-w-[90vw] text-center truncate">{alt}</div>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
@@ -37,15 +37,27 @@ const GenericProviderIcon: React.FC<{ className?: string }> = ({ className = 'w-
|
|
|
37
37
|
</svg>
|
|
38
38
|
);
|
|
39
39
|
|
|
40
|
+
/** "Ask this session": a speech bubble, since the answer comes from the agent session itself. */
|
|
41
|
+
const SessionIcon: React.FC<{ className?: string }> = ({ className = 'w-4 h-4' }) => (
|
|
42
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className={className}>
|
|
43
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h8M8 14h5m-7 6 2.5-3H18a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3H6a3 3 0 0 0-3 3v7a3 3 0 0 0 3 3z" />
|
|
44
|
+
</svg>
|
|
45
|
+
);
|
|
46
|
+
|
|
40
47
|
/** Provider metadata: maps provider type name to display label and icon component. */
|
|
41
48
|
export const PROVIDER_META: Record<string, { label: string; icon: React.FC<{ className?: string }> }> = {
|
|
42
49
|
'claude-agent-sdk': { label: 'Claude', icon: ClaudeIcon },
|
|
43
50
|
'codex-sdk': { label: 'Codex', icon: CodexIcon },
|
|
44
51
|
'pi-sdk': { label: 'Pi', icon: PiIcon },
|
|
45
52
|
'opencode-sdk': { label: 'OpenCode', icon: OpenCodeIcon },
|
|
53
|
+
'session-bridge': { label: 'Ask this session', icon: SessionIcon },
|
|
46
54
|
};
|
|
47
55
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Get provider metadata, with fallback for unknown providers. A server-sent
|
|
58
|
+
* `label` (e.g. "Ask this session · Pi") overrides the name lookup.
|
|
59
|
+
*/
|
|
60
|
+
export function getProviderMeta(providerName: string, label?: string): { label: string; icon: React.FC<{ className?: string }> } {
|
|
61
|
+
const meta = PROVIDER_META[providerName] ?? { label: providerName, icon: GenericProviderIcon };
|
|
62
|
+
return label ? { ...meta, label } : meta;
|
|
51
63
|
}
|
package/components/Viewer.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { generateId } from '../utils/generateId';
|
|
2
2
|
import React, { useRef, useState, useEffect, useLayoutEffect, useMemo, forwardRef, useImperativeHandle, useCallback, lazy, Suspense } from 'react';
|
|
3
3
|
import { createPortal } from 'react-dom';
|
|
4
|
+
import { ImageLightbox } from './ImageLightbox';
|
|
4
5
|
import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types';
|
|
5
6
|
import { applyHighlight, codeBlockClassName, onCodeHighlightSwap } from '../utils/codeHighlight';
|
|
6
7
|
import { paintCodeBlockMark } from '../utils/codeBlockMark';
|
|
@@ -1780,31 +1781,3 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1780
1781
|
</CodePathValidationContext.Provider>
|
|
1781
1782
|
);
|
|
1782
1783
|
});
|
|
1783
|
-
|
|
1784
|
-
/** Simple lightbox overlay for enlarged image viewing. */
|
|
1785
|
-
const ImageLightbox: React.FC<{ src: string; alt: string; onClose: () => void }> = ({ src, alt, onClose }) => {
|
|
1786
|
-
useEffect(() => {
|
|
1787
|
-
const handleKeyDown = (e: KeyboardEvent) => {
|
|
1788
|
-
if (e.key === 'Escape') onClose();
|
|
1789
|
-
};
|
|
1790
|
-
window.addEventListener('keydown', handleKeyDown);
|
|
1791
|
-
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
1792
|
-
}, [onClose]);
|
|
1793
|
-
|
|
1794
|
-
return (
|
|
1795
|
-
<div
|
|
1796
|
-
className="fixed inset-0 z-[200] flex flex-col items-center justify-center bg-black/80 backdrop-blur-sm cursor-zoom-out"
|
|
1797
|
-
onClick={onClose}
|
|
1798
|
-
>
|
|
1799
|
-
<img
|
|
1800
|
-
src={src}
|
|
1801
|
-
alt={alt}
|
|
1802
|
-
className="max-w-[90vw] max-h-[85vh] object-contain rounded-lg shadow-2xl"
|
|
1803
|
-
onClick={(e) => e.stopPropagation()}
|
|
1804
|
-
/>
|
|
1805
|
-
{alt && (
|
|
1806
|
-
<div className="mt-3 text-sm text-white/70 max-w-[90vw] text-center truncate">{alt}</div>
|
|
1807
|
-
)}
|
|
1808
|
-
</div>
|
|
1809
|
-
);
|
|
1810
|
-
};
|
|
@@ -37,7 +37,7 @@ export const AIProviderBar: React.FC<AIProviderBarProps> = ({
|
|
|
37
37
|
const effectiveModel = selectedModel ?? defaultModel?.id ?? '';
|
|
38
38
|
const currentModel = models.find(m => m.id === effectiveModel) ?? defaultModel;
|
|
39
39
|
const reasoningEfforts = currentModel?.reasoningEfforts ?? [];
|
|
40
|
-
const meta = getProviderMeta(currentProvider?.name ?? 'AI');
|
|
40
|
+
const meta = getProviderMeta(currentProvider?.name ?? 'AI', currentProvider?.label);
|
|
41
41
|
const Icon = meta.icon;
|
|
42
42
|
// Show only when the selected model actually reports reasoning efforts.
|
|
43
43
|
const showReasoningEffort = !!onReasoningEffortChange && reasoningEfforts.length > 0;
|
|
@@ -53,7 +53,7 @@ export const AIProviderBar: React.FC<AIProviderBarProps> = ({
|
|
|
53
53
|
aria-label="AI provider"
|
|
54
54
|
>
|
|
55
55
|
{providers.map(provider => {
|
|
56
|
-
const providerMeta = getProviderMeta(provider.name);
|
|
56
|
+
const providerMeta = getProviderMeta(provider.name, provider.label);
|
|
57
57
|
return (
|
|
58
58
|
<option key={provider.id} value={provider.id}>
|
|
59
59
|
{providerMeta.label}
|
|
@@ -5,6 +5,7 @@ import { formatRelativeTime, renderChatMarkdown } from '../../utils/aiChatFormat
|
|
|
5
5
|
import { OverlayScrollArea } from '../OverlayScrollArea';
|
|
6
6
|
import { SparklesIcon } from '../SparklesIcon';
|
|
7
7
|
import { AIProviderBar } from './AIProviderBar';
|
|
8
|
+
import { SessionAskActions, SessionAskStatus, sessionAskErrorTone, type SessionAskAction } from './SessionAskNotice';
|
|
8
9
|
import { submitHint } from '../../utils/platform';
|
|
9
10
|
|
|
10
11
|
interface DocumentAIChatPanelProps {
|
|
@@ -19,6 +20,10 @@ interface DocumentAIChatPanelProps {
|
|
|
19
20
|
aiProviders?: AIProviderOption[];
|
|
20
21
|
aiConfig?: { providerId: string | null; model: string | null; reasoningEffort?: string | null };
|
|
21
22
|
onAIConfigChange?: (config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => void;
|
|
23
|
+
/** "Ask this session": the reviewer's choice on a busy/gone/blocked answer. */
|
|
24
|
+
onSessionAskAction?: (questionId: string, action: SessionAskAction) => void;
|
|
25
|
+
/** Label of the provider "Ask a separate AI instead" would use; null hides it. */
|
|
26
|
+
sessionAskFallbackLabel?: string | null;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
function truncate(text: string, max = 180): string {
|
|
@@ -64,6 +69,8 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
|
|
|
64
69
|
aiProviders = [],
|
|
65
70
|
aiConfig,
|
|
66
71
|
onAIConfigChange,
|
|
72
|
+
onSessionAskAction,
|
|
73
|
+
sessionAskFallbackLabel,
|
|
67
74
|
}) => {
|
|
68
75
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
69
76
|
const [generalInput, setGeneralInput] = useState('');
|
|
@@ -109,7 +116,12 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
|
|
|
109
116
|
)}
|
|
110
117
|
|
|
111
118
|
{messages.map(entry => (
|
|
112
|
-
<DocumentQAPair
|
|
119
|
+
<DocumentQAPair
|
|
120
|
+
key={entry.question.id}
|
|
121
|
+
entry={entry}
|
|
122
|
+
onSessionAskAction={onSessionAskAction}
|
|
123
|
+
sessionAskFallbackLabel={sessionAskFallbackLabel}
|
|
124
|
+
/>
|
|
113
125
|
))}
|
|
114
126
|
</div>
|
|
115
127
|
</OverlayScrollArea>
|
|
@@ -152,7 +164,11 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
|
|
|
152
164
|
);
|
|
153
165
|
};
|
|
154
166
|
|
|
155
|
-
const DocumentQAPair = memo<{
|
|
167
|
+
const DocumentQAPair = memo<{
|
|
168
|
+
entry: AIChatEntry;
|
|
169
|
+
onSessionAskAction?: (questionId: string, action: SessionAskAction) => void;
|
|
170
|
+
sessionAskFallbackLabel?: string | null;
|
|
171
|
+
}>(({ entry, onSessionAskAction, sessionAskFallbackLabel }) => {
|
|
156
172
|
const { question, response } = entry;
|
|
157
173
|
const renderedResponse = useMemo(
|
|
158
174
|
() => response.text ? renderChatMarkdown(response.text) : null,
|
|
@@ -190,12 +206,21 @@ const DocumentQAPair = memo<{ entry: AIChatEntry }>(({ entry }) => {
|
|
|
190
206
|
|
|
191
207
|
<div className="group relative p-2.5 rounded-lg border border-border/50 bg-popover/50 hover:bg-muted/30 transition-colors">
|
|
192
208
|
{response.error ? (
|
|
193
|
-
|
|
209
|
+
<>
|
|
210
|
+
<p className={`text-xs ${sessionAskErrorTone(response)}`}>{response.error}</p>
|
|
211
|
+
<SessionAskActions
|
|
212
|
+
response={response}
|
|
213
|
+
fallbackLabel={sessionAskFallbackLabel}
|
|
214
|
+
onAction={onSessionAskAction ? (action) => onSessionAskAction(question.id, action) : undefined}
|
|
215
|
+
/>
|
|
216
|
+
</>
|
|
194
217
|
) : response.text ? (
|
|
195
218
|
<div className="text-xs">
|
|
196
219
|
{renderedResponse}
|
|
197
220
|
{response.isStreaming && <span className="ai-streaming-cursor inline-block ml-0.5" />}
|
|
198
221
|
</div>
|
|
222
|
+
) : response.status ? (
|
|
223
|
+
<SessionAskStatus response={response} />
|
|
199
224
|
) : response.isStreaming ? (
|
|
200
225
|
<span className="text-xs text-muted-foreground">
|
|
201
226
|
<span className="ai-streaming-cursor" /> Thinking...
|