@comty.io/core 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comty.io/core",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Comty visual comments, inspector and local agent tools",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -34,7 +34,7 @@
34
34
  "check:build": "node scripts/build.mjs --check",
35
35
  "test": "node --experimental-websocket --test tests/measurements.test.mjs tests/anchors.test.mjs tests/package.test.mjs tests/packed.test.mjs tests/companion.test.mjs tests/codex.test.mjs tests/claude.test.mjs tests/cursor.test.mjs tests/acp.test.mjs",
36
36
  "test:capture": "node --test tests/capture.browser.test.mjs",
37
- "test:inspector": "node --test tests/inspector.browser.test.mjs",
37
+ "test:inspector": "node --test tests/inspector.browser.test.mjs tests/inspect-markup.browser.test.mjs tests/idle-network.browser.test.mjs",
38
38
  "test:placement": "node --test tests/placement.browser.test.mjs",
39
39
  "test:anchors": "node --test tests/anchors.test.mjs tests/anchor-reload.browser.test.mjs",
40
40
  "test:vite": "node --test tests/vite.test.mjs",
package/src/browser.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createInspectMarkup } from "./inspect-markup.js";
1
2
  import { getComtyStyle } from "./style.js";
2
3
  import { createAnchorMatcher } from "./anchors.js";
3
4
  import { createScreenshotCapture, createColorMagnifier, captureContext } from "./capture.js";
