@plannotator/ui 0.50.0 → 0.52.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 (62) hide show
  1. package/HANDOFF.md +295 -11
  2. package/README.md +8 -0
  3. package/components/AISettingsTab.tsx +32 -0
  4. package/components/AgentToolAnnouncementDialog.tsx +347 -0
  5. package/components/AgentsTab.tsx +9 -9
  6. package/components/AnnotationToolbar.tsx +2 -2
  7. package/components/AskSessionAnnouncementDialog.tsx +329 -0
  8. package/components/BlockRenderer.tsx +10 -1
  9. package/components/CommentPopover.tsx +36 -18
  10. package/components/CompletionOverlay.tsx +12 -5
  11. package/components/DecisionControl.tsx +2 -7
  12. package/components/DiagramBlock.tsx +46 -4
  13. package/components/DocBadges.tsx +12 -8
  14. package/components/FloatingQuickLabelPicker.tsx +2 -2
  15. package/components/PlanHeaderMenu.tsx +1 -1
  16. package/components/QuickLabelDropdown.tsx +9 -4
  17. package/components/ServerSessionReplacedBanner.tsx +29 -0
  18. package/components/Settings.tsx +76 -192
  19. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  20. package/components/Viewer.tsx +30 -1
  21. package/components/ai/AIProviderBar.tsx +18 -1
  22. package/components/ai/DocumentAIChatPanel.tsx +4 -9
  23. package/components/ai/SessionAskNotice.tsx +33 -21
  24. package/components/blocks/QuestionBlock.tsx +99 -27
  25. package/components/blocks/QuestionContext.tsx +81 -0
  26. package/components/diagram/DiagramComposer.tsx +54 -3
  27. package/components/diagram/DiagramViewer.tsx +31 -2
  28. package/components/diagram/index.ts +1 -1
  29. package/components/html-viewer/HtmlViewer.tsx +13 -3
  30. package/components/html-viewer/bridge-script.asset.js +230 -14
  31. package/components/html-viewer/bridge-script.ts +230 -14
  32. package/components/html-viewer/useHtmlAnnotation.ts +36 -55
  33. package/components/settings/QuickLabelsTab.tsx +378 -0
  34. package/hooks/useAIChat.ts +83 -6
  35. package/hooks/useAIProviderActivation.ts +13 -0
  36. package/hooks/useAIProviderConfig.ts +3 -3
  37. package/hooks/useActiveSection.ts +3 -2
  38. package/hooks/useAgentToolSetting.ts +33 -0
  39. package/hooks/useAnnotationDraft.ts +53 -7
  40. package/hooks/useAnnotationHighlighter.ts +129 -5
  41. package/hooks/useExternalAnnotations.ts +21 -1
  42. package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
  43. package/hooks/useLatchedTrue.ts +15 -0
  44. package/hooks/useLinkedDoc.ts +64 -4
  45. package/hooks/useModelCatalogs.ts +9 -2
  46. package/package.json +2 -2
  47. package/styles.css +1 -1
  48. package/themes/gruvbox.css +6 -0
  49. package/types.ts +19 -0
  50. package/utils/agentClosed.ts +33 -0
  51. package/utils/agentToolAnnouncement.ts +109 -0
  52. package/utils/agentToolSetting.ts +108 -0
  53. package/utils/aiProvider.ts +13 -40
  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/parser.ts +382 -114
  60. package/utils/questionAnswers.ts +3 -1
  61. package/utils/quickLabels.ts +82 -2
  62. package/utils/serverSession.ts +85 -0
package/utils/parser.ts CHANGED
@@ -4,6 +4,7 @@ import { resolveReplyParents } from '@plannotator/core/annotation-threads';
4
4
  import { diagramAnchorLocationLine, parseDiagramAnchor } from '@plannotator/core/diagram-anchor';
