opencode-browser-annotation-plugin 0.3.2 → 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
  },
@@ -43,8 +43,22 @@ chrome.action.onClicked.addListener((tab) => {
43
43
  void sendToOverlay(tab, "oc-toggle-sidebar");
44
44
  });
45
45
 
46
- chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
46
+ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
47
47
  (async () => {
48
+ if (msg?.type === "oc-capture") {
49
+ // Capture the visible tab for the extension's own thumbnail preview only.
50
+ // This image is never sent to the plugin/agent; the content script crops
51
+ // it to the element and keeps it local to the sidebar UI.
52
+ try {
53
+ const windowId = sender?.tab?.windowId;
54
+ const dataUrl = await chrome.tabs.captureVisibleTab(windowId, { format: "png" });
55
+ sendResponse({ ok: true, dataUrl });
56
+ } catch (error) {
57
+ sendResponse({ ok: false, error: error?.message || "capture failed" });
58
+ }
59
+ return;
60
+ }
61
+
48
62
  if (msg?.type === "oc-status") {
49
63
  try {
50
64
  const endpoint = await getEndpoint();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "OpenCode Browser Annotation",
4
- "version": "0.3.2",
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",
@@ -26,6 +26,8 @@
26
26
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
27
27
  logo:
28
28
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0H5a2 2 0 0 1-2-2v-4m6 6h10a2 2 0 0 0 2-2v-4"/></svg>',
29
+ caret:
30
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>',
29
31
  };
30
32
 
31
33
  const STYLE = `
@@ -58,6 +60,8 @@
58
60
  .iconbtn { display: inline-flex; align-items: center; justify-content: center; border: none; background: transparent; color: #8b90a0; cursor: pointer; width: 28px; height: 28px; border-radius: 8px; padding: 0; }
59
61
  .iconbtn svg { width: 15px; height: 15px; }
60
62
  .iconbtn:hover { background: rgba(255,255,255,.08); color: #e6e8ee; }
63
+ .iconbtn.active { background: #3f7dff; color: #fff; box-shadow: 0 0 0 3px rgba(63,125,255,.28); }
64
+ .iconbtn.active:hover { background: #3670f0; color: #fff; }
61
65
 
62
66
  textarea.oc-ta {
63
67
  width: 100%; resize: vertical; min-height: 62px; font: inherit;
@@ -90,20 +94,34 @@
90
94
  .oc-list::-webkit-scrollbar { width: 10px; }
91
95
  .oc-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,.12); border-radius: 6px; border: 3px solid transparent; background-clip: content-box; }
92
96
  .oc-empty { color: #71768a; font-size: 12.5px; padding: 24px 8px; text-align: center; line-height: 1.6; }
93
- .oc-card { background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.08); border-radius: 11px; padding: 9px 10px; }
94
- .oc-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
95
- .oc-desc { font: 11px ui-monospace, monospace; color: #aeb4c6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: rgba(255,255,255,.06); padding: 2px 7px; border-radius: 6px; flex: 1; }
97
+ .oc-card { position: relative; background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.08); border-radius: 11px; padding: 9px 10px; }
98
+ .oc-card .oc-rm { position: absolute; top: 6px; right: 6px; width: 24px; height: 24px; }
99
+ .oc-card .oc-rm svg { width: 13px; height: 13px; }
100
+ .oc-thumb { width: 100%; height: 96px; object-fit: cover; object-position: top left; background: #0e0f14; border: 1px solid rgba(255,255,255,.08); border-radius: 8px; display: block; margin-bottom: 8px; }
101
+ .oc-desc { font: 11px ui-monospace, monospace; color: #aeb4c6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: rgba(255,255,255,.06); padding: 2px 7px; border-radius: 6px; display: inline-block; max-width: calc(100% - 30px); margin-bottom: 6px; }
96
102
  .oc-card .oc-text { font-size: 12.5px; color: #d4d7e0; white-space: pre-wrap; word-break: break-word; }
97
- .oc-foot-bar { padding: 11px 12px; border-top: 1px solid rgba(255,255,255,.07); position: relative; }
98
- .oc-status-row { display: flex; align-items: center; gap: 8px; margin-bottom: 9px; }
99
- .oc-select { flex: none; max-width: 150px; font: inherit; font-size: 11.5px; color: #cfd3df; background: #14151b; border: 1px solid rgba(255,255,255,.12); border-radius: 7px; padding: 4px 6px; }
100
- .oc-select:focus { outline: none; border-color: #4c8dff; }
101
- .oc-status { flex: 1; min-width: 0; font-size: 12px; display: flex; align-items: center; gap: 8px; color: #9298aa; }
102
- .oc-status span, .oc-status { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
103
+ .oc-foot-bar { padding: 12px; border-top: 1px solid rgba(255,255,255,.07); position: relative; }
104
+ .oc-status-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
105
+ .oc-status { flex: 1; min-width: 0; font-size: 12px; display: flex; align-items: center; gap: 8px; color: #9298aa; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
103
106
  .oc-status::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #5b6070; flex: none; }
104
107
  .oc-status.good::before { background: #34d399; box-shadow: 0 0 8px rgba(52,211,153,.6); }
105
108
  .oc-status.warn::before { background: #fbbf24; } .oc-status.bad::before { background: #f87171; } .oc-status.checking::before { background: #60a5fa; }
106
- .oc-status.good { color: #34d399; } .oc-status.warn { color: #fbbf24; } .oc-status.bad { color: #f87171; }
109
+ .oc-status.good { color: #34d399; } .oc-status.warn { color: #fbbf24; } .oc-status.bad { color: #f87171; } .oc-status.checking { color: #93b4ff; }
110
+
111
+ /* custom session dropdown (opens upward) */
112
+ .oc-dd { position: relative; flex: none; width: 176px; }
113
+ .oc-dd-btn { width: 100%; display: flex; align-items: center; gap: 6px; font: inherit; font-size: 11.5px; color: #cfd3df; background: #14151b; border: 1px solid rgba(255,255,255,.12); border-radius: 8px; padding: 6px 8px; cursor: pointer; }
114
+ .oc-dd-btn:hover { border-color: rgba(255,255,255,.22); }
115
+ .oc-dd-btn .lbl { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
116
+ .oc-dd-btn .car { width: 12px; height: 12px; flex: none; transition: transform .15s; }
117
+ .oc-dd.open .oc-dd-btn .car { transform: rotate(180deg); }
118
+ .oc-dd-menu { position: absolute; bottom: calc(100% + 6px); right: 0; left: 0; max-height: 240px; overflow-y: auto; background: #1b1d25; border: 1px solid rgba(255,255,255,.14); border-radius: 10px; box-shadow: 0 -12px 34px rgba(0,0,0,.5); padding: 5px; display: none; }
119
+ .oc-dd.open .oc-dd-menu { display: block; }
120
+ .oc-dd-opt { font-size: 12px; color: #cfd3df; padding: 7px 9px; border-radius: 7px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
121
+ .oc-dd-opt:hover { background: rgba(255,255,255,.08); }
122
+ .oc-dd-opt.sel { background: rgba(76,141,255,.16); color: #7aa9ff; }
123
+ .oc-dd-menu::-webkit-scrollbar { width: 8px; }
124
+ .oc-dd-menu::-webkit-scrollbar-thumb { background: rgba(255,255,255,.14); border-radius: 6px; }
107
125
  .oc-submit { width: 100%; }
108
126
  .oc-toast { position: absolute; left: 12px; right: 12px; bottom: 58px; background: #0e0f14; color: #fff; font-size: 12.5px; padding: 9px 12px; border-radius: 9px; opacity: 0; transform: translateY(6px); transition: opacity .2s, transform .2s; pointer-events: none; box-shadow: 0 10px 30px rgba(0,0,0,.5); border: 1px solid rgba(255,255,255,.08); }
109
127
  .oc-toast.show { opacity: 1; transform: translateY(0); }
@@ -210,9 +228,99 @@
210
228
  return parts.join(" > ");
211
229
  }
212
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
+
213
320
  function elementMeta(el, inShadow) {
214
321
  const r = el.getBoundingClientRect();
215
322
  const classes = el.classList ? Array.from(el.classList) : [];
323
+ const fw = frameworkComponents(el);
216
324
  const m = {
217
325
  selector: cssPath(el),
218
326
  tag: el.tagName,
@@ -228,9 +336,14 @@
228
336
  bounds: { x: r.left, y: r.top, width: r.width, height: r.height },
229
337
  inShadow: Boolean(inShadow),
230
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,
231
343
  html: (el.outerHTML || "").slice(0, 800),
232
344
  };
233
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;
234
347
  return m;
235
348
  }
236
349
 
@@ -244,6 +357,44 @@
244
357
  return String(s || "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
245
358
  }
246
359
 
360
+ // Capture a thumbnail of the selected element for the SIDEBAR LIST ONLY.
361
+ // Never sent to the plugin/agent (stripped before submit). Best-effort: on any
362
+ // failure we simply omit the thumbnail.
363
+ function captureThumb(rect) {
364
+ return new Promise((resolve) => {
365
+ sendMsg({ type: "oc-capture" }, (res) => {
366
+ if (!res || !res.ok || !res.dataUrl) return resolve(null);
367
+ const img = new Image();
368
+ img.onload = () => {
369
+ try {
370
+ const dpr = window.devicePixelRatio || 1;
371
+ const pad = 6 * dpr;
372
+ let sx = Math.max(0, rect.left * dpr - pad);
373
+ let sy = Math.max(0, rect.top * dpr - pad);
374
+ let sw = Math.min(img.width - sx, rect.width * dpr + pad * 2);
375
+ let sh = Math.min(img.height - sy, rect.height * dpr + pad * 2);
376
+ if (sw <= 0 || sh <= 0) return resolve(null);
377
+ // cap output size for a compact list thumbnail
378
+ const maxW = 300;
379
+ const scale = Math.min(1, maxW / sw);
380
+ const cw = Math.round(sw * scale);
381
+ const ch = Math.round(sh * scale);
382
+ const c = document.createElement("canvas");
383
+ c.width = cw;
384
+ c.height = ch;
385
+ const ctx = c.getContext("2d");
386
+ ctx.drawImage(img, sx, sy, sw, sh, 0, 0, cw, ch);
387
+ resolve(c.toDataURL("image/png"));
388
+ } catch {
389
+ resolve(null);
390
+ }
391
+ };
392
+ img.onerror = () => resolve(null);
393
+ img.src = res.dataUrl;
394
+ });
395
+ });
396
+ }
397
+
247
398
  // ---------- picking ----------
248
399
 
249
400
  function pickTarget(e) {
@@ -282,8 +433,13 @@
282
433
  e.stopPropagation();
283
434
  e.stopImmediatePropagation();
284
435
  const rect = hit.el.getBoundingClientRect();
436
+ const meta = elementMeta(hit.el, hit.inShadow);
285
437
  stopPicking();
286
- openPopup(elementMeta(hit.el, hit.inShadow), rect);
438
+ // Capture the thumbnail while the element is still unobscured (before the
439
+ // popup is drawn), then open the popup. Best-effort; thumb may be null.
440
+ requestAnimationFrame(() => {
441
+ captureThumb(rect).then((thumb) => openPopup(meta, rect, thumb));
442
+ });
287
443
  }
288
444
 
289
445
  function onKey(e) {
@@ -330,12 +486,18 @@
330
486
  }
331
487
  }
332
488
 
489
+ function setPickActive(on) {
490
+ const btn = root && root.getElementById("oc-pick");
491
+ if (btn) btn.classList.toggle("active", on);
492
+ }
493
+
333
494
  function startPicking() {
334
495
  ensureUI();
335
496
  closePopup();
336
497
  picking = true;
337
498
  document.documentElement.style.cursor = "crosshair";
338
499
  root.getElementById("oc-hint").style.display = "flex";
500
+ setPickActive(true);
339
501
  }
340
502
 
341
503
  function stopPicking() {
@@ -344,6 +506,7 @@
344
506
  if (root) {
345
507
  hl().style.display = "none";
346
508
  root.getElementById("oc-hint").style.display = "none";
509
+ setPickActive(false);
347
510
  }
348
511
  }
349
512
 
@@ -356,7 +519,7 @@
356
519
  }
357
520
  }
358
521
 
359
- function openPopup(element, rect) {
522
+ function openPopup(element, rect, thumb) {
360
523
  ensureUI();
361
524
  closePopup();
362
525
  const el = document.createElement("div");
@@ -383,7 +546,7 @@
383
546
  setTimeout(() => ta.focus(), 30);
384
547
 
385
548
  el.querySelector(".oc-x").addEventListener("click", closePopup);
386
- const build = () => ({ instruction: ta.value.trim(), page: { url: location.href, title: document.title }, element });
549
+ const build = () => ({ instruction: ta.value.trim(), page: { url: location.href, title: document.title }, element, thumb });
387
550
  el.querySelector(".oc-add").addEventListener("click", () => {
388
551
  if (!ta.value.trim()) return ta.focus();
389
552
  pending.push(build());
@@ -444,7 +607,13 @@
444
607
  <div class="oc-foot-bar">
445
608
  <div class="oc-status-row">
446
609
  <div class="oc-status" id="oc-status">…</div>
447
- <select class="oc-select" id="oc-session" title="Target session"></select>
610
+ <div class="oc-dd" id="oc-dd">
611
+ <button class="oc-dd-btn" id="oc-dd-btn" title="Target session">
612
+ <span class="lbl" id="oc-dd-lbl">Auto (last active)</span>
613
+ <span class="car">${ICON.caret}</span>
614
+ </button>
615
+ <div class="oc-dd-menu" id="oc-dd-menu"></div>
616
+ </div>
448
617
  </div>
449
618
  <button class="btn primary oc-submit" id="oc-submit" disabled>${ICON.send}<span>Submit to agent</span></button>
450
619
  <div class="oc-toast" id="oc-toast"></div>
@@ -452,8 +621,13 @@
452
621
  root.appendChild(sb);
453
622
  sb.querySelector("#oc-close").addEventListener("click", closeSidebar);
454
623
  sb.querySelector("#oc-pick").addEventListener("click", () => startPicking());
455
- sb.querySelector("#oc-session").addEventListener("change", (e) => {
456
- targetSessionID = e.target.value || null;
624
+ sb.querySelector("#oc-dd-btn").addEventListener("click", (e) => {
625
+ e.stopPropagation();
626
+ sb.querySelector("#oc-dd").classList.toggle("open");
627
+ });
628
+ root.addEventListener("click", (e) => {
629
+ const dd = sb.querySelector("#oc-dd");
630
+ if (dd && !dd.contains(e.target)) dd.classList.remove("open");
457
631
  });
458
632
  sb.querySelector("#oc-submit").addEventListener("click", () => {
459
633
  submit(pending, false);
@@ -469,7 +643,7 @@
469
643
  sb.style.display = "flex";
470
644
  renderCards();
471
645
  refreshStatus();
472
- if (!statusTimer) statusTimer = setInterval(refreshStatus, 15000);
646
+ if (!statusTimer) statusTimer = setInterval(refreshStatus, 3500);
473
647
  }
474
648
 
475
649
  function closeSidebar() {
@@ -502,11 +676,11 @@
502
676
  pending.forEach((a, i) => {
503
677
  const card = document.createElement("div");
504
678
  card.className = "oc-card";
679
+ const thumb = a.thumb ? `<img class="oc-thumb" src="${a.thumb}" alt="">` : "";
505
680
  card.innerHTML = `
506
- <div class="oc-card-head">
507
- <span class="oc-desc" title="${escapeHtml(a.element.selector || "")}">${escapeHtml(descriptor(a.element))}</span>
508
- <button class="iconbtn oc-rm" data-i="${i}" title="Remove">${ICON.trash}</button>
509
- </div>
681
+ <button class="iconbtn oc-rm" data-i="${i}" title="Remove">${ICON.trash}</button>
682
+ ${thumb}
683
+ <span class="oc-desc" title="${escapeHtml(a.element.selector || "")}">${escapeHtml(descriptor(a.element))}</span>
510
684
  <div class="oc-text">${escapeHtml(a.instruction || "(no instruction)")}</div>`;
511
685
  list.appendChild(card);
512
686
  });
@@ -535,13 +709,8 @@
535
709
  sessions = Array.isArray(res.data.sessions) ? res.data.sessions : [];
536
710
  autoSessionID = res.data.sessionID || null;
537
711
  renderSessions();
538
- if (sessions.length || res.data.activeSession) {
539
- el.className = "oc-status good";
540
- el.textContent = "Connected";
541
- } else {
542
- el.className = "oc-status warn";
543
- el.textContent = "Connected — send a message in OpenCode first";
544
- }
712
+ el.className = "oc-status good";
713
+ el.textContent = "Connected";
545
714
  } else {
546
715
  el.className = "oc-status bad";
547
716
  el.textContent = "Not connected — check the SSH tunnel";
@@ -551,22 +720,33 @@
551
720
 
552
721
  function renderSessions() {
553
722
  if (!root) return;
554
- const sel = root.getElementById("oc-session");
555
- if (!sel) return;
723
+ const menu = root.getElementById("oc-dd-menu");
724
+ const lbl = root.getElementById("oc-dd-lbl");
725
+ if (!menu || !lbl) return;
556
726
  // If the chosen target vanished, fall back to auto.
557
727
  if (targetSessionID && !sessions.some((s) => s.id === targetSessionID)) targetSessionID = null;
558
728
  const current = effectiveTarget();
559
- const opts = [`<option value="">Auto (last active)</option>`].concat(
560
- sessions.map((s) => {
561
- const active = s.id === autoSessionID ? " • active" : "";
562
- const label = `${(s.title || s.id).slice(0, 40)}${active}`;
563
- const selected = s.id === current ? " selected" : "";
564
- return `<option value="${escapeHtml(s.id)}"${selected}>${escapeHtml(label)}</option>`;
565
- }),
729
+
730
+ const rows = [{ id: "", title: "Auto (last active)" }].concat(
731
+ sessions.map((s) => ({ id: s.id, title: `${s.title || s.id}${s.id === autoSessionID ? " • active" : ""}` })),
566
732
  );
567
- // Keep "Auto" selected when no explicit target.
568
- sel.innerHTML = opts.join("");
569
- if (!targetSessionID) sel.value = "";
733
+ menu.innerHTML = rows
734
+ .map((r) => {
735
+ const selCls = (r.id || null) === targetSessionID ? " sel" : "";
736
+ return `<div class="oc-dd-opt${selCls}" data-id="${escapeHtml(r.id)}" title="${escapeHtml(r.title)}">${escapeHtml(r.title)}</div>`;
737
+ })
738
+ .join("");
739
+ menu.querySelectorAll(".oc-dd-opt").forEach((opt) => {
740
+ opt.addEventListener("click", () => {
741
+ targetSessionID = opt.dataset.id || null;
742
+ root.getElementById("oc-dd").classList.remove("open");
743
+ renderSessions();
744
+ });
745
+ });
746
+
747
+ // Button label reflects the effective target.
748
+ const chosen = sessions.find((s) => s.id === current);
749
+ lbl.textContent = targetSessionID && chosen ? chosen.title : "Auto (last active)";
570
750
  }
571
751
 
572
752
  function toast(text, bad) {
@@ -593,7 +773,9 @@
593
773
  if (!annotations.length) return;
594
774
  toast("Submitting…");
595
775
  const sessionID = effectiveTarget() || undefined;
596
- sendMsg({ type: "oc-submit", annotations, sessionID }, (res) => {
776
+ // Strip the local-only thumbnail; the agent receives text + metadata only.
777
+ const clean = annotations.map(({ thumb, ...rest }) => rest);
778
+ sendMsg({ type: "oc-submit", annotations: clean, sessionID }, (res) => {
597
779
  if (!res) {
598
780
  toast("Extension error", true);
599
781
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-browser-annotation-plugin",
3
- "version": "0.3.2",
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",