@ahmednawaz/crank 0.1.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/.env.example +53 -0
- package/README.md +266 -0
- package/bin/crank.js +737 -0
- package/bookmarklet/crank-bookmarklet.js +2295 -0
- package/bookmarklet/crank-ui-helpers.js +355 -0
- package/bookmarklet/install-snippet.txt +5 -0
- package/bookmarklet/text-source.js +239 -0
- package/companion/agent-queue.js +485 -0
- package/companion/agent-runner.js +334 -0
- package/companion/bin/crank.js +69 -0
- package/companion/dev-loader.js +39 -0
- package/companion/glean-client.js +991 -0
- package/companion/package.json +18 -0
- package/companion/persist-apply.js +189 -0
- package/companion/selection-store.js +175 -0
- package/companion/server.js +1147 -0
- package/companion/text-dispatch.js +419 -0
- package/companion/vizpatch-bridge.js +49 -0
- package/lib/copy-labels.js +86 -0
- package/lib/detect.js +88 -0
- package/lib/env.js +23 -0
- package/lib/init.js +435 -0
- package/lib/load-team-env.js +43 -0
- package/lib/resolve-project.js +203 -0
- package/lib/team-auth.js +82 -0
- package/lib/urls.js +164 -0
- package/mcp-server/index.js +174 -0
- package/mcp-server/package.json +16 -0
- package/mcp-server/tools.js +480 -0
- package/package.json +57 -0
- package/panel/demo.html +62 -0
- package/skill/SKILL.md +60 -0
- package/skills/README.md +16 -0
- package/skills/copy-guidance.md +25 -0
- package/team.env +4 -0
- package/vite.js +75 -0
|
@@ -0,0 +1,2295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crank bookmarklet — uses Vizpatch tool-ui CSS/helpers + text-source classification.
|
|
3
|
+
* Preview (DOM) vs Save (source via SAVE_TEXT_CHANGE) mirrors Vizpatch text dispatch.
|
|
4
|
+
*/
|
|
5
|
+
(function () {
|
|
6
|
+
// Allow re-drag / re-inject to pick up companion updates.
|
|
7
|
+
if (typeof window.__crankTeardown === 'function') {
|
|
8
|
+
try {
|
|
9
|
+
window.__crankTeardown();
|
|
10
|
+
} catch (_) {
|
|
11
|
+
/* ignore */
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
if (window.__crankActive) {
|
|
15
|
+
window.__crankActive = false;
|
|
16
|
+
}
|
|
17
|
+
window.__crankActive = true;
|
|
18
|
+
|
|
19
|
+
const COMPANION_HTTP =
|
|
20
|
+
window.__CRANK_COMPANION__ ||
|
|
21
|
+
detectCompanionOrigin() ||
|
|
22
|
+
'http://127.0.0.1:3344';
|
|
23
|
+
const COMPANION_WS = COMPANION_HTTP.replace(/^http/i, 'ws') + '/ws';
|
|
24
|
+
const TEAM_TOKEN_KEY = 'crank:teamToken';
|
|
25
|
+
|
|
26
|
+
function getTeamToken() {
|
|
27
|
+
try {
|
|
28
|
+
return localStorage.getItem(TEAM_TOKEN_KEY) || '';
|
|
29
|
+
} catch (_) {
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function setTeamToken(value) {
|
|
35
|
+
try {
|
|
36
|
+
localStorage.setItem(TEAM_TOKEN_KEY, String(value || '').trim());
|
|
37
|
+
} catch (_) {
|
|
38
|
+
/* ignore */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function ensureTeamToken() {
|
|
43
|
+
const status = await fetch(COMPANION_HTTP + '/api/auth/status').then(function (r) {
|
|
44
|
+
return r.json();
|
|
45
|
+
}).catch(function () {
|
|
46
|
+
return { teamAuthRequired: false };
|
|
47
|
+
});
|
|
48
|
+
if (!status.teamAuthRequired) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
if (getTeamToken()) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
const entered = window.prompt(
|
|
55
|
+
status.hint ||
|
|
56
|
+
'Enter the Crank team token (ask your team lead):'
|
|
57
|
+
);
|
|
58
|
+
if (!entered) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
const verify = await fetch(COMPANION_HTTP + '/api/auth/verify', {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'Content-Type': 'application/json' },
|
|
64
|
+
body: JSON.stringify({ token: entered.trim() })
|
|
65
|
+
}).then(function (r) {
|
|
66
|
+
return r.json();
|
|
67
|
+
}).catch(function () {
|
|
68
|
+
return { ok: false };
|
|
69
|
+
});
|
|
70
|
+
if (!verify.ok) {
|
|
71
|
+
window.alert('Invalid Crank team token');
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
setTeamToken(entered.trim());
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function authHeaders(extra) {
|
|
79
|
+
const h = extra || {};
|
|
80
|
+
const token = getTeamToken();
|
|
81
|
+
if (token) {
|
|
82
|
+
h['X-Crank-Team-Token'] = token;
|
|
83
|
+
}
|
|
84
|
+
return h;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function wsUrlWithToken() {
|
|
88
|
+
const token = getTeamToken();
|
|
89
|
+
if (!token) {
|
|
90
|
+
return COMPANION_WS;
|
|
91
|
+
}
|
|
92
|
+
const sep = COMPANION_WS.indexOf('?') >= 0 ? '&' : '?';
|
|
93
|
+
return COMPANION_WS + sep + 'teamToken=' + encodeURIComponent(token);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function detectCompanionOrigin() {
|
|
97
|
+
try {
|
|
98
|
+
const scripts = document.querySelectorAll(
|
|
99
|
+
'script[src*="bookmarklet.js"],script[src*="dev-loader.js"]'
|
|
100
|
+
);
|
|
101
|
+
for (const s of scripts) {
|
|
102
|
+
return new URL(s.src, location.href).origin;
|
|
103
|
+
}
|
|
104
|
+
} catch (_) {}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function loadScript(src) {
|
|
109
|
+
return new Promise(function (resolve, reject) {
|
|
110
|
+
const existing = document.querySelector('script[src="' + src + '"]');
|
|
111
|
+
if (existing) {
|
|
112
|
+
resolve();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const s = document.createElement('script');
|
|
116
|
+
s.src = src;
|
|
117
|
+
s.onload = function () {
|
|
118
|
+
resolve();
|
|
119
|
+
};
|
|
120
|
+
s.onerror = reject;
|
|
121
|
+
document.documentElement.appendChild(s);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function loadCss(href) {
|
|
126
|
+
let link = document.querySelector('link[data-crank-ui="1"]');
|
|
127
|
+
if (!link) {
|
|
128
|
+
link = document.createElement('link');
|
|
129
|
+
link.rel = 'stylesheet';
|
|
130
|
+
link.setAttribute('data-crank-ui', '1');
|
|
131
|
+
document.documentElement.appendChild(link);
|
|
132
|
+
}
|
|
133
|
+
link.href = href;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let selectedEl = null;
|
|
137
|
+
let selectedNodes = [];
|
|
138
|
+
let sessionId = null;
|
|
139
|
+
let socket = null;
|
|
140
|
+
let selecting = false;
|
|
141
|
+
let originalSnapshot = [];
|
|
142
|
+
let lastVariants = [];
|
|
143
|
+
let activeVariantId = null;
|
|
144
|
+
/** Bumps on each Ask Glean; stale WS progress after variants must not reopen thinking. */
|
|
145
|
+
let gleanEpoch = 0;
|
|
146
|
+
let gleanActiveEpoch = 0;
|
|
147
|
+
const OPTION_COUNT_KEY = 'crank.gleanOptionCount';
|
|
148
|
+
const DEFAULT_GLEAN_OPTION_COUNT = 3;
|
|
149
|
+
|
|
150
|
+
function normalizeRole(role) {
|
|
151
|
+
return String(role || 'text').toLowerCase();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isTextLikeRole(role) {
|
|
155
|
+
const r = normalizeRole(role);
|
|
156
|
+
return r === 'text' || r === 'label' || r === 'body';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function hasSemanticStructure(nodes) {
|
|
160
|
+
return (Array.isArray(nodes) ? nodes : []).some(function (n) {
|
|
161
|
+
const r = normalizeRole(n.role);
|
|
162
|
+
return r === 'heading' || r === 'action' || r === 'button';
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function textLikeNodes(nodes) {
|
|
167
|
+
return (Array.isArray(nodes) ? nodes : []).filter(function (n) {
|
|
168
|
+
return isTextLikeRole(n.role);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function unitDisplayLabel(node, index, nodes) {
|
|
173
|
+
const list = Array.isArray(nodes) ? nodes : [];
|
|
174
|
+
const role = normalizeRole(node && node.role);
|
|
175
|
+
if (role === 'heading') return 'Heading';
|
|
176
|
+
if (role === 'action' || role === 'button') return 'CTA';
|
|
177
|
+
if (role === 'label') return 'Label';
|
|
178
|
+
const semantic = hasSemanticStructure(list);
|
|
179
|
+
const texts = textLikeNodes(list);
|
|
180
|
+
if (semantic) {
|
|
181
|
+
const bodyIndex = texts.findIndex(function (n) {
|
|
182
|
+
return n === node;
|
|
183
|
+
});
|
|
184
|
+
if (bodyIndex >= 0) {
|
|
185
|
+
return texts.length > 1 ? 'Body ' + (bodyIndex + 1) : 'Body';
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (list.length <= 1) return 'Copy 1';
|
|
189
|
+
return 'Copy ' + (index + 1);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function assignUnitLabels(nodes) {
|
|
193
|
+
const list = Array.isArray(nodes) ? nodes : [];
|
|
194
|
+
return list.map(function (node, index) {
|
|
195
|
+
return Object.assign({}, node, {
|
|
196
|
+
label: node.label || unitDisplayLabel(node, index, list)
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function getGleanOptionCount() {
|
|
202
|
+
const sel = panel && panel.querySelector('#vc-option-count');
|
|
203
|
+
const raw = sel ? sel.value : localStorage.getItem(OPTION_COUNT_KEY);
|
|
204
|
+
const n = parseInt(String(raw || DEFAULT_GLEAN_OPTION_COUNT), 10);
|
|
205
|
+
if (!Number.isFinite(n)) return DEFAULT_GLEAN_OPTION_COUNT;
|
|
206
|
+
return Math.min(6, Math.max(2, n));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const highlight = document.createElement('div');
|
|
210
|
+
highlight.id = 'crank-highlight';
|
|
211
|
+
highlight.style.cssText =
|
|
212
|
+
'position:fixed;pointer-events:none;z-index:2147483646;border:2px solid oklch(0.45 0.08 180);background:oklch(0.7 0.06 180 / 0.12);border-radius:3px;transition:all 80ms ease;';
|
|
213
|
+
|
|
214
|
+
const panel = document.createElement('div');
|
|
215
|
+
panel.id = 'vp-shell';
|
|
216
|
+
panel.setAttribute('data-crank-panel', '1');
|
|
217
|
+
panel.style.cssText =
|
|
218
|
+
'position:fixed;top:16px;right:16px;width:380px;max-height:calc(100vh - 32px);overflow:auto;z-index:2147483647;';
|
|
219
|
+
|
|
220
|
+
function UI() {
|
|
221
|
+
return window.CrankUI || {};
|
|
222
|
+
}
|
|
223
|
+
function TS() {
|
|
224
|
+
return window.CrankTextSource || {};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Short phrase for the *current* Cursor chat — paste once; skill/MCP pulls pending.
|
|
228
|
+
// Cursor has no public API to inject into an open Composer; deeplink opens a *new* chat.
|
|
229
|
+
const AGENT_SHORT_PROMPT = 'Apply the pending Crank change';
|
|
230
|
+
const CURSOR_DEEPLINK_MAX = 7800;
|
|
231
|
+
|
|
232
|
+
function setStatus(text) {
|
|
233
|
+
const el = panel.querySelector('#vc-status');
|
|
234
|
+
if (el) el.textContent = text;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function showToast(text, tone) {
|
|
238
|
+
let toast = panel.querySelector('#vc-toast');
|
|
239
|
+
if (!toast) {
|
|
240
|
+
toast = document.createElement('div');
|
|
241
|
+
toast.id = 'vc-toast';
|
|
242
|
+
const status = panel.querySelector('#vc-status');
|
|
243
|
+
if (status && status.parentNode) {
|
|
244
|
+
status.parentNode.insertBefore(toast, status.nextSibling);
|
|
245
|
+
} else {
|
|
246
|
+
panel.appendChild(toast);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const bg =
|
|
250
|
+
tone === 'error'
|
|
251
|
+
? 'oklch(0.93 0.04 25)'
|
|
252
|
+
: tone === 'ok'
|
|
253
|
+
? 'oklch(0.93 0.04 160)'
|
|
254
|
+
: 'oklch(0.95 0.03 180)';
|
|
255
|
+
const border =
|
|
256
|
+
tone === 'error'
|
|
257
|
+
? 'oklch(0.55 0.14 25)'
|
|
258
|
+
: tone === 'ok'
|
|
259
|
+
? 'oklch(0.45 0.1 160)'
|
|
260
|
+
: 'oklch(0.45 0.08 180)';
|
|
261
|
+
toast.style.cssText =
|
|
262
|
+
'display:block;margin:0 0 10px;padding:8px 10px;border:1px solid ' +
|
|
263
|
+
border +
|
|
264
|
+
';border-radius:8px;background:' +
|
|
265
|
+
bg +
|
|
266
|
+
';color:var(--foreground,#111);font:12px/1.45 ui-sans-serif,system-ui,sans-serif;';
|
|
267
|
+
toast.textContent = text;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function hideToast() {
|
|
271
|
+
const toast = panel.querySelector('#vc-toast');
|
|
272
|
+
if (toast) toast.style.display = 'none';
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function copyTextToClipboard(text) {
|
|
276
|
+
const value = String(text || '');
|
|
277
|
+
if (!value) return false;
|
|
278
|
+
try {
|
|
279
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
280
|
+
await navigator.clipboard.writeText(value);
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
} catch (_) {
|
|
284
|
+
/* fall through */
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
const ta = document.createElement('textarea');
|
|
288
|
+
ta.value = value;
|
|
289
|
+
ta.setAttribute('readonly', '');
|
|
290
|
+
ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;';
|
|
291
|
+
document.body.appendChild(ta);
|
|
292
|
+
ta.focus();
|
|
293
|
+
ta.select();
|
|
294
|
+
const ok = document.execCommand('copy');
|
|
295
|
+
ta.remove();
|
|
296
|
+
return !!ok;
|
|
297
|
+
} catch (_) {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function buildCursorPromptDeeplink(promptText) {
|
|
303
|
+
const text = String(promptText || '').trim();
|
|
304
|
+
if (!text) return '';
|
|
305
|
+
const base = 'cursor://anysphere.cursor-deeplink/prompt?text=';
|
|
306
|
+
let encoded = encodeURIComponent(text);
|
|
307
|
+
if (base.length + encoded.length <= CURSOR_DEEPLINK_MAX) {
|
|
308
|
+
return base + encoded;
|
|
309
|
+
}
|
|
310
|
+
encoded = encodeURIComponent(AGENT_SHORT_PROMPT);
|
|
311
|
+
if (base.length + encoded.length <= CURSOR_DEEPLINK_MAX) {
|
|
312
|
+
return base + encoded;
|
|
313
|
+
}
|
|
314
|
+
return '';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Prefill Cursor chat via official prompt deeplink (user must confirm). */
|
|
318
|
+
function openCursorPrompt(promptText) {
|
|
319
|
+
const href = buildCursorPromptDeeplink(promptText);
|
|
320
|
+
if (!href) return false;
|
|
321
|
+
try {
|
|
322
|
+
const a = document.createElement('a');
|
|
323
|
+
a.href = href;
|
|
324
|
+
a.rel = 'noopener';
|
|
325
|
+
a.style.display = 'none';
|
|
326
|
+
document.documentElement.appendChild(a);
|
|
327
|
+
a.click();
|
|
328
|
+
a.remove();
|
|
329
|
+
return true;
|
|
330
|
+
} catch (_) {
|
|
331
|
+
try {
|
|
332
|
+
window.open(href, '_self');
|
|
333
|
+
return true;
|
|
334
|
+
} catch (__) {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function hideAgentHandoff() {
|
|
341
|
+
const box = panel.querySelector('#vc-agent-handoff');
|
|
342
|
+
if (box) {
|
|
343
|
+
box.style.display = 'none';
|
|
344
|
+
box.innerHTML = '';
|
|
345
|
+
}
|
|
346
|
+
stopAgentRunPoll();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let agentRunPollTimer = null;
|
|
350
|
+
let agentStatusPollTimer = null;
|
|
351
|
+
|
|
352
|
+
function stopAgentRunPoll() {
|
|
353
|
+
if (agentRunPollTimer) {
|
|
354
|
+
clearInterval(agentRunPollTimer);
|
|
355
|
+
agentRunPollTimer = null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function stopAgentStatusPoll() {
|
|
360
|
+
if (agentStatusPollTimer) {
|
|
361
|
+
clearInterval(agentStatusPollTimer);
|
|
362
|
+
agentStatusPollTimer = null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function statusLabelForJob(job) {
|
|
367
|
+
const s = String((job && job.status) || '').toLowerCase();
|
|
368
|
+
if (s === 'queued') return 'Queued';
|
|
369
|
+
if (s === 'running') return 'Running';
|
|
370
|
+
if (s === 'done') return 'Done';
|
|
371
|
+
if (s === 'failed') return 'Failed';
|
|
372
|
+
return s || '—';
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function updateAgentHandoffJobLine(job) {
|
|
376
|
+
const box = panel.querySelector('#vc-agent-handoff');
|
|
377
|
+
if (!box || box.style.display === 'none') return;
|
|
378
|
+
const el = box.querySelector('#vc-agent-job-status');
|
|
379
|
+
if (!el) return;
|
|
380
|
+
const ui = UI();
|
|
381
|
+
const escape = ui.escapeHtml || function (v) {
|
|
382
|
+
return String(v == null ? '' : v);
|
|
383
|
+
};
|
|
384
|
+
if (!job) {
|
|
385
|
+
el.style.display = 'none';
|
|
386
|
+
el.textContent = '';
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
el.style.display = 'block';
|
|
390
|
+
el.innerHTML =
|
|
391
|
+
'Auto-run: <strong>' +
|
|
392
|
+
escape(statusLabelForJob(job)) +
|
|
393
|
+
'</strong>' +
|
|
394
|
+
(job.error ? ' — ' + escape(job.error) : '');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function showAgentHandoffError(message, detail) {
|
|
398
|
+
const box = panel.querySelector('#vc-agent-handoff');
|
|
399
|
+
if (!box) return;
|
|
400
|
+
const ui = UI();
|
|
401
|
+
const escape = ui.escapeHtml || function (v) {
|
|
402
|
+
return String(v == null ? '' : v);
|
|
403
|
+
};
|
|
404
|
+
box.style.display = 'block';
|
|
405
|
+
box.innerHTML =
|
|
406
|
+
'<div class="vp-type-label" style="font-weight:620;margin-bottom:6px;color:oklch(0.45 0.14 25);">Agent queue failed</div>' +
|
|
407
|
+
'<div class="vp-type-support" style="line-height:1.45;color:var(--foreground);">' +
|
|
408
|
+
escape(message || 'Unknown error') +
|
|
409
|
+
(detail
|
|
410
|
+
? '<br/><span style="opacity:0.85;">' + escape(detail) + '</span>'
|
|
411
|
+
: '') +
|
|
412
|
+
'</div>';
|
|
413
|
+
try {
|
|
414
|
+
box.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
415
|
+
} catch (_) {
|
|
416
|
+
/* ignore */
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function showAgentHandoff(task, options) {
|
|
421
|
+
const box = panel.querySelector('#vc-agent-handoff');
|
|
422
|
+
if (!box) return;
|
|
423
|
+
const opts = options || {};
|
|
424
|
+
const ui = UI();
|
|
425
|
+
const escape = ui.escapeHtml || function (v) {
|
|
426
|
+
return String(v == null ? '' : v);
|
|
427
|
+
};
|
|
428
|
+
const btn = ui.vpButtonHtml || function (_v, label, attrs) {
|
|
429
|
+
return (
|
|
430
|
+
'<button type="button" id="' +
|
|
431
|
+
(attrs && attrs.id ? attrs.id : '') +
|
|
432
|
+
'">' +
|
|
433
|
+
label +
|
|
434
|
+
'</button>'
|
|
435
|
+
);
|
|
436
|
+
};
|
|
437
|
+
const prompt = String((task && task.prompt) || '');
|
|
438
|
+
const label = (task && task.variantLabel) || '';
|
|
439
|
+
const changeCount =
|
|
440
|
+
task && typeof task.changeCount === 'number'
|
|
441
|
+
? task.changeCount
|
|
442
|
+
: Array.isArray(task && task.changes)
|
|
443
|
+
? task.changes.length
|
|
444
|
+
: 0;
|
|
445
|
+
const apiKeyConfigured = opts.apiKeyConfigured === true;
|
|
446
|
+
const job = opts.job || null;
|
|
447
|
+
box.style.display = 'block';
|
|
448
|
+
box.innerHTML =
|
|
449
|
+
'<div class="vp-type-label" style="font-weight:620;margin-bottom:6px;">Queued for agent' +
|
|
450
|
+
(label ? ' (“' + escape(label) + '”)' : '') +
|
|
451
|
+
(changeCount ? ' · ' + changeCount + ' change' + (changeCount === 1 ? '' : 's') : '') +
|
|
452
|
+
'</div>' +
|
|
453
|
+
'<div class="vp-type-support" style="margin-bottom:8px;line-height:1.45;color:var(--foreground);">' +
|
|
454
|
+
(opts.waitingWatch
|
|
455
|
+
? 'Waiting for agent… (skill/MCP watch active)'
|
|
456
|
+
: 'Paste in your <strong>current</strong> Cursor chat: <code>' +
|
|
457
|
+
escape(AGENT_SHORT_PROMPT) +
|
|
458
|
+
'</code> — the <code>crank-apply-changes</code> skill pulls pending changes via MCP. Cursor has no public API to inject into an open chat.') +
|
|
459
|
+
(!apiKeyConfigured
|
|
460
|
+
? '<br/><span style="opacity:0.85;">Optional: set <code>CURSOR_API_KEY</code> for one-click fully automatic apply (separate agent, not your open chat).</span>'
|
|
461
|
+
: '') +
|
|
462
|
+
'</div>' +
|
|
463
|
+
'<div id="vc-agent-job-status" class="vp-type-support" style="display:' +
|
|
464
|
+
(job ? 'block' : 'none') +
|
|
465
|
+
';margin-bottom:8px;line-height:1.45;color:var(--foreground);">' +
|
|
466
|
+
(job
|
|
467
|
+
? 'Auto-run: <strong>' +
|
|
468
|
+
escape(statusLabelForJob(job)) +
|
|
469
|
+
'</strong>' +
|
|
470
|
+
(job.error ? ' — ' + escape(job.error) : '')
|
|
471
|
+
: '') +
|
|
472
|
+
'</div>' +
|
|
473
|
+
'<div style="display:flex;gap:8px;margin-top:8px;flex-wrap:wrap;">' +
|
|
474
|
+
(apiKeyConfigured
|
|
475
|
+
? btn('primary', 'Run fully automatic', { id: 'vc-agent-auto', type: 'button' }, {
|
|
476
|
+
size: 'compact',
|
|
477
|
+
flex: true
|
|
478
|
+
})
|
|
479
|
+
: '') +
|
|
480
|
+
btn('secondary', 'Copy again', { id: 'vc-agent-copy', type: 'button' }, {
|
|
481
|
+
size: 'compact',
|
|
482
|
+
flex: true
|
|
483
|
+
}) +
|
|
484
|
+
btn('ghost', 'Open new chat instead', { id: 'vc-agent-open', type: 'button' }, {
|
|
485
|
+
size: 'compact',
|
|
486
|
+
flex: true
|
|
487
|
+
}) +
|
|
488
|
+
btn('ghost', 'Dismiss', { id: 'vc-agent-dismiss', type: 'button' }, {
|
|
489
|
+
size: 'compact',
|
|
490
|
+
flex: true
|
|
491
|
+
}) +
|
|
492
|
+
'</div>' +
|
|
493
|
+
'<div class="vp-type-label" style="margin-top:10px;font-size:12px;">Full prompt</div>' +
|
|
494
|
+
'<textarea id="vc-agent-prompt" readonly rows="8" style="width:100%;box-sizing:border-box;resize:vertical;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;padding:8px;border:1px solid var(--border);border-radius:6px;background:var(--background, #fff);color:var(--foreground);margin-top:6px;max-height:240px;overflow:auto;">' +
|
|
495
|
+
escape(prompt) +
|
|
496
|
+
'</textarea>' +
|
|
497
|
+
'<div style="margin-top:6px;">' +
|
|
498
|
+
btn('ghost', 'Copy full prompt', { id: 'vc-agent-copy-full', type: 'button' }, {
|
|
499
|
+
size: 'compact',
|
|
500
|
+
flex: true
|
|
501
|
+
}) +
|
|
502
|
+
'</div>';
|
|
503
|
+
|
|
504
|
+
const autoBtn = box.querySelector('#vc-agent-auto');
|
|
505
|
+
const openBtn = box.querySelector('#vc-agent-open');
|
|
506
|
+
const copyBtn = box.querySelector('#vc-agent-copy');
|
|
507
|
+
const copyFullBtn = box.querySelector('#vc-agent-copy-full');
|
|
508
|
+
const dismissBtn = box.querySelector('#vc-agent-dismiss');
|
|
509
|
+
|
|
510
|
+
if (autoBtn) {
|
|
511
|
+
autoBtn.addEventListener('click', function (ev) {
|
|
512
|
+
if (ev && ev.preventDefault) ev.preventDefault();
|
|
513
|
+
startFullyAutomaticRun(task);
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
if (openBtn) {
|
|
517
|
+
openBtn.addEventListener('click', function (ev) {
|
|
518
|
+
if (ev && ev.preventDefault) ev.preventDefault();
|
|
519
|
+
const ok = openCursorPrompt(prompt || AGENT_SHORT_PROMPT);
|
|
520
|
+
showToast(
|
|
521
|
+
ok
|
|
522
|
+
? 'Opened a new Cursor chat (deeplink) — confirm if prompted'
|
|
523
|
+
: 'Could not open deeplink — paste “' +
|
|
524
|
+
AGENT_SHORT_PROMPT +
|
|
525
|
+
'” in your current chat instead',
|
|
526
|
+
ok ? 'ok' : 'error'
|
|
527
|
+
);
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
if (copyBtn) {
|
|
531
|
+
copyBtn.addEventListener('click', function () {
|
|
532
|
+
copyTextToClipboard(AGENT_SHORT_PROMPT).then(function (ok) {
|
|
533
|
+
showToast(
|
|
534
|
+
ok
|
|
535
|
+
? 'Copied — paste in your current Cursor chat'
|
|
536
|
+
: 'Copy failed — type: ' + AGENT_SHORT_PROMPT,
|
|
537
|
+
ok ? 'ok' : 'error'
|
|
538
|
+
);
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
if (copyFullBtn) {
|
|
543
|
+
copyFullBtn.addEventListener('click', function () {
|
|
544
|
+
copyTextToClipboard(prompt || AGENT_SHORT_PROMPT).then(function (ok) {
|
|
545
|
+
showToast(
|
|
546
|
+
ok ? 'Full prompt copied' : 'Copy failed — select the textarea',
|
|
547
|
+
ok ? 'ok' : 'error'
|
|
548
|
+
);
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
if (dismissBtn) {
|
|
553
|
+
dismissBtn.addEventListener('click', function () {
|
|
554
|
+
hideAgentHandoff();
|
|
555
|
+
hideToast();
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
try {
|
|
559
|
+
box.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
560
|
+
} catch (_) {
|
|
561
|
+
/* ignore */
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function fetchAgentStatus() {
|
|
566
|
+
try {
|
|
567
|
+
const data = await postJson('/api/agent-status', null, {
|
|
568
|
+
method: 'GET',
|
|
569
|
+
timeoutMs: 8000
|
|
570
|
+
});
|
|
571
|
+
return data || {};
|
|
572
|
+
} catch (_) {
|
|
573
|
+
try {
|
|
574
|
+
const res = await fetch(COMPANION_HTTP + '/api/agent-status', {
|
|
575
|
+
method: 'GET',
|
|
576
|
+
headers: authHeaders()
|
|
577
|
+
});
|
|
578
|
+
return await res.json();
|
|
579
|
+
} catch (__) {
|
|
580
|
+
return {};
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function pollAgentRun(jobId) {
|
|
586
|
+
stopAgentRunPoll();
|
|
587
|
+
if (!jobId) return;
|
|
588
|
+
agentRunPollTimer = setInterval(async function () {
|
|
589
|
+
try {
|
|
590
|
+
const res = await fetch(
|
|
591
|
+
COMPANION_HTTP + '/api/agent-run/' + encodeURIComponent(jobId),
|
|
592
|
+
{ method: 'GET', headers: authHeaders() }
|
|
593
|
+
);
|
|
594
|
+
const data = await res.json();
|
|
595
|
+
const job = data && data.job;
|
|
596
|
+
if (!job) return;
|
|
597
|
+
updateAgentHandoffJobLine(job);
|
|
598
|
+
setStatus('Agent auto-run: ' + statusLabelForJob(job));
|
|
599
|
+
if (job.status === 'done' || job.status === 'failed') {
|
|
600
|
+
stopAgentRunPoll();
|
|
601
|
+
setStatus(
|
|
602
|
+
job.status === 'done'
|
|
603
|
+
? 'Agent auto-run done'
|
|
604
|
+
: 'Agent auto-run failed: ' + (job.error || 'unknown')
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
} catch (_) {
|
|
608
|
+
/* ignore transient poll errors */
|
|
609
|
+
}
|
|
610
|
+
}, 2000);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function startFullyAutomaticRun(task) {
|
|
614
|
+
showToast('Starting fully automatic agent…', 'info');
|
|
615
|
+
setStatus('Starting fully automatic agent…');
|
|
616
|
+
try {
|
|
617
|
+
const body = {
|
|
618
|
+
sessionId: sessionId,
|
|
619
|
+
variantId: (task && task.variantId) || activeVariantId
|
|
620
|
+
};
|
|
621
|
+
const res = await fetch(COMPANION_HTTP + '/api/agent-run', {
|
|
622
|
+
method: 'POST',
|
|
623
|
+
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
624
|
+
body: JSON.stringify(body)
|
|
625
|
+
});
|
|
626
|
+
const data = await res.json().catch(function () {
|
|
627
|
+
return {};
|
|
628
|
+
});
|
|
629
|
+
if (!res.ok || data.ok === false) {
|
|
630
|
+
const msg =
|
|
631
|
+
(data && data.error) ||
|
|
632
|
+
'Set CURSOR_API_KEY to enable one-click Agent apply';
|
|
633
|
+
showToast(msg, 'error');
|
|
634
|
+
setStatus(msg);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const job = data.job;
|
|
638
|
+
showAgentHandoff(task || data.task, {
|
|
639
|
+
apiKeyConfigured: true,
|
|
640
|
+
job: job
|
|
641
|
+
});
|
|
642
|
+
if (job && job.id) pollAgentRun(job.id);
|
|
643
|
+
setStatus('Agent auto-run: ' + statusLabelForJob(job));
|
|
644
|
+
} catch (err) {
|
|
645
|
+
const msg =
|
|
646
|
+
err && err.message
|
|
647
|
+
? String(err.message)
|
|
648
|
+
: 'Fully automatic run failed';
|
|
649
|
+
showToast(msg, 'error');
|
|
650
|
+
setStatus(msg);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
let lastFinishedAgentTaskId = null;
|
|
655
|
+
|
|
656
|
+
async function finishAgentQueue(task, options) {
|
|
657
|
+
const opts = options || {};
|
|
658
|
+
if (
|
|
659
|
+
task &&
|
|
660
|
+
task.id &&
|
|
661
|
+
task.id === lastFinishedAgentTaskId &&
|
|
662
|
+
!opts.force
|
|
663
|
+
) {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (task && task.id) lastFinishedAgentTaskId = task.id;
|
|
667
|
+
let apiKeyConfigured = opts.apiKeyConfigured;
|
|
668
|
+
if (apiKeyConfigured == null) {
|
|
669
|
+
const st = await fetchAgentStatus();
|
|
670
|
+
apiKeyConfigured = !!(st && st.apiKeyConfigured);
|
|
671
|
+
}
|
|
672
|
+
// Same-chat primary: queue for MCP pull + clipboard short phrase.
|
|
673
|
+
// Do NOT auto-open cursor:// prompt deeplink (that opens a *new* chat).
|
|
674
|
+
showAgentHandoff(task, {
|
|
675
|
+
apiKeyConfigured: apiKeyConfigured,
|
|
676
|
+
waitingWatch: !!opts.waitingWatch,
|
|
677
|
+
job: opts.job || null
|
|
678
|
+
});
|
|
679
|
+
let copied = false;
|
|
680
|
+
if (!opts.skipCopy) {
|
|
681
|
+
copied = await copyTextToClipboard(AGENT_SHORT_PROMPT);
|
|
682
|
+
}
|
|
683
|
+
const msg =
|
|
684
|
+
'Queued — paste in your current Cursor chat (or ask: ' +
|
|
685
|
+
AGENT_SHORT_PROMPT +
|
|
686
|
+
')' +
|
|
687
|
+
(copied ? ' · copied' : '');
|
|
688
|
+
showToast(msg, copied || opts.skipCopy ? 'ok' : 'info');
|
|
689
|
+
setStatus(msg);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function cssPath(el) {
|
|
693
|
+
if (!(el instanceof Element)) return '';
|
|
694
|
+
const parts = [];
|
|
695
|
+
let node = el;
|
|
696
|
+
while (node && node.nodeType === 1 && parts.length < 8) {
|
|
697
|
+
let part = node.tagName.toLowerCase();
|
|
698
|
+
if (node.id) {
|
|
699
|
+
part += '#' + CSS.escape(node.id);
|
|
700
|
+
parts.unshift(part);
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
const parent = node.parentElement;
|
|
704
|
+
if (parent) {
|
|
705
|
+
const siblings = Array.from(parent.children).filter(
|
|
706
|
+
(c) => c.tagName === node.tagName
|
|
707
|
+
);
|
|
708
|
+
if (siblings.length > 1) {
|
|
709
|
+
part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
parts.unshift(part);
|
|
713
|
+
node = parent;
|
|
714
|
+
}
|
|
715
|
+
return parts.join(' > ');
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function isInlineTag(tag) {
|
|
719
|
+
return /^(span|strong|b|em|i|u|small|mark|code|a|br|wbr|svg|path)$/i.test(tag);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function isLeafTextWrapper(el) {
|
|
723
|
+
if (!(el instanceof Element)) return false;
|
|
724
|
+
const tag = el.tagName.toLowerCase();
|
|
725
|
+
if (!/^(div|span|section|article|header|footer|aside|blockquote)$/.test(tag)) {
|
|
726
|
+
return false;
|
|
727
|
+
}
|
|
728
|
+
const kids = Array.from(el.childNodes);
|
|
729
|
+
if (!kids.length) return false;
|
|
730
|
+
return kids.every(function (child) {
|
|
731
|
+
if (child.nodeType === 3) return true;
|
|
732
|
+
if (child.nodeType !== 1) return false;
|
|
733
|
+
return isInlineTag(child.tagName);
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function isCopyUnitRoot(el) {
|
|
738
|
+
if (!(el instanceof Element)) return false;
|
|
739
|
+
const tag = el.tagName.toLowerCase();
|
|
740
|
+
if (/^(h1|h2|h3|h4|h5|h6|button|label|li|p|figcaption|caption|td|th)$/.test(tag)) {
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
if (tag === 'a' && el.childNodes.length) return true;
|
|
744
|
+
return isLeafTextWrapper(el);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function unitRole(el) {
|
|
748
|
+
const tag = el.tagName.toLowerCase();
|
|
749
|
+
if (/^(h1|h2|h3|h4|h5|h6)$/.test(tag)) return 'heading';
|
|
750
|
+
if (/^(button|a)$/.test(tag)) return 'action';
|
|
751
|
+
if (/^(label|figcaption|caption)$/.test(tag)) return 'label';
|
|
752
|
+
const ariaRole = String(el.getAttribute('role') || '').toLowerCase();
|
|
753
|
+
if (ariaRole === 'heading') return 'heading';
|
|
754
|
+
if (ariaRole === 'button' || ariaRole === 'link') return 'action';
|
|
755
|
+
const cls = String(el.className || '').toLowerCase();
|
|
756
|
+
if (/\b(heading|headline|title|hero-title|card-title)\b/.test(cls)) {
|
|
757
|
+
return 'heading';
|
|
758
|
+
}
|
|
759
|
+
if (/\b(cta|btn|button|action-link)\b/.test(cls)) return 'action';
|
|
760
|
+
return 'text';
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Within one copy unit, keep the full sentence together but chip any
|
|
765
|
+
* dynamic/bound fragments (DB/prop values) so they are visible, not separate fields.
|
|
766
|
+
*/
|
|
767
|
+
function analyzeUnitParts(el, classify) {
|
|
768
|
+
const parts = [];
|
|
769
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
|
|
770
|
+
let textNode;
|
|
771
|
+
while ((textNode = walker.nextNode())) {
|
|
772
|
+
const raw = String(textNode.nodeValue || '').replace(/\s+/g, ' ');
|
|
773
|
+
if (!raw.trim()) continue;
|
|
774
|
+
const owner =
|
|
775
|
+
textNode.parentElement instanceof Element ? textNode.parentElement : el;
|
|
776
|
+
const source = classify(owner, raw.trim());
|
|
777
|
+
const type = source.type || 'static-label';
|
|
778
|
+
parts.push({
|
|
779
|
+
text: raw,
|
|
780
|
+
type: type,
|
|
781
|
+
editable: source.editable !== false
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
if (!parts.length) {
|
|
785
|
+
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
|
|
786
|
+
if (text) {
|
|
787
|
+
const source = classify(el, text);
|
|
788
|
+
parts.push({
|
|
789
|
+
text: text,
|
|
790
|
+
type: source.type || 'static-label',
|
|
791
|
+
editable: source.editable !== false
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return parts;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function summarizeUnitSource(parts, classify, el, fullText) {
|
|
799
|
+
const types = {};
|
|
800
|
+
parts.forEach(function (p) {
|
|
801
|
+
types[p.type] = (types[p.type] || 0) + 1;
|
|
802
|
+
});
|
|
803
|
+
const hasDynamic = !!types['dynamic-data'];
|
|
804
|
+
const hasBound = !!types['bound-unknown'];
|
|
805
|
+
const hasStatic = !!types['static-label'] || !!types['unknown'];
|
|
806
|
+
|
|
807
|
+
if (hasDynamic && (hasStatic || hasBound)) {
|
|
808
|
+
return {
|
|
809
|
+
type: 'mixed',
|
|
810
|
+
editable: parts.some(function (p) {
|
|
811
|
+
return p.editable;
|
|
812
|
+
}),
|
|
813
|
+
chips: ['Mixed'].concat(hasDynamic ? ['Dynamic'] : []).concat(hasBound ? ['Bound'] : [])
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
if (hasDynamic && !hasStatic && !hasBound) {
|
|
817
|
+
return { type: 'dynamic-data', editable: false, chips: ['Dynamic'] };
|
|
818
|
+
}
|
|
819
|
+
if (hasBound && !hasStatic) {
|
|
820
|
+
return { type: 'bound-unknown', editable: false, chips: ['Bound'] };
|
|
821
|
+
}
|
|
822
|
+
const whole = classify(el, fullText);
|
|
823
|
+
return {
|
|
824
|
+
type: whole.type || 'static-label',
|
|
825
|
+
editable: whole.editable !== false,
|
|
826
|
+
chips: [
|
|
827
|
+
whole.type === 'dynamic-data'
|
|
828
|
+
? 'Dynamic'
|
|
829
|
+
: whole.type === 'bound-unknown'
|
|
830
|
+
? 'Bound'
|
|
831
|
+
: 'Static'
|
|
832
|
+
]
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function buildUnit(el, classify, pathIndex) {
|
|
837
|
+
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
|
|
838
|
+
if (!text) return null;
|
|
839
|
+
const parts = analyzeUnitParts(el, classify);
|
|
840
|
+
const source = summarizeUnitSource(parts, classify, el, text);
|
|
841
|
+
const path = String(pathIndex);
|
|
842
|
+
return {
|
|
843
|
+
id: 'copy-' + path,
|
|
844
|
+
path: path,
|
|
845
|
+
text: text,
|
|
846
|
+
previousText: text,
|
|
847
|
+
originalText: text,
|
|
848
|
+
role: unitRole(el),
|
|
849
|
+
tagName: el.tagName.toLowerCase(),
|
|
850
|
+
selector: cssPath(el),
|
|
851
|
+
parts: parts,
|
|
852
|
+
textSource: {
|
|
853
|
+
type: source.type,
|
|
854
|
+
editable: source.editable,
|
|
855
|
+
chips: source.chips
|
|
856
|
+
},
|
|
857
|
+
editable: source.editable
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Merge consecutive sibling body wrappers under the same parent into one field
|
|
863
|
+
* so a sentence split across nested div/spans is not exploded into N fields.
|
|
864
|
+
*/
|
|
865
|
+
function mergeSiblingBodyUnits(units) {
|
|
866
|
+
if (units.length < 2) return units;
|
|
867
|
+
const merged = [];
|
|
868
|
+
let i = 0;
|
|
869
|
+
while (i < units.length) {
|
|
870
|
+
const cur = units[i];
|
|
871
|
+
if (cur.role !== 'text') {
|
|
872
|
+
merged.push(cur);
|
|
873
|
+
i += 1;
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
let j = i + 1;
|
|
877
|
+
const group = [cur];
|
|
878
|
+
while (j < units.length && units[j].role === 'text') {
|
|
879
|
+
const a = document.querySelector(group[group.length - 1].selector);
|
|
880
|
+
const b = document.querySelector(units[j].selector);
|
|
881
|
+
if (
|
|
882
|
+
a &&
|
|
883
|
+
b &&
|
|
884
|
+
a.parentElement &&
|
|
885
|
+
a.parentElement === b.parentElement &&
|
|
886
|
+
isLeafTextWrapper(a) &&
|
|
887
|
+
isLeafTextWrapper(b)
|
|
888
|
+
) {
|
|
889
|
+
group.push(units[j]);
|
|
890
|
+
j += 1;
|
|
891
|
+
} else {
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (group.length === 1) {
|
|
896
|
+
merged.push(cur);
|
|
897
|
+
} else {
|
|
898
|
+
const parent = document.querySelector(group[0].selector)?.parentElement;
|
|
899
|
+
if (parent) {
|
|
900
|
+
const classify = TS().classifyTextSource || function () {
|
|
901
|
+
return { type: 'static-label', editable: true };
|
|
902
|
+
};
|
|
903
|
+
const unit = buildUnit(parent, classify, merged.length);
|
|
904
|
+
if (unit) merged.push(unit);
|
|
905
|
+
else merged.push(cur);
|
|
906
|
+
} else {
|
|
907
|
+
merged.push(cur);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
i = j;
|
|
911
|
+
}
|
|
912
|
+
return merged.map(function (u, idx) {
|
|
913
|
+
const path = String(idx);
|
|
914
|
+
return Object.assign({}, u, { path: path, id: u.id || 'copy-' + path });
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Coalesce copy into meaningful units (id, optional role, text, selector).
|
|
920
|
+
* Do not split a sentence across text nodes — keep one field per block;
|
|
921
|
+
* chip dynamic/bound fragments inside the unit instead.
|
|
922
|
+
*/
|
|
923
|
+
function extractTextNodes(root) {
|
|
924
|
+
const classify = TS().classifyTextSource || function () {
|
|
925
|
+
return { type: 'static-label', editable: true };
|
|
926
|
+
};
|
|
927
|
+
const units = [];
|
|
928
|
+
const seen = new Set();
|
|
929
|
+
|
|
930
|
+
function pushUnit(el) {
|
|
931
|
+
if (!el || seen.has(el)) return;
|
|
932
|
+
const unit = buildUnit(el, classify, units.length);
|
|
933
|
+
if (!unit) return;
|
|
934
|
+
seen.add(el);
|
|
935
|
+
units.push(unit);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function walk(el) {
|
|
939
|
+
if (!(el instanceof Element)) return;
|
|
940
|
+
if (panel.contains(el) || el.id === 'crank-highlight') return;
|
|
941
|
+
if (isCopyUnitRoot(el)) {
|
|
942
|
+
pushUnit(el);
|
|
943
|
+
return; // do not split children into separate units
|
|
944
|
+
}
|
|
945
|
+
Array.from(el.children).forEach(walk);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
walk(root);
|
|
949
|
+
|
|
950
|
+
// Fallback: whole selection as one body if nothing matched
|
|
951
|
+
if (!units.length) {
|
|
952
|
+
const unit = buildUnit(root, classify, 0);
|
|
953
|
+
if (unit) units.push(unit);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
return assignUnitLabels(mergeSiblingBodyUnits(units));
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function resolveUnitElement(root, node) {
|
|
960
|
+
if (!root || !node) return null;
|
|
961
|
+
if (node.selector) {
|
|
962
|
+
try {
|
|
963
|
+
const found = document.querySelector(node.selector);
|
|
964
|
+
if (found && root.contains(found) && found !== root) return found;
|
|
965
|
+
} catch (_) {}
|
|
966
|
+
// Absolute cssPath may include ancestors; try the leaf selector inside root.
|
|
967
|
+
try {
|
|
968
|
+
const leaf = String(node.selector)
|
|
969
|
+
.split('>')
|
|
970
|
+
.pop()
|
|
971
|
+
.trim();
|
|
972
|
+
if (leaf) {
|
|
973
|
+
const candidates = root.querySelectorAll(leaf);
|
|
974
|
+
if (candidates.length === 1 && candidates[0] !== root) {
|
|
975
|
+
return candidates[0];
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
} catch (_) {}
|
|
979
|
+
}
|
|
980
|
+
return null;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function applyNodesToDom(root, nodes) {
|
|
984
|
+
if (!root || !Array.isArray(nodes)) return 0;
|
|
985
|
+
// Resolve all targets before mutating — never fall back to wiping `root`
|
|
986
|
+
// (that applied one string to the whole selection).
|
|
987
|
+
const resolved = nodes.map(function (node) {
|
|
988
|
+
return { node: node, el: resolveUnitElement(root, node) };
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
// Path-index fallback against live copy units when selectors miss.
|
|
992
|
+
const needPath = resolved.some(function (r) {
|
|
993
|
+
return !r.el;
|
|
994
|
+
});
|
|
995
|
+
let byPath = null;
|
|
996
|
+
if (needPath) {
|
|
997
|
+
byPath = new Map();
|
|
998
|
+
extractTextNodes(root).forEach(function (u) {
|
|
999
|
+
try {
|
|
1000
|
+
const el = document.querySelector(u.selector);
|
|
1001
|
+
if (el && root.contains(el) && el !== root) {
|
|
1002
|
+
byPath.set(String(u.path), el);
|
|
1003
|
+
}
|
|
1004
|
+
} catch (_) {}
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
let applied = 0;
|
|
1009
|
+
for (let i = 0; i < resolved.length; i++) {
|
|
1010
|
+
const node = resolved[i].node;
|
|
1011
|
+
let el = resolved[i].el;
|
|
1012
|
+
if (!el && byPath && node.path != null) {
|
|
1013
|
+
el = byPath.get(String(node.path)) || null;
|
|
1014
|
+
}
|
|
1015
|
+
if (!el) continue;
|
|
1016
|
+
const next = String(node.text || '');
|
|
1017
|
+
if (
|
|
1018
|
+
String(el.textContent || '')
|
|
1019
|
+
.replace(/\s+/g, ' ')
|
|
1020
|
+
.trim() === next.replace(/\s+/g, ' ').trim()
|
|
1021
|
+
) {
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
el.textContent = next;
|
|
1025
|
+
applied += 1;
|
|
1026
|
+
}
|
|
1027
|
+
return applied;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function restoreOriginal() {
|
|
1031
|
+
if (selectedEl && originalSnapshot.length) {
|
|
1032
|
+
applyNodesToDom(selectedEl, originalSnapshot);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function connectWs() {
|
|
1037
|
+
try {
|
|
1038
|
+
socket = new WebSocket(wsUrlWithToken());
|
|
1039
|
+
socket.addEventListener('open', function () {
|
|
1040
|
+
setStatus('Connected to companion');
|
|
1041
|
+
});
|
|
1042
|
+
socket.addEventListener('message', function (ev) {
|
|
1043
|
+
let msg;
|
|
1044
|
+
try {
|
|
1045
|
+
msg = JSON.parse(ev.data);
|
|
1046
|
+
} catch (_) {
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
if (msg.type === 'VARIANTS_READY' && msg.session) {
|
|
1050
|
+
sessionId = msg.session.id;
|
|
1051
|
+
gleanActiveEpoch = 0;
|
|
1052
|
+
hideThinking();
|
|
1053
|
+
renderVariants(msg.session.variants || []);
|
|
1054
|
+
setStatus('Glean returned ' + (msg.session.variants || []).length + ' options');
|
|
1055
|
+
const gleanBtn = panel.querySelector('#vc-glean');
|
|
1056
|
+
if (gleanBtn) gleanBtn.disabled = !sessionId;
|
|
1057
|
+
}
|
|
1058
|
+
if (msg.type === 'GLEAN_PROGRESS') {
|
|
1059
|
+
// Ignore progress once variants are on screen (or from a prior request).
|
|
1060
|
+
if (!gleanActiveEpoch) return;
|
|
1061
|
+
if (msg.sessionId && sessionId && msg.sessionId !== sessionId) return;
|
|
1062
|
+
showThinking(msg.progress || {});
|
|
1063
|
+
}
|
|
1064
|
+
if (msg.type === 'GLEAN_ERROR') {
|
|
1065
|
+
gleanActiveEpoch = 0;
|
|
1066
|
+
hideThinking();
|
|
1067
|
+
setStatus('Glean error: ' + (msg.error || 'unknown'));
|
|
1068
|
+
const gleanBtn = panel.querySelector('#vc-glean');
|
|
1069
|
+
if (gleanBtn) gleanBtn.disabled = !sessionId;
|
|
1070
|
+
}
|
|
1071
|
+
if (msg.type === 'APPLY_VARIANT' && selectedEl) {
|
|
1072
|
+
applyNodesToDom(selectedEl, msg.nodes || []);
|
|
1073
|
+
if (msg.variantId) activeVariantId = msg.variantId;
|
|
1074
|
+
markActiveVariantCard(activeVariantId);
|
|
1075
|
+
if (msg.preview) {
|
|
1076
|
+
setStatus('Previewed in DOM (not saved)');
|
|
1077
|
+
} else if (msg.persistResult) {
|
|
1078
|
+
setStatus(formatPersistStatus(msg.persistResult, msg.variantId));
|
|
1079
|
+
} else {
|
|
1080
|
+
setStatus('Applied variant in DOM');
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
if (msg.type === 'APPLY_RESULT') {
|
|
1084
|
+
if (msg.persistResult) {
|
|
1085
|
+
setStatus(formatPersistStatus(msg.persistResult, msg.variantId));
|
|
1086
|
+
} else if (msg.preview) {
|
|
1087
|
+
setStatus('Previewed in DOM (not saved)');
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
if (msg.type === 'PREVIEW_TEXT' && selectedEl) {
|
|
1091
|
+
applyNodesToDom(selectedEl, msg.nodes || []);
|
|
1092
|
+
}
|
|
1093
|
+
if (msg.type === 'DISCARD_TEXT_PREVIEW') {
|
|
1094
|
+
restoreOriginal();
|
|
1095
|
+
setStatus('Preview discarded');
|
|
1096
|
+
}
|
|
1097
|
+
if (msg.type === 'SAVE_RESULT') {
|
|
1098
|
+
const p = msg.payload || {};
|
|
1099
|
+
setStatus(
|
|
1100
|
+
p.success
|
|
1101
|
+
? 'Saved to source' + (p.file ? ': ' + p.file : '')
|
|
1102
|
+
: 'Save failed: ' + (p.error || 'unknown')
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
if (msg.type === 'AGENT_PROMPT_QUEUED') {
|
|
1106
|
+
const t = msg.task || {};
|
|
1107
|
+
if (t.prompt) {
|
|
1108
|
+
// HTTP path already finished handoff + clipboard; WS only refreshes UI.
|
|
1109
|
+
finishAgentQueue(t, {
|
|
1110
|
+
fromWs: true,
|
|
1111
|
+
skipCopy: true
|
|
1112
|
+
});
|
|
1113
|
+
} else {
|
|
1114
|
+
const wsMsg =
|
|
1115
|
+
'Queued — paste in your current Cursor chat (or ask: ' +
|
|
1116
|
+
AGENT_SHORT_PROMPT +
|
|
1117
|
+
')';
|
|
1118
|
+
showToast(wsMsg, 'ok');
|
|
1119
|
+
setStatus(wsMsg);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
if (msg.type === 'ERROR') {
|
|
1123
|
+
setStatus('Error: ' + (msg.error || 'unknown'));
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
socket.addEventListener('close', function () {
|
|
1127
|
+
setStatus(
|
|
1128
|
+
'Companion disconnected — retrying… (start with: npx crank)'
|
|
1129
|
+
);
|
|
1130
|
+
setTimeout(connectWs, 1500);
|
|
1131
|
+
});
|
|
1132
|
+
socket.addEventListener('error', function () {
|
|
1133
|
+
setStatus(
|
|
1134
|
+
'Companion not running — start with npx crank / open :3344'
|
|
1135
|
+
);
|
|
1136
|
+
});
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
setStatus(
|
|
1139
|
+
'Companion not running — start with npx crank / open :3344'
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function sendWs(message) {
|
|
1145
|
+
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
1146
|
+
socket.send(JSON.stringify(message));
|
|
1147
|
+
return true;
|
|
1148
|
+
}
|
|
1149
|
+
return false;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// Short default for selection/preview/save. Glean /api/variants needs 3–5 min
|
|
1153
|
+
// (Robot Content Designer ~90–140s) — callers must pass timeoutMs explicitly.
|
|
1154
|
+
const GLEAN_VARIANTS_TIMEOUT_MS = 300000;
|
|
1155
|
+
|
|
1156
|
+
async function postJson(path, body, options) {
|
|
1157
|
+
const opts = options || {};
|
|
1158
|
+
const timeoutMs = opts.timeoutMs != null ? opts.timeoutMs : 20000;
|
|
1159
|
+
const controller =
|
|
1160
|
+
typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
1161
|
+
let timer = null;
|
|
1162
|
+
if (controller && timeoutMs > 0) {
|
|
1163
|
+
timer = setTimeout(function () {
|
|
1164
|
+
try {
|
|
1165
|
+
controller.abort();
|
|
1166
|
+
} catch (_) {
|
|
1167
|
+
/* ignore */
|
|
1168
|
+
}
|
|
1169
|
+
}, timeoutMs);
|
|
1170
|
+
}
|
|
1171
|
+
let res;
|
|
1172
|
+
try {
|
|
1173
|
+
res = await fetch(COMPANION_HTTP + path, {
|
|
1174
|
+
method: opts.method || 'POST',
|
|
1175
|
+
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
1176
|
+
body: opts.method === 'GET' ? undefined : JSON.stringify(body),
|
|
1177
|
+
signal: controller ? controller.signal : undefined
|
|
1178
|
+
});
|
|
1179
|
+
} catch (err) {
|
|
1180
|
+
if (timer) clearTimeout(timer);
|
|
1181
|
+
if (err && err.name === 'AbortError') {
|
|
1182
|
+
throw new Error('Request timed out after ' + timeoutMs + 'ms');
|
|
1183
|
+
}
|
|
1184
|
+
const m = (err && err.message) || String(err || '');
|
|
1185
|
+
if (
|
|
1186
|
+
/Failed to fetch|NetworkError|Load failed|Network request failed|fetch failed/i.test(
|
|
1187
|
+
m
|
|
1188
|
+
)
|
|
1189
|
+
) {
|
|
1190
|
+
throw new Error(
|
|
1191
|
+
'Companion not running — start with npx crank / open :3344'
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
throw err;
|
|
1195
|
+
}
|
|
1196
|
+
if (timer) clearTimeout(timer);
|
|
1197
|
+
let data = {};
|
|
1198
|
+
try {
|
|
1199
|
+
data = await res.json();
|
|
1200
|
+
} catch (_) {
|
|
1201
|
+
data = {};
|
|
1202
|
+
}
|
|
1203
|
+
if (!res.ok) {
|
|
1204
|
+
if (res.status === 401 && (data.code === 'TEAM_TOKEN_REQUIRED' || data.code === 'TEAM_TOKEN_INVALID')) {
|
|
1205
|
+
setTeamToken('');
|
|
1206
|
+
const ok = await ensureTeamToken();
|
|
1207
|
+
if (ok) {
|
|
1208
|
+
return postJson(path, body, options);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
const err = new Error(data.error || 'HTTP ' + res.status);
|
|
1212
|
+
err.status = res.status;
|
|
1213
|
+
err.data = data;
|
|
1214
|
+
throw err;
|
|
1215
|
+
}
|
|
1216
|
+
return data;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function formatPersistStatus(persistResult, variantId) {
|
|
1220
|
+
if (!persistResult) {
|
|
1221
|
+
return 'Applied in DOM';
|
|
1222
|
+
}
|
|
1223
|
+
const bits = ['Applied in DOM'];
|
|
1224
|
+
if (persistResult.summary) bits.push(persistResult.summary);
|
|
1225
|
+
const locked = (persistResult.results || []).filter(function (r) {
|
|
1226
|
+
return r.status === 'skipped' && r.reason === 'locked';
|
|
1227
|
+
});
|
|
1228
|
+
const failed = (persistResult.results || []).filter(function (r) {
|
|
1229
|
+
return r.status === 'failed';
|
|
1230
|
+
});
|
|
1231
|
+
if (locked.length) {
|
|
1232
|
+
bits.push(
|
|
1233
|
+
locked.length +
|
|
1234
|
+
' locked/dynamic field(s) not written — use Agent or edit source'
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
if (failed.length) {
|
|
1238
|
+
bits.push(
|
|
1239
|
+
'Save failed: ' + (failed[0].message || 'could not write source')
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
if (variantId && !persistResult.persisted && !failed.length && locked.length) {
|
|
1243
|
+
bits.push('(DOM only)');
|
|
1244
|
+
}
|
|
1245
|
+
return bits.join(' · ');
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function roleLabel(role, node, index, nodes) {
|
|
1249
|
+
if (node && node.label) return node.label;
|
|
1250
|
+
if (node && nodes) return unitDisplayLabel(node, index, nodes);
|
|
1251
|
+
const r = normalizeRole(role);
|
|
1252
|
+
if (r === 'heading') return 'Heading';
|
|
1253
|
+
if (r === 'action' || r === 'button') return 'CTA';
|
|
1254
|
+
if (r === 'label') return 'Label';
|
|
1255
|
+
return 'Copy';
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function sourceChips(textSource) {
|
|
1259
|
+
if (textSource && Array.isArray(textSource.chips) && textSource.chips.length) {
|
|
1260
|
+
return textSource.chips;
|
|
1261
|
+
}
|
|
1262
|
+
const type = textSource && textSource.type;
|
|
1263
|
+
if (type === 'dynamic-data') return ['Dynamic'];
|
|
1264
|
+
if (type === 'bound-unknown') return ['Bound'];
|
|
1265
|
+
if (type === 'mixed') return ['Mixed'];
|
|
1266
|
+
return ['Static'];
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function buildPanelShell() {
|
|
1270
|
+
const ui = UI();
|
|
1271
|
+
const btn = ui.vpButtonHtml || function (v, label, attrs) {
|
|
1272
|
+
return '<button type="button" id="' + (attrs && attrs.id || '') + '">' + label + '</button>';
|
|
1273
|
+
};
|
|
1274
|
+
const group = ui.vpButtonGroupHtml || function (html) {
|
|
1275
|
+
return '<div style="display:flex;gap:8px;flex-wrap:wrap;">' + html + '</div>';
|
|
1276
|
+
};
|
|
1277
|
+
|
|
1278
|
+
panel.innerHTML =
|
|
1279
|
+
'<div class="vp-panel-block" style="padding:var(--vp-panel-padding, 1rem);">' +
|
|
1280
|
+
'<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:8px;">' +
|
|
1281
|
+
'<strong style="font-size:var(--vp-font-size-body,14px);font-weight:var(--vp-font-weight-title,600);">Crank</strong>' +
|
|
1282
|
+
btn('ghost', '×', { id: 'vc-close' }, { icon: true, size: 'compact' }) +
|
|
1283
|
+
'</div>' +
|
|
1284
|
+
'<p id="vc-status" class="vp-muted vp-type-support" style="margin:0 0 10px;">Loading UI…</p>' +
|
|
1285
|
+
'<div id="vc-toast" style="display:none;"></div>' +
|
|
1286
|
+
'<div id="vc-glean-options" style="margin:0 0 10px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">' +
|
|
1287
|
+
'<label for="vc-option-count" class="vp-type-label" style="display:flex;align-items:center;gap:6px;cursor:help;" title="Number of new rewritten alternatives (original draft is not shown). Fewer may reduce wait slightly; most latency is still agent runtime (~1–2 min).">' +
|
|
1288
|
+
'New alternatives' +
|
|
1289
|
+
'<span class="vp-muted vp-type-micro" aria-hidden="true">(?)</span>' +
|
|
1290
|
+
'</label>' +
|
|
1291
|
+
'<select id="vc-option-count" class="vp-type-support" style="min-width:4.5rem;padding:4px 8px;border:1px solid var(--border);border-radius:6px;background:var(--background);color:var(--foreground);">' +
|
|
1292
|
+
[2, 3, 4, 5, 6]
|
|
1293
|
+
.map(function (n) {
|
|
1294
|
+
return (
|
|
1295
|
+
'<option value="' +
|
|
1296
|
+
n +
|
|
1297
|
+
'"' +
|
|
1298
|
+
(n === DEFAULT_GLEAN_OPTION_COUNT ? ' selected' : '') +
|
|
1299
|
+
'>' +
|
|
1300
|
+
n +
|
|
1301
|
+
'</option>'
|
|
1302
|
+
);
|
|
1303
|
+
})
|
|
1304
|
+
.join('') +
|
|
1305
|
+
'</select>' +
|
|
1306
|
+
'</div>' +
|
|
1307
|
+
group(
|
|
1308
|
+
btn('primary', 'Select', { id: 'vc-select' }, { size: 'compact', flex: true }) +
|
|
1309
|
+
btn('secondary', 'Ask Glean', { id: 'vc-glean', disabled: true }, { size: 'compact', flex: true }),
|
|
1310
|
+
{ stretch: true }
|
|
1311
|
+
) +
|
|
1312
|
+
'<div id="vc-edit" style="margin-top:12px;"></div>' +
|
|
1313
|
+
'<div id="vc-thinking" class="vp-muted vp-type-support" style="display:none;margin-top:10px;padding:10px;border:1px solid var(--border);border-radius:8px;white-space:pre-wrap;line-height:1.5;font-size:12px;background:oklch(0.97 0.01 180);max-height:none;overflow:visible;"></div>' +
|
|
1314
|
+
'<div id="vc-actions" style="margin-top:10px;">' +
|
|
1315
|
+
group(
|
|
1316
|
+
btn('secondary', 'Preview', { id: 'vc-preview', disabled: true }, { size: 'compact', flex: true }) +
|
|
1317
|
+
btn('ghost', 'Discard', { id: 'vc-discard', disabled: true }, { size: 'compact', flex: true }) +
|
|
1318
|
+
btn('primary', 'Save', { id: 'vc-save', disabled: true }, { size: 'compact', flex: true }),
|
|
1319
|
+
{ stretch: true }
|
|
1320
|
+
) +
|
|
1321
|
+
'</div>' +
|
|
1322
|
+
'<div id="vc-variants" style="margin-top:12px;"></div>' +
|
|
1323
|
+
'<div id="vc-agent-handoff" style="display:none;margin:12px 0 0;padding:10px;border:1px solid var(--border);border-radius:8px;background:oklch(0.97 0.01 180);"></div>' +
|
|
1324
|
+
'</div>';
|
|
1325
|
+
|
|
1326
|
+
document.documentElement.appendChild(panel);
|
|
1327
|
+
const optionCountSel = panel.querySelector('#vc-option-count');
|
|
1328
|
+
if (optionCountSel) {
|
|
1329
|
+
try {
|
|
1330
|
+
const saved = localStorage.getItem(OPTION_COUNT_KEY);
|
|
1331
|
+
if (saved && [2, 3, 4, 5, 6].indexOf(parseInt(saved, 10)) >= 0) {
|
|
1332
|
+
optionCountSel.value = String(parseInt(saved, 10));
|
|
1333
|
+
}
|
|
1334
|
+
} catch (_) {}
|
|
1335
|
+
optionCountSel.addEventListener('change', function () {
|
|
1336
|
+
try {
|
|
1337
|
+
localStorage.setItem(OPTION_COUNT_KEY, optionCountSel.value);
|
|
1338
|
+
} catch (_) {}
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
panel.querySelector('#vc-close').addEventListener('click', teardown);
|
|
1342
|
+
panel.querySelector('#vc-select').addEventListener('click', startSelect);
|
|
1343
|
+
panel.querySelector('#vc-glean').addEventListener('click', requestGlean);
|
|
1344
|
+
panel.querySelector('#vc-preview').addEventListener('click', previewEdits);
|
|
1345
|
+
panel.querySelector('#vc-discard').addEventListener('click', discardEdits);
|
|
1346
|
+
panel.querySelector('#vc-save').addEventListener('click', saveEdits);
|
|
1347
|
+
// Event delegation so Agent/Preview/Apply keep working after re-renders.
|
|
1348
|
+
const variantsBox = panel.querySelector('#vc-variants');
|
|
1349
|
+
if (variantsBox && !variantsBox.__crankDelegated) {
|
|
1350
|
+
variantsBox.__crankDelegated = true;
|
|
1351
|
+
variantsBox.addEventListener('click', function (ev) {
|
|
1352
|
+
const target = ev.target && ev.target.closest
|
|
1353
|
+
? ev.target.closest(
|
|
1354
|
+
'[data-variant-agent],[data-variant-preview],[data-variant-apply]'
|
|
1355
|
+
)
|
|
1356
|
+
: null;
|
|
1357
|
+
if (!target || !variantsBox.contains(target)) return;
|
|
1358
|
+
if (ev.preventDefault) ev.preventDefault();
|
|
1359
|
+
if (ev.stopPropagation) ev.stopPropagation();
|
|
1360
|
+
const agentId = target.getAttribute('data-variant-agent');
|
|
1361
|
+
if (agentId) {
|
|
1362
|
+
queueAgentVariant(agentId);
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
const previewId = target.getAttribute('data-variant-preview');
|
|
1366
|
+
if (previewId) {
|
|
1367
|
+
applyVariant(previewId, { syncEditors: false, persist: false });
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
const applyId = target.getAttribute('data-variant-apply');
|
|
1371
|
+
if (applyId) {
|
|
1372
|
+
applyVariant(applyId, { syncEditors: true, persist: true });
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function renderNodes() {
|
|
1379
|
+
const ui = UI();
|
|
1380
|
+
const editEl = panel.querySelector('#vc-edit');
|
|
1381
|
+
if (!editEl) return;
|
|
1382
|
+
|
|
1383
|
+
if (!selectedNodes.length) {
|
|
1384
|
+
editEl.innerHTML = '';
|
|
1385
|
+
setEditButtons(false);
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const field = ui.vpFieldHtml || function (label, control) {
|
|
1390
|
+
return '<div><div>' + label + '</div>' + control + '</div>';
|
|
1391
|
+
};
|
|
1392
|
+
const area = ui.vpTextareaHtml || function (attrs, content) {
|
|
1393
|
+
return '<textarea id="' + (attrs.id || '') + '"' + (attrs.disabled ? ' disabled' : '') + '>' + (content || '') + '</textarea>';
|
|
1394
|
+
};
|
|
1395
|
+
const badge = ui.vpCapabilityBadgeHtml || function (_mode, text) {
|
|
1396
|
+
return '<span class="vp-type-micro">' + text + '</span>';
|
|
1397
|
+
};
|
|
1398
|
+
const escape = ui.escapeHtml || function (v) {
|
|
1399
|
+
return String(v || '');
|
|
1400
|
+
};
|
|
1401
|
+
|
|
1402
|
+
editEl.innerHTML = selectedNodes
|
|
1403
|
+
.map(function (n, idx) {
|
|
1404
|
+
const label = roleLabel(n.role, n, idx, selectedNodes);
|
|
1405
|
+
const chips = sourceChips(n.textSource);
|
|
1406
|
+
const mode = n.editable ? 'editable' : 'read-only';
|
|
1407
|
+
const meta =
|
|
1408
|
+
chips
|
|
1409
|
+
.map(function (chip) {
|
|
1410
|
+
return badge(mode, chip);
|
|
1411
|
+
})
|
|
1412
|
+
.join(' ') +
|
|
1413
|
+
(n.editable
|
|
1414
|
+
? ''
|
|
1415
|
+
: '<span class="vp-muted vp-type-micro"> Locked</span>');
|
|
1416
|
+
|
|
1417
|
+
const dynamicHints = (n.parts || [])
|
|
1418
|
+
.filter(function (p) {
|
|
1419
|
+
return p.type === 'dynamic-data' || p.type === 'bound-unknown';
|
|
1420
|
+
})
|
|
1421
|
+
.map(function (p) {
|
|
1422
|
+
return (
|
|
1423
|
+
'<span class="vp-type-micro" style="display:inline-block;margin:2px 4px 0 0;padding:1px 6px;border:1px dashed var(--border);border-radius:999px;">' +
|
|
1424
|
+
escape(String(p.text || '').trim()) +
|
|
1425
|
+
'</span>'
|
|
1426
|
+
);
|
|
1427
|
+
})
|
|
1428
|
+
.join('');
|
|
1429
|
+
|
|
1430
|
+
if (!n.editable) {
|
|
1431
|
+
return (
|
|
1432
|
+
'<div class="vp-field" style="margin-bottom:10px;">' +
|
|
1433
|
+
'<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:4px;">' +
|
|
1434
|
+
'<span class="vp-type-label">' +
|
|
1435
|
+
escape(label) +
|
|
1436
|
+
'</span>' +
|
|
1437
|
+
meta +
|
|
1438
|
+
'</div>' +
|
|
1439
|
+
'<div class="vp-type-support" style="padding:8px;border:1px dashed var(--border);border-radius:8px;opacity:0.85;">' +
|
|
1440
|
+
escape(n.text) +
|
|
1441
|
+
'</div>' +
|
|
1442
|
+
(dynamicHints
|
|
1443
|
+
? '<div style="margin-top:4px;">' + dynamicHints + '</div>'
|
|
1444
|
+
: '') +
|
|
1445
|
+
'</div>'
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
return (
|
|
1450
|
+
field(
|
|
1451
|
+
label,
|
|
1452
|
+
area(
|
|
1453
|
+
{ id: 'vc-edit-' + n.path, rows: '2', 'data-path': n.path },
|
|
1454
|
+
n.text,
|
|
1455
|
+
{ size: 'compact' }
|
|
1456
|
+
),
|
|
1457
|
+
{ size: 'compact', metaHtml: meta }
|
|
1458
|
+
) +
|
|
1459
|
+
(dynamicHints
|
|
1460
|
+
? '<div class="vp-muted vp-type-micro" style="margin:-4px 0 10px;">Vars: ' +
|
|
1461
|
+
dynamicHints +
|
|
1462
|
+
'</div>'
|
|
1463
|
+
: '')
|
|
1464
|
+
);
|
|
1465
|
+
})
|
|
1466
|
+
.join('');
|
|
1467
|
+
|
|
1468
|
+
const anyEditable = selectedNodes.some(function (n) {
|
|
1469
|
+
return n.editable;
|
|
1470
|
+
});
|
|
1471
|
+
setEditButtons(anyEditable);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function setEditButtons(enabled) {
|
|
1475
|
+
['vc-preview', 'vc-discard', 'vc-save', 'vc-glean'].forEach(function (id) {
|
|
1476
|
+
const el = panel.querySelector('#' + id);
|
|
1477
|
+
if (el) el.disabled = !enabled && id !== 'vc-glean' ? true : !sessionId && id === 'vc-glean' ? true : !enabled && id !== 'vc-glean';
|
|
1478
|
+
});
|
|
1479
|
+
const glean = panel.querySelector('#vc-glean');
|
|
1480
|
+
if (glean) glean.disabled = !sessionId;
|
|
1481
|
+
const preview = panel.querySelector('#vc-preview');
|
|
1482
|
+
const discard = panel.querySelector('#vc-discard');
|
|
1483
|
+
const save = panel.querySelector('#vc-save');
|
|
1484
|
+
if (preview) preview.disabled = !enabled;
|
|
1485
|
+
if (discard) discard.disabled = !enabled;
|
|
1486
|
+
if (save) save.disabled = !enabled;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function readEditedNodes() {
|
|
1490
|
+
return selectedNodes.map(function (n) {
|
|
1491
|
+
const input = panel.querySelector('#vc-edit-' + n.path);
|
|
1492
|
+
if (input && n.editable) {
|
|
1493
|
+
return Object.assign({}, n, { text: input.value });
|
|
1494
|
+
}
|
|
1495
|
+
return n;
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function previewEdits() {
|
|
1500
|
+
const nodes = readEditedNodes();
|
|
1501
|
+
if (selectedEl) applyNodesToDom(selectedEl, nodes);
|
|
1502
|
+
selectedNodes = nodes;
|
|
1503
|
+
sendWs({
|
|
1504
|
+
type: 'PREVIEW_TEXT_CHANGE',
|
|
1505
|
+
sessionId: sessionId,
|
|
1506
|
+
payload: { sessionId: sessionId, nodes: nodes }
|
|
1507
|
+
});
|
|
1508
|
+
setStatus('DOM preview applied (not saved to source yet)');
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
function discardEdits() {
|
|
1512
|
+
restoreOriginal();
|
|
1513
|
+
selectedNodes = originalSnapshot.map(function (n) {
|
|
1514
|
+
return Object.assign({}, n);
|
|
1515
|
+
});
|
|
1516
|
+
renderNodes();
|
|
1517
|
+
sendWs({ type: 'DISCARD_TEXT_PREVIEW', sessionId: sessionId });
|
|
1518
|
+
setStatus('Discarded — restored original DOM text');
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function pathHintsFromLocation() {
|
|
1522
|
+
const hints = [];
|
|
1523
|
+
try {
|
|
1524
|
+
const pathname = String(location.pathname || '/');
|
|
1525
|
+
if (pathname && pathname !== '/') {
|
|
1526
|
+
hints.push(pathname);
|
|
1527
|
+
hints.push(pathname.replace(/^\//, ''));
|
|
1528
|
+
const base = pathname.split('/').pop();
|
|
1529
|
+
if (base) hints.push(base);
|
|
1530
|
+
} else {
|
|
1531
|
+
hints.push('/index.html', 'index.html');
|
|
1532
|
+
}
|
|
1533
|
+
} catch (_) {}
|
|
1534
|
+
return hints;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
async function saveEdits() {
|
|
1538
|
+
const nodes = readEditedNodes();
|
|
1539
|
+
previewEdits();
|
|
1540
|
+
setStatus('Saving editable strings to source…');
|
|
1541
|
+
const selector = selectedEl ? cssPath(selectedEl) : '';
|
|
1542
|
+
let saved = 0;
|
|
1543
|
+
let failed = 0;
|
|
1544
|
+
for (const node of nodes) {
|
|
1545
|
+
if (!node.editable) continue;
|
|
1546
|
+
if (String(node.text) === String(node.previousText || originalSnapshot.find(function (o) { return o.path === node.path; })?.text)) {
|
|
1547
|
+
continue;
|
|
1548
|
+
}
|
|
1549
|
+
const oldValue =
|
|
1550
|
+
(originalSnapshot.find(function (o) {
|
|
1551
|
+
return o.path === node.path;
|
|
1552
|
+
}) || {}).text || node.previousText || node.originalText;
|
|
1553
|
+
const payload = {
|
|
1554
|
+
selectorText: node.selector || selector,
|
|
1555
|
+
value: node.text,
|
|
1556
|
+
oldValue: oldValue,
|
|
1557
|
+
sourceUrl: String(document.baseURI || location.href),
|
|
1558
|
+
origin: String(location.origin || ''),
|
|
1559
|
+
pageUrl: String(location.href || ''),
|
|
1560
|
+
pathHints: pathHintsFromLocation()
|
|
1561
|
+
};
|
|
1562
|
+
const sent = sendWs({ type: 'SAVE_TEXT_CHANGE', payload: payload });
|
|
1563
|
+
if (!sent) {
|
|
1564
|
+
const data = await postJson('/api/save-text', payload);
|
|
1565
|
+
if (data.ok || data.success) saved += 1;
|
|
1566
|
+
else failed += 1;
|
|
1567
|
+
} else {
|
|
1568
|
+
saved += 1;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
setStatus(
|
|
1572
|
+
failed
|
|
1573
|
+
? 'Save finished with errors (see status / companion logs)'
|
|
1574
|
+
: 'Save dispatched for ' + saved + ' string(s)'
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function variantFieldRows(variant) {
|
|
1579
|
+
const fields = variant && variant.fields ? variant.fields : null;
|
|
1580
|
+
const rows = [];
|
|
1581
|
+
const sessionLabels = (selectedNodes || []).map(function (n, idx) {
|
|
1582
|
+
return roleLabel(n.role, n, idx, selectedNodes);
|
|
1583
|
+
});
|
|
1584
|
+
if (
|
|
1585
|
+
fields &&
|
|
1586
|
+
(fields.heading ||
|
|
1587
|
+
fields.body ||
|
|
1588
|
+
fields.cta ||
|
|
1589
|
+
(Array.isArray(fields.bodies) && fields.bodies.length))
|
|
1590
|
+
) {
|
|
1591
|
+
if (fields.heading) {
|
|
1592
|
+
rows.push({ label: sessionLabels[0] || 'Heading', text: fields.heading });
|
|
1593
|
+
}
|
|
1594
|
+
if (Array.isArray(fields.bodies) && fields.bodies.length) {
|
|
1595
|
+
fields.bodies.forEach(function (t, i) {
|
|
1596
|
+
if (String(t || '').trim()) {
|
|
1597
|
+
rows.push({
|
|
1598
|
+
label: sessionLabels[rows.length] || 'Body ' + (i + 1),
|
|
1599
|
+
text: t
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
});
|
|
1603
|
+
} else if (fields.body) {
|
|
1604
|
+
rows.push({
|
|
1605
|
+
label: sessionLabels[rows.length] || 'Body',
|
|
1606
|
+
text: fields.body
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
if (fields.cta) {
|
|
1610
|
+
rows.push({
|
|
1611
|
+
label: sessionLabels[sessionLabels.length - 1] || 'CTA',
|
|
1612
|
+
text: fields.cta
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
return rows;
|
|
1616
|
+
}
|
|
1617
|
+
(variant.nodes || []).forEach(function (n, idx) {
|
|
1618
|
+
const text = String(n && n.text != null ? n.text : '').trim();
|
|
1619
|
+
if (!text) return;
|
|
1620
|
+
rows.push({
|
|
1621
|
+
label: roleLabel(n.role, n, idx, variant.nodes),
|
|
1622
|
+
text: text
|
|
1623
|
+
});
|
|
1624
|
+
});
|
|
1625
|
+
return rows;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
function markActiveVariantCard(variantId) {
|
|
1629
|
+
const box = panel.querySelector('#vc-variants');
|
|
1630
|
+
if (!box) return;
|
|
1631
|
+
box.querySelectorAll('[data-variant-card]').forEach(function (card) {
|
|
1632
|
+
const active = card.getAttribute('data-variant-card') === String(variantId || '');
|
|
1633
|
+
card.style.borderColor = active
|
|
1634
|
+
? 'color-mix(in oklch, var(--ring, #888) 45%, var(--border))'
|
|
1635
|
+
: '';
|
|
1636
|
+
card.style.boxShadow = active
|
|
1637
|
+
? '0 0 0 1px color-mix(in oklch, var(--ring, #888) 25%, transparent)'
|
|
1638
|
+
: '';
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
function syncEditorsFromVariant(variant) {
|
|
1643
|
+
if (!variant || !Array.isArray(variant.nodes) || !variant.nodes.length) return;
|
|
1644
|
+
const byPath = {};
|
|
1645
|
+
const byId = {};
|
|
1646
|
+
variant.nodes.forEach(function (n) {
|
|
1647
|
+
byPath[String(n.path)] = n;
|
|
1648
|
+
if (n.id) byId[String(n.id)] = n;
|
|
1649
|
+
});
|
|
1650
|
+
selectedNodes = selectedNodes.map(function (n) {
|
|
1651
|
+
const next = (n.id && byId[String(n.id)]) || byPath[String(n.path)];
|
|
1652
|
+
if (!next) return n;
|
|
1653
|
+
return Object.assign({}, n, { text: next.text });
|
|
1654
|
+
});
|
|
1655
|
+
// If selection was empty of matching paths, fall back to variant nodes.
|
|
1656
|
+
if (!selectedNodes.length) {
|
|
1657
|
+
selectedNodes = variant.nodes.map(function (n) {
|
|
1658
|
+
return Object.assign({}, n);
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
renderNodes();
|
|
1662
|
+
setEditButtons(true);
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
function renderVariantCard(variant, ui) {
|
|
1666
|
+
const escape = ui.escapeHtml || function (v) {
|
|
1667
|
+
return String(v == null ? '' : v);
|
|
1668
|
+
};
|
|
1669
|
+
const escapeAttr = ui.escapeAttr || escape;
|
|
1670
|
+
const btn = ui.vpButtonHtml || function (_v, label, attrs) {
|
|
1671
|
+
const a = attrs || {};
|
|
1672
|
+
let attrHtml = ' type="button"';
|
|
1673
|
+
Object.keys(a).forEach(function (key) {
|
|
1674
|
+
if (key === 'type' || a[key] == null || a[key] === false) return;
|
|
1675
|
+
if (a[key] === true) {
|
|
1676
|
+
attrHtml += ' ' + key;
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
attrHtml += ' ' + key + '="' + escapeAttr(String(a[key])) + '"';
|
|
1680
|
+
});
|
|
1681
|
+
return '<button' + attrHtml + '>' + label + '</button>';
|
|
1682
|
+
};
|
|
1683
|
+
const group = ui.vpButtonGroupHtml || function (html) {
|
|
1684
|
+
return '<div style="display:flex;gap:8px;flex-wrap:wrap;">' + html + '</div>';
|
|
1685
|
+
};
|
|
1686
|
+
const badge = ui.vpCapabilityBadgeHtml || function (_mode, text) {
|
|
1687
|
+
return '<span class="vp-chip">' + escape(text) + '</span>';
|
|
1688
|
+
};
|
|
1689
|
+
const title = variant.label || variant.title || variant.id || 'Option';
|
|
1690
|
+
const rows = variantFieldRows(variant);
|
|
1691
|
+
let bodyHtml = '';
|
|
1692
|
+
|
|
1693
|
+
if (rows.length) {
|
|
1694
|
+
bodyHtml = rows
|
|
1695
|
+
.map(function (row) {
|
|
1696
|
+
return (
|
|
1697
|
+
'<div class="vc-variant-field" style="margin-top:8px;">' +
|
|
1698
|
+
'<div class="vp-muted vp-type-micro" style="margin-bottom:2px;">' +
|
|
1699
|
+
escape(row.label || 'Copy') +
|
|
1700
|
+
'</div>' +
|
|
1701
|
+
'<div class="vp-type-body" style="white-space:pre-wrap;word-break:break-word;color:var(--foreground);">' +
|
|
1702
|
+
escape(row.text) +
|
|
1703
|
+
'</div>' +
|
|
1704
|
+
'</div>'
|
|
1705
|
+
);
|
|
1706
|
+
})
|
|
1707
|
+
.join('');
|
|
1708
|
+
} else if (variant.rawText) {
|
|
1709
|
+
bodyHtml =
|
|
1710
|
+
'<div class="vp-type-support" style="margin-top:8px;white-space:pre-wrap;word-break:break-word;line-height:1.4;color:var(--foreground);">' +
|
|
1711
|
+
escape(String(variant.rawText)) +
|
|
1712
|
+
'</div>';
|
|
1713
|
+
} else if (variant.summary) {
|
|
1714
|
+
bodyHtml =
|
|
1715
|
+
'<div class="vp-muted vp-type-support" style="margin-top:8px;white-space:pre-wrap;word-break:break-word;">' +
|
|
1716
|
+
escape(variant.summary) +
|
|
1717
|
+
'</div>';
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
const recommended = !!variant.recommended;
|
|
1721
|
+
return (
|
|
1722
|
+
'<article class="vp-panel-block vc-variant-card' +
|
|
1723
|
+
(recommended ? ' is-recommended' : '') +
|
|
1724
|
+
'" data-variant-card="' +
|
|
1725
|
+
escape(variant.id) +
|
|
1726
|
+
'" style="padding:10px 12px;margin-bottom:8px;">' +
|
|
1727
|
+
'<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:8px;">' +
|
|
1728
|
+
'<div class="vp-type-label" style="font-weight:var(--vp-font-weight-title,620);color:var(--foreground);line-height:var(--vp-line-height-label,1.4);">' +
|
|
1729
|
+
escape(title) +
|
|
1730
|
+
'</div>' +
|
|
1731
|
+
(recommended ? badge('preview', 'Recommended') : '') +
|
|
1732
|
+
'</div>' +
|
|
1733
|
+
bodyHtml +
|
|
1734
|
+
'<div style="margin-top:10px;">' +
|
|
1735
|
+
group(
|
|
1736
|
+
btn(
|
|
1737
|
+
'secondary',
|
|
1738
|
+
'Preview',
|
|
1739
|
+
{ 'data-variant-preview': variant.id, type: 'button' },
|
|
1740
|
+
{ size: 'compact', flex: true }
|
|
1741
|
+
) +
|
|
1742
|
+
btn(
|
|
1743
|
+
'primary',
|
|
1744
|
+
'Apply',
|
|
1745
|
+
{ 'data-variant-apply': variant.id, type: 'button' },
|
|
1746
|
+
{ size: 'compact', flex: true }
|
|
1747
|
+
) +
|
|
1748
|
+
btn(
|
|
1749
|
+
'secondary',
|
|
1750
|
+
'Agent',
|
|
1751
|
+
{ 'data-variant-agent': variant.id, type: 'button' },
|
|
1752
|
+
{ size: 'compact', flex: true }
|
|
1753
|
+
),
|
|
1754
|
+
{ stretch: true }
|
|
1755
|
+
) +
|
|
1756
|
+
'</div>' +
|
|
1757
|
+
'</article>'
|
|
1758
|
+
);
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
function renderVariants(variants) {
|
|
1762
|
+
const box = panel.querySelector('#vc-variants');
|
|
1763
|
+
const ui = UI();
|
|
1764
|
+
const escape = ui.escapeHtml || String;
|
|
1765
|
+
lastVariants = Array.isArray(variants) ? variants : [];
|
|
1766
|
+
if (!lastVariants.length) {
|
|
1767
|
+
box.innerHTML = '';
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
const titleHtml = ui.vpSectionTitleHtml
|
|
1771
|
+
? ui.vpSectionTitleHtml('New alternatives')
|
|
1772
|
+
: '<div class="vp-section-title"><span>' + escape('New alternatives') + '</span></div>';
|
|
1773
|
+
box.innerHTML =
|
|
1774
|
+
'<div style="margin-bottom:8px;">' +
|
|
1775
|
+
titleHtml +
|
|
1776
|
+
'</div>' +
|
|
1777
|
+
lastVariants.map(function (v) {
|
|
1778
|
+
return renderVariantCard(v, ui);
|
|
1779
|
+
}).join('');
|
|
1780
|
+
|
|
1781
|
+
markActiveVariantCard(activeVariantId);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
async function queueAgentVariant(variantId) {
|
|
1785
|
+
if (!sessionId) {
|
|
1786
|
+
const msg = 'Select a region and Ask Glean before using Agent';
|
|
1787
|
+
showToast(msg, 'error');
|
|
1788
|
+
setStatus(msg);
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
if (!variantId) {
|
|
1792
|
+
const msg = 'Agent: missing variant id';
|
|
1793
|
+
showToast(msg, 'error');
|
|
1794
|
+
setStatus(msg);
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
const local = lastVariants.find(function (v) {
|
|
1798
|
+
return v && v.id === variantId;
|
|
1799
|
+
});
|
|
1800
|
+
activeVariantId = variantId;
|
|
1801
|
+
markActiveVariantCard(variantId);
|
|
1802
|
+
showToast('Queueing for Cursor agent…', 'info');
|
|
1803
|
+
setStatus('Queueing for Cursor agent…');
|
|
1804
|
+
|
|
1805
|
+
// Prefer HTTP so we always get the executable prompt for clipboard + handoff UI.
|
|
1806
|
+
// (WS-only fire-and-forget looked like “nothing happened” when Cursor chat stayed quiet.)
|
|
1807
|
+
try {
|
|
1808
|
+
const data = await postJson(
|
|
1809
|
+
'/api/agent-prompt',
|
|
1810
|
+
{
|
|
1811
|
+
sessionId: sessionId,
|
|
1812
|
+
variantId: variantId,
|
|
1813
|
+
source: 'panel'
|
|
1814
|
+
},
|
|
1815
|
+
{ timeoutMs: 15000 }
|
|
1816
|
+
);
|
|
1817
|
+
const task = (data && data.task) || {};
|
|
1818
|
+
if (!task.prompt) {
|
|
1819
|
+
throw new Error(
|
|
1820
|
+
(data && data.error) || 'Companion returned no agent prompt'
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
1823
|
+
if (!task.variantLabel && local && local.label) {
|
|
1824
|
+
task.variantLabel = local.label;
|
|
1825
|
+
}
|
|
1826
|
+
await finishAgentQueue(task, { force: true });
|
|
1827
|
+
} catch (err) {
|
|
1828
|
+
const raw = err && err.message ? String(err.message) : 'unknown';
|
|
1829
|
+
const down =
|
|
1830
|
+
/Companion not running|Failed to fetch|NetworkError|Load failed/i.test(
|
|
1831
|
+
raw
|
|
1832
|
+
);
|
|
1833
|
+
const msg = down
|
|
1834
|
+
? 'Companion not running — start with npx crank / open :3344'
|
|
1835
|
+
: 'Agent queue failed: ' + raw;
|
|
1836
|
+
showAgentHandoffError(
|
|
1837
|
+
msg,
|
|
1838
|
+
down
|
|
1839
|
+
? 'Expected companion at ' + COMPANION_HTTP
|
|
1840
|
+
: 'Is the companion running at ' + COMPANION_HTTP + '?'
|
|
1841
|
+
);
|
|
1842
|
+
showToast(msg, 'error');
|
|
1843
|
+
setStatus(msg);
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
async function applyVariant(variantId, options) {
|
|
1848
|
+
if (!sessionId) return;
|
|
1849
|
+
const opts = options || {};
|
|
1850
|
+
const persist = opts.persist === true;
|
|
1851
|
+
const local = lastVariants.find(function (v) {
|
|
1852
|
+
return v && v.id === variantId;
|
|
1853
|
+
});
|
|
1854
|
+
setStatus(persist ? 'Applying (DOM + source)…' : 'Previewing…');
|
|
1855
|
+
activeVariantId = variantId;
|
|
1856
|
+
markActiveVariantCard(variantId);
|
|
1857
|
+
|
|
1858
|
+
if (persist) {
|
|
1859
|
+
if (
|
|
1860
|
+
sendWs({
|
|
1861
|
+
type: 'APPLY_VARIANT',
|
|
1862
|
+
sessionId: sessionId,
|
|
1863
|
+
variantId: variantId,
|
|
1864
|
+
persist: true
|
|
1865
|
+
})
|
|
1866
|
+
) {
|
|
1867
|
+
if (opts.syncEditors && local) syncEditorsFromVariant(local);
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
try {
|
|
1871
|
+
const data = await postJson('/api/apply', {
|
|
1872
|
+
sessionId: sessionId,
|
|
1873
|
+
variantId: variantId,
|
|
1874
|
+
persist: true
|
|
1875
|
+
});
|
|
1876
|
+
if (data.ok && selectedEl) {
|
|
1877
|
+
const variant = data.variant || local;
|
|
1878
|
+
applyNodesToDom(selectedEl, (variant && variant.nodes) || []);
|
|
1879
|
+
if (opts.syncEditors && variant) syncEditorsFromVariant(variant);
|
|
1880
|
+
setStatus(
|
|
1881
|
+
formatPersistStatus(
|
|
1882
|
+
data.persistResult,
|
|
1883
|
+
(variant && variant.label) || variantId
|
|
1884
|
+
)
|
|
1885
|
+
);
|
|
1886
|
+
} else {
|
|
1887
|
+
setStatus('Apply error: ' + (data.error || 'failed'));
|
|
1888
|
+
}
|
|
1889
|
+
} catch (err) {
|
|
1890
|
+
setStatus('Apply error: ' + (err.message || 'failed'));
|
|
1891
|
+
}
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
// Preview — DOM only
|
|
1896
|
+
if (
|
|
1897
|
+
sendWs({
|
|
1898
|
+
type: 'PREVIEW_VARIANT',
|
|
1899
|
+
sessionId: sessionId,
|
|
1900
|
+
variantId: variantId
|
|
1901
|
+
})
|
|
1902
|
+
) {
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
try {
|
|
1906
|
+
const data = await postJson('/api/preview-variant', {
|
|
1907
|
+
sessionId: sessionId,
|
|
1908
|
+
variantId: variantId
|
|
1909
|
+
});
|
|
1910
|
+
if (data.ok && selectedEl) {
|
|
1911
|
+
const variant = data.variant || local;
|
|
1912
|
+
applyNodesToDom(selectedEl, (variant && variant.nodes) || []);
|
|
1913
|
+
setStatus(
|
|
1914
|
+
'Previewed "' +
|
|
1915
|
+
((variant && variant.label) || variantId) +
|
|
1916
|
+
'" in DOM (not saved)'
|
|
1917
|
+
);
|
|
1918
|
+
} else {
|
|
1919
|
+
setStatus('Preview error: ' + (data.error || 'failed'));
|
|
1920
|
+
}
|
|
1921
|
+
} catch (err) {
|
|
1922
|
+
// Fallback: local DOM only if companion route missing
|
|
1923
|
+
if (local && selectedEl) {
|
|
1924
|
+
applyNodesToDom(selectedEl, local.nodes || []);
|
|
1925
|
+
setStatus(
|
|
1926
|
+
'Previewed "' +
|
|
1927
|
+
(local.label || variantId) +
|
|
1928
|
+
'" in DOM (local; companion preview unavailable)'
|
|
1929
|
+
);
|
|
1930
|
+
} else {
|
|
1931
|
+
setStatus('Preview error: ' + (err.message || 'failed'));
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
let thinkingLivePartial = '';
|
|
1937
|
+
let lastThinkingProgress = null;
|
|
1938
|
+
let thinkingStartedAt = 0;
|
|
1939
|
+
let thinkingUiTimer = null;
|
|
1940
|
+
|
|
1941
|
+
function thinkingElapsedLine() {
|
|
1942
|
+
if (!thinkingStartedAt) return 'Elapsed 0s';
|
|
1943
|
+
const secs = Math.round((Date.now() - thinkingStartedAt) / 1000);
|
|
1944
|
+
return 'Elapsed ' + secs + 's';
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/** Last N lines of stream text for paragraph-style thinking display. */
|
|
1948
|
+
function streamingBodyLines(partial, maxLines) {
|
|
1949
|
+
maxLines = maxLines || 3;
|
|
1950
|
+
const raw = String(partial || '').trim();
|
|
1951
|
+
if (!raw) return [];
|
|
1952
|
+
|
|
1953
|
+
let lines = raw.split(/\n/).map(function (l) {
|
|
1954
|
+
return l.trim();
|
|
1955
|
+
}).filter(Boolean);
|
|
1956
|
+
if (lines.length > maxLines) {
|
|
1957
|
+
return lines.slice(-maxLines);
|
|
1958
|
+
}
|
|
1959
|
+
if (lines.length >= 2) return lines;
|
|
1960
|
+
|
|
1961
|
+
const compact = raw.replace(/\s+/g, ' ').trim();
|
|
1962
|
+
const tail = compact.length > 320 ? '…' + compact.slice(-317) : compact;
|
|
1963
|
+
const width = 88;
|
|
1964
|
+
const wrapped = [];
|
|
1965
|
+
for (let i = tail.length; i > 0 && wrapped.length < maxLines; ) {
|
|
1966
|
+
const start = Math.max(0, i - width);
|
|
1967
|
+
const piece = tail.slice(start, i).trim();
|
|
1968
|
+
if (piece) wrapped.unshift(piece);
|
|
1969
|
+
i = start;
|
|
1970
|
+
if (start === 0) break;
|
|
1971
|
+
}
|
|
1972
|
+
return wrapped.slice(-maxLines);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
function resolveLatestThought(progress) {
|
|
1976
|
+
if (!progress) return 'Waiting on Glean…';
|
|
1977
|
+
|
|
1978
|
+
const elapsedSec = thinkingStartedAt
|
|
1979
|
+
? Math.round((Date.now() - thinkingStartedAt) / 1000)
|
|
1980
|
+
: 0;
|
|
1981
|
+
const stage = progress.stage ? String(progress.stage) : '';
|
|
1982
|
+
const genericConnected = 'Connected — agent running…';
|
|
1983
|
+
|
|
1984
|
+
if (
|
|
1985
|
+
progress.latestThought &&
|
|
1986
|
+
progress.latestThought !== genericConnected &&
|
|
1987
|
+
!/^Streaming agent output/i.test(String(progress.latestThought))
|
|
1988
|
+
) {
|
|
1989
|
+
return String(progress.latestThought);
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
if (progress.streamChars > 0 && !(progress.live && progress.partial)) {
|
|
1993
|
+
return 'Generating copy… (' + progress.streamChars + ' chars received)';
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
if (stage === 'start') {
|
|
1997
|
+
return progress.latestThought || progress.text || 'Sending to Glean…';
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
// Silent stream / wait — Glean often sends no tokens for ~1 min; rotate by elapsed.
|
|
2001
|
+
if (stage === 'stream_wait' || stage === 'waiting' || progress.text === genericConnected) {
|
|
2002
|
+
if (elapsedSec < 15) {
|
|
2003
|
+
return progress.text || 'Connecting to live agent stream…';
|
|
2004
|
+
}
|
|
2005
|
+
if (elapsedSec < 35) return 'Agent is analyzing your copy…';
|
|
2006
|
+
if (elapsedSec < 60) return 'Agent is drafting alternatives…';
|
|
2007
|
+
if (elapsedSec < 90) return 'Agent is formatting options…';
|
|
2008
|
+
return 'Agent is finishing the response…';
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
if (progress.text) return String(progress.text);
|
|
2012
|
+
return 'Waiting on Glean…';
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
function renderThinking() {
|
|
2016
|
+
const el = panel.querySelector('#vc-thinking');
|
|
2017
|
+
if (!el || !gleanActiveEpoch) return;
|
|
2018
|
+
const progress = lastThinkingProgress || {};
|
|
2019
|
+
el.style.display = 'block';
|
|
2020
|
+
|
|
2021
|
+
if (progress.live && progress.partial) {
|
|
2022
|
+
thinkingLivePartial = String(progress.partial);
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
const elapsed = thinkingElapsedLine();
|
|
2026
|
+
const displayLines = [];
|
|
2027
|
+
const partial = progress.partial || thinkingLivePartial;
|
|
2028
|
+
|
|
2029
|
+
if (progress.live && partial) {
|
|
2030
|
+
const body = streamingBodyLines(partial, 3);
|
|
2031
|
+
if (body.length) {
|
|
2032
|
+
for (let i = 0; i < body.length; i++) displayLines.push(body[i]);
|
|
2033
|
+
} else {
|
|
2034
|
+
displayLines.push(resolveLatestThought(progress));
|
|
2035
|
+
}
|
|
2036
|
+
} else {
|
|
2037
|
+
displayLines.push(resolveLatestThought(progress));
|
|
2038
|
+
if (progress.detail && String(progress.detail).trim()) {
|
|
2039
|
+
displayLines.push(String(progress.detail).trim());
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
displayLines.push(elapsed);
|
|
2044
|
+
|
|
2045
|
+
el.style.whiteSpace = 'pre-wrap';
|
|
2046
|
+
el.style.overflow = 'hidden';
|
|
2047
|
+
el.style.textOverflow = '';
|
|
2048
|
+
el.style.maxHeight = 'min(6.5em, 96px)';
|
|
2049
|
+
el.style.lineHeight = '1.45';
|
|
2050
|
+
el.style.overflowY = 'auto';
|
|
2051
|
+
el.style.overflowX = 'hidden';
|
|
2052
|
+
el.style.fontFamily = progress.live && partial
|
|
2053
|
+
? 'ui-monospace,SFMono-Regular,Menlo,monospace'
|
|
2054
|
+
: '';
|
|
2055
|
+
el.textContent = displayLines.join('\n');
|
|
2056
|
+
el.scrollTop = el.scrollHeight;
|
|
2057
|
+
|
|
2058
|
+
const stage = progress.stage ? String(progress.stage) : '';
|
|
2059
|
+
const statusPreview = displayLines[0] || '';
|
|
2060
|
+
if (stage === 'stub' || stage === 'done' || stage === 'fallback') {
|
|
2061
|
+
setStatus(statusPreview);
|
|
2062
|
+
} else if (stage === 'start') {
|
|
2063
|
+
setStatus('Sending to Glean…');
|
|
2064
|
+
} else if (progress.live) {
|
|
2065
|
+
setStatus('Glean is streaming…');
|
|
2066
|
+
} else {
|
|
2067
|
+
setStatus('Waiting on Glean…');
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
function showThinking(progress) {
|
|
2072
|
+
lastThinkingProgress = progress || {};
|
|
2073
|
+
renderThinking();
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
function startThinkingClock() {
|
|
2077
|
+
thinkingStartedAt = Date.now();
|
|
2078
|
+
lastThinkingProgress = null;
|
|
2079
|
+
thinkingLivePartial = '';
|
|
2080
|
+
if (thinkingUiTimer) clearInterval(thinkingUiTimer);
|
|
2081
|
+
thinkingUiTimer = setInterval(function () {
|
|
2082
|
+
if (!gleanActiveEpoch) return;
|
|
2083
|
+
renderThinking();
|
|
2084
|
+
}, 1000);
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
function stopThinkingClock() {
|
|
2088
|
+
if (thinkingUiTimer) {
|
|
2089
|
+
clearInterval(thinkingUiTimer);
|
|
2090
|
+
thinkingUiTimer = null;
|
|
2091
|
+
}
|
|
2092
|
+
thinkingStartedAt = 0;
|
|
2093
|
+
lastThinkingProgress = null;
|
|
2094
|
+
thinkingLivePartial = '';
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
function hideThinking() {
|
|
2098
|
+
const el = panel.querySelector('#vc-thinking');
|
|
2099
|
+
if (el) {
|
|
2100
|
+
el.style.display = 'none';
|
|
2101
|
+
el.textContent = '';
|
|
2102
|
+
el.style.maxHeight = '';
|
|
2103
|
+
el.style.overflowY = '';
|
|
2104
|
+
el.style.overflowX = '';
|
|
2105
|
+
el.style.fontFamily = '';
|
|
2106
|
+
}
|
|
2107
|
+
stopThinkingClock();
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
async function requestGlean() {
|
|
2111
|
+
if (!sessionId) return;
|
|
2112
|
+
if (gleanActiveEpoch) {
|
|
2113
|
+
setStatus('Glean request already in progress…');
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
const epoch = ++gleanEpoch;
|
|
2117
|
+
gleanActiveEpoch = epoch;
|
|
2118
|
+
setStatus('Waiting on Glean…');
|
|
2119
|
+
startThinkingClock();
|
|
2120
|
+
showThinking({
|
|
2121
|
+
stage: 'stream_wait',
|
|
2122
|
+
text: 'Connecting to live agent stream…',
|
|
2123
|
+
detail: 'Robot Content Designer · usually 1–2 min',
|
|
2124
|
+
live: false
|
|
2125
|
+
});
|
|
2126
|
+
panel.querySelector('#vc-glean').disabled = true;
|
|
2127
|
+
let progressPoll = null;
|
|
2128
|
+
const pollProgress = async function () {
|
|
2129
|
+
if (gleanActiveEpoch !== epoch || !sessionId) return;
|
|
2130
|
+
try {
|
|
2131
|
+
const resp = await fetch(
|
|
2132
|
+
COMPANION_HTTP + '/api/glean-progress?sessionId=' + encodeURIComponent(sessionId),
|
|
2133
|
+
{ cache: 'no-store', headers: authHeaders() }
|
|
2134
|
+
);
|
|
2135
|
+
const data = await resp.json();
|
|
2136
|
+
if (data.ok && data.progress && gleanActiveEpoch === epoch) {
|
|
2137
|
+
showThinking(data.progress);
|
|
2138
|
+
}
|
|
2139
|
+
} catch (_) {}
|
|
2140
|
+
};
|
|
2141
|
+
progressPoll = setInterval(pollProgress, 1000);
|
|
2142
|
+
pollProgress();
|
|
2143
|
+
// HTTP await is source of truth; WS + poll carry live GLEAN_PROGRESS.
|
|
2144
|
+
// Do not also send REQUEST_VARIANTS (would double-call Glean).
|
|
2145
|
+
try {
|
|
2146
|
+
const data = await postJson(
|
|
2147
|
+
'/api/variants',
|
|
2148
|
+
{
|
|
2149
|
+
sessionId: sessionId,
|
|
2150
|
+
optionCount: getGleanOptionCount(),
|
|
2151
|
+
options: { optionCount: getGleanOptionCount() }
|
|
2152
|
+
},
|
|
2153
|
+
{ timeoutMs: GLEAN_VARIANTS_TIMEOUT_MS }
|
|
2154
|
+
);
|
|
2155
|
+
if (gleanActiveEpoch !== epoch) return;
|
|
2156
|
+
if (!data.ok) throw new Error(data.error || 'Glean failed');
|
|
2157
|
+
gleanActiveEpoch = 0;
|
|
2158
|
+
hideThinking();
|
|
2159
|
+
const variants = data.variants || (data.session && data.session.variants) || [];
|
|
2160
|
+
renderVariants(variants);
|
|
2161
|
+
setStatus('Glean returned ' + variants.length + ' options');
|
|
2162
|
+
} catch (err) {
|
|
2163
|
+
if (gleanActiveEpoch !== epoch) return;
|
|
2164
|
+
gleanActiveEpoch = 0;
|
|
2165
|
+
hideThinking();
|
|
2166
|
+
setStatus('Glean error: ' + err.message);
|
|
2167
|
+
} finally {
|
|
2168
|
+
if (progressPoll) clearInterval(progressPoll);
|
|
2169
|
+
if (gleanActiveEpoch === epoch) gleanActiveEpoch = 0;
|
|
2170
|
+
panel.querySelector('#vc-glean').disabled = !sessionId;
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
function onMove(ev) {
|
|
2175
|
+
if (!selecting) return;
|
|
2176
|
+
const el = document.elementFromPoint(ev.clientX, ev.clientY);
|
|
2177
|
+
if (!el || panel.contains(el) || el === highlight) return;
|
|
2178
|
+
const target = el.closest('div') || el;
|
|
2179
|
+
const r = target.getBoundingClientRect();
|
|
2180
|
+
highlight.style.left = r.left + 'px';
|
|
2181
|
+
highlight.style.top = r.top + 'px';
|
|
2182
|
+
highlight.style.width = r.width + 'px';
|
|
2183
|
+
highlight.style.height = r.height + 'px';
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
async function onClick(ev) {
|
|
2187
|
+
if (!selecting) return;
|
|
2188
|
+
ev.preventDefault();
|
|
2189
|
+
ev.stopPropagation();
|
|
2190
|
+
const el = document.elementFromPoint(ev.clientX, ev.clientY);
|
|
2191
|
+
if (!el || panel.contains(el)) return;
|
|
2192
|
+
const target = el.closest('div') || el;
|
|
2193
|
+
stopSelect();
|
|
2194
|
+
selectedEl = target;
|
|
2195
|
+
selectedNodes = extractTextNodes(target);
|
|
2196
|
+
originalSnapshot = selectedNodes.map(function (n) {
|
|
2197
|
+
return Object.assign({}, n, {
|
|
2198
|
+
text: n.text,
|
|
2199
|
+
previousText: n.text,
|
|
2200
|
+
originalText: n.originalText != null ? n.originalText : n.text
|
|
2201
|
+
});
|
|
2202
|
+
});
|
|
2203
|
+
selectedNodes = selectedNodes.map(function (n) {
|
|
2204
|
+
return Object.assign({}, n, {
|
|
2205
|
+
originalText: n.originalText != null ? n.originalText : n.text
|
|
2206
|
+
});
|
|
2207
|
+
});
|
|
2208
|
+
renderNodes();
|
|
2209
|
+
setStatus('Selected ' + selectedNodes.length + ' copy field(s)');
|
|
2210
|
+
const payload = {
|
|
2211
|
+
origin: location.origin,
|
|
2212
|
+
pageUrl: location.href,
|
|
2213
|
+
selector: cssPath(target),
|
|
2214
|
+
tagName: target.tagName.toLowerCase(),
|
|
2215
|
+
nodes: selectedNodes,
|
|
2216
|
+
combinedText: selectedNodes.map(function (n) {
|
|
2217
|
+
return n.text;
|
|
2218
|
+
}).join('\n'),
|
|
2219
|
+
meta: {
|
|
2220
|
+
pathHints: pathHintsFromLocation()
|
|
2221
|
+
}
|
|
2222
|
+
};
|
|
2223
|
+
try {
|
|
2224
|
+
if (sendWs({ type: 'SET_SELECTION', payload: payload })) {
|
|
2225
|
+
/* ack sets session via SELECTION — also POST for reliability */
|
|
2226
|
+
}
|
|
2227
|
+
const data = await postJson('/api/selection', payload);
|
|
2228
|
+
if (data.ok) {
|
|
2229
|
+
sessionId = data.session.id;
|
|
2230
|
+
panel.querySelector('#vc-glean').disabled = false;
|
|
2231
|
+
setStatus('Selection ready — preview/edit or Ask Glean');
|
|
2232
|
+
}
|
|
2233
|
+
} catch (err) {
|
|
2234
|
+
setStatus('Selection error: ' + err.message);
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
function onKey(ev) {
|
|
2239
|
+
if (ev.key === 'Escape' && selecting) stopSelect();
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
function startSelect() {
|
|
2243
|
+
selecting = true;
|
|
2244
|
+
document.documentElement.appendChild(highlight);
|
|
2245
|
+
document.addEventListener('mousemove', onMove, true);
|
|
2246
|
+
document.addEventListener('click', onClick, true);
|
|
2247
|
+
document.addEventListener('keydown', onKey, true);
|
|
2248
|
+
setStatus('Hover a div, click to select. Esc cancels.');
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
function stopSelect() {
|
|
2252
|
+
selecting = false;
|
|
2253
|
+
highlight.remove();
|
|
2254
|
+
document.removeEventListener('mousemove', onMove, true);
|
|
2255
|
+
document.removeEventListener('click', onClick, true);
|
|
2256
|
+
document.removeEventListener('keydown', onKey, true);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
function teardown() {
|
|
2260
|
+
stopSelect();
|
|
2261
|
+
restoreOriginal();
|
|
2262
|
+
if (socket) socket.close();
|
|
2263
|
+
panel.remove();
|
|
2264
|
+
highlight.remove();
|
|
2265
|
+
window.__crankActive = false;
|
|
2266
|
+
window.__crankTeardown = null;
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
window.__crankTeardown = teardown;
|
|
2270
|
+
|
|
2271
|
+
async function boot() {
|
|
2272
|
+
loadCss(COMPANION_HTTP + '/ui/vizpatch-ui.css?v=' + Date.now());
|
|
2273
|
+
await loadScript(COMPANION_HTTP + '/ui/crank-ui-helpers.js?v=' + Date.now());
|
|
2274
|
+
await loadScript(COMPANION_HTTP + '/ui/text-source.js?v=' + Date.now());
|
|
2275
|
+
buildPanelShell();
|
|
2276
|
+
const authed = await ensureTeamToken();
|
|
2277
|
+
if (!authed) {
|
|
2278
|
+
setStatus('Crank team token required — reload after you have it');
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
2281
|
+
connectWs();
|
|
2282
|
+
setStatus('Ready — companion ' + COMPANION_HTTP);
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
boot().catch(function (err) {
|
|
2286
|
+
console.error('[Crank] boot failed', err);
|
|
2287
|
+
if (panel && panel.parentNode) {
|
|
2288
|
+
panel.innerHTML =
|
|
2289
|
+
'<div class="vp-panel-block" style="padding:var(--vp-panel-padding, 1rem);">' +
|
|
2290
|
+
'<div class="vp-banner vp-banner--error vp-type-support">Crank failed to load UI: ' +
|
|
2291
|
+
String(err && err.message ? err.message : err) +
|
|
2292
|
+
'</div></div>';
|
|
2293
|
+
}
|
|
2294
|
+
});
|
|
2295
|
+
})();
|