@@ -56,12 +57,14 @@ export function initComty(options = {}) {
56
57
  const deliveringAgentReplies = new Set();
57
58
  let localApprovals = [];
58
59
  const seenAgentApprovals = new Set();
59
- const seenFailedAgentTasks = new Set();
60
+ // Only requests made in this document may interrupt it with agent notifications.
61
+ const requestedAgentTasks = new Set();
60
62
  let agentPanelOpen = false;
61
63
  let agentPanelSignature = "";
62
64
  let agentPanelTransition = 0;
63
65
  let agentConnectionError = "";
64
66
  let agentActionError = "";
67
+ let clearAgentToast = () => {};
65
68
  const agentPendingActions = new Set();
66
69
  const session = {
67
70
  mode: "off",
@@ -194,6 +197,9 @@ export function initComty(options = {}) {
194
197
  const agentBtn = isLocalSite
195
198
  ? toolButton("Agent task · localhost only", '<path d="M8 7 3 12l5 5m8-10 5 5-5 5M14 4l-4 16"/>', () => {
196
199
  if (session.agent) {
200
+ const failedTask = [...localTasks].reverse().find(task => task.status === 'in_review' && !task.thread_comment_id);
201
+ const failedPin = failedTask && placedPins.get(String(failedTask.id));
202
+ if (failedPin && !failedPin.hasAttribute('data-comty-anchor-hidden')) { failedPin.click(); return; }
197
203
  if (localTasks.some(task => task.status === 'in_review' || (!task.thread_comment_id && task.status === 'done')) || localApprovals.length) {
198
204
  agentPanelOpen = true;
199
205
  renderAgentPanel();
@@ -201,6 +207,10 @@ export function initComty(options = {}) {
201
207
  } else startAgentSession();
202
208
  })
203
209
  : null;
210
+ if (agentBtn) {
211
+ agentBtn.title = "Agent · Shift A · localhost only";
212
+ agentBtn.setAttribute("aria-keyshortcuts", "Shift+A");
213
+ }
204
214
  const divider = document.createElement("span");
205
215
  divider.className = "comty-tool-divider";
206
216
  toolbar.appendChild(divider);
@@ -240,6 +250,7 @@ export function initComty(options = {}) {
240
250
  root,
241
251
  isComtyElement,
242
252
  isElementTreeHidden,
253
+ isInteractionBlocked: () => Boolean(markup.mode),
243
254
  onExit() { rulersEnabled = false; syncInspectorTools(); },
244
255
  });
245
256
  const gridLayer = document.createElement("div");
@@ -266,6 +277,7 @@ export function initComty(options = {}) {
266
277
  syncInspectorTools();
267
278
  });
268
279
  const rulersBtn = toolButton("Rulers · R", '<path d="M3 3h18v6H9v12H3Z"/><path d="M7 3v3m4-3v3m4-3v3M3 11h3m-3 4h3m-3 4h3"/>', () => {
280
+ markup.setMode(null);
269
281
  closePropertyPanel();
270
282
  if (motion.enabled) setMotionEnabled(false);
271
283
  rulersEnabled = !rulersEnabled;
@@ -276,6 +288,7 @@ export function initComty(options = {}) {
276
288
  syncInspectorTools();
277
289
  });
278
290
  const gridBtn = toolButton("Layout guides · G", '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 10h18M10 3v18"/>', () => {
291
+ markup.setMode(null);
279
292
  closePropertyPanel();
280
293
  if (motion.enabled) setMotionEnabled(false);
281
294
  gridPanelOpen = !gridPanelOpen;
@@ -293,7 +306,37 @@ export function initComty(options = {}) {
293
306
  }
294
307
  const motionBtn = toolButton("Motion · M", '<circle cx="12" cy="14" r="8"/><path d="M9 2h6m-3 4V2m6 5 2-2m-8 5v4l3 2"/>', () => setMotionEnabled(!motion.enabled));
295
308
  motionBtn.setAttribute("aria-expanded", "false");
296
- for (const button of [xrayBtn, rulersBtn, gridBtn, pipetteBtn, typographyBtn, motionBtn]) inspectorTools.appendChild(button);
309
+ const annotateBtn = toolButton("Annotate · D", '<path d="m16 3 5 5L8 21H3v-5ZM14 5l5 5"/>', () => toggleMarkup("annotate"));
310
+ const screenshotBtn = toolButton("Capture area · S", '<path d="M8 3H5a2 2 0 0 0-2 2v3m13-5h3a2 2 0 0 1 2 2v3M3 16v3a2 2 0 0 0 2 2h3m13-5v3a2 2 0 0 1-2 2h-3"/><rect x="7" y="8" width="10" height="8" rx="1"/><circle cx="12" cy="12" r="2"/>', () => toggleMarkup("capture"));
311
+ const markup = createInspectMarkup({
312
+ root, icon,
313
+ onError: description => showNotice({ title: "Screenshot unavailable", description }),
314
+ onChange() {
315
+ annotateBtn.setAttribute("aria-pressed", String(markup.mode === "annotate"));
316
+ annotateBtn.setAttribute("aria-expanded", String(markup.mode === "annotate"));
317
+ screenshotBtn.setAttribute("aria-pressed", String(markup.mode === "capture"));
318
+ screenshotBtn.setAttribute("aria-busy", String(markup.busy));
319
+ positionToolbar();
320
+ },
321
+ capture: (region, layer) => captureViewportScreenshot({ region, annotation: () => ({
322
+ elements: [overlayTools, root.querySelector('.comty-distance-layer'), layer].filter(node => node && !node.hidden),
323
+ dispose() {},
324
+ }) }),
325
+ });
326
+ for (const button of [annotateBtn]) {
327
+ button.setAttribute("aria-controls", markup.panel.id);
328
+ button.setAttribute("aria-expanded", "false");
329
+ }
330
+ function toggleMarkup(mode) {
331
+ const next = markup.mode === mode ? null : mode;
332
+ closePropertyPanel();
333
+ if (motion.enabled) setMotionEnabled(false);
334
+ gridPanelOpen = false; toolPanel.hidden = true; gridBtn.setAttribute("aria-expanded", "false");
335
+ cancelRulerDrag();
336
+ selectedElement = null; measuredRegion = null; inspectDrag = null; hideInspector();
337
+ markup.setMode(next);
338
+ }
339
+ for (const button of [xrayBtn, rulersBtn, gridBtn, pipetteBtn, typographyBtn, motionBtn, annotateBtn, screenshotBtn]) inspectorTools.appendChild(button);
297
340
 
298
341
  const motion = { enabled: false, speed: 1, paused: false, managed: new Map(), shadowRoots: [], nextRootScan: 0 };
299
342
  const motionPanel = document.createElement('section');
@@ -342,6 +385,7 @@ export function initComty(options = {}) {
342
385
  motion.enabled = enabled; motionPanel.hidden = !enabled;
343
386
  motionBtn.setAttribute('aria-pressed', String(enabled)); motionBtn.setAttribute('aria-expanded', String(enabled));
344
387
  if (enabled) {
388
+ markup.setMode(null);
345
389
  closePropertyPanel();
346
390
  rulersEnabled = false;
347
391
  syncInspectorTools();
@@ -600,6 +644,7 @@ export function initComty(options = {}) {
600
644
  const visible = session.inspect;
601
645
  measurements.setEnabled(visible && rulersEnabled && !motion.enabled);
602
646
  overlayTools.hidden = !visible;
647
+ markup.setVisible(visible);
603
648
  xrayBtn.setAttribute("aria-pressed", String(visible && xrayEnabled));
604
649
  rulersBtn.setAttribute("aria-pressed", String(visible && rulersEnabled));
605
650
  gridBtn.setAttribute("aria-pressed", String(visible && layoutGuides.some(guide => guide.visible)));
@@ -883,6 +928,7 @@ export function initComty(options = {}) {
883
928
  }
884
929
 
885
930
  function openPropertyPanel(kind, element, property, opener = null, sampledColor = null) {
931
+ markup.setMode(null);
886
932
  propertyPicking = null;
887
933
  colorMagnifier.setEnabled(false);
888
934
  delete document.documentElement.dataset.comtyPropertyPicking;
@@ -1140,7 +1186,7 @@ export function initComty(options = {}) {
1140
1186
  });
1141
1187
  }
1142
1188
  const rect = toolbar.getBoundingClientRect();
1143
- for (const panel of [toolPanel, propertyPanel, sidepanel, motionPanel]) {
1189
+ for (const panel of [toolPanel, propertyPanel, sidepanel, motionPanel, markup.panel]) {
1144
1190
  if (panel.hidden || (panel === sidepanel && !sidepanelOpen)) continue;
1145
1191
  const above = rect.top - 16;
1146
1192
  const below = innerHeight - rect.bottom - 16;
@@ -1157,9 +1203,10 @@ export function initComty(options = {}) {
1157
1203
  panel.style.top = `${useAbove ? rect.top - panel.offsetHeight - 8 : rect.bottom + 8}px`;
1158
1204
  panel.style.bottom = "auto";
1159
1205
  }
1206
+ positionNotice();
1160
1207
  }
1161
1208
  const toolbarResize = new ResizeObserver(positionToolbar);
1162
- [toolbar, toolPanel, propertyPanel, sidepanel, motionPanel].forEach(node => toolbarResize.observe(node));
1209
+ [toolbar, toolPanel, propertyPanel, sidepanel, motionPanel, markup.panel].forEach(node => toolbarResize.observe(node));
1163
1210
  window.addEventListener("resize", positionToolbar);
1164
1211
 
1165
1212
  toolbar.addEventListener("pointerdown", event => {
@@ -1494,7 +1541,7 @@ export function initComty(options = {}) {
1494
1541
 
1495
1542
  function showAuthPrompt(forbidden = false, onReady = startEditSession) {
1496
1543
  if (localOnly) {
1497
- showAgentBridgeError("Shared comments need a Comty project key. Use the Agent tool for local tasks without signing in.");
1544
+ showNotice({ title: "Comments are not configured", description: "Connect this site to a Comty project to leave shared comments." });
1498
1545
  return;
1499
1546
  }
1500
1547
  const guestAllowed = allowGuestComments;
@@ -1628,6 +1675,7 @@ export function initComty(options = {}) {
1628
1675
  inspectorTools.hidden = !session.inspect;
1629
1676
  syncInspectorTools();
1630
1677
  renderAgentPanel();
1678
+ positionNotice();
1631
1679
  }
1632
1680
 
1633
1681
  const inspectionLayer = document.createElement("div");
@@ -1940,7 +1988,7 @@ export function initComty(options = {}) {
1940
1988
  });
1941
1989
  return;
1942
1990
  }
1943
- if (propertyPicking || motion.enabled || (session.inspect && rulersEnabled)) {
1991
+ if (markup.mode || propertyPicking || motion.enabled || (session.inspect && rulersEnabled)) {
1944
1992
  if (inspectionLayer.childNodes.length) hideInspector();
1945
1993
  return;
1946
1994
  }
@@ -2084,10 +2132,12 @@ export function initComty(options = {}) {
2084
2132
 
2085
2133
  function startBrowseSession() {
2086
2134
  setSession("browse", { renderComments: true });
2135
+ void loadComments({ force: true });
2087
2136
  }
2088
2137
 
2089
2138
  function startEditSession() {
2090
2139
  setSession("edit", { renderComments: true });
2140
+ void loadComments({ force: true });
2091
2141
  }
2092
2142
 
2093
2143
  function startAgentSession() {
@@ -2095,6 +2145,7 @@ export function initComty(options = {}) {
2095
2145
  if (session.agent) return;
2096
2146
  closeBubbles();
2097
2147
  setSession("agent", { renderComments: true });
2148
+ void loadComments({ force: true });
2098
2149
  }
2099
2150
 
2100
2151
  function toggleSidepanel() {
@@ -2832,6 +2883,7 @@ export function initComty(options = {}) {
2832
2883
  function createCommentPin(comment, x, y) {
2833
2884
  const pin = document.createElement("button");
2834
2885
  pin.className = "comty-pin";
2886
+ pin.toggleAttribute('data-agent-task', Boolean(comment.local_agent));
2835
2887
  pin.type = "button";
2836
2888
  pin.style.left = `${x}px`;
2837
2889
  pin.style.top = `${y}px`;
@@ -2849,10 +2901,52 @@ export function initComty(options = {}) {
2849
2901
  const thread = document.createElement("div");
2850
2902
  thread.className = "comty-thread";
2851
2903
  thread.appendChild(createThreadItem(comment, { showMore: false, showLink: false }).item);
2904
+ if (comment.local_agent) {
2905
+ thread.appendChild(createThreadItem({ author_kind: 'agent', content: 'Modification failed. Open to retry or dismiss.' }, { showMore: false }).item);
2906
+ }
2852
2907
  bubble.appendChild(thread);
2853
2908
  return bubble;
2854
2909
  }
2855
2910
 
2911
+ function openAgentTask(task, x, y) {
2912
+ closeBubbles();
2913
+ const bubble = document.createElement('div');
2914
+ bubble.className = 'comty-bubble comty-agent-task-thread';
2915
+ bubble.dataset.commentId = task.id;
2916
+ bubble.setAttribute('role', 'dialog');
2917
+ bubble.setAttribute('aria-label', 'Failed modification');
2918
+ const header = document.createElement('div'); header.className = 'comty-bubble-header';
2919
+ const title = document.createElement('strong'); title.textContent = 'Modification failed';
2920
+ const close = document.createElement('button'); close.type = 'button'; close.className = 'comty-icon-button';
2921
+ close.setAttribute('aria-label', 'Close failed modification');
2922
+ close.innerHTML = icon('<path d="m6 6 12 12M6 18 18 6"/>');
2923
+ close.onclick = () => closeBubbles();
2924
+ header.append(title, close);
2925
+ const thread = document.createElement('div'); thread.className = 'comty-thread';
2926
+ const list = document.createElement('div'); list.className = 'comty-thread-list';
2927
+ const issue = task.error ? describeAgentError(task.error) : null;
2928
+ list.append(
2929
+ createThreadItem(task, { showMore: false }).item,
2930
+ createThreadItem({ author_kind: 'agent', content: task.result?.summary || issue?.message || issue?.description || 'The agent could not complete this request.' }, { showMore: false }).item,
2931
+ );
2932
+ thread.appendChild(list);
2933
+ const actions = document.createElement('div'); actions.className = 'comty-agent-task-actions';
2934
+ for (const action of ['retry', 'dismiss']) {
2935
+ const button = document.createElement('button'); button.type = 'button'; button.className = 'comty-agent-action';
2936
+ button.textContent = action === 'retry' ? 'Retry' : 'Dismiss';
2937
+ button.onclick = async () => {
2938
+ for (const control of actions.children) control.disabled = true;
2939
+ try { await runAgentAction(`tasks/${task.id}/${action}`); }
2940
+ catch (error) { showAgentBridgeError(error.message); }
2941
+ finally { for (const control of actions.children) control.disabled = false; }
2942
+ };
2943
+ actions.appendChild(button);
2944
+ }
2945
+ bubble.append(header, thread, actions);
2946
+ root.appendChild(bubble);
2947
+ positionCommentBubble(bubble, x, y);
2948
+ }
2949
+
2856
2950
  function mountCaptureAnnotation(comment, { x, y }) {
2857
2951
  const host = document.createElement("div");
2858
2952
  host.dataset.comtyCaptureExclude = "";
@@ -3036,6 +3130,7 @@ export function initComty(options = {}) {
3036
3130
  const id = isAgentTask ? data?.task?.id : data?.id;
3037
3131
  if (!id) throw new Error("Save returned no ID");
3038
3132
  if (isAgentTask) {
3133
+ requestedAgentTasks.add(id);
3039
3134
  void loadComments({ force: true });
3040
3135
  } else {
3041
3136
  cachedComments = cachedComments.filter(item => String(item.id) !== pendingId);
@@ -3167,14 +3262,53 @@ export function initComty(options = {}) {
3167
3262
  function agentHeaders() { return { "Content-Type": "application/json", "X-Comty-Token": agent?.token || "" }; }
3168
3263
 
3169
3264
  function showAgentBridgeError(message = "Start your app with comty dev to use the local agent. Check the terminal if it is already running.") {
3170
- root.querySelectorAll(".comty-agent-task-toast").forEach((node) => node.remove());
3265
+ const issue = describeAgentError(message);
3266
+ showNotice({ title: 'Modification failed', description: issue.title === 'Task could not finish' ? 'The agent could not complete this request.' : issue.title + '.' });
3267
+ }
3268
+
3269
+ function positionNotice() {
3270
+ const toast = root.querySelector(".comty-agent-task-toast");
3271
+ if (!toast) return;
3272
+ toast.style.removeProperty("left");
3273
+ toast.style.removeProperty("top");
3274
+ toast.toggleAttribute("data-toolbar", isSessionActive());
3275
+ if (!isSessionActive()) return;
3276
+ const rect = toolbar.getBoundingClientRect();
3277
+ const above = rect.top - toast.offsetHeight - 8;
3278
+ toast.style.left = `${Math.max(8, Math.min(rect.right - toast.offsetWidth, innerWidth - toast.offsetWidth - 8))}px`;
3279
+ toast.style.top = `${Math.max(8, Math.min(above >= 8 ? above : rect.bottom + 8, innerHeight - toast.offsetHeight - 8))}px`;
3280
+ }
3281
+
3282
+ function showNotice(issue) {
3283
+ clearAgentToast();
3171
3284
  const toast = document.createElement("div");
3172
- toast.className = "comty-auth-toast comty-agent-task-toast";
3285
+ toast.className = "comty-agent-task-toast";
3173
3286
  toast.setAttribute("role", "alert");
3174
- const issue = describeAgentError(message);
3175
- toast.textContent = issue.title + ". " + issue.description;
3287
+ const title = document.createElement("strong"); title.textContent = issue.title;
3288
+ const description = document.createElement("p"); description.textContent = issue.description;
3289
+ const close = document.createElement("button"); close.type = "button";
3290
+ close.setAttribute("aria-label", "Dismiss notification");
3291
+ close.innerHTML = icon('<path d="m6 6 12 12M6 18 18 6"/>');
3292
+ toast.append(title, description, close);
3293
+ let timer, leaving = false;
3294
+ const dismiss = () => {
3295
+ if (leaving) return;
3296
+ leaving = true;
3297
+ clearTimeout(timer);
3298
+ toast.dataset.leaving = "";
3299
+ timer = setTimeout(() => toast.remove(), reducedMotion.matches ? 0 : 200);
3300
+ };
3301
+ const schedule = () => { if (!leaving) { clearTimeout(timer); timer = setTimeout(dismiss, 6000); } };
3302
+ const pause = () => { if (!leaving) clearTimeout(timer); };
3303
+ clearAgentToast = () => { clearTimeout(timer); toast.remove(); };
3304
+ close.onclick = dismiss;
3305
+ toast.onmouseenter = pause;
3306
+ toast.onmouseleave = schedule;
3307
+ toast.onfocusin = pause;
3308
+ toast.onfocusout = schedule;
3176
3309
  root.appendChild(toast);
3177
- setTimeout(() => toast.remove(), 5000);
3310
+ positionNotice();
3311
+ schedule();
3178
3312
  }
3179
3313
 
3180
3314
  function describeAgentError(error) {
@@ -3203,7 +3337,7 @@ export function initComty(options = {}) {
3203
3337
  } else if (/unauthorized|unauthenticated|authentication|not logged|sign.?in|log.?in|invalid.api.key|\b401\b/i.test(source)) {
3204
3338
  title = 'Sign in to your agent'; description = `Open ${agentName} and sign in again, then retry this task.`;
3205
3339
  } else if (/model.*(?:not found|does not exist|unavailable|not supported)|unsupported model|model_not_found/i.test(source)) {
3206
- title = 'Model unavailable'; description = 'Choose a model available to your agent account in the local Comty configuration, then restart and retry.';
3340
+ title = 'Model unavailable'; description = 'The local agent rejected this model. Check its CLI version and signed-in account, then retry.';
3207
3341
  } else if (/disconnected|ECONNREFUSED|ENOTFOUND|network|failed to fetch|development server stopped/i.test(source)) {
3208
3342
  title = 'Connection lost'; description = 'Check that your local development server and coding agent are running, then reload this page.';
3209
3343
  } else if (/timed? ?out|timeout|exceeded.*minutes/i.test(source)) {
@@ -3216,11 +3350,21 @@ export function initComty(options = {}) {
3216
3350
  title = 'Result needs checking'; description = 'The agent could not confirm completion. Review the changes before retrying.';
3217
3351
  } else {
3218
3352
  title = 'Task could not finish';
3219
- description = message && !/[{}]|\[object Object\]/.test(message) ? message.slice(0, 300) : 'The agent reported an unexpected error. Review the technical details and any changes before retrying.';
3353
+ description = message && !/[{}]|\[object Object\]/.test(message) ? message.slice(0, 160) + (message.length > 160 ? '…' : '') : 'The agent reported an unexpected error. Review the technical details and any changes before retrying.';
3220
3354
  }
3221
3355
  return { title, description, raw, message };
3222
3356
  }
3223
3357
 
3358
+ async function runAgentAction(path, input = {}) {
3359
+ const response = await fetch(`${agentBridgeUrl}/${path}`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify(input) });
3360
+ if (!response.ok) {
3361
+ const body = await response.json().catch(() => null);
3362
+ throw new Error(typeof body?.error === 'string' ? body.error : JSON.stringify(body?.error || { status: response.status, message: 'Request failed' }));
3363
+ }
3364
+ if (path.endsWith('/retry')) requestedAgentTasks.add(path.split('/')[1]);
3365
+ await loadComments({ force: true });
3366
+ }
3367
+
3224
3368
  function renderAgentPanel() {
3225
3369
  if (!agent) return;
3226
3370
  const visibleTasks = [];
@@ -3239,6 +3383,7 @@ export function initComty(options = {}) {
3239
3383
  }
3240
3384
  const open = Boolean(agent) && agentPanelOpen;
3241
3385
  if (open) {
3386
+ clearAgentToast();
3242
3387
  if (panel.hidden) {
3243
3388
  const rect = toolbar.getBoundingClientRect();
3244
3389
  panel.dataset.side = rect.left + rect.width / 2 < innerWidth / 2 ? "left" : "right";
@@ -3259,7 +3404,6 @@ export function initComty(options = {}) {
3259
3404
  const signature = JSON.stringify([visibleTasks, localApprovals, agentConnectionError, agentActionError, agentPanelOpen]);
3260
3405
  if (signature === agentPanelSignature) return;
3261
3406
  agentPanelSignature = signature;
3262
- root.querySelector('.comty-agent-error-popover')?.remove();
3263
3407
  const expanded = new Set([...panel.querySelectorAll('details[data-detail-key][open]')].map(node => node.dataset.detailKey));
3264
3408
  const focusKey = panel.contains(document.activeElement) ? document.activeElement.dataset.focusKey : null;
3265
3409
  const scrollTop = panel.querySelector('.comty-agent-panel-content')?.scrollTop || 0;
@@ -3305,12 +3449,7 @@ export function initComty(options = {}) {
3305
3449
  agentActionError = '';
3306
3450
  agentPendingActions.add(key); button.disabled = true; button.textContent = 'Please wait…';
3307
3451
  try {
3308
- const response = await fetch(`${agentBridgeUrl}/${path}`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify(input) });
3309
- if (!response.ok) {
3310
- const body = await response.json().catch(() => null);
3311
- throw new Error(typeof body?.error === 'string' ? body.error : JSON.stringify(body?.error || { status: response.status, message: 'Request failed' }));
3312
- }
3313
- await loadComments({ force: true });
3452
+ await runAgentAction(path, input);
3314
3453
  } catch (error) { agentActionError = error.message; }
3315
3454
  finally { agentPendingActions.delete(key); agentPanelSignature = ''; renderAgentPanel(); }
3316
3455
  };
@@ -3331,9 +3470,7 @@ export function initComty(options = {}) {
3331
3470
  section.appendChild(actions); content.appendChild(section);
3332
3471
  }
3333
3472
  const list = node('div', 'comty-agent-list'); content.appendChild(list);
3334
- const hideErrorPopover = () => root.querySelector('.comty-agent-error-popover')?.remove();
3335
- content.addEventListener('scroll', hideErrorPopover, { passive: true });
3336
- for (const [index, task] of visibleTasks.reverse().entries()) {
3473
+ for (const task of visibleTasks.reverse()) {
3337
3474
  const failed = Boolean(task.error) || task.status === 'in_review';
3338
3475
  const state = task.status === 'done' ? 'success' : failed ? 'error' : 'working';
3339
3476
  const row = node('div', 'comty-agent-row'); row.dataset.taskId = task.id;
@@ -3349,24 +3486,12 @@ export function initComty(options = {}) {
3349
3486
  if (task.status === 'in_review') action(actions, 'Dismiss', `tasks/${task.id}/dismiss`);
3350
3487
  if (actions.childElementCount) row.appendChild(actions);
3351
3488
  row.setAttribute('aria-label', `${task.content}, ${state === 'error' ? 'failed' : state === 'success' ? 'completed' : state === 'cancel' ? 'cancelled' : task.status === 'todo' ? 'starting' : 'working'}`);
3352
- if (failed) {
3353
- const tooltipId = `comty-agent-error-${index}`;
3354
- const showErrorPopover = () => {
3355
- hideErrorPopover();
3356
- const issue = describeAgentError(task.error || task.result?.summary || task.progress || 'Completion was not verified');
3357
- const popover = node('div', 'comty-agent-error-popover'); popover.id = tooltipId; popover.setAttribute('role', 'tooltip');
3358
- popover.append(node('strong', '', task.content), node('p', '', issue.message || issue.description));
3359
- root.appendChild(popover);
3360
- const rowRect = row.getBoundingClientRect();
3361
- const popoverRect = popover.getBoundingClientRect();
3362
- const left = panel.dataset.side === 'right' ? rowRect.left - popoverRect.width - 8 : rowRect.right + 8;
3363
- popover.style.left = `${Math.max(8, Math.min(left, innerWidth - popoverRect.width - 8))}px`;
3364
- popover.style.top = `${Math.max(8, Math.min(rowRect.top - 4, innerHeight - popoverRect.height - 8))}px`;
3365
- };
3366
- row.addEventListener('mouseenter', showErrorPopover);
3367
- row.addEventListener('mouseleave', hideErrorPopover);
3368
- }
3369
3489
  list.appendChild(row);
3490
+ if (task.error) {
3491
+ const details = node('div', 'comty-agent-item');
3492
+ errorMessage(details, task.error, task.id);
3493
+ list.appendChild(details);
3494
+ }
3370
3495
  if (task.result?.summary) {
3371
3496
  const answer = node('div', 'comty-agent-answer');
3372
3497
  answer.append(brand(), node('p', '', task.result.summary));
@@ -3617,7 +3742,7 @@ export function initComty(options = {}) {
3617
3742
  if (!(pin instanceof HTMLElement)) return;
3618
3743
  const status = pin.dataset.status;
3619
3744
  const shouldShow =
3620
- !hasFilters || Boolean(status && selectedStatusFilters.has(status));
3745
+ pin.hasAttribute('data-agent-task') || !hasFilters || Boolean(status && selectedStatusFilters.has(status));
3621
3746
  pin.classList.toggle("comty-filtered-out", !shouldShow);
3622
3747
  });
3623
3748
  }
@@ -3896,6 +4021,10 @@ export function initComty(options = {}) {
3896
4021
  thread: { commentId: String(comment.id), transcript } }),
3897
4022
  });
3898
4023
  if (!response.ok) throw new Error((await response.json().catch(() => null))?.error || 'Could not start Agent');
4024
+ const data = await response.json();
4025
+ if (!data.task?.id) throw new Error('Agent returned no task ID');
4026
+ requestedAgentTasks.add(data.task.id);
4027
+ void loadComments({ force: true });
3899
4028
  },
3900
4029
  get canWrite() { return canWrite; },
3901
4030
  get canManage() { return canManage; },
@@ -4011,6 +4140,7 @@ export function initComty(options = {}) {
4011
4140
 
4012
4141
  pin.onclick = (event) => {
4013
4142
  event.stopPropagation();
4143
+ if (comment.local_agent) { openAgentTask(comment, getPinPoint(pin).x, getPinPoint(pin).y); return; }
4014
4144
  openCommentThread(
4015
4145
  comment,
4016
4146
  pin,
@@ -4135,7 +4265,7 @@ export function initComty(options = {}) {
4135
4265
  }
4136
4266
  }
4137
4267
 
4138
- async function loadComments({ force = false } = {}) {
4268
+ async function loadComments({ force = false, tasksOnly = false } = {}) {
4139
4269
  if (!projectId && !agent) return;
4140
4270
  const pagePath = getPagePath();
4141
4271
  if (!force && cachedPagePath === pagePath) { await renderComments(cachedComments); return; }
@@ -4145,7 +4275,7 @@ export function initComty(options = {}) {
4145
4275
  }
4146
4276
  pendingCommentsLoad = (async () => {
4147
4277
  let comments = cachedPagePath === pagePath ? cachedComments.filter(comment => !comment.local_agent) : [];
4148
- if (projectId) {
4278
+ if (projectId && !tasksOnly) {
4149
4279
  try {
4150
4280
  const paths = [...new Set([pagePath, window.location.pathname])];
4151
4281
  const batches = await Promise.all(paths.map(async path => {
@@ -4166,15 +4296,20 @@ export function initComty(options = {}) {
4166
4296
  const data = await res.json();
4167
4297
  if (Array.isArray(data.tasks)) localTasks = data.tasks;
4168
4298
  const failedTasks = localTasks.filter(task => task.status === 'in_review');
4169
- if (failedTasks.some(task => !seenFailedAgentTasks.has(task.id))) agentPanelOpen = true;
4170
- seenFailedAgentTasks.clear();
4171
- for (const task of failedTasks) seenFailedAgentTasks.add(task.id);
4299
+ for (const task of failedTasks.filter(task => requestedAgentTasks.has(task.id))) {
4300
+ if (task.error) showAgentBridgeError(task.error);
4301
+ else showNotice({ title: 'Modification failed', description: 'The agent needs clarification.' });
4302
+ }
4172
4303
  if (Array.isArray(data.approvals)) {
4173
4304
  localApprovals = data.approvals;
4174
- if (localApprovals.some(approval => !seenAgentApprovals.has(approval.id))) agentPanelOpen = true;
4305
+ if (localApprovals.some(approval => requestedAgentTasks.has(approval.taskId) && !seenAgentApprovals.has(approval.id))) agentPanelOpen = true;
4175
4306
  seenAgentApprovals.clear();
4176
4307
  for (const approval of localApprovals) seenAgentApprovals.add(approval.id);
4177
4308
  }
4309
+ for (const id of requestedAgentTasks) {
4310
+ const task = localTasks.find(task => task.id === id);
4311
+ if (!task || ['done', 'cancel', 'in_review'].includes(task.status)) requestedAgentTasks.delete(id);
4312
+ }
4178
4313
  agentConnectionError = "";
4179
4314
  } catch (error) {
4180
4315
  agentConnectionError = "Local agent disconnected. Check the development server, then reload this page.";
@@ -4183,7 +4318,7 @@ export function initComty(options = {}) {
4183
4318
  for (const task of localTasks.filter(task => task.thread_comment_id && task.status === 'done' && task.result?.summary)) {
4184
4319
  void deliverThreadReply(task);
4185
4320
  }
4186
- comments.push(...localTasks.filter(task => task.page_path === pagePath && !task.thread_comment_id && !["done", "cancel"].includes(task.status)));
4321
+ comments.push(...localTasks.filter(task => task.page_path === pagePath && !task.thread_comment_id && task.status === 'in_review'));
4187
4322
  renderAgentPanel();
4188
4323
  }
4189
4324
  if (pagePath !== getPagePath()) return;
@@ -4199,8 +4334,13 @@ export function initComty(options = {}) {
4199
4334
 
4200
4335
  if (agent) {
4201
4336
  setInterval(() => {
4202
- if (!pendingCommentsLoad && document.visibilityState === "visible") void loadComments({ force: true });
4337
+ const active = requestedAgentTasks.size || localTasks.some(task =>
4338
+ ['todo', 'in_progress'].includes(task.status) || (task.thread_comment_id && task.status === 'done' && task.result?.summary));
4339
+ if (active && !pendingCommentsLoad && document.visibilityState === "visible") void loadComments({ force: true, tasksOnly: true });
4203
4340
  }, 2000);
4341
+ document.addEventListener('visibilitychange', () => {
4342
+ if (document.visibilityState === 'visible' && !pendingCommentsLoad) void loadComments({ force: true, tasksOnly: true });
4343
+ });
4204
4344
  }
4205
4345
 
4206
4346
  function getPinPoint(pin) {
@@ -4390,6 +4530,8 @@ export function initComty(options = {}) {
4390
4530
  const next = getPagePath();
4391
4531
  if (next === activePagePath) return;
4392
4532
  activePagePath = next;
4533
+ markup.clear();
4534
+ markup.setMode(null);
4393
4535
  closeBubbles();
4394
4536
  commentTargets.clear();
4395
4537
  reconcileComments([]);
@@ -4426,7 +4568,7 @@ export function initComty(options = {}) {
4426
4568
 
4427
4569
  document.addEventListener("pointermove", (event) => {
4428
4570
  if (!session.edit && !session.inspect) return;
4429
- if (motion.enabled) return;
4571
+ if (motion.enabled || markup.mode) return;
4430
4572
  if (propertyPicking) {
4431
4573
  const target = event.target;
4432
4574
  if (propertyPicking === "font" && target instanceof Element && !isComtyElement(target) && getElementOwnTextSignature(target)) {
@@ -4573,6 +4715,12 @@ export function initComty(options = {}) {
4573
4715
  (target.isContentEditable ||
4574
4716
  ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName));
4575
4717
 
4718
+ const markupMode = markup.mode;
4719
+ if (session.inspect && (e.key === "Escape" || !isEditable) && markup.keydown(e)) {
4720
+ e.preventDefault();
4721
+ if (!markup.mode) (markupMode === "annotate" ? annotateBtn : screenshotBtn).focus({ preventScroll: true });
4722
+ return;
4723
+ }
4576
4724
  if (e.key === "Escape" && motion.enabled) {
4577
4725
  e.preventDefault();
4578
4726
  setMotionEnabled(false); motionBtn.focus({ preventScroll: true });
@@ -4587,7 +4735,7 @@ export function initComty(options = {}) {
4587
4735
  return;
4588
4736
  }
4589
4737
  if (session.inspect && !isEditable && !e.repeat && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
4590
- const button = { x: xrayBtn, r: rulersBtn, g: gridBtn, p: pipetteBtn, t: typographyBtn, m: motionBtn }[e.key.toLowerCase()];
4738
+ const button = { x: xrayBtn, r: rulersBtn, g: gridBtn, p: pipetteBtn, t: typographyBtn, m: motionBtn, d: annotateBtn, s: screenshotBtn }[e.key.toLowerCase()];
4591
4739
  if (button) { e.preventDefault(); button.click(); return; }
4592
4740
  }
4593
4741
 
@@ -4595,6 +4743,11 @@ export function initComty(options = {}) {
4595
4743
  measureAlt = true;
4596
4744
  renderInspector();
4597
4745
  }
4746
+ if (isLocalSite && !isEditable && !e.repeat && !e.isComposing && e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey && e.key.toLowerCase() === "a") {
4747
+ e.preventDefault();
4748
+ startAgentSession();
4749
+ return;
4750
+ }
4598
4751
  if (!isEditable && !e.repeat && e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey && e.key.toLowerCase() === "i") {
4599
4752
  e.preventDefault();
4600
4753
  setSession(session.inspect ? "off" : "inspect");
package/src/capture.js CHANGED
@@ -20,8 +20,10 @@ export function createScreenshotCapture({ apiUrl, projectKey, getHeaders = () =>
20
20
  finally { clearTimeout(timer); }
21
21
  }
22
22
 
23
- async function captureViewportScreenshot({ context = captureContext(), annotation } = {}) {
23
+ async function captureViewportScreenshot({ context = captureContext(), annotation, region } = {}) {
24
24
  if (document.visibilityState === "hidden") return null;
25
+ const area = region || { left: 0, top: 0, width: context.width, height: context.height };
26
+ if (![area.left, area.top, area.width, area.height].every(Number.isFinite) || area.left < 0 || area.top < 0 || area.width <= 0 || area.height <= 0 || area.left + area.width > context.width || area.top + area.height > context.height) throw new Error("Invalid capture area");
25
27
  const controller = new AbortController();
26
28
  const timeout = setTimeout(() => controller.abort(), 15000);
27
29
  const { signal } = controller;
@@ -35,7 +37,7 @@ export function createScreenshotCapture({ apiUrl, projectKey, getHeaders = () =>
35
37
  let lastYield = performance.now();
36
38
  const checkContext = () => {
37
39
  signal.throwIfAborted();
38
- if (!frozen && (context.url !== location.href || context.width !== innerWidth || context.height !== innerHeight || context.scrollX !== scrollX || context.scrollY !== scrollY)) {
40
+ if ((!frozen || region) && (context.url !== location.href || context.width !== innerWidth || context.height !== innerHeight || context.scrollX !== scrollX || context.scrollY !== scrollY)) {
39
41
  throw new Error("Page moved before its screenshot was ready");
40
42
  }
41
43
  };
@@ -234,21 +236,21 @@ export function createScreenshotCapture({ apiUrl, projectKey, getHeaders = () =>
234
236
  image.src = svg;
235
237
  await untilAborted(image.decode());
236
238
  checkContext();
237
- const scale = Math.min(2, devicePixelRatio || 1, 2560 / context.width, 2560 / context.height);
239
+ const scale = Math.min(2, devicePixelRatio || 1, 2560 / area.width, 2560 / area.height);
238
240
  const canvas = document.createElement("canvas");
239
- canvas.width = Math.round(context.width * scale);
240
- canvas.height = Math.round(context.height * scale);
241
+ canvas.width = Math.max(1, Math.round(area.width * scale));
242
+ canvas.height = Math.max(1, Math.round(area.height * scale));
241
243
  const ctx = canvas.getContext("2d");
242
244
  ctx.scale(scale, scale);
243
- ctx.drawImage(image, 0, 0);
245
+ ctx.drawImage(image, -area.left, -area.top);
244
246
  let blob = await untilAborted(new Promise(resolve => canvas.toBlob(resolve, "image/png")));
245
247
  if (!blob) throw new Error("Screenshot encoding failed");
246
- if (blob.size > 2800000) {
248
+ if (!region && blob.size > 2800000) {
247
249
  blob = await untilAborted(new Promise(resolve => canvas.toBlob(resolve, "image/jpeg", 0.9)));
248
250
  if (!blob) throw new Error("Screenshot encoding failed");
249
251
  }
250
252
  // CSS dimensions keep the eyedropper's coordinate system independent of DPR.
251
- return { blob, width: canvas.width, height: canvas.height, cssWidth: context.width, cssHeight: context.height };
253
+ return { blob, width: canvas.width, height: canvas.height, cssWidth: area.width, cssHeight: area.height };
252
254
  } finally {
253
255
  clearTimeout(timeout);
254
256
  controller.abort();
package/src/claude.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomBytes, timingSafeEqual } from 'node:crypto';
3
3
  import { createServer } from 'node:http';
4
- import { existsSync, realpathSync } from 'node:fs';
4
+ import { realpathSync } from 'node:fs';
5
5
  import { mkdtemp, rm, writeFile } from 'node:fs/promises';
6
6
  import { tmpdir } from 'node:os';
7
- import { join, resolve } from 'node:path';
7
+ import { join } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { parseReceipt, receiptSchema, taskPrompt } from './codex.mjs';
10
10
 
@@ -13,7 +13,6 @@ const permissionScript = fileURLToPath(new URL('./claude-permission.mjs', import
13
13
  export async function runClaude({ cwd, task, signal, approve, progress, model, executable = 'claude', args = [] }) {
14
14
  signal.throwIfAborted();
15
15
  cwd = realpathSync(cwd);
16
- if (executable === 'claude' && existsSync(resolve(cwd, 'node_modules/.bin/claude'))) executable = resolve(cwd, 'node_modules/.bin/claude');
17
16
 
18
17
  const token = randomBytes(32).toString('hex');
19
18
  const sockets = new Set();