@plannotator/ui 0.47.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/HANDOFF.md +147 -0
  2. package/LICENSE +191 -0
  3. package/README.md +56 -1
  4. package/components/AISettingsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +44 -3
  6. package/components/BlockRenderer.tsx +55 -1
  7. package/components/CompletionOverlay.tsx +23 -1
  8. package/components/DiffFileTree.tsx +363 -0
  9. package/components/ImageLightbox.tsx +66 -0
  10. package/components/PRPlatformIcon.tsx +22 -0
  11. package/components/ProviderIcons.tsx +15 -3
  12. package/components/QuestionProgressChip.tsx +66 -0
  13. package/components/QuestionsPanelSection.tsx +121 -0
  14. package/components/Settings.tsx +25 -1
  15. package/components/Viewer.tsx +170 -34
  16. package/components/ai/AIProviderBar.tsx +5 -5
  17. package/components/ai/DocumentAIChatPanel.tsx +28 -3
  18. package/components/ai/SessionAskNotice.tsx +89 -0
  19. package/components/blocks/QuestionBlock.tsx +591 -0
  20. package/components/html-viewer/bridge-script.asset.js +85 -8
  21. package/components/html-viewer/bridge-script.ts +85 -8
  22. package/config/settings.ts +17 -0
  23. package/configure.ts +12 -0
  24. package/hooks/useAIChat.ts +74 -9
  25. package/hooks/useAIProviderConfig.ts +32 -14
  26. package/hooks/useAnnotationHighlighter.ts +4 -0
  27. package/hooks/useDiffFileTreeExpansion.ts +92 -0
  28. package/hooks/usePinpoint.ts +10 -0
  29. package/hooks/useScrollKeyRouting.ts +157 -0
  30. package/hooks/useUndoHistory.ts +17 -0
  31. package/hooks/useVimDocumentFocus.ts +2 -1
  32. package/package.json +3 -2
  33. package/styles.css +1 -1
  34. package/types.ts +7 -0
  35. package/utils/aiPrompt.ts +31 -0
  36. package/utils/aiProvider.ts +68 -0
  37. package/utils/autoUpdateNotice.ts +59 -0
  38. package/utils/diffFileTree.ts +203 -0
  39. package/utils/htmlLinkNavigation.ts +87 -3
  40. package/utils/parser.ts +125 -563
  41. package/utils/questionAnswers.ts +227 -0
  42. package/utils/syntaxTheme.ts +33 -0
@@ -8,6 +8,20 @@ import { Callout } from "./blocks/Callout";
8
8
  import { AlertBlock } from "./blocks/AlertBlock";
9
9
  import { TableBlock } from "./blocks/TableBlock";
10
10
  import { MathBlock } from "./blocks/MathBlock";
11
+ import { QuestionBlock, type QuestionBlockProps } from "./blocks/QuestionBlock";
12
+ import {
13
+ indexQuestionBlocks,
14
+ isQuestionDirectiveKind,
15
+ type IndexedQuestion,
16
+ type QuestionAnswer,
17
+ } from "@plannotator/core/question-block";
18
+
19
+ /** A question block rendered outside a Viewer's document index (plan diff,
20
+ * other markdown surfaces): parsed alone, unnumbered. */
21
+ const standaloneQuestion = (block: Block): IndexedQuestion | undefined => {
22
+ const [indexed] = indexQuestionBlocks([block]);
23
+ return indexed ? { ...indexed, number: 0 } : undefined;
24
+ };
11
25
 
