@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.
Files changed (64) hide show
  1. package/HANDOFF.md +202 -1
  2. package/LICENSE +191 -0
  3. package/README.md +23 -0
  4. package/components/AISettingsTab.tsx +33 -1
  5. package/components/AgentToolAnnouncementDialog.tsx +347 -0
  6. package/components/AgentsTab.tsx +9 -9
  7. package/components/AskSessionAnnouncementDialog.tsx +329 -0
  8. package/components/CommentPopover.tsx +32 -18
  9. package/components/DecisionControl.tsx +2 -7
  10. package/components/DiagramBlock.tsx +46 -4
  11. package/components/DiffFileTree.tsx +363 -0
  12. package/components/DocBadges.tsx +12 -8
  13. package/components/ImageLightbox.tsx +66 -0
  14. package/components/PlanHeaderMenu.tsx +1 -1
  15. package/components/ProviderIcons.tsx +15 -3
  16. package/components/Settings.tsx +73 -1
  17. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  18. package/components/Viewer.tsx +5 -28
  19. package/components/ai/AIProviderBar.tsx +20 -3
  20. package/components/ai/DocumentAIChatPanel.tsx +23 -3
  21. package/components/ai/SessionAskNotice.tsx +101 -0
  22. package/components/blocks/QuestionBlock.tsx +24 -19
  23. package/components/blocks/QuestionContext.tsx +81 -0
  24. package/components/diagram/DiagramComposer.tsx +54 -3
  25. package/components/diagram/DiagramViewer.tsx +31 -2
  26. package/components/diagram/index.ts +1 -1
  27. package/components/html-viewer/HtmlViewer.tsx +13 -3
  28. package/components/html-viewer/bridge-script.asset.js +209 -16
  29. package/components/html-viewer/bridge-script.ts +209 -16
  30. package/components/html-viewer/useHtmlAnnotation.ts +36 -55
  31. package/configure.ts +12 -0
  32. package/hooks/useAIChat.ts +96 -10
  33. package/hooks/useAIProviderActivation.ts +13 -0
  34. package/hooks/useAIProviderConfig.ts +32 -14
  35. package/hooks/useActiveSection.ts +3 -2
  36. package/hooks/useAgentToolSetting.ts +33 -0
  37. package/hooks/useAnnotationDraft.ts +53 -7
  38. package/hooks/useAnnotationHighlighter.ts +101 -5
  39. package/hooks/useDiffFileTreeExpansion.ts +92 -0
  40. package/hooks/useExternalAnnotations.ts +21 -1
  41. package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
  42. package/hooks/useLatchedTrue.ts +15 -0
  43. package/hooks/useLinkedDoc.ts +64 -4
  44. package/hooks/useModelCatalogs.ts +9 -2
  45. package/package.json +3 -2
  46. package/styles.css +1 -1
  47. package/themes/gruvbox.css +6 -0
  48. package/types.ts +17 -0
  49. package/utils/agentClosed.ts +33 -0
  50. package/utils/agentToolAnnouncement.ts +109 -0
  51. package/utils/agentToolSetting.ts +108 -0
  52. package/utils/aiPrompt.ts +31 -0
  53. package/utils/aiProvider.ts +41 -0
  54. package/utils/annotationOwnsHighlight.ts +27 -0
  55. package/utils/annotationScope.ts +15 -0
  56. package/utils/askSessionAnnouncement.ts +107 -0
  57. package/utils/blockTargeting.ts +61 -2
  58. package/utils/diagramAskAI.ts +161 -0
  59. package/utils/diffFileTree.ts +203 -0
  60. package/utils/htmlLinkNavigation.ts +87 -3
  61. package/utils/parser.ts +236 -110
  62. package/utils/questionAnswers.ts +3 -1
  63. package/utils/quickLabels.ts +2 -2
  64. 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
+ };
@@ -23,7 +23,9 @@ import { OpenInAppButton } from './OpenInAppButton';
23
23
 
24
24
  export interface LinkedDocBadgeInfo {
25
25
  filepath: string;
26
- onBack: () => void;
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
- <button
202
- type="button"
203
- onClick={linkedDocInfo.onBack}
204
- 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"
205
- >
206
- Close
207
- </button>
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
- /** Get provider metadata, with fallback for unknown providers. */
49
- export function getProviderMeta(providerName: string): { label: string; icon: React.FC<{ className?: string }> } {
50
- return PROVIDER_META[providerName] ?? { label: providerName, icon: GenericProviderIcon };
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
  }
@@ -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