5
5
  import {
6
6
  formatQuestionAnswerLines,
7
+ formatQuestionAnswerText,
7
8
  formatQuestionAnswersSection,
8
9
  indexQuestionBlocks,
9
10
  parseQuestionAnswer,
@@ -774,13 +775,41 @@ const isElementPlaceholderQuote = (text: unknown): boolean =>
774
775
  * element instead (`Feedback on the <nav> element — "Primary"`); every other
775
776
  * annotation keeps the quote line exactly as before. */
776
777
  const commentHeadingLine = (ann: any): string => {
778
+ const element = elementSubject(ann);
779
+ return element ? `Feedback on ${element}` : `Feedback on: "${ann.originalText}"`;
780
+ };
781
+
782
+ /** `the <nav> element — "Primary"` for a text-less pinpoint whose quote is
783
+ * the bridge's placeholder; null for every other annotation. */
784
+ const elementSubject = (ann: any): string | null => {
777
785
  const context = ann?.elementContext;
778
- if (context && typeof context.tag === 'string' && isElementPlaceholderQuote(ann.originalText)) {
779
- const tag = safeInline(context.tag, 32);
780
- const name = context.name ? safeInline(context.name, 120) : '';
781
- return `Feedback on the <${tag}> element${name ? ` — "${name}"` : ''}`;
786
+ if (!context || typeof context.tag !== 'string' || !isElementPlaceholderQuote(ann.originalText)) return null;
787
+ const tag = safeInline(context.tag, 32);
788
+ const name = context.name ? safeInline(context.name, 120) : '';
789
+ const file = elementSourceFileName(context);
790
+ return `the <${tag}> element${name ? ` — "${name}"` : ''}${file && file !== name ? ` (${file})` : ''}`;
791
+ };
792
+
793
+ /** The file a media element shows, so two nameless images read differently in a heading:
794
+ * `team.jpg` for `https://cdn.test/img/team.jpg?…`, `data:image/png` for an
795
+ * inline image. Empty when the context carries no source.
796
+ *
797
+ * `sourceName` is the bridge's own resolution (the one the composer quote
798
+ * uses: src, a lazy data-src, srcset, <picture>/<source>, poster), so the
799
+ * heading and the quote can never name different files. Contexts captured
800
+ * before that field existed fall back to the scrubbed `src` attribute. */
801
+ const elementSourceFileName = (context: any): string => {
802
+ if (typeof context?.sourceName === 'string' && context.sourceName.trim()) {
803
+ return safeInline(context.sourceName, 60);
782
804
  }
783
- return `Feedback on: "${ann.originalText}"`;
805
+ const attrs = Array.isArray(context?.attrs) ? context.attrs : [];
806
+ const pair = attrs.find((p: unknown) => Array.isArray(p) && p[0] === 'src' && typeof p[1] === 'string');
807
+ if (!pair) return '';
808
+ const value: string = pair[1].trim();
809
+ if (/^data:/i.test(value)) return safeInline(`data:${value.slice(5).split(/[;,]/)[0] || 'unknown'}`, 60);
810
+ const path = value.replace(/\?…$/, '').replace(/\/+$/, '');
811
+ const name = path.slice(path.lastIndexOf('/') + 1);
812
+ return safeInline(name || path, 60);
784
813
  };
785
814
 
786
815
  export interface ElementContextExportOptions {
@@ -882,6 +911,66 @@ const additionalTargetsExportBlock = (ann: any): string => {
882
911
  return block;
883
912
  };
884
913
 
914
+ /** A URL with its query and fragment replaced by `?…`, the way the bridge
915
+ * scrubs captured `href`/`src` values (tokens live in both). */
916
+ const scrubUrlQuery = (url: string): string => {
917
+ const mark = url.search(/[?#]/);
918
+ return mark < 0 ? url : `${url.slice(0, mark)}?…`;
919
+ };
920
+
921
+ /** Ask AI sends no query strings: the live-app route line drops its query
922
+ * (the persisted context keeps it, since that is the page that was reviewed). */
923
+ const withoutRouteQuery = (context: unknown): unknown => {
924
+ const page = (context as any)?.page;
925
+ if (!page || typeof page.url !== 'string') return context;
926
+ return { ...(context as object), page: { ...page, url: scrubUrlQuery(page.url) } };
927
+ };
928
+
929
+ /** The anchor's `href`/`src` selector rung carries the raw attribute value,
930
+ * query included (it must, to stay a working selector); Ask AI gets the
931
+ * value with the query scrubbed instead. */
932
+ const withoutSelectorQuery = (anchor: AskAIElementTarget['anchor']): AskAIElementTarget['anchor'] => {
933
+ if (!anchor || typeof anchor.selector !== 'string') return anchor;
934
+ const selector = anchor.selector.replace(
935
+ /(\[(?:href|src)=")((?:[^"\\]|\\.)*)(")/g,
936
+ (_m, open: string, value: string, close: string) => `${open}${scrubUrlQuery(value)}${close}`,
937
+ );
938
+ return { ...anchor, selector };
939
+ };
940
+
941
+ /** One pinpointed element as the Ask AI composer holds it (a draft target). */
942
+ export interface AskAIElementTarget {
943
+ text?: string;
944
+ anchor?: { selector?: string } | null;
945
+ context?: unknown;
946
+ }
947
+
948
+ /**
949
+ * The element identity behind an Ask AI question asked from a raw-HTML or
950
+ * live-app pinpoint: the same identity lines the feedback export prints
951
+ * (`elementContextExportBlock`), without the fenced outline (the costly part
952
+ * of a model turn). One entry per pinpointed element, numbered when the
953
+ * question covers several. Empty when no target carries element context, so
954
+ * a plain text selection asks exactly what it asked before.
955
+ */
956
+ export const elementIdentityForAskAI = (targets: readonly AskAIElementTarget[] | null | undefined): string => {
957
+ if (!Array.isArray(targets)) return '';
958
+ const blocks = targets
959
+ .map((target) => ({
960
+ text: typeof target?.text === 'string' ? safeInline(target.text, 200) : '',
961
+ lines: elementContextExportBlock(
962
+ { elementContext: withoutRouteQuery(target?.context), htmlAnchor: withoutSelectorQuery(target?.anchor) },
963
+ { includeOutline: false, includeRoute: true },
964
+ ).trim(),
965
+ }))
966
+ .filter((block) => block.lines);
967
+ if (blocks.length === 0) return '';
968
+ if (blocks.length === 1) return blocks[0].lines;
969
+ return blocks
970
+ .map((block, i) => `Element ${i + 1}${block.text ? `: "${block.text}"` : ''}\n${block.lines}`)
971
+ .join('\n\n');
972
+ };
973
+
885
974
  /**
886
975
  * One annotation rendered as a standalone feedback entry (no number, no
887
976
  * document heading), for hosts that surface a single annotation to an agent.
@@ -948,6 +1037,29 @@ const lineLabelForAnnotation = (blocks: Block[], ann: any): string | null => {
948
1037
  return `lines ${block.startLine}–${end}`;
949
1038
  };
950
1039
 
1040
+ /** The source lines a text selection covers, for an Ask AI question
1041
+ * (`AIQuestion.scope.lineStart` / `lineEnd`). `blockIds` are the blocks the
1042
+ * selection touches; the range runs from the first block's start line to
1043
+ * the last block's end line, so a selection inside one block names exactly
1044
+ * the span its annotation would export (`(line 41)` / `(lines 41–44)`).
1045
+ * Blocks without a line (diff blocks, unknown ids) are skipped; `null` when
1046
+ * none is left. */
1047
+ export const selectionSourceLines = (
1048
+ blocks: readonly Block[],
1049
+ blockIds: readonly string[],
1050
+ ): { lineStart: number; lineEnd: number } | null => {
1051
+ let lineStart = Infinity;
1052
+ let lineEnd = -Infinity;
1053
+ for (const id of blockIds) {
1054
+ if (id.startsWith('diff-block-')) continue;
1055
+ const block = blocks.find(b => b.id === id);
1056
+ if (!block || typeof block.startLine !== 'number' || block.startLine < 1) continue;
1057
+ lineStart = Math.min(lineStart, block.startLine);
1058
+ lineEnd = Math.max(lineEnd, blockEndLine(block), block.startLine);
1059
+ }
1060
+ return Number.isFinite(lineStart) ? { lineStart, lineEnd } : null;
1061
+ };
1062
+
951
1063
  /** Separate valid question answers from ordinary feedback. A row whose
952
1064
  * `questionAnswer` fails validation stays ordinary feedback (its one-line
953
1065
  * text still reads), so nothing is ever dropped. */
@@ -974,57 +1086,81 @@ const questionItemsFromAnswers = (answers: QuestionAnswer[]): QuestionExportItem
974
1086
  settled: false,
975
1087
  }));
976
1088
 
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
- }
1089
+ /** The diff-block index of a comment made in the plan/version diff view
1090
+ * (`blockId` `diff-block-N`), or null for every other annotation. */
1091
+ const diffBlockIndex = (ann: any): number | null => {
1092
+ const match = typeof ann?.blockId === 'string' ? /^diff-block-(\d+)$/.exec(ann.blockId) : null;
1093
+ return match ? Number(match[1]) : null;
1094
+ };
994
1095
 
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;
1096
+ /**
1097
+ * Sort annotations in DOCUMENT order: by the block's position in `blocks`,
1098
+ * then by offset. Without blocks (a linked document whose text is unknown)
1099
+ * block ids compare numerically, so block-10 never precedes block-2.
1100
+ *
1101
+ * Comments made in the version diff view (`diff-block-N`) cannot be placed in
1102
+ * the document: N indexes the diff against whichever base was selected, and
1103
+ * nothing in the annotation maps it back to a block. They go together AFTER
1104
+ * every other comment, in diff order (which is itself document order, since
1105
+ * the diff walks the document). A list with no diff comments sorts exactly as
1106
+ * before.
1107
+ */
1108
+ const sortAnnotationsInDocumentOrder = (annotations: any[], blocks: Block[] | undefined): any[] => {
1109
+ const order = new Map<string, number>();
1110
+ blocks?.forEach((blk, index) => order.set(blk.id, index));
1111
+ return [...annotations].sort((a, b) => {
1112
+ const diffA = diffBlockIndex(a);
1113
+ const diffB = diffBlockIndex(b);
1114
+ if (diffA !== null || diffB !== null) {
1115
+ if (diffA === null) return -1;
1116
+ if (diffB === null) return 1;
1117
+ if (diffA !== diffB) return diffA - diffB;
1118
+ return a.startOffset - b.startOffset;
1119
+ }
1120
+ if (a.blockId !== b.blockId) {
1121
+ // Two ids missing from `blocks` share position -1; fall through to the
1122
+ // offset so the main document keeps its pre-#1696 order.
1123
+ const byBlock = blocks
1124
+ ? (order.get(a.blockId) ?? -1) - (order.get(b.blockId) ?? -1)
1125
+ : String(a.blockId ?? '').localeCompare(String(b.blockId ?? ''), undefined, { numeric: true });
1126
+ if (byBlock) return byBlock;
1127
+ }
1000
1128
  return a.startOffset - b.startOffset;
1001
1129
  });
1130
+ };
1002
1131
 
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`;
1132
+ /** `Label Summary` heading (at `level`) tallying quick labels, or '' when none. */
1133
+ const labelSummaryBlock = (sortedAnns: any[], level: number): string => {
1134
+ const labeledAnns = sortedAnns.filter((a: any) => a.isQuickLabel && a.text);
1135
+ if (labeledAnns.length === 0) return '';
1136
+ const grouped = new Map<string, number>();
1137
+ labeledAnns.forEach((a: any) => {
1138
+ grouped.set(a.text, (grouped.get(a.text) || 0) + 1);
1139
+ });
1140
+ let output = `${'#'.repeat(level)} Label Summary\n\n`;
1141
+ for (const [text, count] of grouped) {
1142
+ output += `- **${text}**: ${count}\n`;
1023
1143
  }
1144
+ output += '\n';
1145
+ return output;
1146
+ };
1024
1147
 
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
- }
1148
+ /**
1149
+ * The numbered feedback entries of ONE document, shared by the root export
1150
+ * (`exportAnnotations`, entries at `##`) and every linked/folder document
1151
+ * (`exportLinkedDocAnnotations`, entries at `###` under the file heading), so
1152
+ * a document's comments read the same whichever section carries them: line
1153
+ * labels, `[In diff content]`, quick labels with their tip, element context,
1154
+ * skill references, images, and threaded replies.
1155
+ */
1156
+ const renderAnnotationEntries = (
1157
+ sortedAnns: any[],
1158
+ blocks: Block[] | undefined,
1159
+ opts: { subject: string; level: number; injectedSkills: Set<string> },
1160
+ ): string => {
1161
+ const { subject, injectedSkills } = opts;
1162
+ const hashes = (n: number) => '#'.repeat(n);
1163
+ let output = '';
1028
1164
 
1029
1165
  // Live app sessions stamp annotations with the page they were made on.
1030
1166
  // When any exported annotation carries a pageUrl, entries are grouped under
@@ -1036,7 +1172,7 @@ export const exportAnnotations = (
1036
1172
  // number of its position in the ungrouped order, matching the on-page
1037
1173
  // marker numbering, so grouped sections may show non-contiguous numbers.
1038
1174
  // With no pageUrl anywhere the output is byte-identical to the ungrouped
1039
- // export (`## N.` entries, no page headers).
1175
+ // export (entries at `level`, no page headers).
1040
1176
  const hasPageGroups = sortedAnns.some(
1041
1177
  (a: any) => typeof a.pageUrl === 'string' && a.pageUrl.length > 0,
1042
1178
  );
@@ -1117,16 +1253,16 @@ export const exportAnnotations = (
1117
1253
  let lastEmittedPage: string | null = null;
1118
1254
  emitOrder.forEach((ann) => {
1119
1255
  if (hasPageGroups && ann.pageUrl && ann.pageUrl !== lastEmittedPage) {
1120
- output += `## Page: ${ann.pageUrl}\n\n`;
1256
+ output += `${hashes(opts.level)} Page: ${ann.pageUrl}\n\n`;
1121
1257
  lastEmittedPage = ann.pageUrl;
1122
1258
  }
1123
- output += `${hasPageGroups ? '###' : '##'} ${annotationNumbers.get(ann)}. `;
1259
+ output += `${hashes(hasPageGroups ? opts.level + 1 : opts.level)} ${annotationNumbers.get(ann)}. `;
1124
1260
 
1125
1261
  // Add diff context label if annotation was created in diff view
1126
1262
  if (ann.diffContext) {
1127
1263
  output += `[In diff content] `;
1128
1264
  } else {
1129
- const lineLabel = lineLabelForAnnotation(blocks, ann);
1265
+ const lineLabel = blocks ? lineLabelForAnnotation(blocks, ann) : null;
1130
1266
  if (lineLabel) output += `(${lineLabel}) `;
1131
1267
  }
1132
1268
 
@@ -1188,23 +1324,62 @@ export const exportAnnotations = (
1188
1324
  output += '\n';
1189
1325
  });
1190
1326
 
1191
- output += `---\n`;
1327
+ return output;
1328
+ };
1192
1329
 
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);
1330
+ export const exportAnnotations = (
1331
+ blocks: Block[],
1332
+ allAnnotations: any[],
1333
+ globalAttachments: ImageAttachment[] = [],
1334
+ title: string = 'Plan Feedback',
1335
+ subject: string = 'plan',
1336
+ opts: ExportAnnotationsOptions = {},
1337
+ ): string => {
1338
+ // Answers to `:::question` blocks are printed first, in their own section,
1339
+ // and never counted as numbered feedback.
1340
+ const { answers, annotations } = splitQuestionAnswers(allAnnotations);
1341
+ const answersSection = answers.length > 0
1342
+ ? formatQuestionAnswersSection(questionExportItems(indexQuestionBlocks(blocks)), answers, { headingLevel: 2 })
1343
+ : '';
1344
+ if (annotations.length === 0 && globalAttachments.length === 0 && !answersSection) {
1345
+ return 'No changes detected.';
1346
+ }
1347
+
1348
+ const sortedAnns = sortAnnotationsInDocumentOrder(annotations, blocks);
1349
+
1350
+ // One injection per export: a human-only skill referenced by several
1351
+ // comments has its instructions injected once (see skillReferenceExportBlock).
1352
+ const injectedSkills = new Set<string>();
1353
+
1354
+ let output = `# ${title}\n\n`;
1355
+
1356
+ if (opts.sourceConverted) {
1357
+ output += `> Note: Line numbers below refer to the converted markdown, not the original HTML/URL source.\n\n`;
1358
+ }
1359
+
1360
+ output += answersSection;
1361
+
1362
+ // Add global reference images section if any
1363
+ if (globalAttachments.length > 0) {
1364
+ output += `## Reference Images\n`;
1365
+ output += `Please review these reference images (use the Read tool to view):\n`;
1366
+ globalAttachments.forEach((img, idx) => {
1367
+ output += `${idx + 1}. [${img.name}] \`${img.path}\`\n`;
1199
1368
  });
1369
+ output += `\n`;
1370
+ }
1200
1371
 
1201
- output += `\n## Label Summary\n\n`;
1202
- for (const [text, count] of grouped) {
1203
- output += `- **${text}**: ${count}\n`;
1204
- }
1205
- output += '\n';
1372
+ if (annotations.length > 0) {
1373
+ output += `I've reviewed this ${subject} and have ${annotations.length} piece${annotations.length > 1 ? 's' : ''} of feedback:\n\n`;
1206
1374
  }
1207
1375
 
1376
+ output += renderAnnotationEntries(sortedAnns, blocks, { subject, level: 2, injectedSkills });
1377
+
1378
+ output += `---\n`;
1379
+
1380
+ const summary = labelSummaryBlock(sortedAnns, 2);
1381
+ if (summary) output += `\n${summary}`;
1382
+
1208
1383
  return output;
1209
1384
  };
1210
1385
 
@@ -1216,10 +1391,33 @@ export interface LinkedDocAnnotationEntry {
1216
1391
  isConverted?: boolean;
1217
1392
  }
1218
1393
 
1394
+ /** Section heading for exportLinkedDocAnnotations. The default describes
1395
+ * documents a plan links to; a folder session's documents are not that. */
1396
+ export interface LinkedDocExportHeading {
1397
+ title: string;
1398
+ intro: string;
1399
+ }
1400
+
1401
+ export const LINKED_DOC_EXPORT_HEADING: LinkedDocExportHeading = {
1402
+ title: 'Linked Document Feedback',
1403
+ intro: 'The following feedback is on documents referenced in the plan.',
1404
+ };
1405
+
1406
+ export const FOLDER_DOC_EXPORT_HEADING: LinkedDocExportHeading = {
1407
+ title: 'Folder Document Feedback',
1408
+ intro: 'The following feedback is on files in the reviewed folder, grouped by file.',
1409
+ };
1410
+
1411
+ export const BUNDLE_DOC_EXPORT_HEADING: LinkedDocExportHeading = {
1412
+ title: 'File Feedback',
1413
+ intro: 'The following feedback is on the files under review, one section per file, in the order they were given for review.',
1414
+ };
1415
+
1219
1416
  export const exportLinkedDocAnnotations = (
1220
- docAnnotations: Map<string, LinkedDocAnnotationEntry>
1417
+ docAnnotations: Map<string, LinkedDocAnnotationEntry>,
1418
+ heading: LinkedDocExportHeading = LINKED_DOC_EXPORT_HEADING,
1221
1419
  ): string => {
1222
- let output = `\n# Linked Document Feedback\n\nThe following feedback is on documents referenced in the plan.\n\n`;
1420
+ let output = `\n# ${heading.title}\n\n${heading.intro}\n\n`;
1223
1421
 
1224
1422
  // One injection per export, across all linked documents.
1225
1423
  const injectedSkills = new Set<string>();
@@ -1247,57 +1445,14 @@ export const exportLinkedDocAnnotations = (
1247
1445
  output += `\n`;
1248
1446
  }
1249
1447
 
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
- });
1448
+ const sortedAnns = sortAnnotationsInDocumentOrder(annotations, docBlocks);
1255
1449
 
1256
1450
  if (annotations.length > 0 || answers.length === 0) {
1257
1451
  output += `I've reviewed this document and have ${annotations.length} piece${annotations.length !== 1 ? 's' : ''} of feedback:\n\n`;
1258
1452
  }
1259
1453
 
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
- });
1454
+ output += renderAnnotationEntries(sortedAnns, docBlocks, { subject: 'document', level: 3, injectedSkills });
1455
+ output += labelSummaryBlock(sortedAnns, 3);
1301
1456
  }
