opencode-browser-annotation-plugin 0.3.0 → 0.3.2

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
@@ -7,9 +7,9 @@ import { createServer } from "node:http";
7
7
  * the browser runs on a separate desktop, the extension reaches this server over
8
8
  * an `ssh -L` local forward (desktop -> host).
9
9
  *
10
- * On receipt the plugin injects a new user turn into the most recently active
11
- * OpenCode session so the agent responds. Annotations may be acted on
12
- * immediately ("act") or queued and flushed with a later act ("queue").
10
+ * On receipt the plugin injects a new user turn into the chosen OpenCode session
11
+ * (the extension may target any session by id; otherwise the most recently
12
+ * active one) so the agent responds.
13
13
  *
14
14
  * Scope: text + element metadata only. No screenshots, no image/vision.
15
15
  */
@@ -142,18 +142,35 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
142
142
  const host = envHost();
143
143
  const port = envPort();
144
144
  let activeSessionID = null;
145
- let activeSessionTitle = null;
146
145
  let server = null;
147
- const queued = [];
148
146
  const log = (level, message, extra) => {
149
147
  void client.app
150
148
  .log({ body: { service: "browser-annotation", level, message, extra } })
151
149
  .catch(() => { });
152
150
  };
153
- async function injectPrompt(annotations) {
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() {
157
+ try {
158
+ const res = (await client.session.list({ query: { directory } }));
159
+ const rows = Array.isArray(res) ? res : Array.isArray(res?.data) ? res.data : [];
160
+ return rows
161
+ .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);
164
+ }
165
+ catch (error) {
166
+ log("warn", `session.list failed: ${error instanceof Error ? error.message : "unknown"}`);
167
+ return [];
168
+ }
169
+ }
170
+ async function injectPrompt(sessionID, annotations) {
154
171
  try {
155
172
  await client.session.promptAsync({
156
- path: { id: activeSessionID },
173
+ path: { id: sessionID },
157
174
  query: { directory },
158
175
  body: { parts: [{ type: "text", text: buildPrompt(annotations) }] },
159
176
  });
@@ -163,34 +180,26 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
163
180
  return { ok: false, error: error instanceof Error ? error.message : "session.prompt failed" };
164
181
  }
165
182
  }
