@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.
- package/HANDOFF.md +295 -11
- package/README.md +8 -0
- package/components/AISettingsTab.tsx +32 -0
- package/components/AgentToolAnnouncementDialog.tsx +347 -0
- package/components/AgentsTab.tsx +9 -9
- package/components/AnnotationToolbar.tsx +2 -2
- package/components/AskSessionAnnouncementDialog.tsx +329 -0
- package/components/BlockRenderer.tsx +10 -1
- package/components/CommentPopover.tsx +36 -18
- package/components/CompletionOverlay.tsx +12 -5
- package/components/DecisionControl.tsx +2 -7
- package/components/DiagramBlock.tsx +46 -4
- package/components/DocBadges.tsx +12 -8
- package/components/FloatingQuickLabelPicker.tsx +2 -2
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/QuickLabelDropdown.tsx +9 -4
- package/components/ServerSessionReplacedBanner.tsx +29 -0
- package/components/Settings.tsx +76 -192
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/Viewer.tsx +30 -1
- package/components/ai/AIProviderBar.tsx +18 -1
- package/components/ai/DocumentAIChatPanel.tsx +4 -9
- package/components/ai/SessionAskNotice.tsx +33 -21
- package/components/blocks/QuestionBlock.tsx +99 -27
- package/components/blocks/QuestionContext.tsx +81 -0
- package/components/diagram/DiagramComposer.tsx +54 -3
- package/components/diagram/DiagramViewer.tsx +31 -2
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +13 -3
- package/components/html-viewer/bridge-script.asset.js +230 -14
- package/components/html-viewer/bridge-script.ts +230 -14
- package/components/html-viewer/useHtmlAnnotation.ts +36 -55
- package/components/settings/QuickLabelsTab.tsx +378 -0
- package/hooks/useAIChat.ts +83 -6
- package/hooks/useAIProviderActivation.ts +13 -0
- package/hooks/useAIProviderConfig.ts +3 -3
- package/hooks/useActiveSection.ts +3 -2
- package/hooks/useAgentToolSetting.ts +33 -0
- package/hooks/useAnnotationDraft.ts +53 -7
- package/hooks/useAnnotationHighlighter.ts +129 -5
- package/hooks/useExternalAnnotations.ts +21 -1
- package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
- package/hooks/useLatchedTrue.ts +15 -0
- package/hooks/useLinkedDoc.ts +64 -4
- package/hooks/useModelCatalogs.ts +9 -2
- package/package.json +2 -2
- package/styles.css +1 -1
- package/themes/gruvbox.css +6 -0
- package/types.ts +19 -0
- package/utils/agentClosed.ts +33 -0
- package/utils/agentToolAnnouncement.ts +109 -0
- package/utils/agentToolSetting.ts +108 -0
- package/utils/aiProvider.ts +13 -40
- package/utils/annotationOwnsHighlight.ts +27 -0
- package/utils/annotationScope.ts +15 -0
- package/utils/askSessionAnnouncement.ts +107 -0
- package/utils/blockTargeting.ts +61 -2
- package/utils/diagramAskAI.ts +161 -0
- package/utils/parser.ts +382 -114
- package/utils/questionAnswers.ts +3 -1
- package/utils/quickLabels.ts +82 -2
- 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
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
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
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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
|
-
|
|
1004
|
-
|
|
1005
|
-
const
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
|
|
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
|
-
|
|
1026
|
-
|
|
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 (
|
|
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 +=
|
|
1256
|
+
output += `${hashes(opts.level)} Page: ${ann.pageUrl}\n\n`;
|
|
1121
1257
|
lastEmittedPage = ann.pageUrl;
|
|
1122
1258
|
}
|
|
1123
|
-
output += `${hasPageGroups ?
|
|
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
|
|
1327
|
+
return output;
|
|
1328
|
+
};
|
|
1192
1329
|
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
-
|
|
1202
|
-
|
|
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#
|
|
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
|
-
|
|
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
|
|
1261
|
-
|
|
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;
|
package/utils/questionAnswers.ts
CHANGED
|
@@ -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
|
|
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
|