@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
package/utils/parser.ts CHANGED
@@ -778,11 +778,34 @@ const commentHeadingLine = (ann: any): string => {
778
778
  if (context && typeof context.tag === 'string' && isElementPlaceholderQuote(ann.originalText)) {
779
779
  const tag = safeInline(context.tag, 32);
780
780
  const name = context.name ? safeInline(context.name, 120) : '';
781
- return `Feedback on the <${tag}> element${name ? ` — "${name}"` : ''}`;
781
+ const file = elementSourceFileName(context);
782
+ return `Feedback on the <${tag}> element${name ? ` — "${name}"` : ''}${file && file !== name ? ` (${file})` : ''}`;
782
783
  }
783
784
  return `Feedback on: "${ann.originalText}"`;
784
785
  };
785
786
 
787
+ /** The file a media element shows, so two nameless images read differently in a heading:
788
+ * `team.jpg` for `https://cdn.test/img/team.jpg?…`, `data:image/png` for an
789
+ * inline image. Empty when the context carries no source.
790
+ *
791
+ * `sourceName` is the bridge's own resolution (the one the composer quote
792
+ * uses: src, a lazy data-src, srcset, <picture>/<source>, poster), so the
793
+ * heading and the quote can never name different files. Contexts captured
794
+ * before that field existed fall back to the scrubbed `src` attribute. */
795
+ const elementSourceFileName = (context: any): string => {
796
+ if (typeof context?.sourceName === 'string' && context.sourceName.trim()) {
797
+ return safeInline(context.sourceName, 60);
798
+ }
799
+ const attrs = Array.isArray(context?.attrs) ? context.attrs : [];
800
+ const pair = attrs.find((p: unknown) => Array.isArray(p) && p[0] === 'src' && typeof p[1] === 'string');
801
+ if (!pair) return '';
802
+ const value: string = pair[1].trim();
803
+ if (/^data:/i.test(value)) return safeInline(`data:${value.slice(5).split(/[;,]/)[0] || 'unknown'}`, 60);
804
+ const path = value.replace(/\?…$/, '').replace(/\/+$/, '');
805
+ const name = path.slice(path.lastIndexOf('/') + 1);
806
+ return safeInline(name || path, 60);
807
+ };
808
+
786
809
  export interface ElementContextExportOptions {
787
810
  /** Emit the live-app route line. The grouped export already prints a
788
811
  * `## Page:` heading, so it passes false; a single copied entry passes true. */
@@ -882,6 +905,66 @@ const additionalTargetsExportBlock = (ann: any): string => {
882
905
  return block;
883
906
  };
884
907
 
908
+ /** A URL with its query and fragment replaced by `?…`, the way the bridge
909
+ * scrubs captured `href`/`src` values (tokens live in both). */
910
+ const scrubUrlQuery = (url: string): string => {
911
+ const mark = url.search(/[?#]/);
912
+ return mark < 0 ? url : `${url.slice(0, mark)}?…`;
913
+ };
914
+
915
+ /** Ask AI sends no query strings: the live-app route line drops its query
916
+ * (the persisted context keeps it, since that is the page that was reviewed). */
917
+ const withoutRouteQuery = (context: unknown): unknown => {
918
+ const page = (context as any)?.page;
919
+ if (!page || typeof page.url !== 'string') return context;
920
+ return { ...(context as object), page: { ...page, url: scrubUrlQuery(page.url) } };
921
+ };
922
+
923
+ /** The anchor's `href`/`src` selector rung carries the raw attribute value,
924
+ * query included (it must, to stay a working selector); Ask AI gets the
925
+ * value with the query scrubbed instead. */
926
+ const withoutSelectorQuery = (anchor: AskAIElementTarget['anchor']): AskAIElementTarget['anchor'] => {
927
+ if (!anchor || typeof anchor.selector !== 'string') return anchor;
928
+ const selector = anchor.selector.replace(
929
+ /(\[(?:href|src)=")((?:[^"\\]|\\.)*)(")/g,
930
+ (_m, open: string, value: string, close: string) => `${open}${scrubUrlQuery(value)}${close}`,
931
+ );
932
+ return { ...anchor, selector };
933
+ };
934
+
935
+ /** One pinpointed element as the Ask AI composer holds it (a draft target). */
936
+ export interface AskAIElementTarget {
937
+ text?: string;
938
+ anchor?: { selector?: string } | null;
939
+ context?: unknown;
940
+ }
941
+
942
+ /**
943
+ * The element identity behind an Ask AI question asked from a raw-HTML or
944
+ * live-app pinpoint: the same identity lines the feedback export prints
945
+ * (`elementContextExportBlock`), without the fenced outline (the costly part
946
+ * of a model turn). One entry per pinpointed element, numbered when the
947
+ * question covers several. Empty when no target carries element context, so
948
+ * a plain text selection asks exactly what it asked before.
949
+ */
950
+ export const elementIdentityForAskAI = (targets: readonly AskAIElementTarget[] | null | undefined): string => {
951
+ if (!Array.isArray(targets)) return '';
952
+ const blocks = targets
953
+ .map((target) => ({
954
+ text: typeof target?.text === 'string' ? safeInline(target.text, 200) : '',
955
+ lines: elementContextExportBlock(
956
+ { elementContext: withoutRouteQuery(target?.context), htmlAnchor: withoutSelectorQuery(target?.anchor) },
957
+ { includeOutline: false, includeRoute: true },
958
+ ).trim(),
959
+ }))
960
+ .filter((block) => block.lines);
961
+ if (blocks.length === 0) return '';
962
+ if (blocks.length === 1) return blocks[0].lines;
963
+ return blocks
964
+ .map((block, i) => `Element ${i + 1}${block.text ? `: "${block.text}"` : ''}\n${block.lines}`)
965
+ .join('\n\n');
966
+ };
967
+
885
968
  /**
886
969
  * One annotation rendered as a standalone feedback entry (no number, no
887
970
  * document heading), for hosts that surface a single annotation to an agent.
@@ -974,57 +1057,81 @@ const questionItemsFromAnswers = (answers: QuestionAnswer[]): QuestionExportItem
974
1057
  settled: false,
975
1058
  }));
976
1059
 
977
- export const exportAnnotations = (
978
- blocks: Block[],
979
- allAnnotations: any[],
980
- globalAttachments: ImageAttachment[] = [],
981
- title: string = 'Plan Feedback',
982
- subject: string = 'plan',
983
- opts: ExportAnnotationsOptions = {},
984
- ): string => {
985
- // Answers to `:::question` blocks are printed first, in their own section,
986
- // and never counted as numbered feedback.
987
- const { answers, annotations } = splitQuestionAnswers(allAnnotations);
988
- const answersSection = answers.length > 0
989
- ? formatQuestionAnswersSection(questionExportItems(indexQuestionBlocks(blocks)), answers, { headingLevel: 2 })
990
- : '';
991
- if (annotations.length === 0 && globalAttachments.length === 0 && !answersSection) {
992
- return 'No changes detected.';
993
- }
1060
+ /** The diff-block index of a comment made in the plan/version diff view
1061
+ * (`blockId` `diff-block-N`), or null for every other annotation. */
1062
+ const diffBlockIndex = (ann: any): number | null => {
1063
+ const match = typeof ann?.blockId === 'string' ? /^diff-block-(\d+)$/.exec(ann.blockId) : null;
1064
+ return match ? Number(match[1]) : null;
1065
+ };
994
1066
 
995
- // Sort annotations by block and offset
996
- const sortedAnns = [...annotations].sort((a, b) => {
997
- const blockA = blocks.findIndex(blk => blk.id === a.blockId);
998
- const blockB = blocks.findIndex(blk => blk.id === b.blockId);
999
- if (blockA !== blockB) return blockA - blockB;
1067
+ /**
1068
+ * Sort annotations in DOCUMENT order: by the block's position in `blocks`,
1069
+ * then by offset. Without blocks (a linked document whose text is unknown)
1070
+ * block ids compare numerically, so block-10 never precedes block-2.
1071
+ *
1072
+ * Comments made in the version diff view (`diff-block-N`) cannot be placed in
1073
+ * the document: N indexes the diff against whichever base was selected, and
1074
+ * nothing in the annotation maps it back to a block. They go together AFTER
1075
+ * every other comment, in diff order (which is itself document order, since
1076
+ * the diff walks the document). A list with no diff comments sorts exactly as
1077
+ * before.
1078
+ */
1079
+ const sortAnnotationsInDocumentOrder = (annotations: any[], blocks: Block[] | undefined): any[] => {
1080
+ const order = new Map<string, number>();
1081
+ blocks?.forEach((blk, index) => order.set(blk.id, index));
1082
+ return [...annotations].sort((a, b) => {
1083
+ const diffA = diffBlockIndex(a);
1084
+ const diffB = diffBlockIndex(b);
1085
+ if (diffA !== null || diffB !== null) {
1086
+ if (diffA === null) return -1;
1087
+ if (diffB === null) return 1;
1088
+ if (diffA !== diffB) return diffA - diffB;
1089
+ return a.startOffset - b.startOffset;
1090
+ }
1091
+ if (a.blockId !== b.blockId) {
1092
+ // Two ids missing from `blocks` share position -1; fall through to the
1093
+ // offset so the main document keeps its pre-#1696 order.
1094
+ const byBlock = blocks
1095
+ ? (order.get(a.blockId) ?? -1) - (order.get(b.blockId) ?? -1)
1096
+ : String(a.blockId ?? '').localeCompare(String(b.blockId ?? ''), undefined, { numeric: true });
1097
+ if (byBlock) return byBlock;
1098
+ }
1000
1099
  return a.startOffset - b.startOffset;
1001
1100
  });
1101
+ };
1002
1102
 
1003
- // One injection per export: a human-only skill referenced by several
1004
- // comments has its instructions injected once (see skillReferenceExportBlock).
1005
- const injectedSkills = new Set<string>();
1006
-
1007
- let output = `# ${title}\n\n`;
1008
-
1009
- if (opts.sourceConverted) {
1010
- output += `> Note: Line numbers below refer to the converted markdown, not the original HTML/URL source.\n\n`;
1011
- }
1012
-
1013
- output += answersSection;
1014
-
1015
- // Add global reference images section if any
1016
- if (globalAttachments.length > 0) {
1017
- output += `## Reference Images\n`;
1018
- output += `Please review these reference images (use the Read tool to view):\n`;
1019
- globalAttachments.forEach((img, idx) => {
1020
- output += `${idx + 1}. [${img.name}] \`${img.path}\`\n`;
1021
- });
1022
- output += `\n`;
1103
+ /** `Label Summary` heading (at `level`) tallying quick labels, or '' when none. */
1104
+ const labelSummaryBlock = (sortedAnns: any[], level: number): string => {
1105
+ const labeledAnns = sortedAnns.filter((a: any) => a.isQuickLabel && a.text);
1106
+ if (labeledAnns.length === 0) return '';
1107
+ const grouped = new Map<string, number>();
1108
+ labeledAnns.forEach((a: any) => {
1109
+ grouped.set(a.text, (grouped.get(a.text) || 0) + 1);
1110
+ });
1111
+ let output = `${'#'.repeat(level)} Label Summary\n\n`;
1112
+ for (const [text, count] of grouped) {
1113
+ output += `- **${text}**: ${count}\n`;
1023
1114
  }
1115
+ output += '\n';
1116
+ return output;
1117
+ };
1024
1118
 
1025
- if (annotations.length > 0) {
1026
- output += `I've reviewed this ${subject} and have ${annotations.length} piece${annotations.length > 1 ? 's' : ''} of feedback:\n\n`;
1027
- }
1119
+ /**
1120
+ * The numbered feedback entries of ONE document, shared by the root export
1121
+ * (`exportAnnotations`, entries at `##`) and every linked/folder document
1122
+ * (`exportLinkedDocAnnotations`, entries at `###` under the file heading), so
1123
+ * a document's comments read the same whichever section carries them: line
1124
+ * labels, `[In diff content]`, quick labels with their tip, element context,
1125
+ * skill references, images, and threaded replies.
1126
+ */
1127
+ const renderAnnotationEntries = (
1128
+ sortedAnns: any[],
1129
+ blocks: Block[] | undefined,
1130
+ opts: { subject: string; level: number; injectedSkills: Set<string> },
1131
+ ): string => {
1132
+ const { subject, injectedSkills } = opts;
1133
+ const hashes = (n: number) => '#'.repeat(n);
1134
+ let output = '';
1028
1135
 
1029
1136
  // Live app sessions stamp annotations with the page they were made on.
1030
1137
  // When any exported annotation carries a pageUrl, entries are grouped under
@@ -1036,7 +1143,7 @@ export const exportAnnotations = (
1036
1143
  // number of its position in the ungrouped order, matching the on-page
1037
1144
  // marker numbering, so grouped sections may show non-contiguous numbers.
1038
1145
  // With no pageUrl anywhere the output is byte-identical to the ungrouped
1039
- // export (`## N.` entries, no page headers).
1146
+ // export (entries at `level`, no page headers).
1040
1147
  const hasPageGroups = sortedAnns.some(
1041
1148
  (a: any) => typeof a.pageUrl === 'string' && a.pageUrl.length > 0,
1042
1149
  );
@@ -1117,16 +1224,16 @@ export const exportAnnotations = (
1117
1224
  let lastEmittedPage: string | null = null;
1118
1225
  emitOrder.forEach((ann) => {
1119
1226
  if (hasPageGroups && ann.pageUrl && ann.pageUrl !== lastEmittedPage) {
1120
- output += `## Page: ${ann.pageUrl}\n\n`;
1227
+ output += `${hashes(opts.level)} Page: ${ann.pageUrl}\n\n`;
1121
1228
  lastEmittedPage = ann.pageUrl;
1122
1229
  }
1123
- output += `${hasPageGroups ? '###' : '##'} ${annotationNumbers.get(ann)}. `;
1230
+ output += `${hashes(hasPageGroups ? opts.level + 1 : opts.level)} ${annotationNumbers.get(ann)}. `;
1124
1231
 
1125
1232
  // Add diff context label if annotation was created in diff view
1126
1233
  if (ann.diffContext) {
1127
1234
  output += `[In diff content] `;
1128
1235
  } else {
1129
- const lineLabel = lineLabelForAnnotation(blocks, ann);
1236
+ const lineLabel = blocks ? lineLabelForAnnotation(blocks, ann) : null;
1130
1237
  if (lineLabel) output += `(${lineLabel}) `;
1131
1238
  }
1132
1239
 
@@ -1188,23 +1295,62 @@ export const exportAnnotations = (
1188
1295
  output += '\n';
1189
1296
  });
1190
1297
 
1191
- output += `---\n`;
1298
+ return output;
1299
+ };
1192
1300
 
1193
- // Quick Label Summary
1194
- const labeledAnns = sortedAnns.filter((a: any) => a.isQuickLabel && a.text);
1195
- if (labeledAnns.length > 0) {
1196
- const grouped = new Map<string, number>();
1197
- labeledAnns.forEach((a: any) => {
1198
- grouped.set(a.text, (grouped.get(a.text) || 0) + 1);
1301
+ export const exportAnnotations = (
1302
+ blocks: Block[],
1303
+ allAnnotations: any[],
1304
+ globalAttachments: ImageAttachment[] = [],
1305
+ title: string = 'Plan Feedback',
1306
+ subject: string = 'plan',
1307
+ opts: ExportAnnotationsOptions = {},
1308
+ ): string => {
1309
+ // Answers to `:::question` blocks are printed first, in their own section,
1310
+ // and never counted as numbered feedback.
1311
+ const { answers, annotations } = splitQuestionAnswers(allAnnotations);
1312
+ const answersSection = answers.length > 0
1313
+ ? formatQuestionAnswersSection(questionExportItems(indexQuestionBlocks(blocks)), answers, { headingLevel: 2 })
1314
+ : '';
1315
+ if (annotations.length === 0 && globalAttachments.length === 0 && !answersSection) {
1316
+ return 'No changes detected.';
1317
+ }
1318
+
1319
+ const sortedAnns = sortAnnotationsInDocumentOrder(annotations, blocks);
1320
+
1321
+ // One injection per export: a human-only skill referenced by several
1322
+ // comments has its instructions injected once (see skillReferenceExportBlock).
1323
+ const injectedSkills = new Set<string>();
1324
+
1325
+ let output = `# ${title}\n\n`;
1326
+
1327
+ if (opts.sourceConverted) {
1328
+ output += `> Note: Line numbers below refer to the converted markdown, not the original HTML/URL source.\n\n`;
1329
+ }
1330
+
1331
+ output += answersSection;
1332
+
1333
+ // Add global reference images section if any
1334
+ if (globalAttachments.length > 0) {
1335
+ output += `## Reference Images\n`;
1336
+ output += `Please review these reference images (use the Read tool to view):\n`;
1337
+ globalAttachments.forEach((img, idx) => {
1338
+ output += `${idx + 1}. [${img.name}] \`${img.path}\`\n`;
1199
1339
  });
1340
+ output += `\n`;
1341
+ }
1200
1342
 
1201
- output += `\n## Label Summary\n\n`;
1202
- for (const [text, count] of grouped) {
1203
- output += `- **${text}**: ${count}\n`;
1204
- }
1205
- output += '\n';
1343
+ if (annotations.length > 0) {
1344
+ output += `I've reviewed this ${subject} and have ${annotations.length} piece${annotations.length > 1 ? 's' : ''} of feedback:\n\n`;
1206
1345
  }
1207
1346
 
1347
+ output += renderAnnotationEntries(sortedAnns, blocks, { subject, level: 2, injectedSkills });
1348
+
1349
+ output += `---\n`;
1350
+
1351
+ const summary = labelSummaryBlock(sortedAnns, 2);
1352
+ if (summary) output += `\n${summary}`;
1353
+
1208
1354
  return output;
1209
1355
  };
1210
1356
 
@@ -1216,10 +1362,33 @@ export interface LinkedDocAnnotationEntry {
1216
1362
  isConverted?: boolean;
1217
1363
  }
1218
1364
 
1365
+ /** Section heading for exportLinkedDocAnnotations. The default describes
1366
+ * documents a plan links to; a folder session's documents are not that. */
1367
+ export interface LinkedDocExportHeading {
1368
+ title: string;
1369
+ intro: string;
1370
+ }
1371
+
1372
+ export const LINKED_DOC_EXPORT_HEADING: LinkedDocExportHeading = {
1373
+ title: 'Linked Document Feedback',
1374
+ intro: 'The following feedback is on documents referenced in the plan.',
1375
+ };
1376
+
1377
+ export const FOLDER_DOC_EXPORT_HEADING: LinkedDocExportHeading = {
1378
+ title: 'Folder Document Feedback',
1379
+ intro: 'The following feedback is on files in the reviewed folder, grouped by file.',
1380
+ };
1381
+
1382
+ export const BUNDLE_DOC_EXPORT_HEADING: LinkedDocExportHeading = {
1383
+ title: 'File Feedback',
1384
+ intro: 'The following feedback is on the files under review, one section per file, in the order they were given for review.',
1385
+ };
1386
+
1219
1387
  export const exportLinkedDocAnnotations = (
1220
- docAnnotations: Map<string, LinkedDocAnnotationEntry>
1388
+ docAnnotations: Map<string, LinkedDocAnnotationEntry>,
1389
+ heading: LinkedDocExportHeading = LINKED_DOC_EXPORT_HEADING,
1221
1390
  ): string => {
1222
- let output = `\n# Linked Document Feedback\n\nThe following feedback is on documents referenced in the plan.\n\n`;
1391
+ let output = `\n# ${heading.title}\n\n${heading.intro}\n\n`;
1223
1392
 
1224
1393
  // One injection per export, across all linked documents.
1225
1394
  const injectedSkills = new Set<string>();
@@ -1247,57 +1416,14 @@ export const exportLinkedDocAnnotations = (
1247
1416
  output += `\n`;
1248
1417
  }
1249
1418
 
1250
- // Sort annotations by block and offset
1251
- const sortedAnns = [...annotations].sort((a, b) => {
1252
- if (a.blockId !== b.blockId) return a.blockId.localeCompare(b.blockId);
1253
- return a.startOffset - b.startOffset;
1254
- });
1419
+ const sortedAnns = sortAnnotationsInDocumentOrder(annotations, docBlocks);
1255
1420
 
1256
1421
  if (annotations.length > 0 || answers.length === 0) {
1257
1422
  output += `I've reviewed this document and have ${annotations.length} piece${annotations.length !== 1 ? 's' : ''} of feedback:\n\n`;
1258
1423
  }
1259
1424
 
1260
- sortedAnns.forEach((ann, index) => {
1261
- output += `### ${index + 1}. `;
1262
-
1263
- const lineLabel = docBlocks ? lineLabelForAnnotation(docBlocks, ann) : null;
1264
- if (lineLabel) output += `(${lineLabel}) `;
1265
-
1266
- switch (ann.type) {
1267
- case 'DELETION':
1268
- output += `Remove this\n`;
1269
- output += `\`\`\`\n${ann.originalText}\n\`\`\`\n`;
1270
- output += `> I don't want this in the document.\n`;
1271
- break;
1272
-
1273
- case 'COMMENT':
1274
- output += `${commentHeadingLine(ann)}\n`;
1275
- output += diagramLocationExportLine(ann);
1276
- output += `> ${ann.text}\n`;
1277
- break;
1278
-
1279
- case 'GLOBAL_COMMENT':
1280
- output += `General feedback about the document\n`;
1281
- output += `> ${ann.text}\n`;
1282
- break;
1283
- }
1284
-
1285
- output += elementContextExportBlock(ann, { includeRoute: false });
1286
- // Multi-target raw-HTML comments list every additional covered element.
1287
- output += additionalTargetsExportBlock(ann);
1288
-
1289
- // External (tool-sourced) comments list skills but never inject.
1290
- output += skillReferenceExportBlock(ann.text, injectedSkills, { external: !!ann.source });
1291
-
1292
- if (ann.images && ann.images.length > 0) {
1293
- output += `**Attached images:**\n`;
1294
- ann.images.forEach((img: ImageAttachment) => {
1295
- output += `- [${img.name}] \`${img.path}\`\n`;
1296
- });
1297
- }
1298
-
1299
- output += '\n';
1300
- });
1425
+ output += renderAnnotationEntries(sortedAnns, docBlocks, { subject: 'document', level: 3, injectedSkills });
1426
+ output += labelSummaryBlock(sortedAnns, 3);
1301
1427
  }
1302
1428
 
1303
1429
  output += `---\n`;
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import {
10
10
  buildQuestionAnswerAnnotation,
11
+ canonicalQuestionAnswer,
11
12
  indexQuestionBlocks,
12
13
  isQuestionAnswerEmpty,
13
14
  isQuestionAnswered,
@@ -144,7 +145,8 @@ export const buildQuestionPanelRows = (
144
145
  const seen = new Set<string>();
145
146
  for (const { blockId, number, line, question } of index) {
146
147
  seen.add(question.key);
147
- const answer = answers.get(question.key);
148
+ const stored = answers.get(question.key);
149
+ const answer = stored && canonicalQuestionAnswer(question, stored);
148
150
  const status = questionStatus(question, answer);
149
151
  const settledLabels = question.choices.filter((c) => c.settled).map((c) => oneLine(c.label));
150
152
  const answerText = status === 'answered' && answer
@@ -32,8 +32,8 @@ export const LABEL_COLOR_MAP: Record<string, { bg: string; text: string; darkTex
32
32
  };
33
33
 
34
34
  /**
35
- * The hardcoded one-click positive label behind the toolbar's 👍 button and
36
- * the composer's "Looks good" action. Deliberately NOT part of the
35
+ * The hardcoded one-click positive label behind the 👍 "Looks good" buttons
36
+ * (the selection toolbar's and the HTML pinpoint composer's). Deliberately NOT part of the
37
37
  * configurable set: it is the ONLY label comment-only surfaces (HTML /
38
38
  * live-app) may emit — their restricted handlers filter on this id.
39
39
  */
@@ -73,11 +73,44 @@ export function resolveSyntaxTheme(colorTheme: string, mode: 'dark' | 'light'):
73
73
  return { dark: map.dark || DEFAULT_SYNTAX_THEME.dark, light: map.light || DEFAULT_SYNTAX_THEME.light };
74
74
  }
75
75
 
76
+ /**
77
+ * Host override for the fence theme (`configurePlannotatorUI({ fenceTheme })`).
78
+ * Return a Shiki theme name to use it, or `undefined` to fall through to the
79
+ * default resolution below. A name Shiki does not know renders the block as
80
+ * plain text, exactly like an unknown fence language.
81
+ */
82
+ export type FenceThemeResolver = (colorTheme: string, mode: 'light' | 'dark') => string | undefined;
83
+
84
+ let fenceThemeResolver: FenceThemeResolver | null = null;
85
+
86
+ /** Install (or with `null`, remove) the host's fence theme override. */
87
+ export function setFenceThemeResolver(resolver: FenceThemeResolver | null): void {
88
+ fenceThemeResolver = resolver;
89
+ }
90
+
91
+ export function resetFenceThemeResolver(): void {
92
+ fenceThemeResolver = null;
93
+ }
94
+
76
95
  /**
77
96
  * The single concrete Shiki theme name for the palette currently on screen.
78
97
  * Markdown fences render one mode at a time, so unlike the diff pane (which
79
98
  * hands Pierre a dark/light pair and lets CSS pick) they want a resolved name.
99
+ *
100
+ * Every fence-style consumer resolves through here (`useFenceTheme`: Viewer
101
+ * fences, `CodeBlock`, the code-file hover preview, the plan diff view, code
102
+ * review's suggestion snippets), so a host override applies to all of them.
103
+ * The diff pane's dark/light pair (`resolveSyntaxTheme`) is not affected.
80
104
  */
81
105
  export function resolveFenceTheme(colorTheme: string, mode: 'dark' | 'light'): string {
106
+ if (fenceThemeResolver) {
107
+ let override: string | undefined;
108
+ try {
109
+ override = fenceThemeResolver(colorTheme, mode);
110
+ } catch {
111
+ override = undefined; // a throwing host resolver must not break rendering
112
+ }
113
+ if (typeof override === 'string' && override) return override;
114
+ }
82
115
  return resolveSyntaxTheme(colorTheme, mode)?.[mode] ?? DEFAULT_SYNTAX_THEME[mode];
83
116
  }