@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
package/types.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { DiagramAnchor } from '@plannotator/core/diagram-anchor';
2
2
  import type { DiagramRenderKind } from '@plannotator/core/annotatable';
3
+ import type { QuestionAnswer } from '@plannotator/core/question-block';
3
4
 
4
5
  export type { DiagramAnchor } from '@plannotator/core/diagram-anchor';
6
+ export type { QuestionAnswer } from '@plannotator/core/question-block';
5
7
  export type { DiagramRenderKind } from '@plannotator/core/annotatable';
6
8
 
7
9
  /**
@@ -100,6 +102,7 @@ export interface Annotation {
100
102
  elementContext?: HtmlElementContext; // raw-HTML / live-app pinpoint: bounded agent-facing description of the primary element (never used by restore)
101
103
  htmlAdditionalTargets?: HtmlAnnotationTarget[]; // raw-HTML shift-click multi-select: extra elements this one comment covers (primary stays htmlAnchor/originalText)
102
104
  diagramAnchor?: DiagramAnchor; // a comment on a rendered diagram part (Mermaid / Graphviz fence): the part's own id, label and document source line; the highlighter skips it and the diagram overlay restores it (see @plannotator/core/diagram-anchor)
105
+ questionAnswer?: QuestionAnswer; // the reviewer's answer to a `:::question` block (id `ann-question-<key>`): the Viewer draws the answer from it, the highlighter skips it, and the export prints it in the "Answers to your questions" section instead of as a numbered comment (see @plannotator/core/question-block)
103
106
  // web-highlighter metadata for cross-element selections
104
107
  startMeta?: AnnotationTextMeta;
105
108
  endMeta?: AnnotationTextMeta;
@@ -459,6 +462,10 @@ export interface AIResponse {
459
462
  text: string;
460
463
  isStreaming: boolean;
461
464
  error?: string;
465
+ /** Server error code with `error` (e.g. `agent_busy`, `session_gone` from "Ask this session"). */
466
+ errorCode?: string;
467
+ /** "Ask this session": the question is waiting for a busy session, or interrupting it. */
468
+ status?: 'waiting' | 'interrupting';
462
469
  createdAt: number;
463
470
  }
464
471
 
package/utils/aiPrompt.ts CHANGED
@@ -27,3 +27,34 @@ export function buildReviewContextPreamble(
27
27
  ? '[Still reviewing the same changes shown earlier in this conversation.]'
28
28
  : ctx;
29
29
  }
30
+
31
+ const MAX_IDENTITY_FILES = 30;
32
+
33
+ /**
34
+ * The code-review context for "Ask this session". The agent that opened the
35
+ * review wrote (or can read) the changes, so it gets the diff's identity —
36
+ * which comparison, against which base, which files — and never the patch
37
+ * itself, which would land permanently in its context window.
38
+ */
39
+ export function buildSessionReviewIdentity(review: {
40
+ diffType?: string | null;
41
+ base?: string | null;
42
+ patch?: string | null;
43
+ }): string {
44
+ const files: string[] = [];
45
+ for (const match of (review.patch ?? '').matchAll(/^diff --git a\/.+? b\/(.+)$/gm)) {
46
+ files.push(match[1]);
47
+ }
48
+ const lines: string[] = [];
49
+ if (review.diffType) {
50
+ lines.push(`Diff on screen: ${review.diffType}${review.base ? ` (against ${review.base})` : ''}`);
51
+ } else if (review.base) {
52
+ lines.push(`Diff on screen: against ${review.base}`);
53
+ }
54
+ if (files.length > 0) {
55
+ const shown = files.slice(0, MAX_IDENTITY_FILES).join(', ');
56
+ const more = files.length > MAX_IDENTITY_FILES ? ` (+${files.length - MAX_IDENTITY_FILES} more)` : '';
57
+ lines.push(`Changed files (${files.length}): ${shown}${more}`);
58
+ }
59
+ return lines.join('\n');
60
+ }
@@ -25,6 +25,45 @@ export interface AIProviderOption {
25
25
  modelsSource?: 'fallback' | 'discovered';
26
26
  /** The installed CLI's version, when the server reports it. */
27
27
  toolVersion?: string;
28
+ /** Display label overriding the name lookup (e.g. "Ask this session · Pi"). */
29
+ label?: string;
30
+ /** Present only on the "Ask this session" provider: the host session's live status. */
31
+ sessionBridge?: SessionBridgeInfo;
32
+ }
33
+
34
+ /** "Ask this session": Ask AI answered by the agent session that opened Plannotator. */
35
+ export const SESSION_BRIDGE_PROVIDER_NAME = 'session-bridge';
36
+
37
+ export interface SessionBridgeInfo {
38
+ host: string;
39
+ status: 'ready' | 'busy' | 'blocked' | 'gone';
40
+ modes: { turn: boolean; transient: boolean };
41
+ }
42
+
43
+ /** Error codes "Ask this session" answers with that the panel turns into actions. */
44
+ export const SESSION_ASK_ERROR_CODES = {
45
+ agentBusy: 'agent_busy',
46
+ blocked: 'session_blocked',
47
+ gone: 'session_gone',
48
+ } as const;
49
+
50
+ export function isSessionBridgeProvider(provider: Pick<AIProviderOption, 'name' | 'sessionBridge'> | null | undefined): boolean {
51
+ return !!provider && (provider.name === SESSION_BRIDGE_PROVIDER_NAME || !!provider.sessionBridge);
52
+ }
53
+
54
+ /**
55
+ * The "Ask this session" provider when it can answer here: present, the
56
+ * session not gone, and not blocked unless it offers a transient answer.
57
+ */
58
+ export function findUsableSessionBridge(providers: AIProviderOption[]): AIProviderOption | null {
59
+ for (const provider of providers) {
60
+ const bridge = provider.sessionBridge;
61
+ if (!bridge) continue;
62
+ if (bridge.status === 'gone') continue;
63
+ if (bridge.status === 'blocked' && !bridge.modes.transient) continue;
64
+ return provider;
65
+ }
66
+ return null;
28
67
  }
