opencode-browser-annotation-plugin 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/plugin.js CHANGED
@@ -54,6 +54,12 @@ function formatElement(el) {
54
54
  lines.push(` src: ${el.src}`);
55
55
  if (el.text)
56
56
  lines.push(` text: ${JSON.stringify(truncate(el.text.trim(), 200))}`);
57
+ if (el.componentPath)
58
+ lines.push(` ${el.framework ?? "component"} components: ${el.componentPath}`);
59
+ if (el.landmark)
60
+ lines.push(` nearest region: ${el.landmark}`);
61
+ if (el.ancestors && el.ancestors.length)
62
+ lines.push(` ancestors (nearest first): ${el.ancestors.join(" < ")}`);
57
63
  if (el.selector)
58
64
  lines.push(` css path: ${el.selector}`);
59
65
  const context = [];
@@ -96,7 +102,8 @@ function buildPrompt(annotations) {
96
102
  ...blocks,
97
103
  "",
98
104
  "Guidance:",
99
- "- Locate the code for each element using the most stable identifier available (data-testid, id, name, role, then unique class or text). Treat the CSS path and viewport bounds as weak hints only.",
105
+ "- Locate the code for each element using the most stable identifier available: framework component path first if given, then data-testid, id, name, role, then the ancestor chain and nearest region to disambiguate, then unique class or text. Treat the CSS path and viewport bounds as weak hints only.",
106
+ "- Use the ancestors and nearest region to find the right component/file when the element itself is generic (e.g. a bare button that appears many times).",
100
107
  "- Confirm the element actually exists in this codebase before editing; if you cannot find it, say so instead of guessing.",
101
108
  "- No screenshot is attached; reason from the metadata and the code.",
102
109
  ].join("\n");
@@ -143,30 +150,51 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
143
150
  const port = envPort();
144
151
  let activeSessionID = null;
145
152
  let server = null;
153
+ // Sessions that showed activity in THIS OpenCode run (created, messaged, or
154
+ // status/idle events). OpenCode has no open/closed flag, so this approximates
155
+ // "sessions you're currently working in". Deleted sessions are removed.
156
+ const activeIDs = new Set();
157
+ const RECENT_FALLBACK_MS = 2 * 60 * 60 * 1000; // 2h
158
+ const RECENT_FALLBACK_MAX = 8;
146
159
  const log = (level, message, extra) => {
147
160
  void client.app
148
161
  .log({ body: { service: "browser-annotation", level, message, extra } })
149
162
  .catch(() => { });
150
163
  };
151
- /**
152
- * All sessions live in this one OpenCode process, so the plugin can address
153
- * any of them by id — a single HTTP port/tunnel does not limit targeting.
154
- * Returns top-level sessions (no parentID), most-recently-updated first.
155
- */
156
- async function listSessions() {
164
+ async function allSessions() {
157
165
  try {
158
166
  const res = (await client.session.list({ query: { directory } }));
159
167
  const rows = Array.isArray(res) ? res : Array.isArray(res?.data) ? res.data : [];
160
168
  return rows
161
169
  .filter((s) => s && typeof s.id === "string" && !s.parentID)
162
- .map((s) => ({ id: s.id, title: typeof s.title === "string" ? s.title : s.id, updated: s.time?.updated ?? 0 }))
163
- .sort((a, b) => b.updated - a.updated);
170
+ .map((s) => ({ id: s.id, title: typeof s.title === "string" ? s.title : s.id, updated: s.time?.updated ?? 0 }));
164
171
  }
165
172
  catch (error) {
166
173
  log("warn", `session.list failed: ${error instanceof Error ? error.message : "unknown"}`);
167
174
  return [];
168
175
  }
169
176
  }
177
+ /**
178
+ * The picker list: sessions active in this run (newest first). If none have
179
+ * been observed yet (e.g. right after a restart), fall back to the most
180
+ * recently updated few so the picker is not empty. All live in this one
181
+ * process, so any can be targeted over the single port.
182
+ */
183
+ async function listSessions() {
184
+ const all = await allSessions();
185
+ const byRecent = [...all].sort((a, b) => b.updated - a.updated);
186
+ // Prune ids that no longer exist.
187
+ const existing = new Set(all.map((s) => s.id));
188
+ for (const id of activeIDs)
189
+ if (!existing.has(id))
190
+ activeIDs.delete(id);
191
+ if (activeIDs.size > 0) {
192
+ return byRecent.filter((s) => activeIDs.has(s.id));
193
+ }
194
+ const now = Date.now();
195
+ const recent = byRecent.filter((s) => now - s.updated < RECENT_FALLBACK_MS).slice(0, RECENT_FALLBACK_MAX);
196
+ return recent.length ? recent : byRecent.slice(0, RECENT_FALLBACK_MAX);
197
+ }
170
198
  async function injectPrompt(sessionID, annotations) {
171
199
  try {
172
200
  await client.session.promptAsync({
@@ -269,12 +297,36 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
269
297
  start();
270
298
  return {
271
299
  "chat.message": async (input) => {
272
- if (input?.sessionID)
300
+ if (input?.sessionID) {
273
301
  activeSessionID = input.sessionID;
302
+ activeIDs.add(input.sessionID);
303
+ }
274
304
  },
275
305
  event: async ({ event }) => {
276
- if (event.type === "session.deleted") {
277
- if (event.properties.info.id === activeSessionID)
306
+ if (event.type === "session.created") {
307
+ const id = event.properties.info.id;
308
+ if (id)
309
+ activeIDs.add(id);
310
+ }
311
+ else if (event.type === "session.updated") {
312
+ const id = event.properties.info.id;
313
+ if (id)
314
+ activeIDs.add(id);
315
+ }
316
+ else if (event.type === "session.status") {
317
+ const id = event.properties.sessionID;
318
+ if (id)
319
+ activeIDs.add(id);
320
+ }
321
+ else if (event.type === "session.idle") {
322
+ const id = event.properties.sessionID;
323
+ if (id)
324
+ activeIDs.add(id);
325
+ }
326
+ else if (event.type === "session.deleted") {
327
+ const id = event.properties.info.id;
328
+ activeIDs.delete(id);
329
+ if (id === activeSessionID)
278
330
  activeSessionID = null;
279
331
  }
280
332
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "OpenCode Browser Annotation",
4
- "version": "0.4.0",
4
+ "version": "0.5.1",
5
5
  "description": "Alt+A to annotate an element, Alt+Shift+A for the annotation list. Sends element metadata to your OpenCode agent.",
6
6
  "permissions": [
7
7
  "activeTab",
@@ -139,6 +139,7 @@
139
139
  let sessions = []; // [{ id, title, updated }]
140
140
  let targetSessionID = null; // user-chosen target; null = auto (last active)
141
141
  let autoSessionID = null; // the plugin's last-active session
142
+ let lastStatusState = null; // "good" | "bad" | "checking" | null; avoids flicker
142
143
 
143
144
  // A content script keeps running after its extension is reloaded/updated, but
144
145
  // its chrome.* calls then throw "Extension context invalidated". Guard every
@@ -203,6 +204,25 @@
203
204
  return undefined;
204
205
  }
205
206
 
207
+ // Drop build-generated/hashed class names (CSS Modules, styled-components,
208
+ // Emotion, etc.). They change every build and don't map to source, so they are
209
+ // noise for locating code. Keep human-authored classes like "heading-element".
210
+ function isHashedClass(c) {
211
+ if (!c) return true;
212
+ if (/module__/i.test(c)) return true; // CSS Modules: Foo-module__Bar__hHXUL
213
+ if (/__[A-Za-z0-9]{4,}$/.test(c)) return true; // trailing __hash
214
+ if (/^css-[a-z0-9]{5,}$/i.test(c)) return true; // Emotion: css-1ab2c3
215
+ if (/^sc-[A-Za-z0-9]{5,}$/.test(c)) return true; // styled-components: sc-bdVaJa
216
+ if (/^[A-Za-z0-9]{7,}$/.test(c) && /[0-9]/.test(c) && /[A-Z]/.test(c)) return true; // mixed-case+digit opaque hash
217
+ return false;
218
+ }
219
+
220
+ function cleanClasses(list) {
221
+ if (!list || !list.length) return undefined;
222
+ const kept = list.filter((c) => !isHashedClass(c));
223
+ return kept.length ? kept : undefined;
224
+ }
225
+
206
226
  function cssPath(el) {
207
227
  if (!(el instanceof Element)) return "";
208
228
  if (el.id) return `#${CSS.escape(el.id)}`;
@@ -228,9 +248,99 @@
228
248
  return parts.join(" > ");
229
249
  }
230
250
 
251
+ // A compact one-line signature of an element: tag + the most locating bits.
252
+ function elemSignature(el) {
253
+ if (!(el instanceof Element)) return "";
254
+ let s = el.tagName.toLowerCase();
255
+ const tid = bestTestId(el);
256
+ if (tid) s += `[data-testid=${tid}]`;
257
+ else if (el.id) s += `#${el.id}`;
258
+ const role = el.getAttribute && el.getAttribute("role");
259
+ if (role) s += `[role=${role}]`;
260
+ const cls = cleanClasses(el.classList ? Array.from(el.classList) : []);
261
+ if (cls) s += "." + cls.slice(0, 3).join(".");
262
+ return s;
263
+ }
264
+
265
+ // Ancestor chain from the parent up to (but excluding) <body>, piercing shadow
266
+ // roots. Nearest first. Gives the agent "where in the tree" this element is.
267
+ function ancestorChain(el, max = 6) {
268
+ const chain = [];
269
+ let node = el.parentElement || (el.getRootNode && el.getRootNode().host) || null;
270
+ while (node && node.nodeType === 1 && chain.length < max) {
271
+ const tag = node.tagName ? node.tagName.toLowerCase() : "";
272
+ if (tag === "body" || tag === "html") break;
273
+ chain.push(elemSignature(node));
274
+ node = node.parentElement || (node.getRootNode && node.getRootNode() instanceof ShadowRoot ? node.getRootNode().host : null);
275
+ }
276
+ return chain;
277
+ }
278
+
279
+ // Nearest semantic landmark/region ancestor (main, nav, header, dialog, form,
280
+ // or any element with a data-component / data-testid). Tells the agent which
281
+ // feature/region of the page the element belongs to.
282
+ function nearestLandmark(el) {
283
+ const landmarkTags = new Set(["main", "nav", "header", "footer", "aside", "section", "article", "form", "dialog"]);
284
+ const landmarkRoles = new Set(["main", "navigation", "banner", "contentinfo", "dialog", "form", "search", "region"]);
285
+ let node = el.parentElement;
286
+ while (node && node.nodeType === 1) {
287
+ const tag = node.tagName.toLowerCase();
288
+ const role = node.getAttribute("role");
289
+ const comp = node.getAttribute("data-component") || node.getAttribute("data-testid");
290
+ if (landmarkTags.has(tag) || (role && landmarkRoles.has(role)) || comp) {
291
+ return elemSignature(node);
292
+ }
293
+ if (tag === "body") break;
294
+ node = node.parentElement;
295
+ }
296
+ return undefined;
297
+ }
298
+
299
+ // Best-effort framework component path (React/Preact fiber, or Vue). Returns
300
+ // e.g. "App > Dashboard > SignupCard". Absent on non-framework pages.
301
+ function frameworkComponents(el) {
302
+ try {
303
+ const keys = Object.keys(el);
304
+ const fiberKey = keys.find((k) => k.startsWith("__reactFiber$") || k.startsWith("__reactInternalInstance$"));
305
+ if (fiberKey) {
306
+ const names = [];
307
+ let cur = el[fiberKey];
308
+ let depth = 0;
309
+ while (cur && depth < 12) {
310
+ const t = cur.type;
311
+ let name = null;
312
+ if (typeof t === "function") name = t.displayName || t.name || null;
313
+ else if (t && typeof t === "object") name = (t.render && (t.render.displayName || t.render.name)) || (t.type && (t.type.displayName || t.type.name)) || null;
314
+ if (name && !name.startsWith("_") && name !== "Fragment") names.unshift(name);
315
+ cur = cur.return;
316
+ depth++;
317
+ }
318
+ if (names.length) return { framework: "react", path: names.slice(-6).join(" > ") };
319
+ }
320
+ // Vue 3 exposes __vueParentComponent; Vue 2 exposes __vue__.
321
+ let vnode = el.__vueParentComponent || (el.__vue__ && el.__vue__.$);
322
+ if (vnode) {
323
+ const names = [];
324
+ let cur = vnode;
325
+ let depth = 0;
326
+ while (cur && depth < 12) {
327
+ const type = cur.type || (cur.$options && cur.$options);
328
+ const name = (type && (type.name || type.__name)) || (cur.$options && cur.$options.name) || null;
329
+ if (name) names.unshift(name);
330
+ cur = cur.parent || (cur.$parent && cur.$parent.$);
331
+ depth++;
332
+ }
333
+ if (names.length) return { framework: "vue", path: names.slice(-6).join(" > ") };
334
+ }
335
+ } catch {
336
+ /* ignore */
337
+ }
338
+ return undefined;
339
+ }
340
+
231
341
  function elementMeta(el, inShadow) {
232
342
  const r = el.getBoundingClientRect();
233
- const classes = el.classList ? Array.from(el.classList) : [];
343
+ const fw = frameworkComponents(el);
234
344
  const m = {
235
345
  selector: cssPath(el),
236
346
  tag: el.tagName,
@@ -239,16 +349,21 @@
239
349
  testId: bestTestId(el),
240
350
  role: el.getAttribute("role") || undefined,
241
351
  ariaLabel: el.getAttribute("aria-label") || undefined,
242
- classes: classes.length ? classes : undefined,
352
+ classes: cleanClasses(el.classList ? Array.from(el.classList) : []),
243
353
  text: (el.textContent || "").trim().slice(0, 500) || undefined,
244
354
  href: el.getAttribute("href") || undefined,
245
355
  src: el.getAttribute("src") || undefined,
246
356
  bounds: { x: r.left, y: r.top, width: r.width, height: r.height },
247
357
  inShadow: Boolean(inShadow),
248
358
  inIframe: window.top !== window.self,
359
+ ancestors: ancestorChain(el),
360
+ landmark: nearestLandmark(el),
361
+ componentPath: fw ? fw.path : undefined,
362
+ framework: fw ? fw.framework : undefined,
249
363
  html: (el.outerHTML || "").slice(0, 800),
250
364
  };
251
365
  for (const k of Object.keys(m)) if (m[k] === undefined) delete m[k];
366
+ if (Array.isArray(m.ancestors) && m.ancestors.length === 0) delete m.ancestors;
252
367
  return m;
253
368
  }
254
369
 
@@ -276,18 +391,22 @@
276
391
  const pad = 6 * dpr;
277
392
  let sx = Math.max(0, rect.left * dpr - pad);
278
393
  let sy = Math.max(0, rect.top * dpr - pad);
279
- let sw = Math.min(img.width - sx, rect.width * dpr + pad * 2);
280
- let sh = Math.min(img.height - sy, rect.height * dpr + pad * 2);
394
+ const sw = Math.min(img.width - sx, rect.width * dpr + pad * 2);
395
+ const sh = Math.min(img.height - sy, rect.height * dpr + pad * 2);
281
396
  if (sw <= 0 || sh <= 0) return resolve(null);
282
- // cap output size for a compact list thumbnail
283
- const maxW = 300;
397
+ // Keep the crop at (up to) full captured resolution so it stays sharp
398
+ // on the sidebar's HiDPI display. Only downscale if it's very large,
399
+ // to keep the data URL reasonable.
400
+ const maxW = 900;
284
401
  const scale = Math.min(1, maxW / sw);
285
- const cw = Math.round(sw * scale);
286
- const ch = Math.round(sh * scale);
402
+ const cw = Math.max(1, Math.round(sw * scale));
403
+ const ch = Math.max(1, Math.round(sh * scale));
287
404
  const c = document.createElement("canvas");
288
405
  c.width = cw;
289
406
  c.height = ch;
290
407
  const ctx = c.getContext("2d");
408
+ ctx.imageSmoothingEnabled = true;
409
+ ctx.imageSmoothingQuality = "high";
291
410
  ctx.drawImage(img, sx, sy, sw, sh, 0, 0, cw, ch);
292
411
  resolve(c.toDataURL("image/png"));
293
412
  } catch {
@@ -525,7 +644,7 @@
525
644
  </div>`;
526
645
  root.appendChild(sb);
527
646
  sb.querySelector("#oc-close").addEventListener("click", closeSidebar);
528
- sb.querySelector("#oc-pick").addEventListener("click", () => startPicking());
647
+ sb.querySelector("#oc-pick").addEventListener("click", () => (picking ? stopPicking() : startPicking()));
529
648
  sb.querySelector("#oc-dd-btn").addEventListener("click", (e) => {
530
649
  e.stopPropagation();
531
650
  sb.querySelector("#oc-dd").classList.toggle("open");
@@ -548,7 +667,7 @@
548
667
  sb.style.display = "flex";
549
668
  renderCards();
550
669
  refreshStatus();
551
- if (!statusTimer) statusTimer = setInterval(refreshStatus, 15000);
670
+ if (!statusTimer) statusTimer = setInterval(refreshStatus, 3500);
552
671
  }
553
672
 
554
673
  function closeSidebar() {
@@ -559,6 +678,7 @@
559
678
  clearInterval(statusTimer);
560
679
  statusTimer = null;
561
680
  }
681
+ lastStatusState = null; // re-check cleanly on reopen
562
682
  }
563
683
 
564
684
  function toggleSidebar() {
@@ -598,27 +718,33 @@
598
718
 
599
719
  // ---------- status + submit ----------
600
720
 
721
+ // Apply a status only when it actually changes, so routine polls don't make
722
+ // the text flicker. Only the very first check shows "Checking…".
723
+ function setStatus(state, text) {
724
+ const el = root && root.getElementById("oc-status");
725
+ if (!el) return;
726
+ if (lastStatusState === state) return; // stable during polls
727
+ lastStatusState = state;
728
+ el.className = `oc-status ${state}`;
729
+ el.textContent = text;
730
+ }
731
+
601
732
  function refreshStatus() {
602
733
  if (!root) return;
603
- const el = root.getElementById("oc-status");
604
- if (!el) return;
605
- el.className = "oc-status checking";
606
- el.textContent = "Checking connection…";
734
+ if (lastStatusState === null) setStatus("checking", "Checking connection…");
607
735
  sendMsg({ type: "oc-status" }, (res) => {
608
736
  if (!res) {
609
- el.className = "oc-status bad";
610
- el.textContent = "Extension error";
737
+ setStatus("bad", "Extension error");
611
738
  return;
612
739
  }
613
740
  if (res.ok && res.data?.ok) {
741
+ // Session list updates every poll; the status text stays stable.
614
742
  sessions = Array.isArray(res.data.sessions) ? res.data.sessions : [];
615
743
  autoSessionID = res.data.sessionID || null;
616
744
  renderSessions();
617
- el.className = "oc-status good";
618
- el.textContent = "Connected";
745
+ setStatus("good", "Connected");
619
746
  } else {
620
- el.className = "oc-status bad";
621
- el.textContent = "Not connected — check the SSH tunnel";
747
+ setStatus("bad", "Not connected — check the SSH tunnel");
622
748
  }
623
749
  });
624
750
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-browser-annotation-plugin",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Select an element in your browser, type an instruction, and send it to your OpenCode agent over a loopback + SSH tunnel. Text and element metadata only (no screenshots).",
5
5
  "keywords": [
6
6
  "opencode",