166
- /**
167
- * Queue annotations are held until an "act" annotation arrives (or all-queue
168
- * submits nothing yet). An act annotation flushes the queue plus itself.
169
- */
170
- async function handleSubmit(annotations) {
171
- if (!activeSessionID) {
183
+ async function handleSubmit(annotations, requestedSessionID) {
184
+ // Prefer the explicitly targeted session; fall back to the last active one.
185
+ let targetID = requestedSessionID || activeSessionID;
186
+ if (requestedSessionID) {
187
+ const sessions = await listSessions();
188
+ if (!sessions.some((s) => s.id === requestedSessionID)) {
189
+ return { ok: false, error: "Target session no longer exists.", injected: 0 };
190
+ }
191
+ }
192
+ if (!targetID) {
172
193
  return {
173
194
  ok: false,
174
- error: "No active OpenCode session yet. Send a message in OpenCode first.",
195
+ error: "No target session. Send a message in OpenCode first, or pick a session.",
175
196
  injected: 0,
176
- queued: queued.length,
177
197
  };
178
198
  }
179
- const toQueue = annotations.filter((a) => a.mode === "queue");
180
- const toAct = annotations.filter((a) => a.mode !== "queue");
181
- queued.push(...toQueue);
182
- if (toAct.length === 0) {
183
- return { ok: true, injected: 0, queued: queued.length, sessionID: activeSessionID };
184
- }
185
- const batch = [...queued, ...toAct];
186
- queued.length = 0;
187
- const result = await injectPrompt(batch);
188
- if (!result.ok) {
189
- // Re-queue so nothing is lost.
190
- queued.unshift(...batch.filter((a) => a.mode === "queue"));
191
- return { ok: false, error: result.error, injected: 0, queued: queued.length, sessionID: activeSessionID };
192
- }
193
- return { ok: true, injected: batch.length, queued: queued.length, sessionID: activeSessionID };
199
+ const result = await injectPrompt(targetID, annotations);
200
+ if (!result.ok)
201
+ return { ok: false, error: result.error, injected: 0, sessionID: targetID };
202
+ return { ok: true, injected: annotations.length, sessionID: targetID };
194
203
  }
195
204
  function handle(req, res) {
196
205
  if (req.method === "OPTIONS") {
@@ -198,14 +207,17 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
198
207
  return;
199
208
  }
200
209
  if (req.method === "GET" && req.url === "/status") {
201
- sendJson(res, 200, {
202
- ok: true,
203
- activeSession: Boolean(activeSessionID),
204
- sessionID: activeSessionID,
205
- sessionTitle: activeSessionTitle,
206
- queued: queued.length,
207
- host,
208
- port,
210
+ void listSessions().then((sessions) => {
211
+ const active = sessions.find((s) => s.id === activeSessionID);
212
+ sendJson(res, 200, {
213
+ ok: true,
214
+ activeSession: Boolean(activeSessionID),
215
+ sessionID: activeSessionID,
216
+ sessionTitle: active?.title ?? null,
217
+ sessions,
218
+ host,
219
+ port,
220
+ });
209
221
  });
210
222
  return;
211
223
  }
@@ -218,11 +230,9 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
218
230
  sendJson(res, 400, { ok: false, error: "No annotations in payload." });
219
231
  return;
220
232
  }
221
- const result = await handleSubmit(annotations);
233
+ const result = await handleSubmit(annotations, payload.sessionID);
222
234
  if (result.ok) {
223
- log("info", `Annotations: injected ${result.injected}, queued ${result.queued}`, {
224
- sessionID: result.sessionID,
225
- });
235
+ log("info", `Injected ${result.injected} annotation(s)`, { sessionID: result.sessionID });
226
236
  sendJson(res, 200, result);
227
237
  }
228
238
  else {
@@ -263,17 +273,9 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
263
273
  activeSessionID = input.sessionID;
264
274
  },
265
275
  event: async ({ event }) => {
266
- if (event.type === "session.updated") {
267
- const info = event.properties.info;
268
- if (info.id === activeSessionID && typeof info.title === "string") {
269
- activeSessionTitle = info.title;
270
- }
271
- }
272
- else if (event.type === "session.deleted") {
273
- if (event.properties.info.id === activeSessionID) {
276
+ if (event.type === "session.deleted") {
277
+ if (event.properties.info.id === activeSessionID)
274
278
  activeSessionID = null;
275
- activeSessionTitle = null;
276
- }
277
279
  }
278
280
  },
279
281
  };
@@ -68,7 +68,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
68
68
  const res = await fetch(`${endpoint}/annotations`, {
69
69
  method: "POST",
70
70
  headers: { "content-type": "application/json" },
71
- body: JSON.stringify({ extensionVersion: EXTENSION_VERSION, annotations }),
71
+ body: JSON.stringify({ extensionVersion: EXTENSION_VERSION, annotations, sessionID: msg.sessionID }),
72
72
  });
73
73
  const data = await res.json().catch(() => ({}));
74
74
  if (res.ok && data.ok) {
@@ -1,10 +1,17 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "OpenCode Browser Annotation",
4
- "version": "0.3.0",
4
+ "version": "0.3.2",
5
5
  "description": "Alt+A to annotate an element, Alt+Shift+A for the annotation list. Sends element metadata to your OpenCode agent.",
6
- "permissions": ["activeTab", "scripting", "storage"],
7
- "host_permissions": ["http://127.0.0.1/*", "http://localhost/*"],
6
+ "permissions": [
7
+ "activeTab",
8
+ "scripting",
9
+ "storage"
10
+ ],
11
+ "host_permissions": [
12
+ "http://127.0.0.1/*",
13
+ "http://localhost/*"
14
+ ],
8
15
  "background": {
9
16
  "service_worker": "background.js",
10
17
  "type": "module"
@@ -15,11 +22,17 @@
15
22
  "options_page": "options.html",
16
23
  "commands": {
17
24
  "select-element": {
18
- "suggested_key": { "default": "Alt+A", "mac": "Alt+A" },
25
+ "suggested_key": {
26
+ "default": "Alt+A",
27
+ "mac": "Alt+A"
28
+ },
19
29
  "description": "Pick an element to annotate"
20
30
  },
21
31
  "toggle-sidebar": {
22
- "suggested_key": { "default": "Alt+Shift+A", "mac": "Alt+Shift+A" },
32
+ "suggested_key": {
33
+ "default": "Alt+Shift+A",
34
+ "mac": "Alt+Shift+A"
35
+ },
23
36
  "description": "Toggle the annotation list sidebar"
24
37
  }
25
38
  }
@@ -5,9 +5,9 @@
5
5
  // Alt+A -> start element picking (background sends "oc-pick")
6
6
  // Alt+Shift+A -> toggle the list sidebar ("oc-toggle-sidebar")
7
7
  // Picking an element opens a floating popup near it (onUI-style): type an
8
- // instruction, choose Act/Queue, then Add (to the sidebar list) or Send (submit
9
- // immediately). The sidebar is a floating rounded card holding pending
10
- // annotations for batch submit.
8
+ // instruction, then Add (to the sidebar list) or Send (submit immediately). The
9
+ // sidebar is a floating rounded card holding pending annotations for batch
10
+ // submit, with a session-target picker.
11
11
 
12
12
  (() => {
13
13
  if (window.__ocAnnotationInjected) return;
@@ -20,10 +20,6 @@
20
20
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>',
21
21
  trash:
22
22
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
23
- bolt:
24
- '<svg viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
25
- layers:
26
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>',
27
23
  send:
28
24
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>',
29
25
  plus:
@@ -71,11 +67,6 @@
71
67
  textarea.oc-ta::placeholder { color: #616678; }
72
68
  textarea.oc-ta:focus { outline: none; border-color: #4c8dff; box-shadow: 0 0 0 3px rgba(76,141,255,.18); }
73
69
 
74
- .oc-modes { display: flex; gap: 6px; margin-top: 9px; }
75
- .oc-mode { font: inherit; font-size: 12px; font-weight: 600; flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 6px 8px; border-radius: 8px; cursor: pointer; background: rgba(255,255,255,.05); color: #9298aa; border: 1px solid transparent; }
76
- .oc-mode svg { width: 13px; height: 13px; }
77
- .oc-mode:hover { background: rgba(255,255,255,.09); color: #cfd3df; }
78
- .oc-mode.sel { background: rgba(76,141,255,.16); color: #7aa9ff; border-color: rgba(76,141,255,.4); }
79
70
 
80
71
  .btn { font: inherit; font-weight: 600; font-size: 12.5px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 8px 12px; border-radius: 9px; cursor: pointer; border: 1px solid rgba(255,255,255,.14); background: rgba(255,255,255,.05); color: #e6e8ee; transition: background .15s, transform .05s; }
81
72
  .btn svg { width: 15px; height: 15px; }
@@ -103,10 +94,12 @@
103
94
  .oc-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
104
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; }
105
96
  .oc-card .oc-text { font-size: 12.5px; color: #d4d7e0; white-space: pre-wrap; word-break: break-word; }
106
- .oc-card .oc-tag { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 600; color: #9298aa; margin-top: 6px; }
107
- .oc-card .oc-tag svg { width: 12px; height: 12px; }
108
97
  .oc-foot-bar { padding: 11px 12px; border-top: 1px solid rgba(255,255,255,.07); position: relative; }
109
- .oc-status { font-size: 12px; margin-bottom: 9px; display: flex; align-items: center; gap: 8px; color: #9298aa; }
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; }
110
103
  .oc-status::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #5b6070; flex: none; }
111
104
  .oc-status.good::before { background: #34d399; box-shadow: 0 0 8px rgba(52,211,153,.6); }
112
105
  .oc-status.warn::before { background: #fbbf24; } .oc-status.bad::before { background: #f87171; } .oc-status.checking::before { background: #60a5fa; }
@@ -125,6 +118,62 @@
125
118
  let pending = []; // annotations added to the sidebar list
126
119
  let sidebarOpen = false;
127
120
  let statusTimer = null;
121
+ let sessions = []; // [{ id, title, updated }]
122
+ let targetSessionID = null; // user-chosen target; null = auto (last active)
123
+ let autoSessionID = null; // the plugin's last-active session
124
+
125
+ // A content script keeps running after its extension is reloaded/updated, but
126
+ // its chrome.* calls then throw "Extension context invalidated". Guard every
127
+ // message so a stale overlay fails quietly instead of spamming errors, and
128
+ // tear itself down once the context is gone.
129
+ function contextAlive() {
130
+ try {
131
+ return Boolean(chrome.runtime && chrome.runtime.id);
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+
137
+ function sendMsg(payload, cb) {
138
+ if (!contextAlive()) {
139
+ teardown();
140
+ return;
141
+ }
142
+ try {
143
+ chrome.runtime.sendMessage(payload, (res) => {
144
+ if (chrome.runtime.lastError) {
145
+ cb(null);
146
+ return;
147
+ }
148
+ cb(res);
149
+ });
150
+ } catch {
151
+ teardown();
152
+ }
153
+ }
154
+
155
+ function teardown() {
156
+ if (statusTimer) {
157
+ clearInterval(statusTimer);
158
+ statusTimer = null;
159
+ }
160
+ if (host) host.style.display = "none";
161
+ }
162
+
163
+ function effectiveTarget() {
164
+ return targetSessionID || autoSessionID;
165
+ }
166
+
167
+ function targetTitle() {
168
+ const id = effectiveTarget();
169
+ const s = sessions.find((x) => x.id === id);
170
+ return s ? s.title : null;
171
+ }
172
+
173
+ function targetLabel() {
174
+ const t = targetTitle();
175
+ return t ? ` · → ${escapeHtml(t)}` : "";
176
+ }
128
177
 
129
178
  // ---------- metadata ----------
130
179
 
@@ -266,6 +315,19 @@
266
315
  document.addEventListener("mousemove", onMove, true);
267
316
  document.addEventListener("click", onClick, true);
268
317
  document.addEventListener("keydown", onKey, true);
318
+
319
+ // Keep keystrokes typed inside our UI from reaching the page's global
320
+ // hotkeys (e.g. GitHub's "s"/"f"). Capture key events at the document before
321
+ // page listeners run; if the event originates inside our shadow UI, stop it
322
+ // (Esc is handled by onKey above, which runs first in capture order here).
323
+ const contain = (e) => {
324
+ if (e.composedPath && e.composedPath().includes(host)) {
325
+ e.stopImmediatePropagation();
326
+ }
327
+ };
328
+ for (const type of ["keydown", "keyup", "keypress"]) {
329
+ document.addEventListener(type, contain, true);
330
+ }
269
331
  }
270
332
 
271
333
  function startPicking() {
@@ -297,7 +359,6 @@
297
359
  function openPopup(element, rect) {
298
360
  ensureUI();
299
361
  closePopup();
300
- const state = { mode: "act" };
301
362
  const el = document.createElement("div");
302
363
  el.className = "oc-popup oc-surface";
303
364
  el.innerHTML = `
@@ -308,11 +369,7 @@
308
369
  </div>
309
370
  <div class="oc-body">
310
371
  <textarea class="oc-ta" rows="3" placeholder="Describe the change or ask about this element…"></textarea>
311
- <div class="oc-modes">
312
- <button class="oc-mode sel" data-mode="act">${ICON.bolt}<span>Act now</span></button>
313
- <button class="oc-mode" data-mode="queue">${ICON.layers}<span>Queue</span></button>
314
- </div>
315
- <div class="oc-hintline">Cmd/Ctrl+Enter to send</div>
372
+ <div class="oc-hintline">Cmd/Ctrl+Enter to send${targetLabel()}</div>
316
373
  </div>
317
374
  <div class="oc-foot">
318
375
  <button class="btn oc-add">${ICON.plus}<span>Add to list</span></button>
@@ -326,13 +383,7 @@
326
383
  setTimeout(() => ta.focus(), 30);
327
384
 
328
385
  el.querySelector(".oc-x").addEventListener("click", closePopup);
329
- el.querySelectorAll(".oc-mode").forEach((b) =>
330
- b.addEventListener("click", () => {
331
- state.mode = b.dataset.mode;
332
- el.querySelectorAll(".oc-mode").forEach((x) => x.classList.toggle("sel", x === b));
333
- }),
334
- );
335
- const build = () => ({ instruction: ta.value.trim(), mode: state.mode, page: { url: location.href, title: document.title }, element });
386
+ const build = () => ({ instruction: ta.value.trim(), page: { url: location.href, title: document.title }, element });
336
387
  el.querySelector(".oc-add").addEventListener("click", () => {
337
388
  if (!ta.value.trim()) return ta.focus();
338
389
  pending.push(build());
@@ -386,16 +437,24 @@
386
437
  <span class="logo">${ICON.logo}</span>
387
438
  <span class="title">Annotations</span>
388
439
  <span class="badge" id="oc-badge">0</span>
440
+ <button class="iconbtn" id="oc-pick" title="Select element (Alt+A)">${ICON.target}</button>
389
441
  <button class="iconbtn" id="oc-close" title="Close (Alt+Shift+A)">${ICON.close}</button>
390
442
  </div>
391
443
  <div class="oc-list" id="oc-list"></div>
392
444
  <div class="oc-foot-bar">
393
- <div class="oc-status" id="oc-status">…</div>
445
+ <div class="oc-status-row">
446
+ <div class="oc-status" id="oc-status">…</div>
447
+ <select class="oc-select" id="oc-session" title="Target session"></select>
448
+ </div>
394
449
  <button class="btn primary oc-submit" id="oc-submit" disabled>${ICON.send}<span>Submit to agent</span></button>
395
450
  <div class="oc-toast" id="oc-toast"></div>
396
451
  </div>`;
397
452
  root.appendChild(sb);
398
453
  sb.querySelector("#oc-close").addEventListener("click", closeSidebar);
454
+ sb.querySelector("#oc-pick").addEventListener("click", () => startPicking());
455
+ sb.querySelector("#oc-session").addEventListener("change", (e) => {
456
+ targetSessionID = e.target.value || null;
457
+ });
399
458
  sb.querySelector("#oc-submit").addEventListener("click", () => {
400
459
  submit(pending, false);
401
460
  });
@@ -443,15 +502,12 @@
443
502
  pending.forEach((a, i) => {
444
503
  const card = document.createElement("div");
445
504
  card.className = "oc-card";
446
- const modeIcon = a.mode === "queue" ? ICON.layers : ICON.bolt;
447
- const modeName = a.mode === "queue" ? "Queue" : "Act now";
448
505
  card.innerHTML = `
449
506
  <div class="oc-card-head">
450
507
  <span class="oc-desc" title="${escapeHtml(a.element.selector || "")}">${escapeHtml(descriptor(a.element))}</span>
451
508
  <button class="iconbtn oc-rm" data-i="${i}" title="Remove">${ICON.trash}</button>
452
509
  </div>
453
- <div class="oc-text">${escapeHtml(a.instruction || "(no instruction)")}</div>
454
- <div class="oc-tag">${modeIcon}<span>${modeName}</span></div>`;
510
+ <div class="oc-text">${escapeHtml(a.instruction || "(no instruction)")}</div>`;
455
511
  list.appendChild(card);
456
512
  });
457
513
  const btn = root.getElementById("oc-submit");
@@ -469,17 +525,19 @@
469
525
  if (!el) return;
470
526
  el.className = "oc-status checking";
471
527
  el.textContent = "Checking connection…";
472
- chrome.runtime.sendMessage({ type: "oc-status" }, (res) => {
473
- if (chrome.runtime.lastError || !res) {
528
+ sendMsg({ type: "oc-status" }, (res) => {
529
+ if (!res) {
474
530
  el.className = "oc-status bad";
475
531
  el.textContent = "Extension error";
476
532
  return;
477
533
  }
478
534
  if (res.ok && res.data?.ok) {
479
- if (res.data.activeSession) {
480
- const q = res.data.queued ? ` · ${res.data.queued} queued` : "";
535
+ sessions = Array.isArray(res.data.sessions) ? res.data.sessions : [];
536
+ autoSessionID = res.data.sessionID || null;
537
+ renderSessions();
538
+ if (sessions.length || res.data.activeSession) {
481
539
  el.className = "oc-status good";
482
- el.textContent = `Connected${q}`;
540
+ el.textContent = "Connected";
483
541
  } else {
484
542
  el.className = "oc-status warn";
485
543
  el.textContent = "Connected — send a message in OpenCode first";
@@ -491,6 +549,26 @@
491
549
  });
492
550
  }
493
551
 
552
+ function renderSessions() {
553
+ if (!root) return;
554
+ const sel = root.getElementById("oc-session");
555
+ if (!sel) return;
556
+ // If the chosen target vanished, fall back to auto.
557
+ if (targetSessionID && !sessions.some((s) => s.id === targetSessionID)) targetSessionID = null;
558
+ 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
+ }),
566
+ );
567
+ // Keep "Auto" selected when no explicit target.
568
+ sel.innerHTML = opts.join("");
569
+ if (!targetSessionID) sel.value = "";
570
+ }
571
+
494
572
  function toast(text, bad) {
495
573
  if (!root) return;
496
574
  // Prefer the sidebar's toast when it is open; otherwise use a standalone
@@ -514,16 +592,14 @@
514
592
  function submit(annotations, quick) {
515
593
  if (!annotations.length) return;
516
594
  toast("Submitting…");
517
- chrome.runtime.sendMessage({ type: "oc-submit", annotations }, (res) => {
518
- if (chrome.runtime.lastError || !res) {
595
+ const sessionID = effectiveTarget() || undefined;
596
+ sendMsg({ type: "oc-submit", annotations, sessionID }, (res) => {
597
+ if (!res) {
519
598
  toast("Extension error", true);
520
599
  return;
521
600
  }
522
601
  if (res.ok) {
523
- const parts = [];
524
- if (res.injected) parts.push(`${res.injected} sent`);
525
- if (res.queued) parts.push(`${res.queued} queued`);
526
- toast(parts.length ? parts.join(", ") : "Submitted");
602
+ toast(res.injected ? `${res.injected} sent to agent` : "Submitted");
527
603
  if (!quick) {
528
604
  pending = [];
529
605
  renderCards();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-browser-annotation-plugin",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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",