@plannotator/ui 0.38.2 → 0.40.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 +99 -9
- package/README.md +4 -4
- package/components/AnnotationPanel.tsx +244 -13
- package/components/CommentPopover.tsx +91 -2
- package/components/DecisionControl.tsx +0 -10
- package/components/HtmlSurfaceControls.tsx +188 -23
- package/components/ListMarker.tsx +10 -1
- package/components/MermaidBlock.tsx +39 -5
- package/components/TableOfContents.tsx +5 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
- package/components/Viewer.tsx +25 -1
- package/components/blocks/AlertBlock.tsx +7 -2
- package/components/html-viewer/HtmlViewer.tsx +32 -0
- package/components/html-viewer/bridge-script.asset.js +533 -9
- package/components/html-viewer/bridge-script.ts +533 -9
- package/components/html-viewer/useHtmlAnnotation.ts +71 -4
- package/hooks/useAnnotationHighlighter.ts +464 -14
- package/hooks/useLinkedDoc.ts +100 -9
- package/package.json +3 -3
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
- package/styles.css +1 -1
- package/theme.css +62 -0
- package/types.ts +45 -0
- package/utils/annotationScope.ts +159 -0
- package/utils/cssColor.ts +463 -0
- package/utils/decisionSpec.ts +1 -4
- package/utils/htmlChrome.ts +70 -5
- package/utils/htmlLinkNavigation.ts +196 -0
- package/utils/mermaid-eager.ts +13 -11
- package/utils/mermaid.ts +19 -10
- package/utils/mermaidTheme.ts +732 -0
- package/utils/parser.ts +150 -4
- package/utils/terminalToolsAnnouncement.ts +76 -0
package/utils/parser.ts
CHANGED
|
@@ -1099,6 +1099,92 @@ const blockEndLine = (block: Block): number => {
|
|
|
1099
1099
|
|
|
1100
1100
|
/** Resolve the source-line label for a single annotation.
|
|
1101
1101
|
* Returns null for global comments, diff-view annotations, or missing blocks. */
|
|
1102
|
+
/** Defense in depth for page-controlled strings in agent-read feedback:
|
|
1103
|
+
* collapse whitespace (no injected markdown structure) and defuse any
|
|
1104
|
+
* backtick run that could close an inline code span or a fence. */
|
|
1105
|
+
const safeInline = (value: unknown, max = 200): string => {
|
|
1106
|
+
const collapsed = typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
|
|
1107
|
+
const defused = collapsed.replace(/`+/g, "'");
|
|
1108
|
+
return defused.length > max ? `${defused.slice(0, max)}…` : defused;
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
/** The synthesized quote the bridge posts for a text-less element
|
|
1112
|
+
* (`[element: Navigation]`): a placeholder, not something the agent can use. */
|
|
1113
|
+
const isElementPlaceholderQuote = (text: unknown): boolean =>
|
|
1114
|
+
typeof text === 'string' && /^\[element: [^\]]*\]$/.test(text.trim());
|
|
1115
|
+
|
|
1116
|
+
/** The heading line for a COMMENT entry. When the quote is the bridge's
|
|
1117
|
+
* text-less placeholder and the annotation carries element context, name the
|
|
1118
|
+
* element instead (`Feedback on the <nav> element — "Primary"`); every other
|
|
1119
|
+
* annotation keeps the quote line exactly as before. */
|
|
1120
|
+
const commentHeadingLine = (ann: any): string => {
|
|
1121
|
+
const context = ann?.elementContext;
|
|
1122
|
+
if (context && typeof context.tag === 'string' && isElementPlaceholderQuote(ann.originalText)) {
|
|
1123
|
+
const tag = safeInline(context.tag, 32);
|
|
1124
|
+
const name = context.name ? safeInline(context.name, 120) : '';
|
|
1125
|
+
return `Feedback on the <${tag}> element${name ? ` — "${name}"` : ''}`;
|
|
1126
|
+
}
|
|
1127
|
+
return `Feedback on: "${ann.originalText}"`;
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
export interface ElementContextExportOptions {
|
|
1131
|
+
/** Emit the live-app route line. The grouped export already prints a
|
|
1132
|
+
* `## Page:` heading, so it passes false; a single copied entry passes true. */
|
|
1133
|
+
includeRoute?: boolean;
|
|
1134
|
+
/** Print the identity lines WITHOUT the fenced outline, for model turns where
|
|
1135
|
+
* the 600-char outline is the expensive part. Default true. */
|
|
1136
|
+
includeOutline?: boolean;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/** The agent-facing element block for a raw-HTML / live-app pinpoint: a
|
|
1140
|
+
* fenced HTML skeleton (the one markdown construct whose interior cannot
|
|
1141
|
+
* become structure) plus the identity lines an agent greps for. Emits
|
|
1142
|
+
* nothing when the annotation carries no context, keeping every other
|
|
1143
|
+
* annotation's output byte-identical. */
|
|
1144
|
+
export const elementContextExportBlock = (ann: any, opts: ElementContextExportOptions = {}): string => {
|
|
1145
|
+
const context = ann?.elementContext;
|
|
1146
|
+
if (!context || typeof context !== 'object' || typeof context.tag !== 'string') return '';
|
|
1147
|
+
const includeOutline = opts.includeOutline ?? true;
|
|
1148
|
+
let block = '';
|
|
1149
|
+
if (includeOutline && typeof context.outline === 'string' && context.outline.trim()) {
|
|
1150
|
+
// Fence at 4 backticks; the boundary already defuses 3+ runs inside the
|
|
1151
|
+
// outline, and a 4-run here cannot be closed by anything the page wrote.
|
|
1152
|
+
const outline = context.outline.replace(/`{3,}/g, "'''").trim();
|
|
1153
|
+
block += `\n\`\`\`\`html\n${outline}\n\`\`\`\`\n`;
|
|
1154
|
+
} else {
|
|
1155
|
+
block += '\n';
|
|
1156
|
+
}
|
|
1157
|
+
const selector = ann?.htmlAnchor?.selector;
|
|
1158
|
+
if (typeof selector === 'string' && selector) block += `- **selector** \`${safeInline(selector, 300)}\`\n`;
|
|
1159
|
+
if (context.path) block += `- **path** \`${safeInline(context.path, 512)}\`\n`;
|
|
1160
|
+
const identity: string[] = [];
|
|
1161
|
+
if (context.role) identity.push(`**role** ${safeInline(context.role, 32)}`);
|
|
1162
|
+
if (context.name) identity.push(`**name** "${safeInline(context.name, 120)}"`);
|
|
1163
|
+
if (context.component) identity.push(`**component** \`${safeInline(context.component, 100)}\``);
|
|
1164
|
+
if (identity.length) block += `- ${identity.join(' · ')}\n`;
|
|
1165
|
+
if (Array.isArray(context.attrs) && context.attrs.length) {
|
|
1166
|
+
const attrs = context.attrs
|
|
1167
|
+
.filter((pair: unknown) => Array.isArray(pair) && pair.length === 2)
|
|
1168
|
+
.map((pair: [string, string]) => `${safeInline(pair[0], 40)}="${safeInline(pair[1], 120)}"`)
|
|
1169
|
+
.join(' ');
|
|
1170
|
+
if (attrs) block += `- **attrs** \`${attrs.replace(/`/g, "'")}\`\n`;
|
|
1171
|
+
}
|
|
1172
|
+
if (context.text) block += `- **text** "${safeInline(context.text, 300)}"\n`;
|
|
1173
|
+
if (opts.includeRoute && context.page && context.page.url) {
|
|
1174
|
+
const title = context.page.title ? ` — "${safeInline(context.page.title, 200)}"` : '';
|
|
1175
|
+
block += `- **route** \`${safeInline(context.page.url, 2048)}\`${title}\n`;
|
|
1176
|
+
}
|
|
1177
|
+
const r = context.rect;
|
|
1178
|
+
if (r && ['x', 'y', 'w', 'h', 'vw', 'vh'].every((k) => typeof r[k] === 'number' && Number.isFinite(r[k]))) {
|
|
1179
|
+
block += `- **box** ${r.x},${r.y} ${r.w}×${r.h} (viewport ${r.vw}×${r.vh})\n`;
|
|
1180
|
+
}
|
|
1181
|
+
const near: string[] = [];
|
|
1182
|
+
if (context.landmark) near.push(safeInline(context.landmark, 80));
|
|
1183
|
+
if (context.heading) near.push(`heading ${safeInline(context.heading, 130)}`);
|
|
1184
|
+
if (near.length) block += `- **near** ${near.join(' · ')}\n`;
|
|
1185
|
+
return block;
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1102
1188
|
/** Multi-target raw-HTML comments: list every ADDITIONAL element the one
|
|
1103
1189
|
* comment covers (the primary target is already quoted as `originalText`),
|
|
1104
1190
|
* labeled with the semantic hover label plus a short excerpt so the agent
|
|
@@ -1120,11 +1206,67 @@ const additionalTargetsExportBlock = (ann: any): string => {
|
|
|
1120
1206
|
const raw = typeof target?.text === 'string' ? target.text : '';
|
|
1121
1207
|
const excerpt = raw.replace(/\s+/g, ' ').trim();
|
|
1122
1208
|
const clipped = excerpt.length > 120 ? `${excerpt.slice(0, 120)}…` : excerpt;
|
|
1123
|
-
|
|
1209
|
+
// Element identity for the agent, one line per extra target: the
|
|
1210
|
+
// selector and the path (a full context block per target would swamp
|
|
1211
|
+
// the comment; the rest of the context stays persisted, not exported).
|
|
1212
|
+
//
|
|
1213
|
+
// Gated on the target carrying element context, which is what makes the
|
|
1214
|
+
// element-context work additive in the literal sense it claims: a target
|
|
1215
|
+
// captured before it existed (or restored from an older draft) has an
|
|
1216
|
+
// anchor but no context, and exports byte-identically to before.
|
|
1217
|
+
const locators: string[] = [];
|
|
1218
|
+
if (target?.context && typeof target.context === 'object') {
|
|
1219
|
+
const selector = target?.anchor?.selector;
|
|
1220
|
+
if (typeof selector === 'string' && selector) locators.push(`\`${safeInline(selector, 300)}\``);
|
|
1221
|
+
const path = target?.context?.path;
|
|
1222
|
+
if (typeof path === 'string' && path) locators.push(`\`${safeInline(path, 512)}\``);
|
|
1223
|
+
}
|
|
1224
|
+
block += `- ${label}"${clipped}"${locators.length ? ` — ${locators.join(' · ')}` : ''}\n`;
|
|
1124
1225
|
});
|
|
1125
1226
|
return block;
|
|
1126
1227
|
};
|
|
1127
1228
|
|
|
1229
|
+
/**
|
|
1230
|
+
* One annotation rendered as a standalone feedback entry (no number, no
|
|
1231
|
+
* document heading), for hosts that surface a single annotation to an agent.
|
|
1232
|
+
* The body is the same shape the full export emits for the entry, including
|
|
1233
|
+
* the element block, so a standalone entry never drifts from what Send
|
|
1234
|
+
* Feedback delivers; the live-app route line is included here because the
|
|
1235
|
+
* standalone entry has no `## Page:` heading above it. Plannotator's own
|
|
1236
|
+
* panel chrome does not use it.
|
|
1237
|
+
*/
|
|
1238
|
+
export const exportAnnotationEntry = (ann: any, opts: ElementContextExportOptions = { includeRoute: true }): string => {
|
|
1239
|
+
let output = '';
|
|
1240
|
+
switch (ann?.type) {
|
|
1241
|
+
case 'DELETION':
|
|
1242
|
+
output += `Remove this\n\`\`\`\n${ann.originalText}\n\`\`\`\n`;
|
|
1243
|
+
break;
|
|
1244
|
+
case 'GLOBAL_COMMENT':
|
|
1245
|
+
output += `General feedback\n> ${ann.text}\n`;
|
|
1246
|
+
break;
|
|
1247
|
+
default:
|
|
1248
|
+
if (ann?.isQuickLabel) {
|
|
1249
|
+
output += `[${ann.text}] ${commentHeadingLine(ann)}\n`;
|
|
1250
|
+
if (ann.quickLabelTip) output += `> ${ann.quickLabelTip}\n`;
|
|
1251
|
+
} else {
|
|
1252
|
+
output += `${commentHeadingLine(ann)}\n> ${ann?.text ?? ''}\n`;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
const resolvedOpts: ElementContextExportOptions = {
|
|
1256
|
+
includeRoute: opts.includeRoute ?? true,
|
|
1257
|
+
...(opts.includeOutline !== undefined ? { includeOutline: opts.includeOutline } : {}),
|
|
1258
|
+
};
|
|
1259
|
+
output += elementContextExportBlock(ann, resolvedOpts);
|
|
1260
|
+
output += additionalTargetsExportBlock(ann);
|
|
1261
|
+
if (Array.isArray(ann?.images) && ann.images.length > 0) {
|
|
1262
|
+
output += `**Attached images:**\n`;
|
|
1263
|
+
ann.images.forEach((img: ImageAttachment) => {
|
|
1264
|
+
output += `- [${img.name}] \`${img.path}\`\n`;
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
return output;
|
|
1268
|
+
};
|
|
1269
|
+
|
|
1128
1270
|
const lineLabelForAnnotation = (blocks: Block[], ann: any): string | null => {
|
|
1129
1271
|
if (!ann.blockId || ann.type === 'GLOBAL_COMMENT') return null;
|
|
1130
1272
|
if (typeof ann.blockId === 'string' && ann.blockId.startsWith('diff-block-')) return null;
|
|
@@ -1292,12 +1434,12 @@ export const exportAnnotations = (
|
|
|
1292
1434
|
|
|
1293
1435
|
case 'COMMENT':
|
|
1294
1436
|
if (ann.isQuickLabel) {
|
|
1295
|
-
output += `[${ann.text}]
|
|
1437
|
+
output += `[${ann.text}] ${commentHeadingLine(ann)}\n`;
|
|
1296
1438
|
if (ann.quickLabelTip) {
|
|
1297
1439
|
output += `> ${ann.quickLabelTip}\n`;
|
|
1298
1440
|
}
|
|
1299
1441
|
} else {
|
|
1300
|
-
output +=
|
|
1442
|
+
output += `${commentHeadingLine(ann)}\n`;
|
|
1301
1443
|
output += `> ${ann.text}\n`;
|
|
1302
1444
|
}
|
|
1303
1445
|
break;
|
|
@@ -1308,6 +1450,9 @@ export const exportAnnotations = (
|
|
|
1308
1450
|
break;
|
|
1309
1451
|
}
|
|
1310
1452
|
|
|
1453
|
+
// Raw-HTML / live-app pinpoints describe their element for the agent
|
|
1454
|
+
// (the grouped export's `## Page:` heading already carries the route).
|
|
1455
|
+
output += elementContextExportBlock(ann, { includeRoute: false });
|
|
1311
1456
|
// Multi-target raw-HTML comments list every additional covered element.
|
|
1312
1457
|
output += additionalTargetsExportBlock(ann);
|
|
1313
1458
|
|
|
@@ -1408,7 +1553,7 @@ export const exportLinkedDocAnnotations = (
|
|
|
1408
1553
|
break;
|
|
1409
1554
|
|
|
1410
1555
|
case 'COMMENT':
|
|
1411
|
-
output +=
|
|
1556
|
+
output += `${commentHeadingLine(ann)}\n`;
|
|
1412
1557
|
output += `> ${ann.text}\n`;
|
|
1413
1558
|
break;
|
|
1414
1559
|
|
|
@@ -1418,6 +1563,7 @@ export const exportLinkedDocAnnotations = (
|
|
|
1418
1563
|
break;
|
|
1419
1564
|
}
|
|
1420
1565
|
|
|
1566
|
+
output += elementContextExportBlock(ann, { includeRoute: false });
|
|
1421
1567
|
// Multi-target raw-HTML comments list every additional covered element.
|
|
1422
1568
|
output += additionalTargetsExportBlock(ann);
|
|
1423
1569
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time gate for the terminal-tools announcement (Plannotator TUI and
|
|
3
|
+
* Herdr Annotate). Cookie-backed like the other announcement gates, so a
|
|
4
|
+
* dismissal survives Plannotator's random localhost ports, and shared by the
|
|
5
|
+
* plan editor, the annotate surfaces and the code review editor: dismissing it
|
|
6
|
+
* anywhere retires it everywhere.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { storage } from './storage';
|
|
10
|
+
|
|
11
|
+
const STORAGE_KEY = 'plannotator-announce-tui-herdr-seen';
|
|
12
|
+
// Bump to re-announce after a meaningful revision.
|
|
13
|
+
const CURRENT_VERSION = '1';
|
|
14
|
+
|
|
15
|
+
export function needsTerminalToolsAnnouncement(): boolean {
|
|
16
|
+
return storage.getItem(STORAGE_KEY) !== CURRENT_VERSION;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function markTerminalToolsAnnouncementSeen(): void {
|
|
20
|
+
storage.setItem(STORAGE_KEY, CURRENT_VERSION);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TerminalToolsAnnouncementGateState {
|
|
24
|
+
/**
|
|
25
|
+
* Latched at mount from needsTerminalToolsAnnouncement(). Latched rather than
|
|
26
|
+
* read per render so a dismissal cannot unmount the dialog before its own
|
|
27
|
+
* click handler finishes.
|
|
28
|
+
*/
|
|
29
|
+
readonly announcementPending: boolean;
|
|
30
|
+
/** The app has not finished loading its initial payload. */
|
|
31
|
+
readonly isLoading: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* The session has no author to address: archive browsing, a read-only shared
|
|
34
|
+
* plan, and any session with no Plannotator server behind it (the share
|
|
35
|
+
* portal's root and its demo plan included — those are not "shared
|
|
36
|
+
* sessions", so the host must fold that in). Telling a viewer about a CLI
|
|
37
|
+
* they did not open is noise, and the cookie is deliberately NOT consumed,
|
|
38
|
+
* so the next authoring session still shows it.
|
|
39
|
+
*/
|
|
40
|
+
readonly readOnlySession: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Plannotator's compact touch shell. The panel is desktop-shaped (install
|
|
43
|
+
* commands to copy, four outbound links) and a phone is not where anyone
|
|
44
|
+
* installs a terminal tool. Also deferred rather than consumed.
|
|
45
|
+
*/
|
|
46
|
+
readonly compact: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Any other first-run dialog is on screen. The chain dialogs never stack.
|
|
49
|
+
*/
|
|
50
|
+
readonly otherFirstRunDialogVisible: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Chain gate for the announcement. It is LAST in each app's first-run dialog
|
|
55
|
+
* chain, after every dialog that asks the user to decide something (code
|
|
56
|
+
* review: guide intro, look-and-feel, review setup, edit mode, token hover
|
|
57
|
+
* cards; plan and annotate: look-and-feel, goal setup, permission mode).
|
|
58
|
+
*
|
|
59
|
+
* Last rather than first because none of those dialogs consume this cookie:
|
|
60
|
+
* a session that is busy asking questions defers the announcement to the next
|
|
61
|
+
* load instead of burning it. That also puts it in front of the right reader.
|
|
62
|
+
* Someone opening Plannotator for the first time is still learning this app;
|
|
63
|
+
* the people who should hear that it now runs in a terminal are the ones who
|
|
64
|
+
* already answered every setup question, and they see it on their next load.
|
|
65
|
+
*/
|
|
66
|
+
export function terminalToolsAnnouncementCanShow(
|
|
67
|
+
state: TerminalToolsAnnouncementGateState,
|
|
68
|
+
): boolean {
|
|
69
|
+
return (
|
|
70
|
+
state.announcementPending &&
|
|
71
|
+
!state.isLoading &&
|
|
72
|
+
!state.readOnlySession &&
|
|
73
|
+
!state.compact &&
|
|
74
|
+
!state.otherFirstRunDialogVisible
|
|
75
|
+
);
|
|
76
|
+
}
|