12
26
  export const BlockRenderer: React.FC<{
13
27
  block: Block;
@@ -22,7 +36,22 @@ export const BlockRenderer: React.FC<{
22
36
  repoHost?: string;
23
37
  headingAnchorId?: string;
24
38
  onNavigateAnchor?: (hash: string) => void;
25
- }> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, repoHost, headingAnchorId, onNavigateAnchor }) => {
39
+ /** `:::question` blocks: this block's entry in the document's question
40
+ * index (numbering, key, prompt line) and the document's question count.
41
+ * Absent, a question block is parsed on its own and shows no "N of M". */
42
+ question?: IndexedQuestion;
43
+ questionTotal?: number;
44
+ /** The answer to draw (from the annotation carrying `questionAnswer`). */
45
+ questionAnswer?: QuestionAnswer;
46
+ /** Absent: question blocks render read-only. */
47
+ onAnswerQuestion?: (blockId: string, answer: QuestionAnswer | null, key: string) => void;
48
+ /** Explicit save mode for question cards (see `QuestionBlock`). */
49
+ onSaveQuestionAnswer?: QuestionBlockProps['onSaveAnswer'];
50
+ /** Label of the explicit-save-mode Save button (see `QuestionBlock`). */
51
+ saveQuestionAnswerLabel?: QuestionBlockProps['saveLabel'];
52
+ /** Host actions in a question card's footer (see `QuestionBlock`). */
53
+ renderQuestionFooter?: QuestionBlockProps['renderFooter'];
54
+ }> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, repoHost, headingAnchorId, onNavigateAnchor, question, questionTotal, questionAnswer, onAnswerQuestion, onSaveQuestionAnswer, saveQuestionAnswerLabel, renderQuestionFooter }) => {
26
55
  switch (block.type) {
27
56
  case 'heading': {
28
57
  const Tag = `h${block.level || 1}` as React.ElementType;
@@ -135,6 +164,31 @@ export const BlockRenderer: React.FC<{
135
164
 
136
165
  case 'directive': {
137
166
  const kind = block.directiveKind || 'note';
167
+ if (isQuestionDirectiveKind(kind)) {
168
+ // An unparseable question (no prompt) falls through to the callout.
169
+ const indexed = question ?? standaloneQuestion(block);
170
+ if (indexed) {
171
+ return (
172
+ <QuestionBlock
173
+ blockId={block.id}
174
+ indexed={indexed}
175
+ total={questionTotal ?? 0}
176
+ answer={questionAnswer}
177
+ onAnswer={onAnswerQuestion}
178
+ onSaveAnswer={onSaveQuestionAnswer}
179
+ saveLabel={saveQuestionAnswerLabel}
180
+ renderFooter={renderQuestionFooter}
181
+ onOpenLinkedDoc={onOpenLinkedDoc}
182
+ onOpenCodeFile={onOpenCodeFile}
183
+ imageBaseDir={imageBaseDir}
184
+ onImageClick={onImageClick}
185
+ githubRepo={githubRepo}
186
+ repoHost={repoHost}
187
+ onNavigateAnchor={onNavigateAnchor}
188
+ />
189
+ );
190
+ }
191
+ }
138
192
  return (
139
193
  <Callout
140
194
  blockId={block.id}
@@ -29,9 +29,15 @@ interface CompletionOverlayProps {
29
29
  title: string;
30
30
  subtitle: string;
31
31
  agentLabel: string;
32
+ /**
33
+ * Optional destinations shown under the subtitle as ordinary links (opened
34
+ * in a new tab by a real click, so no popup blocker applies) — e.g. the PR
35
+ * a platform review was just posted to. Omitted or empty renders nothing.
36
+ */
37
+ links?: ReadonlyArray<{ href: string; label: string }>;
32
38
  }
33
39
 
34
- export function CompletionOverlay({ submitted, title, subtitle, agentLabel }: CompletionOverlayProps) {
40
+ export function CompletionOverlay({ submitted, title, subtitle, agentLabel, links }: CompletionOverlayProps) {
35
41
  const { state, enableAndStart } = useAutoClose(!!submitted);
36
42
 
37
43
  if (!submitted) return null;
@@ -52,6 +58,22 @@ export function CompletionOverlay({ submitted, title, subtitle, agentLabel }: Co
52
58
  <div className="space-y-2">
53
59
  <h2 className="text-xl font-semibold text-foreground">{title}</h2>
54
60
  <p className="text-muted-foreground">{subtitle}</p>
61
+ {links && links.length > 0 && (
62
+ <div className="flex flex-col items-center gap-1 pt-1">
63
+ {links.map((link) => (
64
+ <a
65
+ key={link.href}
66
+ href={link.href}
67
+ target="_blank"
68
+ rel="noopener noreferrer"
69
+ data-completion-link
70
+ className="text-sm font-medium text-primary underline-offset-4 hover:underline break-all"
71
+ >
72
+ {link.label}
73
+ </a>
74
+ ))}
75
+ </div>
76
+ )}
55
77
  </div>
56
78
 
57
79
  <div className="pt-4 border-t border-border space-y-2">
@@ -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
+ };
@@ -0,0 +1,22 @@
1
+ import React from "react";
2
+ import { GitHubIcon } from "./GitHubIcon";
3
+ import { GitLabIcon } from "./GitLabIcon";
4
+
5
+ /** Bitbucket bucket mark (simple-icons, CC0). Uses currentColor. */
6
+ export const BitbucketIcon: React.FC<{ className?: string }> = ({ className }) => (
7
+ <svg
8
+ className={className}
9
+ viewBox="0 0 24 24"
10
+ fill="currentColor"
11
+ xmlns="http://www.w3.org/2000/svg"
12
+ >
13
+ <path d="M.778 1.213a.768.768 0 00-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 00.77-.646l3.27-20.03a.768.768 0 00-.768-.891zM14.52 15.53H9.522L8.17 8.466h7.561z" />
14
+ </svg>
15
+ );
16
+
17
+ /** The mark of the platform a PR/MR lives on. Unknown platforms get GitHub's. */
18
+ export const PRPlatformIcon: React.FC<{ platform: string; className?: string }> = ({ platform, className }) => {
19
+ if (platform === "gitlab") return <GitLabIcon className={className} />;
20
+ if (platform === "bitbucket") return <BitbucketIcon className={className} />;
21
+ return <GitHubIcon className={className} />;
22
+ };
@@ -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
  }
@@ -0,0 +1,66 @@
1
+ import React from 'react';
2
+ import { cn } from '../lib/utils';
3
+
4
+ /**
5
+ * The header "N/M answered" chip for documents with `:::question` blocks
6
+ * (the approved Card mock): a progress ring and the count. It is a button:
7
+ * a click jumps to the next open question. With no open question left it
8
+ * still reads the progress but has nothing to jump to.
9
+ */
10
+ export interface QuestionProgressChipProps {
11
+ /** Answered or settled questions. */
12
+ done: number;
13
+ total: number;
14
+ /** Whether a question is still open (not answered, settled or skipped). */
15
+ hasOpen: boolean;
16
+ onJump: () => void;
17
+ className?: string;
18
+ }
19
+
20
+ const Ring: React.FC<{ done: number; total: number }> = ({ done, total }) => {
21
+ const r = 6;
22
+ const c = 2 * Math.PI * r;
23
+ const f = total > 0 ? done / total : 0;
24
+ const complete = total > 0 && done === total;
25
+ return (
26
+ <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" className="shrink-0">
27
+ <circle cx="8" cy="8" r={r} fill="none" stroke="var(--border)" strokeWidth="2.2" />
28
+ <circle
29
+ cx="8"
30
+ cy="8"
31
+ r={r}
32
+ fill="none"
33
+ stroke={complete ? 'var(--success)' : 'var(--primary)'}
34
+ strokeWidth="2.2"
35
+ strokeDasharray={`${c * f} ${c}`}
36
+ transform="rotate(-90 8 8)"
37
+ strokeLinecap="round"
38
+ />
39
+ </svg>
40
+ );
41
+ };
42
+
43
+ export const QuestionProgressChip: React.FC<QuestionProgressChipProps> = ({ done, total, hasOpen, onJump, className }) => {
44
+ if (total <= 0) return null;
45
+ const label = `${done} of ${total} question${total === 1 ? '' : 's'} answered.${hasOpen ? ' Go to next unanswered.' : ''}`;
46
+ return (
47
+ <button
48
+ type="button"
49
+ data-question-progress="true"
50
+ onClick={onJump}
51
+ aria-label={label}
52
+ title={hasOpen ? 'Go to the next open question' : done >= total ? 'All questions answered' : 'No open questions'}
53
+ className={cn(
54
+ 'inline-flex h-7 shrink-0 items-center gap-[7px] whitespace-nowrap rounded-full border border-border bg-card pl-[7px] pr-2.5 text-xs text-muted-foreground transition-colors',
55
+ 'hover:border-primary/50 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
56
+ className,
57
+ )}
58
+ >
59
+ <Ring done={done} total={total} />
60
+ <span>
61
+ <b className="font-semibold text-foreground">{done}/{total}</b>
62
+ <span className="hidden md:inline"> answered</span>
63
+ </span>
64
+ </button>
65
+ );
66
+ };