@comty.io/core 0.1.5 → 0.1.7

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/src/browser.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createInspectMarkup } from "./inspect-markup.js";
1
2
  import { getComtyStyle } from "./style.js";
2
3
  import { createAnchorMatcher } from "./anchors.js";
3
4
  import { createScreenshotCapture, createColorMagnifier, captureContext } from "./capture.js";
@@ -9,7 +10,17 @@ const instanceKey = Symbol.for("comty.sdk.instance");
9
10
  /** Initialize once per document; safe to import and call during server rendering. */
10
11
  export function initComty(options = {}) {
11
12
  if (typeof window === "undefined" || typeof document === "undefined") return Promise.resolve();
12
- const projectKey = options.projectKey;
13
+ const isLocalSite = ["localhost", "127.0.0.1", "[::1]"].includes(window.location.hostname);
14
+ const agent = options.agent;
15
+ if (agent && (!isLocalSite || typeof agent.token !== "string" || !/^[a-f0-9]{64}$/.test(agent.token))) {
16
+ return Promise.reject(new TypeError("Comty: invalid local companion configuration"));
17
+ }
18
+ if (agent) {
19
+ try {
20
+ if (new URL(agent.url).origin !== window.location.origin) throw new Error();
21
+ } catch { return Promise.reject(new TypeError("Comty: companion must share the page origin")); }
22
+ }
23
+ const projectKey = options.projectKey || (agent ? "local" : undefined);
13
24
  const apiUrl = options.apiUrl || "http://localhost:4000";
14
25
  if (typeof projectKey !== "string" || !projectKey.trim()) {
15
26
  return Promise.reject(new TypeError("Comty: projectKey is required"));
@@ -21,12 +32,14 @@ export function initComty(options = {}) {
21
32
  if (!["http:", "https:"].includes(parsedApi.protocol)) {
22
33
  return Promise.reject(new TypeError("Comty: apiUrl must be an absolute HTTP(S) URL"));
23
34
  }
24
- const configuration = JSON.stringify([projectKey, apiUrl]);
35
+ const configuration = JSON.stringify([projectKey, apiUrl, agent || null]);
25
36
  const existing = window[instanceKey];
26
37
  if (existing) {
27
38
  return existing.configuration === configuration ? existing.ready
28
39
  : Promise.reject(new Error("Comty: already initialized with different options"));
29
40
  }
41
+ const agentBridgeUrl = agent?.url;
42
+ const localOnly = Boolean(agent && !options.projectKey);
30
43
  const ready = new Promise((resolve, reject) => {
31
44
  const mount = () => {
32
45
  try { resolve(mountComty()); } catch (error) { reject(error); }
@@ -40,10 +53,24 @@ export function initComty(options = {}) {
40
53
 
41
54
  function mountComty() {
42
55
  let projectId = null;
56
+ let localTasks = [];
57
+ const deliveringAgentReplies = new Set();
58
+ let localApprovals = [];
59
+ const seenAgentApprovals = new Set();
60
+ // Only requests made in this document may interrupt it with agent notifications.
61
+ const requestedAgentTasks = new Set();
62
+ let agentPanelOpen = false;
63
+ let agentPanelSignature = "";
64
+ let agentPanelTransition = 0;
65
+ let agentConnectionError = "";
66
+ let agentActionError = "";
67
+ let clearAgentToast = () => {};
68
+ const agentPendingActions = new Set();
43
69
  const session = {
44
70
  mode: "off",
45
- get comments() { return this.mode === "browse" || this.mode === "edit"; },
46
- get edit() { return this.mode === "edit"; },
71
+ get comments() { return this.mode === "browse" || this.mode === "edit" || this.mode === "agent"; },
72
+ get edit() { return this.mode === "edit" || this.mode === "agent"; },
73
+ get agent() { return this.mode === "agent"; },
47
74
  get inspect() { return this.mode === "inspect"; },
48
75
  };
49
76
  const selectedStatusFilters = new Set();
@@ -67,9 +94,10 @@ export function initComty(options = {}) {
67
94
  const authReturnStorageKey = `comty:${projectKey}:auth-return`;
68
95
  let sdkToken = "";
69
96
  const activeHoverCommentIds = new Set();
70
- /** @type {null | 'edit' | 'browse'} */
97
+ /** @type {null | 'edit' | 'browse' | 'agent'} */
71
98
  let sessionEntry = null;
72
99
  const placedPins = new Map();
100
+ const workingOutlines = new Map();
73
101
  const placementDiagnostics = new Map();
74
102
  let inspectedElement = null;
75
103
  let selectedElement = null;
@@ -83,8 +111,8 @@ export function initComty(options = {}) {
83
111
  const commentTargets = new Map();
84
112
 
85
113
  const {
86
- matchesAnchorContext, resolveByAnchorContext,
87
- isElementTreeHidden, getTopActiveModal, getVisibleAnchorPoint,
114
+ matchesAnchorContext,
115
+ resolveAnchor, isElementTreeHidden, getTopActiveModal, getVisibleAnchorPoint,
88
116
  } = createAnchorMatcher({
89
117
  getElementTextSignature,
90
118
  getElementOwnTextSignature,
@@ -162,10 +190,27 @@ export function initComty(options = {}) {
162
190
  const inspectorBtn = toolButton("Inspect · Shift I", '<path d="M8 3H5a2 2 0 0 0-2 2v3m13-5h3a2 2 0 0 1 2 2v3M3 16v3a2 2 0 0 0 2 2h3m13-5v3a2 2 0 0 1-2 2h-3"/><path d="m9 9 8 4-4 1-1 4Z"/>', () => setSession("inspect"));
163
191
  const btn = toolButton("Comment · Shift C", '<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6A8.4 8.4 0 0 1 12.5 3h.5a8.5 8.5 0 0 1 8 8v.5Z"/>', () => {
164
192
  if (!canWrite) showAuthPrompt();
165
- else if (session.inspect || !isSessionActive()) startEditSession();
193
+ else if (session.agent || session.inspect || !isSessionActive()) startEditSession();
166
194
  else if (!session.edit) setSession("edit", { entry: sessionEntry });
167
195
  });
168
196
  btn.id = "comty-toggle";
197
+ const agentBtn = isLocalSite
198
+ ? toolButton("Agent task · localhost only", '<path d="M8 7 3 12l5 5m8-10 5 5-5 5M14 4l-4 16"/>', () => {
199
+ if (session.agent) {
200
+ const failedTask = [...localTasks].reverse().find(task => task.status === 'in_review' && !task.thread_comment_id);
201
+ const failedPin = failedTask && placedPins.get(String(failedTask.id));
202
+ if (failedPin && !failedPin.hasAttribute('data-comty-anchor-hidden')) { failedPin.click(); return; }
203
+ if (localTasks.some(task => task.status === 'in_review' || (!task.thread_comment_id && task.status === 'done')) || localApprovals.length) {
204
+ agentPanelOpen = true;
205
+ renderAgentPanel();
206
+ }
207
+ } else startAgentSession();
208
+ })
209
+ : null;
210
+ if (agentBtn) {
211
+ agentBtn.title = "Agent · Shift A · localhost only";
212
+ agentBtn.setAttribute("aria-keyshortcuts", "Shift+A");
213
+ }
169
214
  const divider = document.createElement("span");
170
215
  divider.className = "comty-tool-divider";
171
216
  toolbar.appendChild(divider);
@@ -175,7 +220,7 @@ export function initComty(options = {}) {
175
220
  const primaryTools = document.createElement("div");
176
221
  primaryTools.className = "comty-tool-primary";
177
222
  toolbar.insertBefore(primaryTools, browseBtn);
178
- for (const button of [browseBtn, inspectorBtn, btn]) primaryTools.appendChild(button);
223
+ for (const button of [browseBtn, inspectorBtn, btn, agentBtn].filter(Boolean)) primaryTools.appendChild(button);
179
224
  const inspectorTools = document.createElement("div");
180
225
  inspectorTools.className = "comty-tool-context";
181
226
  inspectorTools.hidden = true;
@@ -205,6 +250,7 @@ export function initComty(options = {}) {
205
250
  root,
206
251
  isComtyElement,
207
252
  isElementTreeHidden,
253
+ isInteractionBlocked: () => Boolean(markup.mode),
208
254
  onExit() { rulersEnabled = false; syncInspectorTools(); },
209
255
  });
210
256
  const gridLayer = document.createElement("div");
@@ -231,6 +277,7 @@ export function initComty(options = {}) {
231
277
  syncInspectorTools();
232
278
  });
233
279
  const rulersBtn = toolButton("Rulers · R", '<path d="M3 3h18v6H9v12H3Z"/><path d="M7 3v3m4-3v3m4-3v3M3 11h3m-3 4h3m-3 4h3"/>', () => {
280
+ markup.setMode(null);
234
281
  closePropertyPanel();
235
282
  if (motion.enabled) setMotionEnabled(false);
236
283
  rulersEnabled = !rulersEnabled;
@@ -241,6 +288,7 @@ export function initComty(options = {}) {
241
288
  syncInspectorTools();
242
289
  });
243
290
  const gridBtn = toolButton("Layout guides · G", '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 10h18M10 3v18"/>', () => {
291
+ markup.setMode(null);
244
292
  closePropertyPanel();
245
293
  if (motion.enabled) setMotionEnabled(false);
246
294
  gridPanelOpen = !gridPanelOpen;
@@ -258,7 +306,37 @@ export function initComty(options = {}) {
258
306
  }
259
307
  const motionBtn = toolButton("Motion · M", '<circle cx="12" cy="14" r="8"/><path d="M9 2h6m-3 4V2m6 5 2-2m-8 5v4l3 2"/>', () => setMotionEnabled(!motion.enabled));
260
308
  motionBtn.setAttribute("aria-expanded", "false");
261
- for (const button of [xrayBtn, rulersBtn, gridBtn, pipetteBtn, typographyBtn, motionBtn]) inspectorTools.appendChild(button);
309
+ const annotateBtn = toolButton("Annotate · D", '<path d="m16 3 5 5L8 21H3v-5ZM14 5l5 5"/>', () => toggleMarkup("annotate"));
310
+ const screenshotBtn = toolButton("Capture area · S", '<path d="M8 3H5a2 2 0 0 0-2 2v3m13-5h3a2 2 0 0 1 2 2v3M3 16v3a2 2 0 0 0 2 2h3m13-5v3a2 2 0 0 1-2 2h-3"/><rect x="7" y="8" width="10" height="8" rx="1"/><circle cx="12" cy="12" r="2"/>', () => toggleMarkup("capture"));
311
+ const markup = createInspectMarkup({
312
+ root, icon,
313
+ onError: description => showNotice({ title: "Screenshot unavailable", description }),
314
+ onChange() {
315
+ annotateBtn.setAttribute("aria-pressed", String(markup.mode === "annotate"));
316
+ annotateBtn.setAttribute("aria-expanded", String(markup.mode === "annotate"));
317
+ screenshotBtn.setAttribute("aria-pressed", String(markup.mode === "capture"));
318
+ screenshotBtn.setAttribute("aria-busy", String(markup.busy));
319
+ positionToolbar();
320
+ },
321
+ capture: (region, layer) => captureViewportScreenshot({ region, annotation: () => ({
322
+ elements: [overlayTools, root.querySelector('.comty-distance-layer'), layer].filter(node => node && !node.hidden),
323
+ dispose() {},
324
+ }) }),
325
+ });
326
+ for (const button of [annotateBtn]) {
327
+ button.setAttribute("aria-controls", markup.panel.id);
328
+ button.setAttribute("aria-expanded", "false");
329
+ }
330
+ function toggleMarkup(mode) {
331
+ const next = markup.mode === mode ? null : mode;
332
+ closePropertyPanel();
333
+ if (motion.enabled) setMotionEnabled(false);
334
+ gridPanelOpen = false; toolPanel.hidden = true; gridBtn.setAttribute("aria-expanded", "false");
335
+ cancelRulerDrag();
336
+ selectedElement = null; measuredRegion = null; inspectDrag = null; hideInspector();
337
+ markup.setMode(next);
338
+ }
339
+ for (const button of [xrayBtn, rulersBtn, gridBtn, pipetteBtn, typographyBtn, motionBtn, annotateBtn, screenshotBtn]) inspectorTools.appendChild(button);
262
340
 
263
341
  const motion = { enabled: false, speed: 1, paused: false, managed: new Map(), shadowRoots: [], nextRootScan: 0 };
264
342
  const motionPanel = document.createElement('section');
@@ -307,6 +385,7 @@ export function initComty(options = {}) {
307
385
  motion.enabled = enabled; motionPanel.hidden = !enabled;
308
386
  motionBtn.setAttribute('aria-pressed', String(enabled)); motionBtn.setAttribute('aria-expanded', String(enabled));
309
387
  if (enabled) {
388
+ markup.setMode(null);
310
389
  closePropertyPanel();
311
390
  rulersEnabled = false;
312
391
  syncInspectorTools();
@@ -565,6 +644,7 @@ export function initComty(options = {}) {
565
644
  const visible = session.inspect;
566
645
  measurements.setEnabled(visible && rulersEnabled && !motion.enabled);
567
646
  overlayTools.hidden = !visible;
647
+ markup.setVisible(visible);
568
648
  xrayBtn.setAttribute("aria-pressed", String(visible && xrayEnabled));
569
649
  rulersBtn.setAttribute("aria-pressed", String(visible && rulersEnabled));
570
650
  gridBtn.setAttribute("aria-pressed", String(visible && layoutGuides.some(guide => guide.visible)));
@@ -848,6 +928,7 @@ export function initComty(options = {}) {
848
928
  }
849
929
 
850
930
  function openPropertyPanel(kind, element, property, opener = null, sampledColor = null) {
931
+ markup.setMode(null);
851
932
  propertyPicking = null;
852
933
  colorMagnifier.setEnabled(false);
853
934
  delete document.documentElement.dataset.comtyPropertyPicking;
@@ -1028,6 +1109,50 @@ export function initComty(options = {}) {
1028
1109
  let toolbarMotionFrame = 0;
1029
1110
  let toolbarAnimations = [];
1030
1111
  const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
1112
+ let workingIndicatorTimer = 0;
1113
+ function scheduleWorkingIndicators() {
1114
+ if (workingIndicatorTimer || reducedMotion.matches || document.hidden) return;
1115
+ workingIndicatorTimer = setTimeout(() => {
1116
+ workingIndicatorTimer = 0;
1117
+ if (!root.isConnected) return;
1118
+ const indicators = root.querySelectorAll('.comty-working-indicator');
1119
+ if (!indicators.length) return;
1120
+ for (const indicator of indicators) {
1121
+ const previous = Number(indicator.dataset.activeDots.split(',')[0]);
1122
+ const first = (previous + 1 + Math.floor(Math.random() * 8)) % 9;
1123
+ const second = (first + 1 + Math.floor(Math.random() * 8)) % 9;
1124
+ indicator.dataset.activeDots = `${first},${second}`;
1125
+ [...indicator.children].forEach((dot, index) => dot.toggleAttribute('data-active', index === first || index === second));
1126
+ }
1127
+ scheduleWorkingIndicators();
1128
+ }, 600);
1129
+ }
1130
+ function createWorkingIndicator() {
1131
+ const indicator = document.createElement('span');
1132
+ indicator.className = 'comty-working-indicator';
1133
+ indicator.setAttribute('aria-hidden', 'true');
1134
+ indicator.dataset.activeDots = '3,5';
1135
+ for (let index = 0; index < 9; index++) {
1136
+ const dot = document.createElement('i');
1137
+ dot.toggleAttribute('data-active', index === 3 || index === 5);
1138
+ indicator.appendChild(dot);
1139
+ }
1140
+ scheduleWorkingIndicators();
1141
+ return indicator;
1142
+ }
1143
+ function resetWorkingIndicators() {
1144
+ clearTimeout(workingIndicatorTimer);
1145
+ workingIndicatorTimer = 0;
1146
+ if (reducedMotion.matches) {
1147
+ for (const indicator of root.querySelectorAll('.comty-working-indicator')) {
1148
+ indicator.dataset.activeDots = '3,5';
1149
+ [...indicator.children].forEach((dot, index) => dot.toggleAttribute('data-active', index === 3 || index === 5));
1150
+ }
1151
+ }
1152
+ if (root.querySelector('.comty-working-indicator')) scheduleWorkingIndicators();
1153
+ }
1154
+ reducedMotion.addEventListener('change', resetWorkingIndicators);
1155
+ document.addEventListener('visibilitychange', resetWorkingIndicators);
1031
1156
  const clampToolbar = (value, size, viewport) => Math.max(8, Math.min(value, viewport - size - 8));
1032
1157
  function rememberToolbarPosition(left, top) {
1033
1158
  const width = toolbar.offsetWidth, height = toolbar.offsetHeight;
@@ -1061,7 +1186,7 @@ export function initComty(options = {}) {
1061
1186
  });
1062
1187
  }
1063
1188
  const rect = toolbar.getBoundingClientRect();
1064
- for (const panel of [toolPanel, propertyPanel, sidepanel, motionPanel]) {
1189
+ for (const panel of [toolPanel, propertyPanel, sidepanel, motionPanel, markup.panel]) {
1065
1190
  if (panel.hidden || (panel === sidepanel && !sidepanelOpen)) continue;
1066
1191
  const above = rect.top - 16;
1067
1192
  const below = innerHeight - rect.bottom - 16;
@@ -1078,9 +1203,10 @@ export function initComty(options = {}) {
1078
1203
  panel.style.top = `${useAbove ? rect.top - panel.offsetHeight - 8 : rect.bottom + 8}px`;
1079
1204
  panel.style.bottom = "auto";
1080
1205
  }
1206
+ positionNotice();
1081
1207
  }
1082
1208
  const toolbarResize = new ResizeObserver(positionToolbar);
1083
- [toolbar, toolPanel, propertyPanel, sidepanel, motionPanel].forEach(node => toolbarResize.observe(node));
1209
+ [toolbar, toolPanel, propertyPanel, sidepanel, motionPanel, markup.panel].forEach(node => toolbarResize.observe(node));
1084
1210
  window.addEventListener("resize", positionToolbar);
1085
1211
 
1086
1212
  toolbar.addEventListener("pointerdown", event => {
@@ -1140,7 +1266,7 @@ export function initComty(options = {}) {
1140
1266
 
1141
1267
  function prepareToolbarMotion() {
1142
1268
  if (toolbarMotionFrame) return;
1143
- const nodes = [toolbarSurface, dragHandle, primaryTools, commentTools, inspectorTools].filter(Boolean);
1269
+ const nodes = [toolbarSurface, dragHandle, primaryTools, commentTools, inspectorTools];
1144
1270
  // Move each group's background with its icons, including when interrupted.
1145
1271
  const before = new Map(nodes.filter(node => !node.hidden).map(node => [node, {
1146
1272
  rect: node.getBoundingClientRect(), opacity: getComputedStyle(node).opacity,
@@ -1364,7 +1490,8 @@ export function initComty(options = {}) {
1364
1490
  selectedElement = null;
1365
1491
  sessionEntry = null;
1366
1492
  session.mode = "off";
1367
- document.documentElement.classList.remove("comty-comments-active", "comty-comment-placement");
1493
+ syncPlacementCursor();
1494
+ document.documentElement.classList.remove("comty-comments-active", "comty-inspect-annotations");
1368
1495
  hideInspector();
1369
1496
  setSidepanelOpen(false);
1370
1497
  closeBubbles();
@@ -1378,25 +1505,29 @@ export function initComty(options = {}) {
1378
1505
  if (mode === "off") return;
1379
1506
  if (mode === "inspect") {
1380
1507
  session.mode = "inspect";
1508
+ document.documentElement.classList.add("comty-inspect-annotations");
1381
1509
  syncToolbar();
1510
+ renderCachedComments();
1382
1511
  ensureLiveTracking();
1383
1512
  return;
1384
1513
  }
1385
1514
 
1386
- if (session.edit && mode === "browse") {
1515
+ if ((session.edit && mode === "browse") || (session.agent && mode === "edit")) {
1387
1516
  closeBubbles();
1388
1517
  }
1389
1518
  const hadComments = session.comments;
1390
1519
  session.mode = mode;
1391
1520
  sessionEntry = entry;
1521
+ document.documentElement.classList.remove("comty-inspect-annotations");
1392
1522
  document.documentElement.classList.toggle("comty-comments-active", session.comments);
1393
- document.documentElement.classList.toggle("comty-comment-placement", session.edit);
1523
+ syncPlacementCursor();
1394
1524
  if (session.edit) {
1395
1525
  setMotionEnabled(false);
1396
1526
  selectedElement = null;
1397
1527
  } else {
1398
1528
  hideInspector();
1399
1529
  }
1530
+ if (session.agent && sidepanelOpen) setSidepanelOpen(false);
1400
1531
  syncToolbar();
1401
1532
  ensureLiveTracking();
1402
1533
  if (renderComments || !hadComments) renderCachedComments();
@@ -1409,6 +1540,10 @@ export function initComty(options = {}) {
1409
1540
  }
1410
1541
 
1411
1542
  function showAuthPrompt(forbidden = false, onReady = startEditSession) {
1543
+ if (localOnly) {
1544
+ showNotice({ title: "Comments are not configured", description: "Connect this site to a Comty project to leave shared comments." });
1545
+ return;
1546
+ }
1412
1547
  const guestAllowed = allowGuestComments;
1413
1548
  document.querySelector(".comty-auth-backdrop")?.remove();
1414
1549
  const opener = document.activeElement;
@@ -1530,14 +1665,17 @@ export function initComty(options = {}) {
1530
1665
  }
1531
1666
 
1532
1667
  function syncToolbar() {
1533
- if (commentTools.hidden !== session.inspect || inspectorTools.hidden !== !session.inspect) prepareToolbarMotion();
1668
+ if (commentTools.hidden !== (session.inspect || session.agent) || inspectorTools.hidden !== !session.inspect) prepareToolbarMotion();
1534
1669
  root.classList.toggle("comty-active", session.comments || session.edit || sidepanelOpen || session.inspect);
1535
1670
  browseBtn.setAttribute("aria-pressed", String(!session.edit && !session.inspect));
1536
1671
  inspectorBtn.setAttribute("aria-pressed", String(session.inspect));
1537
- btn.setAttribute("aria-pressed", String(session.edit));
1538
- commentTools.hidden = session.inspect;
1672
+ btn.setAttribute("aria-pressed", String(session.edit && !session.agent));
1673
+ agentBtn?.setAttribute("aria-pressed", String(session.edit && session.agent));
1674
+ commentTools.hidden = session.inspect || session.agent;
1539
1675
  inspectorTools.hidden = !session.inspect;
1540
1676
  syncInspectorTools();
1677
+ renderAgentPanel();
1678
+ positionNotice();
1541
1679
  }
1542
1680
 
1543
1681
  const inspectionLayer = document.createElement("div");
@@ -1850,7 +1988,7 @@ export function initComty(options = {}) {
1850
1988
  });
1851
1989
  return;
1852
1990
  }
1853
- if (propertyPicking || motion.enabled || (session.inspect && rulersEnabled)) {
1991
+ if (markup.mode || propertyPicking || motion.enabled || (session.inspect && rulersEnabled)) {
1854
1992
  if (inspectionLayer.childNodes.length) hideInspector();
1855
1993
  return;
1856
1994
  }
@@ -1994,10 +2132,20 @@ export function initComty(options = {}) {
1994
2132
 
1995
2133
  function startBrowseSession() {
1996
2134
  setSession("browse", { renderComments: true });
2135
+ void loadComments({ force: true });
1997
2136
  }
1998
2137
 
1999
2138
  function startEditSession() {
2000
2139
  setSession("edit", { renderComments: true });
2140
+ void loadComments({ force: true });
2141
+ }
2142
+
2143
+ function startAgentSession() {
2144
+ if (!isLocalSite) return;
2145
+ if (session.agent) return;
2146
+ closeBubbles();
2147
+ setSession("agent", { renderComments: true });
2148
+ void loadComments({ force: true });
2001
2149
  }
2002
2150
 
2003
2151
  function toggleSidepanel() {
@@ -2023,7 +2171,7 @@ export function initComty(options = {}) {
2023
2171
  showAuthPrompt();
2024
2172
  return;
2025
2173
  }
2026
- if (session.inspect || !isSessionActive()) {
2174
+ if (session.agent || session.inspect || !isSessionActive()) {
2027
2175
  startEditSession();
2028
2176
  return;
2029
2177
  }
@@ -2311,6 +2459,24 @@ export function initComty(options = {}) {
2311
2459
  ancestors,
2312
2460
  layer: layer ? getElementFingerprint(layer) : null,
2313
2461
  };
2462
+ // React exposes this in development builds. Keep it as a hint: the coding
2463
+ // agent must confirm the component and variant in source before editing.
2464
+ const fiberKey = Object.keys(el).find(key => key.startsWith('__reactFiber$'));
2465
+ let fiber = fiberKey ? el[fiberKey] : null;
2466
+ const components = [];
2467
+ while (fiber && components.length < 8) {
2468
+ const component = fiber.type;
2469
+ const name = component?.displayName || component?.name;
2470
+ if (name && typeof name === 'string') {
2471
+ const props = fiber.memoizedProps || {};
2472
+ const variants = Object.fromEntries(['variant', 'size', 'tone', 'appearance'].filter(key =>
2473
+ ['string', 'number', 'boolean'].includes(typeof props[key])).map(key => [key, props[key]]));
2474
+ const source = fiber._debugSource;
2475
+ components.push({ name, variants, ...(source?.fileName ? { file: source.fileName, line: source.lineNumber } : {}) });
2476
+ }
2477
+ fiber = fiber.return;
2478
+ }
2479
+ if (components.length) context.components = components;
2314
2480
  context.candidateCount = [...document.querySelectorAll(context.target.tag)]
2315
2481
  .filter(candidate => !isComtyNode(candidate) && matchesAnchorContext(candidate, context)).length;
2316
2482
  return context;
@@ -2339,6 +2505,11 @@ export function initComty(options = {}) {
2339
2505
  };
2340
2506
  }
2341
2507
 
2508
+ function getPagePath() {
2509
+ const { pathname, hash } = window.location;
2510
+ return pathname + (hash.startsWith("#/") || hash.startsWith("#!/") ? hash : "");
2511
+ }
2512
+
2342
2513
  function resolveBySelector(selector) {
2343
2514
  if (!selector) return null;
2344
2515
  try {
@@ -2360,17 +2531,9 @@ export function initComty(options = {}) {
2360
2531
  }
2361
2532
  const context = parseAnchorContext(comment);
2362
2533
  const exact = resolveBySelector(comment.selector || "body");
2363
- if (context?.candidateCount > 1) {
2364
- const count = [...document.querySelectorAll("*")].filter(candidate => candidate.tagName.toLowerCase() === context.target.tag)
2365
- .filter(candidate => !isComtyNode(candidate) && matchesAnchorContext(candidate, context)).length;
2366
- if (count !== context.candidateCount) {
2367
- commentTargets.set(key, { element: null, signature, revision: anchorRevision });
2368
- return null;
2369
- }
2370
- }
2371
- let element = exact && matchesAnchorContext(exact, context) ? getAnchorElement(exact) : null;
2372
- if (!element) element = resolveByAnchorContext(context);
2373
- commentTargets.set(key, { element, signature, revision: anchorRevision });
2534
+ const resolution = resolveAnchor(context, exact);
2535
+ const element = resolution.element ? getAnchorElement(resolution.element) : null;
2536
+ commentTargets.set(key, { ...resolution, element, signature, revision: anchorRevision });
2374
2537
  return element;
2375
2538
  }
2376
2539
 
@@ -2441,9 +2604,14 @@ export function initComty(options = {}) {
2441
2604
  return { deviceType: "desktop", deviceLabel: "Desktop" };
2442
2605
  }
2443
2606
 
2607
+ function syncPlacementCursor() {
2608
+ document.documentElement.classList.toggle("comty-comment-placement", session.edit && !draftAnchor);
2609
+ }
2610
+
2444
2611
  function closeBubbles() {
2445
2612
  const hadDraft = Boolean(draftAnchor);
2446
2613
  draftAnchor = null;
2614
+ syncPlacementCursor();
2447
2615
  activeHoverCommentIds.clear();
2448
2616
  if (!session.edit || hadDraft) hideInspector();
2449
2617
  document
@@ -2488,7 +2656,7 @@ export function initComty(options = {}) {
2488
2656
  pending: commentId.startsWith("pending-"),
2489
2657
  project_id: projectId,
2490
2658
  domain: payload.host || window.location.host,
2491
- page_path: payload.pagePath || window.location.pathname,
2659
+ page_path: payload.pagePath || getPagePath(),
2492
2660
  selector: payload.selector || "body",
2493
2661
  rel_x: payload.relX,
2494
2662
  rel_y: payload.relY,
@@ -2513,7 +2681,7 @@ export function initComty(options = {}) {
2513
2681
  replies: [],
2514
2682
  };
2515
2683
 
2516
- cachedPagePath = window.location.pathname;
2684
+ cachedPagePath = getPagePath();
2517
2685
  cachedComments = [
2518
2686
  ...cachedComments.filter(
2519
2687
  (cached) => getCommentId(cached) !== commentId,
@@ -2526,6 +2694,7 @@ export function initComty(options = {}) {
2526
2694
  }
2527
2695
 
2528
2696
  function getCommentAuthorName(comment) {
2697
+ if (comment?.author_kind === 'agent') return 'Agent';
2529
2698
  return comment?.author_name || comment?.name || "Anonymous";
2530
2699
  }
2531
2700
 
@@ -2547,6 +2716,12 @@ export function initComty(options = {}) {
2547
2716
  function createCommentAvatar(comment, extraClass = "") {
2548
2717
  const avatar = document.createElement("span");
2549
2718
  avatar.className = `comty-comment-avatar${extraClass ? ` ${extraClass}` : ""}`;
2719
+ if (comment?.author_kind === 'agent') {
2720
+ avatar.classList.add('comty-agent-avatar');
2721
+ avatar.setAttribute('aria-label', 'Agent');
2722
+ avatar.innerHTML = '<svg viewBox="0 0 48 48" aria-hidden="true"><path d="M24 2C26.62 2 28.47 5.29 31.27 6.45C34.07 7.61 37.71 6.59 39.56 8.44C41.41 10.29 40.39 13.93 41.55 16.73C42.71 19.53 46 21.38 46 24C46 26.62 42.71 28.47 41.55 31.27C40.39 34.07 41.41 37.71 39.56 39.56C37.71 41.41 34.07 40.39 31.27 41.55C28.47 42.71 26.62 46 24 46C21.38 46 19.53 42.71 16.73 41.55C13.93 40.39 10.29 41.41 8.44 39.56C6.59 37.71 7.61 34.07 6.45 31.27C5.29 28.47 2 26.62 2 24C2 21.38 5.29 19.53 6.45 16.73C7.61 13.93 6.59 10.29 8.44 8.44C10.29 6.59 13.93 7.61 16.73 6.45C19.53 5.29 21.38 2 24 2Z" fill="currentColor"/><path fill="var(--comty-surface)" fill-rule="evenodd" d="M16 33 22.3 15h3.4L32 33h-3.3l-1.3-4h-6.8l-1.3 4H16Zm5.5-6.8h5L24 18.4l-2.5 7.8Z"/></svg>';
2723
+ return avatar;
2724
+ }
2550
2725
  const authorName = getCommentAuthorName(comment);
2551
2726
  const avatarUrl = getCommentAuthorAvatarUrl(comment);
2552
2727
 
@@ -2554,6 +2729,7 @@ export function initComty(options = {}) {
2554
2729
  const image = document.createElement("img");
2555
2730
  image.src = avatarUrl;
2556
2731
  image.alt = "";
2732
+ image.draggable = false;
2557
2733
  image.onerror = () => {
2558
2734
  image.remove();
2559
2735
  avatar.innerText = authorName.charAt(0).toUpperCase();
@@ -2707,6 +2883,7 @@ export function initComty(options = {}) {
2707
2883
  function createCommentPin(comment, x, y) {
2708
2884
  const pin = document.createElement("button");
2709
2885
  pin.className = "comty-pin";
2886
+ pin.toggleAttribute('data-agent-task', Boolean(comment.local_agent));
2710
2887
  pin.type = "button";
2711
2888
  pin.style.left = `${x}px`;
2712
2889
  pin.style.top = `${y}px`;
@@ -2724,10 +2901,52 @@ export function initComty(options = {}) {
2724
2901
  const thread = document.createElement("div");
2725
2902
  thread.className = "comty-thread";
2726
2903
  thread.appendChild(createThreadItem(comment, { showMore: false, showLink: false }).item);
2904
+ if (comment.local_agent) {
2905
+ thread.appendChild(createThreadItem({ author_kind: 'agent', content: 'Modification failed. Open to retry or dismiss.' }, { showMore: false }).item);
2906
+ }
2727
2907
  bubble.appendChild(thread);
2728
2908
  return bubble;
2729
2909
  }
2730
2910
 
2911
+ function openAgentTask(task, x, y) {
2912
+ closeBubbles();
2913
+ const bubble = document.createElement('div');
2914
+ bubble.className = 'comty-bubble comty-agent-task-thread';
2915
+ bubble.dataset.commentId = task.id;
2916
+ bubble.setAttribute('role', 'dialog');
2917
+ bubble.setAttribute('aria-label', 'Failed modification');
2918
+ const header = document.createElement('div'); header.className = 'comty-bubble-header';
2919
+ const title = document.createElement('strong'); title.textContent = 'Modification failed';
2920
+ const close = document.createElement('button'); close.type = 'button'; close.className = 'comty-icon-button';
2921
+ close.setAttribute('aria-label', 'Close failed modification');
2922
+ close.innerHTML = icon('<path d="m6 6 12 12M6 18 18 6"/>');
2923
+ close.onclick = () => closeBubbles();
2924
+ header.append(title, close);
2925
+ const thread = document.createElement('div'); thread.className = 'comty-thread';
2926
+ const list = document.createElement('div'); list.className = 'comty-thread-list';
2927
+ const issue = task.error ? describeAgentError(task.error) : null;
2928
+ list.append(
2929
+ createThreadItem(task, { showMore: false }).item,
2930
+ createThreadItem({ author_kind: 'agent', content: task.result?.summary || issue?.message || issue?.description || 'The agent could not complete this request.' }, { showMore: false }).item,
2931
+ );
2932
+ thread.appendChild(list);
2933
+ const actions = document.createElement('div'); actions.className = 'comty-agent-task-actions';
2934
+ for (const action of ['retry', 'dismiss']) {
2935
+ const button = document.createElement('button'); button.type = 'button'; button.className = 'comty-agent-action';
2936
+ button.textContent = action === 'retry' ? 'Retry' : 'Dismiss';
2937
+ button.onclick = async () => {
2938
+ for (const control of actions.children) control.disabled = true;
2939
+ try { await runAgentAction(`tasks/${task.id}/${action}`); }
2940
+ catch (error) { showAgentBridgeError(error.message); }
2941
+ finally { for (const control of actions.children) control.disabled = false; }
2942
+ };
2943
+ actions.appendChild(button);
2944
+ }
2945
+ bubble.append(header, thread, actions);
2946
+ root.appendChild(bubble);
2947
+ positionCommentBubble(bubble, x, y);
2948
+ }
2949
+
2731
2950
  function mountCaptureAnnotation(comment, { x, y }) {
2732
2951
  const host = document.createElement("div");
2733
2952
  host.dataset.comtyCaptureExclude = "";
@@ -2778,6 +2997,7 @@ export function initComty(options = {}) {
2778
2997
  payload,
2779
2998
  preview: { rect: target.getBoundingClientRect(), rows: getInspectRows(target), selector: getSelector(target), spacing: getSpacingPreview(target) },
2780
2999
  };
3000
+ syncPlacementCursor();
2781
3001
  ensureLiveTracking();
2782
3002
 
2783
3003
  const pin = document.createElement("div");
@@ -2795,10 +3015,21 @@ export function initComty(options = {}) {
2795
3015
 
2796
3016
  const textarea = document.createElement("textarea");
2797
3017
  textarea.className = "comty-create-textarea";
2798
- textarea.placeholder = "Add a comment";
3018
+ textarea.placeholder = payload.kind === "agent" ? "Ask a question or describe a change" : "Add a comment";
2799
3019
  textarea.setAttribute("aria-label", textarea.placeholder);
2800
3020
  textarea.rows = 1;
2801
3021
 
3022
+ const scopePicker = document.createElement('label');
3023
+ scopePicker.className = 'comty-agent-scope';
3024
+ if (payload.kind === 'agent') {
3025
+ const input = document.createElement('input');
3026
+ input.type = 'checkbox';
3027
+ input.setAttribute('aria-label', 'Apply to all instances of this variant');
3028
+ const track = document.createElement('span'); track.className = 'comty-agent-scope-track'; track.setAttribute('aria-hidden', 'true');
3029
+ scopePicker.title = 'Apply to all instances of this variant';
3030
+ scopePicker.append(input, track, document.createTextNode('Apply globally'));
3031
+ }
3032
+
2802
3033
  const actions = document.createElement("div");
2803
3034
  actions.className = "comty-create-actions";
2804
3035
 
@@ -2852,7 +3083,7 @@ export function initComty(options = {}) {
2852
3083
  const submitBtn = document.createElement("button");
2853
3084
  submitBtn.type = "button";
2854
3085
  submitBtn.className = "comty-btn-submit";
2855
- submitBtn.setAttribute("aria-label", "Post comment");
3086
+ submitBtn.setAttribute("aria-label", payload.kind === "agent" ? "Send agent task" : "Post comment");
2856
3087
  submitBtn.disabled = true;
2857
3088
  submitBtn.innerHTML = `
2858
3089
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -2869,49 +3100,63 @@ export function initComty(options = {}) {
2869
3100
  textarea.disabled = true;
2870
3101
  linkBtn.disabled = true;
2871
3102
 
3103
+ if (payload.kind === "agent" && !agent) {
3104
+ showAgentBridgeError(); submitBtn.disabled = false; textarea.disabled = false; return;
3105
+ }
2872
3106
  payload.content = content;
3107
+ if (payload.kind === 'agent') payload.scope = scopePicker.querySelector('input').checked ? 'variant' : 'element';
2873
3108
  payload.linkUrl = attachedLink || null;
2874
3109
  textarea.textContent = content;
2875
3110
 
2876
- const context = captureContext();
2877
- const point = getPinPoint(pin);
2878
- const pendingId = `pending-${crypto.randomUUID()}`;
2879
- // Paint the comment immediately. The network save never waits for capture work.
3111
+ const isAgentTask = payload.kind === "agent";
3112
+ const context = isAgentTask ? null : captureContext();
3113
+ const point = isAgentTask ? null : getPinPoint(pin);
3114
+ const pendingId = isAgentTask ? null : `pending-${crypto.randomUUID()}`;
2880
3115
  bubble.remove();
2881
3116
  pin.remove();
2882
3117
  if (draftAnchor === editorDraft) draftAnchor = null;
3118
+ syncPlacementCursor();
2883
3119
  hideInspector();
2884
- addLocalComment(pendingId, payload);
3120
+ if (!isAgentTask) addLocalComment(pendingId, payload);
2885
3121
  let post;
2886
3122
  try {
2887
- post = await fetch(`${apiUrl}/api/comments`, {
3123
+ post = await fetch(payload.kind === "agent" ? `${agentBridgeUrl}/tasks` : `${apiUrl}/api/comments`, {
2888
3124
  method: "POST",
2889
- headers: getSdkHeaders({ "Content-Type": "application/json" }),
2890
- body: JSON.stringify(payload),
3125
+ headers: payload.kind === "agent" ? agentHeaders() : getSdkHeaders({ "Content-Type": "application/json" }),
3126
+ body: JSON.stringify(payload.kind === "agent" ? { ...payload, projectKey } : payload),
2891
3127
  });
2892
- if (!post.ok) throw new Error(`Comment save failed (${post.status})`);
3128
+ if (!post.ok) throw new Error(`Save failed (${post.status})`);
2893
3129
  const data = await post.json();
2894
- if (!data?.id) throw new Error("Comment save returned no ID");
2895
- cachedComments = cachedComments.filter(item => String(item.id) !== pendingId);
2896
- if (context.url === location.href) {
2897
- const comment = addLocalComment(String(data.id), payload);
2898
- // Start only after the save and immediate UI update; the capture yields to input.
2899
- void uploadCommentScreenshot(comment, context, point);
2900
- } else void loadComments({ force: true });
3130
+ const id = isAgentTask ? data?.task?.id : data?.id;
3131
+ if (!id) throw new Error("Save returned no ID");
3132
+ if (isAgentTask) {
3133
+ requestedAgentTasks.add(id);
3134
+ void loadComments({ force: true });
3135
+ } else {
3136
+ cachedComments = cachedComments.filter(item => String(item.id) !== pendingId);
3137
+ if (context.url === location.href) {
3138
+ const comment = addLocalComment(String(id), payload);
3139
+ void uploadCommentScreenshot(comment, context, point);
3140
+ } else void loadComments({ force: true });
3141
+ }
2901
3142
  } catch (error) {
2902
3143
  console.error("Comty: save failed", error);
2903
- cachedComments = cachedComments.filter(item => String(item.id) !== pendingId);
2904
- renderSidepanelComments();
2905
- void renderComments(cachedComments, { retry: false });
3144
+ if (isAgentTask) showAgentBridgeError(error.message);
3145
+ else {
3146
+ cachedComments = cachedComments.filter(item => String(item.id) !== pendingId);
3147
+ renderSidepanelComments();
3148
+ void renderComments(cachedComments, { retry: false });
3149
+ }
2906
3150
  // Keep the original editor and its text available for retry, including attachments.
2907
3151
  if (!draftAnchor) draftAnchor = editorDraft;
3152
+ syncPlacementCursor();
2908
3153
  root.append(pin, bubble);
2909
3154
  submitBtn.disabled = false;
2910
3155
  textarea.disabled = false;
2911
3156
  linkBtn.disabled = false;
2912
3157
  const errorMessage = document.createElement("p");
2913
3158
  errorMessage.setAttribute("role", "alert");
2914
- errorMessage.textContent = "Comment not saved. Your draft is ready to retry.";
3159
+ errorMessage.textContent = `${isAgentTask ? "Task" : "Comment"} not saved. Your draft is ready to retry.`;
2915
3160
  bubble.querySelector('[role="alert"]')?.remove();
2916
3161
  bubble.append(errorMessage);
2917
3162
  if (bubble.isConnected) textarea.focus();
@@ -3000,7 +3245,8 @@ export function initComty(options = {}) {
3000
3245
  renderInspector();
3001
3246
  };
3002
3247
 
3003
- actions.append(linkBtn, submitBtn, linkEditor);
3248
+ if (payload.kind === 'agent') actions.append(scopePicker, submitBtn);
3249
+ else actions.append(linkBtn, submitBtn, linkEditor);
3004
3250
 
3005
3251
  bubble.appendChild(textarea);
3006
3252
  bubble.appendChild(attachment);
@@ -3013,6 +3259,249 @@ export function initComty(options = {}) {
3013
3259
  setTimeout(() => textarea.focus(), 10);
3014
3260
  }
3015
3261
 
3262
+ function agentHeaders() { return { "Content-Type": "application/json", "X-Comty-Token": agent?.token || "" }; }
3263
+
3264
+ function showAgentBridgeError(message = "Start your app with comty dev to use the local agent. Check the terminal if it is already running.") {
3265
+ const issue = describeAgentError(message);
3266
+ showNotice({ title: 'Modification failed', description: issue.title === 'Task could not finish' ? 'The agent could not complete this request.' : issue.title + '.' });
3267
+ }
3268
+
3269
+ function positionNotice() {
3270
+ const toast = root.querySelector(".comty-agent-task-toast");
3271
+ if (!toast) return;
3272
+ toast.style.removeProperty("left");
3273
+ toast.style.removeProperty("top");
3274
+ toast.toggleAttribute("data-toolbar", isSessionActive());
3275
+ if (!isSessionActive()) return;
3276
+ const rect = toolbar.getBoundingClientRect();
3277
+ const above = rect.top - toast.offsetHeight - 8;
3278
+ toast.style.left = `${Math.max(8, Math.min(rect.right - toast.offsetWidth, innerWidth - toast.offsetWidth - 8))}px`;
3279
+ toast.style.top = `${Math.max(8, Math.min(above >= 8 ? above : rect.bottom + 8, innerHeight - toast.offsetHeight - 8))}px`;
3280
+ }
3281
+
3282
+ function showNotice(issue) {
3283
+ clearAgentToast();
3284
+ const toast = document.createElement("div");
3285
+ toast.className = "comty-agent-task-toast";
3286
+ toast.setAttribute("role", "alert");
3287
+ const title = document.createElement("strong"); title.textContent = issue.title;
3288
+ const description = document.createElement("p"); description.textContent = issue.description;
3289
+ const close = document.createElement("button"); close.type = "button";
3290
+ close.setAttribute("aria-label", "Dismiss notification");
3291
+ close.innerHTML = icon('<path d="m6 6 12 12M6 18 18 6"/>');
3292
+ toast.append(title, description, close);
3293
+ let timer, leaving = false;
3294
+ const dismiss = () => {
3295
+ if (leaving) return;
3296
+ leaving = true;
3297
+ clearTimeout(timer);
3298
+ toast.dataset.leaving = "";
3299
+ timer = setTimeout(() => toast.remove(), reducedMotion.matches ? 0 : 200);
3300
+ };
3301
+ const schedule = () => { if (!leaving) { clearTimeout(timer); timer = setTimeout(dismiss, 6000); } };
3302
+ const pause = () => { if (!leaving) clearTimeout(timer); };
3303
+ clearAgentToast = () => { clearTimeout(timer); toast.remove(); };
3304
+ close.onclick = dismiss;
3305
+ toast.onmouseenter = pause;
3306
+ toast.onmouseleave = schedule;
3307
+ toast.onfocusin = pause;
3308
+ toast.onfocusout = schedule;
3309
+ root.appendChild(toast);
3310
+ positionNotice();
3311
+ schedule();
3312
+ }
3313
+
3314
+ function describeAgentError(error) {
3315
+ const raw = typeof error === 'string' ? error : JSON.stringify(error, null, 2) || 'Unknown error';
3316
+ let value = error;
3317
+ for (let depth = 0; depth < 8; depth++) {
3318
+ if (typeof value === 'string') {
3319
+ try { value = JSON.parse(value); continue; } catch {}
3320
+ const start = value.indexOf('{'), end = value.lastIndexOf('}');
3321
+ if (start >= 0 && end > start) { try { value = JSON.parse(value.slice(start, end + 1)); continue; } catch {} }
3322
+ break;
3323
+ }
3324
+ if (!value || typeof value !== 'object') break;
3325
+ value = value.error || value.message || value.cause || value.stderr || value;
3326
+ }
3327
+ const message = typeof value === 'string' ? value : '';
3328
+ const source = raw + ' ' + message;
3329
+ const agentName = /codex/i.test(source) ? 'Codex' : /claude/i.test(source) ? 'Claude Code' : /cursor/i.test(source) ? 'Cursor' : 'your coding agent';
3330
+ let title, description;
3331
+ if (/requires a newer version|upgrade.*latest|outdated|unsupported.*version/i.test(source)) {
3332
+ title = 'Agent update required'; description = `Update ${agentName}, restart your local development session, then retry this task.`;
3333
+ } else if (/insufficient_quota|quota exceeded|credit balance|billing|usage limit/i.test(source)) {
3334
+ title = 'Usage limit reached'; description = 'Check your agent account’s usage allowance or billing, then retry when capacity is available.';
3335
+ } else if (/rate.?limit|too many requests|\b429\b/i.test(source)) {
3336
+ title = 'Too many requests'; description = 'The agent is temporarily rate limited. Wait a moment before retrying.';
3337
+ } else if (/unauthorized|unauthenticated|authentication|not logged|sign.?in|log.?in|invalid.api.key|\b401\b/i.test(source)) {
3338
+ title = 'Sign in to your agent'; description = `Open ${agentName} and sign in again, then retry this task.`;
3339
+ } else if (/model.*(?:not found|does not exist|unavailable|not supported)|unsupported model|model_not_found/i.test(source)) {
3340
+ title = 'Model unavailable'; description = 'The local agent rejected this model. Check its CLI version and signed-in account, then retry.';
3341
+ } else if (/disconnected|ECONNREFUSED|ENOTFOUND|network|failed to fetch|development server stopped/i.test(source)) {
3342
+ title = 'Connection lost'; description = 'Check that your local development server and coding agent are running, then reload this page.';
3343
+ } else if (/timed? ?out|timeout|exceeded.*minutes/i.test(source)) {
3344
+ title = 'Task timed out'; description = 'Review any changes already made, then retry with a smaller, more specific request.';
3345
+ } else if (/permission|denied|forbidden|\b403\b/i.test(source)) {
3346
+ title = 'Permission needed'; description = 'Check the agent’s access to this project and any pending approval before retrying.';
3347
+ } else if (/ENOENT|command not found|not installed|executable.*not found/i.test(source)) {
3348
+ title = 'Agent not found'; description = `Install or configure ${agentName} in your local development environment, then restart and retry.`;
3349
+ } else if (/completion was not verified/i.test(source)) {
3350
+ title = 'Result needs checking'; description = 'The agent could not confirm completion. Review the changes before retrying.';
3351
+ } else {
3352
+ title = 'Task could not finish';
3353
+ description = message && !/[{}]|\[object Object\]/.test(message) ? message.slice(0, 160) + (message.length > 160 ? '…' : '') : 'The agent reported an unexpected error. Review the technical details and any changes before retrying.';
3354
+ }
3355
+ return { title, description, raw, message };
3356
+ }
3357
+
3358
+ async function runAgentAction(path, input = {}) {
3359
+ const response = await fetch(`${agentBridgeUrl}/${path}`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify(input) });
3360
+ if (!response.ok) {
3361
+ const body = await response.json().catch(() => null);
3362
+ throw new Error(typeof body?.error === 'string' ? body.error : JSON.stringify(body?.error || { status: response.status, message: 'Request failed' }));
3363
+ }
3364
+ if (path.endsWith('/retry')) requestedAgentTasks.add(path.split('/')[1]);
3365
+ await loadComments({ force: true });
3366
+ }
3367
+
3368
+ function renderAgentPanel() {
3369
+ if (!agent) return;
3370
+ const visibleTasks = [];
3371
+ const latestResult = [...localTasks].reverse().find(task => ['done', 'in_review'].includes(task.status) && (!task.thread_comment_id || task.status === 'in_review'));
3372
+ if (latestResult) visibleTasks.push(latestResult);
3373
+ if (!visibleTasks.length && !localApprovals.length && !agentActionError) agentPanelOpen = false;
3374
+ let panel = root.querySelector(".comty-agent-panel");
3375
+ if (!panel && !agentPanelOpen) return;
3376
+ if (!panel) {
3377
+ panel = document.createElement("section");
3378
+ panel.className = "comty-agent-panel";
3379
+ panel.setAttribute("aria-label", "Local agent tasks");
3380
+ panel.hidden = true;
3381
+ // Keep the task card above inspection overlays and below the toolbar.
3382
+ root.insertBefore(panel, toolbar);
3383
+ }
3384
+ const open = Boolean(agent) && agentPanelOpen;
3385
+ if (open) {
3386
+ clearAgentToast();
3387
+ if (panel.hidden) {
3388
+ const rect = toolbar.getBoundingClientRect();
3389
+ panel.dataset.side = rect.left + rect.width / 2 < innerWidth / 2 ? "left" : "right";
3390
+ panel.dataset.closed = "";
3391
+ panel.hidden = false;
3392
+ panel.getBoundingClientRect();
3393
+ }
3394
+ if (panel.hasAttribute("data-closed")) { agentPanelTransition++; delete panel.dataset.closed; }
3395
+ } else if (!panel.hidden && !panel.hasAttribute("data-closed")) {
3396
+ const transition = ++agentPanelTransition;
3397
+ panel.dataset.closed = "";
3398
+ const finish = () => { if (transition === agentPanelTransition && panel.hasAttribute("data-closed")) panel.hidden = true; };
3399
+ if (reducedMotion.matches) finish();
3400
+ else setTimeout(finish, parseFloat(getComputedStyle(root).getPropertyValue("--comty-motion-duration")));
3401
+ }
3402
+ panel.inert = !open;
3403
+ panel.setAttribute("aria-hidden", String(!open));
3404
+ const signature = JSON.stringify([visibleTasks, localApprovals, agentConnectionError, agentActionError, agentPanelOpen]);
3405
+ if (signature === agentPanelSignature) return;
3406
+ agentPanelSignature = signature;
3407
+ const expanded = new Set([...panel.querySelectorAll('details[data-detail-key][open]')].map(node => node.dataset.detailKey));
3408
+ const focusKey = panel.contains(document.activeElement) ? document.activeElement.dataset.focusKey : null;
3409
+ const scrollTop = panel.querySelector('.comty-agent-panel-content')?.scrollTop || 0;
3410
+ const node = (tag, className, text) => {
3411
+ const element = document.createElement(tag);
3412
+ if (className) element.className = className;
3413
+ if (text != null) element.textContent = text;
3414
+ return element;
3415
+ };
3416
+ const brand = () => createCommentAvatar({ author_kind: 'agent' }, 'comty-agent-brand');
3417
+ panel.replaceChildren();
3418
+ const header = node('header', 'comty-agent-panel-header');
3419
+ const heading = node('div', 'comty-agent-heading');
3420
+ heading.append(node('strong', '', 'Agent'));
3421
+ const toggle = node('button', 'comty-agent-action comty-agent-close'); toggle.type = 'button';
3422
+ toggle.innerHTML = icon('<path d="m6 6 12 12M6 18 18 6"/>');
3423
+ toggle.setAttribute('aria-label', 'Close Agent task'); toggle.title = 'Close'; toggle.dataset.focusKey = 'close';
3424
+ toggle.onclick = () => { agentPanelOpen = false; renderAgentPanel(); agentBtn?.focus({ preventScroll: true }); };
3425
+ header.append(heading, toggle); panel.appendChild(header);
3426
+ const content = node('div', 'comty-agent-panel-content'); panel.appendChild(content);
3427
+ const technicalDetails = (parent, raw, key) => {
3428
+ const details = node('details', 'comty-agent-details'); details.dataset.detailKey = key; details.open = expanded.has(key);
3429
+ const summary = node('summary', '', 'Technical details'); summary.dataset.focusKey = 'details-' + key;
3430
+ details.append(summary, node('pre', '', raw)); parent.appendChild(details);
3431
+ };
3432
+ const errorMessage = (parent, error, key) => {
3433
+ const issue = describeAgentError(error);
3434
+ const message = node('div', 'comty-agent-error');
3435
+ message.append(node('strong', '', issue.title), node('p', '', issue.description));
3436
+ parent.appendChild(message); technicalDetails(parent, issue.raw, key);
3437
+ };
3438
+ if (agentConnectionError || agentActionError) {
3439
+ const banner = node('div', 'comty-agent-item comty-agent-banner'); banner.setAttribute('role', 'alert');
3440
+ errorMessage(banner, agentConnectionError || agentActionError, 'connection'); content.appendChild(banner);
3441
+ }
3442
+ const action = (parent, label, path, input = {}, primary = false) => {
3443
+ const button = node('button', 'comty-agent-action', label); button.type = 'button';
3444
+ const key = path + JSON.stringify(input); button.dataset.focusKey = key;
3445
+ button.disabled = agentPendingActions.has(key);
3446
+ if (primary) button.dataset.primary = '';
3447
+ button.onclick = async () => {
3448
+ if (agentPendingActions.has(key)) return;
3449
+ agentActionError = '';
3450
+ agentPendingActions.add(key); button.disabled = true; button.textContent = 'Please wait…';
3451
+ try {
3452
+ await runAgentAction(path, input);
3453
+ } catch (error) { agentActionError = error.message; }
3454
+ finally { agentPendingActions.delete(key); agentPanelSignature = ''; renderAgentPanel(); }
3455
+ };
3456
+ parent.appendChild(button);
3457
+ };
3458
+ for (const approval of localApprovals) {
3459
+ const section = node('div', 'comty-agent-item');
3460
+ section.append(node('h3', '', 'Approval required'));
3461
+ const task = localTasks.find(task => task.id === approval.taskId);
3462
+ if (task) section.appendChild(node('p', '', task.content));
3463
+ section.appendChild(node('p', '', 'Review this operation before allowing the agent to continue.'));
3464
+ const operation = approval.details?.command || approval.details?.file_path || approval.details?.path;
3465
+ if (typeof operation === 'string' || Array.isArray(operation)) section.appendChild(node('pre', '', Array.isArray(operation) ? operation.join(' ') : operation));
3466
+ technicalDetails(section, JSON.stringify(approval.details ?? {}, null, 2), 'approval-' + approval.id);
3467
+ const actions = node('div', 'comty-agent-actions');
3468
+ action(actions, 'Allow once', `approvals/${approval.id}`, { allow: true }, true);
3469
+ action(actions, 'Deny', `approvals/${approval.id}`, { allow: false });
3470
+ section.appendChild(actions); content.appendChild(section);
3471
+ }
3472
+ const list = node('div', 'comty-agent-list'); content.appendChild(list);
3473
+ for (const task of visibleTasks.reverse()) {
3474
+ const failed = Boolean(task.error) || task.status === 'in_review';
3475
+ const state = task.status === 'done' ? 'success' : failed ? 'error' : 'working';
3476
+ const row = node('div', 'comty-agent-row'); row.dataset.taskId = task.id;
3477
+ const dot = state === 'working' ? createWorkingIndicator() : node('span', 'comty-agent-dot');
3478
+ dot.dataset.status = state; dot.setAttribute('aria-hidden', 'true');
3479
+ const name = node('span', 'comty-agent-row-name', task.thread_comment_id ? `@Agent · ${task.content}` : task.content);
3480
+ row.append(dot, name);
3481
+ if (state === 'working') row.appendChild(node('span', 'comty-agent-row-state', task.status === 'todo' ? 'Starting' : 'Working'));
3482
+ const actions = node('div', 'comty-agent-row-actions');
3483
+ if (['in_review', 'cancel'].includes(task.status)) action(actions, 'Retry', `tasks/${task.id}/retry`);
3484
+ if (['todo', 'in_progress'].includes(task.status)) action(actions, 'Cancel', `tasks/${task.id}/cancel`);
3485
+ if (task.status === 'done') action(actions, 'Dismiss', `tasks/${task.id}/dismiss`);
3486
+ if (task.status === 'in_review') action(actions, 'Dismiss', `tasks/${task.id}/dismiss`);
3487
+ if (actions.childElementCount) row.appendChild(actions);
3488
+ row.setAttribute('aria-label', `${task.content}, ${state === 'error' ? 'failed' : state === 'success' ? 'completed' : state === 'cancel' ? 'cancelled' : task.status === 'todo' ? 'starting' : 'working'}`);
3489
+ list.appendChild(row);
3490
+ if (task.error) {
3491
+ const details = node('div', 'comty-agent-item');
3492
+ errorMessage(details, task.error, task.id);
3493
+ list.appendChild(details);
3494
+ }
3495
+ if (task.result?.summary) {
3496
+ const answer = node('div', 'comty-agent-answer');
3497
+ answer.append(brand(), node('p', '', task.result.summary));
3498
+ list.appendChild(answer);
3499
+ }
3500
+ }
3501
+ content.scrollTop = scrollTop;
3502
+ if (focusKey && open) [...panel.querySelectorAll('[data-focus-key]')].find(element => element.dataset.focusKey === focusKey)?.focus({ preventScroll: true });
3503
+ }
3504
+
3016
3505
  function setSidepanelOpen(open) {
3017
3506
  sidepanelOpen = open;
3018
3507
  sidepanel.classList.toggle("comty-sidepanel-open", sidepanelOpen);
@@ -3048,12 +3537,52 @@ export function initComty(options = {}) {
3048
3537
 
3049
3538
  function openCommentFromPanel(comment) {
3050
3539
  const target = resolveCommentElement(comment);
3051
- if (!target || isElementTreeHidden(target)) return;
3540
+ if (!target || isElementTreeHidden(target)) {
3541
+ openDetachedThread(comment);
3542
+ return;
3543
+ }
3052
3544
  setSession(session.comments ? session.mode : "browse", { entry: sessionEntry, renderComments: true });
3053
3545
  target.scrollIntoView({ block: "center", inline: "nearest", behavior: "instant" });
3546
+ const rect = target.getBoundingClientRect();
3547
+ const relX = Number(comment.rel_x ?? comment.relX);
3548
+ const relY = Number(comment.rel_y ?? comment.relY);
3549
+ // Large page containers must reveal the annotation point, not their center.
3550
+ window.scrollBy({
3551
+ left: rect.width > innerWidth && Number.isFinite(relX) ? rect.left + Math.max(0, Math.min(1, relX)) * rect.width - innerWidth / 2 : 0,
3552
+ top: rect.height > innerHeight && Number.isFinite(relY) ? rect.top + Math.max(0, Math.min(1, relY)) * rect.height - innerHeight / 2 : 0,
3553
+ behavior: "instant",
3554
+ });
3054
3555
  syncCommentAnchors();
3055
3556
  const pin = findPlacedPin(comment);
3056
3557
  if (pin && !pin.hasAttribute("data-comty-anchor-hidden")) pin.click();
3558
+ else openDetachedThread(comment);
3559
+ }
3560
+
3561
+ function openDetachedThread(comment) {
3562
+ openCommentThread(comment, null, null, innerWidth / 2, innerHeight / 3, () => {});
3563
+ const bubble = [...root.querySelectorAll(".comty-bubble")].find(node => node.dataset.commentId === String(comment.id));
3564
+ if (bubble) {
3565
+ bubble.dataset.detached = "";
3566
+ positionCommentBubble(bubble, innerWidth / 2, innerHeight / 3);
3567
+ }
3568
+ }
3569
+
3570
+ function updateAnchorBadge(item, comment) {
3571
+ resolveCommentElement(comment);
3572
+ const result = commentTargets.get(String(comment.id));
3573
+ const legacy = getPagePath() !== window.location.pathname && comment.page_path === window.location.pathname;
3574
+ const status = legacy && !result?.element ? "uncertain" : result?.status;
3575
+ const label = status === "missing" ? "Target missing" : status === "uncertain" ? "Target uncertain" : "";
3576
+ let badge = item.querySelector(".comty-anchor-badge");
3577
+ if (!label) { badge?.remove(); return; }
3578
+ if (!badge) {
3579
+ badge = document.createElement("span");
3580
+ badge.className = "comty-anchor-badge";
3581
+ item.querySelector(".comty-sidepanel-item-body").appendChild(badge);
3582
+ }
3583
+ if (badge.textContent !== label) badge.textContent = label;
3584
+ badge.title = legacy ? "This older comment has no saved hash route. Its target may be on another page." :
3585
+ status === "missing" ? "The target is not present on this page. The comment is still available." : "The target cannot be identified reliably. No pin is placed.";
3057
3586
  }
3058
3587
 
3059
3588
  let sidepanelFilters = { status: "", device: "", dateSort: "desc" };
@@ -3066,6 +3595,7 @@ export function initComty(options = {}) {
3066
3595
  ? sidepanelSearch.value.trim().toLowerCase()
3067
3596
  : "";
3068
3597
  let list = cachedComments.filter((comment) => {
3598
+ if (comment.local_agent || comment.kind === "agent") return false;
3069
3599
  if (
3070
3600
  search &&
3071
3601
  !`${comment.content ?? ""} ${getCommentLabel(comment)}`
@@ -3112,6 +3642,7 @@ export function initComty(options = {}) {
3112
3642
  const item = document.createElement("button");
3113
3643
  item.type = "button";
3114
3644
  item.className = "comty-sidepanel-item";
3645
+ item.dataset.commentId = String(comment.id);
3115
3646
  item.disabled = Boolean(comment.pending);
3116
3647
  item.innerHTML = `
3117
3648
  <span class="comty-sidepanel-item-status" aria-hidden="true"></span>
@@ -3135,8 +3666,13 @@ export function initComty(options = {}) {
3135
3666
  if (metaEl instanceof HTMLElement) {
3136
3667
  metaEl.innerText = `${comment.author_name || "Anonymous"} · ${comment.pending ? "Sending…" : getCommentLabel(comment)}`;
3137
3668
  metaEl.title = getCommentLabel(comment);
3669
+ if (getPagePath() !== window.location.pathname && comment.page_path === window.location.pathname && !resolveCommentElement(comment)) {
3670
+ metaEl.innerText += " · Original page unknown";
3671
+ }
3138
3672
  }
3139
3673
 
3674
+ updateAnchorBadge(item, comment);
3675
+
3140
3676
  item.onclick = (event) => {
3141
3677
  event.stopPropagation();
3142
3678
  openCommentFromPanel(comment);
@@ -3206,7 +3742,7 @@ export function initComty(options = {}) {
3206
3742
  if (!(pin instanceof HTMLElement)) return;
3207
3743
  const status = pin.dataset.status;
3208
3744
  const shouldShow =
3209
- !hasFilters || Boolean(status && selectedStatusFilters.has(status));
3745
+ pin.hasAttribute('data-agent-task') || !hasFilters || Boolean(status && selectedStatusFilters.has(status));
3210
3746
  pin.classList.toggle("comty-filtered-out", !shouldShow);
3211
3747
  });
3212
3748
  }
@@ -3252,14 +3788,15 @@ export function initComty(options = {}) {
3252
3788
  createStatusFilters();
3253
3789
 
3254
3790
  async function updateCommentStatus(comment, status) {
3255
- if (!projectId || !comment?.id) return false;
3791
+ if (comment?.local_agent || !projectId || !comment?.id) return false;
3256
3792
 
3257
3793
  let res;
3258
3794
  try {
3259
- res = await fetch(`${apiUrl}/api/comments/${comment.id}/status`, {
3795
+ const local = comment.local_agent === true;
3796
+ res = await fetch(local ? `${agentBridgeUrl}/tasks/${encodeURIComponent(comment.id)}/status` : `${apiUrl}/api/comments/${comment.id}/status`, {
3260
3797
  method: "POST",
3261
- headers: getSdkHeaders({ "Content-Type": "application/json" }),
3262
- body: JSON.stringify({
3798
+ headers: local ? { "Content-Type": "application/json" } : getSdkHeaders({ "Content-Type": "application/json" }),
3799
+ body: JSON.stringify(local ? { status } : {
3263
3800
  projectKey,
3264
3801
  host: window.location.host,
3265
3802
  status,
@@ -3276,7 +3813,7 @@ export function initComty(options = {}) {
3276
3813
  }
3277
3814
 
3278
3815
  const data = await res.json();
3279
- comment.status = getCommentStatus(data.status).value;
3816
+ comment.status = getCommentStatus(data.task?.status ?? data.status).value;
3280
3817
  return comment.status;
3281
3818
  }
3282
3819
 
@@ -3468,6 +4005,27 @@ export function initComty(options = {}) {
3468
4005
  isOwnCommentEntry, updateCommentContent, deleteSdkComment, getCommentId,
3469
4006
  updateReplyContent, deleteSdkReply, createCommentAvatar, getSdkHeaders,
3470
4007
  showAuthPrompt,
4008
+ startThreadAgent: async (comment, replies, content) => {
4009
+ if (!agent) throw new Error('Start the local agent to reply with @Agent');
4010
+ if (!canManage) throw new Error('Sign in as a project editor to use @Agent');
4011
+ const transcript = [
4012
+ `${getCommentAuthorName(comment)}: ${comment.content}`,
4013
+ ...replies.map(reply => `${getCommentAuthorName(reply)}: ${reply.content}`),
4014
+ ].join('\n').slice(-24000);
4015
+ const response = await fetch(`${agentBridgeUrl}/tasks`, {
4016
+ method: 'POST', headers: agentHeaders(),
4017
+ body: JSON.stringify({ content, pagePath: comment.page_path || getPagePath(),
4018
+ selector: comment.selector || 'body', relX: comment.rel_x ?? 0.5, relY: comment.rel_y ?? 0.5,
4019
+ domElement: comment.dom_element || '', viewport: comment.viewport || '',
4020
+ anchorContext: comment.anchor_context || '', scope: 'element',
4021
+ thread: { commentId: String(comment.id), transcript } }),
4022
+ });
4023
+ if (!response.ok) throw new Error((await response.json().catch(() => null))?.error || 'Could not start Agent');
4024
+ const data = await response.json();
4025
+ if (!data.task?.id) throw new Error('Agent returned no task ID');
4026
+ requestedAgentTasks.add(data.task.id);
4027
+ void loadComments({ force: true });
4028
+ },
3471
4029
  get canWrite() { return canWrite; },
3472
4030
  get canManage() { return canManage; },
3473
4031
  get allowGuestComments() { return allowGuestComments; },
@@ -3582,6 +4140,7 @@ export function initComty(options = {}) {
3582
4140
 
3583
4141
  pin.onclick = (event) => {
3584
4142
  event.stopPropagation();
4143
+ if (comment.local_agent) { openAgentTask(comment, getPinPoint(pin).x, getPinPoint(pin).y); return; }
3585
4144
  openCommentThread(
3586
4145
  comment,
3587
4146
  pin,
@@ -3668,42 +4227,122 @@ export function initComty(options = {}) {
3668
4227
  function reconcileComments(comments) {
3669
4228
  const previous = new Map(cachedComments.map(comment => [String(comment.id), comment]));
3670
4229
  cachedComments = comments
3671
- .filter(comment => comment.kind !== "agent" && !comment.local_agent)
4230
+ .filter(comment => comment.kind !== "agent" || comment.status !== "done")
3672
4231
  .map(comment => Object.assign(previous.get(String(comment.id)) || {}, comment));
3673
4232
  for (const comment of previous.values()) {
3674
4233
  if (comment.pending && !cachedComments.some(item => item.id === comment.id)) cachedComments.push(comment);
3675
4234
  }
3676
4235
  }
3677
4236
 
3678
- async function loadComments({ force = false } = {}) {
3679
- if (!projectId) return;
3680
- const pagePath = window.location.pathname;
4237
+ async function deliverThreadReply(task) {
4238
+ if (deliveringAgentReplies.has(task.id)) return;
4239
+ deliveringAgentReplies.add(task.id);
4240
+ try {
4241
+ const response = await fetch(`${apiUrl}/api/comments/${encodeURIComponent(task.thread_comment_id)}/replies`, {
4242
+ method: 'POST', headers: getSdkHeaders({ 'Content-Type': 'application/json' }),
4243
+ body: JSON.stringify({ projectKey, content: task.result.summary, agentTaskId: task.id }),
4244
+ });
4245
+ if (!response.ok) throw new Error(`Could not save Agent reply (${response.status})`);
4246
+ const { reply } = await response.json();
4247
+ const local = cachedComments.find(comment => String(comment.id) === task.thread_comment_id);
4248
+ if (local && !local.replies?.some(item => item.id === reply.id)) {
4249
+ local.replies ||= [];
4250
+ local.replies.push(reply);
4251
+ }
4252
+ for (const bubble of root.querySelectorAll('.comty-bubble')) {
4253
+ if (bubble.dataset.commentId === task.thread_comment_id) bubble.dispatchEvent(new CustomEvent('comty-agent-reply', { detail: reply }));
4254
+ }
4255
+ const ack = await fetch(`${agentBridgeUrl}/tasks/${task.id}/dismiss`, { method: 'POST', headers: agentHeaders(), body: '{}' });
4256
+ if (!ack.ok) throw new Error('Agent reply saved, but local acknowledgement failed');
4257
+ localTasks = localTasks.filter(item => item.id !== task.id);
4258
+ } catch (error) {
4259
+ agentActionError = error.message;
4260
+ console.error('Comty: Agent reply delivery failed', error);
4261
+ } finally {
4262
+ deliveringAgentReplies.delete(task.id);
4263
+ agentPanelSignature = '';
4264
+ renderAgentPanel();
4265
+ }
4266
+ }
4267
+
4268
+ async function loadComments({ force = false, tasksOnly = false } = {}) {
4269
+ if (!projectId && !agent) return;
4270
+ const pagePath = getPagePath();
3681
4271
  if (!force && cachedPagePath === pagePath) { await renderComments(cachedComments); return; }
3682
4272
  if (pendingCommentsLoad) {
3683
4273
  await pendingCommentsLoad;
3684
- if (!force) return;
4274
+ if (!force && cachedPagePath === pagePath) return;
3685
4275
  }
3686
4276
  pendingCommentsLoad = (async () => {
3687
- let comments = cachedPagePath === pagePath ? cachedComments : [];
3688
- if (projectId) {
4277
+ let comments = cachedPagePath === pagePath ? cachedComments.filter(comment => !comment.local_agent) : [];
4278
+ if (projectId && !tasksOnly) {
3689
4279
  try {
3690
- const url = new URL(`${apiUrl}/api/comments`);
3691
- url.search = new URLSearchParams({ projectKey, host: window.location.host, pagePath, ...(force ? { _t: String(Date.now()) } : {}) });
3692
- const res = await fetch(url);
3693
- if (!res.ok) throw new Error(await res.text());
3694
- const data = await res.json();
3695
- if (Array.isArray(data.comments)) comments = data.comments;
4280
+ const paths = [...new Set([pagePath, window.location.pathname])];
4281
+ const batches = await Promise.all(paths.map(async path => {
4282
+ const url = new URL(`${apiUrl}/api/comments`);
4283
+ url.search = new URLSearchParams({ projectKey, host: window.location.host, pagePath: path, ...(force ? { _t: String(Date.now()) } : {}) });
4284
+ const res = await fetch(url);
4285
+ if (!res.ok) throw new Error(await res.text());
4286
+ const data = await res.json();
4287
+ return Array.isArray(data.comments) ? data.comments : [];
4288
+ }));
4289
+ comments = [...new Map(batches.flat().map(comment => [String(comment.id), comment])).values()];
3696
4290
  } catch (error) { console.error("Comty: list comments failed", error); }
3697
4291
  }
4292
+ if (agent) {
4293
+ try {
4294
+ const res = await fetch(`${agentBridgeUrl}/tasks`, { headers: agentHeaders(), cache: "no-store" });
4295
+ if (!res.ok) throw new Error("Local companion unavailable");
4296
+ const data = await res.json();
4297
+ if (Array.isArray(data.tasks)) localTasks = data.tasks;
4298
+ const failedTasks = localTasks.filter(task => task.status === 'in_review');
4299
+ for (const task of failedTasks.filter(task => requestedAgentTasks.has(task.id))) {
4300
+ if (task.error) showAgentBridgeError(task.error);
4301
+ else showNotice({ title: 'Modification failed', description: 'The agent needs clarification.' });
4302
+ }
4303
+ if (Array.isArray(data.approvals)) {
4304
+ localApprovals = data.approvals;
4305
+ if (localApprovals.some(approval => requestedAgentTasks.has(approval.taskId) && !seenAgentApprovals.has(approval.id))) agentPanelOpen = true;
4306
+ seenAgentApprovals.clear();
4307
+ for (const approval of localApprovals) seenAgentApprovals.add(approval.id);
4308
+ }
4309
+ for (const id of requestedAgentTasks) {
4310
+ const task = localTasks.find(task => task.id === id);
4311
+ if (!task || ['done', 'cancel', 'in_review'].includes(task.status)) requestedAgentTasks.delete(id);
4312
+ }
4313
+ agentConnectionError = "";
4314
+ } catch (error) {
4315
+ agentConnectionError = "Local agent disconnected. Check the development server, then reload this page.";
4316
+ console.error("Comty: local tasks failed", error);
4317
+ }
4318
+ for (const task of localTasks.filter(task => task.thread_comment_id && task.status === 'done' && task.result?.summary)) {
4319
+ void deliverThreadReply(task);
4320
+ }
4321
+ comments.push(...localTasks.filter(task => task.page_path === pagePath && !task.thread_comment_id && task.status === 'in_review'));
4322
+ renderAgentPanel();
4323
+ }
4324
+ if (pagePath !== getPagePath()) return;
3698
4325
  reconcileComments(comments);
3699
4326
  cachedPagePath = pagePath;
3700
4327
  renderSidepanelComments();
3701
4328
  await renderComments(cachedComments);
4329
+ syncAgentWorkingOutlines();
3702
4330
  ensureLiveTracking();
3703
4331
  })();
3704
4332
  try { await pendingCommentsLoad; } finally { pendingCommentsLoad = null; }
3705
4333
  }
3706
4334
 
4335
+ if (agent) {
4336
+ setInterval(() => {
4337
+ const active = requestedAgentTasks.size || localTasks.some(task =>
4338
+ ['todo', 'in_progress'].includes(task.status) || (task.thread_comment_id && task.status === 'done' && task.result?.summary));
4339
+ if (active && !pendingCommentsLoad && document.visibilityState === "visible") void loadComments({ force: true, tasksOnly: true });
4340
+ }, 2000);
4341
+ document.addEventListener('visibilitychange', () => {
4342
+ if (document.visibilityState === 'visible' && !pendingCommentsLoad) void loadComments({ force: true, tasksOnly: true });
4343
+ });
4344
+ }
4345
+
3707
4346
  function getPinPoint(pin) {
3708
4347
  return { x: parseFloat(pin.style.left), y: parseFloat(pin.style.top) };
3709
4348
  }
@@ -3731,7 +4370,7 @@ export function initComty(options = {}) {
3731
4370
  }
3732
4371
 
3733
4372
  function syncCommentAnchors() {
3734
- if (!session.comments) return;
4373
+ if (!session.comments && !session.inspect) return;
3735
4374
  const modal = getTopActiveModal();
3736
4375
  for (const comment of cachedComments) {
3737
4376
  const id = String(comment.id);
@@ -3742,7 +4381,7 @@ export function initComty(options = {}) {
3742
4381
  placePin(comment, modal);
3743
4382
  pin = placedPins.get(id);
3744
4383
  }
3745
- const bubbles = [...root.querySelectorAll(".comty-bubble")].filter(bubble => bubble.dataset.commentId === id || bubble.dataset.hoverId === id);
4384
+ const bubbles = [...root.querySelectorAll(".comty-bubble")].filter(bubble => (bubble.dataset.commentId === id || bubble.dataset.hoverId === id) && !bubble.hasAttribute("data-detached"));
3746
4385
  // A pin can remove its target's :hover style as the pointer enters it.
3747
4386
  if (!point && pin && target?.isConnected && !target.hasAttribute("hidden") && !isElementTreeHidden(target.parentElement) &&
3748
4387
  (activeHoverCommentIds.has(id) || bubbles.some(bubble => bubble.dataset.commentId === id))) continue;
@@ -3757,6 +4396,10 @@ export function initComty(options = {}) {
3757
4396
  if (pin.style.top !== y) pin.style.top = y;
3758
4397
  for (const bubble of bubbles) positionCommentBubble(bubble, point.clientX, point.clientY);
3759
4398
  }
4399
+ if (sidepanelOpen) for (const item of sidepanelList.querySelectorAll(".comty-sidepanel-item")) {
4400
+ const comment = cachedComments.find(comment => String(comment.id) === item.dataset.commentId);
4401
+ if (comment) updateAnchorBadge(item, comment);
4402
+ }
3760
4403
  if (draftAnchor) {
3761
4404
  if (!draftAnchor.target?.isConnected) draftAnchor.target = resolveCommentElement(draftAnchor.payload);
3762
4405
  const point = getVisibleAnchorPoint(draftAnchor.target, draftAnchor.payload.relX, draftAnchor.payload.relY, modal);
@@ -3770,16 +4413,50 @@ export function initComty(options = {}) {
3770
4413
  }
3771
4414
  }
3772
4415
 
4416
+ function syncAgentWorkingOutlines() {
4417
+ const working = localTasks.filter(task => task.status === "in_progress" && task.page_path === getPagePath());
4418
+ const ids = new Set(working.map(task => String(task.id)));
4419
+ for (const [id, outline] of workingOutlines) {
4420
+ if (ids.has(id)) continue;
4421
+ outline.remove(); workingOutlines.delete(id);
4422
+ }
4423
+ const modal = getTopActiveModal();
4424
+ for (const task of working) {
4425
+ const id = String(task.id);
4426
+ let outline = workingOutlines.get(id);
4427
+ if (!outline) {
4428
+ outline = document.createElement("div");
4429
+ outline.className = "comty-agent-working";
4430
+ outline.setAttribute("aria-label", "Agent working on this element");
4431
+ const badge = document.createElement("span");
4432
+ badge.className = "comty-agent-working-badge";
4433
+ badge.append(createWorkingIndicator(), "Working");
4434
+ outline.appendChild(badge); root.appendChild(outline);
4435
+ workingOutlines.set(id, outline);
4436
+ }
4437
+ const target = resolveCommentElement(task);
4438
+ const point = target && getVisibleAnchorPoint(target, Number(task.rel_x), Number(task.rel_y), modal);
4439
+ outline.hidden = !point;
4440
+ if (!point) continue;
4441
+ const rect = target.getBoundingClientRect();
4442
+ setMeasuredRect(outline, rect);
4443
+ outline.toggleAttribute("data-badge-below", rect.top < 32);
4444
+ }
4445
+ }
4446
+
3773
4447
  function onFrame() {
3774
4448
  if (motion.enabled) updateMotion();
3775
- if (session.comments) syncCommentAnchors();
4449
+ if (session.comments || session.inspect) syncCommentAnchors();
4450
+ if (session.agent || workingOutlines.size || localTasks.some(task => task.status === "in_progress" && task.page_path === getPagePath())) {
4451
+ syncAgentWorkingOutlines();
4452
+ }
3776
4453
  if (session.inspect || session.edit || inspectionLayer.childNodes.length) renderInspector();
3777
4454
  if (session.inspect && rulersEnabled) renderRulers();
3778
4455
  if (session.inspect && rulersEnabled) measurements.render();
3779
4456
  }
3780
4457
 
3781
4458
  function ensureLiveTracking() {
3782
- const active = motion.enabled || session.inspect || session.edit || (session.comments && cachedComments.length > 0);
4459
+ const active = motion.enabled || session.inspect || session.edit || (session.comments && cachedComments.length > 0) || workingOutlines.size > 0;
3783
4460
  if (!active || document.hidden) {
3784
4461
  if (liveFrame) cancelAnimationFrame(liveFrame);
3785
4462
  liveFrame = null;
@@ -3799,13 +4476,14 @@ export function initComty(options = {}) {
3799
4476
  syncOverlayHost();
3800
4477
 
3801
4478
  function renderCachedComments() {
3802
- if (!session.comments && !session.inspect && !session.edit) return;
4479
+ if (!session.comments && !session.inspect && !session.edit && !workingOutlines.size) return;
3803
4480
  if (resizeRenderFrame) {
3804
4481
  window.cancelAnimationFrame(resizeRenderFrame);
3805
4482
  }
3806
4483
  resizeRenderFrame = window.requestAnimationFrame(() => {
3807
4484
  resizeRenderFrame = null;
3808
4485
  syncCommentAnchors();
4486
+ syncAgentWorkingOutlines();
3809
4487
  renderInspector();
3810
4488
  ensureLiveTracking();
3811
4489
  });
@@ -3847,6 +4525,31 @@ export function initComty(options = {}) {
3847
4525
  characterData: true,
3848
4526
  });
3849
4527
 
4528
+ let activePagePath = getPagePath();
4529
+ function handleRouteChange() {
4530
+ const next = getPagePath();
4531
+ if (next === activePagePath) return;
4532
+ activePagePath = next;
4533
+ markup.clear();
4534
+ markup.setMode(null);
4535
+ closeBubbles();
4536
+ commentTargets.clear();
4537
+ reconcileComments([]);
4538
+ void renderComments([], { retry: false });
4539
+ renderSidepanelComments();
4540
+ void loadComments({ force: true });
4541
+ }
4542
+ window.addEventListener("hashchange", handleRouteChange);
4543
+ window.addEventListener("popstate", handleRouteChange);
4544
+ for (const method of ["pushState", "replaceState"]) {
4545
+ const original = history[method];
4546
+ history[method] = function (...args) {
4547
+ const result = original.apply(this, args);
4548
+ handleRouteChange();
4549
+ return result;
4550
+ };
4551
+ }
4552
+
3850
4553
  document.addEventListener("scroll", handleHostLayoutEvent, {
3851
4554
  capture: true,
3852
4555
  passive: true,
@@ -3865,7 +4568,7 @@ export function initComty(options = {}) {
3865
4568
 
3866
4569
  document.addEventListener("pointermove", (event) => {
3867
4570
  if (!session.edit && !session.inspect) return;
3868
- if (motion.enabled) return;
4571
+ if (motion.enabled || markup.mode) return;
3869
4572
  if (propertyPicking) {
3870
4573
  const target = event.target;
3871
4574
  if (propertyPicking === "font" && target instanceof Element && !isComtyElement(target) && getElementOwnTextSignature(target)) {
@@ -3984,13 +4687,14 @@ export function initComty(options = {}) {
3984
4687
  const y = e.clientY;
3985
4688
 
3986
4689
  const anchorContext = getAnchorContext(el);
4690
+ if (session.agent && anchorContext) anchorContext.inspectedStyles = getInspectRows(el);
3987
4691
 
3988
4692
  const payload = {
3989
4693
  ...getDeviceContext(),
3990
4694
  projectId,
3991
- kind: "comment",
4695
+ kind: session.agent ? "agent" : "comment",
3992
4696
  host: window.location.host,
3993
- pagePath: window.location.pathname,
4697
+ pagePath: getPagePath(),
3994
4698
  selector: getSelector(el),
3995
4699
  relX,
3996
4700
  relY,
@@ -4011,6 +4715,12 @@ export function initComty(options = {}) {
4011
4715
  (target.isContentEditable ||
4012
4716
  ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName));
4013
4717
 
4718
+ const markupMode = markup.mode;
4719
+ if (session.inspect && (e.key === "Escape" || !isEditable) && markup.keydown(e)) {
4720
+ e.preventDefault();
4721
+ if (!markup.mode) (markupMode === "annotate" ? annotateBtn : screenshotBtn).focus({ preventScroll: true });
4722
+ return;
4723
+ }
4014
4724
  if (e.key === "Escape" && motion.enabled) {
4015
4725
  e.preventDefault();
4016
4726
  setMotionEnabled(false); motionBtn.focus({ preventScroll: true });
@@ -4025,7 +4735,7 @@ export function initComty(options = {}) {
4025
4735
  return;
4026
4736
  }
4027
4737
  if (session.inspect && !isEditable && !e.repeat && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
4028
- const button = { x: xrayBtn, r: rulersBtn, g: gridBtn, p: pipetteBtn, t: typographyBtn, m: motionBtn }[e.key.toLowerCase()];
4738
+ const button = { x: xrayBtn, r: rulersBtn, g: gridBtn, p: pipetteBtn, t: typographyBtn, m: motionBtn, d: annotateBtn, s: screenshotBtn }[e.key.toLowerCase()];
4029
4739
  if (button) { e.preventDefault(); button.click(); return; }
4030
4740
  }
4031
4741
 
@@ -4033,6 +4743,11 @@ export function initComty(options = {}) {
4033
4743
  measureAlt = true;
4034
4744
  renderInspector();
4035
4745
  }
4746
+ if (isLocalSite && !isEditable && !e.repeat && !e.isComposing && e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey && e.key.toLowerCase() === "a") {
4747
+ e.preventDefault();
4748
+ startAgentSession();
4749
+ return;
4750
+ }
4036
4751
  if (!isEditable && !e.repeat && e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey && e.key.toLowerCase() === "i") {
4037
4752
  e.preventDefault();
4038
4753
  setSession(session.inspect ? "off" : "inspect");
@@ -4088,6 +4803,7 @@ export function initComty(options = {}) {
4088
4803
  window.addEventListener("blur", () => { measureAlt = false; renderInspector(); });
4089
4804
 
4090
4805
  async function boot() {
4806
+ if (localOnly) { await loadComments(); return; }
4091
4807
  const res = await fetch(`${apiUrl}/api/sdk/bootstrap`, {
4092
4808
  method: "POST",
4093
4809
  headers: getSdkHeaders({ "Content-Type": "application/json" }),
@@ -4100,6 +4816,7 @@ export function initComty(options = {}) {
4100
4816
 
4101
4817
  if (!res.ok) {
4102
4818
  console.error("Comty: bootstrap failed", await res.text());
4819
+ if (agent) await loadComments();
4103
4820
  return;
4104
4821
  }
4105
4822
 
@@ -4144,16 +4861,30 @@ export function initComty(options = {}) {
4144
4861
 
4145
4862
  if (typeof options.onTestReady === "function") {
4146
4863
  options.onTestReady({
4147
- getSelector, getAnchorContext, resolveCommentElement, getVisibleAnchorPoint,
4864
+ getPagePath, getSelector, getAnchorContext, resolveCommentElement, getVisibleAnchorPoint,
4148
4865
  getDistanceSegments, getInspectRows, syncCommentAnchors,
4149
4866
  setInspectorMode(enabled) { setSession(enabled ? "inspect" : "off"); },
4150
4867
  startEditSession, startBrowseSession, endSession, showInspector,
4151
- motion, setMotionEnabled, updateMotion,
4868
+ motion, setMotionEnabled, updateMotion, describeAgentError,
4869
+ previewAgent(tasks, approvals = [], error = "") {
4870
+ localTasks = tasks;
4871
+ localApprovals = approvals;
4872
+ agentConnectionError = error;
4873
+ startAgentSession();
4874
+ agentPanelOpen = true;
4875
+ agentPanelSignature = "";
4876
+ renderAgentPanel();
4877
+ },
4152
4878
  seed(comments) {
4153
4879
  reconcileComments(comments);
4154
4880
  setSession(session.comments ? session.mode : "browse", { entry: sessionEntry, renderComments: true });
4155
4881
  return renderComments(cachedComments, { retry: false });
4156
4882
  },
4883
+ seedAgentTasks(tasks) {
4884
+ localTasks = tasks;
4885
+ syncAgentWorkingOutlines();
4886
+ ensureLiveTracking();
4887
+ },
4157
4888
  setCanWrite(value) { canWrite = value; canManage = value; },
4158
4889
  invalidate() { anchorRevision += 1; },
4159
4890
  });
@@ -4161,6 +4892,7 @@ export function initComty(options = {}) {
4161
4892
 
4162
4893
  return boot().catch(error => {
4163
4894
  console.error("Comty: bootstrap failed", error);
4895
+ if (agent) return loadComments();
4164
4896
  });
4165
4897
  }
4166
4898
  }