opencode-browser-annotation-plugin 0.4.0 → 0.5.0

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.0",
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",
@@ -228,9 +228,99 @@
228
228
  return parts.join(" > ");
229
229
  }
230
230
 
231
+ // A compact one-line signature of an element: tag + the most locating bits.
232
+ function elemSignature(el) {
233
+ if (!(el instanceof Element)) return "";
234
+ let s = el.tagName.toLowerCase();
235
+ const tid = bestTestId(el);
236
+ if (tid) s += `[data-testid=${tid}]`;
237
+ else if (el.id) s += `#${el.id}`;
238
+ const role = el.getAttribute && el.getAttribute("role");
239
+ if (role) s += `[role=${role}]`;
240
+ if (el.classList && el.classList.length) s += "." + Array.from(el.classList).slice(0, 3).join(".");
241
+ return s;
242
+ }
243
+
244
+ // Ancestor chain from the parent up to (but excluding) <body>, piercing shadow
245
+ // roots. Nearest first. Gives the agent "where in the tree" this element is.
246
+ function ancestorChain(el, max = 6) {
247
+ const chain = [];
248
+ let node = el.parentElement || (el.getRootNode && el.getRootNode().host) || null;
249
+ while (node && node.nodeType === 1 && chain.length < max) {
250
+ const tag = node.tagName ? node.tagName.toLowerCase() : "";
251
+ if (tag === "body" || tag === "html") break;
252
+ chain.push(elemSignature(node));
253
+ node = node.parentElement || (node.getRootNode && node.getRootNode() instanceof ShadowRoot ? node.getRootNode().host : null);
254
+ }
255
+ return chain;
256
+ }
257
+
258
+ // Nearest semantic landmark/region ancestor (main, nav, header, dialog, form,
259
+ // or any element with a data-component / data-testid). Tells the agent which
260
+ // feature/region of the page the element belongs to.
261
+ function nearestLandmark(el) {
262
+ const landmarkTags = new Set(["main", "nav", "header", "footer", "aside", "section", "article", "form", "dialog"]);
263
+ const landmarkRoles = new Set(["main", "navigation", "banner", "contentinfo", "dialog", "form", "search", "region"]);
264
+ let node = el.parentElement;
265
+ while (node && node.nodeType === 1) {
266
+ const tag = node.tagName.toLowerCase();
267
+ const role = node.getAttribute("role");
268
+ const comp = node.getAttribute("data-component") || node.getAttribute("data-testid");
269
+ if (landmarkTags.has(tag) || (role && landmarkRoles.has(role)) || comp) {
270
+ return elemSignature(node);
271
+ }
272
+ if (tag === "body") break;
273
+ node = node.parentElement;
274
+ }
275
+ return undefined;
276
+ }
277
+
278
+ // Best-effort framework component path (React/Preact fiber, or Vue). Returns
279
+ // e.g. "App > Dashboard > SignupCard". Absent on non-framework pages.
280
+ function frameworkComponents(el) {
281
+ try {
282
+ const keys = Object.keys(el);
283
+ const fiberKey = keys.find((k) => k.startsWith("__reactFiber$") || k.startsWith("__reactInternalInstance$"));
284
+ if (fiberKey) {
285
+ const names = [];
286
+ let cur = el[fiberKey];
287
+ let depth = 0;
288
+ while (cur && depth < 12) {
289
+ const t = cur.type;
290
+ let name = null;
291
+ if (typeof t === "function") name = t.displayName || t.name || null;
292
+ else if (t && typeof t === "object") name = (t.render && (t.render.displayName || t.render.name)) || (t.type && (t.type.displayName || t.type.name)) || null;
293
+ if (name && !name.startsWith("_") && name !== "Fragment") names.unshift(name);
294
+ cur = cur.return;
295
+ depth++;
296
+ }
297
+ if (names.length) return { framework: "react", path: names.slice(-6).join(" > ") };
298
+ }
299
+ // Vue 3 exposes __vueParentComponent; Vue 2 exposes __vue__.
300
+ let vnode = el.__vueParentComponent || (el.__vue__ && el.__vue__.$);
301
+ if (vnode) {
302
+ const names = [];
303
+ let cur = vnode;
304
+ let depth = 0;
305
+ while (cur && depth < 12) {
306
+ const type = cur.type || (cur.$options && cur.$options);
307
+ const name = (type && (type.name || type.__name)) || (cur.$options && cur.$options.name) || null;
308
+ if (name) names.unshift(name);
309
+ cur = cur.parent || (cur.$parent && cur.$parent.$);
310
+ depth++;
311
+ }
312
+ if (names.length) return { framework: "vue", path: names.slice(-6).join(" > ") };
313
+ }
314
+ } catch {
315
+ /* ignore */
316
+ }
317
+ return undefined;
318
+ }
319
+
231
320
  function elementMeta(el, inShadow) {
232
321
  const r = el.getBoundingClientRect();
233
322
  const classes = el.classList ? Array.from(el.classList) : [];
323
+ const fw = frameworkComponents(el);
234
324
  const m = {
235
325
  selector: cssPath(el),
236
326
  tag: el.tagName,
@@ -246,9 +336,14 @@
246
336
  bounds: { x: r.left, y: r.top, width: r.width, height: r.height },
247
337
  inShadow: Boolean(inShadow),
248
338
  inIframe: window.top !== window.self,
339
+ ancestors: ancestorChain(el),
340
+ landmark: nearestLandmark(el),
341
+ componentPath: fw ? fw.path : undefined,
342
+ framework: fw ? fw.framework : undefined,
249
343
  html: (el.outerHTML || "").slice(0, 800),
250
344
  };
251
345
  for (const k of Object.keys(m)) if (m[k] === undefined) delete m[k];
346
+ if (Array.isArray(m.ancestors) && m.ancestors.length === 0) delete m.ancestors;
252
347
  return m;
253
348
  }
254
349
 
@@ -548,7 +643,7 @@
548
643
  sb.style.display = "flex";
549
644
  renderCards();
550
645
  refreshStatus();
551
- if (!statusTimer) statusTimer = setInterval(refreshStatus, 15000);
646
+ if (!statusTimer) statusTimer = setInterval(refreshStatus, 3500);
552
647
  }
553
648
 
554
649
  function closeSidebar() {
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.0",
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",