@comty.io/core 0.1.4 → 0.1.6

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
@@ -9,7 +9,17 @@ const instanceKey = Symbol.for("comty.sdk.instance");
9
9
  /** Initialize once per document; safe to import and call during server rendering. */
10
10
  export function initComty(options = {}) {
11
11
  if (typeof window === "undefined" || typeof document === "undefined") return Promise.resolve();
12
- const projectKey = options.projectKey;
12
+ const isLocalSite = ["localhost", "127.0.0.1", "[::1]"].includes(window.location.hostname);
13
+ const agent = options.agent;
14
+ if (agent && (!isLocalSite || typeof agent.token !== "string" || !/^[a-f0-9]{64}$/.test(agent.token))) {
15
+ return Promise.reject(new TypeError("Comty: invalid local companion configuration"));
16
+ }
17
+ if (agent) {
18
+ try {
19
+ if (new URL(agent.url).origin !== window.location.origin) throw new Error();
20
+ } catch { return Promise.reject(new TypeError("Comty: companion must share the page origin")); }
21
+ }
22
+ const projectKey = options.projectKey || (agent ? "local" : undefined);
13
23
  const apiUrl = options.apiUrl || "http://localhost:4000";
14
24
  if (typeof projectKey !== "string" || !projectKey.trim()) {
15
25
  return Promise.reject(new TypeError("Comty: projectKey is required"));
@@ -21,12 +31,14 @@ export function initComty(options = {}) {
21
31
  if (!["http:", "https:"].includes(parsedApi.protocol)) {
22
32
  return Promise.reject(new TypeError("Comty: apiUrl must be an absolute HTTP(S) URL"));
23
33
  }
24
- const configuration = JSON.stringify([projectKey, apiUrl]);
34
+ const configuration = JSON.stringify([projectKey, apiUrl, agent || null]);
25
35
  const existing = window[instanceKey];
26
36
  if (existing) {
27
37
  return existing.configuration === configuration ? existing.ready
28
38
  : Promise.reject(new Error("Comty: already initialized with different options"));
29
39
  }
40
+ const agentBridgeUrl = agent?.url;
41
+ const localOnly = Boolean(agent && !options.projectKey);
30
42
  const ready = new Promise((resolve, reject) => {
31
43
  const mount = () => {
32
44
  try { resolve(mountComty()); } catch (error) { reject(error); }
@@ -40,10 +52,22 @@ export function initComty(options = {}) {
40
52
 
41
53
  function mountComty() {
42
54
  let projectId = null;
55
+ let localTasks = [];
56
+ const deliveringAgentReplies = new Set();
57
+ let localApprovals = [];
58
+ const seenAgentApprovals = new Set();
59
+ const seenFailedAgentTasks = new Set();
60
+ let agentPanelOpen = false;
61
+ let agentPanelSignature = "";
62
+ let agentPanelTransition = 0;
63
+ let agentConnectionError = "";
64
+ let agentActionError = "";
65
+ const agentPendingActions = new Set();
43
66
  const session = {
44
67
  mode: "off",
45
- get comments() { return this.mode === "browse" || this.mode === "edit"; },
46
- get edit() { return this.mode === "edit"; },
68
+ get comments() { return this.mode === "browse" || this.mode === "edit" || this.mode === "agent"; },
69
+ get edit() { return this.mode === "edit" || this.mode === "agent"; },
70
+ get agent() { return this.mode === "agent"; },
47
71
  get inspect() { return this.mode === "inspect"; },
48
72
  };
49
73
  const selectedStatusFilters = new Set();
@@ -67,9 +91,10 @@ export function initComty(options = {}) {
67
91
  const authReturnStorageKey = `comty:${projectKey}:auth-return`;
68
92
  let sdkToken = "";
69
93
  const activeHoverCommentIds = new Set();
70
- /** @type {null | 'edit' | 'browse'} */
94
+ /** @type {null | 'edit' | 'browse' | 'agent'} */
71
95
  let sessionEntry = null;
72
96
  const placedPins = new Map();
97
+ const workingOutlines = new Map();
73
98
  const placementDiagnostics = new Map();
74
99
  let inspectedElement = null;
75
100
  let selectedElement = null;
@@ -83,8 +108,8 @@ export function initComty(options = {}) {
83
108
  const commentTargets = new Map();
84
109
 
85
110
  const {
86
- matchesAnchorContext, resolveByAnchorContext,
87
- isElementTreeHidden, getTopActiveModal, getVisibleAnchorPoint,
111
+ matchesAnchorContext,
112
+ resolveAnchor, isElementTreeHidden, getTopActiveModal, getVisibleAnchorPoint,
88
113
  } = createAnchorMatcher({
89
114
  getElementTextSignature,
90
115
  getElementOwnTextSignature,
@@ -162,10 +187,20 @@ export function initComty(options = {}) {
162
187
  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
188
  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
189
  if (!canWrite) showAuthPrompt();
165
- else if (session.inspect || !isSessionActive()) startEditSession();
190
+ else if (session.agent || session.inspect || !isSessionActive()) startEditSession();
166
191
  else if (!session.edit) setSession("edit", { entry: sessionEntry });
167
192
  });
168
193
  btn.id = "comty-toggle";
194
+ const agentBtn = isLocalSite
195
+ ? toolButton("Agent task · localhost only", '<path d="M8 7 3 12l5 5m8-10 5 5-5 5M14 4l-4 16"/>', () => {
196
+ if (session.agent) {
197
+ if (localTasks.some(task => task.status === 'in_review' || (!task.thread_comment_id && task.status === 'done')) || localApprovals.length) {
198
+ agentPanelOpen = true;
199
+ renderAgentPanel();
200
+ }
201
+ } else startAgentSession();
202
+ })
203
+ : null;
169
204
  const divider = document.createElement("span");
170
205
  divider.className = "comty-tool-divider";
171
206
  toolbar.appendChild(divider);
@@ -175,7 +210,7 @@ export function initComty(options = {}) {
175
210
  const primaryTools = document.createElement("div");
176
211
  primaryTools.className = "comty-tool-primary";
177
212
  toolbar.insertBefore(primaryTools, browseBtn);
178
- for (const button of [browseBtn, inspectorBtn, btn]) primaryTools.appendChild(button);
213
+ for (const button of [browseBtn, inspectorBtn, btn, agentBtn].filter(Boolean)) primaryTools.appendChild(button);
179
214
  const inspectorTools = document.createElement("div");
180
215
  inspectorTools.className = "comty-tool-context";
181
216
  inspectorTools.hidden = true;
@@ -1028,6 +1063,50 @@ export function initComty(options = {}) {
1028
1063
  let toolbarMotionFrame = 0;
1029
1064
  let toolbarAnimations = [];
1030
1065
  const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
1066
+ let workingIndicatorTimer = 0;
1067
+ function scheduleWorkingIndicators() {
1068
+ if (workingIndicatorTimer || reducedMotion.matches || document.hidden) return;
1069
+ workingIndicatorTimer = setTimeout(() => {
1070
+ workingIndicatorTimer = 0;
1071
+ if (!root.isConnected) return;
1072
+ const indicators = root.querySelectorAll('.comty-working-indicator');
1073
+ if (!indicators.length) return;
1074
+ for (const indicator of indicators) {
1075
+ const previous = Number(indicator.dataset.activeDots.split(',')[0]);
1076
+ const first = (previous + 1 + Math.floor(Math.random() * 8)) % 9;
1077
+ const second = (first + 1 + Math.floor(Math.random() * 8)) % 9;
1078
+ indicator.dataset.activeDots = `${first},${second}`;
1079
+ [...indicator.children].forEach((dot, index) => dot.toggleAttribute('data-active', index === first || index === second));
1080
+ }
1081
+ scheduleWorkingIndicators();
1082
+ }, 600);
1083
+ }
1084
+ function createWorkingIndicator() {
1085
+ const indicator = document.createElement('span');
1086
+ indicator.className = 'comty-working-indicator';
1087
+ indicator.setAttribute('aria-hidden', 'true');
1088
+ indicator.dataset.activeDots = '3,5';
1089
+ for (let index = 0; index < 9; index++) {
1090
+ const dot = document.createElement('i');
1091
+ dot.toggleAttribute('data-active', index === 3 || index === 5);
1092
+ indicator.appendChild(dot);
1093
+ }
1094
+ scheduleWorkingIndicators();
1095
+ return indicator;
1096
+ }
1097
+ function resetWorkingIndicators() {
1098
+ clearTimeout(workingIndicatorTimer);
1099
+ workingIndicatorTimer = 0;
1100
+ if (reducedMotion.matches) {
1101
+ for (const indicator of root.querySelectorAll('.comty-working-indicator')) {
1102
+ indicator.dataset.activeDots = '3,5';
1103
+ [...indicator.children].forEach((dot, index) => dot.toggleAttribute('data-active', index === 3 || index === 5));
1104
+ }
1105
+ }
1106
+ if (root.querySelector('.comty-working-indicator')) scheduleWorkingIndicators();
1107
+ }
1108
+ reducedMotion.addEventListener('change', resetWorkingIndicators);
1109
+ document.addEventListener('visibilitychange', resetWorkingIndicators);
1031
1110
  const clampToolbar = (value, size, viewport) => Math.max(8, Math.min(value, viewport - size - 8));
1032
1111
  function rememberToolbarPosition(left, top) {
1033
1112
  const width = toolbar.offsetWidth, height = toolbar.offsetHeight;
@@ -1140,7 +1219,7 @@ export function initComty(options = {}) {
1140
1219
 
1141
1220
  function prepareToolbarMotion() {
1142
1221
  if (toolbarMotionFrame) return;
1143
- const nodes = [toolbarSurface, dragHandle, primaryTools, commentTools, inspectorTools].filter(Boolean);
1222
+ const nodes = [toolbarSurface, dragHandle, primaryTools, commentTools, inspectorTools];
1144
1223
  // Move each group's background with its icons, including when interrupted.
1145
1224
  const before = new Map(nodes.filter(node => !node.hidden).map(node => [node, {
1146
1225
  rect: node.getBoundingClientRect(), opacity: getComputedStyle(node).opacity,
@@ -1364,7 +1443,8 @@ export function initComty(options = {}) {
1364
1443
  selectedElement = null;
1365
1444
  sessionEntry = null;
1366
1445
  session.mode = "off";
1367
- document.documentElement.classList.remove("comty-comments-active", "comty-comment-placement");
1446
+ syncPlacementCursor();
1447
+ document.documentElement.classList.remove("comty-comments-active", "comty-inspect-annotations");
1368
1448
  hideInspector();
1369
1449
  setSidepanelOpen(false);
1370
1450
  closeBubbles();
@@ -1378,25 +1458,29 @@ export function initComty(options = {}) {
1378
1458
  if (mode === "off") return;
1379
1459
  if (mode === "inspect") {
1380
1460
  session.mode = "inspect";
1461
+ document.documentElement.classList.add("comty-inspect-annotations");
1381
1462
  syncToolbar();
1463
+ renderCachedComments();
1382
1464
  ensureLiveTracking();
1383
1465
  return;
1384
1466
  }
1385
1467
 
1386
- if (session.edit && mode === "browse") {
1468
+ if ((session.edit && mode === "browse") || (session.agent && mode === "edit")) {
1387
1469
  closeBubbles();
1388
1470
  }
1389
1471
  const hadComments = session.comments;
1390
1472
  session.mode = mode;
1391
1473
  sessionEntry = entry;
1474
+ document.documentElement.classList.remove("comty-inspect-annotations");
1392
1475
  document.documentElement.classList.toggle("comty-comments-active", session.comments);
1393
- document.documentElement.classList.toggle("comty-comment-placement", session.edit);
1476
+ syncPlacementCursor();
1394
1477
  if (session.edit) {
1395
1478
  setMotionEnabled(false);
1396
1479
  selectedElement = null;
1397
1480
  } else {
1398
1481
  hideInspector();
1399
1482
  }
1483
+ if (session.agent && sidepanelOpen) setSidepanelOpen(false);
1400
1484
  syncToolbar();
1401
1485
  ensureLiveTracking();
1402
1486
  if (renderComments || !hadComments) renderCachedComments();
@@ -1409,6 +1493,10 @@ export function initComty(options = {}) {
1409
1493
  }
1410
1494
 
1411
1495
  function showAuthPrompt(forbidden = false, onReady = startEditSession) {
1496
+ if (localOnly) {
1497
+ showAgentBridgeError("Shared comments need a Comty project key. Use the Agent tool for local tasks without signing in.");
1498
+ return;
1499
+ }
1412
1500
  const guestAllowed = allowGuestComments;
1413
1501
  document.querySelector(".comty-auth-backdrop")?.remove();
1414
1502
  const opener = document.activeElement;
@@ -1530,14 +1618,16 @@ export function initComty(options = {}) {
1530
1618
  }
1531
1619
 
1532
1620
  function syncToolbar() {
1533
- if (commentTools.hidden !== session.inspect || inspectorTools.hidden !== !session.inspect) prepareToolbarMotion();
1621
+ if (commentTools.hidden !== (session.inspect || session.agent) || inspectorTools.hidden !== !session.inspect) prepareToolbarMotion();
1534
1622
  root.classList.toggle("comty-active", session.comments || session.edit || sidepanelOpen || session.inspect);
1535
1623
  browseBtn.setAttribute("aria-pressed", String(!session.edit && !session.inspect));
1536
1624
  inspectorBtn.setAttribute("aria-pressed", String(session.inspect));
1537
- btn.setAttribute("aria-pressed", String(session.edit));
1538
- commentTools.hidden = session.inspect;
1625
+ btn.setAttribute("aria-pressed", String(session.edit && !session.agent));
1626
+ agentBtn?.setAttribute("aria-pressed", String(session.edit && session.agent));
1627
+ commentTools.hidden = session.inspect || session.agent;
1539
1628
  inspectorTools.hidden = !session.inspect;
1540
1629
  syncInspectorTools();
1630
+ renderAgentPanel();
1541
1631
  }
1542
1632
 
1543
1633
  const inspectionLayer = document.createElement("div");
@@ -2000,6 +2090,13 @@ export function initComty(options = {}) {
2000
2090
  setSession("edit", { renderComments: true });
2001
2091
  }
2002
2092
 
2093
+ function startAgentSession() {
2094
+ if (!isLocalSite) return;
2095
+ if (session.agent) return;
2096
+ closeBubbles();
2097
+ setSession("agent", { renderComments: true });
2098
+ }
2099
+
2003
2100
  function toggleSidepanel() {
2004
2101
  if (sidepanelOpen) {
2005
2102
  setSidepanelOpen(false);
@@ -2023,7 +2120,7 @@ export function initComty(options = {}) {
2023
2120
  showAuthPrompt();
2024
2121
  return;
2025
2122
  }
2026
- if (session.inspect || !isSessionActive()) {
2123
+ if (session.agent || session.inspect || !isSessionActive()) {
2027
2124
  startEditSession();
2028
2125
  return;
2029
2126
  }
@@ -2311,6 +2408,24 @@ export function initComty(options = {}) {
2311
2408
  ancestors,
2312
2409
  layer: layer ? getElementFingerprint(layer) : null,
2313
2410
  };
2411
+ // React exposes this in development builds. Keep it as a hint: the coding
2412
+ // agent must confirm the component and variant in source before editing.
2413
+ const fiberKey = Object.keys(el).find(key => key.startsWith('__reactFiber$'));
2414
+ let fiber = fiberKey ? el[fiberKey] : null;
2415
+ const components = [];
2416
+ while (fiber && components.length < 8) {
2417
+ const component = fiber.type;
2418
+ const name = component?.displayName || component?.name;
2419
+ if (name && typeof name === 'string') {
2420
+ const props = fiber.memoizedProps || {};
2421
+ const variants = Object.fromEntries(['variant', 'size', 'tone', 'appearance'].filter(key =>
2422
+ ['string', 'number', 'boolean'].includes(typeof props[key])).map(key => [key, props[key]]));
2423
+ const source = fiber._debugSource;
2424
+ components.push({ name, variants, ...(source?.fileName ? { file: source.fileName, line: source.lineNumber } : {}) });
2425
+ }
2426
+ fiber = fiber.return;
2427
+ }
2428
+ if (components.length) context.components = components;
2314
2429
  context.candidateCount = [...document.querySelectorAll(context.target.tag)]
2315
2430
  .filter(candidate => !isComtyNode(candidate) && matchesAnchorContext(candidate, context)).length;
2316
2431
  return context;
@@ -2339,6 +2454,11 @@ export function initComty(options = {}) {
2339
2454
  };
2340
2455
  }
2341
2456
 
2457
+ function getPagePath() {
2458
+ const { pathname, hash } = window.location;
2459
+ return pathname + (hash.startsWith("#/") || hash.startsWith("#!/") ? hash : "");
2460
+ }
2461
+
2342
2462
  function resolveBySelector(selector) {
2343
2463
  if (!selector) return null;
2344
2464
  try {
@@ -2360,17 +2480,9 @@ export function initComty(options = {}) {
2360
2480
  }
2361
2481
  const context = parseAnchorContext(comment);
2362
2482
  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 });
2483
+ const resolution = resolveAnchor(context, exact);
2484
+ const element = resolution.element ? getAnchorElement(resolution.element) : null;
2485
+ commentTargets.set(key, { ...resolution, element, signature, revision: anchorRevision });
2374
2486
  return element;
2375
2487
  }
2376
2488
 
@@ -2441,9 +2553,14 @@ export function initComty(options = {}) {
2441
2553
  return { deviceType: "desktop", deviceLabel: "Desktop" };
2442
2554
  }
2443
2555
 
2556
+ function syncPlacementCursor() {
2557
+ document.documentElement.classList.toggle("comty-comment-placement", session.edit && !draftAnchor);
2558
+ }
2559
+
2444
2560
  function closeBubbles() {
2445
2561
  const hadDraft = Boolean(draftAnchor);
2446
2562
  draftAnchor = null;
2563
+ syncPlacementCursor();
2447
2564
  activeHoverCommentIds.clear();
2448
2565
  if (!session.edit || hadDraft) hideInspector();
2449
2566
  document
@@ -2488,7 +2605,7 @@ export function initComty(options = {}) {
2488
2605
  pending: commentId.startsWith("pending-"),
2489
2606
  project_id: projectId,
2490
2607
  domain: payload.host || window.location.host,
2491
- page_path: payload.pagePath || window.location.pathname,
2608
+ page_path: payload.pagePath || getPagePath(),
2492
2609
  selector: payload.selector || "body",
2493
2610
  rel_x: payload.relX,
2494
2611
  rel_y: payload.relY,
@@ -2513,7 +2630,7 @@ export function initComty(options = {}) {
2513
2630
  replies: [],
2514
2631
  };
2515
2632
 
2516
- cachedPagePath = window.location.pathname;
2633
+ cachedPagePath = getPagePath();
2517
2634
  cachedComments = [
2518
2635
  ...cachedComments.filter(
2519
2636
  (cached) => getCommentId(cached) !== commentId,
@@ -2526,6 +2643,7 @@ export function initComty(options = {}) {
2526
2643
  }
2527
2644
 
2528
2645
  function getCommentAuthorName(comment) {
2646
+ if (comment?.author_kind === 'agent') return 'Agent';
2529
2647
  return comment?.author_name || comment?.name || "Anonymous";
2530
2648
  }
2531
2649
 
@@ -2547,6 +2665,12 @@ export function initComty(options = {}) {
2547
2665
  function createCommentAvatar(comment, extraClass = "") {
2548
2666
  const avatar = document.createElement("span");
2549
2667
  avatar.className = `comty-comment-avatar${extraClass ? ` ${extraClass}` : ""}`;
2668
+ if (comment?.author_kind === 'agent') {
2669
+ avatar.classList.add('comty-agent-avatar');
2670
+ avatar.setAttribute('aria-label', 'Agent');
2671
+ 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>';
2672
+ return avatar;
2673
+ }
2550
2674
  const authorName = getCommentAuthorName(comment);
2551
2675
  const avatarUrl = getCommentAuthorAvatarUrl(comment);
2552
2676
 
@@ -2554,6 +2678,7 @@ export function initComty(options = {}) {
2554
2678
  const image = document.createElement("img");
2555
2679
  image.src = avatarUrl;
2556
2680
  image.alt = "";
2681
+ image.draggable = false;
2557
2682
  image.onerror = () => {
2558
2683
  image.remove();
2559
2684
  avatar.innerText = authorName.charAt(0).toUpperCase();
@@ -2778,6 +2903,7 @@ export function initComty(options = {}) {
2778
2903
  payload,
2779
2904
  preview: { rect: target.getBoundingClientRect(), rows: getInspectRows(target), selector: getSelector(target), spacing: getSpacingPreview(target) },
2780
2905
  };
2906
+ syncPlacementCursor();
2781
2907
  ensureLiveTracking();
2782
2908
 
2783
2909
  const pin = document.createElement("div");
@@ -2795,10 +2921,21 @@ export function initComty(options = {}) {
2795
2921
 
2796
2922
  const textarea = document.createElement("textarea");
2797
2923
  textarea.className = "comty-create-textarea";
2798
- textarea.placeholder = "Add a comment";
2924
+ textarea.placeholder = payload.kind === "agent" ? "Ask a question or describe a change" : "Add a comment";
2799
2925
  textarea.setAttribute("aria-label", textarea.placeholder);
2800
2926
  textarea.rows = 1;
2801
2927
 
2928
+ const scopePicker = document.createElement('label');
2929
+ scopePicker.className = 'comty-agent-scope';
2930
+ if (payload.kind === 'agent') {
2931
+ const input = document.createElement('input');
2932
+ input.type = 'checkbox';
2933
+ input.setAttribute('aria-label', 'Apply to all instances of this variant');
2934
+ const track = document.createElement('span'); track.className = 'comty-agent-scope-track'; track.setAttribute('aria-hidden', 'true');
2935
+ scopePicker.title = 'Apply to all instances of this variant';
2936
+ scopePicker.append(input, track, document.createTextNode('Apply globally'));
2937
+ }
2938
+
2802
2939
  const actions = document.createElement("div");
2803
2940
  actions.className = "comty-create-actions";
2804
2941
 
@@ -2852,7 +2989,7 @@ export function initComty(options = {}) {
2852
2989
  const submitBtn = document.createElement("button");
2853
2990
  submitBtn.type = "button";
2854
2991
  submitBtn.className = "comty-btn-submit";
2855
- submitBtn.setAttribute("aria-label", "Post comment");
2992
+ submitBtn.setAttribute("aria-label", payload.kind === "agent" ? "Send agent task" : "Post comment");
2856
2993
  submitBtn.disabled = true;
2857
2994
  submitBtn.innerHTML = `
2858
2995
  <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 +3006,62 @@ export function initComty(options = {}) {
2869
3006
  textarea.disabled = true;
2870
3007
  linkBtn.disabled = true;
2871
3008
 
3009
+ if (payload.kind === "agent" && !agent) {
3010
+ showAgentBridgeError(); submitBtn.disabled = false; textarea.disabled = false; return;
3011
+ }
2872
3012
  payload.content = content;
3013
+ if (payload.kind === 'agent') payload.scope = scopePicker.querySelector('input').checked ? 'variant' : 'element';
2873
3014
  payload.linkUrl = attachedLink || null;
2874
3015
  textarea.textContent = content;
2875
3016
 
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.
3017
+ const isAgentTask = payload.kind === "agent";
3018
+ const context = isAgentTask ? null : captureContext();
3019
+ const point = isAgentTask ? null : getPinPoint(pin);
3020
+ const pendingId = isAgentTask ? null : `pending-${crypto.randomUUID()}`;
2880
3021
  bubble.remove();
2881
3022
  pin.remove();
2882
3023
  if (draftAnchor === editorDraft) draftAnchor = null;
3024
+ syncPlacementCursor();
2883
3025
  hideInspector();
2884
- addLocalComment(pendingId, payload);
3026
+ if (!isAgentTask) addLocalComment(pendingId, payload);
2885
3027
  let post;
2886
3028
  try {
2887
- post = await fetch(`${apiUrl}/api/comments`, {
3029
+ post = await fetch(payload.kind === "agent" ? `${agentBridgeUrl}/tasks` : `${apiUrl}/api/comments`, {
2888
3030
  method: "POST",
2889
- headers: getSdkHeaders({ "Content-Type": "application/json" }),
2890
- body: JSON.stringify(payload),
3031
+ headers: payload.kind === "agent" ? agentHeaders() : getSdkHeaders({ "Content-Type": "application/json" }),
3032
+ body: JSON.stringify(payload.kind === "agent" ? { ...payload, projectKey } : payload),
2891
3033
  });
2892
- if (!post.ok) throw new Error(`Comment save failed (${post.status})`);
3034
+ if (!post.ok) throw new Error(`Save failed (${post.status})`);
2893
3035
  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 });
3036
+ const id = isAgentTask ? data?.task?.id : data?.id;
3037
+ if (!id) throw new Error("Save returned no ID");
3038
+ if (isAgentTask) {
3039
+ void loadComments({ force: true });
3040
+ } else {
3041
+ cachedComments = cachedComments.filter(item => String(item.id) !== pendingId);
3042
+ if (context.url === location.href) {
3043
+ const comment = addLocalComment(String(id), payload);
3044
+ void uploadCommentScreenshot(comment, context, point);
3045
+ } else void loadComments({ force: true });
3046
+ }
2901
3047
  } catch (error) {
2902
3048
  console.error("Comty: save failed", error);
2903
- cachedComments = cachedComments.filter(item => String(item.id) !== pendingId);
2904
- renderSidepanelComments();
2905
- void renderComments(cachedComments, { retry: false });
3049
+ if (isAgentTask) showAgentBridgeError(error.message);
3050
+ else {
3051
+ cachedComments = cachedComments.filter(item => String(item.id) !== pendingId);
3052
+ renderSidepanelComments();
3053
+ void renderComments(cachedComments, { retry: false });
3054
+ }
2906
3055
  // Keep the original editor and its text available for retry, including attachments.
2907
3056
  if (!draftAnchor) draftAnchor = editorDraft;
3057
+ syncPlacementCursor();
2908
3058
  root.append(pin, bubble);
2909
3059
  submitBtn.disabled = false;
2910
3060
  textarea.disabled = false;
2911
3061
  linkBtn.disabled = false;
2912
3062
  const errorMessage = document.createElement("p");
2913
3063
  errorMessage.setAttribute("role", "alert");
2914
- errorMessage.textContent = "Comment not saved. Your draft is ready to retry.";
3064
+ errorMessage.textContent = `${isAgentTask ? "Task" : "Comment"} not saved. Your draft is ready to retry.`;
2915
3065
  bubble.querySelector('[role="alert"]')?.remove();
2916
3066
  bubble.append(errorMessage);
2917
3067
  if (bubble.isConnected) textarea.focus();
@@ -3000,7 +3150,8 @@ export function initComty(options = {}) {
3000
3150
  renderInspector();
3001
3151
  };
3002
3152
 
3003
- actions.append(linkBtn, submitBtn, linkEditor);
3153
+ if (payload.kind === 'agent') actions.append(scopePicker, submitBtn);
3154
+ else actions.append(linkBtn, submitBtn, linkEditor);
3004
3155
 
3005
3156
  bubble.appendChild(textarea);
3006
3157
  bubble.appendChild(attachment);
@@ -3013,6 +3164,219 @@ export function initComty(options = {}) {
3013
3164
  setTimeout(() => textarea.focus(), 10);
3014
3165
  }
3015
3166
 
3167
+ function agentHeaders() { return { "Content-Type": "application/json", "X-Comty-Token": agent?.token || "" }; }
3168
+
3169
+ function showAgentBridgeError(message = "Start your app with comty dev to use the local agent. Check the terminal if it is already running.") {
3170
+ root.querySelectorAll(".comty-agent-task-toast").forEach((node) => node.remove());
3171
+ const toast = document.createElement("div");
3172
+ toast.className = "comty-auth-toast comty-agent-task-toast";
3173
+ toast.setAttribute("role", "alert");
3174
+ const issue = describeAgentError(message);
3175
+ toast.textContent = issue.title + ". " + issue.description;
3176
+ root.appendChild(toast);
3177
+ setTimeout(() => toast.remove(), 5000);
3178
+ }
3179
+
3180
+ function describeAgentError(error) {
3181
+ const raw = typeof error === 'string' ? error : JSON.stringify(error, null, 2) || 'Unknown error';
3182
+ let value = error;
3183
+ for (let depth = 0; depth < 8; depth++) {
3184
+ if (typeof value === 'string') {
3185
+ try { value = JSON.parse(value); continue; } catch {}
3186
+ const start = value.indexOf('{'), end = value.lastIndexOf('}');
3187
+ if (start >= 0 && end > start) { try { value = JSON.parse(value.slice(start, end + 1)); continue; } catch {} }
3188
+ break;
3189
+ }
3190
+ if (!value || typeof value !== 'object') break;
3191
+ value = value.error || value.message || value.cause || value.stderr || value;
3192
+ }
3193
+ const message = typeof value === 'string' ? value : '';
3194
+ const source = raw + ' ' + message;
3195
+ const agentName = /codex/i.test(source) ? 'Codex' : /claude/i.test(source) ? 'Claude Code' : /cursor/i.test(source) ? 'Cursor' : 'your coding agent';
3196
+ let title, description;
3197
+ if (/requires a newer version|upgrade.*latest|outdated|unsupported.*version/i.test(source)) {
3198
+ title = 'Agent update required'; description = `Update ${agentName}, restart your local development session, then retry this task.`;
3199
+ } else if (/insufficient_quota|quota exceeded|credit balance|billing|usage limit/i.test(source)) {
3200
+ title = 'Usage limit reached'; description = 'Check your agent account’s usage allowance or billing, then retry when capacity is available.';
3201
+ } else if (/rate.?limit|too many requests|\b429\b/i.test(source)) {
3202
+ title = 'Too many requests'; description = 'The agent is temporarily rate limited. Wait a moment before retrying.';
3203
+ } else if (/unauthorized|unauthenticated|authentication|not logged|sign.?in|log.?in|invalid.api.key|\b401\b/i.test(source)) {
3204
+ title = 'Sign in to your agent'; description = `Open ${agentName} and sign in again, then retry this task.`;
3205
+ } else if (/model.*(?:not found|does not exist|unavailable|not supported)|unsupported model|model_not_found/i.test(source)) {
3206
+ title = 'Model unavailable'; description = 'Choose a model available to your agent account in the local Comty configuration, then restart and retry.';
3207
+ } else if (/disconnected|ECONNREFUSED|ENOTFOUND|network|failed to fetch|development server stopped/i.test(source)) {
3208
+ title = 'Connection lost'; description = 'Check that your local development server and coding agent are running, then reload this page.';
3209
+ } else if (/timed? ?out|timeout|exceeded.*minutes/i.test(source)) {
3210
+ title = 'Task timed out'; description = 'Review any changes already made, then retry with a smaller, more specific request.';
3211
+ } else if (/permission|denied|forbidden|\b403\b/i.test(source)) {
3212
+ title = 'Permission needed'; description = 'Check the agent’s access to this project and any pending approval before retrying.';
3213
+ } else if (/ENOENT|command not found|not installed|executable.*not found/i.test(source)) {
3214
+ title = 'Agent not found'; description = `Install or configure ${agentName} in your local development environment, then restart and retry.`;
3215
+ } else if (/completion was not verified/i.test(source)) {
3216
+ title = 'Result needs checking'; description = 'The agent could not confirm completion. Review the changes before retrying.';
3217
+ } else {
3218
+ title = 'Task could not finish';
3219
+ description = message && !/[{}]|\[object Object\]/.test(message) ? message.slice(0, 300) : 'The agent reported an unexpected error. Review the technical details and any changes before retrying.';
3220
+ }
3221
+ return { title, description, raw, message };
3222
+ }
3223
+
3224
+ function renderAgentPanel() {
3225
+ if (!agent) return;
3226
+ const visibleTasks = [];
3227
+ const latestResult = [...localTasks].reverse().find(task => ['done', 'in_review'].includes(task.status) && (!task.thread_comment_id || task.status === 'in_review'));
3228
+ if (latestResult) visibleTasks.push(latestResult);
3229
+ if (!visibleTasks.length && !localApprovals.length && !agentActionError) agentPanelOpen = false;
3230
+ let panel = root.querySelector(".comty-agent-panel");
3231
+ if (!panel && !agentPanelOpen) return;
3232
+ if (!panel) {
3233
+ panel = document.createElement("section");
3234
+ panel.className = "comty-agent-panel";
3235
+ panel.setAttribute("aria-label", "Local agent tasks");
3236
+ panel.hidden = true;
3237
+ // Keep the task card above inspection overlays and below the toolbar.
3238
+ root.insertBefore(panel, toolbar);
3239
+ }
3240
+ const open = Boolean(agent) && agentPanelOpen;
3241
+ if (open) {
3242
+ if (panel.hidden) {
3243
+ const rect = toolbar.getBoundingClientRect();
3244
+ panel.dataset.side = rect.left + rect.width / 2 < innerWidth / 2 ? "left" : "right";
3245
+ panel.dataset.closed = "";
3246
+ panel.hidden = false;
3247
+ panel.getBoundingClientRect();
3248
+ }
3249
+ if (panel.hasAttribute("data-closed")) { agentPanelTransition++; delete panel.dataset.closed; }
3250
+ } else if (!panel.hidden && !panel.hasAttribute("data-closed")) {
3251
+ const transition = ++agentPanelTransition;
3252
+ panel.dataset.closed = "";
3253
+ const finish = () => { if (transition === agentPanelTransition && panel.hasAttribute("data-closed")) panel.hidden = true; };
3254
+ if (reducedMotion.matches) finish();
3255
+ else setTimeout(finish, parseFloat(getComputedStyle(root).getPropertyValue("--comty-motion-duration")));
3256
+ }
3257
+ panel.inert = !open;
3258
+ panel.setAttribute("aria-hidden", String(!open));
3259
+ const signature = JSON.stringify([visibleTasks, localApprovals, agentConnectionError, agentActionError, agentPanelOpen]);
3260
+ if (signature === agentPanelSignature) return;
3261
+ agentPanelSignature = signature;
3262
+ root.querySelector('.comty-agent-error-popover')?.remove();
3263
+ const expanded = new Set([...panel.querySelectorAll('details[data-detail-key][open]')].map(node => node.dataset.detailKey));
3264
+ const focusKey = panel.contains(document.activeElement) ? document.activeElement.dataset.focusKey : null;
3265
+ const scrollTop = panel.querySelector('.comty-agent-panel-content')?.scrollTop || 0;
3266
+ const node = (tag, className, text) => {
3267
+ const element = document.createElement(tag);
3268
+ if (className) element.className = className;
3269
+ if (text != null) element.textContent = text;
3270
+ return element;
3271
+ };
3272
+ const brand = () => createCommentAvatar({ author_kind: 'agent' }, 'comty-agent-brand');
3273
+ panel.replaceChildren();
3274
+ const header = node('header', 'comty-agent-panel-header');
3275
+ const heading = node('div', 'comty-agent-heading');
3276
+ heading.append(node('strong', '', 'Agent'));
3277
+ const toggle = node('button', 'comty-agent-action comty-agent-close'); toggle.type = 'button';
3278
+ toggle.innerHTML = icon('<path d="m6 6 12 12M6 18 18 6"/>');
3279
+ toggle.setAttribute('aria-label', 'Close Agent task'); toggle.title = 'Close'; toggle.dataset.focusKey = 'close';
3280
+ toggle.onclick = () => { agentPanelOpen = false; renderAgentPanel(); agentBtn?.focus({ preventScroll: true }); };
3281
+ header.append(heading, toggle); panel.appendChild(header);
3282
+ const content = node('div', 'comty-agent-panel-content'); panel.appendChild(content);
3283
+ const technicalDetails = (parent, raw, key) => {
3284
+ const details = node('details', 'comty-agent-details'); details.dataset.detailKey = key; details.open = expanded.has(key);
3285
+ const summary = node('summary', '', 'Technical details'); summary.dataset.focusKey = 'details-' + key;
3286
+ details.append(summary, node('pre', '', raw)); parent.appendChild(details);
3287
+ };
3288
+ const errorMessage = (parent, error, key) => {
3289
+ const issue = describeAgentError(error);
3290
+ const message = node('div', 'comty-agent-error');
3291
+ message.append(node('strong', '', issue.title), node('p', '', issue.description));
3292
+ parent.appendChild(message); technicalDetails(parent, issue.raw, key);
3293
+ };
3294
+ if (agentConnectionError || agentActionError) {
3295
+ const banner = node('div', 'comty-agent-item comty-agent-banner'); banner.setAttribute('role', 'alert');
3296
+ errorMessage(banner, agentConnectionError || agentActionError, 'connection'); content.appendChild(banner);
3297
+ }
3298
+ const action = (parent, label, path, input = {}, primary = false) => {
3299
+ const button = node('button', 'comty-agent-action', label); button.type = 'button';
3300
+ const key = path + JSON.stringify(input); button.dataset.focusKey = key;
3301
+ button.disabled = agentPendingActions.has(key);
3302
+ if (primary) button.dataset.primary = '';
3303
+ button.onclick = async () => {
3304
+ if (agentPendingActions.has(key)) return;
3305
+ agentActionError = '';
3306
+ agentPendingActions.add(key); button.disabled = true; button.textContent = 'Please wait…';
3307
+ try {
3308
+ const response = await fetch(`${agentBridgeUrl}/${path}`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify(input) });
3309
+ if (!response.ok) {
3310
+ const body = await response.json().catch(() => null);
3311
+ throw new Error(typeof body?.error === 'string' ? body.error : JSON.stringify(body?.error || { status: response.status, message: 'Request failed' }));
3312
+ }
3313
+ await loadComments({ force: true });
3314
+ } catch (error) { agentActionError = error.message; }
3315
+ finally { agentPendingActions.delete(key); agentPanelSignature = ''; renderAgentPanel(); }
3316
+ };
3317
+ parent.appendChild(button);
3318
+ };
3319
+ for (const approval of localApprovals) {
3320
+ const section = node('div', 'comty-agent-item');
3321
+ section.append(node('h3', '', 'Approval required'));
3322
+ const task = localTasks.find(task => task.id === approval.taskId);
3323
+ if (task) section.appendChild(node('p', '', task.content));
3324
+ section.appendChild(node('p', '', 'Review this operation before allowing the agent to continue.'));
3325
+ const operation = approval.details?.command || approval.details?.file_path || approval.details?.path;
3326
+ if (typeof operation === 'string' || Array.isArray(operation)) section.appendChild(node('pre', '', Array.isArray(operation) ? operation.join(' ') : operation));
3327
+ technicalDetails(section, JSON.stringify(approval.details ?? {}, null, 2), 'approval-' + approval.id);
3328
+ const actions = node('div', 'comty-agent-actions');
3329
+ action(actions, 'Allow once', `approvals/${approval.id}`, { allow: true }, true);
3330
+ action(actions, 'Deny', `approvals/${approval.id}`, { allow: false });
3331
+ section.appendChild(actions); content.appendChild(section);
3332
+ }
3333
+ const list = node('div', 'comty-agent-list'); content.appendChild(list);
3334
+ const hideErrorPopover = () => root.querySelector('.comty-agent-error-popover')?.remove();
3335
+ content.addEventListener('scroll', hideErrorPopover, { passive: true });
3336
+ for (const [index, task] of visibleTasks.reverse().entries()) {
3337
+ const failed = Boolean(task.error) || task.status === 'in_review';
3338
+ const state = task.status === 'done' ? 'success' : failed ? 'error' : 'working';
3339
+ const row = node('div', 'comty-agent-row'); row.dataset.taskId = task.id;
3340
+ const dot = state === 'working' ? createWorkingIndicator() : node('span', 'comty-agent-dot');
3341
+ dot.dataset.status = state; dot.setAttribute('aria-hidden', 'true');
3342
+ const name = node('span', 'comty-agent-row-name', task.thread_comment_id ? `@Agent · ${task.content}` : task.content);
3343
+ row.append(dot, name);
3344
+ if (state === 'working') row.appendChild(node('span', 'comty-agent-row-state', task.status === 'todo' ? 'Starting' : 'Working'));
3345
+ const actions = node('div', 'comty-agent-row-actions');
3346
+ if (['in_review', 'cancel'].includes(task.status)) action(actions, 'Retry', `tasks/${task.id}/retry`);
3347
+ if (['todo', 'in_progress'].includes(task.status)) action(actions, 'Cancel', `tasks/${task.id}/cancel`);
3348
+ if (task.status === 'done') action(actions, 'Dismiss', `tasks/${task.id}/dismiss`);
3349
+ if (task.status === 'in_review') action(actions, 'Dismiss', `tasks/${task.id}/dismiss`);
3350
+ if (actions.childElementCount) row.appendChild(actions);
3351
+ row.setAttribute('aria-label', `${task.content}, ${state === 'error' ? 'failed' : state === 'success' ? 'completed' : state === 'cancel' ? 'cancelled' : task.status === 'todo' ? 'starting' : 'working'}`);
3352
+ if (failed) {
3353
+ const tooltipId = `comty-agent-error-${index}`;
3354
+ const showErrorPopover = () => {
3355
+ hideErrorPopover();
3356
+ const issue = describeAgentError(task.error || task.result?.summary || task.progress || 'Completion was not verified');
3357
+ const popover = node('div', 'comty-agent-error-popover'); popover.id = tooltipId; popover.setAttribute('role', 'tooltip');
3358
+ popover.append(node('strong', '', task.content), node('p', '', issue.message || issue.description));
3359
+ root.appendChild(popover);
3360
+ const rowRect = row.getBoundingClientRect();
3361
+ const popoverRect = popover.getBoundingClientRect();
3362
+ const left = panel.dataset.side === 'right' ? rowRect.left - popoverRect.width - 8 : rowRect.right + 8;
3363
+ popover.style.left = `${Math.max(8, Math.min(left, innerWidth - popoverRect.width - 8))}px`;
3364
+ popover.style.top = `${Math.max(8, Math.min(rowRect.top - 4, innerHeight - popoverRect.height - 8))}px`;
3365
+ };
3366
+ row.addEventListener('mouseenter', showErrorPopover);
3367
+ row.addEventListener('mouseleave', hideErrorPopover);
3368
+ }
3369
+ list.appendChild(row);
3370
+ if (task.result?.summary) {
3371
+ const answer = node('div', 'comty-agent-answer');
3372
+ answer.append(brand(), node('p', '', task.result.summary));
3373
+ list.appendChild(answer);
3374
+ }
3375
+ }
3376
+ content.scrollTop = scrollTop;
3377
+ if (focusKey && open) [...panel.querySelectorAll('[data-focus-key]')].find(element => element.dataset.focusKey === focusKey)?.focus({ preventScroll: true });
3378
+ }
3379
+
3016
3380
  function setSidepanelOpen(open) {
3017
3381
  sidepanelOpen = open;
3018
3382
  sidepanel.classList.toggle("comty-sidepanel-open", sidepanelOpen);
@@ -3048,12 +3412,52 @@ export function initComty(options = {}) {
3048
3412
 
3049
3413
  function openCommentFromPanel(comment) {
3050
3414
  const target = resolveCommentElement(comment);
3051
- if (!target || isElementTreeHidden(target)) return;
3415
+ if (!target || isElementTreeHidden(target)) {
3416
+ openDetachedThread(comment);
3417
+ return;
3418
+ }
3052
3419
  setSession(session.comments ? session.mode : "browse", { entry: sessionEntry, renderComments: true });
3053
3420
  target.scrollIntoView({ block: "center", inline: "nearest", behavior: "instant" });
3421
+ const rect = target.getBoundingClientRect();
3422
+ const relX = Number(comment.rel_x ?? comment.relX);
3423
+ const relY = Number(comment.rel_y ?? comment.relY);
3424
+ // Large page containers must reveal the annotation point, not their center.
3425
+ window.scrollBy({
3426
+ left: rect.width > innerWidth && Number.isFinite(relX) ? rect.left + Math.max(0, Math.min(1, relX)) * rect.width - innerWidth / 2 : 0,
3427
+ top: rect.height > innerHeight && Number.isFinite(relY) ? rect.top + Math.max(0, Math.min(1, relY)) * rect.height - innerHeight / 2 : 0,
3428
+ behavior: "instant",
3429
+ });
3054
3430
  syncCommentAnchors();
3055
3431
  const pin = findPlacedPin(comment);
3056
3432
  if (pin && !pin.hasAttribute("data-comty-anchor-hidden")) pin.click();
3433
+ else openDetachedThread(comment);
3434
+ }
3435
+
3436
+ function openDetachedThread(comment) {
3437
+ openCommentThread(comment, null, null, innerWidth / 2, innerHeight / 3, () => {});
3438
+ const bubble = [...root.querySelectorAll(".comty-bubble")].find(node => node.dataset.commentId === String(comment.id));
3439
+ if (bubble) {
3440
+ bubble.dataset.detached = "";
3441
+ positionCommentBubble(bubble, innerWidth / 2, innerHeight / 3);
3442
+ }
3443
+ }
3444
+
3445
+ function updateAnchorBadge(item, comment) {
3446
+ resolveCommentElement(comment);
3447
+ const result = commentTargets.get(String(comment.id));
3448
+ const legacy = getPagePath() !== window.location.pathname && comment.page_path === window.location.pathname;
3449
+ const status = legacy && !result?.element ? "uncertain" : result?.status;
3450
+ const label = status === "missing" ? "Target missing" : status === "uncertain" ? "Target uncertain" : "";
3451
+ let badge = item.querySelector(".comty-anchor-badge");
3452
+ if (!label) { badge?.remove(); return; }
3453
+ if (!badge) {
3454
+ badge = document.createElement("span");
3455
+ badge.className = "comty-anchor-badge";
3456
+ item.querySelector(".comty-sidepanel-item-body").appendChild(badge);
3457
+ }
3458
+ if (badge.textContent !== label) badge.textContent = label;
3459
+ badge.title = legacy ? "This older comment has no saved hash route. Its target may be on another page." :
3460
+ 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
3461
  }
3058
3462
 
3059
3463
  let sidepanelFilters = { status: "", device: "", dateSort: "desc" };
@@ -3066,6 +3470,7 @@ export function initComty(options = {}) {
3066
3470
  ? sidepanelSearch.value.trim().toLowerCase()
3067
3471
  : "";
3068
3472
  let list = cachedComments.filter((comment) => {
3473
+ if (comment.local_agent || comment.kind === "agent") return false;
3069
3474
  if (
3070
3475
  search &&
3071
3476
  !`${comment.content ?? ""} ${getCommentLabel(comment)}`
@@ -3112,6 +3517,7 @@ export function initComty(options = {}) {
3112
3517
  const item = document.createElement("button");
3113
3518
  item.type = "button";
3114
3519
  item.className = "comty-sidepanel-item";
3520
+ item.dataset.commentId = String(comment.id);
3115
3521
  item.disabled = Boolean(comment.pending);
3116
3522
  item.innerHTML = `
3117
3523
  <span class="comty-sidepanel-item-status" aria-hidden="true"></span>
@@ -3135,8 +3541,13 @@ export function initComty(options = {}) {
3135
3541
  if (metaEl instanceof HTMLElement) {
3136
3542
  metaEl.innerText = `${comment.author_name || "Anonymous"} · ${comment.pending ? "Sending…" : getCommentLabel(comment)}`;
3137
3543
  metaEl.title = getCommentLabel(comment);
3544
+ if (getPagePath() !== window.location.pathname && comment.page_path === window.location.pathname && !resolveCommentElement(comment)) {
3545
+ metaEl.innerText += " · Original page unknown";
3546
+ }
3138
3547
  }
3139
3548
 
3549
+ updateAnchorBadge(item, comment);
3550
+
3140
3551
  item.onclick = (event) => {
3141
3552
  event.stopPropagation();
3142
3553
  openCommentFromPanel(comment);
@@ -3252,14 +3663,15 @@ export function initComty(options = {}) {
3252
3663
  createStatusFilters();
3253
3664
 
3254
3665
  async function updateCommentStatus(comment, status) {
3255
- if (!projectId || !comment?.id) return false;
3666
+ if (comment?.local_agent || !projectId || !comment?.id) return false;
3256
3667
 
3257
3668
  let res;
3258
3669
  try {
3259
- res = await fetch(`${apiUrl}/api/comments/${comment.id}/status`, {
3670
+ const local = comment.local_agent === true;
3671
+ res = await fetch(local ? `${agentBridgeUrl}/tasks/${encodeURIComponent(comment.id)}/status` : `${apiUrl}/api/comments/${comment.id}/status`, {
3260
3672
  method: "POST",
3261
- headers: getSdkHeaders({ "Content-Type": "application/json" }),
3262
- body: JSON.stringify({
3673
+ headers: local ? { "Content-Type": "application/json" } : getSdkHeaders({ "Content-Type": "application/json" }),
3674
+ body: JSON.stringify(local ? { status } : {
3263
3675
  projectKey,
3264
3676
  host: window.location.host,
3265
3677
  status,
@@ -3276,7 +3688,7 @@ export function initComty(options = {}) {
3276
3688
  }
3277
3689
 
3278
3690
  const data = await res.json();
3279
- comment.status = getCommentStatus(data.status).value;
3691
+ comment.status = getCommentStatus(data.task?.status ?? data.status).value;
3280
3692
  return comment.status;
3281
3693
  }
3282
3694
 
@@ -3468,6 +3880,23 @@ export function initComty(options = {}) {
3468
3880
  isOwnCommentEntry, updateCommentContent, deleteSdkComment, getCommentId,
3469
3881
  updateReplyContent, deleteSdkReply, createCommentAvatar, getSdkHeaders,
3470
3882
  showAuthPrompt,
3883
+ startThreadAgent: async (comment, replies, content) => {
3884
+ if (!agent) throw new Error('Start the local agent to reply with @Agent');
3885
+ if (!canManage) throw new Error('Sign in as a project editor to use @Agent');
3886
+ const transcript = [
3887
+ `${getCommentAuthorName(comment)}: ${comment.content}`,
3888
+ ...replies.map(reply => `${getCommentAuthorName(reply)}: ${reply.content}`),
3889
+ ].join('\n').slice(-24000);
3890
+ const response = await fetch(`${agentBridgeUrl}/tasks`, {
3891
+ method: 'POST', headers: agentHeaders(),
3892
+ body: JSON.stringify({ content, pagePath: comment.page_path || getPagePath(),
3893
+ selector: comment.selector || 'body', relX: comment.rel_x ?? 0.5, relY: comment.rel_y ?? 0.5,
3894
+ domElement: comment.dom_element || '', viewport: comment.viewport || '',
3895
+ anchorContext: comment.anchor_context || '', scope: 'element',
3896
+ thread: { commentId: String(comment.id), transcript } }),
3897
+ });
3898
+ if (!response.ok) throw new Error((await response.json().catch(() => null))?.error || 'Could not start Agent');
3899
+ },
3471
3900
  get canWrite() { return canWrite; },
3472
3901
  get canManage() { return canManage; },
3473
3902
  get allowGuestComments() { return allowGuestComments; },
@@ -3668,42 +4097,112 @@ export function initComty(options = {}) {
3668
4097
  function reconcileComments(comments) {
3669
4098
  const previous = new Map(cachedComments.map(comment => [String(comment.id), comment]));
3670
4099
  cachedComments = comments
3671
- .filter(comment => comment.kind !== "agent" && !comment.local_agent)
4100
+ .filter(comment => comment.kind !== "agent" || comment.status !== "done")
3672
4101
  .map(comment => Object.assign(previous.get(String(comment.id)) || {}, comment));
3673
4102
  for (const comment of previous.values()) {
3674
4103
  if (comment.pending && !cachedComments.some(item => item.id === comment.id)) cachedComments.push(comment);
3675
4104
  }
3676
4105
  }
3677
4106
 
4107
+ async function deliverThreadReply(task) {
4108
+ if (deliveringAgentReplies.has(task.id)) return;
4109
+ deliveringAgentReplies.add(task.id);
4110
+ try {
4111
+ const response = await fetch(`${apiUrl}/api/comments/${encodeURIComponent(task.thread_comment_id)}/replies`, {
4112
+ method: 'POST', headers: getSdkHeaders({ 'Content-Type': 'application/json' }),
4113
+ body: JSON.stringify({ projectKey, content: task.result.summary, agentTaskId: task.id }),
4114
+ });
4115
+ if (!response.ok) throw new Error(`Could not save Agent reply (${response.status})`);
4116
+ const { reply } = await response.json();
4117
+ const local = cachedComments.find(comment => String(comment.id) === task.thread_comment_id);
4118
+ if (local && !local.replies?.some(item => item.id === reply.id)) {
4119
+ local.replies ||= [];
4120
+ local.replies.push(reply);
4121
+ }
4122
+ for (const bubble of root.querySelectorAll('.comty-bubble')) {
4123
+ if (bubble.dataset.commentId === task.thread_comment_id) bubble.dispatchEvent(new CustomEvent('comty-agent-reply', { detail: reply }));
4124
+ }
4125
+ const ack = await fetch(`${agentBridgeUrl}/tasks/${task.id}/dismiss`, { method: 'POST', headers: agentHeaders(), body: '{}' });
4126
+ if (!ack.ok) throw new Error('Agent reply saved, but local acknowledgement failed');
4127
+ localTasks = localTasks.filter(item => item.id !== task.id);
4128
+ } catch (error) {
4129
+ agentActionError = error.message;
4130
+ console.error('Comty: Agent reply delivery failed', error);
4131
+ } finally {
4132
+ deliveringAgentReplies.delete(task.id);
4133
+ agentPanelSignature = '';
4134
+ renderAgentPanel();
4135
+ }
4136
+ }
4137
+
3678
4138
  async function loadComments({ force = false } = {}) {
3679
- if (!projectId) return;
3680
- const pagePath = window.location.pathname;
4139
+ if (!projectId && !agent) return;
4140
+ const pagePath = getPagePath();
3681
4141
  if (!force && cachedPagePath === pagePath) { await renderComments(cachedComments); return; }
3682
4142
  if (pendingCommentsLoad) {
3683
4143
  await pendingCommentsLoad;
3684
- if (!force) return;
4144
+ if (!force && cachedPagePath === pagePath) return;
3685
4145
  }
3686
4146
  pendingCommentsLoad = (async () => {
3687
- let comments = cachedPagePath === pagePath ? cachedComments : [];
4147
+ let comments = cachedPagePath === pagePath ? cachedComments.filter(comment => !comment.local_agent) : [];
3688
4148
  if (projectId) {
3689
4149
  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;
4150
+ const paths = [...new Set([pagePath, window.location.pathname])];
4151
+ const batches = await Promise.all(paths.map(async path => {
4152
+ const url = new URL(`${apiUrl}/api/comments`);
4153
+ url.search = new URLSearchParams({ projectKey, host: window.location.host, pagePath: path, ...(force ? { _t: String(Date.now()) } : {}) });
4154
+ const res = await fetch(url);
4155
+ if (!res.ok) throw new Error(await res.text());
4156
+ const data = await res.json();
4157
+ return Array.isArray(data.comments) ? data.comments : [];
4158
+ }));
4159
+ comments = [...new Map(batches.flat().map(comment => [String(comment.id), comment])).values()];
3696
4160
  } catch (error) { console.error("Comty: list comments failed", error); }
3697
4161
  }
4162
+ if (agent) {
4163
+ try {
4164
+ const res = await fetch(`${agentBridgeUrl}/tasks`, { headers: agentHeaders(), cache: "no-store" });
4165
+ if (!res.ok) throw new Error("Local companion unavailable");
4166
+ const data = await res.json();
4167
+ if (Array.isArray(data.tasks)) localTasks = data.tasks;
4168
+ const failedTasks = localTasks.filter(task => task.status === 'in_review');
4169
+ if (failedTasks.some(task => !seenFailedAgentTasks.has(task.id))) agentPanelOpen = true;
4170
+ seenFailedAgentTasks.clear();
4171
+ for (const task of failedTasks) seenFailedAgentTasks.add(task.id);
4172
+ if (Array.isArray(data.approvals)) {
4173
+ localApprovals = data.approvals;
4174
+ if (localApprovals.some(approval => !seenAgentApprovals.has(approval.id))) agentPanelOpen = true;
4175
+ seenAgentApprovals.clear();
4176
+ for (const approval of localApprovals) seenAgentApprovals.add(approval.id);
4177
+ }
4178
+ agentConnectionError = "";
4179
+ } catch (error) {
4180
+ agentConnectionError = "Local agent disconnected. Check the development server, then reload this page.";
4181
+ console.error("Comty: local tasks failed", error);
4182
+ }
4183
+ for (const task of localTasks.filter(task => task.thread_comment_id && task.status === 'done' && task.result?.summary)) {
4184
+ void deliverThreadReply(task);
4185
+ }
4186
+ comments.push(...localTasks.filter(task => task.page_path === pagePath && !task.thread_comment_id && !["done", "cancel"].includes(task.status)));
4187
+ renderAgentPanel();
4188
+ }
4189
+ if (pagePath !== getPagePath()) return;
3698
4190
  reconcileComments(comments);
3699
4191
  cachedPagePath = pagePath;
3700
4192
  renderSidepanelComments();
3701
4193
  await renderComments(cachedComments);
4194
+ syncAgentWorkingOutlines();
3702
4195
  ensureLiveTracking();
3703
4196
  })();
3704
4197
  try { await pendingCommentsLoad; } finally { pendingCommentsLoad = null; }
3705
4198
  }
3706
4199
 
4200
+ if (agent) {
4201
+ setInterval(() => {
4202
+ if (!pendingCommentsLoad && document.visibilityState === "visible") void loadComments({ force: true });
4203
+ }, 2000);
4204
+ }
4205
+
3707
4206
  function getPinPoint(pin) {
3708
4207
  return { x: parseFloat(pin.style.left), y: parseFloat(pin.style.top) };
3709
4208
  }
@@ -3731,7 +4230,7 @@ export function initComty(options = {}) {
3731
4230
  }
3732
4231
 
3733
4232
  function syncCommentAnchors() {
3734
- if (!session.comments) return;
4233
+ if (!session.comments && !session.inspect) return;
3735
4234
  const modal = getTopActiveModal();
3736
4235
  for (const comment of cachedComments) {
3737
4236
  const id = String(comment.id);
@@ -3742,7 +4241,7 @@ export function initComty(options = {}) {
3742
4241
  placePin(comment, modal);
3743
4242
  pin = placedPins.get(id);
3744
4243
  }
3745
- const bubbles = [...root.querySelectorAll(".comty-bubble")].filter(bubble => bubble.dataset.commentId === id || bubble.dataset.hoverId === id);
4244
+ const bubbles = [...root.querySelectorAll(".comty-bubble")].filter(bubble => (bubble.dataset.commentId === id || bubble.dataset.hoverId === id) && !bubble.hasAttribute("data-detached"));
3746
4245
  // A pin can remove its target's :hover style as the pointer enters it.
3747
4246
  if (!point && pin && target?.isConnected && !target.hasAttribute("hidden") && !isElementTreeHidden(target.parentElement) &&
3748
4247
  (activeHoverCommentIds.has(id) || bubbles.some(bubble => bubble.dataset.commentId === id))) continue;
@@ -3757,6 +4256,10 @@ export function initComty(options = {}) {
3757
4256
  if (pin.style.top !== y) pin.style.top = y;
3758
4257
  for (const bubble of bubbles) positionCommentBubble(bubble, point.clientX, point.clientY);
3759
4258
  }
4259
+ if (sidepanelOpen) for (const item of sidepanelList.querySelectorAll(".comty-sidepanel-item")) {
4260
+ const comment = cachedComments.find(comment => String(comment.id) === item.dataset.commentId);
4261
+ if (comment) updateAnchorBadge(item, comment);
4262
+ }
3760
4263
  if (draftAnchor) {
3761
4264
  if (!draftAnchor.target?.isConnected) draftAnchor.target = resolveCommentElement(draftAnchor.payload);
3762
4265
  const point = getVisibleAnchorPoint(draftAnchor.target, draftAnchor.payload.relX, draftAnchor.payload.relY, modal);
@@ -3770,16 +4273,50 @@ export function initComty(options = {}) {
3770
4273
  }
3771
4274
  }
3772
4275
 
4276
+ function syncAgentWorkingOutlines() {
4277
+ const working = localTasks.filter(task => task.status === "in_progress" && task.page_path === getPagePath());
4278
+ const ids = new Set(working.map(task => String(task.id)));
4279
+ for (const [id, outline] of workingOutlines) {
4280
+ if (ids.has(id)) continue;
4281
+ outline.remove(); workingOutlines.delete(id);
4282
+ }
4283
+ const modal = getTopActiveModal();
4284
+ for (const task of working) {
4285
+ const id = String(task.id);
4286
+ let outline = workingOutlines.get(id);
4287
+ if (!outline) {
4288
+ outline = document.createElement("div");
4289
+ outline.className = "comty-agent-working";
4290
+ outline.setAttribute("aria-label", "Agent working on this element");
4291
+ const badge = document.createElement("span");
4292
+ badge.className = "comty-agent-working-badge";
4293
+ badge.append(createWorkingIndicator(), "Working");
4294
+ outline.appendChild(badge); root.appendChild(outline);
4295
+ workingOutlines.set(id, outline);
4296
+ }
4297
+ const target = resolveCommentElement(task);
4298
+ const point = target && getVisibleAnchorPoint(target, Number(task.rel_x), Number(task.rel_y), modal);
4299
+ outline.hidden = !point;
4300
+ if (!point) continue;
4301
+ const rect = target.getBoundingClientRect();
4302
+ setMeasuredRect(outline, rect);
4303
+ outline.toggleAttribute("data-badge-below", rect.top < 32);
4304
+ }
4305
+ }
4306
+
3773
4307
  function onFrame() {
3774
4308
  if (motion.enabled) updateMotion();
3775
- if (session.comments) syncCommentAnchors();
4309
+ if (session.comments || session.inspect) syncCommentAnchors();
4310
+ if (session.agent || workingOutlines.size || localTasks.some(task => task.status === "in_progress" && task.page_path === getPagePath())) {
4311
+ syncAgentWorkingOutlines();
4312
+ }
3776
4313
  if (session.inspect || session.edit || inspectionLayer.childNodes.length) renderInspector();
3777
4314
  if (session.inspect && rulersEnabled) renderRulers();
3778
4315
  if (session.inspect && rulersEnabled) measurements.render();
3779
4316
  }
3780
4317
 
3781
4318
  function ensureLiveTracking() {
3782
- const active = motion.enabled || session.inspect || session.edit || (session.comments && cachedComments.length > 0);
4319
+ const active = motion.enabled || session.inspect || session.edit || (session.comments && cachedComments.length > 0) || workingOutlines.size > 0;
3783
4320
  if (!active || document.hidden) {
3784
4321
  if (liveFrame) cancelAnimationFrame(liveFrame);
3785
4322
  liveFrame = null;
@@ -3799,13 +4336,14 @@ export function initComty(options = {}) {
3799
4336
  syncOverlayHost();
3800
4337
 
3801
4338
  function renderCachedComments() {
3802
- if (!session.comments && !session.inspect && !session.edit) return;
4339
+ if (!session.comments && !session.inspect && !session.edit && !workingOutlines.size) return;
3803
4340
  if (resizeRenderFrame) {
3804
4341
  window.cancelAnimationFrame(resizeRenderFrame);
3805
4342
  }
3806
4343
  resizeRenderFrame = window.requestAnimationFrame(() => {
3807
4344
  resizeRenderFrame = null;
3808
4345
  syncCommentAnchors();
4346
+ syncAgentWorkingOutlines();
3809
4347
  renderInspector();
3810
4348
  ensureLiveTracking();
3811
4349
  });
@@ -3847,6 +4385,29 @@ export function initComty(options = {}) {
3847
4385
  characterData: true,
3848
4386
  });
3849
4387
 
4388
+ let activePagePath = getPagePath();
4389
+ function handleRouteChange() {
4390
+ const next = getPagePath();
4391
+ if (next === activePagePath) return;
4392
+ activePagePath = next;
4393
+ closeBubbles();
4394
+ commentTargets.clear();
4395
+ reconcileComments([]);
4396
+ void renderComments([], { retry: false });
4397
+ renderSidepanelComments();
4398
+ void loadComments({ force: true });
4399
+ }
4400
+ window.addEventListener("hashchange", handleRouteChange);
4401
+ window.addEventListener("popstate", handleRouteChange);
4402
+ for (const method of ["pushState", "replaceState"]) {
4403
+ const original = history[method];
4404
+ history[method] = function (...args) {
4405
+ const result = original.apply(this, args);
4406
+ handleRouteChange();
4407
+ return result;
4408
+ };
4409
+ }
4410
+
3850
4411
  document.addEventListener("scroll", handleHostLayoutEvent, {
3851
4412
  capture: true,
3852
4413
  passive: true,
@@ -3984,13 +4545,14 @@ export function initComty(options = {}) {
3984
4545
  const y = e.clientY;
3985
4546
 
3986
4547
  const anchorContext = getAnchorContext(el);
4548
+ if (session.agent && anchorContext) anchorContext.inspectedStyles = getInspectRows(el);
3987
4549
 
3988
4550
  const payload = {
3989
4551
  ...getDeviceContext(),
3990
4552
  projectId,
3991
- kind: "comment",
4553
+ kind: session.agent ? "agent" : "comment",
3992
4554
  host: window.location.host,
3993
- pagePath: window.location.pathname,
4555
+ pagePath: getPagePath(),
3994
4556
  selector: getSelector(el),
3995
4557
  relX,
3996
4558
  relY,
@@ -4088,6 +4650,7 @@ export function initComty(options = {}) {
4088
4650
  window.addEventListener("blur", () => { measureAlt = false; renderInspector(); });
4089
4651
 
4090
4652
  async function boot() {
4653
+ if (localOnly) { await loadComments(); return; }
4091
4654
  const res = await fetch(`${apiUrl}/api/sdk/bootstrap`, {
4092
4655
  method: "POST",
4093
4656
  headers: getSdkHeaders({ "Content-Type": "application/json" }),
@@ -4100,6 +4663,7 @@ export function initComty(options = {}) {
4100
4663
 
4101
4664
  if (!res.ok) {
4102
4665
  console.error("Comty: bootstrap failed", await res.text());
4666
+ if (agent) await loadComments();
4103
4667
  return;
4104
4668
  }
4105
4669
 
@@ -4144,16 +4708,30 @@ export function initComty(options = {}) {
4144
4708
 
4145
4709
  if (typeof options.onTestReady === "function") {
4146
4710
  options.onTestReady({
4147
- getSelector, getAnchorContext, resolveCommentElement, getVisibleAnchorPoint,
4711
+ getPagePath, getSelector, getAnchorContext, resolveCommentElement, getVisibleAnchorPoint,
4148
4712
  getDistanceSegments, getInspectRows, syncCommentAnchors,
4149
4713
  setInspectorMode(enabled) { setSession(enabled ? "inspect" : "off"); },
4150
4714
  startEditSession, startBrowseSession, endSession, showInspector,
4151
- motion, setMotionEnabled, updateMotion,
4715
+ motion, setMotionEnabled, updateMotion, describeAgentError,
4716
+ previewAgent(tasks, approvals = [], error = "") {
4717
+ localTasks = tasks;
4718
+ localApprovals = approvals;
4719
+ agentConnectionError = error;
4720
+ startAgentSession();
4721
+ agentPanelOpen = true;
4722
+ agentPanelSignature = "";
4723
+ renderAgentPanel();
4724
+ },
4152
4725
  seed(comments) {
4153
4726
  reconcileComments(comments);
4154
4727
  setSession(session.comments ? session.mode : "browse", { entry: sessionEntry, renderComments: true });
4155
4728
  return renderComments(cachedComments, { retry: false });
4156
4729
  },
4730
+ seedAgentTasks(tasks) {
4731
+ localTasks = tasks;
4732
+ syncAgentWorkingOutlines();
4733
+ ensureLiveTracking();
4734
+ },
4157
4735
  setCanWrite(value) { canWrite = value; canManage = value; },
4158
4736
  invalidate() { anchorRevision += 1; },
4159
4737
  });
@@ -4161,6 +4739,7 @@ export function initComty(options = {}) {
4161
4739
 
4162
4740
  return boot().catch(error => {
4163
4741
  console.error("Comty: bootstrap failed", error);
4742
+ if (agent) return loadComments();
4164
4743
  });
4165
4744
  }
4166
4745
  }