@plannotator/ui 0.49.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HANDOFF.md +202 -1
- package/LICENSE +191 -0
- package/README.md +23 -0
- package/components/AISettingsTab.tsx +33 -1
- package/components/AgentToolAnnouncementDialog.tsx +347 -0
- package/components/AgentsTab.tsx +9 -9
- package/components/AskSessionAnnouncementDialog.tsx +329 -0
- package/components/CommentPopover.tsx +32 -18
- package/components/DecisionControl.tsx +2 -7
- package/components/DiagramBlock.tsx +46 -4
- package/components/DiffFileTree.tsx +363 -0
- package/components/DocBadges.tsx +12 -8
- package/components/ImageLightbox.tsx +66 -0
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/ProviderIcons.tsx +15 -3
- package/components/Settings.tsx +73 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/Viewer.tsx +5 -28
- package/components/ai/AIProviderBar.tsx +20 -3
- package/components/ai/DocumentAIChatPanel.tsx +23 -3
- package/components/ai/SessionAskNotice.tsx +101 -0
- package/components/blocks/QuestionBlock.tsx +24 -19
- package/components/blocks/QuestionContext.tsx +81 -0
- package/components/diagram/DiagramComposer.tsx +54 -3
- package/components/diagram/DiagramViewer.tsx +31 -2
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +13 -3
- package/components/html-viewer/bridge-script.asset.js +209 -16
- package/components/html-viewer/bridge-script.ts +209 -16
- package/components/html-viewer/useHtmlAnnotation.ts +36 -55
- package/configure.ts +12 -0
- package/hooks/useAIChat.ts +96 -10
- package/hooks/useAIProviderActivation.ts +13 -0
- package/hooks/useAIProviderConfig.ts +32 -14
- package/hooks/useActiveSection.ts +3 -2
- package/hooks/useAgentToolSetting.ts +33 -0
- package/hooks/useAnnotationDraft.ts +53 -7
- package/hooks/useAnnotationHighlighter.ts +101 -5
- package/hooks/useDiffFileTreeExpansion.ts +92 -0
- package/hooks/useExternalAnnotations.ts +21 -1
- package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
- package/hooks/useLatchedTrue.ts +15 -0
- package/hooks/useLinkedDoc.ts +64 -4
- package/hooks/useModelCatalogs.ts +9 -2
- package/package.json +3 -2
- package/styles.css +1 -1
- package/themes/gruvbox.css +6 -0
- package/types.ts +17 -0
- package/utils/agentClosed.ts +33 -0
- package/utils/agentToolAnnouncement.ts +109 -0
- package/utils/agentToolSetting.ts +108 -0
- package/utils/aiPrompt.ts +31 -0
- package/utils/aiProvider.ts +41 -0
- package/utils/annotationOwnsHighlight.ts +27 -0
- package/utils/annotationScope.ts +15 -0
- package/utils/askSessionAnnouncement.ts +107 -0
- package/utils/blockTargeting.ts +61 -2
- package/utils/diagramAskAI.ts +161 -0
- package/utils/diffFileTree.ts +203 -0
- package/utils/htmlLinkNavigation.ts +87 -3
- package/utils/parser.ts +236 -110
- package/utils/questionAnswers.ts +3 -1
- package/utils/quickLabels.ts +2 -2
- package/utils/syntaxTheme.ts +33 -0
|
@@ -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
|
+
};
|
package/components/DocBadges.tsx
CHANGED
|
@@ -23,7 +23,9 @@ import { OpenInAppButton } from './OpenInAppButton';
|
|
|
23
23
|
|
|
24
24
|
export interface LinkedDocBadgeInfo {
|
|
25
25
|
filepath: string;
|
|
26
|
-
|
|
26
|
+
/** Leave the document. Optional for `folder-file`: absent shows no Close
|
|
27
|
+
* (a file of a review of several files, which the switcher moves between). */
|
|
28
|
+
onBack?: () => void;
|
|
27
29
|
label?: string;
|
|
28
30
|
backLabel?: string;
|
|
29
31
|
variant?: 'breadcrumb' | 'folder-file';
|
|
@@ -198,13 +200,15 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
|
|
|
198
200
|
{!isRow && linkedDocInfo && (
|
|
199
201
|
linkedDocInfo.variant === 'folder-file' ? (
|
|
200
202
|
<div className="flex items-center gap-2">
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
203
|
+
{linkedDocInfo.onBack && (
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
onClick={linkedDocInfo.onBack}
|
|
207
|
+
className="rounded-sm text-[9px] font-medium text-primary transition-colors hover:text-primary/80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
208
|
+
>
|
|
209
|
+
Close
|
|
210
|
+
</button>
|
|
211
|
+
)}
|
|
208
212
|
<span
|
|
209
213
|
className="truncate rounded bg-muted/50 px-1.5 py-0.5 text-[9px] text-muted-foreground max-w-[220px]"
|
|
210
214
|
title={linkedDocInfo.filepath}
|
|
@@ -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
|
+
};
|
|
@@ -40,7 +40,7 @@ interface PlanHeaderMenuProps {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export interface CompactPlanAction {
|
|
43
|
-
id: 'exit' | 'feedback' | 'approve' | 'copy' | 'done' | 'edit' | 'tools' | 'annotate' | 'refresh' | 'annotations' | 'ai' | 'review' | 'note' | 'discard-finish';
|
|
43
|
+
id: 'exit' | 'feedback' | 'approve' | 'copy' | 'done' | 'edit' | 'tools' | 'annotate' | 'refresh' | 'annotations' | 'ai' | 'review' | 'note' | 'discard-finish' | 'bundle-previous' | 'bundle-next';
|
|
44
44
|
label: string;
|
|
45
45
|
subtitle?: string;
|
|
46
46
|
onSelect: () => void;
|
|
@@ -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/Settings.tsx
CHANGED
|
@@ -7,6 +7,12 @@ import type { TokenHoverDelay } from '@plannotator/core/token-hover';
|
|
|
7
7
|
import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType, setReviewAutoViewed } from '../config';
|
|
8
8
|
import { setWebMcpToolsEnabled, useWebMcpToolsEnabled } from '../webmcp/preference';
|
|
9
9
|
import { DIAGRAM_SHADOW_OPTIONS } from '../utils/diagramShadow';
|
|
10
|
+
import {
|
|
11
|
+
agentToolAppliesWhen,
|
|
12
|
+
agentToolCostNote,
|
|
13
|
+
agentToolHostName,
|
|
14
|
+
type AgentToolSetting,
|
|
15
|
+
} from '../utils/agentToolSetting';
|
|
10
16
|
import { loadDiffFont } from '../utils/diffFonts';
|
|
11
17
|
import { TaterSpritePullup } from './TaterSpritePullup';
|
|
12
18
|
import { getIdentity, regenerateIdentity, setCustomIdentity, isIdentityEditable } from '../utils/identity';
|
|
@@ -106,6 +112,14 @@ interface SettingsProps {
|
|
|
106
112
|
* `env` is PLANNOTATOR_AUTO_UPDATE when it overrides the config file.
|
|
107
113
|
* Hosts that omit it get no toggle. */
|
|
108
114
|
autoUpdateSetting?: { env?: boolean };
|
|
115
|
+
/** The `plannotator` agent tool switch, as the server reports it
|
|
116
|
+
* (parseAgentToolSetting of serverConfig: present only when the session's
|
|
117
|
+
* host registers the tool). Shown only together with onAgentToolChange;
|
|
118
|
+
* hosts that omit them get no row. */
|
|
119
|
+
agentToolSetting?: AgentToolSetting;
|
|
120
|
+
/** Saves the agent tool switch (saveAgentToolSetting); rejects with a
|
|
121
|
+
* readable Error, which the row shows. */
|
|
122
|
+
onAgentToolChange?: (enabled: boolean) => Promise<void>;
|
|
109
123
|
/** Current session is a local git review where since-base ISN'T offered
|
|
110
124
|
* (base ref unresolvable) — the Git tab shows a note that the Git-status
|
|
111
125
|
* preference can't take effect in THIS repo. */
|
|
@@ -269,6 +283,53 @@ function ToggleSwitch({ checked, onChange, label, description, disabled = false
|
|
|
269
283
|
);
|
|
270
284
|
}
|
|
271
285
|
|
|
286
|
+
/**
|
|
287
|
+
* The `plannotator` agent tool switch. The value is the one the NEXT session
|
|
288
|
+
* uses (the tool list is part of the model's prompt, so a running session
|
|
289
|
+
* never changes it). Writes go straight to POST /api/config and wait for the
|
|
290
|
+
* answer, so a failed save is reported and the switch stays where it was.
|
|
291
|
+
*/
|
|
292
|
+
function AgentToolSettingRow({ setting, onChange }: {
|
|
293
|
+
readonly setting: AgentToolSetting;
|
|
294
|
+
readonly onChange: (enabled: boolean) => Promise<void>;
|
|
295
|
+
}) {
|
|
296
|
+
const [saving, setSaving] = useState(false);
|
|
297
|
+
const [error, setError] = useState<string | null>(null);
|
|
298
|
+
const locked = setting.env !== undefined;
|
|
299
|
+
const change = async (next: boolean) => {
|
|
300
|
+
if (saving || locked) return;
|
|
301
|
+
setSaving(true);
|
|
302
|
+
setError(null);
|
|
303
|
+
try {
|
|
304
|
+
await onChange(next);
|
|
305
|
+
} catch (e) {
|
|
306
|
+
setError(e instanceof Error && e.message ? e.message : 'Could not save the setting.');
|
|
307
|
+
} finally {
|
|
308
|
+
setSaving(false);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
return (
|
|
312
|
+
<div data-agent-tool-setting={setting.host} aria-busy={saving}>
|
|
313
|
+
<ToggleSwitch
|
|
314
|
+
checked={setting.enabled}
|
|
315
|
+
onChange={(next) => void change(next)}
|
|
316
|
+
disabled={locked}
|
|
317
|
+
label="Let the agent open reviews (plannotator tool)"
|
|
318
|
+
description={
|
|
319
|
+
locked
|
|
320
|
+
? `Set by PLANNOTATOR_AGENT_TOOL in your environment, so it is ${setting.enabled ? 'on' : 'off'} here.`
|
|
321
|
+
: `${agentToolHostName(setting.host)} can open reviews itself and keep working. ${agentToolCostNote(setting.host)} Applies from ${agentToolAppliesWhen(setting.host)}.`
|
|
322
|
+
}
|
|
323
|
+
/>
|
|
324
|
+
{error && (
|
|
325
|
+
<p role="alert" data-agent-tool-setting-error className="mt-1 text-xs text-destructive">
|
|
326
|
+
{error}
|
|
327
|
+
</p>
|
|
328
|
+
)}
|
|
329
|
+
</div>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
272
333
|
interface VimSettingsTabProps {
|
|
273
334
|
readonly vimModeEnabled: boolean;
|
|
274
335
|
readonly vimHudEnabled: boolean;
|
|
@@ -941,7 +1002,7 @@ const CommentsTab: React.FC = () => {
|
|
|
941
1002
|
);
|
|
942
1003
|
};
|
|
943
1004
|
|
|
944
|
-
export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', annotateParity = false, onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, autoUpdateSetting, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false, webmcpAvailable = false }) => {
|
|
1005
|
+
export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', annotateParity = false, onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, autoUpdateSetting, agentToolSetting, onAgentToolChange, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false, webmcpAvailable = false }) => {
|
|
945
1006
|
const webmcpTools = useWebMcpToolsEnabled();
|
|
946
1007
|
const autoUpdate = useConfigValue('autoUpdate');
|
|
947
1008
|
const [showDialog, setShowDialog] = useState(false);
|
|
@@ -1403,6 +1464,17 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
|
|
|
1403
1464
|
</>
|
|
1404
1465
|
)}
|
|
1405
1466
|
|
|
1467
|
+
{/* The `plannotator` agent tool switch: only where the
|
|
1468
|
+
server reports a tool host (Claude Code mod, Pi,
|
|
1469
|
+
OpenCode 2). Writes agentTool to config.json; applies
|
|
1470
|
+
to the next session. */}
|
|
1471
|
+
{agentToolSetting && onAgentToolChange && (
|
|
1472
|
+
<>
|
|
1473
|
+
<div className="border-t border-border" />
|
|
1474
|
+
<AgentToolSettingRow setting={agentToolSetting} onChange={onAgentToolChange} />
|
|
1475
|
+
</>
|
|
1476
|
+
)}
|
|
1477
|
+
|
|
1406
1478
|
{/* Agent tools (WebMCP). Shown only when the browser
|
|
1407
1479
|
exposes document.modelContext; off unregisters the
|
|
1408
1480
|
tools for this browser. Cookie-only and idle at its
|
|
@@ -107,7 +107,7 @@ function GitHubMark({ className }: { readonly className?: string }) {
|
|
|
107
107
|
);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
function XMark({ className }: { readonly className?: string }) {
|
|
110
|
+
export function XMark({ className }: { readonly className?: string }) {
|
|
111
111
|
return (
|
|
112
112
|
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" focusable="false">
|
|
113
113
|
<path
|