opencode-browser-annotation-plugin 0.2.1 → 0.3.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/README.md CHANGED
@@ -73,15 +73,18 @@ extension's **Settings** page.
73
73
  ## Use
74
74
 
75
75
  1. Send at least one message in OpenCode so the plugin knows the active session.
76
- 2. Press **Alt+A** on any page to open the annotation overlay (or click the
77
- toolbar icon). Press Alt+A again or Esc to close it.
78
- 3. In the sidebar, click **Select element**, hover to highlight, and click the
79
- element you want (works inside shadow DOM).
80
- 4. Type an instruction in the card. Choose **Act now** (agent responds
81
- immediately) or **Queue** (held for context until the next Act). Repeat for
82
- more elements.
83
- 5. Click **Submit to agent**. The sidebar footer shows the connection and active
84
- session; a toast confirms how many were sent vs queued.
76
+ 2. Press **Alt+A** on any page, then click an element (highlight follows the
77
+ cursor; works inside shadow DOM). A popup opens next to it.
78
+ 3. In the popup, type an instruction and choose **Act now** (agent responds
79
+ immediately) or **Queue** (held for context until the next Act). Then either:
80
+ - **Send** (or Cmd/Ctrl+Enter) submit this one right away, no sidebar needed.
81
+ - **Add to list** stash it in the sidebar to batch with others.
82
+ 4. Press **Alt+Shift+A** (or click the toolbar icon) to open the **list sidebar**,
83
+ review pending annotations, and **Submit** them together. The footer shows the
84
+ connection status; a toast confirms how many were sent vs queued.
85
+
86
+ Shortcuts: `Alt+A` picks an element, `Alt+Shift+A` toggles the list. If they do
87
+ nothing after loading an unpacked build, set them at `chrome://extensions/shortcuts`.
85
88
 
86
89
  ## Payload
87
90
 
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
  };
@@ -1,8 +1,9 @@
1
1
  // Background service worker.
2
- // - Toggles the annotation overlay on Alt+A (command) or toolbar click.
3
- // - Performs all network I/O to the plugin endpoint (status + submit), because
4
- // page CSP can block a content script from fetching localhost. Results are
5
- // returned to the overlay via message responses.
2
+ // - Alt+A -> start element picking
3
+ // - Alt+Shift+A -> toggle the annotation-list sidebar
4
+ // - toolbar click -> toggle the sidebar
5
+ // Performs all network I/O to the plugin endpoint (status + submit), because
6
+ // page CSP can block a content script from fetching localhost.
6
7
 
7
8
  const DEFAULT_ENDPOINT = "http://127.0.0.1:39517";
8
9
  const EXTENSION_VERSION = chrome.runtime.getManifest().version;
@@ -12,33 +13,34 @@ async function getEndpoint() {
12
13
  return (endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, "");
13
14
  }
14
15
 
15
- async function toggleOverlay(tab) {
16
+ async function ensureInjected(tabId) {
17
+ const [{ result } = {}] = await chrome.scripting.executeScript({
18
+ target: { tabId },
19
+ func: () => Boolean(window.__ocAnnotationInjected),
20
+ });
21
+ if (!result) {
22
+ await chrome.scripting.executeScript({ target: { tabId }, files: ["overlay.js"] });
23
+ }
24
+ }
25
+
26
+ async function sendToOverlay(tab, type) {
16
27
  if (!tab?.id) return;
17
28
  try {
18
- // The overlay content script listens for this and toggles itself. If it is
19
- // not injected yet, inject it first, then it opens on load.
20
- const [{ result } = {}] = await chrome.scripting.executeScript({
21
- target: { tabId: tab.id },
22
- func: () => Boolean(window.__ocAnnotationInjected),
23
- });
24
- if (!result) {
25
- await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["overlay.js"] });
26
- } else {
27
- await chrome.tabs.sendMessage(tab.id, { type: "oc-toggle" });
28
- }
29
+ await ensureInjected(tab.id);
30
+ await chrome.tabs.sendMessage(tab.id, { type });
29
31
  } catch {
30
32
  // e.g. chrome:// pages or the web store cannot be injected.
31
33
  }
32
34
  }
33
35
 
34
36
  chrome.commands.onCommand.addListener(async (command) => {
35
- if (command !== "toggle-overlay") return;
36
37
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
37
- await toggleOverlay(tab);
38
+ if (command === "select-element") await sendToOverlay(tab, "oc-pick");
39
+ else if (command === "toggle-sidebar") await sendToOverlay(tab, "oc-toggle-sidebar");
38
40
  });
39
41
 
40
42
  chrome.action.onClicked.addListener((tab) => {
41
- void toggleOverlay(tab);
43
+ void sendToOverlay(tab, "oc-toggle-sidebar");
42
44
  });
43
45
 
44
46
  chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
@@ -66,7 +68,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
66
68
  const res = await fetch(`${endpoint}/annotations`, {
67
69
  method: "POST",
68
70
  headers: { "content-type": "application/json" },
69
- body: JSON.stringify({ extensionVersion: EXTENSION_VERSION, annotations }),
71
+ body: JSON.stringify({ extensionVersion: EXTENSION_VERSION, annotations, sessionID: msg.sessionID }),
70
72
  });
71
73
  const data = await res.json().catch(() => ({}));
72
74
  if (res.ok && data.ok) {
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "OpenCode Browser Annotation",
4
- "version": "0.2.1",
5
- "description": "Alt+A to annotate elements and send them to your OpenCode agent. Text and element metadata only.",
4
+ "version": "0.3.1",
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",
8
8
  "scripting",
@@ -17,16 +17,23 @@
17
17
  "type": "module"
18
18
  },
19
19
  "action": {
20
- "default_title": "Toggle OpenCode Annotation (Alt+A)"
20
+ "default_title": "OpenCode Annotation Alt+A to pick, Alt+Shift+A for list"
21
21
  },
22
22
  "options_page": "options.html",