29
68
 
30
69
  export interface AIProviderSettings {
@@ -153,8 +192,20 @@ export function resolveAIProviderSelection(options: {
153
192
  const byId = (id: string | null | undefined) =>
154
193
  id ? providers.find(provider => provider.id === id) ?? null : null;
155
194
 
195
+ // An explicit saved pick wins, then "Ask this session" whenever the host
196
+ // session can answer, then the origin's own SDK provider and the older
197
+ // fallbacks. Without a usable bridge this is exactly the previous order.
198
+ const sessionBridge = findUsableSessionBridge(providers);
199
+ const explicitPick = sessionBridge
200
+ ? byId(originHasDedicatedAIProvider(origin)
201
+ ? (origin ? settings.providerByOrigin[origin] : null)
202
+ : settings.providerId)
203
+ : null;
204
+
156
205
  const provider =
206
+ explicitPick ??
157
207
  byId(origin ? settings.providerByOrigin[origin] : null) ??
208
+ sessionBridge ??
158
209
  findOriginAIProvider(providers, origin) ??
159
210
  byId(settings.providerId) ??
160
211
  byId(serverDefaultProvider) ??
@@ -167,6 +218,23 @@ export function resolveAIProviderSelection(options: {
167
218
  };
168
219
  }
169
220
 
221
+ /**
222
+ * Where "Ask a separate AI instead" goes when "Ask this session" cannot
223
+ * answer: the selection the app would make if the bridge were not there.
224
+ */
225
+ export function resolveSessionBridgeFallback(options: {
226
+ providers: AIProviderOption[];
227
+ origin?: Origin | null;
228
+ settings?: AIProviderSettings;
229
+ serverDefaultProvider?: string | null;
230
+ }): AIProviderSelection {
231
+ const providers = options.providers.filter(provider => !isSessionBridgeProvider(provider));
232
+ const serverDefaultProvider = providers.some(provider => provider.id === options.serverDefaultProvider)
233
+ ? options.serverDefaultProvider
234
+ : null;
235
+ return resolveAIProviderSelection({ ...options, providers, serverDefaultProvider });
236
+ }
237
+
170
238
  export function saveAIProviderSelection(options: {
171
239
  providerId: string | null;
172
240
  model?: string | null;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * One-time notice after a background auto-update (#1634).
3
+ *
4
+ * The compiled CLI's servers attach `autoUpdateNotice` to /api/plan and
5
+ * /api/diff when the data dir records an install attempt: `updated` once the
6
+ * running binary is the new version, `failed` when the install script exited
7
+ * non-zero. The notice id is stable per attempt; the "seen" marker is a cookie,
8
+ * which is shared across the random ports sessions run on, so the toast shows
9
+ * once whichever app (plan, annotate, review) loads first.
10
+ */
11
+
12
+ import { getItem, setItem } from './storage';
13
+
14
+ export interface AutoUpdateNotice {
15
+ id: string;
16
+ kind: 'updated' | 'failed';
17
+ version: string;
18
+ releaseUrl: string;
19
+ logPath?: string;
20
+ }
21
+
22
+ const SEEN_KEY = 'plannotator-auto-update-notice-seen';
23
+
24
+ export function parseAutoUpdateNotice(value: unknown): AutoUpdateNotice | undefined {
25
+ if (!value || typeof value !== 'object') return undefined;
26
+ const v = value as Record<string, unknown>;
27
+ if (typeof v.id !== 'string' || !v.id) return undefined;
28
+ if (v.kind !== 'updated' && v.kind !== 'failed') return undefined;
29
+ if (typeof v.version !== 'string' || typeof v.releaseUrl !== 'string') return undefined;
30
+ if (!/^https:\/\/github\.com\//.test(v.releaseUrl)) return undefined;
31
+ return {
32
+ id: v.id,
33
+ kind: v.kind,
34
+ version: v.version,
35
+ releaseUrl: v.releaseUrl,
36
+ ...(typeof v.logPath === 'string' && { logPath: v.logPath }),
37
+ };
38
+ }
39
+
40
+ /**
41
+ * True the first time a given notice is claimed in this browser, false after.
42
+ * Claiming marks it seen, so a caller shows the toast only when this is true.
43
+ */
44
+ export function claimAutoUpdateNotice(notice: AutoUpdateNotice): boolean {
45
+ if (getItem(SEEN_KEY) === notice.id) return false;
46
+ setItem(SEEN_KEY, notice.id);
47
+ return true;
48
+ }
49
+
50
+ /** Title and description for the toast. */
51
+ export function describeAutoUpdateNotice(notice: AutoUpdateNotice): { title: string; description: string } {
52
+ if (notice.kind === 'updated') {
53
+ return { title: `Updated to v${notice.version}`, description: 'Plannotator updated itself in the background.' };
54
+ }
55
+ return {
56
+ title: 'Auto-update failed',
57
+ description: notice.logPath ? `See ${notice.logPath}` : `Could not install v${notice.version}.`,
58
+ };
59
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Pure tree builder for a list of changed files (a diff), shared by
3
+ * Plannotator's code-review file tree (`packages/review-editor`) and the
4
+ * embeddable `DiffFileTree` component. Browser-safe, no React.
5
+ *
6
+ * Shape rules (the review's, unchanged when this moved here):
7
+ * - folders before files at every level, each group sorted by name
8
+ * (`localeCompare`);
9
+ * - a folder whose only child is a folder is merged into it
10
+ * (`packages/app/src`), recursively;
11
+ * - when the whole tree is one root folder holding only files, that folder is
12
+ * unwrapped and the files sit at the root;
13
+ * - a folder's +/- counts are the sums of everything under it.
14
+ */
15
+
16
+ /** Change status a tree row can show. 'binary' is a host-facing extra; the
17
+ * review's own parser never produces it. */
18
+ export type DiffFileTreeStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'binary';
19
+
20
+ /** The minimum a file needs to sit in the tree. */
21
+ export interface DiffFileTreeFile {
22
+ path: string;
23
+ oldPath?: string;
24
+ status: DiffFileTreeStatus;
25
+ additions: number;
26
+ deletions: number;
27
+ }
28
+
29
+ export interface DiffFileTreeNode<F extends DiffFileTreeFile = DiffFileTreeFile> {
30
+ type: 'file' | 'folder';
31
+ /** Display name: the file name, or the (possibly merged) folder segment(s). */
32
+ name: string;
33
+ /** File: the file's own path. Folder: the full folder path from the root. */
34
+ path: string;
35
+ depth: number;
36
+ /** Index of the file in the input array (file nodes only). */
37
+ fileIndex?: number;
38
+ file?: F;
39
+ children?: DiffFileTreeNode<F>[];
40
+ additions: number;
41
+ deletions: number;
42
+ }
43
+
44
+ interface TrieNode<F> {
45
+ children: Map<string, TrieNode<F>>;
46
+ file?: { index: number; data: F };
47
+ }
48
+
49
+ function buildTrie<F extends DiffFileTreeFile>(files: readonly F[]): TrieNode<F> {
50
+ const root: TrieNode<F> = { children: new Map() };
51
+
52
+ for (let i = 0; i < files.length; i++) {
53
+ const segments = files[i].path.split('/').filter(Boolean);
54
+ let current = root;
55
+
56
+ for (let j = 0; j < segments.length - 1; j++) {
57
+ if (!current.children.has(segments[j])) {
58
+ current.children.set(segments[j], { children: new Map() });
59
+ }
60
+ current = current.children.get(segments[j])!;
61
+ }
62
+
63
+ const fileName = segments[segments.length - 1];
64
+ const leaf: TrieNode<F> = { children: new Map(), file: { index: i, data: files[i] } };
65
+ current.children.set(fileName, leaf);
66
+ }
67
+
68
+ return root;
69
+ }
70
+
71
+ function trieToNodes<F extends DiffFileTreeFile>(
72
+ trie: TrieNode<F>,
73
+ parentPath: string,
74
+ depth: number,
75
+ ): DiffFileTreeNode<F>[] {
76
+ const folders: DiffFileTreeNode<F>[] = [];
77
+ const fileNodes: DiffFileTreeNode<F>[] = [];
78
+
79
+ for (const [name, child] of trie.children) {
80
+ const fullPath = parentPath ? `${parentPath}/${name}` : name;
81
+
82
+ if (child.file) {
83
+ fileNodes.push({
84
+ type: 'file',
85
+ name,
86
+ path: child.file.data.path,
87
+ depth,
88
+ fileIndex: child.file.index,
89
+ file: child.file.data,
90
+ additions: child.file.data.additions,
91
+ deletions: child.file.data.deletions,
92
+ });
93
+ } else {
94
+ const children = trieToNodes(child, fullPath, depth + 1);
95
+ const additions = children.reduce((s, c) => s + c.additions, 0);
96
+ const deletions = children.reduce((s, c) => s + c.deletions, 0);
97
+
98
+ folders.push({
99
+ type: 'folder',
100
+ name,
101
+ path: fullPath,
102
+ depth,
103
+ children,
104
+ additions,
105
+ deletions,
106
+ });
107
+ }
108
+ }
109
+
110
+ folders.sort((a, b) => a.name.localeCompare(b.name));
111
+ fileNodes.sort((a, b) => a.name.localeCompare(b.name));
112
+
113
+ return [...folders, ...fileNodes];
114
+ }
115
+
116
+ function collapseSingleChild<F extends DiffFileTreeFile>(nodes: DiffFileTreeNode<F>[]): DiffFileTreeNode<F>[] {
117
+ return nodes.map(node => {
118
+ if (node.type !== 'folder' || !node.children) return node;
119
+
120
+ let current = node;
121
+ while (
122
+ current.children &&
123
+ current.children.length === 1 &&
124
+ current.children[0].type === 'folder'
125
+ ) {
126
+ const child = current.children[0];
127
+ current = {
128
+ ...child,
129
+ name: `${current.name}/${child.name}`,
130
+ depth: node.depth,
131
+ };
132
+ }
133
+
134
+ return {
135
+ ...current,
136
+ children: current.children ? collapseSingleChild(fixDepths(current.children, node.depth + 1)) : undefined,
137
+ };
138
+ });
139
+ }
140
+
141
+ function fixDepths<F extends DiffFileTreeFile>(nodes: DiffFileTreeNode<F>[], depth: number): DiffFileTreeNode<F>[] {
142
+ return nodes.map(node => ({
143
+ ...node,
144
+ depth,
145
+ children: node.children ? fixDepths(node.children, depth + 1) : undefined,
146
+ }));
147
+ }
148
+
149
+ export function buildDiffFileTree<F extends DiffFileTreeFile>(files: readonly F[]): DiffFileTreeNode<F>[] {
150
+ if (files.length === 0) return [];
151
+
152
+ const trie = buildTrie(files);
153
+ let tree = trieToNodes(trie, '', 0);
154
+ tree = collapseSingleChild(tree);
155
+
156
+ // Flat fallback: if the tree is a single root folder with only file children, unwrap it
157
+ if (
158
+ tree.length === 1 &&
159
+ tree[0].type === 'folder' &&
160
+ tree[0].children?.every(c => c.type === 'file')
161
+ ) {
162
+ return fixDepths(tree[0].children!, 0);
163
+ }
164
+
165
+ return tree;
166
+ }
167
+
168
+ /** Every proper ancestor folder path of a file path (`a/b/c.ts` → `a`, `a/b`).
169
+ * A merged folder's path is one of these, so expanding them all reveals the file. */
170
+ export function getAncestorPaths(filePath: string): string[] {
171
+ const segments = filePath.split('/').filter(Boolean);
172
+ const paths: string[] = [];
173
+ for (let i = 1; i < segments.length; i++) {
174
+ paths.push(segments.slice(0, i).join('/'));
175
+ }
176
+ return paths;
177
+ }
178
+
179
+ /** File indexes in the order the tree renders them (ignores collapse state). */
180
+ export function getVisualFileOrder<F extends DiffFileTreeFile>(nodes: DiffFileTreeNode<F>[]): number[] {
181
+ const order: number[] = [];
182
+ for (const node of nodes) {
183
+ if (node.type === 'file' && node.fileIndex != null) {
184
+ order.push(node.fileIndex);
185
+ } else if (node.children) {
186
+ order.push(...getVisualFileOrder(node.children));
187
+ }
188
+ }
189
+ return order;
190
+ }
191
+
192
+ export function getAllFolderPaths<F extends DiffFileTreeFile>(nodes: DiffFileTreeNode<F>[]): string[] {
193
+ const paths: string[] = [];
194
+ for (const node of nodes) {
195
+ if (node.type === 'folder') {
196
+ paths.push(node.path);
197
+ if (node.children) {
198
+ paths.push(...getAllFolderPaths(node.children));
199
+ }
200
+ }
201
+ }
202
+ return paths;
203
+ }
@@ -12,8 +12,16 @@
12
12
  * server origin and the directories, so every branch is unit-testable.
13
13
  */
14
14
 
15
+ import { isReviewImagePath } from "@plannotator/core/diff-paths";
15
16
  import { hasLinkedDocExtension } from "./markdownExtensions";
16
17
 
18
+ /**
19
+ * Route the servers serve a raw-HTML page's support assets from. Spelled here
20
+ * rather than imported because `@plannotator/shared/html-assets` is node-side
21
+ * (parse5, `path`); `htmlLinkNavigation.test.ts` pins the two together.
22
+ */
23
+ export const HTML_ASSET_ROUTE_PREFIX = "/api/html-assets";
24
+
17
25
  /** Longest href the parent will look at. Matches the bridge's own cap. */
18
26
  export const MAX_HTML_LINK_HREF_LENGTH = 2048;
19
27
 
@@ -27,11 +35,29 @@ export type HtmlLinkIntent =
27
35
  | { kind: "document"; path: string; hash: string; rendersHtml: boolean }
28
36
  /** Another origin. Opens in a new tab; the frame never navigates. */
29
37
  | { kind: "external"; url: string }
30
- /** A local file Plannotator cannot render as a document (`.pdf`, `.zip`, …). */
31
- | { kind: "unsupported"; path: string; label: string }
38
+ /**
39
+ * A local image inside the page's asset root, shown in the image lightbox.
40
+ * `url` is the page's own `/api/html-assets/<token>/…` route, so the
41
+ * lightbox reads exactly what the page itself can already load.
42
+ */
43
+ | { kind: "image"; path: string; url: string; label: string }
44
+ /**
45
+ * A local file Plannotator will not open. `type`: not a document or image
46
+ * (`.pdf`, `.zip`, …). `outside-asset-root`: an image outside the page's
47
+ * own folder, which the asset route refuses. `no-asset-root`: an image on
48
+ * a page served without an asset route (share links, converted pages).
49
+ */
50
+ | {
51
+ kind: "unsupported";
52
+ path: string;
53
+ label: string;
54
+ reason: HtmlLinkUnsupportedReason;
55
+ }
32
56
  /** Nothing to do: empty, fragment-only, or a scheme we do not follow. */
33
57
  | { kind: "ignored"; reason: HtmlLinkIgnoreReason };
34
58
 
59
+ export type HtmlLinkUnsupportedReason = "type" | "outside-asset-root" | "no-asset-root";
60
+
35
61
  export type HtmlLinkIgnoreReason =
36
62
  | "empty"
37
63
  | "too-long"
@@ -55,6 +81,12 @@ export interface HtmlLinkContext {
55
81
  /** The session's `--markdown` preference: HTML is Turndowned by `/api/doc`,
56
82
  * so an `.html` target renders as markdown rather than as an HTML surface. */
57
83
  convertHtml?: boolean;
84
+ /**
85
+ * The current page's asset root: the directory its `/api/html-assets`
86
+ * token was minted for, and that token's route (`/api/html-assets/<t>/`).
87
+ * Image links open in the lightbox only from inside `dir`.
88
+ */
89
+ assetRoot?: { dir: string; url: string } | null;
58
90
  }
59
91
 
60
92
  /** Control characters never appear in a real href; they are how structure gets smuggled. */
@@ -119,11 +151,13 @@ function resolveRelative(
119
151
  if (!baseDir) return { kind: "ignored", reason: "no-base" };
120
152
  const resolved = joinPath(baseDir, decoded);
121
153
  if (!resolved) return { kind: "ignored", reason: "invalid" };
154
+ const label = basename(resolved);
155
+ if (isReviewImagePath(resolved)) return resolveImage(resolved, label, context.assetRoot);
122
156
  // Containment is the server's call (`/api/doc` answers 403 for an escaping
123
157
  // path). The extension gate is ours: a `.pdf` would be a pointless fetch
124
158
  // and a confusing server error, so it is reported as unsupported here.
125
159
  if (!hasLinkedDocExtension(resolved)) {
126
- return { kind: "unsupported", path: resolved, label: basename(resolved) };
160
+ return { kind: "unsupported", path: resolved, label, reason: "type" };
127
161
  }
128
162
  return {
129
163
  kind: "document",
@@ -133,6 +167,56 @@ function resolveRelative(
133
167
  };
134
168
  }
135
169
 
170
+ /**
171
+ * An image link opens in the lightbox, read through the page's own asset
172
+ * route. That route serves only files below the token's directory (it refuses
173
+ * `..` and checks the realpath), so an image outside it is reported here
174
+ * instead of becoming a request the server would refuse. This check is
175
+ * lexical and only decides the UI; the server's check is the one that holds.
176
+ */
177
+ function resolveImage(
178
+ path: string,
179
+ label: string,
180
+ assetRoot: HtmlLinkContext["assetRoot"],
181
+ ): HtmlLinkIntent {
182
+ if (!assetRoot?.dir || !assetRoot.url) {
183
+ return { kind: "unsupported", path, label, reason: "no-asset-root" };
184
+ }
185
+ const dir = assetRoot.dir.replace(/\\/g, "/").replace(/\/+$/, "");
186
+ if (!path.startsWith(`${dir}/`)) {
187
+ return { kind: "unsupported", path, label, reason: "outside-asset-root" };
188
+ }
189
+ const relative = path
190
+ .slice(dir.length + 1)
191
+ .split("/")
192
+ .map(encodeURIComponent)
193
+ .join("/");
194
+ const base = assetRoot.url.endsWith("/") ? assetRoot.url : `${assetRoot.url}/`;
195
+ return { kind: "image", path, url: `${base}${relative}`, label };
196
+ }
197
+
198
+ /**
199
+ * The asset route a served raw-HTML page is anchored at. Both servers install
200
+ * `<base href="/api/html-assets/<token>/">` first in `<head>` (re-anchoring a
201
+ * relative author base under the same token). Returns that token's route, or
202
+ * null when the page carries none (share links, an author's absolute base).
203
+ *
204
+ * Only the FIRST `<base>` element counts, as in the browser, and comments are
205
+ * skipped: a page cannot name a different token in a comment or a later base
206
+ * tag and have the lightbox read through it.
207
+ */
208
+ const HTML_COMMENT_PATTERN = new RegExp("<!--[\\s\\S]*?(?:-->|$)", "g");
209
+
210
+ export function htmlAssetRouteFromDocument(rawHtml: string | null | undefined): string | null {
211
+ if (!rawHtml) return null;
212
+ // RegExp constructor, not a literal: Semgrep's TS parser chokes on `<!--` in a regex literal.
213
+ const withoutComments = rawHtml.replace(HTML_COMMENT_PATTERN, "");
214
+ const baseTag = /<base\b[^>]*>/i.exec(withoutComments);
215
+ if (!baseTag) return null;
216
+ const match = /\bhref\s*=\s*["']?\/api\/html-assets\/([A-Za-z0-9_-]+)\//i.exec(baseTag[0]);
217
+ return match ? `${HTML_ASSET_ROUTE_PREFIX}/${match[1]}/` : null;
218
+ }
219
+
136
220
  /**
137
221
  * Whether opening `path` lands on another raw-HTML surface rather than a
138
222
  * markdown one. `--markdown` sessions convert HTML on the way in, so nothing