1302
1457
 
1303
1458
  output += `---\n`;
@@ -1367,6 +1522,119 @@ export const exportCodeFileAnnotations = (annotations: CodeAnnotation[]): string
1367
1522
  return output;
1368
1523
  };
1369
1524
 
1525
+ /** What `formatDraftAnnotationsForAsk` lists: the same collections the
1526
+ * feedback export reads, before the reviewer submits them. */
1527
+ export interface DraftAnnotationsForAsk {
1528
+ /** The session's own document (plan, file, message). */
1529
+ annotations: readonly Annotation[];
1530
+ blocks?: Block[];
1531
+ globalAttachments?: readonly ImageAttachment[];
1532
+ /** Every other document holding drafts, by path. */
1533
+ documents?: ReadonlyMap<string, LinkedDocAnnotationEntry>;
1534
+ codeAnnotations?: readonly CodeAnnotation[];
1535
+ editorAnnotations?: readonly EditorAnnotation[];
1536
+ }
1537
+
1538
+ const MAX_ASK_DRAFT_ENTRIES = 60;
1539
+ const ASK_DRAFT_QUOTE_CHARS = 160;
1540
+ const ASK_DRAFT_TEXT_CHARS = 1000;
1541
+
1542
+ /**
1543
+ * The reviewer's UNSUBMITTED annotations as a plain list, for an "Ask this
1544
+ * session" question (#1748). It is deliberately not the feedback export: no
1545
+ * title, no "I've reviewed this … and have N pieces of feedback", no numbered
1546
+ * "Feedback on" headings, no injected skill instructions. The session that
1547
+ * reads it is the agent the review is for, so a draft must read as something
1548
+ * the reviewer is still writing, not as a request. The server wraps the list
1549
+ * in a read-only frame (`SESSION_ASK_DRAFTS_LABEL`); this function only lists.
1550
+ *
1551
+ * One line per draft (`Draft 2 (line 14): comment on "…" — …`), numbered in
1552
+ * document order so a question can say "draft 2"; every page-, file- or
1553
+ * reviewer-supplied string is collapsed to one line and capped. Empty string
1554
+ * when there are no drafts.
1555
+ */
1556
+ export const formatDraftAnnotationsForAsk = (input: DraftAnnotationsForAsk): string => {
1557
+ type Row = { ann: any; where: string | null };
1558
+ const rows: Row[] = [];
1559
+ const placeOf = (blocks: Block[] | undefined, ann: any): string | null => {
1560
+ if (ann.diffContext) return 'in the version diff';
1561
+ return blocks ? lineLabelForAnnotation(blocks, ann) : null;
1562
+ };
1563
+ for (const ann of sortAnnotationsInDocumentOrder([...input.annotations], input.blocks)) {
1564
+ rows.push({ ann, where: placeOf(input.blocks, ann) });
1565
+ }
1566
+ for (const [path, doc] of input.documents ?? []) {
1567
+ for (const ann of sortAnnotationsInDocumentOrder([...doc.annotations], doc.blocks)) {
1568
+ const line = placeOf(doc.blocks, ann);
1569
+ rows.push({ ann, where: line ? `${safeInline(path, 300)}, ${line}` : safeInline(path, 300) });
1570
+ }
1571
+ }
1572
+
1573
+ const numbers = new Map<string, number>();
1574
+ rows.forEach((row, index) => {
1575
+ if (typeof row.ann?.id === 'string') numbers.set(row.ann.id, index + 1);
1576
+ });
1577
+ const replyParents = resolveReplyParents(rows.map((row) => row.ann));
1578
+ const quote = (value: unknown) => `"${safeInline(value, ASK_DRAFT_QUOTE_CHARS)}"`;
1579
+ const text = (value: unknown) => safeInline(value, ASK_DRAFT_TEXT_CHARS);
1580
+ const imageList = (list: readonly ImageAttachment[]): string =>
1581
+ list.map((img) => `[${safeInline(img?.name, 80)}] ${safeInline(img?.path, 300)}`).join(', ');
1582
+ const images = (list: unknown): string =>
1583
+ Array.isArray(list) && list.length > 0 ? ` (attached: ${imageList(list)})` : '';
1584
+
1585
+ const lines: string[] = [];
1586
+ rows.forEach(({ ann, where }, index) => {
1587
+ const parent = replyParents.get(ann.id);
1588
+ const parentNumber = parent ? numbers.get(parent) : undefined;
1589
+ const head = `Draft ${index + 1}${where ? ` (${where})` : ''}:`;
1590
+ const from = ann.source ? ` (from ${safeInline(ann.source, 60)})` : '';
1591
+ const answer = ann.questionAnswer == null ? null : parseQuestionAnswer(ann.questionAnswer);
1592
+ let body: string;
1593
+ if (answer) {
1594
+ body = `answer to the question ${quote(answer.prompt)} — ${text(formatQuestionAnswerText(answer))}`;
1595
+ } else if (parentNumber !== undefined) {
1596
+ body = `reply to draft ${parentNumber} — ${text(ann.text)}`;
1597
+ } else if (ann.type === 'DELETION') {
1598
+ body = `suggests removing ${quote(ann.originalText)}`;
1599
+ } else if (ann.type === 'GLOBAL_COMMENT') {
1600
+ body = `general note — ${text(ann.text)}`;
1601
+ } else {
1602
+ const subject = elementSubject(ann) ?? quote(ann.originalText);
1603
+ const anchor = ann.diagramAnchor === undefined ? null : parseDiagramAnchor(ann.diagramAnchor);
1604
+ const target = anchor ? `${subject} [${safeInline(diagramAnchorLocationLine(anchor), 300)}]` : subject;
1605
+ body = ann.isQuickLabel
1606
+ ? `label ${quote(ann.text)} on ${target}`
1607
+ : `comment on ${target} — ${text(ann.text)}`;
1608
+ }
1609
+ lines.push(`${head} ${body}${from}${images(ann.images)}`);
1610
+ });
1611
+
1612
+ let next = rows.length;
1613
+ for (const ann of input.codeAnnotations ?? []) {
1614
+ next += 1;
1615
+ const range = ann.lineStart === ann.lineEnd ? `line ${ann.lineStart}` : `lines ${ann.lineStart}–${ann.lineEnd}`;
1616
+ const on = ann.originalCode ? ` on ${quote(ann.originalCode)}` : '';
1617
+ lines.push(`Draft ${next} (${safeInline(ann.filePath, 300)}, ${range}): comment${on} — ${text(ann.text)}${ann.source ? ` (from ${safeInline(ann.source, 60)})` : ''}${images(ann.images)}`);
1618
+ }
1619
+ for (const ann of input.editorAnnotations ?? []) {
1620
+ next += 1;
1621
+ const range = ann.lineStart === ann.lineEnd ? `line ${ann.lineStart}` : `lines ${ann.lineStart}–${ann.lineEnd}`;
1622
+ lines.push(`Draft ${next} (${safeInline(ann.filePath, 300)}, ${range}): comment on ${quote(ann.selectedText)} — ${text(ann.comment)}`);
1623
+ }
1624
+
1625
+ const shown = lines.slice(0, MAX_ASK_DRAFT_ENTRIES);
1626
+ if (lines.length > shown.length) shown.push(`… and ${lines.length - shown.length} more draft annotations`);
1627
+
1628
+ const attachments = input.globalAttachments ?? [];
1629
+ if (attachments.length > 0) shown.push(`Reference images: ${imageList(attachments)}`);
1630
+ for (const [path, doc] of input.documents ?? []) {
1631
+ if (doc.globalAttachments.length > 0) {
1632
+ shown.push(`Reference images (${safeInline(path, 300)}): ${imageList(doc.globalAttachments)}`);
1633
+ }
1634
+ }
1635
+ return shown.join('\n');
1636
+ };
1637
+
1370
1638
  export interface MessageAnnotationEntry {
1371
1639
  messageId: string;
1372
1640
  text: string;
@@ -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