23
23
  "commands": {
24
- "toggle-overlay": {
24
+ "select-element": {
25
25
  "suggested_key": {
26
26
  "default": "Alt+A",
27
27
  "mac": "Alt+A"
28
28
  },
29
- "description": "Toggle the annotation overlay"
29
+ "description": "Pick an element to annotate"
30
+ },
31
+ "toggle-sidebar": {
32
+ "suggested_key": {
33
+ "default": "Alt+Shift+A",
34
+ "mac": "Alt+Shift+A"
35
+ },
36
+ "description": "Toggle the annotation list sidebar"
30
37
  }
31
38
  }
32
39
  }
@@ -37,7 +37,7 @@
37
37
  <span id="saved"></span>
38
38
 
39
39
  <div class="hint">
40
- <p><strong>Shortcut:</strong> press <code>Alt+A</code> on any page to toggle the annotation overlay.</p>
40
+ <p><strong>Shortcuts:</strong> <code>Alt+A</code> to pick an element, <code>Alt+Shift+A</code> to toggle the annotation list. If they do nothing after an unpacked reload, set them at <code>chrome://extensions/shortcuts</code>.</p>
41
41
  <p>When OpenCode runs on a remote host, forward the port from your desktop and point this at the local end:</p>
42
42
  <p><code>ssh -N -L 39517:127.0.0.1:39517 you@host</code></p>
43
43
  </div>
@@ -1,33 +1,29 @@
1
- // Overlay content script: a picking layer plus a right sidebar, rendered inside
2
- // a shadow root so the host page's CSS cannot interfere and ours cannot leak.
3
- // Toggled via Alt+A (background command) or the toolbar icon.
1
+ // Overlay content script. All UI lives inside one shadow root so host-page CSS
2
+ // cannot interfere and ours cannot leak. Nothing pushes the page.
4
3
  //
5
- // Flow: pick an element (hover highlight + click, shadow-DOM aware via
6
- // composedPath) -> type an instruction in a card -> choose Act/Queue ->
7
- // Submit sends all cards to the background worker, which POSTs to the plugin.
4
+ // Interactions:
5
+ // Alt+A -> start element picking (background sends "oc-pick")
6
+ // Alt+Shift+A -> toggle the list sidebar ("oc-toggle-sidebar")
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
11
 
