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 +64 -12
- package/extension/background.js +15 -1
- package/extension/manifest.json +1 -1
- package/extension/overlay.js +223 -41
- package/package.json +1 -1
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
|
|
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.
|
|
277
|
-
|
|
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
|
},
|
package/extension/background.js
CHANGED
|
@@ -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,
|
|
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();
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "OpenCode Browser Annotation",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.0",
|
|
5
5
|
"description": "Alt+A to annotate an element, Alt+Shift+A for the annotation list. Sends element metadata to your OpenCode agent.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"activeTab",
|
package/extension/overlay.js
CHANGED
|
@@ -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-
|
|
95
|
-
.oc-
|
|
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:
|
|
98
|
-
.oc-status-row { display: flex; align-items: center; gap:
|
|
99
|
-
.oc-
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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
|
-
|
|
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
|
-
<
|
|
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-
|
|
456
|
-
|
|
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,
|
|
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
|
-
<
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
539
|
-
|
|
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
|
|
555
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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
|
+
"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",
|