@plannotator/ui 0.50.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.
- package/HANDOFF.md +144 -11
- package/components/AISettingsTab.tsx +32 -0
- package/components/AgentToolAnnouncementDialog.tsx +347 -0
- package/components/AgentsTab.tsx +9 -9
- package/components/AskSessionAnnouncementDialog.tsx +329 -0
- package/components/CommentPopover.tsx +32 -18
- package/components/DecisionControl.tsx +2 -7
- package/components/DiagramBlock.tsx +46 -4
- package/components/DocBadges.tsx +12 -8
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/Settings.tsx +73 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/Viewer.tsx +4 -0
- 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 +24 -19
- 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 +124 -8
- package/components/html-viewer/bridge-script.ts +124 -8
- package/components/html-viewer/useHtmlAnnotation.ts +36 -55
- package/hooks/useAIChat.ts +26 -5
- 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 +101 -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 +13 -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 +236 -110
- package/utils/questionAnswers.ts +3 -1
- package/utils/quickLabels.ts +2 -2
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import {
|
|
2
|
+
diagramAnchorLocationLine,
|
|
3
|
+
diagramTargetName,
|
|
4
|
+
diagramTargetText,
|
|
5
|
+
type DiagramAnchor,
|
|
6
|
+
type DiagramKind,
|
|
7
|
+
type DiagramTarget,
|
|
8
|
+
} from '@plannotator/core/diagram-anchor';
|
|
9
|
+
import type { CommentAskAIContext } from '../components/CommentPopover';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Ask AI from a diagram comment: the question rides the same
|
|
13
|
+
* `CommentAskAIContext` the markdown and html composers send, so the host's
|
|
14
|
+
* one Ask AI path (an SDK provider, "Ask this session", the agent terminal)
|
|
15
|
+
* carries it unchanged. `text` is the part's label (what the chat shows under
|
|
16
|
+
* the question); `detail` is the agent-facing identity, which
|
|
17
|
+
* `buildDefaultPrompt` puts on the USER message before the question, never in
|
|
18
|
+
* the system prompt:
|
|
19
|
+
*
|
|
20
|
+
* Diagram node Approve? (D), line 7 <- the export's location line
|
|
21
|
+
* Diagram type: Mermaid (flowchart)
|
|
22
|
+
* Diagram source, document lines 6–9:
|
|
23
|
+
* ````mermaid
|
|
24
|
+
* …
|
|
25
|
+
* ````
|
|
26
|
+
*
|
|
27
|
+
* The source is bounded (a window around the part when the diagram is long),
|
|
28
|
+
* so a huge whole-file diagram never floods a turn.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const ENGINE_LABELS: Record<DiagramKind, string> = { mermaid: 'Mermaid', graphviz: 'Graphviz' };
|
|
32
|
+
const FENCE_LANGUAGE: Record<DiagramKind, string> = { mermaid: 'mermaid', graphviz: 'dot' };
|
|
33
|
+
|
|
34
|
+
/** Most diagram source lines a question carries. */
|
|
35
|
+
export const MAX_ASK_AI_DIAGRAM_SOURCE_LINES = 40;
|
|
36
|
+
/** Most diagram source characters a question carries. */
|
|
37
|
+
export const MAX_ASK_AI_DIAGRAM_SOURCE_CHARS = 4000;
|
|
38
|
+
|
|
39
|
+
export interface DiagramAskAIInput {
|
|
40
|
+
readonly kind: DiagramKind;
|
|
41
|
+
/** The part the question is about; `sourceLine` names DOCUMENT lines. */
|
|
42
|
+
readonly anchor: DiagramAnchor;
|
|
43
|
+
/** Shift-click targets the draft also covers (absent or empty for most). */
|
|
44
|
+
readonly additionalTargets?: readonly DiagramTarget[];
|
|
45
|
+
/** The diagram text (the fence body, or the whole file). */
|
|
46
|
+
readonly source: string;
|
|
47
|
+
/** Lines to add to a diagram line to name the document line: the fence's
|
|
48
|
+
* opening line for a fence, 0 when the document is the diagram. */
|
|
49
|
+
readonly sourceLineOffset: number;
|
|
50
|
+
/** The document the diagram lives in, as the other composers send it. */
|
|
51
|
+
readonly sourcePath?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A fence that no backtick run in the source can close early. */
|
|
55
|
+
function fenceFor(source: string): string {
|
|
56
|
+
let longest = 0;
|
|
57
|
+
for (const run of source.match(/`+/g) ?? []) longest = Math.max(longest, run.length);
|
|
58
|
+
return '`'.repeat(Math.max(4, longest + 1));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A line longer than the whole budget is cut, with a visible marker. */
|
|
62
|
+
const TRUNCATION_MARKER = '…';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The bounded slice of the source a question carries, grown outward from the
|
|
66
|
+
* part's line (the top of the diagram when the part has no line), one line
|
|
67
|
+
* below then one above, until the line cap or the character cap stops a
|
|
68
|
+
* side. The part's own line is always in it; a part line longer than the
|
|
69
|
+
* whole character budget is cut with a visible marker, so the slice never
|
|
70
|
+
* exceeds MAX_ASK_AI_DIAGRAM_SOURCE_CHARS (newlines included). Returns
|
|
71
|
+
* 1-based DIAGRAM lines and the text to quote.
|
|
72
|
+
*/
|
|
73
|
+
function sourceWindow(
|
|
74
|
+
lines: readonly string[],
|
|
75
|
+
anchor: DiagramAnchor,
|
|
76
|
+
offset: number,
|
|
77
|
+
): { first: number; last: number; body: string; excerpt: boolean } | null {
|
|
78
|
+
let wholeFirst = 1;
|
|
79
|
+
let wholeLast = lines.length;
|
|
80
|
+
while (wholeLast > 0 && (lines[wholeLast - 1] ?? '').trim() === '') wholeLast -= 1;
|
|
81
|
+
while (wholeFirst <= wholeLast && (lines[wholeFirst - 1] ?? '').trim() === '') wholeFirst += 1;
|
|
82
|
+
if (wholeLast === 0 || wholeFirst > wholeLast) return null;
|
|
83
|
+
|
|
84
|
+
const partLine = anchor.kind !== 'diagram' && anchor.sourceLine !== null ? anchor.sourceLine[0] - offset : null;
|
|
85
|
+
const center = partLine !== null && partLine >= wholeFirst && partLine <= wholeLast ? partLine : wholeFirst;
|
|
86
|
+
|
|
87
|
+
let centerText = lines[center - 1] ?? '';
|
|
88
|
+
let truncated = false;
|
|
89
|
+
if (centerText.length > MAX_ASK_AI_DIAGRAM_SOURCE_CHARS) {
|
|
90
|
+
centerText = centerText.slice(0, MAX_ASK_AI_DIAGRAM_SOURCE_CHARS - TRUNCATION_MARKER.length) + TRUNCATION_MARKER;
|
|
91
|
+
truncated = true;
|
|
92
|
+
}
|
|
93
|
+
let chars = centerText.length;
|
|
94
|
+
let count = 1;
|
|
95
|
+
let first = center;
|
|
96
|
+
let last = center;
|
|
97
|
+
let growDown = true;
|
|
98
|
+
let growUp = true;
|
|
99
|
+
while ((growDown || growUp) && count < MAX_ASK_AI_DIAGRAM_SOURCE_LINES) {
|
|
100
|
+
if (growDown) {
|
|
101
|
+
const next = last + 1;
|
|
102
|
+
const cost = (lines[next - 1] ?? '').length + 1;
|
|
103
|
+
if (next > wholeLast || chars + cost > MAX_ASK_AI_DIAGRAM_SOURCE_CHARS) growDown = false;
|
|
104
|
+
else {
|
|
105
|
+
last = next;
|
|
106
|
+
chars += cost;
|
|
107
|
+
count += 1;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (growUp && count < MAX_ASK_AI_DIAGRAM_SOURCE_LINES) {
|
|
111
|
+
const next = first - 1;
|
|
112
|
+
const cost = (lines[next - 1] ?? '').length + 1;
|
|
113
|
+
if (next < wholeFirst || chars + cost > MAX_ASK_AI_DIAGRAM_SOURCE_CHARS) growUp = false;
|
|
114
|
+
else {
|
|
115
|
+
first = next;
|
|
116
|
+
chars += cost;
|
|
117
|
+
count += 1;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const body = [...lines.slice(first - 1, center - 1), centerText, ...lines.slice(center, last)].join('\n');
|
|
122
|
+
return { first, last, body, excerpt: truncated || first > wholeFirst || last < wholeLast };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The agent-facing identity of a diagram part (the `detail` Ask AI sends). */
|
|
126
|
+
export function diagramIdentityForAskAI(input: DiagramAskAIInput): string {
|
|
127
|
+
const { kind, anchor, source, sourceLineOffset } = input;
|
|
128
|
+
const out: string[] = [diagramAnchorLocationLine(anchor)];
|
|
129
|
+
for (const extra of input.additionalTargets ?? []) {
|
|
130
|
+
const text = diagramTargetText(extra);
|
|
131
|
+
out.push(`Also: ${diagramTargetName(extra)}${text !== '' && text !== extra.id ? ` "${text}"` : ''}`);
|
|
132
|
+
}
|
|
133
|
+
out.push(`Diagram type: ${ENGINE_LABELS[kind]} (${anchor.family})`);
|
|
134
|
+
|
|
135
|
+
const lines = source.split('\n');
|
|
136
|
+
const window = sourceWindow(lines, anchor, sourceLineOffset);
|
|
137
|
+
if (window !== null) {
|
|
138
|
+
const body = window.body;
|
|
139
|
+
const docFirst = window.first + sourceLineOffset;
|
|
140
|
+
const docLast = window.last + sourceLineOffset;
|
|
141
|
+
const range = docLast > docFirst ? `lines ${docFirst}–${docLast}` : `line ${docFirst}`;
|
|
142
|
+
const partial = window.excerpt ? ' (excerpt)' : '';
|
|
143
|
+
const fence = fenceFor(body);
|
|
144
|
+
out.push(`Diagram source${partial}, document ${range}:`);
|
|
145
|
+
out.push(`${fence}${FENCE_LANGUAGE[kind]}\n${body}\n${fence}`);
|
|
146
|
+
}
|
|
147
|
+
return out.join('\n');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The Ask AI context for a question asked from a diagram comment. */
|
|
151
|
+
export function diagramAskAIContext(input: DiagramAskAIInput): CommentAskAIContext {
|
|
152
|
+
const { kind, anchor } = input;
|
|
153
|
+
const engine = ENGINE_LABELS[kind];
|
|
154
|
+
return {
|
|
155
|
+
kind: 'selection',
|
|
156
|
+
label: anchor.kind === 'diagram' ? `${engine} diagram` : `${engine} diagram ${diagramTargetName(anchor)}`,
|
|
157
|
+
text: diagramTargetText(anchor),
|
|
158
|
+
...(input.sourcePath !== undefined ? { sourcePath: input.sourcePath } : {}),
|
|
159
|
+
detail: diagramIdentityForAskAI(input),
|
|
160
|
+
};
|
|
161
|
+
}
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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
|
-
|
|
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`;
|
|
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
|
-
|
|
1026
|
-
|
|
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 (
|
|
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 +=
|
|
1227
|
+
output += `${hashes(opts.level)} Page: ${ann.pageUrl}\n\n`;
|
|
1121
1228
|
lastEmittedPage = ann.pageUrl;
|
|
1122
1229
|
}
|
|
1123
|
-
output += `${hasPageGroups ?
|
|
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
|
|
1298
|
+
return output;
|
|
1299
|
+
};
|
|
1192
1300
|
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
-
|
|
1202
|
-
|
|
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#
|
|
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
|
-
|
|
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
|
|
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
|
-
});
|
|
1425
|
+
output += renderAnnotationEntries(sortedAnns, docBlocks, { subject: 'document', level: 3, injectedSkills });
|
|
1426
|
+
output += labelSummaryBlock(sortedAnns, 3);
|
|
1301
1427
|
}
|
|
1302
1428
|
|
|
1303
1429
|
output += `---\n`;
|
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
|
package/utils/quickLabels.ts
CHANGED
|
@@ -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
|
|
36
|
-
* 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
|
*/
|