openvibers/openvibe.shared dock-code.js

dock-code.js

main 22.4 KB · 304 lines · javascript

1// ═══════════════════════════════════════════════════════════════
2// OpenVibe — the Code part (openvibe-shared 4.201.0). Ask 350, the owner: openvibe.codes "should be better interconnected
3// and shared … so that you can do chat, codes, and agents all in the same place and in one area". The menu's Vibe place
4// is Chat · Code · Agents, and the Dock has the same Code tab. dock.js loads this file the first time it opens:
5// OpenVibeDockCode.mount(container, { token, frameBase, base, codes, active, take }) → { refresh(), focus(), destroy() }
6// take() → the words another part handed over (Vibe's "Build it in Code"), which go in the box.
7//
8// One box to start a coding task, the way openvibe.codes starts one (Create: a new website, game or app; OpenVibe:
9// a change to OpenVibe itself; My code: a repository of yours), and your latest tasks under it, running ones first,
10// each with its state, its repository, how long ago, and Stop while it runs. The reads go through Network's same-origin
11// relay (/api/frame/codes-tasks…), because every site's connect-src allows openvibe.network and refuses openvibe.codes;
12// `base` reaches Codes itself in a test. While a task runs the list is read again every 8 seconds, only while the part
13// is on screen. When Codes cannot be reached, the box still works: it opens openvibe.codes with what you wrote in it.
14// Everything is set as text, never markup; a task's address is used only when it is on openvibe.codes.
15// ═══════════════════════════════════════════════════════════════
16(function (root) {
17 'use strict';
18 if (typeof document === 'undefined' || root.OpenVibeDockCode) return;
19
20 const CODES = 'https://openvibe.codes';
21 const SCRIPT_BASE = document.currentScript && document.currentScript.src ? new URL('.', document.currentScript.src).href : '/shared/';
22 const POLL_MS = 8000;
23 const MODES = [['create', 'Create', 'A new website, game or app'], ['openvibe', 'OpenVibe', 'A change to OpenVibe itself'], ['repo', 'My code', 'A repository of yours']];
24 const STATE = { queued: 'Waiting', running: 'Running', 'needs-you': 'Needs you', done: 'Done', failed: 'Failed', stopped: 'Stopped' };
25 const PLACEHOLDER = { create: 'A goal bar for my sub goal', openvibe: 'Make the menu remember my last tab', repo: 'Fix the login redirect and add a test' };
26 const svg = (d) => `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${d}"/></svg>`;
27 const G = {
28 send: 'M5 12h13M13 6l6 6-6 6',
29 stop: 'M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18zM9.5 9.5h5v5h-5z',
30 open: 'M14 4h6v6M20 4l-9 9M19 14v5a1.5 1.5 0 0 1-1.5 1.5h-12A1.5 1.5 0 0 1 4 19V7a1.5 1.5 0 0 1 1.5-1.5H10',
31 code: 'M8 7l-5 5 5 5M16 7l5 5-5 5',
32 };
33
34 function styles(d) {
35 if (d.getElementById('ovdc-styles')) return;
36 const st = d.createElement('style');
37 st.id = 'ovdc-styles';
38 st.textContent = `
39 .ovdc { display: flex; flex-direction: column; gap: 12px; padding: 10px 10px 14px; }
40 .ovdc-box { display: grid; gap: 8px; padding: 10px 10px 8px 12px; border-radius: 18px; border: 1px solid var(--border, rgba(255,255,255,.1)); background: var(--bg-card, rgba(255,255,255,.03)); }
41 .ovdc-box:focus-within { border-color: color-mix(in srgb, var(--accent, #3b82f6) 60%, var(--border, rgba(255,255,255,.1))); }
42 .ovdc-text { display: block; width: 100%; box-sizing: border-box; min-height: 44px; max-height: 30vh; padding: 2px 0; border: 0; outline: 0; resize: none; background: none; color: var(--text-primary, #ececf1); font: inherit; font-size: 15.5px; line-height: 1.45; }
43 .ovdc-repo { width: 100%; box-sizing: border-box; height: 36px; padding: 0 10px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-input, rgba(0,0,0,.2)); color: inherit; font: inherit; font-size: 14px; }
44 .ovdc-repo[hidden] { display: none; }
45 .ovdc-row { display: flex; align-items: center; gap: 6px; }
46 .ovdc-modes { flex: 1; min-width: 0; display: flex; gap: 4px; overflow-x: auto; scrollbar-width: none; }
47 .ovdc-mode { flex: none; height: 32px; padding: 0 11px; border-radius: 999px; border: 1px solid transparent; background: none; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; }
48 .ovdc-mode:hover { color: var(--text-primary, #ececf1); background: var(--bg-hover, rgba(255,255,255,.06)); }
49 .ovdc-mode[aria-checked="true"] { color: var(--accent-light, #93c5fd); border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }
50 .ovdc-go { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 13px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); cursor: pointer; }
51 .ovdc-go:disabled { opacity: .45; cursor: default; }
52 .ovdc-note { margin: 0 2px; font-size: 12.5px; color: var(--text-muted, #8b8b96); }
53 .ovdc-note:empty { display: none; }
54 .ovdc-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 4px; }
55 .ovdc-head b { font-size: 13px; letter-spacing: .02em; color: var(--text-secondary, #a8b3c4); }
56 .ovdc-all { display: inline-flex; align-items: center; gap: 5px; min-height: 32px; font-size: 13px; font-weight: 600; color: var(--accent-light, #93c5fd); text-decoration: none; }
57 .ovdc-all svg { width: 15px; height: 15px; }
58 .ovdc-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
59 .ovdc-task { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 8px; border-radius: 12px; }
60 .ovdc-task:hover { background: var(--bg-hover, rgba(255,255,255,.05)); }
61 .ovdc-a { min-width: 0; display: grid; gap: 3px; padding: 9px 4px 9px 10px; color: inherit; text-decoration: none; }
62 .ovdc-t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; font-weight: 600; color: var(--text-primary, #ececf1); }
63 .ovdc-m { display: flex; align-items: center; gap: 7px; min-width: 0; font-size: 12.5px; color: var(--text-muted, #8b8b96); white-space: nowrap; overflow: hidden; }
64 .ovdc-s { flex: none; display: inline-flex; align-items: center; gap: 5px; font-weight: 650; }
65 .ovdc-s::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
66 .ovdc-s[data-s="running"], .ovdc-s[data-s="queued"] { color: var(--accent-light, #93c5fd); }
67 .ovdc-s[data-s="running"]::before { animation: ovdc-beat 1.2s ease-in-out infinite; }
68 .ovdc-s[data-s="needs-you"] { color: #fbbf24; }
69 .ovdc-s[data-s="done"] { color: #4ade80; }
70 .ovdc-s[data-s="failed"] { color: var(--live-red, #f87171); }
71 .ovdc-s[data-s="stopped"] { color: var(--text-muted, #8b8b96); }
72 .ovdc-stop { display: grid; place-items: center; width: 36px; height: 36px; margin-right: 4px; border: 0; border-radius: 10px; background: none; color: var(--text-secondary, #a8b3c4); cursor: pointer; }
73 .ovdc-stop:hover, .ovdc-stop:focus-visible { background: color-mix(in srgb, var(--live-red, #ef4444) 14%, transparent); color: var(--live-red, #f87171); }
74 .ovdc-stop svg { width: 19px; height: 19px; }
75 .ovdc-none { padding: 18px 12px; text-align: center; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }
76 .ovdc-none a { color: var(--accent-light, #93c5fd); }
77 @keyframes ovdc-beat { 50% { opacity: .35; } }
78 html[data-ov-motion=off] .ovdc-s::before { animation: none; }`;
79 d.head.appendChild(st);
80 }
81
82 const ago = (t) => {
83 const s = Math.max(0, (Date.now() - new Date(t).getTime()) / 1000);
84 if (!isFinite(s)) return '';
85 return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)} min ago` : s < 86400 ? `${Math.floor(s / 3600)} h ago` : `${Math.floor(s / 86400)} d ago`;
86 };
87 const onCodes = (u) => { try { const x = new URL(u); return x.protocol === 'https:' && (x.hostname === 'openvibe.codes' || x.hostname.endsWith('.openvibe.codes')) ? x.href : null; } catch { return null; } };
88 const repoName = (r) => String(r || '').replace(/^https?:\/\/[^/]+\//, '').replace(/\.git$/, '');
89
90 function mount(container, opts = {}) {
91 const d = container.ownerDocument || document;
92 styles(d);
93 const o = { ...opts };
94 const tok = () => (typeof o.token === 'function' ? o.token() : o.token) || null;
95 const codes = String(o.codes || CODES).replace(/\/+$/, '');
96 const direct = o.base ? String(o.base).replace(/\/+$/, '') : '';
97 const relay = String(o.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');
98 const url = (what, id) => (direct
99 ? direct + ({ list: '/api/v1/me/tasks?limit=8', start: '/api/v1/tasks', stop: `/api/v1/tasks/${encodeURIComponent(id)}/stop` })[what]
100 : relay + ({ list: '/codes-tasks?limit=8', start: '/codes-tasks', stop: `/codes-tasks/${encodeURIComponent(id)}/stop` })[what]);
101 const h = (tag, cls, text) => { const n = d.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
102 let gone = false, timer = 0, mode = 'create', tasks = null, reachable = true;
103
104 function loadScript(file, ready) {
105 if (ready()) return Promise.resolve(true);
106 return new Promise((resolve) => {
107 const id = `ovdc-${file}`;
108 let script = d.getElementById(id);
109 if (!script) { script = d.createElement('script'); script.id = id; script.src = SCRIPT_BASE + file; script.async = true; d.head.appendChild(script); }
110 script.addEventListener('load', () => resolve(!!ready()), { once: true });
111 script.addEventListener('error', () => resolve(false), { once: true });
112 });
113 }
114 function runUrl(id) {
115 return (direct ? direct + '/api/v1/runs/' : relay + '/codes-runs/') + encodeURIComponent(id);
116 }
117 async function openRun(task, from) {
118 const href = onCodes(task.url);
119 if (!href || !tok()) return;
120 const loaded = await Promise.all([
121 loadScript('ov-window.js', () => !!root.OVWindow),
122 loadScript('agent-session.js', () => !!root.OpenVibeAgentSession)
123 ]);
124 if (loaded.some((ok) => !ok)) { d.defaultView.location.assign(href); return; }
125 const body = h('div');
126 let stopped = false, controller = null, view = null;
127 const win = root.OVWindow.open({ id: `codes-run-${task.id}`, title: task.title || 'Coding task', body, kind: 'dialog', size: 'md', from,
128 onClose: () => { stopped = true; if (controller) controller.abort(); if (view) view.destroy(); } });
129 body.appendChild(h('p', 'ovdc-none', 'Loading the run…'));
130 const headers = () => ({ Authorization: `Bearer ${tok()}`, Accept: 'application/json' });
131 try {
132 const res = await fetch(runUrl(task.id), { headers: headers(), credentials: 'omit' });
133 if (!res.ok) throw new Error('The run could not be read.');
134 const run = await res.json();
135 if (stopped) return;
136 body.replaceChildren();
137 view = root.OpenVibeAgentSession.mount(body, { title: run.title || task.title || 'Coding task',
138 status: run.status || task.status || 'running', mode: 'card', href });
139 let last = 0;
140 while (!stopped) {
141 controller = new AbortController();
142 const events = await fetch(runUrl(task.id) + '/events', { credentials: 'omit',
143 headers: { ...headers(), Accept: 'text/event-stream', 'Last-Event-ID': String(last) }, signal: controller.signal });
144 if (!events.ok || !events.body) break;
145 const reader = events.body.getReader();
146 const decoder = new TextDecoder();
147 let buffer = '', terminal = false;
148 while (!stopped) {
149 const part = await reader.read();
150 if (part.done) break;
151 buffer = (buffer + decoder.decode(part.value, { stream: true })).replace(/\r\n/g, '\n');
152 let cut;
153 while ((cut = buffer.indexOf('\n\n')) !== -1) {
154 const frame = buffer.slice(0, cut); buffer = buffer.slice(cut + 2);
155 const id = /^id: *(\d+)$/m.exec(frame), data = frame.split('\n').filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trimStart()).join('\n');
156 if (id) last = Number(id[1]);
157 if (!data) continue;
158 try { const event = JSON.parse(data); view.push(event); if (event.type === 'RUN_FINISHED' || event.type === 'RUN_ERROR') terminal = true; } catch { /* skip one malformed event */ }
159 }
160 }
161 if (terminal || !/running|queued|placed/.test(run.status || '')) break;
162 await new Promise((resolve) => setTimeout(resolve, 1500));
163 }
164 } catch (err) {
165 if (!stopped) { body.replaceChildren(); const a = h('a', 'ovk-btn ovk-btn--quiet', 'Open on Codes'); a.href = href; body.appendChild(a); }
166 }
167 return win;
168 }
169
170 container.innerHTML = '';
171 container.classList.add('ovdc');
172 const box = h('form', 'ovdc-box');
173 box.setAttribute('aria-label', 'Start a coding task');
174 const text = h('textarea', 'ovdc-text');
175 text.rows = 2; text.maxLength = 16000; text.placeholder = PLACEHOLDER.create;
176 text.setAttribute('aria-label', 'What should we make?');
177 const repo = h('input', 'ovdc-repo');
178 repo.placeholder = 'Repository: owner/name or its address'; repo.setAttribute('aria-label', 'Repository'); repo.hidden = true; repo.maxLength = 300;
179 const row = h('div', 'ovdc-row');
180 const modes = h('div', 'ovdc-modes');
181 modes.setAttribute('role', 'radiogroup'); modes.setAttribute('aria-label', 'Where it works');
182 for (const [id, label, hint] of MODES) {
183 const b = h('button', 'ovdc-mode', label);
184 b.type = 'button'; b.dataset.mode = id; b.title = hint;
185 b.setAttribute('role', 'radio'); b.setAttribute('aria-checked', String(id === mode));
186 modes.appendChild(b);
187 }
188 const go = h('button', 'ovdc-go'); go.type = 'submit'; go.innerHTML = svg(G.send); go.setAttribute('aria-label', 'Start'); go.disabled = true;
189 row.append(modes, go);
190 const note = h('p', 'ovdc-note'); note.setAttribute('role', 'status');
191 box.append(text, repo, row, note);
192 const head = h('div', 'ovdc-head');
193 const all = h('a', 'ovdc-all'); all.href = `${codes}/`; all.innerHTML = `<span>Open Codes</span>${svg(G.open)}`;
194 head.append(h('b', null, 'Your tasks'), all);
195 const list = h('ul', 'ovdc-list');
196 container.append(box, head, list);
197
198 const ready = () => { go.disabled = !text.value.trim() || (mode === 'repo' && !repo.value.trim()); };
199 text.addEventListener('input', () => { text.style.height = 'auto'; text.style.height = Math.min(text.scrollHeight, d.defaultView.innerHeight * 0.3) + 'px'; ready(); });
200 repo.addEventListener('input', ready);
201 text.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !go.disabled) { e.preventDefault(); box.requestSubmit ? box.requestSubmit() : start(); } });
202 modes.addEventListener('click', (e) => {
203 const b = e.target.closest && e.target.closest('[data-mode]'); if (!b) return;
204 mode = b.dataset.mode;
205 modes.querySelectorAll('[data-mode]').forEach((x) => x.setAttribute('aria-checked', String(x === b)));
206 text.placeholder = PLACEHOLDER[mode];
207 repo.hidden = mode !== 'repo';
208 ready();
209 (mode === 'repo' && !repo.value ? repo : text).focus();
210 });
211
212 const fallback = () => `${codes}/?${new URLSearchParams({ prompt: text.value.trim(), mode, ...(mode === 'repo' ? { repo: repo.value.trim() } : {}) })}`;
213 async function call(what, id, body) {
214 const t = tok(); if (!t) throw new Error('signed out');
215 const r = await fetch(url(what, id), { method: body || what === 'stop' ? 'POST' : 'GET', headers: { authorization: `Bearer ${t}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined, credentials: 'omit' });
216 if (!r.ok) { const e = new Error(`HTTP ${r.status}`); e.status = r.status; try { e.body = await r.json(); } catch { /* */ } throw e; }
217 return r.json().catch(() => ({}));
218 }
219 async function start() {
220 if (go.disabled) return;
221 if (!reachable) { d.defaultView.location.assign(fallback()); return; }
222 go.disabled = true; note.textContent = 'Starting…';
223 try {
224 const r = await call('start', null, { text: text.value.trim(), mode, ...(mode === 'repo' ? { repo: repo.value.trim() } : {}) });
225 text.value = ''; text.dispatchEvent(new Event('input'));
226 note.textContent = '';
227 const there = onCodes(r && r.url);
228 if (there) { const a = h('a', null, 'Follow it on openvibe.codes'); a.href = there; note.textContent = 'Started. '; note.appendChild(a); }
229 read();
230 } catch (e) {
231 ready();
232 if (e.status === 404 || e.status === 502 || e.status === 503 || e.message === 'Failed to fetch') { d.defaultView.location.assign(fallback()); return; }
233 note.textContent = (e.body && (e.body.message || e.body.error)) || 'It did not start. Try again in a moment.';
234 }
235 }
236 box.addEventListener('submit', (e) => { e.preventDefault(); start(); });
237
238 function paint() {
239 list.replaceChildren();
240 if (!tok()) { list.appendChild(h('li', 'ovdc-none', 'Sign in to start coding tasks and follow them here.')); return; }
241 if (!reachable) {
242 const li = h('li', 'ovdc-none');
243 li.append('Your tasks are on ');
244 const a = h('a', null, 'openvibe.codes'); a.href = `${codes}/`; li.append(a, '. Starting one here opens it there.');
245 list.appendChild(li); return;
246 }
247 if (!tasks) return;
248 if (!tasks.length) { list.appendChild(h('li', 'ovdc-none', 'Nothing yet. Say what to make, and an agent builds it while you watch.')); return; }
249 for (const t of tasks) {
250 const li = h('li', 'ovdc-task');
251 const there = onCodes(t.url);
252 const a = h(there ? 'a' : 'div', 'ovdc-a');
253 if (there) { a.href = there; a.addEventListener('click', (event) => {
254 if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
255 event.preventDefault(); openRun(t, a);
256 }); }
257 a.appendChild(h('span', 'ovdc-t', t.title || 'A coding task'));
258 const m = h('span', 'ovdc-m');
259 const s = h('span', 'ovdc-s', STATE[t.status] || t.status || ''); s.dataset.s = t.status || '';
260 m.appendChild(s);
261 const bits = [t.mode === 'repo' && t.repo ? repoName(t.repo) : t.mode === 'openvibe' ? 'OpenVibe' : null, t.steps ? `${t.steps} steps` : null, ago(t.updated_at || t.started_at)].filter(Boolean);
262 if (bits.length) m.appendChild(h('span', null, bits.join(' · ')));
263 a.appendChild(m);
264 li.appendChild(a);
265 if (t.status === 'running' || t.status === 'queued' || t.status === 'needs-you') {
266 const x = h('button', 'ovdc-stop'); x.type = 'button'; x.innerHTML = svg(G.stop); x.setAttribute('aria-label', `Stop: ${t.title || 'this task'}`); x.title = 'Stop';
267 x.addEventListener('click', async () => { x.disabled = true; try { await call('stop', t.id); } catch { /* the next read says */ } read(); });
268 li.appendChild(x);
269 }
270 list.appendChild(li);
271 }
272 }
273 async function read() {
274 clearTimeout(timer);
275 if (gone || !tok()) { paint(); return; }
276 try {
277 const j = await call('list');
278 reachable = true;
279 tasks = (Array.isArray(j) ? j : j.tasks || []).slice(0, 8);
280 tasks.sort((a, b) => (/running|queued|needs-you/.test(b.status) ? 1 : 0) - (/running|queued|needs-you/.test(a.status) ? 1 : 0));
281 } catch (e) {
282 if (e.status === 401 || e.status === 403) tasks = [];
283 else reachable = false;
284 }
285 if (gone) return;
286 paint();
287 if (reachable && tasks && tasks.some((t) => /running|queued/.test(t.status)) && (!o.active || o.active())) timer = setTimeout(read, POLL_MS);
288 }
289 // What another part handed over (Vibe's Build it in Code) goes in the box, ready to start.
290 const take = () => { const t = typeof o.take === 'function' ? o.take() : ''; if (!t) return; text.value = t; text.dispatchEvent(new Event('input')); text.focus(); note.textContent = 'From your chat with Vibe. Start it, or change it first.'; };
291 paint();
292 read();
293 take();
294 return {
295 refresh: () => { take(); read(); },
296 focus: () => text.focus(),
297 destroy() { gone = true; clearTimeout(timer); container.replaceChildren(); container.classList.remove('ovdc'); },
298 };
299 }
300
301 root.OpenVibeDockCode = { mount };
302 if (typeof module === 'object' && module.exports) module.exports = { mount };
303})(typeof window !== 'undefined' ? window : globalThis);
304