@comty.io/core 0.1.2 → 0.1.4

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,6 +1,6 @@
1
1
  import { getComtyStyle } from "./style.js";
2
2
  import { createAnchorMatcher } from "./anchors.js";
3
- import { createScreenshotCapture, createColorMagnifier } from "./capture.js";
3
+ import { createScreenshotCapture, createColorMagnifier, captureContext } from "./capture.js";
4
4
  import { createCommentThread } from "./comments.js";
5
5
  import { createMeasurements } from "./measurements.js";
6
6
  // Shared browser runtime. Both npm and the standalone CDN script use this file.
@@ -9,17 +9,7 @@ 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 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);
12
+ const projectKey = options.projectKey;
23
13
  const apiUrl = options.apiUrl || "http://localhost:4000";
24
14
  if (typeof projectKey !== "string" || !projectKey.trim()) {
25
15
  return Promise.reject(new TypeError("Comty: projectKey is required"));
@@ -31,14 +21,12 @@ export function initComty(options = {}) {
31
21
  if (!["http:", "https:"].includes(parsedApi.protocol)) {
32
22
  return Promise.reject(new TypeError("Comty: apiUrl must be an absolute HTTP(S) URL"));
33
23
  }
34
- const configuration = JSON.stringify([projectKey, apiUrl, agent || null]);
24
+ const configuration = JSON.stringify([projectKey, apiUrl]);
35
25
  const existing = window[instanceKey];
36
26
  if (existing) {
37
27
  return existing.configuration === configuration ? existing.ready
38
28
  : Promise.reject(new Error("Comty: already initialized with different options"));
39
29
  }
40
- const agentBridgeUrl = agent?.url;
41
- const localOnly = Boolean(agent && !options.projectKey);
42
30
  const ready = new Promise((resolve, reject) => {
43
31
  const mount = () => {
44
32
  try { resolve(mountComty()); } catch (error) { reject(error); }
@@ -52,19 +40,10 @@ export function initComty(options = {}) {
52
40
 
53
41
  function mountComty() {
54
42
  let projectId = null;
55
- let localTasks = [];
56
- let localApprovals = [];
57
- let agentPanelOpen = false;
58
- let agentPanelSignature = "";
59
- let agentPanelTransition = 0;
60
- let agentConnectionError = "";
61
- let agentActionError = "";
62
- const agentPendingActions = new Set();
63
43
  const session = {
64
44
  mode: "off",
65
- get comments() { return this.mode === "browse" || this.mode === "edit" || this.mode === "agent"; },
66
- get edit() { return this.mode === "edit" || this.mode === "agent"; },
67
- get agent() { return this.mode === "agent"; },
45
+ get comments() { return this.mode === "browse" || this.mode === "edit"; },
46
+ get edit() { return this.mode === "edit"; },
68
47
  get inspect() { return this.mode === "inspect"; },
69
48
  };
70
49
  const selectedStatusFilters = new Set();
@@ -78,21 +57,22 @@ export function initComty(options = {}) {
78
57
  const observedCommentTargets = new WeakSet();
79
58
  let sidepanelOpen = false;
80
59
  let canWrite = false;
60
+ let canManage = false;
61
+ let allowGuestComments = false;
81
62
  let loginUrl = "";
82
63
  let currentUser = null;
83
64
  let authStatus = "";
84
65
  let didCompleteSignIn = false;
85
66
  const tokenStorageKey = `comty:${projectKey}:token`;
67
+ const authReturnStorageKey = `comty:${projectKey}:auth-return`;
86
68
  let sdkToken = "";
87
69
  const activeHoverCommentIds = new Set();
88
- /** @type {null | 'edit' | 'browse' | 'agent'} */
70
+ /** @type {null | 'edit' | 'browse'} */
89
71
  let sessionEntry = null;
90
72
  const placedPins = new Map();
91
- const workingOutlines = new Map();
92
73
  const placementDiagnostics = new Map();
93
74
  let inspectedElement = null;
94
75
  let selectedElement = null;
95
- let inspectExpanded = false;
96
76
  let measureAlt = false;
97
77
  let inspectDrag = null;
98
78
  let measuredRegion = null;
@@ -135,7 +115,6 @@ export function initComty(options = {}) {
135
115
  const root = document.createElement("div");
136
116
  root.id = "comty-root";
137
117
  document.body.appendChild(root);
138
- if (agent) document.documentElement.classList.add("comty-agent-enabled");
139
118
 
140
119
  const style = document.createElement("style");
141
120
  style.innerHTML = getComtyStyle();
@@ -183,40 +162,20 @@ export function initComty(options = {}) {
183
162
  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"));
184
163
  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"/>', () => {
185
164
  if (!canWrite) showAuthPrompt();
186
- else if (session.agent || session.inspect || !isSessionActive()) startEditSession();
165
+ else if (session.inspect || !isSessionActive()) startEditSession();
187
166
  else if (!session.edit) setSession("edit", { entry: sessionEntry });
188
167
  });
189
168
  btn.id = "comty-toggle";
190
- const agentBtn = isLocalSite
191
- ? toolButton("Agent task · localhost only", '<path d="M8 7 3 12l5 5m8-10 5 5-5 5M14 4l-4 16"/>', () => {
192
- startAgentSession();
193
- })
194
- : null;
195
169
  const divider = document.createElement("span");
196
170
  divider.className = "comty-tool-divider";
197
171
  toolbar.appendChild(divider);
198
172
  const commentTools = document.createElement("div");
199
173
  commentTools.className = "comty-tool-context";
200
174
  toolbar.appendChild(commentTools);
201
- const agentTools = agentBtn ? document.createElement("div") : null;
202
- if (agentTools) {
203
- agentTools.className = "comty-tool-context";
204
- agentTools.hidden = true;
205
- toolbar.appendChild(agentTools);
206
- }
207
- const agentHistoryBtn = agentTools ? toolButton("Local agent history", '<path d="M3 12a9 9 0 1 0 3-6.7M3 4v4h4"/><path d="M12 7v5l3 2"/>', () => {
208
- if (!agent) { showAgentBridgeError(); return; }
209
- agentPanelOpen = !agentPanelOpen;
210
- renderAgentPanel();
211
- }) : null;
212
- if (agentHistoryBtn) {
213
- agentHistoryBtn.setAttribute("aria-expanded", "false");
214
- agentTools.appendChild(agentHistoryBtn);
215
- }
216
175
  const primaryTools = document.createElement("div");
217
176
  primaryTools.className = "comty-tool-primary";
218
177
  toolbar.insertBefore(primaryTools, browseBtn);
219
- for (const button of [browseBtn, inspectorBtn, btn, agentBtn].filter(Boolean)) primaryTools.appendChild(button);
178
+ for (const button of [browseBtn, inspectorBtn, btn]) primaryTools.appendChild(button);
220
179
  const inspectorTools = document.createElement("div");
221
180
  inspectorTools.className = "comty-tool-context";
222
181
  inspectorTools.hidden = true;
@@ -795,6 +754,21 @@ export function initComty(options = {}) {
795
754
  const copyIcon = icon('<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>');
796
755
  const checkIcon = icon('<path d="m5 12 4 4 10-10"/>');
797
756
  const copyFeedbackTimers = new WeakMap();
757
+ async function copyPropertyValue(button, feedback) {
758
+ clearTimeout(copyFeedbackTimers.get(button));
759
+ try {
760
+ await navigator.clipboard.writeText(button.dataset.copy);
761
+ if (!button.isConnected) return;
762
+ button.dataset.copied = "";
763
+ feedback.textContent = "Copied";
764
+ clearTimeout(copyFeedbackTimers.get(button));
765
+ copyFeedbackTimers.set(button, setTimeout(() => { delete button.dataset.copied; }, 1200));
766
+ } catch {
767
+ delete button.dataset.copied;
768
+ if (button.isConnected) feedback.textContent = "Clipboard unavailable — select the value to copy";
769
+ }
770
+ }
771
+
798
772
  function propertyRow(label, value, display = value) {
799
773
  const unavailable = value === "—";
800
774
  return `<div class="comty-property-row"><span>${label}</span><button type="button" data-copy="${escapeInspectorText(value)}" aria-label="Copy ${label}: ${escapeInspectorText(display)}" ${unavailable ? "disabled" : ""}><span>${escapeInspectorText(display)}</span>${unavailable ? "" : `<i class="comty-property-copy-icon" aria-hidden="true">${copyIcon}${checkIcon}</i>`}</button></div>`;
@@ -821,7 +795,7 @@ export function initComty(options = {}) {
821
795
  propertyPanel.setAttribute("aria-label", propertyKind === "color" ? "Recent colours" : "Recent typography");
822
796
  const recent = propertyKind === "color" ? recentColors : recentFonts;
823
797
  const details = propertyKind === "color" && expanded
824
- ? `<div class="comty-property-detail"><div class="comty-property-rows">${propertyRow("HEX", propertySelection.hex)}${propertyRow("RGB", propertySelection.rgb)}${propertyRow("HSL", propertySelection.hsl)}${propertyRow("OKLCH", propertySelection.oklch)}${propertyRow("Token", propertySelection.token ? `var(${propertySelection.token})` : "—")}</div></div>`
798
+ ? `<div class="comty-property-detail"><div class="comty-property-rows">${propertyRow("HEX", propertySelection.hex)}${propertyRow("RGB", propertySelection.rgb)}${propertyRow("HSL", propertySelection.hsl)}${propertyRow("OKLCH", propertySelection.oklch)}${propertyRow("Token", propertySelection.token ? propertySelection.token.replace(/^--/, "") : "—", propertySelection.token || "—")}</div></div>`
825
799
  : propertyKind === "font" && expanded
826
800
  ? `<div class="comty-property-detail"><div class="comty-property-rows">${propertyRow("Family", propertySelection.family)}${propertyRow("Size", propertySelection.size)}${propertyRow("Weight", propertySelection.weight)}${propertyRow("Leading", propertySelection.leading)}${propertyRow("Tracking", propertySelection.tracking)}${propertyRow("Colour", propertySelection.color?.hex || "—")}${propertyRow("CSS", propertySelection.css, "All of the above")}</div></div>`
827
801
  : "";
@@ -937,18 +911,7 @@ export function initComty(options = {}) {
937
911
  renderPropertyPanel(true);
938
912
  propertyPanel.querySelector(`button[data-recent="${button.dataset.recent}"]`)?.focus({ preventScroll: true });
939
913
  } else if (button.dataset.copy != null && button.dataset.copy !== "—") {
940
- clearTimeout(copyFeedbackTimers.get(button));
941
- try {
942
- await navigator.clipboard.writeText(button.dataset.copy);
943
- if (!button.isConnected || propertyPanel.hidden) return;
944
- button.dataset.copied = "";
945
- propertyPanel.querySelector(".comty-property-feedback").textContent = `${button.getAttribute("aria-label").slice(5)} copied`;
946
- clearTimeout(copyFeedbackTimers.get(button));
947
- copyFeedbackTimers.set(button, setTimeout(() => { delete button.dataset.copied; }, 1200));
948
- } catch {
949
- delete button.dataset.copied;
950
- if (button.isConnected && !propertyPanel.hidden) propertyPanel.querySelector(".comty-property-feedback").textContent = "Clipboard unavailable";
951
- }
914
+ await copyPropertyValue(button, propertyPanel.querySelector(".comty-property-feedback"));
952
915
  }
953
916
  };
954
917
 
@@ -1065,50 +1028,6 @@ export function initComty(options = {}) {
1065
1028
  let toolbarMotionFrame = 0;
1066
1029
  let toolbarAnimations = [];
1067
1030
  const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
1068
- let workingIndicatorTimer = 0;
1069
- function scheduleWorkingIndicators() {
1070
- if (workingIndicatorTimer || reducedMotion.matches || document.hidden) return;
1071
- workingIndicatorTimer = setTimeout(() => {
1072
- workingIndicatorTimer = 0;
1073
- if (!root.isConnected) return;
1074
- const indicators = root.querySelectorAll('.comty-working-indicator');
1075
- if (!indicators.length) return;
1076
- for (const indicator of indicators) {
1077
- const previous = Number(indicator.dataset.activeDots.split(',')[0]);
1078
- const first = (previous + 1 + Math.floor(Math.random() * 8)) % 9;
1079
- const second = (first + 1 + Math.floor(Math.random() * 8)) % 9;
1080
- indicator.dataset.activeDots = `${first},${second}`;
1081
- [...indicator.children].forEach((dot, index) => dot.toggleAttribute('data-active', index === first || index === second));
1082
- }
1083
- scheduleWorkingIndicators();
1084
- }, 600);
1085
- }
1086
- function createWorkingIndicator() {
1087
- const indicator = document.createElement('span');
1088
- indicator.className = 'comty-working-indicator';
1089
- indicator.setAttribute('aria-hidden', 'true');
1090
- indicator.dataset.activeDots = '3,5';
1091
- for (let index = 0; index < 9; index++) {
1092
- const dot = document.createElement('i');
1093
- dot.toggleAttribute('data-active', index === 3 || index === 5);
1094
- indicator.appendChild(dot);
1095
- }
1096
- scheduleWorkingIndicators();
1097
- return indicator;
1098
- }
1099
- function resetWorkingIndicators() {
1100
- clearTimeout(workingIndicatorTimer);
1101
- workingIndicatorTimer = 0;
1102
- if (reducedMotion.matches) {
1103
- for (const indicator of root.querySelectorAll('.comty-working-indicator')) {
1104
- indicator.dataset.activeDots = '3,5';
1105
- [...indicator.children].forEach((dot, index) => dot.toggleAttribute('data-active', index === 3 || index === 5));
1106
- }
1107
- }
1108
- if (root.querySelector('.comty-working-indicator')) scheduleWorkingIndicators();
1109
- }
1110
- reducedMotion.addEventListener('change', resetWorkingIndicators);
1111
- document.addEventListener('visibilitychange', resetWorkingIndicators);
1112
1031
  const clampToolbar = (value, size, viewport) => Math.max(8, Math.min(value, viewport - size - 8));
1113
1032
  function rememberToolbarPosition(left, top) {
1114
1033
  const width = toolbar.offsetWidth, height = toolbar.offsetHeight;
@@ -1221,7 +1140,7 @@ export function initComty(options = {}) {
1221
1140
 
1222
1141
  function prepareToolbarMotion() {
1223
1142
  if (toolbarMotionFrame) return;
1224
- const nodes = [toolbarSurface, dragHandle, primaryTools, commentTools, agentTools, inspectorTools].filter(Boolean);
1143
+ const nodes = [toolbarSurface, dragHandle, primaryTools, commentTools, inspectorTools].filter(Boolean);
1225
1144
  // Move each group's background with its icons, including when interrupted.
1226
1145
  const before = new Map(nodes.filter(node => !node.hidden).map(node => [node, {
1227
1146
  rect: node.getBoundingClientRect(), opacity: getComputedStyle(node).opacity,
@@ -1464,7 +1383,7 @@ export function initComty(options = {}) {
1464
1383
  return;
1465
1384
  }
1466
1385
 
1467
- if ((session.edit && mode === "browse") || (session.agent && mode === "edit")) {
1386
+ if (session.edit && mode === "browse") {
1468
1387
  closeBubbles();
1469
1388
  }
1470
1389
  const hadComments = session.comments;
@@ -1478,7 +1397,6 @@ export function initComty(options = {}) {
1478
1397
  } else {
1479
1398
  hideInspector();
1480
1399
  }
1481
- if (session.agent && sidepanelOpen) setSidepanelOpen(false);
1482
1400
  syncToolbar();
1483
1401
  ensureLiveTracking();
1484
1402
  if (renderComments || !hadComments) renderCachedComments();
@@ -1490,11 +1408,8 @@ export function initComty(options = {}) {
1490
1408
  : headers;
1491
1409
  }
1492
1410
 
1493
- function showAuthPrompt(forbidden = false) {
1494
- if (localOnly) {
1495
- showAgentBridgeError("Shared comments need a Comty project key. Use the Agent tool for local tasks without signing in.");
1496
- return;
1497
- }
1411
+ function showAuthPrompt(forbidden = false, onReady = startEditSession) {
1412
+ const guestAllowed = allowGuestComments;
1498
1413
  document.querySelector(".comty-auth-backdrop")?.remove();
1499
1414
  const opener = document.activeElement;
1500
1415
  const backdrop = document.createElement("div");
@@ -1505,12 +1420,23 @@ export function initComty(options = {}) {
1505
1420
  <span class="comty-auth-icon">${icon('<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.5 8.5 0 1 1 16.1-3.8Z"/>')}</span>
1506
1421
  <button type="button" class="comty-auth-close" data-comty-close aria-label="Close sign-in dialog">${icon('<path d="m6 6 12 12M6 18 18 6"/>')}</button>
1507
1422
  </div>
1508
- <h2 id="comty-auth-title">${forbidden ? "Project access required" : "Sign in to comment"}</h2>
1509
- <p id="comty-auth-description">${forbidden ? "This account is not a member of this project. Ask the project owner for an invite." : "Sign in to share feedback with your team and keep the conversation in one place."}</p>
1510
- <div class="comty-auth-actions">
1511
- <button type="button" data-comty-close>${forbidden ? "Close" : "Cancel"}</button>
1512
- ${forbidden ? "" : '<button type="button" class="comty-auth-primary" data-comty-login>Continue to sign in</button>'}
1513
- </div>
1423
+ <h2 id="comty-auth-title">${guestAllowed ? "Leave a comment" : forbidden ? "Project access required" : "Sign in to comment"}</h2>
1424
+ <p id="comty-auth-description">${guestAllowed ? "Continue with your name or sign in with Google." : forbidden ? "This account is not a member of this project. Ask the project owner for an invite." : "Sign in to share feedback with your team and keep the conversation in one place."}</p>
1425
+ ${guestAllowed ? `
1426
+ <form class="comty-guest-form">
1427
+ <label for="comty-guest-name">Your name</label>
1428
+ <input id="comty-guest-name" name="name" autocomplete="name" placeholder="Your name" maxlength="80" required aria-describedby="comty-guest-error" />
1429
+ <p id="comty-guest-error" role="alert" hidden></p>
1430
+ <div class="comty-auth-actions comty-guest-actions">
1431
+ <button type="submit" class="comty-auth-primary" data-comty-guest>Continue</button>
1432
+ <span class="comty-auth-or">or</span>
1433
+ <button type="button" data-comty-login>Continue with Google</button>
1434
+ </div>
1435
+ </form>` : `
1436
+ <div class="comty-auth-actions">
1437
+ <button type="button" data-comty-close>${forbidden ? "Close" : "Cancel"}</button>
1438
+ ${forbidden ? "" : '<button type="button" class="comty-auth-primary" data-comty-login>Continue to sign in</button>'}
1439
+ </div>`}
1514
1440
  </div>`;
1515
1441
  const close = () => {
1516
1442
  backdrop.remove();
@@ -1521,11 +1447,12 @@ export function initComty(options = {}) {
1521
1447
  event.stopPropagation();
1522
1448
  if (event.key === "Escape") { event.preventDefault(); close(); }
1523
1449
  if (event.key === "Tab") {
1524
- const buttons = [...backdrop.querySelectorAll("button:not(:disabled)")];
1450
+ const buttons = [...backdrop.querySelectorAll("button:not(:disabled), input:not(:disabled)")];
1525
1451
  if (!buttons.length) { event.preventDefault(); return; }
1526
- const first = buttons[0], last = buttons.at(-1);
1527
- if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
1528
- else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
1452
+ event.preventDefault();
1453
+ const index = buttons.indexOf(document.activeElement);
1454
+ const next = (index + (event.shiftKey ? -1 : 1) + buttons.length) % buttons.length;
1455
+ buttons[next].focus();
1529
1456
  }
1530
1457
  });
1531
1458
  const loginButton = backdrop.querySelector("[data-comty-login]");
@@ -1533,15 +1460,62 @@ export function initComty(options = {}) {
1533
1460
  loginButton.onclick = () => {
1534
1461
  loginButton.disabled = true;
1535
1462
  loginButton.innerText = "Connecting…";
1536
- backdrop.querySelectorAll("[data-comty-close]").forEach(button => { button.disabled = true; });
1537
- setTimeout(() => window.location.assign(loginUrl), 50);
1463
+ backdrop.querySelectorAll("button, input").forEach(control => { control.disabled = true; });
1464
+ try {
1465
+ sessionStorage.setItem(authReturnStorageKey, JSON.stringify({
1466
+ mode: onReady === startEditSession || session.mode === "off" ? "edit" : session.mode,
1467
+ entry: sessionEntry,
1468
+ sidepanelOpen,
1469
+ }));
1470
+ } catch { /* Resume in comment mode if browser storage is unavailable. */ }
1471
+ const destination = new URL("/api/auth/sign-in/google", apiUrl);
1472
+ destination.searchParams.set("callbackURL", loginUrl);
1473
+ setTimeout(() => window.location.assign(guestAllowed ? destination.toString() : loginUrl), 50);
1538
1474
  };
1539
1475
  }
1476
+ const guestForm = backdrop.querySelector(".comty-guest-form");
1477
+ if (guestForm) guestForm.onsubmit = async event => {
1478
+ event.preventDefault();
1479
+ const nameInput = guestForm.querySelector("input");
1480
+ const error = guestForm.querySelector("[role=alert]");
1481
+ const name = nameInput.value.trim();
1482
+ if (!name) { nameInput.focus(); return; }
1483
+ const controls = [...backdrop.querySelectorAll("button, input")];
1484
+ controls.forEach(control => { control.disabled = true; });
1485
+ error.hidden = true;
1486
+ const continueButton = guestForm.querySelector("[data-comty-guest]");
1487
+ continueButton.textContent = "Connecting…";
1488
+ try {
1489
+ const response = await fetch(`${apiUrl}/api/sdk/guest`, {
1490
+ method: "POST",
1491
+ headers: { "Content-Type": "application/json" },
1492
+ body: JSON.stringify({ projectKey, name }),
1493
+ });
1494
+ const data = await response.json();
1495
+ if (!response.ok) throw new Error(data.error || "Unable to continue. Please try again.");
1496
+ sdkToken = data.token;
1497
+ try { sessionStorage.setItem(tokenStorageKey, sdkToken); } catch { /* Keep the session in memory when browser storage is unavailable. */ }
1498
+ currentUser = data.user;
1499
+ canWrite = true;
1500
+ canManage = false;
1501
+ if (backdrop.isConnected) {
1502
+ close();
1503
+ onReady();
1504
+ }
1505
+ } catch (failure) {
1506
+ if (!backdrop.isConnected) return;
1507
+ error.textContent = failure.message || "Unable to connect. Please try again.";
1508
+ error.hidden = false;
1509
+ controls.forEach(control => { control.disabled = false; });
1510
+ continueButton.textContent = "Continue";
1511
+ nameInput.focus();
1512
+ }
1513
+ };
1540
1514
  backdrop.onclick = (event) => {
1541
1515
  if (event.target === backdrop) close();
1542
1516
  };
1543
1517
  root.appendChild(backdrop);
1544
- (loginButton || backdrop.querySelector("[data-comty-close]")).focus({ preventScroll: true });
1518
+ (backdrop.querySelector("#comty-guest-name") || loginButton || backdrop.querySelector("[data-comty-close]")).focus({ preventScroll: true });
1545
1519
  }
1546
1520
 
1547
1521
  function formatBoxValue(value) {
@@ -1556,17 +1530,14 @@ export function initComty(options = {}) {
1556
1530
  }
1557
1531
 
1558
1532
  function syncToolbar() {
1559
- if (commentTools.hidden !== (session.inspect || session.agent) || inspectorTools.hidden !== !session.inspect || (agentTools && agentTools.hidden !== !session.agent)) prepareToolbarMotion();
1533
+ if (commentTools.hidden !== session.inspect || inspectorTools.hidden !== !session.inspect) prepareToolbarMotion();
1560
1534
  root.classList.toggle("comty-active", session.comments || session.edit || sidepanelOpen || session.inspect);
1561
1535
  browseBtn.setAttribute("aria-pressed", String(!session.edit && !session.inspect));
1562
1536
  inspectorBtn.setAttribute("aria-pressed", String(session.inspect));
1563
- btn.setAttribute("aria-pressed", String(session.edit && !session.agent));
1564
- agentBtn?.setAttribute("aria-pressed", String(session.edit && session.agent));
1565
- commentTools.hidden = session.inspect || session.agent;
1566
- if (agentTools) agentTools.hidden = !session.agent;
1537
+ btn.setAttribute("aria-pressed", String(session.edit));
1538
+ commentTools.hidden = session.inspect;
1567
1539
  inspectorTools.hidden = !session.inspect;
1568
1540
  syncInspectorTools();
1569
- renderAgentPanel();
1570
1541
  }
1571
1542
 
1572
1543
  const inspectionLayer = document.createElement("div");
@@ -1741,7 +1712,8 @@ export function initComty(options = {}) {
1741
1712
  add("background", css.backgroundColor, (tokens.background.length > 0 || !["rgba(0, 0, 0, 0)", "rgb(255, 255, 255)"].includes(css.backgroundColor)));
1742
1713
  add("color", css.color);
1743
1714
  if (getElementOwnTextSignature(el)) {
1744
- add("font", `${css.fontSize} / ${css.fontWeight}`);
1715
+ add("font-size", css.fontSize);
1716
+ add("font-weight", css.fontWeight);
1745
1717
  add("font-family", css.fontFamily);
1746
1718
  add("line-height", css.lineHeight);
1747
1719
  add("letter-spacing", css.letterSpacing, css.letterSpacing !== "normal");
@@ -1925,7 +1897,7 @@ export function initComty(options = {}) {
1925
1897
  height: draftBubble.offsetHeight,
1926
1898
  };
1927
1899
  const pinBounds = draftPin?.getBoundingClientRect();
1928
- const signature = JSON.stringify([selector, rect, hoverRect, rows, !!selected, inspectExpanded, measureAlt, draftBounds, pinBounds, spacing]);
1900
+ const signature = JSON.stringify([selector, rect, hoverRect, rows, !!selected, measureAlt, draftBounds, pinBounds, spacing, innerWidth, innerHeight]);
1929
1901
  if (signature === inspectionSignature) return;
1930
1902
  inspectionSignature = signature;
1931
1903
  inspectionLayer.replaceChildren();
@@ -1951,36 +1923,34 @@ export function initComty(options = {}) {
1951
1923
  const panel = document.createElement("div");
1952
1924
  panel.className = "comty-inspector";
1953
1925
  panel.setAttribute("aria-label", "Element properties");
1954
- if (selected) panel.dataset.selected = "";
1955
- const copyable = (value) => `<button type="button" title="Copy ${escapeInspectorText(value)}" data-copy="${escapeInspectorText(value)}">${escapeInspectorText(value)}</button>`;
1956
- const featured = new Set(["background", "color", "font", "font-family"]);
1957
- const visibleRows = inspectExpanded ? rows : selected ? [...rows.filter(([key]) => featured.has(key)), ...rows.filter(([key]) => !featured.has(key))].slice(0, 6) : rows.slice(0, 6);
1958
- panel.innerHTML = `<div class="comty-inspector-head">${copyable(selector)}${copyable(`${formatBoxValue(rect.width)} × ${formatBoxValue(rect.height)}`)}</div><div class="comty-inspector-details">${visibleRows.map(([key, value, tokens]) => {
1959
- const picker = key === "background" || key === "color" ? "color" : key === "font" || key === "font-family" ? "font" : "";
1960
- const valueButton = picker && selected
1961
- ? `<button type="button" class="comty-inspector-picker" data-picker="${picker}" data-property="${key}" aria-label="Open ${picker === "color" ? key + " colour" : "typography"} details">${picker === "color" ? '<span class="comty-inspector-swatch" aria-hidden="true"></span>' : ""}${escapeInspectorText(value)}</button>`
1962
- : copyable(value);
1963
- return `<div class="comty-inspector-row"><span>${key}</span><div class="comty-inspector-value">${tokens?.length ? `<div class="comty-inspector-tokens">${tokens.map(token => copyable(token)).join("")}</div>` : ""}${valueButton}</div></div>`;
1964
- }).join("")}</div>${rows.length > 6 && selected ? `<button type="button" class="comty-inspector-expand">${inspectExpanded ? "Show less" : `Show ${rows.length - 6} more`}</button>` : ""}`;
1965
- for (const button of panel.querySelectorAll('.comty-inspector-picker[data-picker="color"]')) {
1966
- button.querySelector(".comty-inspector-swatch").style.backgroundColor = getComputedStyle(target)[button.dataset.property === "background" ? "backgroundColor" : "color"];
1926
+ if (selected || draftPreview) panel.dataset.selected = "";
1927
+ const priority = ["background", "color", "font-family", "font-size", "font-weight", "margin", "line-height", "letter-spacing"];
1928
+ const orderedRows = [...rows.filter(([key]) => priority.includes(key)).sort((a, b) => priority.indexOf(a[0]) - priority.indexOf(b[0])), ...rows.filter(([key]) => !priority.includes(key))];
1929
+ const labels = { "font-family": "Font", "font-size": "Size", "font-weight": "Weight", "line-height": "Line height", "letter-spacing": "Spacing", "background": "Background", "color": "Color", "margin": "Margin" };
1930
+ const cssProperty = key => ({ radius: "border-radius", background: "background-color" })[key] || key;
1931
+ const copyValue = (value, tokens) => tokens?.length ? `var(${tokens[0]})` : value;
1932
+ const allProperties = `${selector} {\n${rows.map(([key, value, tokens]) => ` ${cssProperty(key)}: ${copyValue(value, tokens)};`).join("\n")}\n}`;
1933
+ const copyButton = (value, label) => `<button type="button" class="comty-inspector-copy" data-copy="${escapeInspectorText(value)}" aria-label="${escapeInspectorText(label)}" title="${escapeInspectorText(label)}"><i class="comty-property-copy-icon" aria-hidden="true">${copyIcon}${checkIcon}</i></button>`;
1934
+ panel.innerHTML = `<div class="comty-inspector-head"><span class="comty-inspector-selector" dir="rtl" title="${escapeInspectorText(selector)}"><bdi dir="ltr">${escapeInspectorText(selector)}</bdi></span>${copyButton(allProperties, "Copy all properties")}</div><div class="comty-inspector-details">${orderedRows.map(([key, value, tokens]) => {
1935
+ const label = labels[key] || key.replaceAll("-", " ").replace(/^./, c => c.toUpperCase());
1936
+ const isColor = key === "background" || key === "color";
1937
+ const picker = isColor ? "color" : key.startsWith("font-") || ["line-height", "letter-spacing"].includes(key) ? "font" : "";
1938
+ const displayValue = isColor ? colorDetails(value)?.hex || value : value;
1939
+ const valueMarkup = `${isColor ? '<span class="comty-inspector-swatch" aria-hidden="true"></span>' : ""}<span class="comty-inspector-text">${tokens?.length ? `<span class="comty-inspector-tokens">${tokens.map(escapeInspectorText).join(", ")}</span><span class="comty-inspector-secondary">${escapeInspectorText(displayValue)}</span>` : escapeInspectorText(displayValue)}</span>`;
1940
+ const valueElement = picker && selected
1941
+ ? `<button type="button" class="comty-inspector-value comty-inspector-picker" data-picker="${picker}" data-property="${key}" aria-label="Open ${isColor ? key + " colour" : "typography"} details">${valueMarkup}</button>`
1942
+ : `<span class="comty-inspector-value" data-property="${key}">${valueMarkup}</span>`;
1943
+ return `<div class="comty-inspector-row"><span class="comty-inspector-label">${label}</span>${valueElement}${copyButton(tokens?.length ? tokens[0].replace(/^--/, "") : value, `Copy ${label}`)}</div>`;
1944
+ }).join("")}</div><span class="comty-property-feedback" role="status"></span>`;
1945
+ for (const swatch of panel.querySelectorAll('.comty-inspector-swatch')) {
1946
+ const key = swatch.parentElement.dataset.property;
1947
+ swatch.style.backgroundColor = rows.find(([name]) => name === key)?.[1];
1967
1948
  }
1968
1949
  panel.onclick = async (event) => {
1969
1950
  const button = event.target.closest("button");
1970
1951
  if (!button) return;
1971
- if (button.classList.contains("comty-inspector-expand")) {
1972
- inspectExpanded = !inspectExpanded;
1973
- renderInspector();
1974
- } else if (button.dataset.picker) {
1975
- openPropertyPanel(button.dataset.picker, target, button.dataset.property, button);
1976
- } else {
1977
- try {
1978
- await navigator.clipboard.writeText(button.dataset.copy);
1979
- button.title = "Copied";
1980
- } catch {
1981
- button.title = "Clipboard unavailable — select the value to copy";
1982
- }
1983
- }
1952
+ if (button.dataset.picker) openPropertyPanel(button.dataset.picker, target, button.dataset.property, button);
1953
+ else await copyPropertyValue(button, panel.querySelector(".comty-property-feedback"));
1984
1954
  };
1985
1955
  inspectionLayer.appendChild(panel);
1986
1956
  const bounds = panel.getBoundingClientRect();
@@ -1998,8 +1968,24 @@ export function initComty(options = {}) {
1998
1968
  }
1999
1969
  return;
2000
1970
  }
2001
- panel.style.left = `${Math.max(8, Math.min(rect.left + rect.width / 2 - bounds.width / 2, innerWidth - bounds.width - 8))}px`;
2002
- panel.style.top = `${Math.max(8, Math.min(rect.bottom + 4 + bounds.height <= innerHeight ? rect.bottom + 4 : rect.top - bounds.height - 4, innerHeight - bounds.height - 8))}px`;
1971
+ const inset = 8, gap = 12;
1972
+ const clampX = x => Math.max(inset, Math.min(x, innerWidth - bounds.width - inset));
1973
+ const clampY = y => Math.max(inset, Math.min(y, innerHeight - bounds.height - inset));
1974
+ const x = clampX(rect.left), y = clampY(rect.top);
1975
+ const candidates = [
1976
+ { left: x, top: rect.bottom + gap },
1977
+ { left: rect.right + gap, top: y },
1978
+ { left: rect.left - bounds.width - gap, top: y },
1979
+ { left: x, top: rect.top - bounds.height - gap },
1980
+ ];
1981
+ let position = candidates.find(({ left, top }) => left >= inset && top >= inset && left + bounds.width <= innerWidth - inset && top + bounds.height <= innerHeight - inset);
1982
+ if (!position) {
1983
+ // A viewport-sized target leaves no outside space; minimize the covered area.
1984
+ const overlap = ({ left, top }) => Math.max(0, Math.min(left + bounds.width, rect.right) - Math.max(left, rect.left)) * Math.max(0, Math.min(top + bounds.height, rect.bottom) - Math.max(top, rect.top));
1985
+ position = candidates.map(({ left, top }) => ({ left: clampX(left), top: clampY(top) })).sort((a, b) => overlap(a) - overlap(b))[0];
1986
+ }
1987
+ panel.style.left = `${position.left}px`;
1988
+ panel.style.top = `${position.top}px`;
2003
1989
  }
2004
1990
 
2005
1991
  function endSession() {
@@ -2014,13 +2000,6 @@ export function initComty(options = {}) {
2014
2000
  setSession("edit", { renderComments: true });
2015
2001
  }
2016
2002
 
2017
- function startAgentSession() {
2018
- if (!isLocalSite) return;
2019
- if (session.agent) return;
2020
- closeBubbles();
2021
- setSession("agent", { renderComments: true });
2022
- }
2023
-
2024
2003
  function toggleSidepanel() {
2025
2004
  if (sidepanelOpen) {
2026
2005
  setSidepanelOpen(false);
@@ -2044,7 +2023,7 @@ export function initComty(options = {}) {
2044
2023
  showAuthPrompt();
2045
2024
  return;
2046
2025
  }
2047
- if (session.agent || session.inspect || !isSessionActive()) {
2026
+ if (session.inspect || !isSessionActive()) {
2048
2027
  startEditSession();
2049
2028
  return;
2050
2029
  }
@@ -2395,15 +2374,6 @@ export function initComty(options = {}) {
2395
2374
  return element;
2396
2375
  }
2397
2376
 
2398
- async function waitForCommentTargetReady(comment) {
2399
- const el = resolveCommentElement(comment);
2400
- if (!el) return false;
2401
-
2402
- await waitForCustomElements(el);
2403
- await waitForPaint();
2404
- return true;
2405
- }
2406
-
2407
2377
  function getDeviceContext() {
2408
2378
  const width = window.innerWidth;
2409
2379
  const height = window.innerHeight;
@@ -2478,125 +2448,44 @@ export function initComty(options = {}) {
2478
2448
  if (!session.edit || hadDraft) hideInspector();
2479
2449
  document
2480
2450
  .querySelectorAll(
2481
- ".comty-bubble, .comty-create-bubble, .comty-temp-pin, .comty-popover",
2451
+ ".comty-bubble:not([data-comty-capture-keep]), .comty-create-bubble, .comty-temp-pin, .comty-popover",
2482
2452
  )
2483
2453
  .forEach((x) => x.remove());
2484
2454
  }
2485
2455
 
2486
- const { waitForPaint, waitForCustomElements, captureViewportScreenshot } = createScreenshotCapture();
2487
-
2488
- async function buildCommentScreenshotFormData(payload) {
2489
- const formData = new FormData();
2490
-
2491
- Object.entries(payload).forEach(([key, value]) => {
2492
- if (value === undefined || value === null) return;
2493
- formData.append(key, String(value));
2494
- });
2495
-
2496
- try {
2497
- await waitForCommentTargetReady(payload);
2498
- const screenshot = await captureViewportScreenshot();
2499
- if (screenshot) {
2500
- const extension =
2501
- screenshot.blob.type === "image/svg+xml"
2502
- ? "svg"
2503
- : screenshot.blob.type === "image/jpeg"
2504
- ? "jpg"
2505
- : screenshot.blob.type === "image/png"
2506
- ? "png"
2507
- : "webp";
2508
- formData.append(
2509
- "screenshot",
2510
- screenshot.blob,
2511
- `comment.${extension}`,
2512
- );
2513
- formData.append("screenshotWidth", String(screenshot.width));
2514
- formData.append("screenshotHeight", String(screenshot.height));
2515
- }
2516
- } catch (error) {
2517
- console.error("Comty: screenshot capture failed", error);
2518
- }
2519
-
2520
- return formData;
2521
- }
2456
+ const { captureViewportScreenshot } = createScreenshotCapture({ apiUrl, projectKey, getHeaders: getSdkHeaders });
2522
2457
 
2523
- async function uploadCommentScreenshot(
2524
- commentId,
2525
- payload,
2526
- captureNodes = [],
2527
- ) {
2458
+ async function uploadCommentScreenshot(comment, context, point) {
2528
2459
  try {
2529
- const formData = await buildCommentScreenshotFormData({
2530
- projectKey,
2531
- host: window.location.host,
2532
- ...payload,
2533
- });
2534
-
2535
- captureNodes.forEach((node) => node.remove());
2536
-
2537
- if (!formData.has("screenshot")) return;
2538
-
2460
+ const screenshot = await captureViewportScreenshot({ context, annotation: () => mountCaptureAnnotation(comment, point) });
2461
+ if (!screenshot) return;
2462
+ const formData = new FormData();
2463
+ formData.append("projectKey", projectKey);
2464
+ formData.append("screenshot", screenshot.blob, screenshot.blob.type === "image/jpeg" ? "comment.jpg" : "comment.png");
2465
+ formData.append("screenshotWidth", String(screenshot.width));
2466
+ formData.append("screenshotHeight", String(screenshot.height));
2539
2467
  const response = await fetch(
2540
- `${apiUrl}/api/comments/${encodeURIComponent(commentId)}/screenshot`,
2541
- {
2542
- method: "POST",
2543
- headers: getSdkHeaders(),
2544
- body: formData,
2545
- },
2468
+ `${apiUrl}/api/comments/${encodeURIComponent(comment.id)}/screenshot`,
2469
+ { method: "POST", headers: getSdkHeaders(), body: formData },
2546
2470
  );
2547
-
2548
- if (!response.ok) {
2549
- console.error(
2550
- "Comty: screenshot upload failed",
2551
- await response.text(),
2552
- );
2553
- return;
2554
- }
2555
-
2556
- await loadComments({ force: true });
2471
+ if (!response.ok) throw new Error(`Screenshot upload failed (${response.status})`);
2472
+ // Updating metadata must not redraw the page or interrupt a new draft.
2473
+ const { screenshotUrl } = await response.json();
2474
+ const cached = cachedComments.find(item => String(item.id) === String(comment.id));
2475
+ if (cached) Object.assign(cached, {
2476
+ screenshot_url: screenshotUrl,
2477
+ screenshot_width: screenshot.width,
2478
+ screenshot_height: screenshot.height,
2479
+ });
2557
2480
  } catch (error) {
2558
- console.error("Comty: screenshot upload failed", error);
2559
- captureNodes.forEach((node) => node.remove());
2481
+ console.warn("Comty: comment saved, screenshot unavailable", error);
2560
2482
  }
2561
2483
  }
2562
2484
 
2563
- function createCaptureAnnotation(pin, bubble, content) {
2564
- const capturePin = pin.cloneNode(false);
2565
- capturePin.dataset.comtyCaptureKeep = "true";
2566
- capturePin.classList.add("comty-capture-annotation");
2567
- capturePin.classList.remove("comty-temp-pin");
2568
- capturePin.style.display = "grid";
2569
- capturePin.style.pointerEvents = "none";
2570
- capturePin.style.animation = "none";
2571
- capturePin.style.opacity = "1";
2572
- capturePin.style.transform = "translate(-50%, -50%)";
2573
- setPinIcon(capturePin, currentUser || { name: "You" });
2574
-
2575
- const captureBubble = document.createElement("div");
2576
- captureBubble.className =
2577
- "comty-bubble comty-hover-bubble comty-capture-annotation";
2578
- captureBubble.dataset.comtyCaptureKeep = "true";
2579
- captureBubble.style.left = bubble.style.left;
2580
- captureBubble.style.top = bubble.style.top;
2581
- captureBubble.style.display = "block";
2582
- captureBubble.style.pointerEvents = "none";
2583
- captureBubble.style.animation = "none";
2584
- captureBubble.style.opacity = "1";
2585
- captureBubble.style.transform = "none";
2586
-
2587
- const contentEl = document.createElement("div");
2588
- contentEl.className = "comty-bubble-content";
2589
- contentEl.innerText = content;
2590
- captureBubble.appendChild(contentEl);
2591
-
2592
- document.body.appendChild(capturePin);
2593
- document.body.appendChild(captureBubble);
2594
- return [capturePin, captureBubble];
2595
- }
2596
-
2597
2485
  function addLocalComment(commentId, payload) {
2598
2486
  const comment = {
2599
2487
  id: commentId,
2488
+ pending: commentId.startsWith("pending-"),
2600
2489
  project_id: projectId,
2601
2490
  domain: payload.host || window.location.host,
2602
2491
  page_path: payload.pagePath || window.location.pathname,
@@ -2604,6 +2493,7 @@ export function initComty(options = {}) {
2604
2493
  rel_x: payload.relX,
2605
2494
  rel_y: payload.relY,
2606
2495
  content: payload.content || "",
2496
+ link_url: payload.linkUrl || null,
2607
2497
  created_at: new Date().toISOString(),
2608
2498
  dom_element: payload.domElement || null,
2609
2499
  viewport: payload.viewport || null,
@@ -2615,7 +2505,9 @@ export function initComty(options = {}) {
2615
2505
  screenshot_width: null,
2616
2506
  screenshot_height: null,
2617
2507
  anchor_context: payload.anchorContext || null,
2618
- author_user_id: currentUser?.id || null,
2508
+ author_user_id: currentUser?.isGuest ? null : currentUser?.id || null,
2509
+ guest_author_id: currentUser?.isGuest ? currentUser.id : null,
2510
+ guest_author_name: currentUser?.isGuest ? currentUser.name : null,
2619
2511
  author_name: currentUser?.name || null,
2620
2512
  author_avatar_url: currentUser?.avatarUrl || null,
2621
2513
  replies: [],
@@ -2630,6 +2522,7 @@ export function initComty(options = {}) {
2630
2522
  ];
2631
2523
  renderSidepanelComments();
2632
2524
  void renderComments(cachedComments, { retry: false });
2525
+ return comment;
2633
2526
  }
2634
2527
 
2635
2528
  function getCommentAuthorName(comment) {
@@ -2639,8 +2532,7 @@ export function initComty(options = {}) {
2639
2532
  function isOwnCommentEntry(entry) {
2640
2533
  return Boolean(
2641
2534
  currentUser?.id &&
2642
- entry?.author_user_id &&
2643
- entry.author_user_id === currentUser.id,
2535
+ (currentUser.isGuest ? entry?.guest_author_id : entry?.author_user_id) === currentUser.id,
2644
2536
  );
2645
2537
  }
2646
2538
 
@@ -2716,7 +2608,7 @@ export function initComty(options = {}) {
2716
2608
 
2717
2609
  function createThreadItem(
2718
2610
  entry,
2719
- { showMore = isOwnCommentEntry(entry) } = {},
2611
+ { showMore = isOwnCommentEntry(entry), showLink = true } = {},
2720
2612
  ) {
2721
2613
  const item = document.createElement("div");
2722
2614
  item.className = "comty-thread-item";
@@ -2751,15 +2643,51 @@ export function initComty(options = {}) {
2751
2643
 
2752
2644
  const content = document.createElement("div");
2753
2645
  content.className = "comty-bubble-content";
2754
- content.innerText = entry?.content || "";
2646
+ renderCommentContent(content, entry?.content || "");
2755
2647
 
2756
2648
  body.appendChild(author);
2757
2649
  body.appendChild(content);
2650
+ if (showLink && entry?.link_url) body.appendChild(createCommentLink(entry.link_url));
2758
2651
  item.appendChild(body);
2759
2652
 
2760
2653
  return { item, body, content, moreButton };
2761
2654
  }
2762
2655
 
2656
+ function renderCommentContent(element, value) {
2657
+ const content = String(value ?? "");
2658
+ const nodes = [];
2659
+ let previousEnd = 0;
2660
+ for (const match of content.matchAll(/https?:\/\/[^\s<>"']+/gi)) {
2661
+ const url = match[0].replace(/[.,!?;:]+$/, "");
2662
+ if (!url) continue;
2663
+ let parsed;
2664
+ try { parsed = new URL(url); } catch { continue; }
2665
+ if (!["http:", "https:"].includes(parsed.protocol) || !parsed.hostname) continue;
2666
+ nodes.push(document.createTextNode(content.slice(previousEnd, match.index)));
2667
+ const link = document.createElement("a");
2668
+ link.href = url;
2669
+ link.target = "_blank";
2670
+ link.rel = "noopener noreferrer";
2671
+ link.textContent = url;
2672
+ nodes.push(link);
2673
+ previousEnd = match.index + url.length;
2674
+ }
2675
+ nodes.push(document.createTextNode(content.slice(previousEnd)));
2676
+ element.replaceChildren(...nodes);
2677
+ }
2678
+
2679
+ function createCommentLink(url) {
2680
+ const link = document.createElement("a");
2681
+ link.className = "comty-comment-link";
2682
+ link.href = url;
2683
+ link.target = "_blank";
2684
+ link.rel = "noopener noreferrer";
2685
+ link.title = url;
2686
+ link.setAttribute("aria-label", `Open attached link: ${url}`);
2687
+ link.textContent = url;
2688
+ return link;
2689
+ }
2690
+
2763
2691
  function setPinIcon(pin, comment = null) {
2764
2692
  if (comment) {
2765
2693
  pin.classList.add("comty-pin-with-avatar");
@@ -2776,6 +2704,54 @@ export function initComty(options = {}) {
2776
2704
  `;
2777
2705
  }
2778
2706
 
2707
+ function createCommentPin(comment, x, y) {
2708
+ const pin = document.createElement("button");
2709
+ pin.className = "comty-pin";
2710
+ pin.type = "button";
2711
+ pin.style.left = `${x}px`;
2712
+ pin.style.top = `${y}px`;
2713
+ setPinIcon(pin, comment);
2714
+ const statusDot = document.createElement("span");
2715
+ statusDot.className = "comty-pin-status-dot";
2716
+ applyStatusDot(statusDot, comment.status);
2717
+ pin.appendChild(statusDot);
2718
+ return pin;
2719
+ }
2720
+
2721
+ function createCommentPreview(comment) {
2722
+ const bubble = document.createElement("div");
2723
+ bubble.className = "comty-bubble comty-hover-bubble";
2724
+ const thread = document.createElement("div");
2725
+ thread.className = "comty-thread";
2726
+ thread.appendChild(createThreadItem(comment, { showMore: false, showLink: false }).item);
2727
+ bubble.appendChild(thread);
2728
+ return bubble;
2729
+ }
2730
+
2731
+ function mountCaptureAnnotation(comment, { x, y }) {
2732
+ const host = document.createElement("div");
2733
+ host.dataset.comtyCaptureExclude = "";
2734
+ host.setAttribute("aria-hidden", "true");
2735
+ host.inert = true;
2736
+ // Ancestor opacity hides measurement without changing the components'
2737
+ // computed opacity. Nothing flashes or intercepts input during cloning.
2738
+ Object.assign(host.style, { position: "fixed", inset: "0", opacity: "0", pointerEvents: "none" });
2739
+ const pin = createCommentPin(comment, x, y);
2740
+ const bubble = createCommentPreview(comment);
2741
+ for (const element of [pin, bubble]) {
2742
+ element.dataset.comtyCaptureKeep = "";
2743
+ element.style.setProperty("animation", "none", "important");
2744
+ element.style.setProperty("transition", "none", "important");
2745
+ element.style.setProperty("pointer-events", "none", "important");
2746
+ }
2747
+ pin.style.display = "grid";
2748
+ bubble.style.display = "block";
2749
+ host.append(pin, bubble);
2750
+ root.appendChild(host);
2751
+ positionCommentBubble(bubble, x, y);
2752
+ return { elements: [pin, bubble], dispose: () => host.remove() };
2753
+ }
2754
+
2779
2755
  function createCommentAuthor(comment) {
2780
2756
  const author = document.createElement("div");
2781
2757
  author.className = "comty-comment-author";
@@ -2819,74 +2795,126 @@ export function initComty(options = {}) {
2819
2795
 
2820
2796
  const textarea = document.createElement("textarea");
2821
2797
  textarea.className = "comty-create-textarea";
2822
- textarea.placeholder = payload.kind === "agent" ? "Describe the change for your agent" : "Add a comment";
2798
+ textarea.placeholder = "Add a comment";
2823
2799
  textarea.setAttribute("aria-label", textarea.placeholder);
2824
2800
  textarea.rows = 1;
2825
2801
 
2826
2802
  const actions = document.createElement("div");
2827
2803
  actions.className = "comty-create-actions";
2828
2804
 
2805
+ const linkBtn = document.createElement("button");
2806
+ linkBtn.type = "button";
2807
+ linkBtn.className = "comty-create-link-button";
2808
+ linkBtn.setAttribute("aria-label", "Add link");
2809
+ linkBtn.innerHTML = `
2810
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2811
+ <path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
2812
+ <path d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
2813
+ </svg>
2814
+ `;
2815
+
2816
+ const linkEditor = document.createElement("div");
2817
+ linkEditor.className = "comty-create-link-editor";
2818
+ linkEditor.inert = true;
2819
+ linkEditor.setAttribute("aria-hidden", "true");
2820
+ const linkInput = document.createElement("input");
2821
+ linkInput.type = "url";
2822
+ linkInput.placeholder = "Paste a link";
2823
+ linkInput.setAttribute("aria-label", "Link URL");
2824
+ const linkInsert = document.createElement("button");
2825
+ linkInsert.type = "button";
2826
+ linkInsert.textContent = "Add";
2827
+ const linkCancel = document.createElement("button");
2828
+ linkCancel.type = "button";
2829
+ linkCancel.setAttribute("aria-label", "Cancel link");
2830
+ linkCancel.textContent = "×";
2831
+ const linkField = document.createElement("div");
2832
+ linkField.className = "comty-create-link-field";
2833
+ linkField.append(linkInput, linkCancel);
2834
+ linkEditor.append(linkField, linkInsert);
2835
+
2836
+ const attachment = document.createElement("div");
2837
+ attachment.className = "comty-create-attachment";
2838
+ const attachmentLink = document.createElement("a");
2839
+ attachmentLink.className = "comty-comment-link";
2840
+ attachmentLink.target = "_blank";
2841
+ attachmentLink.rel = "noopener noreferrer";
2842
+ const removeAttachment = document.createElement("button");
2843
+ removeAttachment.type = "button";
2844
+ removeAttachment.setAttribute("aria-label", "Remove attached link");
2845
+ removeAttachment.textContent = "×";
2846
+ const attachmentChip = document.createElement("div");
2847
+ attachmentChip.className = "comty-create-attachment-chip";
2848
+ attachmentChip.append(attachmentLink, removeAttachment);
2849
+ attachment.appendChild(attachmentChip);
2850
+ let attachedLink = "";
2851
+
2829
2852
  const submitBtn = document.createElement("button");
2853
+ submitBtn.type = "button";
2830
2854
  submitBtn.className = "comty-btn-submit";
2831
- submitBtn.setAttribute("aria-label", payload.kind === "agent" ? "Send agent task" : "Post comment");
2855
+ submitBtn.setAttribute("aria-label", "Post comment");
2856
+ submitBtn.disabled = true;
2832
2857
  submitBtn.innerHTML = `
2833
2858
  <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">
2834
2859
  <path d="M12 19V5M5 12l7-7 7 7"/>
2835
2860
  </svg>
2836
2861
  `;
2837
2862
 
2863
+ const editorDraft = draftAnchor;
2838
2864
  const submit = async () => {
2839
2865
  const content = textarea.value.trim();
2840
- if (!content) return;
2866
+ if (!content || textarea.disabled) return;
2841
2867
 
2842
2868
  submitBtn.disabled = true;
2843
2869
  textarea.disabled = true;
2870
+ linkBtn.disabled = true;
2844
2871
 
2845
- if (payload.kind === "agent" && !agent) {
2846
- showAgentBridgeError(); submitBtn.disabled = false; textarea.disabled = false; return;
2847
- }
2848
2872
  payload.content = content;
2873
+ payload.linkUrl = attachedLink || null;
2849
2874
  textarea.textContent = content;
2850
2875
 
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.
2880
+ bubble.remove();
2881
+ pin.remove();
2882
+ if (draftAnchor === editorDraft) draftAnchor = null;
2883
+ hideInspector();
2884
+ addLocalComment(pendingId, payload);
2851
2885
  let post;
2852
2886
  try {
2853
- post = await fetch(payload.kind === "agent" ? `${agentBridgeUrl}/tasks` : `${apiUrl}/api/comments`, {
2887
+ post = await fetch(`${apiUrl}/api/comments`, {
2854
2888
  method: "POST",
2855
- headers: payload.kind === "agent" ? agentHeaders() : getSdkHeaders({ "Content-Type": "application/json" }),
2856
- body: JSON.stringify(payload.kind === "agent" ? { ...payload, projectKey } : payload),
2889
+ headers: getSdkHeaders({ "Content-Type": "application/json" }),
2890
+ body: JSON.stringify(payload),
2857
2891
  });
2892
+ if (!post.ok) throw new Error(`Comment save failed (${post.status})`);
2893
+ 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 });
2858
2901
  } catch (error) {
2859
2902
  console.error("Comty: save failed", error);
2860
- if (payload.kind === "agent") showAgentBridgeError();
2861
- submitBtn.disabled = false;
2862
- textarea.disabled = false;
2863
- return;
2864
- }
2865
-
2866
- if (!post.ok) {
2867
- console.error("Comty: save failed", await post.text());
2868
- if (payload.kind === "agent") showAgentBridgeError();
2903
+ cachedComments = cachedComments.filter(item => String(item.id) !== pendingId);
2904
+ renderSidepanelComments();
2905
+ void renderComments(cachedComments, { retry: false });
2906
+ // Keep the original editor and its text available for retry, including attachments.
2907
+ if (!draftAnchor) draftAnchor = editorDraft;
2908
+ root.append(pin, bubble);
2869
2909
  submitBtn.disabled = false;
2870
2910
  textarea.disabled = false;
2871
- return;
2872
- }
2873
-
2874
- const data = await post.json();
2875
- const commentId = payload.kind === "agent" ? data?.task?.id : data?.id;
2876
- const captureNodes = commentId && payload.kind !== "agent"
2877
- ? createCaptureAnnotation(pin, bubble, content)
2878
- : [];
2879
- bubble.remove();
2880
- pin.remove();
2881
- draftAnchor = null;
2882
- hideInspector();
2883
- if (commentId) {
2884
- if (payload.kind === "agent") showAgentTaskToast(String(commentId));
2885
- else addLocalComment(String(commentId), payload);
2886
- void loadComments({ force: true });
2887
- if (payload.kind !== "agent") void uploadCommentScreenshot(String(commentId), payload, captureNodes);
2888
- } else {
2889
- void loadComments({ force: true });
2911
+ linkBtn.disabled = false;
2912
+ const errorMessage = document.createElement("p");
2913
+ errorMessage.setAttribute("role", "alert");
2914
+ errorMessage.textContent = "Comment not saved. Your draft is ready to retry.";
2915
+ bubble.querySelector('[role="alert"]')?.remove();
2916
+ bubble.append(errorMessage);
2917
+ if (bubble.isConnected) textarea.focus();
2890
2918
  }
2891
2919
  };
2892
2920
 
@@ -2895,8 +2923,63 @@ export function initComty(options = {}) {
2895
2923
  submit();
2896
2924
  };
2897
2925
 
2926
+ const closeLinkEditor = () => {
2927
+ bubble.classList.remove("comty-create-link-open");
2928
+ linkEditor.inert = true;
2929
+ linkEditor.setAttribute("aria-hidden", "true");
2930
+ linkInput.value = "";
2931
+ linkInput.removeAttribute("aria-invalid");
2932
+ linkInput.setCustomValidity("");
2933
+ textarea.focus();
2934
+ };
2935
+ linkBtn.onclick = () => {
2936
+ if (!bubble.classList.contains("comty-create-expanded")) return;
2937
+ bubble.classList.add("comty-create-link-open");
2938
+ linkEditor.inert = false;
2939
+ linkEditor.setAttribute("aria-hidden", "false");
2940
+ linkInput.focus();
2941
+ };
2942
+ linkCancel.onclick = closeLinkEditor;
2943
+ linkInput.oninput = () => {
2944
+ linkInput.removeAttribute("aria-invalid");
2945
+ linkInput.setCustomValidity("");
2946
+ };
2947
+ linkInput.onkeydown = (event) => {
2948
+ if (event.key === "Enter") { event.preventDefault(); linkInsert.click(); }
2949
+ if (event.key === "Escape") { event.preventDefault(); closeLinkEditor(); }
2950
+ };
2951
+ linkInsert.onclick = () => {
2952
+ const value = linkInput.value.trim();
2953
+ const candidate = /^[a-z][a-z0-9+.-]*:/i.test(value) ? value : `https://${value}`;
2954
+ let url;
2955
+ try { url = new URL(candidate); } catch { /* Report below. */ }
2956
+ if (!url || !["http:", "https:"].includes(url.protocol) || !url.hostname || url.username || url.password || /\s/.test(value) || String(url).length > 2048) {
2957
+ linkInput.setAttribute("aria-invalid", "true");
2958
+ linkInput.setCustomValidity("Enter a valid HTTP(S) URL.");
2959
+ linkInput.reportValidity();
2960
+ linkInput.focus();
2961
+ return;
2962
+ }
2963
+ attachedLink = String(url);
2964
+ attachmentLink.href = attachedLink;
2965
+ attachmentLink.title = attachedLink;
2966
+ attachmentLink.textContent = attachedLink;
2967
+ bubble.classList.add("comty-create-has-attachment");
2968
+ closeLinkEditor();
2969
+ const point = getPinPoint(pin);
2970
+ positionCommentBubble(bubble, point.x, point.y);
2971
+ renderInspector();
2972
+ };
2973
+
2974
+ removeAttachment.onclick = () => {
2975
+ attachedLink = "";
2976
+ bubble.classList.remove("comty-create-has-attachment");
2977
+ textarea.focus();
2978
+ textarea.oninput();
2979
+ };
2980
+
2898
2981
  textarea.onkeydown = (e) => {
2899
- if (e.key === "Enter" && !e.shiftKey) {
2982
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
2900
2983
  e.preventDefault();
2901
2984
  submit();
2902
2985
  }
@@ -2907,31 +2990,20 @@ export function initComty(options = {}) {
2907
2990
  };
2908
2991
 
2909
2992
  textarea.oninput = () => {
2910
- textarea.style.height = "24px";
2911
- const scrollHeight = textarea.scrollHeight;
2912
- textarea.style.height = scrollHeight + "px";
2913
-
2914
- if (scrollHeight > 24) {
2915
- bubble.style.borderRadius = "16px";
2916
- bubble.style.alignItems = "flex-end";
2917
- } else {
2918
- bubble.style.borderRadius = "999px";
2919
- bubble.style.alignItems = "center";
2920
- }
2921
-
2922
- if (textarea.value.trim().length > 0) {
2923
- submitBtn.classList.add("comty-btn-active");
2924
- } else {
2925
- submitBtn.classList.remove("comty-btn-active");
2926
- }
2993
+ const expanded = textarea.value.length > 0 || Boolean(attachedLink);
2994
+ bubble.classList.toggle("comty-create-expanded", expanded);
2995
+ if (!expanded && bubble.classList.contains("comty-create-link-open")) closeLinkEditor();
2996
+ submitBtn.disabled = !textarea.value.trim();
2997
+ submitBtn.classList.toggle("comty-btn-active", !submitBtn.disabled);
2927
2998
  const point = getPinPoint(pin);
2928
2999
  positionCommentBubble(bubble, point.x, point.y);
2929
3000
  renderInspector();
2930
3001
  };
2931
3002
 
2932
- actions.appendChild(submitBtn);
3003
+ actions.append(linkBtn, submitBtn, linkEditor);
2933
3004
 
2934
3005
  bubble.appendChild(textarea);
3006
+ bubble.appendChild(attachment);
2935
3007
  bubble.appendChild(actions);
2936
3008
 
2937
3009
  root.appendChild(bubble);
@@ -2941,223 +3013,6 @@ export function initComty(options = {}) {
2941
3013
  setTimeout(() => textarea.focus(), 10);
2942
3014
  }
2943
3015
 
2944
- function agentHeaders() { return { "Content-Type": "application/json", "X-Comty-Token": agent?.token || "" }; }
2945
-
2946
- function showAgentBridgeError(message = "Start your app with comty dev to use the local agent. Check the terminal if it is already running.") {
2947
- root.querySelectorAll(".comty-agent-task-toast").forEach((node) => node.remove());
2948
- const toast = document.createElement("div");
2949
- toast.className = "comty-auth-toast comty-agent-task-toast";
2950
- toast.setAttribute("role", "alert");
2951
- const issue = describeAgentError(message);
2952
- toast.textContent = issue.title + ". " + issue.description;
2953
- root.appendChild(toast);
2954
- setTimeout(() => toast.remove(), 5000);
2955
- }
2956
-
2957
- function showAgentTaskToast() {
2958
- agentPanelOpen = true;
2959
- agentPanelSignature = "";
2960
- renderAgentPanel();
2961
- }
2962
-
2963
- function describeAgentError(error) {
2964
- const raw = typeof error === 'string' ? error : JSON.stringify(error, null, 2) || 'Unknown error';
2965
- let value = error;
2966
- for (let depth = 0; depth < 8; depth++) {
2967
- if (typeof value === 'string') {
2968
- try { value = JSON.parse(value); continue; } catch {}
2969
- const start = value.indexOf('{'), end = value.lastIndexOf('}');
2970
- if (start >= 0 && end > start) { try { value = JSON.parse(value.slice(start, end + 1)); continue; } catch {} }
2971
- break;
2972
- }
2973
- if (!value || typeof value !== 'object') break;
2974
- value = value.error || value.message || value.cause || value.stderr || value;
2975
- }
2976
- const message = typeof value === 'string' ? value : '';
2977
- const source = raw + ' ' + message;
2978
- const agentName = /codex/i.test(source) ? 'Codex' : /claude/i.test(source) ? 'Claude Code' : /cursor/i.test(source) ? 'Cursor' : 'your coding agent';
2979
- let title, description;
2980
- if (/requires a newer version|upgrade.*latest|outdated|unsupported.*version/i.test(source)) {
2981
- title = 'Agent update required'; description = `Update ${agentName}, restart your local development session, then retry this task.`;
2982
- } else if (/insufficient_quota|quota exceeded|credit balance|billing|usage limit/i.test(source)) {
2983
- title = 'Usage limit reached'; description = 'Check your agent account’s usage allowance or billing, then retry when capacity is available.';
2984
- } else if (/rate.?limit|too many requests|\b429\b/i.test(source)) {
2985
- title = 'Too many requests'; description = 'The agent is temporarily rate limited. Wait a moment before retrying.';
2986
- } else if (/unauthorized|unauthenticated|authentication|not logged|sign.?in|log.?in|invalid.api.key|\b401\b/i.test(source)) {
2987
- title = 'Sign in to your agent'; description = `Open ${agentName} and sign in again, then retry this task.`;
2988
- } else if (/model.*(?:not found|does not exist|unavailable|not supported)|unsupported model|model_not_found/i.test(source)) {
2989
- title = 'Model unavailable'; description = 'Choose a model available to your agent account in the local Comty configuration, then restart and retry.';
2990
- } else if (/disconnected|ECONNREFUSED|ENOTFOUND|network|failed to fetch|development server stopped/i.test(source)) {
2991
- title = 'Connection lost'; description = 'Check that your local development server and coding agent are running. Reconnect before retrying; your task history is kept.';
2992
- } else if (/timed? ?out|timeout|exceeded.*minutes/i.test(source)) {
2993
- title = 'Task timed out'; description = 'Review any changes already made, then retry with a smaller, more specific request.';
2994
- } else if (/permission|denied|forbidden|\b403\b/i.test(source)) {
2995
- title = 'Permission needed'; description = 'Check the agent’s access to this project and any pending approval before retrying.';
2996
- } else if (/ENOENT|command not found|not installed|executable.*not found/i.test(source)) {
2997
- title = 'Agent not found'; description = `Install or configure ${agentName} in your local development environment, then restart and retry.`;
2998
- } else if (/completion was not verified/i.test(source)) {
2999
- title = 'Result needs checking'; description = 'The agent could not confirm completion. Review the changes before retrying.';
3000
- } else {
3001
- title = 'Task could not finish';
3002
- 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.';
3003
- }
3004
- return { title, description, raw, message };
3005
- }
3006
-
3007
- function renderAgentPanel() {
3008
- if (!agent) return;
3009
- let panel = root.querySelector(".comty-agent-panel");
3010
- if (!panel) {
3011
- panel = document.createElement("section");
3012
- panel.className = "comty-agent-panel";
3013
- panel.setAttribute("aria-label", "Local agent tasks");
3014
- panel.hidden = true;
3015
- // Keep the dock above inspection overlays and below the toolbar.
3016
- root.insertBefore(panel, toolbar);
3017
- }
3018
- const open = session.agent && agentPanelOpen;
3019
- if (open) {
3020
- if (panel.hidden) {
3021
- const rect = toolbar.getBoundingClientRect();
3022
- panel.dataset.side = rect.left + rect.width / 2 < innerWidth / 2 ? "left" : "right";
3023
- document.documentElement.dataset.comtyAgentSide = panel.dataset.side;
3024
- panel.dataset.closed = "";
3025
- panel.hidden = false;
3026
- panel.getBoundingClientRect();
3027
- }
3028
- if (panel.hasAttribute("data-closed")) { agentPanelTransition++; delete panel.dataset.closed; }
3029
- } else if (!panel.hidden && !panel.hasAttribute("data-closed")) {
3030
- const transition = ++agentPanelTransition;
3031
- panel.dataset.closed = "";
3032
- const finish = () => { if (transition === agentPanelTransition && panel.hasAttribute("data-closed")) panel.hidden = true; };
3033
- if (reducedMotion.matches) finish();
3034
- else setTimeout(finish, parseFloat(getComputedStyle(root).getPropertyValue("--comty-motion-duration")));
3035
- }
3036
- panel.inert = !open;
3037
- panel.setAttribute("aria-hidden", String(!open));
3038
- document.documentElement.classList.toggle("comty-agent-docked", open);
3039
- agentHistoryBtn?.setAttribute("aria-expanded", String(open));
3040
- agentHistoryBtn?.setAttribute("aria-pressed", String(open));
3041
- const signature = JSON.stringify([localTasks, localApprovals, agentConnectionError, agentActionError, agentPanelOpen]);
3042
- if (signature === agentPanelSignature) return;
3043
- agentPanelSignature = signature;
3044
- root.querySelector('.comty-agent-error-popover')?.remove();
3045
- const expanded = new Set([...panel.querySelectorAll('details[data-detail-key][open]')].map(node => node.dataset.detailKey));
3046
- const focusKey = panel.contains(document.activeElement) ? document.activeElement.dataset.focusKey : null;
3047
- const scrollTop = panel.querySelector('.comty-agent-panel-content')?.scrollTop || 0;
3048
- const node = (tag, className, text) => {
3049
- const element = document.createElement(tag);
3050
- if (className) element.className = className;
3051
- if (text != null) element.textContent = text;
3052
- return element;
3053
- };
3054
- const codeIcon = '<path d="m8 6-6 6 6 6m8-12 6 6-6 6m-3-15-2 18"/>';
3055
- const brand = () => { const element = node('span', 'comty-agent-brand'); element.innerHTML = icon(codeIcon); element.setAttribute('aria-hidden', 'true'); return element; };
3056
- panel.replaceChildren();
3057
- const header = node('header', 'comty-agent-panel-header');
3058
- const heading = node('div', 'comty-agent-heading');
3059
- heading.append(node('strong', '', 'Modifications'));
3060
- const toggle = node('button', 'comty-agent-action comty-agent-close'); toggle.type = 'button';
3061
- toggle.innerHTML = icon('<path d="m6 6 12 12M6 18 18 6"/>');
3062
- toggle.setAttribute('aria-label', 'Close local agent sidebar'); toggle.title = 'Close sidebar'; toggle.dataset.focusKey = 'close';
3063
- toggle.onclick = () => { agentPanelOpen = false; renderAgentPanel(); agentHistoryBtn?.focus({ preventScroll: true }); };
3064
- header.append(heading, toggle); panel.appendChild(header);
3065
- const content = node('div', 'comty-agent-panel-content'); panel.appendChild(content);
3066
- const technicalDetails = (parent, raw, key) => {
3067
- const details = node('details', 'comty-agent-details'); details.dataset.detailKey = key; details.open = expanded.has(key);
3068
- const summary = node('summary', '', 'Technical details'); summary.dataset.focusKey = 'details-' + key;
3069
- details.append(summary, node('pre', '', raw)); parent.appendChild(details);
3070
- };
3071
- const errorMessage = (parent, error, key) => {
3072
- const issue = describeAgentError(error);
3073
- const message = node('div', 'comty-agent-error');
3074
- message.append(node('strong', '', issue.title), node('p', '', issue.description));
3075
- parent.appendChild(message); technicalDetails(parent, issue.raw, key);
3076
- };
3077
- if (agentConnectionError || agentActionError) {
3078
- const banner = node('div', 'comty-agent-item comty-agent-banner'); banner.setAttribute('role', 'alert');
3079
- errorMessage(banner, agentConnectionError || agentActionError, 'connection'); content.appendChild(banner);
3080
- }
3081
- const action = (parent, label, path, input = {}, primary = false) => {
3082
- const button = node('button', 'comty-agent-action', label); button.type = 'button';
3083
- const key = path + JSON.stringify(input); button.dataset.focusKey = key;
3084
- button.disabled = agentPendingActions.has(key);
3085
- if (primary) button.dataset.primary = '';
3086
- button.onclick = async () => {
3087
- if (agentPendingActions.has(key)) return;
3088
- agentActionError = '';
3089
- agentPendingActions.add(key); button.disabled = true; button.textContent = 'Please wait…';
3090
- try {
3091
- const response = await fetch(`${agentBridgeUrl}/${path}`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify(input) });
3092
- if (!response.ok) {
3093
- const body = await response.json().catch(() => null);
3094
- throw new Error(typeof body?.error === 'string' ? body.error : JSON.stringify(body?.error || { status: response.status, message: 'Request failed' }));
3095
- }
3096
- await loadComments({ force: true });
3097
- } catch (error) { agentActionError = error.message; }
3098
- finally { agentPendingActions.delete(key); agentPanelSignature = ''; renderAgentPanel(); }
3099
- };
3100
- parent.appendChild(button);
3101
- };
3102
- for (const approval of localApprovals) {
3103
- const section = node('div', 'comty-agent-item');
3104
- section.append(node('h3', '', 'Approval required'));
3105
- const task = localTasks.find(task => task.id === approval.taskId);
3106
- if (task) section.appendChild(node('p', '', task.content));
3107
- section.appendChild(node('p', '', 'Review this operation before allowing the agent to continue.'));
3108
- const operation = approval.details?.command || approval.details?.file_path || approval.details?.path;
3109
- if (typeof operation === 'string' || Array.isArray(operation)) section.appendChild(node('pre', '', Array.isArray(operation) ? operation.join(' ') : operation));
3110
- technicalDetails(section, JSON.stringify(approval.details ?? {}, null, 2), 'approval-' + approval.id);
3111
- const actions = node('div', 'comty-agent-actions');
3112
- action(actions, 'Allow once', `approvals/${approval.id}`, { allow: true }, true);
3113
- action(actions, 'Deny', `approvals/${approval.id}`, { allow: false });
3114
- section.appendChild(actions); content.appendChild(section);
3115
- }
3116
- const list = node('div', 'comty-agent-list'); content.appendChild(list);
3117
- const hideErrorPopover = () => root.querySelector('.comty-agent-error-popover')?.remove();
3118
- content.addEventListener('scroll', hideErrorPopover, { passive: true });
3119
- for (const [index, task] of [...localTasks].reverse().entries()) {
3120
- const failed = Boolean(task.error) || task.status === 'in_review';
3121
- const state = failed ? 'error' : task.status === 'done' ? 'success' : task.status === 'cancel' ? 'cancel' : 'working';
3122
- const row = node('div', 'comty-agent-row'); row.dataset.taskId = task.id;
3123
- const dot = state === 'working' ? createWorkingIndicator() : node('span', 'comty-agent-dot');
3124
- dot.dataset.status = state; dot.setAttribute('aria-hidden', 'true');
3125
- const name = node('span', 'comty-agent-row-name', task.content);
3126
- row.append(dot, name);
3127
- if (state === 'working') row.appendChild(node('span', 'comty-agent-row-state', task.status === 'todo' ? 'Starting' : 'Working'));
3128
- const actions = node('div', 'comty-agent-row-actions');
3129
- if (['in_review', 'cancel'].includes(task.status)) action(actions, 'Retry', `tasks/${task.id}/retry`);
3130
- if (['todo', 'in_progress'].includes(task.status)) action(actions, 'Cancel', `tasks/${task.id}/cancel`);
3131
- if (actions.childElementCount) row.appendChild(actions);
3132
- row.setAttribute('aria-label', `${task.content}, ${state === 'error' ? 'failed' : state === 'success' ? 'completed' : state === 'cancel' ? 'cancelled' : task.status === 'todo' ? 'starting' : 'working'}`);
3133
- if (failed) {
3134
- const tooltipId = `comty-agent-error-${index}`;
3135
- const showErrorPopover = () => {
3136
- hideErrorPopover();
3137
- const issue = describeAgentError(task.error || task.result?.summary || task.progress || 'Completion was not verified');
3138
- const popover = node('div', 'comty-agent-error-popover'); popover.id = tooltipId; popover.setAttribute('role', 'tooltip');
3139
- popover.append(node('strong', '', task.content), node('p', '', issue.message || issue.description));
3140
- root.appendChild(popover);
3141
- const rowRect = row.getBoundingClientRect();
3142
- const popoverRect = popover.getBoundingClientRect();
3143
- const left = panel.dataset.side === 'right' ? rowRect.left - popoverRect.width - 8 : rowRect.right + 8;
3144
- popover.style.left = `${Math.max(8, Math.min(left, innerWidth - popoverRect.width - 8))}px`;
3145
- popover.style.top = `${Math.max(8, Math.min(rowRect.top - 4, innerHeight - popoverRect.height - 8))}px`;
3146
- };
3147
- row.addEventListener('mouseenter', showErrorPopover);
3148
- row.addEventListener('mouseleave', hideErrorPopover);
3149
- }
3150
- list.appendChild(row);
3151
- }
3152
- if (!localTasks.length && !localApprovals.length) {
3153
- const empty = node('div', 'comty-agent-empty');
3154
- empty.append(brand(), node('strong', '', localTasks.length ? 'All caught up' : 'What shall we change?'), node('p', '', 'Point at an element on the page and describe the change you want.'));
3155
- content.appendChild(empty);
3156
- }
3157
- content.scrollTop = scrollTop;
3158
- if (focusKey && open) [...panel.querySelectorAll('[data-focus-key]')].find(element => element.dataset.focusKey === focusKey)?.focus({ preventScroll: true });
3159
- }
3160
-
3161
3016
  function setSidepanelOpen(open) {
3162
3017
  sidepanelOpen = open;
3163
3018
  sidepanel.classList.toggle("comty-sidepanel-open", sidepanelOpen);
@@ -3211,7 +3066,6 @@ export function initComty(options = {}) {
3211
3066
  ? sidepanelSearch.value.trim().toLowerCase()
3212
3067
  : "";
3213
3068
  let list = cachedComments.filter((comment) => {
3214
- if (comment.local_agent || comment.kind === "agent") return false;
3215
3069
  if (
3216
3070
  search &&
3217
3071
  !`${comment.content ?? ""} ${getCommentLabel(comment)}`
@@ -3258,6 +3112,7 @@ export function initComty(options = {}) {
3258
3112
  const item = document.createElement("button");
3259
3113
  item.type = "button";
3260
3114
  item.className = "comty-sidepanel-item";
3115
+ item.disabled = Boolean(comment.pending);
3261
3116
  item.innerHTML = `
3262
3117
  <span class="comty-sidepanel-item-status" aria-hidden="true"></span>
3263
3118
  <span class="comty-sidepanel-item-body">
@@ -3278,7 +3133,7 @@ export function initComty(options = {}) {
3278
3133
 
3279
3134
  const metaEl = item.querySelector(".comty-sidepanel-item-meta");
3280
3135
  if (metaEl instanceof HTMLElement) {
3281
- metaEl.innerText = `${comment.author_name || "Anonymous"} · ${getCommentLabel(comment)}`;
3136
+ metaEl.innerText = `${comment.author_name || "Anonymous"} · ${comment.pending ? "Sending…" : getCommentLabel(comment)}`;
3282
3137
  metaEl.title = getCommentLabel(comment);
3283
3138
  }
3284
3139
 
@@ -3397,15 +3252,14 @@ export function initComty(options = {}) {
3397
3252
  createStatusFilters();
3398
3253
 
3399
3254
  async function updateCommentStatus(comment, status) {
3400
- if (comment?.local_agent || !projectId || !comment?.id) return false;
3255
+ if (!projectId || !comment?.id) return false;
3401
3256
 
3402
3257
  let res;
3403
3258
  try {
3404
- const local = comment.local_agent === true;
3405
- res = await fetch(local ? `${agentBridgeUrl}/tasks/${encodeURIComponent(comment.id)}/status` : `${apiUrl}/api/comments/${comment.id}/status`, {
3259
+ res = await fetch(`${apiUrl}/api/comments/${comment.id}/status`, {
3406
3260
  method: "POST",
3407
- headers: local ? { "Content-Type": "application/json" } : getSdkHeaders({ "Content-Type": "application/json" }),
3408
- body: JSON.stringify(local ? { status } : {
3261
+ headers: getSdkHeaders({ "Content-Type": "application/json" }),
3262
+ body: JSON.stringify({
3409
3263
  projectKey,
3410
3264
  host: window.location.host,
3411
3265
  status,
@@ -3422,7 +3276,7 @@ export function initComty(options = {}) {
3422
3276
  }
3423
3277
 
3424
3278
  const data = await res.json();
3425
- comment.status = getCommentStatus(data.task?.status ?? data.status).value;
3279
+ comment.status = getCommentStatus(data.status).value;
3426
3280
  return comment.status;
3427
3281
  }
3428
3282
 
@@ -3610,11 +3464,13 @@ export function initComty(options = {}) {
3610
3464
  const openCommentThread = createCommentThread({
3611
3465
  root, apiUrl, projectKey, commentStatuses, selectedStatusFilters, placedPins,
3612
3466
  closeBubbles, getCommentStatus, updateCommentStatus, applyStatusDot,
3613
- renderSidepanelComments, applyStatusFilters, loadComments, createThreadItem,
3467
+ renderSidepanelComments, applyStatusFilters, createThreadItem, renderCommentContent,
3614
3468
  isOwnCommentEntry, updateCommentContent, deleteSdkComment, getCommentId,
3615
3469
  updateReplyContent, deleteSdkReply, createCommentAvatar, getSdkHeaders,
3616
3470
  showAuthPrompt,
3617
3471
  get canWrite() { return canWrite; },
3472
+ get canManage() { return canManage; },
3473
+ get allowGuestComments() { return allowGuestComments; },
3618
3474
  get currentUser() { return currentUser; },
3619
3475
  get cachedComments() { return cachedComments; },
3620
3476
  set cachedComments(value) { cachedComments = value; },
@@ -3664,20 +3520,11 @@ export function initComty(options = {}) {
3664
3520
  const x = visibility.clientX;
3665
3521
  const y = visibility.clientY;
3666
3522
 
3667
- const pin = document.createElement("button");
3668
- pin.className = "comty-pin";
3669
- pin.type = "button";
3523
+ const pin = createCommentPin(comment, x, y);
3524
+ pin.disabled = Boolean(comment.pending);
3670
3525
  pin.dataset.status = getCommentStatus(comment.status).value;
3671
- if (comment.id) {
3672
- pin.dataset.commentId = String(comment.id);
3673
- }
3674
- pin.style.left = `${x}px`;
3675
- pin.style.top = `${y}px`;
3676
- setPinIcon(pin, comment);
3677
- const pinStatusDot = document.createElement("span");
3678
- pinStatusDot.className = "comty-pin-status-dot";
3679
- applyStatusDot(pinStatusDot, comment.status);
3680
- pin.appendChild(pinStatusDot);
3526
+ if (comment.id) pin.dataset.commentId = String(comment.id);
3527
+ const pinStatusDot = pin.querySelector(".comty-pin-status-dot");
3681
3528
  pin.setAttribute(
3682
3529
  "aria-label",
3683
3530
  `Afficher le commentaire, statut ${getCommentStatus(comment.status).label}`,
@@ -3719,32 +3566,14 @@ export function initComty(options = {}) {
3719
3566
  activeHoverCommentIds.add(commentId);
3720
3567
  }
3721
3568
 
3722
- const hoverBubble = document.createElement("div");
3723
- hoverBubble.className = "comty-bubble comty-hover-bubble";
3569
+ const hoverBubble = createCommentPreview(comment);
3724
3570
  hoverBubble.style.left = `${x + 32}px`;
3725
3571
  hoverBubble.style.top = `${y - 17}px`;
3726
- hoverBubble.style.pointerEvents = "none";
3727
3572
  hoverBubble.dataset.hoverId = commentId;
3728
3573
 
3729
- const hoverThread = document.createElement("div");
3730
- hoverThread.className = "comty-thread";
3731
- const hoverItem = createThreadItem(comment, { showMore: false });
3732
- hoverThread.appendChild(hoverItem.item);
3733
- hoverBubble.appendChild(hoverThread);
3734
-
3735
3574
  root.appendChild(hoverBubble);
3736
3575
 
3737
- requestAnimationFrame(() => {
3738
- if (x + 16 + hoverBubble.offsetWidth > window.innerWidth) {
3739
- hoverBubble.style.left = `${x - hoverBubble.offsetWidth - 24}px`;
3740
- hoverBubble.classList.add("comty-bubble-left");
3741
- }
3742
-
3743
- const rect = hoverBubble.getBoundingClientRect();
3744
- if (rect.bottom > window.innerHeight) {
3745
- hoverBubble.style.top = `${y - rect.height - 16}px`;
3746
- }
3747
- });
3576
+ requestAnimationFrame(() => positionCommentBubble(hoverBubble, x, y));
3748
3577
  };
3749
3578
 
3750
3579
  pin.onmouseleave = () => {
@@ -3839,12 +3668,15 @@ export function initComty(options = {}) {
3839
3668
  function reconcileComments(comments) {
3840
3669
  const previous = new Map(cachedComments.map(comment => [String(comment.id), comment]));
3841
3670
  cachedComments = comments
3842
- .filter(comment => comment.kind !== "agent" || comment.status !== "done")
3671
+ .filter(comment => comment.kind !== "agent" && !comment.local_agent)
3843
3672
  .map(comment => Object.assign(previous.get(String(comment.id)) || {}, comment));
3673
+ for (const comment of previous.values()) {
3674
+ if (comment.pending && !cachedComments.some(item => item.id === comment.id)) cachedComments.push(comment);
3675
+ }
3844
3676
  }
3845
3677
 
3846
3678
  async function loadComments({ force = false } = {}) {
3847
- if (!projectId && !agent) return;
3679
+ if (!projectId) return;
3848
3680
  const pagePath = window.location.pathname;
3849
3681
  if (!force && cachedPagePath === pagePath) { await renderComments(cachedComments); return; }
3850
3682
  if (pendingCommentsLoad) {
@@ -3852,7 +3684,7 @@ export function initComty(options = {}) {
3852
3684
  if (!force) return;
3853
3685
  }
3854
3686
  pendingCommentsLoad = (async () => {
3855
- let comments = cachedPagePath === pagePath ? cachedComments.filter(comment => !comment.local_agent) : [];
3687
+ let comments = cachedPagePath === pagePath ? cachedComments : [];
3856
3688
  if (projectId) {
3857
3689
  try {
3858
3690
  const url = new URL(`${apiUrl}/api/comments`);
@@ -3863,37 +3695,15 @@ export function initComty(options = {}) {
3863
3695
  if (Array.isArray(data.comments)) comments = data.comments;
3864
3696
  } catch (error) { console.error("Comty: list comments failed", error); }
3865
3697
  }
3866
- if (agent) {
3867
- try {
3868
- const res = await fetch(`${agentBridgeUrl}/tasks`, { headers: agentHeaders(), cache: "no-store" });
3869
- if (!res.ok) throw new Error("Local companion unavailable");
3870
- const data = await res.json();
3871
- if (Array.isArray(data.tasks)) localTasks = data.tasks;
3872
- if (Array.isArray(data.approvals)) localApprovals = data.approvals;
3873
- agentConnectionError = "";
3874
- } catch (error) {
3875
- agentConnectionError = "Local agent disconnected. Check the development server, then reload this page. Your task history is kept.";
3876
- console.error("Comty: local tasks failed", error);
3877
- }
3878
- comments.push(...localTasks.filter(task => task.page_path === pagePath && !["done", "cancel"].includes(task.status)));
3879
- renderAgentPanel();
3880
- }
3881
3698
  reconcileComments(comments);
3882
3699
  cachedPagePath = pagePath;
3883
3700
  renderSidepanelComments();
3884
3701
  await renderComments(cachedComments);
3885
- syncAgentWorkingOutlines();
3886
3702
  ensureLiveTracking();
3887
3703
  })();
3888
3704
  try { await pendingCommentsLoad; } finally { pendingCommentsLoad = null; }
3889
3705
  }
3890
3706
 
3891
- if (agent) {
3892
- setInterval(() => {
3893
- if (!pendingCommentsLoad && document.visibilityState === "visible") void loadComments({ force: true });
3894
- }, 2000);
3895
- }
3896
-
3897
3707
  function getPinPoint(pin) {
3898
3708
  return { x: parseFloat(pin.style.left), y: parseFloat(pin.style.top) };
3899
3709
  }
@@ -3960,50 +3770,16 @@ export function initComty(options = {}) {
3960
3770
  }
3961
3771
  }
3962
3772
 
3963
- function syncAgentWorkingOutlines() {
3964
- const working = localTasks.filter(task => task.status === "in_progress" && task.page_path === window.location.pathname);
3965
- const ids = new Set(working.map(task => String(task.id)));
3966
- for (const [id, outline] of workingOutlines) {
3967
- if (ids.has(id)) continue;
3968
- outline.remove(); workingOutlines.delete(id);
3969
- }
3970
- const modal = getTopActiveModal();
3971
- for (const task of working) {
3972
- const id = String(task.id);
3973
- let outline = workingOutlines.get(id);
3974
- if (!outline) {
3975
- outline = document.createElement("div");
3976
- outline.className = "comty-agent-working";
3977
- outline.setAttribute("aria-label", "Agent working on this element");
3978
- const badge = document.createElement("span");
3979
- badge.className = "comty-agent-working-badge";
3980
- badge.append(createWorkingIndicator(), "Working");
3981
- outline.appendChild(badge); root.appendChild(outline);
3982
- workingOutlines.set(id, outline);
3983
- }
3984
- const target = resolveCommentElement(task);
3985
- const point = target && getVisibleAnchorPoint(target, Number(task.rel_x), Number(task.rel_y), modal);
3986
- outline.hidden = !point;
3987
- if (!point) continue;
3988
- const rect = target.getBoundingClientRect();
3989
- setMeasuredRect(outline, rect);
3990
- outline.toggleAttribute("data-badge-below", rect.top < 32);
3991
- }
3992
- }
3993
-
3994
3773
  function onFrame() {
3995
3774
  if (motion.enabled) updateMotion();
3996
3775
  if (session.comments) syncCommentAnchors();
3997
- if (session.agent || workingOutlines.size || localTasks.some(task => task.status === "in_progress" && task.page_path === window.location.pathname)) {
3998
- syncAgentWorkingOutlines();
3999
- }
4000
3776
  if (session.inspect || session.edit || inspectionLayer.childNodes.length) renderInspector();
4001
3777
  if (session.inspect && rulersEnabled) renderRulers();
4002
3778
  if (session.inspect && rulersEnabled) measurements.render();
4003
3779
  }
4004
3780
 
4005
3781
  function ensureLiveTracking() {
4006
- const active = motion.enabled || session.inspect || session.edit || (session.comments && cachedComments.length > 0) || workingOutlines.size > 0;
3782
+ const active = motion.enabled || session.inspect || session.edit || (session.comments && cachedComments.length > 0);
4007
3783
  if (!active || document.hidden) {
4008
3784
  if (liveFrame) cancelAnimationFrame(liveFrame);
4009
3785
  liveFrame = null;
@@ -4023,14 +3799,13 @@ export function initComty(options = {}) {
4023
3799
  syncOverlayHost();
4024
3800
 
4025
3801
  function renderCachedComments() {
4026
- if (!session.comments && !session.inspect && !session.edit && !workingOutlines.size) return;
3802
+ if (!session.comments && !session.inspect && !session.edit) return;
4027
3803
  if (resizeRenderFrame) {
4028
3804
  window.cancelAnimationFrame(resizeRenderFrame);
4029
3805
  }
4030
3806
  resizeRenderFrame = window.requestAnimationFrame(() => {
4031
3807
  resizeRenderFrame = null;
4032
3808
  syncCommentAnchors();
4033
- syncAgentWorkingOutlines();
4034
3809
  renderInspector();
4035
3810
  ensureLiveTracking();
4036
3811
  });
@@ -4118,7 +3893,6 @@ export function initComty(options = {}) {
4118
3893
  hideInspector();
4119
3894
  return;
4120
3895
  }
4121
- if (inspectedElement !== target) inspectExpanded = selectedElement ? inspectExpanded : false;
4122
3896
  showInspector(target);
4123
3897
  }, true);
4124
3898
  document.documentElement.addEventListener("pointerleave", () => {
@@ -4175,7 +3949,6 @@ export function initComty(options = {}) {
4175
3949
  }
4176
3950
  measuredRegion = null;
4177
3951
  selectedElement = getAnchorElement(target);
4178
- inspectExpanded = false;
4179
3952
  showInspector(target);
4180
3953
  return;
4181
3954
  }
@@ -4211,12 +3984,11 @@ export function initComty(options = {}) {
4211
3984
  const y = e.clientY;
4212
3985
 
4213
3986
  const anchorContext = getAnchorContext(el);
4214
- if (session.agent && anchorContext) anchorContext.inspectedStyles = getInspectRows(el);
4215
3987
 
4216
3988
  const payload = {
4217
3989
  ...getDeviceContext(),
4218
3990
  projectId,
4219
- kind: session.agent ? "agent" : "comment",
3991
+ kind: "comment",
4220
3992
  host: window.location.host,
4221
3993
  pagePath: window.location.pathname,
4222
3994
  selector: getSelector(el),
@@ -4316,7 +4088,6 @@ export function initComty(options = {}) {
4316
4088
  window.addEventListener("blur", () => { measureAlt = false; renderInspector(); });
4317
4089
 
4318
4090
  async function boot() {
4319
- if (localOnly) { await loadComments(); return; }
4320
4091
  const res = await fetch(`${apiUrl}/api/sdk/bootstrap`, {
4321
4092
  method: "POST",
4322
4093
  headers: getSdkHeaders({ "Content-Type": "application/json" }),
@@ -4329,20 +4100,32 @@ export function initComty(options = {}) {
4329
4100
 
4330
4101
  if (!res.ok) {
4331
4102
  console.error("Comty: bootstrap failed", await res.text());
4332
- if (agent) await loadComments();
4333
4103
  return;
4334
4104
  }
4335
4105
 
4336
4106
  const data = await res.json();
4337
4107
  projectId = data.projectId;
4338
4108
  canWrite = Boolean(data.canWrite);
4109
+ canManage = Boolean(data.canManage ?? data.canWrite);
4110
+ allowGuestComments = Boolean(data.allowGuestComments);
4339
4111
  currentUser = data.user || null;
4340
4112
  loginUrl = data.loginUrl || "";
4341
4113
 
4342
4114
  if (!canWrite && sdkToken) {
4343
- sessionStorage.removeItem(tokenStorageKey);
4115
+ try { sessionStorage.removeItem(tokenStorageKey); } catch { /* Storage may be unavailable. */ }
4344
4116
  sdkToken = "";
4345
4117
  }
4118
+ if (didCompleteSignIn || authStatus === "forbidden") {
4119
+ let previousSession;
4120
+ try {
4121
+ previousSession = JSON.parse(sessionStorage.getItem(authReturnStorageKey) || "null");
4122
+ sessionStorage.removeItem(authReturnStorageKey);
4123
+ } catch { /* Fall back to the requested comment mode. */ }
4124
+ const mode = canWrite && ["browse", "edit", "inspect"].includes(previousSession?.mode)
4125
+ ? previousSession.mode : canWrite ? "edit" : "browse";
4126
+ setSession(mode, { entry: previousSession?.entry || mode });
4127
+ if (previousSession?.sidepanelOpen) setSidepanelOpen(true);
4128
+ }
4346
4129
  if (authStatus === "forbidden") showAuthPrompt(true);
4347
4130
  if (canWrite && didCompleteSignIn) {
4348
4131
  const toast = document.createElement("div");
@@ -4365,34 +4148,19 @@ export function initComty(options = {}) {
4365
4148
  getDistanceSegments, getInspectRows, syncCommentAnchors,
4366
4149
  setInspectorMode(enabled) { setSession(enabled ? "inspect" : "off"); },
4367
4150
  startEditSession, startBrowseSession, endSession, showInspector,
4368
- motion, setMotionEnabled, updateMotion, describeAgentError,
4369
- previewAgent(tasks, approvals = [], error = "") {
4370
- localTasks = tasks;
4371
- localApprovals = approvals;
4372
- agentConnectionError = error;
4373
- startAgentSession();
4374
- agentPanelOpen = true;
4375
- agentPanelSignature = "";
4376
- renderAgentPanel();
4377
- },
4151
+ motion, setMotionEnabled, updateMotion,
4378
4152
  seed(comments) {
4379
4153
  reconcileComments(comments);
4380
4154
  setSession(session.comments ? session.mode : "browse", { entry: sessionEntry, renderComments: true });
4381
4155
  return renderComments(cachedComments, { retry: false });
4382
4156
  },
4383
- seedAgentTasks(tasks) {
4384
- localTasks = tasks;
4385
- syncAgentWorkingOutlines();
4386
- ensureLiveTracking();
4387
- },
4388
- setCanWrite(value) { canWrite = value; },
4157
+ setCanWrite(value) { canWrite = value; canManage = value; },
4389
4158
  invalidate() { anchorRevision += 1; },
4390
4159
  });
4391
4160
  }
4392
4161
 
4393
4162
  return boot().catch(error => {
4394
4163
  console.error("Comty: bootstrap failed", error);
4395
- if (agent) return loadComments();
4396
4164
  });
4397
4165
  }
4398
4166
  }