@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 ADDED
@@ -0,0 +1,48 @@
1
+ # @coframe-gtm/annotations
2
+
3
+ Clean-room **AFS v1.1** annotation overlay + transport-agnostic ingest core.
4
+ Injects into any web page; humans and agents annotate the same page
5
+ bidirectionally. No platform deps — `preact` + `@preact/signals` only — so it
6
+ ports anywhere.
7
+
8
+ ## Layout
9
+
10
+ - `src/` — **frontend**: closed Shadow DOM overlay (element / text / multi
11
+ picker, pins, composer, discussion threads), forensic capture, 4-tier
12
+ Markdown output, and the `window.__annotations` JS bridge.
13
+ - `src/server/` — **backend (portable)**: `parseEnvelope`, `annotationQueueReducer`,
14
+ `corsHeaders`, and a `RunStore` port. Wrap in a Cloudflare Worker route or an
15
+ AWS Lambda — same logic, no platform import.
16
+ - `src/inject/` — CDP install helpers + the esbuild bundler that emits the IIFE
17
+ and regenerates `bundle-source.generated.ts`.
18
+
19
+ ## Use
20
+
21
+ ```bash
22
+ pnpm install
23
+ pnpm test # vitest (node + jsdom)
24
+ pnpm typecheck
25
+ pnpm build # → dist/annotations-v1.iife.js + regenerated bundle-source
26
+ ```
27
+
28
+ Inject the built IIFE into a page (via CDP `Page.addScriptToEvaluateOnNewDocument`
29
+ or a `<script>`), then control it:
30
+
31
+ ```js
32
+ window.__annotations.__init({
33
+ webhookUrl: "https://your-host/annotations/webhook",
34
+ sessionId: "sess_123",
35
+ author: { kind: "human", displayName: "You" },
36
+ });
37
+ window.__annotations.addAnnotation({ comment: "tighten this", elementPath: "button.cta", element: "button", x: 50, y: 100 });
38
+ ```
39
+
40
+ The overlay POSTs AFS event envelopes (`{type, timestamp, sessionId, sequence, payload}`)
41
+ to `webhookUrl`. Your backend feeds them through `src/server` into whatever
42
+ store/queue you want.
43
+
44
+ ## Two layers, one package
45
+
46
+ - **Engine** (this package) — the reusable overlay + portable ingest core.
47
+ - **Backend** — wire `src/server` into a Cloudflare Worker or AWS Lambda over
48
+ any S3 / DynamoDB / queue store you own. Nothing here pins you to a platform.
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@coframe-gtm/annotations",
3
+ "version": "1.0.1",
4
+ "type": "module",
5
+ "description": "Clean-room AFS v1.1 annotation overlay (frontend) + transport-agnostic ingest core (backend). Injects into any page; humans + agents annotate bidirectionally.",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/Coframe/GTM-growth-engine.git",
9
+ "directory": "coframe-agentation/frontend"
10
+ },
11
+ "main": "./src/index.ts",
12
+ "files": ["src", "README.md"],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "exports": {
17
+ ".": "./src/index.ts",
18
+ "./server": "./src/server/index.ts",
19
+ "./inject": "./src/inject/install.ts",
20
+ "./v1": "./src/index.ts",
21
+ "./v1/server": "./src/server/index.ts",
22
+ "./v1/inject": "./src/inject/install.ts"
23
+ },
24
+ "scripts": {
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "vitest run",
27
+ "build": "node --experimental-strip-types src/inject/build.ts"
28
+ },
29
+ "dependencies": {
30
+ "preact": "^10.24.0",
31
+ "@preact/signals": "^2.0.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^22.10.5",
35
+ "esbuild": "^0.28.0",
36
+ "jsdom": "^25.0.0",
37
+ "typescript": "^5.9.2",
38
+ "vitest": "^4.1.5"
39
+ },
40
+ "pnpm": {
41
+ "onlyBuiltDependencies": ["esbuild"]
42
+ }
43
+ }
package/src/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # `@coframe-gtm/annotations/v1` — P1 scaffold
2
+
3
+ The clean-room injection bundle described in
4
+ [`../../PLAN.md`](../../PLAN.md). P1 (phase one) of that plan ships
5
+ this scaffold:
6
+
7
+ - Closed Shadow DOM mount (`shadow.ts`)
8
+ - Preact-rendered debug shell (`ui/App.ts`)
9
+ - `window.__annotations` API surface (`api.ts`)
10
+ - Outbound webhook emitter (`webhook.ts`)
11
+ - CDP install helpers (`inject/install.ts`)
12
+ - esbuild build pipeline (`inject/build.ts`)
13
+
14
+ What it does today: inject the bundle into any page, see a small
15
+ floating "Annotations · 0 · view mode" badge in the bottom
16
+ left. Call `window.__annotations.addAnnotation({…})` from the
17
+ console to add a row; the badge updates live.
18
+
19
+ What it doesn't do yet: pin rendering on the target element, the
20
+ element picker, threading UI, settings panel, forensic extraction.
21
+ Those land in P2 → P7.
22
+
23
+ ## Build
24
+
25
+ ```sh
26
+ pnpm build
27
+ ```
28
+
29
+ Outputs `dist/annotations-v1.iife.js` and regenerates
30
+ `src/v1/inject/bundle-source.generated.ts` with the bundle as a
31
+ TypeScript string constant.
32
+
33
+ ## Manual smoke test
34
+
35
+ ```js
36
+ // 1. Build the bundle.
37
+ // 2. Open any page (e.g. https://linear.app) in Chrome.
38
+ // 3. Open DevTools → Sources → paste the contents of
39
+ // dist/annotations-v1.iife.js, run it.
40
+ // 4. Verify the floating annotations badge appears.
41
+ // 5. Drop an annotation:
42
+ window.__annotations.addAnnotation({
43
+ kind: "feedback",
44
+ target: { type: "element", path: "h1" },
45
+ body: [{ kind: "markdown", text: "Headline test" }],
46
+ });
47
+ // 6. Badge shows "1 annotation"; the row lists "feedback · Agent".
48
+ // 7. Set the webhook URL and watch events POST:
49
+ window.__annotations.__init({ webhookUrl: "https://webhook.site/your-id" });
50
+ window.__annotations.addAnnotation({
51
+ kind: "bug",
52
+ target: { type: "element", path: "button" },
53
+ body: [{ kind: "markdown", text: "Broken on mobile" }],
54
+ });
55
+ ```
56
+
57
+ ## Layout
58
+
59
+ ```
60
+ src/v1/
61
+ ├── README.md # this file
62
+ ├── index.ts # public Node surface (install helpers, types)
63
+ ├── types.ts # data model
64
+ ├── store.ts # Preact signals
65
+ ├── ulid.ts # tiny inlined ULID
66
+ ├── shadow.ts # closed Shadow DOM mount
67
+ ├── webhook.ts # outbound POST emitter
68
+ ├── api.ts # window.__annotations
69
+ ├── bundle.ts # IIFE entry
70
+ ├── ui/
71
+ │ └── App.ts # root Preact tree (debug shell for P1)
72
+ └── inject/
73
+ ├── install.ts # CDP install helpers
74
+ └── build.ts # esbuild build script
75
+ ```
76
+
77
+ Phases P2 → P7 fill in `ui/Pin.ts`, `ui/ElementPicker.ts`,
78
+ `ui/CommentPopup.ts`, `ui/SettingsPanel.ts`, plus the
79
+ `extract.ts` / `selectors.ts` / `react.ts` modules.
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Smoke tests for the v1 API surface — AFS 1.1 shape.
3
+ *
4
+ * Confirms store mutations, event payloads, and ordering match the
5
+ * contract in PLAN.md. Does not exercise the shadow / Preact render
6
+ * path (that needs jsdom + a separate setup).
7
+ */
8
+
9
+ import { beforeEach, describe, expect, it, vi } from "vitest";
10
+
11
+ import { api, initSession } from "./api.js";
12
+ import {
13
+ annotations,
14
+ cursors,
15
+ mode,
16
+ sequence,
17
+ sessionId,
18
+ theme,
19
+ webhookUrl,
20
+ } from "./store.js";
21
+ import { formatAnnotation, formatAnnotationBundle } from "./output.js";
22
+ import type { AgentationEvent, Annotation } from "./types.js";
23
+
24
+ beforeEach(() => {
25
+ annotations.value = [];
26
+ mode.value = "view";
27
+ theme.value = "dark";
28
+ webhookUrl.value = null;
29
+ sessionId.value = "test-session";
30
+ sequence.value = 0;
31
+ });
32
+
33
+ describe("api.addAnnotation (AFS shape)", () => {
34
+ it("emits AFS 1.1-shaped annotations", () => {
35
+ const id = api.addAnnotation({
36
+ comment: "**Headline** is buried",
37
+ elementPath: "body > main > h1",
38
+ element: "h1",
39
+ x: 50,
40
+ y: 200,
41
+ });
42
+ expect(id).toMatch(/^ann_/);
43
+ const a = annotations.value[0]!;
44
+ expect(a.element).toBe("h1");
45
+ expect(a.elementPath).toBe("body > main > h1");
46
+ expect(a.kind).toBe("feedback");
47
+ expect(a.status).toBe("pending");
48
+ expect(a.x).toBe(50);
49
+ expect(a.y).toBe(200);
50
+ expect(a.thread).toEqual([]);
51
+ expect(a.timestamp).toBeGreaterThan(0);
52
+ expect(typeof a.createdAt).toBe("string");
53
+ });
54
+
55
+ it("respects custom kind / intent / severity / author", () => {
56
+ api.addAnnotation({
57
+ comment: "broken on mobile",
58
+ elementPath: "button.cta",
59
+ element: "button",
60
+ x: 25,
61
+ y: 480,
62
+ kind: "feedback",
63
+ intent: "fix",
64
+ severity: "blocking",
65
+ author: { kind: "human", id: "ari", displayName: "Ari" },
66
+ });
67
+ const a = annotations.value[0]!;
68
+ expect(a.intent).toBe("fix");
69
+ expect(a.severity).toBe("blocking");
70
+ expect(a.author?.kind).toBe("human");
71
+ });
72
+ });
73
+
74
+ describe("threading and lifecycle", () => {
75
+ it("acknowledge → resolve flow", () => {
76
+ const id = api.addAnnotation({
77
+ comment: "fix nav density",
78
+ elementPath: "nav",
79
+ element: "nav",
80
+ x: 50,
81
+ y: 80,
82
+ });
83
+ expect(api.acknowledgeAnnotation(id)).toBe(true);
84
+ expect(annotations.value[0]!.status).toBe("acknowledged");
85
+ expect(api.resolveAnnotation(id, "agent")).toBe(true);
86
+ expect(annotations.value[0]!.status).toBe("resolved");
87
+ expect(annotations.value[0]!.resolvedBy).toBe("agent");
88
+ expect(typeof annotations.value[0]!.resolvedAt).toBe("string");
89
+ });
90
+
91
+ it("dismiss sets dismissed status", () => {
92
+ const id = api.addAnnotation({
93
+ comment: "ok skip",
94
+ elementPath: "footer",
95
+ element: "footer",
96
+ x: 50,
97
+ y: 1200,
98
+ });
99
+ expect(api.dismissAnnotation(id)).toBe(true);
100
+ expect(annotations.value[0]!.status).toBe("dismissed");
101
+ });
102
+
103
+ it("replyToAnnotation appends thread message", () => {
104
+ const id = api.addAnnotation({
105
+ comment: "ok",
106
+ elementPath: "h1",
107
+ element: "h1",
108
+ x: 50,
109
+ y: 100,
110
+ });
111
+ const msgId = api.replyToAnnotation(id, {
112
+ role: "agent",
113
+ content: "Try `Start free →`",
114
+ });
115
+ expect(msgId).toMatch(/^msg_/);
116
+ const a = annotations.value[0]!;
117
+ expect(a.thread).toHaveLength(1);
118
+ expect(a.thread![0]!.role).toBe("agent");
119
+ });
120
+
121
+ it("replyToAnnotation returns null for unknown ids", () => {
122
+ expect(api.replyToAnnotation("missing", { role: "human", content: "x" })).toBeNull();
123
+ });
124
+ });
125
+
126
+ describe("clearAnnotations / removeAnnotation", () => {
127
+ it("clear returns count and empties", () => {
128
+ api.addAnnotation({ comment: "a", elementPath: "h1", element: "h1", x: 0, y: 0 });
129
+ api.addAnnotation({ comment: "b", elementPath: "h2", element: "h2", x: 0, y: 0 });
130
+ expect(api.clearAnnotations()).toBe(2);
131
+ expect(annotations.value).toEqual([]);
132
+ });
133
+ });
134
+
135
+ describe("webhook emission", () => {
136
+ it("emits AFS event envelope on addAnnotation", async () => {
137
+ const fetchSpy = vi.fn().mockResolvedValue({ ok: true });
138
+ vi.stubGlobal("fetch", fetchSpy);
139
+ initSession({ webhookUrl: "https://example.com/webhook", sessionId: "s1" });
140
+
141
+ api.addAnnotation({
142
+ comment: "test",
143
+ elementPath: "h1",
144
+ element: "h1",
145
+ x: 50,
146
+ y: 100,
147
+ });
148
+
149
+ await Promise.resolve();
150
+ // 2 emits: session.created (initSession) + annotation.created.
151
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
152
+ const lastCall = fetchSpy.mock.calls[1]!;
153
+ const body = JSON.parse((lastCall[1] as { body: string }).body) as AgentationEvent;
154
+ expect(body.type).toBe("annotation.created");
155
+ expect(body.sessionId).toBe("s1");
156
+ expect(body.sequence).toBeGreaterThan(0);
157
+ expect(typeof body.timestamp).toBe("string");
158
+ vi.unstubAllGlobals();
159
+ });
160
+
161
+ it("monotonic sequence per session", () => {
162
+ initSession({ webhookUrl: null as unknown as string, sessionId: "s2" });
163
+ const seqStart = sequence.value;
164
+ api.addAnnotation({ comment: "a", elementPath: "h1", element: "h1", x: 0, y: 0 });
165
+ const id = annotations.value[0]!.id;
166
+ api.replyToAnnotation(id, { role: "agent", content: "ack" });
167
+ api.acknowledgeAnnotation(id);
168
+ expect(sequence.value).toBeGreaterThan(seqStart);
169
+ });
170
+ });
171
+
172
+ describe("formatAnnotation (Forensic output)", () => {
173
+ const sample: Annotation = {
174
+ id: "ann_k8x2m",
175
+ comment: "Button is cut off on mobile viewport",
176
+ elementPath: "body > main > .hero-section > button.cta",
177
+ timestamp: 1705694400000,
178
+ x: 45.5,
179
+ y: 480,
180
+ element: "button",
181
+ url: "http://localhost:3000/landing",
182
+ boundingBox: { x: 120, y: 480, width: 200, height: 48 },
183
+ reactComponents: "App > LandingPage > HeroSection > CTAButton",
184
+ cssClasses: "cta btn-primary",
185
+ nearbyText: "Get Started Free",
186
+ intent: "fix",
187
+ severity: "blocking",
188
+ status: "pending",
189
+ thread: [
190
+ { id: "msg_1", role: "human", content: "blocks 50% of users", timestamp: 1 },
191
+ { id: "msg_2", role: "agent", content: "fixing in next variant", timestamp: 2 },
192
+ ],
193
+ };
194
+
195
+ it("forensic includes the full field set", () => {
196
+ const md = formatAnnotation(sample, { detail: "forensic", index: 1 });
197
+ expect(md).toContain("# Annotation");
198
+ expect(md).toContain("Annotation #1");
199
+ expect(md).toContain("**Element:** button");
200
+ expect(md).toContain("**Path:**");
201
+ expect(md).toContain("**React:** App > LandingPage");
202
+ expect(md).toContain("**Severity:**");
203
+ expect(md).toContain("Intent");
204
+ expect(md).toContain("**Nearby text:**");
205
+ expect(md).toContain("**Thread:**");
206
+ expect(md).toContain("agent");
207
+ expect(md).toContain("Button is cut off on mobile viewport");
208
+ });
209
+
210
+ it("compact strips contextual fields", () => {
211
+ const md = formatAnnotation(sample, { detail: "compact" });
212
+ expect(md).not.toContain("**React:**");
213
+ expect(md).not.toContain("**Nearby text:**");
214
+ expect(md).toContain("**Element:**");
215
+ });
216
+
217
+ it("bundle formats N annotations with a header", () => {
218
+ const md = formatAnnotationBundle([sample, sample], {
219
+ pageUrl: "https://stripe.com",
220
+ sessionId: "s1",
221
+ });
222
+ expect(md.startsWith("# Annotations")).toBe(true);
223
+ expect(md).toContain("URL: https://stripe.com");
224
+ expect(md).toContain("Count: 2");
225
+ expect(md.match(/^## Annotation/gm) ?? []).toHaveLength(2);
226
+ });
227
+ });
228
+
229
+ describe("presence cursors (multi-cursor)", () => {
230
+ beforeEach(() => api.clearCursors());
231
+
232
+ it("setCursors replaces all; setCursor upserts by id; removeCursor drops one", () => {
233
+ api.setCursors([
234
+ { id: "you", label: "You", kind: "human", x: 30, y: 100 },
235
+ { id: "agent", label: "Agent", kind: "agent", x: 50, y: 200 },
236
+ ]);
237
+ expect(cursors.value.map((c) => c.id)).toEqual(["you", "agent"]);
238
+
239
+ api.setCursor({ id: "agent", label: "Agent", kind: "agent", x: 55, y: 210 });
240
+ expect(cursors.value).toHaveLength(2);
241
+ expect(cursors.value.find((c) => c.id === "agent")?.x).toBe(55);
242
+
243
+ api.setCursor({ id: "scout", label: "Scout", kind: "agent", x: 70, y: 150 });
244
+ expect(cursors.value).toHaveLength(3);
245
+
246
+ expect(api.removeCursor("you")).toBe(true);
247
+ expect(api.removeCursor("ghost")).toBe(false);
248
+ expect(cursors.value).toHaveLength(2);
249
+
250
+ api.clearCursors();
251
+ expect(cursors.value).toHaveLength(0);
252
+ });
253
+ });
package/src/api.ts ADDED
@@ -0,0 +1,264 @@
1
+ /**
2
+ * `window.__annotations` — the inbound JS bridge.
3
+ *
4
+ * AFS 1.1 compatible: every annotation is the flat shape from
5
+ * `./types.ts`. Callers (CDP injectors, in-page UI, agents) hit
6
+ * these methods to mutate the store; the store emits the
7
+ * corresponding `annotation.created / updated / deleted` event.
8
+ */
9
+
10
+ import { ulid } from "./ulid.js";
11
+ import {
12
+ activeThreadId,
13
+ annotations,
14
+ cursors,
15
+ mode,
16
+ sessionId,
17
+ theme,
18
+ webhookUrl,
19
+ type PresenceCursor,
20
+ } from "./store.js";
21
+ import { emit } from "./webhook.js";
22
+ import type {
23
+ Annotation,
24
+ AnnotationKind,
25
+ AnnotationStatus,
26
+ Mode,
27
+ Theme,
28
+ ThreadMessage,
29
+ } from "./types.js";
30
+
31
+ export interface AddAnnotationInput {
32
+ /** Generated if omitted. */
33
+ id?: string;
34
+ /** Markdown. */
35
+ comment: string;
36
+ elementPath: string;
37
+ element: string;
38
+ x: number;
39
+ y: number;
40
+ timestamp?: number;
41
+ url?: string;
42
+ boundingBox?: Annotation["boundingBox"];
43
+ reactComponents?: string;
44
+ cssClasses?: string;
45
+ computedStyles?: string;
46
+ accessibility?: string;
47
+ nearbyText?: string;
48
+ selectedText?: string;
49
+ isFixed?: boolean;
50
+ isMultiSelect?: boolean;
51
+ fullPath?: string;
52
+ nearbyElements?: string;
53
+ elementBoundingBoxes?: Annotation["elementBoundingBoxes"];
54
+ intent?: Annotation["intent"];
55
+ severity?: Annotation["severity"];
56
+ kind?: AnnotationKind;
57
+ placement?: Annotation["placement"];
58
+ rearrange?: Annotation["rearrange"];
59
+ status?: AnnotationStatus;
60
+ author?: Annotation["author"];
61
+ }
62
+
63
+ export interface ReplyInput {
64
+ role: "human" | "agent";
65
+ /** Markdown. */
66
+ content: string;
67
+ }
68
+
69
+ export interface AnnotationsApi {
70
+ readonly version: string;
71
+ readonly schema: "afs-1.1";
72
+ addAnnotation(input: AddAnnotationInput): string;
73
+ replyToAnnotation(annotationId: string, message: ReplyInput): string | null;
74
+ updateAnnotation(annotationId: string, patch: Partial<Annotation>): boolean;
75
+ acknowledgeAnnotation(annotationId: string): boolean;
76
+ resolveAnnotation(annotationId: string, by?: "human" | "agent"): boolean;
77
+ dismissAnnotation(annotationId: string): boolean;
78
+ removeAnnotation(annotationId: string): boolean;
79
+ clearAnnotations(): number;
80
+ /** Open an annotation's thread panel in the UI. Returns false if not found. */
81
+ focusAnnotation(annotationId: string): boolean;
82
+ /** Close any open thread panel. */
83
+ closeThread(): void;
84
+ /** Replace all live presence cursors (multi-cursor collaboration). */
85
+ setCursors(next: PresenceCursor[]): void;
86
+ /** Upsert a single actor's cursor by id. */
87
+ setCursor(cursor: PresenceCursor): void;
88
+ /** Remove one actor's cursor (e.g. an agent finished / a human left). */
89
+ removeCursor(id: string): boolean;
90
+ /** Remove all cursors. */
91
+ clearCursors(): void;
92
+ setMode(next: Mode): void;
93
+ setTheme(next: Theme): void;
94
+ getAnnotations(): Annotation[];
95
+ }
96
+
97
+ export const api: AnnotationsApi = {
98
+ version: "0.1.0",
99
+ schema: "afs-1.1",
100
+
101
+ addAnnotation(input) {
102
+ const id = input.id ?? `ann_${ulid().slice(-12).toLowerCase()}`;
103
+ const now = Date.now();
104
+ const annotation: Annotation = {
105
+ id,
106
+ comment: input.comment,
107
+ elementPath: input.elementPath,
108
+ element: input.element,
109
+ x: input.x,
110
+ y: input.y,
111
+ timestamp: input.timestamp ?? now,
112
+ url: input.url,
113
+ boundingBox: input.boundingBox,
114
+ reactComponents: input.reactComponents,
115
+ cssClasses: input.cssClasses,
116
+ computedStyles: input.computedStyles,
117
+ accessibility: input.accessibility,
118
+ nearbyText: input.nearbyText,
119
+ selectedText: input.selectedText,
120
+ isFixed: input.isFixed,
121
+ isMultiSelect: input.isMultiSelect,
122
+ fullPath: input.fullPath,
123
+ nearbyElements: input.nearbyElements,
124
+ elementBoundingBoxes: input.elementBoundingBoxes,
125
+ intent: input.intent,
126
+ severity: input.severity,
127
+ kind: input.kind ?? "feedback",
128
+ placement: input.placement,
129
+ rearrange: input.rearrange,
130
+ status: input.status ?? "pending",
131
+ thread: [],
132
+ author: input.author ?? {
133
+ kind: "agent",
134
+ id: "agent",
135
+ displayName: "Agent",
136
+ },
137
+ createdAt: new Date(now).toISOString(),
138
+ updatedAt: new Date(now).toISOString(),
139
+ };
140
+ annotations.value = [...annotations.value, annotation];
141
+ emit("annotation.created", annotation);
142
+ return id;
143
+ },
144
+
145
+ replyToAnnotation(annotationId, message) {
146
+ const existing = annotations.value.find((a) => a.id === annotationId);
147
+ if (!existing) return null;
148
+ const msg: ThreadMessage = {
149
+ id: `msg_${ulid().slice(-10).toLowerCase()}`,
150
+ role: message.role,
151
+ content: message.content,
152
+ timestamp: Date.now(),
153
+ };
154
+ annotations.value = annotations.value.map((a) =>
155
+ a.id === annotationId
156
+ ? {
157
+ ...a,
158
+ thread: [...(a.thread ?? []), msg],
159
+ updatedAt: new Date().toISOString(),
160
+ }
161
+ : a,
162
+ );
163
+ emit("thread.message", { annotationId, message: msg });
164
+ return msg.id;
165
+ },
166
+
167
+ updateAnnotation(annotationId, patch) {
168
+ let found = false;
169
+ annotations.value = annotations.value.map((a) => {
170
+ if (a.id !== annotationId) return a;
171
+ found = true;
172
+ return { ...a, ...patch, updatedAt: new Date().toISOString() } as Annotation;
173
+ });
174
+ if (found) emit("annotation.updated", { id: annotationId, patch });
175
+ return found;
176
+ },
177
+
178
+ acknowledgeAnnotation(annotationId) {
179
+ return this.updateAnnotation(annotationId, { status: "acknowledged" });
180
+ },
181
+
182
+ resolveAnnotation(annotationId, by = "agent") {
183
+ return this.updateAnnotation(annotationId, {
184
+ status: "resolved",
185
+ resolvedAt: new Date().toISOString(),
186
+ resolvedBy: by,
187
+ });
188
+ },
189
+
190
+ dismissAnnotation(annotationId) {
191
+ return this.updateAnnotation(annotationId, { status: "dismissed" });
192
+ },
193
+
194
+ removeAnnotation(annotationId) {
195
+ const before = annotations.value.length;
196
+ annotations.value = annotations.value.filter((a) => a.id !== annotationId);
197
+ const removed = annotations.value.length < before;
198
+ if (removed) emit("annotation.deleted", { id: annotationId });
199
+ return removed;
200
+ },
201
+
202
+ clearAnnotations() {
203
+ const n = annotations.value.length;
204
+ annotations.value = [];
205
+ activeThreadId.value = null;
206
+ emit("session.updated", { cleared: n });
207
+ return n;
208
+ },
209
+
210
+ focusAnnotation(annotationId) {
211
+ const exists = annotations.value.some((a) => a.id === annotationId);
212
+ if (exists) activeThreadId.value = annotationId;
213
+ return exists;
214
+ },
215
+
216
+ closeThread() {
217
+ activeThreadId.value = null;
218
+ },
219
+
220
+ setCursors(next) {
221
+ cursors.value = next;
222
+ },
223
+
224
+ setCursor(cursor) {
225
+ const others = cursors.value.filter((c) => c.id !== cursor.id);
226
+ cursors.value = [...others, cursor];
227
+ },
228
+
229
+ removeCursor(id) {
230
+ const before = cursors.value.length;
231
+ cursors.value = cursors.value.filter((c) => c.id !== id);
232
+ return cursors.value.length < before;
233
+ },
234
+
235
+ clearCursors() {
236
+ cursors.value = [];
237
+ },
238
+
239
+ setMode(next) {
240
+ if (mode.value === next) return;
241
+ mode.value = next;
242
+ emit("session.updated", { mode: next });
243
+ },
244
+
245
+ setTheme(next) {
246
+ theme.value = next;
247
+ },
248
+
249
+ getAnnotations() {
250
+ return annotations.value;
251
+ },
252
+ };
253
+
254
+ export function initSession(opts: {
255
+ webhookUrl?: string;
256
+ sessionId?: string;
257
+ }): void {
258
+ if (opts.webhookUrl !== undefined) webhookUrl.value = opts.webhookUrl;
259
+ sessionId.value = opts.sessionId ?? `cf_${ulid().slice(-12).toLowerCase()}`;
260
+ emit("session.created", {
261
+ sessionId: sessionId.value,
262
+ pageUrl: typeof location !== "undefined" ? location.href : "",
263
+ });
264
+ }