9
12
  (() => {
10
- if (window.__ocAnnotationInjected) {
11
- window.dispatchEvent(new CustomEvent("oc-annotation-toggle"));
12
- return;
13
- }
13
+ if (window.__ocAnnotationInjected) return;
14
14
  window.__ocAnnotationInjected = true;
15
15
 
16
- const SIDEBAR_W = 348;
17
-
18
16
  const ICON = {
19
17
  target:
20
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="7"/><line x1="12" y1="1" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="1" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="23" y2="12"/></svg>',
18
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="7"/><line x1="12" y1="1" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="23"/><line x1="1" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="23" y2="12"/></svg>',
21
19
  close:
22
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>',
23
21
  trash:
24
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>',
25
- bolt:
26
- '<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>',
27
- layers:
28
- '<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>',
29
23
  send:
30
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>',
25
+ plus:
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>',
31
27
  logo:
32
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>',
33
29
  };
@@ -35,126 +31,117 @@
35
31
  const STYLE = `
36
32
  :host { all: initial; }
37
33
  * { box-sizing: border-box; }
38
- #oc-layer { position: fixed; inset: 0; pointer-events: none; z-index: 1; }
39
- #oc-highlight {
40
- position: fixed; display: none; pointer-events: none; z-index: 2;
34
+ .oc-hl {
35
+ position: fixed; display: none; pointer-events: none; z-index: 2147483644;
41
36
  border: 2px solid #4c8dff; background: rgba(76,141,255,0.14);
42
- border-radius: 3px; box-shadow: 0 0 0 1px rgba(0,0,0,.4);
43
- transition: all .04s ease-out;
37
+ border-radius: 3px; box-shadow: 0 0 0 1px rgba(0,0,0,.4); transition: all .04s ease-out;
44
38
  }
45
- #oc-hint {
46
- position: fixed; top: 18px; left: calc(50% - ${SIDEBAR_W / 2}px);
47
- transform: translateX(-50%); z-index: 3;
39
+ .oc-hint {
40
+ position: fixed; top: 18px; left: 50%; transform: translateX(-50%); z-index: 2147483646;
48
41
  display: none; gap: 10px; align-items: center; pointer-events: none;
49
42
  font: 13px system-ui, sans-serif; color: #e6e8ee;
50
- background: rgba(20,22,30,.94); padding: 8px 14px; border-radius: 999px;
51
- box-shadow: 0 8px 24px rgba(0,0,0,.4); border: 1px solid rgba(255,255,255,.08);
43
+ background: rgba(18,20,28,.95); padding: 8px 14px; border-radius: 999px;
44
+ box-shadow: 0 8px 24px rgba(0,0,0,.45); border: 1px solid rgba(255,255,255,.08);
52
45
  }
53
- #oc-hint .key { font-size: 11px; background: rgba(255,255,255,.14); padding: 2px 7px; border-radius: 5px; }
46
+ .oc-hint .key { font-size: 11px; background: rgba(255,255,255,.14); padding: 2px 7px; border-radius: 5px; }
54
47
 
55
- #oc-sidebar {
56
- position: fixed; top: 0; right: 0; height: 100vh; width: ${SIDEBAR_W}px;
57
- pointer-events: auto; display: flex; flex-direction: column; z-index: 4;
58
- background: #16171d; color: #e6e8ee;
48
+ /* shared surface */
49
+ .oc-surface {
50
+ background: rgba(20,22,30,.98); color: #e6e8ee; border: 1px solid rgba(255,255,255,.1);
51
+ border-radius: 14px; box-shadow: 0 18px 50px rgba(0,0,0,.5);
59
52
  font: 13.5px/1.5 system-ui, -apple-system, sans-serif;
60
- border-left: 1px solid rgba(255,255,255,.08);
61
- box-shadow: -12px 0 40px rgba(0,0,0,.45);
62
- }
63
- #oc-sidebar header {
64
- display: flex; align-items: center; gap: 9px;
65
- padding: 14px 14px; border-bottom: 1px solid rgba(255,255,255,.07);
66
- }
67
- #oc-sidebar header .logo { width: 18px; height: 18px; color: #4c8dff; flex: none; }
68
- #oc-sidebar header .title { font-weight: 650; font-size: 13.5px; flex: 1; letter-spacing: .2px; }
69
- .iconbtn {
70
- display: inline-flex; align-items: center; justify-content: center;
71
- border: none; background: transparent; color: #8b90a0; cursor: pointer;
72
- width: 30px; height: 30px; border-radius: 8px; padding: 0;
73
- }
74
- .iconbtn svg { width: 16px; height: 16px; }
75
- .iconbtn:hover { background: rgba(255,255,255,.07); color: #e6e8ee; }
76
-
77
- .oc-actions { padding: 12px 14px 6px; }
78
- button.primary {
79
- font: inherit; font-weight: 600; width: 100%;
80
- display: inline-flex; align-items: center; justify-content: center; gap: 8px;
81
- padding: 10px 12px; border-radius: 10px; cursor: pointer;
82
- color: #fff; background: #3f7dff; border: 1px solid #3f7dff;
83
- transition: background .15s, transform .05s;
84
- }
85
- button.primary svg { width: 16px; height: 16px; }
86
- button.primary:hover:not(:disabled) { background: #3670f0; }
87
- button.primary:active:not(:disabled) { transform: translateY(1px); }
88
- button.primary:disabled { opacity: .4; cursor: default; }
89
- #oc-pick.active { background: #e0632a; border-color: #e0632a; }
90
-
91
- #oc-list { flex: 1; overflow-y: auto; padding: 10px 14px; display: flex; flex-direction: column; gap: 10px; }
92
- #oc-list::-webkit-scrollbar { width: 10px; }
93
- #oc-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,.12); border-radius: 6px; border: 3px solid #16171d; }
94
- .oc-empty { color: #71768a; font-size: 12.5px; padding: 26px 8px; text-align: center; line-height: 1.6; }
95
-
96
- .oc-card {
97
- background: #1f2129; border: 1px solid rgba(255,255,255,.08); border-radius: 12px;
98
- padding: 10px; box-shadow: 0 1px 2px rgba(0,0,0,.3);
99
- }
100
- .oc-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
101
- .oc-desc {
102
- font: 11.5px ui-monospace, monospace; color: #aeb4c6;
103
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
104
- background: rgba(255,255,255,.06); padding: 3px 8px; border-radius: 6px; flex: 1;
105
53
  }
106
- .oc-card textarea {
107
- width: 100%; resize: vertical; min-height: 46px; font: inherit;
108
- border: 1px solid rgba(255,255,255,.1); border-radius: 8px; padding: 8px 9px;
54
+ .oc-head { display: flex; align-items: center; gap: 9px; padding: 11px 12px; border-bottom: 1px solid rgba(255,255,255,.07); }
55
+ .oc-head .logo { width: 17px; height: 17px; color: #4c8dff; flex: none; }
56
+ .oc-head .title { font-weight: 650; font-size: 13px; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
57
+ .oc-path { font: 11px ui-monospace, monospace; color: #8b90a0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
58
+ .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
+ .iconbtn svg { width: 15px; height: 15px; }
60
+ .iconbtn:hover { background: rgba(255,255,255,.08); color: #e6e8ee; }
61
+
62
+ textarea.oc-ta {
63
+ width: 100%; resize: vertical; min-height: 62px; font: inherit;
64
+ border: 1px solid rgba(255,255,255,.12); border-radius: 9px; padding: 9px 10px;
109
65
  background: #14151b; color: #e6e8ee;
110
66
  }
111
- .oc-card textarea::placeholder { color: #616678; }
112
- .oc-card textarea:focus { outline: none; border-color: #4c8dff; box-shadow: 0 0 0 3px rgba(76,141,255,.18); }
113
- .oc-modes { display: flex; gap: 6px; margin-top: 8px; }
114
- .oc-mode {
115
- font: inherit; font-size: 12px; font-weight: 600; flex: 1;
116
- display: inline-flex; align-items: center; justify-content: center; gap: 6px;
117
- padding: 6px 8px; border-radius: 8px; cursor: pointer;
118
- background: rgba(255,255,255,.05); color: #9298aa; border: 1px solid transparent;
119
- }
120
- .oc-mode svg { width: 13px; height: 13px; }
121
- .oc-mode:hover { background: rgba(255,255,255,.09); color: #cfd3df; }
122
- .oc-mode.sel { background: rgba(76,141,255,.16); color: #7aa9ff; border-color: rgba(76,141,255,.4); }
123
-
124
- #oc-sidebar footer { padding: 12px 14px 14px; border-top: 1px solid rgba(255,255,255,.07); position: relative; }
67
+ textarea.oc-ta::placeholder { color: #616678; }
68
+ textarea.oc-ta:focus { outline: none; border-color: #4c8dff; box-shadow: 0 0 0 3px rgba(76,141,255,.18); }
69
+
70
+
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; }
72
+ .btn svg { width: 15px; height: 15px; }
73
+ .btn:hover:not(:disabled) { background: rgba(255,255,255,.1); }
74
+ .btn:active:not(:disabled) { transform: translateY(1px); }
75
+ .btn:disabled { opacity: .4; cursor: default; }
76
+ .btn.primary { background: #3f7dff; border-color: #3f7dff; color: #fff; }
77
+ .btn.primary:hover:not(:disabled) { background: #3670f0; }
78
+
79
+ /* popup */
80
+ .oc-popup { position: fixed; z-index: 2147483647; width: 320px; pointer-events: auto; }
81
+ .oc-popup .oc-body { padding: 11px 12px; }
82
+ .oc-popup .oc-foot { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid rgba(255,255,255,.07); }
83
+ .oc-popup .oc-foot .btn { flex: 1; }
84
+ .oc-hintline { font-size: 11px; color: #6a6f80; margin-top: 8px; text-align: center; }
85
+
86
+ /* sidebar (floating card at right, not pinned) */
87
+ .oc-sidebar { position: fixed; top: 16px; right: 16px; bottom: 16px; width: 330px; z-index: 2147483645; display: flex; flex-direction: column; pointer-events: auto; overflow: hidden; }
88
+ .oc-sidebar .badge { font-size: 11px; font-weight: 700; color: #7aa9ff; background: rgba(76,141,255,.16); border-radius: 999px; padding: 1px 8px; }
89
+ .oc-list { flex: 1; overflow-y: auto; padding: 10px 12px; display: flex; flex-direction: column; gap: 9px; }
90
+ .oc-list::-webkit-scrollbar { width: 10px; }
91
+ .oc-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,.12); border-radius: 6px; border: 3px solid transparent; background-clip: content-box; }
92
+ .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; }
96
+ .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-target-row { display: flex; align-items: center; gap: 8px; margin-bottom: 9px; }
99
+ .oc-target-label { font-size: 11px; font-weight: 600; color: #8b90a0; flex: none; }
100
+ .oc-select { flex: 1; min-width: 0; font: inherit; font-size: 12px; color: #e6e8ee; background: #14151b; border: 1px solid rgba(255,255,255,.12); border-radius: 8px; padding: 6px 8px; }
101
+ .oc-select:focus { outline: none; border-color: #4c8dff; }
125
102
  .oc-status { font-size: 12px; margin-bottom: 9px; display: flex; align-items: center; gap: 8px; color: #9298aa; }
126
103
  .oc-status::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #5b6070; flex: none; }
127
104
  .oc-status.good::before { background: #34d399; box-shadow: 0 0 8px rgba(52,211,153,.6); }
128
- .oc-status.warn::before { background: #fbbf24; }
129
- .oc-status.bad::before { background: #f87171; }
130
- .oc-status.checking::before { background: #60a5fa; }
131
- .oc-status.good { color: #34d399; }
132
- .oc-status.warn { color: #fbbf24; }
133
- .oc-status.bad { color: #f87171; }
134
-
135
- .oc-toast {
136
- position: absolute; left: 14px; right: 14px; bottom: 62px;
137
- background: #0e0f14; color: #fff; font-size: 12.5px;
138
- padding: 9px 12px; border-radius: 9px; opacity: 0; transform: translateY(6px);
139
- transition: opacity .2s, transform .2s; pointer-events: none;
140
- box-shadow: 0 10px 30px rgba(0,0,0,.5); border: 1px solid rgba(255,255,255,.08);
141
- }
105
+ .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; }
107
+ .oc-submit { width: 100%; }
108
+ .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); }
142
109
  .oc-toast.show { opacity: 1; transform: translateY(0); }
143
110
  .oc-toast.bad { border-color: rgba(248,113,113,.5); color: #fca5a5; }
111
+ .oc-toast-float { position: fixed; left: auto; right: 20px; bottom: 20px; width: auto; max-width: 320px; z-index: 2147483647; }
144
112
  `;
145
113
 
146
114
  let host = null;
147
115
  let root = null;
148
116
  let picking = false;
149
- let annotations = [];
117
+ let popupEl = null;
118
+ let pending = []; // annotations added to the sidebar list
119
+ let sidebarOpen = false;
150
120
  let statusTimer = null;
151
- let visible = false;
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
+ function effectiveTarget() {
126
+ return targetSessionID || autoSessionID;
127
+ }
128
+
129
+ function targetTitle() {
130
+ const id = effectiveTarget();
131
+ const s = sessions.find((x) => x.id === id);
132
+ return s ? s.title : null;
133
+ }
152
134
 
153
- // ---------- element metadata ----------
135
+ function targetLabel() {
136
+ const t = targetTitle();
137
+ return t ? ` · → ${escapeHtml(t)}` : "";
138
+ }
139
+
140
+ // ---------- metadata ----------
154
141
 
155
142
  function bestTestId(el) {
156
- for (const attr of ["data-testid", "data-test", "data-test-id", "data-cy", "data-qa"]) {
157
- const v = el.getAttribute && el.getAttribute(attr);
143
+ for (const a of ["data-testid", "data-test", "data-test-id", "data-cy", "data-qa"]) {
144
+ const v = el.getAttribute && el.getAttribute(a);
158
145
  if (v) return v;
159
146
  }
160
147
  return undefined;
@@ -170,9 +157,9 @@
170
157
  if (node.classList && node.classList.length) {
171
158
  sel += "." + Array.from(node.classList).slice(0, 3).map((c) => CSS.escape(c)).join(".");
172
159
  }
173
- const parent = node.parentElement;
174
- if (parent) {
175
- const sibs = Array.from(parent.children).filter((c) => c.nodeName === node.nodeName);
160
+ const p = node.parentElement;
161
+ if (p) {
162
+ const sibs = Array.from(p.children).filter((c) => c.nodeName === node.nodeName);
176
163
  if (sibs.length > 1) sel += `:nth-of-type(${sibs.indexOf(node) + 1})`;
177
164
  }
178
165
  parts.unshift(sel);
@@ -188,7 +175,7 @@
188
175
  function elementMeta(el, inShadow) {
189
176
  const r = el.getBoundingClientRect();
190
177
  const classes = el.classList ? Array.from(el.classList) : [];
191
- const meta = {
178
+ const m = {
192
179
  selector: cssPath(el),
193
180
  tag: el.tagName,
194
181
  id: el.id || undefined,
@@ -205,61 +192,48 @@
205
192
  inIframe: window.top !== window.self,
206
193
  html: (el.outerHTML || "").slice(0, 800),
207
194
  };
208
- for (const k of Object.keys(meta)) if (meta[k] === undefined) delete meta[k];
209
- return meta;
195
+ for (const k of Object.keys(m)) if (m[k] === undefined) delete m[k];
196
+ return m;
210
197
  }
211
198
 
212
- function shortDescriptor(el) {
199
+ function descriptor(el) {
213
200
  const tag = (el.tag || "el").toLowerCase();
214
- const id = el.testId
215
- ? `[data-testid=${el.testId}]`
216
- : el.id
217
- ? `#${el.id}`
218
- : el.ariaLabel
219
- ? `[${el.ariaLabel}]`
220
- : el.classes && el.classes.length
221
- ? `.${el.classes[0]}`
222
- : "";
201
+ const id = el.testId ? `[data-testid=${el.testId}]` : el.id ? `#${el.id}` : el.ariaLabel ? `[${el.ariaLabel}]` : el.classes && el.classes.length ? `.${el.classes[0]}` : "";
223
202
  return `${tag}${id}`;
224
203
  }
225
204
 
226
- // ---------- picking (layer is always pointer-events:none; read the real
227
- // element from the event's composed path, which pierces shadow DOM) ----------
205
+ function escapeHtml(s) {
206
+ return String(s || "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
207
+ }
208
+
209
+ // ---------- picking ----------
228
210
 
229
211
  function pickTarget(e) {
230
212
  const path = e.composedPath ? e.composedPath() : [e.target];
231
213
  for (const n of path) {
232
- if (n === host) return null; // our UI wrapper
214
+ if (n === host) return null;
233
215
  if (!(n instanceof Element)) continue;
234
- if (root && root.contains(n)) return null; // hovering our own UI
216
+ if (root && root.contains(n)) return null;
235
217
  const inShadow = n.getRootNode && n.getRootNode() instanceof ShadowRoot;
236
218
  return { el: n, inShadow };
237
219
  }
238
220
  return null;
239
221
  }
240
222
 
241
- function positionHighlight(el) {
242
- const box = root.getElementById("oc-highlight");
243
- if (!box) return;
244
- const r = el.getBoundingClientRect();
245
- Object.assign(box.style, {
246
- display: "block",
247
- left: `${r.left}px`,
248
- top: `${r.top}px`,
249
- width: `${r.width}px`,
250
- height: `${r.height}px`,
251
- });
223
+ function hl() {
224
+ return root.getElementById("oc-hl");
252
225
  }
253
226
 
254
227
  function onMove(e) {
255
228
  if (!picking) return;
256
229
  const hit = pickTarget(e);
230
+ const box = hl();
257
231
  if (!hit) {
258
- const box = root.getElementById("oc-highlight");
259
- if (box) box.style.display = "none";
232
+ box.style.display = "none";
260
233
  return;
261
234
  }
262
- positionHighlight(hit.el);
235
+ const r = hit.el.getBoundingClientRect();
236
+ Object.assign(box.style, { display: "block", left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px` });
263
237
  }
264
238
 
265
239
  function onClick(e) {
@@ -269,94 +243,234 @@
269
243
  e.preventDefault();
270
244
  e.stopPropagation();
271
245
  e.stopImmediatePropagation();
246
+ const rect = hit.el.getBoundingClientRect();
272
247
  stopPicking();
273
- addCardForElement(elementMeta(hit.el, hit.inShadow));
248
+ openPopup(elementMeta(hit.el, hit.inShadow), rect);
274
249
  }
275
250
 
276
251
  function onKey(e) {
277
252
  if (e.key === "Escape") {
278
- if (picking) stopPicking();
279
- else toggle();
253
+ if (popupEl) closePopup();
254
+ else if (picking) stopPicking();
280
255
  }
281
256
  }
282
257
 
258
+ function ensureUI() {
259
+ if (host) return;
260
+ host = document.createElement("div");
261
+ host.id = "oc-annotation-host";
262
+ host.style.cssText = "all: initial;";
263
+ root = host.attachShadow({ mode: "open" });
264
+ const style = document.createElement("style");
265
+ style.textContent = STYLE;
266
+ root.appendChild(style);
267
+ const hlBox = document.createElement("div");
268
+ hlBox.className = "oc-hl";
269
+ hlBox.id = "oc-hl";
270
+ root.appendChild(hlBox);
271
+ const hint = document.createElement("div");
272
+ hint.className = "oc-hint";
273
+ hint.id = "oc-hint";
274
+ hint.innerHTML = `<span>Click an element to annotate</span><span class="key">Esc</span>`;
275
+ root.appendChild(hint);
276
+ document.documentElement.appendChild(host);
277
+ document.addEventListener("mousemove", onMove, true);
278
+ document.addEventListener("click", onClick, true);
279
+ document.addEventListener("keydown", onKey, true);
280
+ }
281
+
283
282
  function startPicking() {
283
+ ensureUI();
284
+ closePopup();
284
285
  picking = true;
285
286
  document.documentElement.style.cursor = "crosshair";
286
287
  root.getElementById("oc-hint").style.display = "flex";
287
- setPickBtn(true);
288
288
  }
289
289
 
290
290
  function stopPicking() {
291
291
  picking = false;
292
292
  document.documentElement.style.cursor = "";
293
- const box = root.getElementById("oc-highlight");
294
- if (box) box.style.display = "none";
295
- root.getElementById("oc-hint").style.display = "none";
296
- setPickBtn(false);
293
+ if (root) {
294
+ hl().style.display = "none";
295
+ root.getElementById("oc-hint").style.display = "none";
296
+ }
297
297
  }
298
298
 
299
- function setPickBtn(active) {
300
- const btn = root.getElementById("oc-pick");
301
- if (btn) {
302
- btn.innerHTML = `${ICON.target}<span>${active ? "Picking… (Esc)" : "Select element"}</span>`;
303
- btn.classList.toggle("active", active);
299
+ // ---------- popup (onUI-style, positioned near element) ----------
300
+
301
+ function closePopup() {
302
+ if (popupEl) {
303
+ popupEl.remove();
304
+ popupEl = null;
304
305
  }
305
306
  }
306
307
 
307
- // ---------- cards ----------
308
+ function openPopup(element, rect) {
309
+ ensureUI();
310
+ closePopup();
311
+ const el = document.createElement("div");
312
+ el.className = "oc-popup oc-surface";
313
+ el.innerHTML = `
314
+ <div class="oc-head">
315
+ <span class="logo">${ICON.logo}</span>
316
+ <span class="oc-path" title="${escapeHtml(element.selector || "")}">${escapeHtml(descriptor(element))}</span>
317
+ <button class="iconbtn oc-x" title="Cancel (Esc)">${ICON.close}</button>
318
+ </div>
319
+ <div class="oc-body">
320
+ <textarea class="oc-ta" rows="3" placeholder="Describe the change or ask about this element…"></textarea>
321
+ <div class="oc-hintline">Cmd/Ctrl+Enter to send${targetLabel()}</div>
322
+ </div>
323
+ <div class="oc-foot">
324
+ <button class="btn oc-add">${ICON.plus}<span>Add to list</span></button>
325
+ <button class="btn primary oc-send">${ICON.send}<span>Send</span></button>
326
+ </div>`;
327
+ root.appendChild(el);
328
+ popupEl = el;
329
+ positionPopup(el, rect);
330
+
331
+ const ta = el.querySelector(".oc-ta");
332
+ setTimeout(() => ta.focus(), 30);
333
+
334
+ el.querySelector(".oc-x").addEventListener("click", closePopup);
335
+ const build = () => ({ instruction: ta.value.trim(), page: { url: location.href, title: document.title }, element });
336
+ el.querySelector(".oc-add").addEventListener("click", () => {
337
+ if (!ta.value.trim()) return ta.focus();
338
+ pending.push(build());
339
+ closePopup();
340
+ openSidebar();
341
+ });
342
+ el.querySelector(".oc-send").addEventListener("click", () => {
343
+ if (!ta.value.trim()) return ta.focus();
344
+ const one = build();
345
+ closePopup();
346
+ submit([one], true);
347
+ });
348
+ ta.addEventListener("keydown", (e) => {
349
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
350
+ e.preventDefault();
351
+ if (ta.value.trim()) {
352
+ const one = build();
353
+ closePopup();
354
+ submit([one], true);
355
+ }
356
+ }
357
+ });
358
+ }
359
+
360
+ function positionPopup(el, rect) {
361
+ const w = 320;
362
+ const r = el.getBoundingClientRect();
363
+ const h = r.height || 220;
364
+ let left = rect.left + rect.width + 14;
365
+ let top = rect.top;
366
+ if (left + w > window.innerWidth - 16) left = rect.left - w - 14;
367
+ if (left < 16) left = 16;
368
+ if (top + h > window.innerHeight - 16) top = window.innerHeight - h - 16;
369
+ if (top < 16) top = 16;
370
+ el.style.left = `${left}px`;
371
+ el.style.top = `${top}px`;
372
+ }
308
373
 
309
- function addCardForElement(element) {
310
- annotations.push({ instruction: "", mode: "act", page: { url: location.href, title: document.title }, element });
374
+ // ---------- sidebar ----------
375
+
376
+ function openSidebar() {
377
+ ensureUI();
378
+ sidebarOpen = true;
379
+ let sb = root.getElementById("oc-sidebar");
380
+ if (!sb) {
381
+ sb = document.createElement("aside");
382
+ sb.className = "oc-sidebar oc-surface";
383
+ sb.id = "oc-sidebar";
384
+ sb.innerHTML = `
385
+ <div class="oc-head">
386
+ <span class="logo">${ICON.logo}</span>
387
+ <span class="title">Annotations</span>
388
+ <span class="badge" id="oc-badge">0</span>
389
+ <button class="iconbtn" id="oc-close" title="Close (Alt+Shift+A)">${ICON.close}</button>
390
+ </div>
391
+ <div class="oc-list" id="oc-list"></div>
392
+ <div class="oc-foot-bar">
393
+ <label class="oc-target-row">
394
+ <span class="oc-target-label">Session</span>
395
+ <select class="oc-select" id="oc-session"></select>
396
+ </label>
397
+ <div class="oc-status" id="oc-status">…</div>
398
+ <button class="btn primary oc-submit" id="oc-submit" disabled>${ICON.send}<span>Submit to agent</span></button>
399
+ <div class="oc-toast" id="oc-toast"></div>
400
+ </div>`;
401
+ root.appendChild(sb);
402
+ sb.querySelector("#oc-close").addEventListener("click", closeSidebar);
403
+ sb.querySelector("#oc-session").addEventListener("change", (e) => {
404
+ targetSessionID = e.target.value || null;
405
+ });
406
+ sb.querySelector("#oc-submit").addEventListener("click", () => {
407
+ submit(pending, false);
408
+ });
409
+ sb.querySelector("#oc-list").addEventListener("click", (e) => {
410
+ const rm = e.target.closest(".oc-rm");
411
+ if (rm) {
412
+ pending.splice(Number(rm.dataset.i), 1);
413
+ renderCards();
414
+ }
415
+ });
416
+ }
417
+ sb.style.display = "flex";
311
418
  renderCards();
312
- const areas = root.querySelectorAll(".oc-card textarea");
313
- const last = areas[areas.length - 1];
314
- if (last) last.focus();
419
+ refreshStatus();
420
+ if (!statusTimer) statusTimer = setInterval(refreshStatus, 15000);
315
421
  }
316
422
 
317
- function escapeHtml(s) {
318
- return String(s || "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
423
+ function closeSidebar() {
424
+ sidebarOpen = false;
425
+ const sb = root && root.getElementById("oc-sidebar");
426
+ if (sb) sb.style.display = "none";
427
+ if (statusTimer) {
428
+ clearInterval(statusTimer);
429
+ statusTimer = null;
430
+ }
431
+ }
432
+
433
+ function toggleSidebar() {
434
+ if (sidebarOpen) closeSidebar();
435
+ else openSidebar();
319
436
  }
320
437
 
321
438
  function renderCards() {
439
+ if (!root) return;
322
440
  const list = root.getElementById("oc-list");
441
+ const badge = root.getElementById("oc-badge");
442
+ if (badge) badge.textContent = String(pending.length);
323
443
  list.innerHTML = "";
324
- if (annotations.length === 0) {
325
- const empty = document.createElement("div");
326
- empty.className = "oc-empty";
327
- empty.textContent = "No annotations yet. Click “Select element”, then click something on the page.";
328
- list.appendChild(empty);
444
+ if (pending.length === 0) {
445
+ const e = document.createElement("div");
446
+ e.className = "oc-empty";
447
+ e.textContent = "No annotations yet. Press Alt+A, click an element, and “Add to list”.";
448
+ list.appendChild(e);
329
449
  }
330
- annotations.forEach((a, i) => {
450
+ pending.forEach((a, i) => {
331
451
  const card = document.createElement("div");
332
452
  card.className = "oc-card";
333
453
  card.innerHTML = `
334
454
  <div class="oc-card-head">
335
- <span class="oc-desc" title="${escapeHtml(a.element.selector || "")}">${escapeHtml(shortDescriptor(a.element))}</span>
455
+ <span class="oc-desc" title="${escapeHtml(a.element.selector || "")}">${escapeHtml(descriptor(a.element))}</span>
336
456
  <button class="iconbtn oc-rm" data-i="${i}" title="Remove">${ICON.trash}</button>
337
457
  </div>
338
- <textarea rows="2" placeholder="Instruction for this element…" data-i="${i}">${escapeHtml(a.instruction)}</textarea>
339
- <div class="oc-modes">
340
- <button class="oc-mode ${a.mode === "act" ? "sel" : ""}" data-mode="act" data-i="${i}">${ICON.bolt}<span>Act now</span></button>
341
- <button class="oc-mode ${a.mode === "queue" ? "sel" : ""}" data-mode="queue" data-i="${i}">${ICON.layers}<span>Queue</span></button>
342
- </div>`;
458
+ <div class="oc-text">${escapeHtml(a.instruction || "(no instruction)")}</div>`;
343
459
  list.appendChild(card);
344
460
  });
345
- updateSubmit();
346
- }
347
-
348
- function updateSubmit() {
349
461
  const btn = root.getElementById("oc-submit");
350
- btn.disabled = annotations.length === 0;
351
- btn.querySelector("span").textContent = annotations.length
352
- ? `Submit ${annotations.length} to agent`
353
- : "Submit to agent";
462
+ if (btn) {
463
+ btn.disabled = pending.length === 0;
464
+ btn.querySelector("span").textContent = pending.length ? `Submit ${pending.length} to agent` : "Submit to agent";
465
+ }
354
466
  }
355
467
 
356
- // ---------- status ----------
468
+ // ---------- status + submit ----------
357
469
 
358
470
  function refreshStatus() {
471
+ if (!root) return;
359
472
  const el = root.getElementById("oc-status");
473
+ if (!el) return;
360
474
  el.className = "oc-status checking";
361
475
  el.textContent = "Checking connection…";
362
476
  chrome.runtime.sendMessage({ type: "oc-status" }, (res) => {
@@ -366,10 +480,12 @@
366
480
  return;
367
481
  }
368
482
  if (res.ok && res.data?.ok) {
369
- if (res.data.activeSession) {
370
- const q = res.data.queued ? ` · ${res.data.queued} queued` : "";
483
+ sessions = Array.isArray(res.data.sessions) ? res.data.sessions : [];
484
+ autoSessionID = res.data.sessionID || null;
485
+ renderSessions();
486
+ if (sessions.length || res.data.activeSession) {
371
487
  el.className = "oc-status good";
372
- el.textContent = `Connected${q}`;
488
+ el.textContent = "Connected";
373
489
  } else {
374
490
  el.className = "oc-status warn";
375
491
  el.textContent = "Connected — send a message in OpenCode first";
@@ -381,141 +497,79 @@
381
497
  });
382
498
  }
383
499
 
384
- // ---------- submit ----------
500
+ function renderSessions() {
501
+ if (!root) return;
502
+ const sel = root.getElementById("oc-session");
503
+ if (!sel) return;
504
+ // If the chosen target vanished, fall back to auto.
505
+ if (targetSessionID && !sessions.some((s) => s.id === targetSessionID)) targetSessionID = null;
506
+ const current = effectiveTarget();
507
+ const opts = [`<option value="">Auto (last active)</option>`].concat(
508
+ sessions.map((s) => {
509
+ const active = s.id === autoSessionID ? " • active" : "";
510
+ const label = `${(s.title || s.id).slice(0, 40)}${active}`;
511
+ const selected = s.id === current ? " selected" : "";
512
+ return `<option value="${escapeHtml(s.id)}"${selected}>${escapeHtml(label)}</option>`;
513
+ }),
514
+ );
515
+ // Keep "Auto" selected when no explicit target.
516
+ sel.innerHTML = opts.join("");
517
+ if (!targetSessionID) sel.value = "";
518
+ }
385
519
 
386
- function collectInstructions() {
387
- root.querySelectorAll(".oc-card textarea").forEach((ta) => {
388
- const i = Number(ta.dataset.i);
389
- if (annotations[i]) annotations[i].instruction = ta.value.trim();
390
- });
520
+ function toast(text, bad) {
521
+ if (!root) return;
522
+ // Prefer the sidebar's toast when it is open; otherwise use a standalone
523
+ // floating toast so a quick Send never forces the sidebar open.
524
+ const sb = root.getElementById("oc-sidebar");
525
+ let t = sb && sb.style.display !== "none" ? root.getElementById("oc-toast") : root.getElementById("oc-toast-float");
526
+ if (!t) {
527
+ t = document.createElement("div");
528
+ t.className = "oc-toast oc-toast-float";
529
+ t.id = "oc-toast-float";
530
+ root.appendChild(t);
531
+ }
532
+ t.textContent = text;
533
+ t.className = (t.id === "oc-toast-float" ? "oc-toast oc-toast-float" : "oc-toast") + " show" + (bad ? " bad" : "");
534
+ clearTimeout(t.__timer);
535
+ t.__timer = setTimeout(() => {
536
+ t.className = t.id === "oc-toast-float" ? "oc-toast oc-toast-float" : "oc-toast";
537
+ }, 4000);
391
538
  }
392
539
 
393
- function submit() {
394
- collectInstructions();
395
- if (annotations.length === 0) return;
396
- const btn = root.getElementById("oc-submit");
397
- btn.disabled = true;
398
- setToast("Submitting…");
399
- chrome.runtime.sendMessage({ type: "oc-submit", annotations }, (res) => {
540
+ function submit(annotations, quick) {
541
+ if (!annotations.length) return;
542
+ toast("Submitting…");
543
+ const sessionID = effectiveTarget() || undefined;
544
+ chrome.runtime.sendMessage({ type: "oc-submit", annotations, sessionID }, (res) => {
400
545
  if (chrome.runtime.lastError || !res) {
401
- setToast("Extension error", true);
402
- updateSubmit();
546
+ toast("Extension error", true);
403
547
  return;
404
548
  }
405
549
  if (res.ok) {
406
550
  const parts = [];
407
551
  if (res.injected) parts.push(`${res.injected} sent`);
408
552
  if (res.queued) parts.push(`${res.queued} queued`);
409
- setToast(parts.length ? parts.join(", ") : "Submitted");
410
- annotations = [];
411
- renderCards();
553
+ toast(parts.length ? parts.join(", ") : "Submitted");
554
+ if (!quick) {
555
+ pending = [];
556
+ renderCards();
557
+ }
412
558
  refreshStatus();
413
559
  } else {
414
- setToast(`Failed: ${res.error || "unknown"}`, true);
415
- updateSubmit();
416
- }
417
- });
418
- }
419
-
420
- function setToast(text, bad) {
421
- const t = root.getElementById("oc-toast");
422
- t.textContent = text;
423
- t.className = "oc-toast show" + (bad ? " bad" : "");
424
- clearTimeout(t.__timer);
425
- t.__timer = setTimeout(() => (t.className = "oc-toast"), 4000);
426
- }
427
-
428
- // ---------- UI ----------
429
-
430
- function buildUI() {
431
- host = document.createElement("div");
432
- host.id = "oc-annotation-host";
433
- host.style.cssText = "all: initial;";
434
- root = host.attachShadow({ mode: "open" });
435
- root.innerHTML = `
436
- <style>${STYLE}</style>
437
- <div id="oc-layer"></div>
438
- <div id="oc-highlight"></div>
439
- <div id="oc-hint"><span>Hover an element and click to annotate</span><span class="key">Esc</span></div>
440
- <aside id="oc-sidebar">
441
- <header>
442
- <span class="logo">${ICON.logo}</span>
443
- <span class="title">OpenCode Annotation</span>
444
- <button id="oc-close" class="iconbtn" title="Close (Alt+A)">${ICON.close}</button>
445
- </header>
446
- <div class="oc-actions"><button id="oc-pick" class="primary">${ICON.target}<span>Select element</span></button></div>
447
- <div id="oc-list"></div>
448
- <footer>
449
- <div id="oc-status" class="oc-status">…</div>
450
- <button id="oc-submit" class="primary" disabled>${ICON.send}<span>Submit to agent</span></button>
451
- <div id="oc-toast" class="oc-toast"></div>
452
- </footer>
453
- </aside>`;
454
- document.documentElement.appendChild(host);
455
-
456
- root.getElementById("oc-pick").addEventListener("click", () => (picking ? stopPicking() : startPicking()));
457
- root.getElementById("oc-close").addEventListener("click", () => toggle());
458
- root.getElementById("oc-submit").addEventListener("click", submit);
459
- root.getElementById("oc-list").addEventListener("click", (e) => {
460
- const rm = e.target.closest(".oc-rm");
461
- if (rm) {
462
- annotations.splice(Number(rm.dataset.i), 1);
463
- renderCards();
464
- return;
465
- }
466
- const mode = e.target.closest(".oc-mode");
467
- if (mode) {
468
- annotations[Number(mode.dataset.i)].mode = mode.dataset.mode;
469
- renderCards();
560
+ toast(`Failed: ${res.error || "unknown"}`, true);
561
+ if (!quick) {
562
+ // keep the list so the user can retry
563
+ renderCards();
564
+ }
470
565
  }
471
566
  });
472
-
473
- renderCards();
474
- refreshStatus();
475
567
  }
476
568
 
477
- function pushPage(on) {
478
- const el = document.documentElement;
479
- el.style.transition = "margin-right .22s ease";
480
- el.style.marginRight = on ? `${SIDEBAR_W}px` : "";
481
- }
569
+ // ---------- messages ----------
482
570
 
483
- function open() {
484
- if (!host) buildUI();
485
- else host.style.display = "";
486
- pushPage(true);
487
- document.addEventListener("mousemove", onMove, true);
488
- document.addEventListener("click", onClick, true);
489
- document.addEventListener("keydown", onKey, true);
490
- if (!statusTimer) statusTimer = setInterval(refreshStatus, 15000);
491
- refreshStatus();
492
- }
493
-
494
- function close() {
495
- stopPicking();
496
- pushPage(false);
497
- document.removeEventListener("mousemove", onMove, true);
498
- document.removeEventListener("click", onClick, true);
499
- document.removeEventListener("keydown", onKey, true);
500
- if (host) host.style.display = "none";
501
- if (statusTimer) {
502
- clearInterval(statusTimer);
503
- statusTimer = null;
504
- }
505
- }
506
-
507
- function toggle() {
508
- visible = !visible;
509
- if (visible) open();
510
- else close();
511
- }
512
-
513
- window.addEventListener("oc-annotation-toggle", toggle);
514
571
  chrome.runtime.onMessage.addListener((msg) => {
515
- if (msg?.type === "oc-toggle") toggle();
572
+ if (msg?.type === "oc-pick") startPicking();
573
+ else if (msg?.type === "oc-toggle-sidebar") toggleSidebar();
516
574
  });
517
-
518
- // First injection opens immediately.
519
- visible = true;
520
- open();
521
575
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-browser-annotation-plugin",
3
- "version": "0.2.1",
3
+ "version": "0.3.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",