@coframe-gtm/annotations 1.0.1
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/README.md +48 -0
- package/package.json +43 -0
- package/src/README.md +79 -0
- package/src/api.test.ts +253 -0
- package/src/api.ts +264 -0
- package/src/bundle.ts +69 -0
- package/src/capture.test.ts +88 -0
- package/src/capture.ts +345 -0
- package/src/index.ts +45 -0
- package/src/inject/build.ts +52 -0
- package/src/inject/bundle-source.generated.ts +5 -0
- package/src/inject/install.test.ts +84 -0
- package/src/inject/install.ts +126 -0
- package/src/output.ts +171 -0
- package/src/picker.ts +203 -0
- package/src/server/index.ts +28 -0
- package/src/server/ingest.test.ts +144 -0
- package/src/server/ingest.ts +175 -0
- package/src/server/run-store.test.ts +51 -0
- package/src/server/run-store.ts +155 -0
- package/src/shadow.ts +84 -0
- package/src/store.ts +79 -0
- package/src/types.ts +154 -0
- package/src/ui/App.ts +516 -0
- package/src/ui/styles.ts +283 -0
- package/src/ulid.ts +21 -0
- package/src/webhook.ts +33 -0
package/src/ui/App.ts
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root Preact tree — the full annotation overlay.
|
|
3
|
+
*
|
|
4
|
+
* Plain h() calls (no JSX) so the package's React-flavoured tsc
|
|
5
|
+
* config needs no per-directory override; esbuild builds the IIFE
|
|
6
|
+
* either way. Local component state uses preact/hooks; cross-cutting
|
|
7
|
+
* state lives in the signals from `../store.js`.
|
|
8
|
+
*
|
|
9
|
+
* Layers (all inside the closed Shadow DOM, fixed-positioned):
|
|
10
|
+
* 1. hover highlight — element under the cursor in feedback mode
|
|
11
|
+
* 2. multi-select boxes
|
|
12
|
+
* 3. pins — one numbered marker per annotation
|
|
13
|
+
* 4. toolbar — mode + tool switches
|
|
14
|
+
* 5. composer popup — comment + intent/severity/kind on capture
|
|
15
|
+
* 6. thread panel — per-pin discussion (human ↔ agent)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { h } from "preact";
|
|
19
|
+
import type { ComponentChild } from "preact";
|
|
20
|
+
import { useState } from "preact/hooks";
|
|
21
|
+
|
|
22
|
+
import { api } from "../api.js";
|
|
23
|
+
import { requery } from "../capture.js";
|
|
24
|
+
import type { CapturedTarget } from "../capture.js";
|
|
25
|
+
import {
|
|
26
|
+
activeThreadId,
|
|
27
|
+
annotations,
|
|
28
|
+
author,
|
|
29
|
+
cursors,
|
|
30
|
+
draft,
|
|
31
|
+
feedbackTool,
|
|
32
|
+
hoverBox,
|
|
33
|
+
mode,
|
|
34
|
+
multiSelection,
|
|
35
|
+
theme,
|
|
36
|
+
viewportTick,
|
|
37
|
+
type PresenceCursor,
|
|
38
|
+
} from "../store.js";
|
|
39
|
+
import type {
|
|
40
|
+
Annotation,
|
|
41
|
+
AnnotationIntent,
|
|
42
|
+
AnnotationKind,
|
|
43
|
+
AnnotationSeverity,
|
|
44
|
+
} from "../types.js";
|
|
45
|
+
import { commitMultiSelection } from "../picker.js";
|
|
46
|
+
import { STYLES } from "./styles.js";
|
|
47
|
+
|
|
48
|
+
const SEVERITY_COLOR: Record<AnnotationSeverity, string> = {
|
|
49
|
+
blocking: "#ef4444",
|
|
50
|
+
important: "#f59e0b",
|
|
51
|
+
suggestion: "#3ecf8e",
|
|
52
|
+
};
|
|
53
|
+
const DEFAULT_DOT = "#a78bfa";
|
|
54
|
+
|
|
55
|
+
const INTENTS: AnnotationIntent[] = ["fix", "change", "question", "approve"];
|
|
56
|
+
const SEVERITIES: AnnotationSeverity[] = ["blocking", "important", "suggestion"];
|
|
57
|
+
|
|
58
|
+
export function App(): ComponentChild {
|
|
59
|
+
const themeAttr = theme.value === "auto" ? "dark" : theme.value;
|
|
60
|
+
// Subscribe to viewport changes so pins recompute their screen pos.
|
|
61
|
+
void viewportTick.value;
|
|
62
|
+
|
|
63
|
+
return h(
|
|
64
|
+
"div",
|
|
65
|
+
{ class: "cf-overlay", "data-theme": themeAttr },
|
|
66
|
+
[
|
|
67
|
+
h("style", { key: "styles" }, STYLES),
|
|
68
|
+
hoverBox.value && !draft.value
|
|
69
|
+
? h("div", {
|
|
70
|
+
key: "hover",
|
|
71
|
+
class: "cf-hover",
|
|
72
|
+
style: boxStyle(hoverBox.value),
|
|
73
|
+
})
|
|
74
|
+
: null,
|
|
75
|
+
...multiSelection.value.map((el, i) => renderMultiBox(el, i)),
|
|
76
|
+
h(
|
|
77
|
+
"div",
|
|
78
|
+
{ key: "pins" },
|
|
79
|
+
annotations.value.map((a, i) => renderPin(a, i + 1)),
|
|
80
|
+
),
|
|
81
|
+
h(
|
|
82
|
+
"div",
|
|
83
|
+
{ key: "cursors" },
|
|
84
|
+
cursors.value.map((c) => renderCursor(c)),
|
|
85
|
+
),
|
|
86
|
+
renderToolbar(),
|
|
87
|
+
draft.value ? h(Composer, { key: "composer", target: draft.value }) : null,
|
|
88
|
+
activeThreadId.value
|
|
89
|
+
? h(ThreadPanel, { key: "thread", id: activeThreadId.value })
|
|
90
|
+
: null,
|
|
91
|
+
],
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Toolbar ────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
function renderToolbar(): ComponentChild {
|
|
98
|
+
const active = mode.value === "feedback";
|
|
99
|
+
const count = annotations.value.length;
|
|
100
|
+
const multi = multiSelection.value.length;
|
|
101
|
+
|
|
102
|
+
return h("div", { key: "toolbar", class: "cf-toolbar" }, [
|
|
103
|
+
h("div", { key: "head", class: "cf-toolbar-head" }, [
|
|
104
|
+
h("span", { key: "dot", class: "cf-logo-dot" }),
|
|
105
|
+
h("span", { key: "label" }, "Annotations"),
|
|
106
|
+
h("span", { key: "count", class: "cf-count" }, String(count)),
|
|
107
|
+
]),
|
|
108
|
+
h("div", { key: "modes", class: "cf-seg" }, [
|
|
109
|
+
segButton("View", mode.value === "view", () => {
|
|
110
|
+
mode.value = "view";
|
|
111
|
+
hoverBox.value = null;
|
|
112
|
+
multiSelection.value = [];
|
|
113
|
+
}),
|
|
114
|
+
segButton("Comment", active, () => {
|
|
115
|
+
mode.value = "feedback";
|
|
116
|
+
}),
|
|
117
|
+
]),
|
|
118
|
+
active
|
|
119
|
+
? h("div", { key: "tools", class: "cf-seg cf-tools" }, [
|
|
120
|
+
segButton("Element", feedbackTool.value === "element", () => {
|
|
121
|
+
feedbackTool.value = "element";
|
|
122
|
+
multiSelection.value = [];
|
|
123
|
+
}),
|
|
124
|
+
segButton("Text", feedbackTool.value === "text", () => {
|
|
125
|
+
feedbackTool.value = "text";
|
|
126
|
+
multiSelection.value = [];
|
|
127
|
+
}),
|
|
128
|
+
segButton("Multi", feedbackTool.value === "multi", () => {
|
|
129
|
+
feedbackTool.value = "multi";
|
|
130
|
+
}),
|
|
131
|
+
])
|
|
132
|
+
: null,
|
|
133
|
+
active && feedbackTool.value === "multi" && multi
|
|
134
|
+
? h(
|
|
135
|
+
"button",
|
|
136
|
+
{
|
|
137
|
+
key: "commit",
|
|
138
|
+
class: "cf-btn cf-btn-primary cf-commit",
|
|
139
|
+
onClick: commitMultiSelection,
|
|
140
|
+
},
|
|
141
|
+
`Comment ${multi} element${multi === 1 ? "" : "s"}`,
|
|
142
|
+
)
|
|
143
|
+
: null,
|
|
144
|
+
active
|
|
145
|
+
? h(
|
|
146
|
+
"div",
|
|
147
|
+
{ key: "hint", class: "cf-hint" },
|
|
148
|
+
hintFor(feedbackTool.value),
|
|
149
|
+
)
|
|
150
|
+
: null,
|
|
151
|
+
]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function hintFor(tool: string): string {
|
|
155
|
+
if (tool === "text") return "Select text on the page to annotate it.";
|
|
156
|
+
if (tool === "multi") return "Click elements to group them, then commit.";
|
|
157
|
+
return "Click any element to comment. Esc to exit.";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function segButton(
|
|
161
|
+
label: string,
|
|
162
|
+
on: boolean,
|
|
163
|
+
onClick: () => void,
|
|
164
|
+
): ComponentChild {
|
|
165
|
+
return h(
|
|
166
|
+
"button",
|
|
167
|
+
{
|
|
168
|
+
key: label,
|
|
169
|
+
class: `cf-seg-btn${on ? " on" : ""}`,
|
|
170
|
+
onClick,
|
|
171
|
+
},
|
|
172
|
+
label,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Pins ───────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
function renderPin(a: Annotation, index: number): ComponentChild {
|
|
179
|
+
const pos = pinPosition(a);
|
|
180
|
+
if (!pos) return null;
|
|
181
|
+
const color = a.severity ? SEVERITY_COLOR[a.severity] : DEFAULT_DOT;
|
|
182
|
+
const resolved = a.status === "resolved" || a.status === "dismissed";
|
|
183
|
+
return h(
|
|
184
|
+
"button",
|
|
185
|
+
{
|
|
186
|
+
key: a.id,
|
|
187
|
+
class: `cf-pin${resolved ? " resolved" : ""}${a.author?.kind === "agent" ? " agent" : ""}`,
|
|
188
|
+
style: `top:${pos.top}px;left:${pos.left}px;--cf-pin:${color};`,
|
|
189
|
+
title: firstLine(a.comment),
|
|
190
|
+
onClick: () => {
|
|
191
|
+
activeThreadId.value = activeThreadId.value === a.id ? null : a.id;
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
a.author?.kind === "agent" ? "★" : String(index),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Live screen position of a pin, re-anchored to the element. */
|
|
199
|
+
function pinPosition(a: Annotation): { top: number; left: number } | null {
|
|
200
|
+
const el = requery(a.fullPath ?? "") ?? requery(a.elementPath ?? "");
|
|
201
|
+
if (el) {
|
|
202
|
+
const rect = el.getBoundingClientRect();
|
|
203
|
+
return { top: rect.top - 11, left: rect.left - 11 };
|
|
204
|
+
}
|
|
205
|
+
if (a.boundingBox) {
|
|
206
|
+
return {
|
|
207
|
+
top: a.boundingBox.y - (a.isFixed ? 0 : window.scrollY) - 11,
|
|
208
|
+
left: a.boundingBox.x - (a.isFixed ? 0 : window.scrollX) - 11,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function renderMultiBox(el: Element, i: number): ComponentChild {
|
|
215
|
+
const rect = el.getBoundingClientRect();
|
|
216
|
+
return h("div", {
|
|
217
|
+
key: `multi-${i}`,
|
|
218
|
+
class: "cf-multi-box",
|
|
219
|
+
style: boxStyle({
|
|
220
|
+
top: rect.top,
|
|
221
|
+
left: rect.left,
|
|
222
|
+
width: rect.width,
|
|
223
|
+
height: rect.height,
|
|
224
|
+
}),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const CURSOR_PALETTE = ["#a78bfa", "#3ecf8e", "#f59e0b", "#38bdf8", "#f472b6"];
|
|
229
|
+
|
|
230
|
+
function renderCursor(c: PresenceCursor): ComponentChild {
|
|
231
|
+
// x is % of viewport width; y is px from document top.
|
|
232
|
+
const left = (c.x / 100) * window.innerWidth;
|
|
233
|
+
const top = c.y - window.scrollY;
|
|
234
|
+
const color =
|
|
235
|
+
c.color ??
|
|
236
|
+
(c.kind === "agent"
|
|
237
|
+
? CURSOR_PALETTE[Math.abs(hashId(c.id)) % CURSOR_PALETTE.length]!
|
|
238
|
+
: "#ffffff");
|
|
239
|
+
return h(
|
|
240
|
+
"div",
|
|
241
|
+
{
|
|
242
|
+
key: `cursor-${c.id}`,
|
|
243
|
+
class: `cf-cursor cf-cursor-${c.kind}`,
|
|
244
|
+
style: `top:${top}px;left:${left}px;--cf-cursor:${color};`,
|
|
245
|
+
},
|
|
246
|
+
[
|
|
247
|
+
// Arrow pointer.
|
|
248
|
+
h(
|
|
249
|
+
"svg",
|
|
250
|
+
{
|
|
251
|
+
key: "arrow",
|
|
252
|
+
width: "18",
|
|
253
|
+
height: "18",
|
|
254
|
+
viewBox: "0 0 18 18",
|
|
255
|
+
class: "cf-cursor-arrow",
|
|
256
|
+
},
|
|
257
|
+
h("path", {
|
|
258
|
+
d: "M2 2 L2 14 L6 10 L9 16 L11 15 L8 9 L14 9 Z",
|
|
259
|
+
fill: color,
|
|
260
|
+
stroke: "rgba(0,0,0,0.35)",
|
|
261
|
+
"stroke-width": "0.75",
|
|
262
|
+
}),
|
|
263
|
+
),
|
|
264
|
+
h(
|
|
265
|
+
"span",
|
|
266
|
+
{ key: "label", class: "cf-cursor-label" },
|
|
267
|
+
`${c.kind === "agent" ? "🤖 " : ""}${c.label}`,
|
|
268
|
+
),
|
|
269
|
+
],
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function hashId(s: string): number {
|
|
274
|
+
let h = 0;
|
|
275
|
+
for (let i = 0; i < s.length; i++) h = (h << 5) - h + s.charCodeAt(i);
|
|
276
|
+
return h;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Composer popup ─────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
interface ComposerProps {
|
|
282
|
+
target: CapturedTarget;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function Composer({ target }: ComposerProps): ComponentChild {
|
|
286
|
+
const [comment, setComment] = useState("");
|
|
287
|
+
const [intent, setIntent] = useState<AnnotationIntent>("change");
|
|
288
|
+
const [severity, setSeverity] = useState<AnnotationSeverity>("important");
|
|
289
|
+
|
|
290
|
+
const submit = (): void => {
|
|
291
|
+
const text = comment.trim();
|
|
292
|
+
if (!text) return;
|
|
293
|
+
api.addAnnotation({
|
|
294
|
+
...target,
|
|
295
|
+
comment: text,
|
|
296
|
+
intent,
|
|
297
|
+
severity,
|
|
298
|
+
kind: "feedback" satisfies AnnotationKind,
|
|
299
|
+
author: author.value,
|
|
300
|
+
});
|
|
301
|
+
draft.value = null;
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const cancel = (): void => {
|
|
305
|
+
draft.value = null;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const label = target.isMultiSelect
|
|
309
|
+
? `${target.elementBoundingBoxes?.length ?? 0} elements`
|
|
310
|
+
: target.selectedText
|
|
311
|
+
? `"${truncate(target.selectedText, 40)}"`
|
|
312
|
+
: `<${target.element}>`;
|
|
313
|
+
|
|
314
|
+
return h("div", { class: "cf-popup cf-composer" }, [
|
|
315
|
+
h("div", { key: "head", class: "cf-popup-head" }, [
|
|
316
|
+
h("span", { key: "t", class: "cf-popup-target" }, label),
|
|
317
|
+
h(
|
|
318
|
+
"button",
|
|
319
|
+
{ key: "x", class: "cf-icon-btn", onClick: cancel, title: "Cancel" },
|
|
320
|
+
"✕",
|
|
321
|
+
),
|
|
322
|
+
]),
|
|
323
|
+
h("textarea", {
|
|
324
|
+
key: "ta",
|
|
325
|
+
class: "cf-textarea",
|
|
326
|
+
placeholder: "Describe the change… (Markdown supported)",
|
|
327
|
+
autofocus: true,
|
|
328
|
+
value: comment,
|
|
329
|
+
onInput: (e: Event) => setComment((e.target as HTMLTextAreaElement).value),
|
|
330
|
+
onKeyDown: (e: KeyboardEvent) => {
|
|
331
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") submit();
|
|
332
|
+
},
|
|
333
|
+
}),
|
|
334
|
+
h("div", { key: "intent", class: "cf-field" }, [
|
|
335
|
+
h("span", { key: "l", class: "cf-field-label" }, "Intent"),
|
|
336
|
+
h(
|
|
337
|
+
"div",
|
|
338
|
+
{ key: "g", class: "cf-chipset" },
|
|
339
|
+
INTENTS.map((it) =>
|
|
340
|
+
chip(it, intent === it, SEVERITY_NEUTRAL, () => setIntent(it)),
|
|
341
|
+
),
|
|
342
|
+
),
|
|
343
|
+
]),
|
|
344
|
+
h("div", { key: "sev", class: "cf-field" }, [
|
|
345
|
+
h("span", { key: "l", class: "cf-field-label" }, "Severity"),
|
|
346
|
+
h(
|
|
347
|
+
"div",
|
|
348
|
+
{ key: "g", class: "cf-chipset" },
|
|
349
|
+
SEVERITIES.map((s) =>
|
|
350
|
+
chip(s, severity === s, SEVERITY_COLOR[s], () => setSeverity(s)),
|
|
351
|
+
),
|
|
352
|
+
),
|
|
353
|
+
]),
|
|
354
|
+
h("div", { key: "actions", class: "cf-popup-actions" }, [
|
|
355
|
+
h("button", { key: "c", class: "cf-btn", onClick: cancel }, "Cancel"),
|
|
356
|
+
h(
|
|
357
|
+
"button",
|
|
358
|
+
{
|
|
359
|
+
key: "s",
|
|
360
|
+
class: "cf-btn cf-btn-primary",
|
|
361
|
+
disabled: !comment.trim(),
|
|
362
|
+
onClick: submit,
|
|
363
|
+
},
|
|
364
|
+
"Comment",
|
|
365
|
+
),
|
|
366
|
+
]),
|
|
367
|
+
]);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const SEVERITY_NEUTRAL = "#8b94a3";
|
|
371
|
+
|
|
372
|
+
function chip(
|
|
373
|
+
label: string,
|
|
374
|
+
on: boolean,
|
|
375
|
+
color: string,
|
|
376
|
+
onClick: () => void,
|
|
377
|
+
): ComponentChild {
|
|
378
|
+
return h(
|
|
379
|
+
"button",
|
|
380
|
+
{
|
|
381
|
+
key: label,
|
|
382
|
+
class: `cf-chip${on ? " on" : ""}`,
|
|
383
|
+
style: on ? `--cf-chip:${color};` : undefined,
|
|
384
|
+
onClick,
|
|
385
|
+
},
|
|
386
|
+
label,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ── Thread panel ───────────────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
function ThreadPanel({ id }: { id: string }): ComponentChild {
|
|
393
|
+
const [reply, setReply] = useState("");
|
|
394
|
+
const a = annotations.value.find((x) => x.id === id);
|
|
395
|
+
if (!a) return null;
|
|
396
|
+
|
|
397
|
+
const send = (): void => {
|
|
398
|
+
const text = reply.trim();
|
|
399
|
+
if (!text) return;
|
|
400
|
+
api.replyToAnnotation(id, { role: author.value.kind, content: text });
|
|
401
|
+
setReply("");
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
return h("div", { class: "cf-popup cf-thread" }, [
|
|
405
|
+
h("div", { key: "head", class: "cf-popup-head" }, [
|
|
406
|
+
h("span", { key: "t", class: "cf-popup-target" }, `<${a.element}>`),
|
|
407
|
+
a.severity
|
|
408
|
+
? h(
|
|
409
|
+
"span",
|
|
410
|
+
{
|
|
411
|
+
key: "sev",
|
|
412
|
+
class: "cf-tag",
|
|
413
|
+
style: `--cf-tag:${SEVERITY_COLOR[a.severity]};`,
|
|
414
|
+
},
|
|
415
|
+
a.severity,
|
|
416
|
+
)
|
|
417
|
+
: null,
|
|
418
|
+
h(
|
|
419
|
+
"button",
|
|
420
|
+
{
|
|
421
|
+
key: "x",
|
|
422
|
+
class: "cf-icon-btn",
|
|
423
|
+
onClick: () => (activeThreadId.value = null),
|
|
424
|
+
title: "Close",
|
|
425
|
+
},
|
|
426
|
+
"✕",
|
|
427
|
+
),
|
|
428
|
+
]),
|
|
429
|
+
h("div", { key: "body", class: "cf-thread-body" }, [
|
|
430
|
+
renderMessage(
|
|
431
|
+
a.author?.kind ?? "human",
|
|
432
|
+
a.author?.displayName ?? "You",
|
|
433
|
+
a.comment,
|
|
434
|
+
"root",
|
|
435
|
+
),
|
|
436
|
+
...(a.thread ?? []).map((m) =>
|
|
437
|
+
renderMessage(m.role, m.role === "agent" ? "Agent" : "You", m.content, m.id),
|
|
438
|
+
),
|
|
439
|
+
]),
|
|
440
|
+
h("div", { key: "compose", class: "cf-thread-compose" }, [
|
|
441
|
+
h("textarea", {
|
|
442
|
+
key: "ta",
|
|
443
|
+
class: "cf-textarea cf-textarea-sm",
|
|
444
|
+
placeholder: "Reply…",
|
|
445
|
+
value: reply,
|
|
446
|
+
onInput: (e: Event) => setReply((e.target as HTMLTextAreaElement).value),
|
|
447
|
+
onKeyDown: (e: KeyboardEvent) => {
|
|
448
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") send();
|
|
449
|
+
},
|
|
450
|
+
}),
|
|
451
|
+
h("div", { key: "row", class: "cf-thread-actions" }, [
|
|
452
|
+
a.status !== "resolved"
|
|
453
|
+
? h(
|
|
454
|
+
"button",
|
|
455
|
+
{
|
|
456
|
+
key: "resolve",
|
|
457
|
+
class: "cf-btn cf-btn-ghost",
|
|
458
|
+
onClick: () => api.resolveAnnotation(id, author.value.kind),
|
|
459
|
+
},
|
|
460
|
+
"Resolve",
|
|
461
|
+
)
|
|
462
|
+
: h(
|
|
463
|
+
"span",
|
|
464
|
+
{ key: "resolved", class: "cf-resolved-tag" },
|
|
465
|
+
"✓ resolved",
|
|
466
|
+
),
|
|
467
|
+
h(
|
|
468
|
+
"button",
|
|
469
|
+
{
|
|
470
|
+
key: "send",
|
|
471
|
+
class: "cf-btn cf-btn-primary",
|
|
472
|
+
disabled: !reply.trim(),
|
|
473
|
+
onClick: send,
|
|
474
|
+
},
|
|
475
|
+
"Reply",
|
|
476
|
+
),
|
|
477
|
+
]),
|
|
478
|
+
]),
|
|
479
|
+
]);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function renderMessage(
|
|
483
|
+
role: "human" | "agent",
|
|
484
|
+
name: string,
|
|
485
|
+
content: string,
|
|
486
|
+
key: string,
|
|
487
|
+
): ComponentChild {
|
|
488
|
+
return h("div", { key, class: `cf-msg cf-msg-${role}` }, [
|
|
489
|
+
h("div", { key: "meta", class: "cf-msg-meta" }, [
|
|
490
|
+
h("span", { key: "av", class: `cf-avatar cf-avatar-${role}` }, role === "agent" ? "🤖" : "🧑"),
|
|
491
|
+
h("span", { key: "n", class: "cf-msg-name" }, name),
|
|
492
|
+
]),
|
|
493
|
+
h("div", { key: "c", class: "cf-msg-body" }, content),
|
|
494
|
+
]);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ── helpers ────────────────────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
function boxStyle(b: {
|
|
500
|
+
top: number;
|
|
501
|
+
left: number;
|
|
502
|
+
width: number;
|
|
503
|
+
height: number;
|
|
504
|
+
}): string {
|
|
505
|
+
return `top:${b.top}px;left:${b.left}px;width:${b.width}px;height:${b.height}px;`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function firstLine(markdown: string): string {
|
|
509
|
+
const trimmed = markdown.trim();
|
|
510
|
+
const nl = trimmed.indexOf("\n");
|
|
511
|
+
return nl > 0 ? trimmed.slice(0, nl) : trimmed;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function truncate(text: string, max: number): string {
|
|
515
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
516
|
+
}
|