openvibers/openvibe.shared dock-actor.js

dock-actor.js

main 31.0 KB · 382 lines · javascript

1// ═══════════════════════════════════════════════════════════════
2// OpenVibe — the Dock's Actor block (Actor v3 plan, stage A3; redrawn 2026-11-10)
3//
4// Your Actor at the top of the Dock's Agents tab, on every OpenVibe site. dock.js loads this file the first time that
5// tab opens:
6// OpenVibeDockActor.mount(container, { token, base, active }) → { refresh(), destroy() }
7//
8// It reads OpenVibe.Actor's GET /api/v1/me/actor with the person's Network token (scope actor.me.read); the answers,
9// pause, resume and tell are POSTs with actor.me.write. On a site every call goes through Network's same-origin relay
10// (/api/frame/actor…) because every site's connect-src allows openvibe.network and refuses openvibe.actor; `base`
11// reaches Actor itself in a test, and `frameBase` names the relay. Nothing is asked without a token.
12//
13// The look (the owner, 2026-11-10: "more intuitive, minimalist, elegant"): one row and one line to type in.
14// - Who: the face with a ring in the colour of its state, the name, and one quiet line (the state, today's spend
15// against the cap). The row is the way to the Actor's page; there is no separate "Open".
16// - One action beside it, the one that fits: "Resume" in the accent when it is paused, a quiet pause when it runs.
17// - What it does, only when there is something to say: the step it is on (a soft pulse), what it looks at, the
18// steps before it, and what comes next, as a short line of dots. A paused Actor shows none of this: the row says it.
19// - What it asks you, with its own buttons, in the warning tone (a lean and a glow, never a bar).
20// - One field to tell it something, with send inside the field.
21// A person with no Actor sees nothing here. Everything people or the Actor wrote is set as text, never markup. The
22// block is built once: a refresh repaints what changed and never touches the line being typed. Every control is 44 px.
23// ═══════════════════════════════════════════════════════════════
24(function (root) {
25 'use strict';
26 if (typeof document === 'undefined' || root.OpenVibeDockActor) return;
27
28 const POLL_MS = 15000;
29 const ACTOR_REV = { 'ov-window.js': '1c4db9b0ce9c', 'agent-sheet.js': '8d150490ad64' };
30 const SCRIPT_BASE = document.currentScript && document.currentScript.src ? new URL('.', document.currentScript.src).href : '/shared/';
31 const STATE = { working: 'Working', resting: 'Resting', paused: 'Paused', waiting: 'Waiting for you' };
32 // The lines Actor sends as "the current step" when there is none: the state already says them.
33 const NOT_A_STEP = /^(paused|resting\b.*)\.?$/i;
34 const ICON = {
35 play: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8.5 5.6v12.8a.9.9 0 0 0 1.37.77l10.3-6.4a.9.9 0 0 0 0-1.54l-10.3-6.4a.9.9 0 0 0-1.37.77z" fill="currentColor"/></svg>',
36 pause: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6.5" y="5" width="4" height="14" rx="1.3" fill="currentColor"/><rect x="13.5" y="5" width="4" height="14" rx="1.3" fill="currentColor"/></svg>',
37 go: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 6.5 15 12l-5.5 5.5"/></svg>',
38 send: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5.5M6 11l6-6 6 6"/></svg>',
39 ask: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.2 9.3a2.9 2.9 0 1 1 4.4 2.5c-.9.6-1.6 1.2-1.6 2.3"/><path d="M12 17.6v.1"/></svg>',
40 past: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12a8 8 0 1 0 2.6-5.9"/><path d="M4 5v3.6h3.6"/><path d="M12 8.2V12l2.6 1.7"/></svg>',
41 };
42
43 function styles(doc) {
44 const d = doc || document;
45 if (!d || d.getElementById('ovdac-styles')) return;
46 const st = d.createElement('style');
47 st.id = 'ovdac-styles';
48 st.textContent = `
49 .ovdac { display: none; flex: none; padding: 12px 12px 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08));
50 --ovdac-ok: var(--success, #22c55e); --ovdac-warn: var(--warning, #f5a524); --ovdac-ink: var(--text-primary, #ececf1); }
51 .ovdac.on { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
52 .ovdac [hidden] { display: none !important; }
53 .ovdac svg { width: 18px; height: 18px; flex: none; }
54 .ovdac .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
55
56 .ovdac-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
57 .ovdac-id { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 2px 8px 2px 4px; margin-left: -4px; border-radius: 14px;
58 color: inherit; text-decoration: none; transition: background .15s ease; }
59 .ovdac-id:hover { background: color-mix(in srgb, var(--ovdac-ink) 5%, transparent); }
60 .ovdac-id:focus-visible, .ovdac-go:focus-visible, .ovdac-btn:focus-visible, .ovdac-link:focus-visible, .ovdac-send:focus-visible { outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: 2px; }
61 .ovdac-face { position: relative; flex: none; width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center;
62 background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font-weight: 800; font-size: 16px; transition: filter .2s ease, opacity .2s ease; }
63 .ovdac-face img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
64 .ovdac-face::after { content: ''; position: absolute; inset: -4px; border-radius: 50%; border: 2px solid transparent; transition: border-color .2s ease; }
65 .ovdac[data-state="working"] .ovdac-face::after { border-color: var(--ovdac-ok); animation: ovdac-ring 2.4s ease-in-out infinite; }
66 .ovdac[data-state="waiting"] .ovdac-face::after { border-color: var(--ovdac-warn); }
67 .ovdac[data-state="paused"] .ovdac-face { filter: saturate(.5); opacity: .78; }
68 .ovdac-who { min-width: 0; display: grid; gap: 1px; }
69 .ovdac-name { display: flex; align-items: center; gap: 1px; min-width: 0; font-weight: 750; font-size: 15px; line-height: 1.25; color: var(--ovdac-ink); }
70 .ovdac-name span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
71 .ovdac-name svg { width: 16px; height: 16px; color: var(--text-muted, #8b8b96); transition: transform .18s ease, color .18s ease; }
72 .ovdac-id:hover .ovdac-name svg { transform: translateX(2px); color: var(--text-secondary, #b6b6c0); }
73 .ovdac-line { display: flex; gap: 6px; min-width: 0; font-size: 12.5px; line-height: 1.35; color: var(--text-muted, #8b8b96); white-space: nowrap; }
74 .ovdac-state { flex: none; font-weight: 650; color: var(--text-secondary, #b6b6c0); }
75 .ovdac[data-state="working"] .ovdac-state { color: color-mix(in srgb, var(--ovdac-ok) 72%, var(--ovdac-ink)); }
76 .ovdac[data-state="waiting"] .ovdac-state { color: color-mix(in srgb, var(--ovdac-warn) 76%, var(--ovdac-ink)); }
77 .ovdac-spend { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
78 .ovdac-spend::before { content: '·'; margin-right: 6px; }
79
80 .ovdac-go { flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 44px; height: 44px; padding: 0; border-radius: 999px;
81 border: 1px solid var(--border, rgba(255,255,255,.14)); background: transparent; color: var(--text-secondary, #b6b6c0);
82 font: inherit; font-size: 13.5px; font-weight: 700; cursor: pointer; white-space: nowrap; transition: background .15s ease, color .15s ease, transform .12s ease, box-shadow .2s ease; }
83 .ovdac-go:hover { color: var(--ovdac-ink); background: color-mix(in srgb, var(--ovdac-ink) 7%, transparent); }
84 .ovdac-go.is-main { padding: 0 18px 0 13px; border-color: transparent; background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff);
85 box-shadow: 0 8px 20px -10px var(--accent, #3b82f6); }
86 .ovdac-go.is-main:hover { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); filter: brightness(1.08); }
87 .ovdac-go:active, .ovdac-btn:active, .ovdac-send:active { transform: scale(.96); }
88 .ovdac .is-busy { opacity: .6; pointer-events: none; animation: ovdac-pulse 1.1s ease-in-out infinite; }
89
90 .ovdac-rail { list-style: none; margin: 0; padding: 2px 4px 2px 6px; display: grid; gap: 9px; }
91 .ovdac-rail li { position: relative; padding-left: 22px; font-size: 12.5px; line-height: 1.4; color: var(--text-secondary, #b6b6c0); overflow-wrap: anywhere; }
92 .ovdac-rail li::before { content: ''; position: absolute; left: 1px; top: .36em; width: 6px; height: 6px; border-radius: 50%; border: 1.5px solid var(--text-muted, #8b8b96); }
93 .ovdac-rail li:not(:last-child)::after { content: ''; position: absolute; left: 5px; top: calc(.36em + 13px); bottom: -8px; width: 1px;
94 background: color-mix(in srgb, var(--ovdac-ink) 14%, transparent); }
95 .ovdac-rail .is-live { font-size: 13.5px; font-weight: 650; color: var(--ovdac-ink); }
96 .ovdac-rail .is-live::before { border-color: var(--ovdac-ok); background: var(--ovdac-ok); box-shadow: 0 0 0 4px color-mix(in srgb, var(--ovdac-ok) 20%, transparent); animation: ovdac-pulse 1.8s ease-in-out infinite; }
97 .ovdac-rail .is-next { color: var(--text-muted, #8b8b96); }
98 .ovdac-rail .is-next::before { border-style: dashed; }
99 .ovdac-rail b { font-weight: 650; }
100 .ovdac-look { display: block; margin-top: 2px; font-size: 12px; font-weight: 400; color: var(--text-muted, #8b8b96); }
101
102 .ovdac-ask { display: grid; gap: 10px; padding: 12px 14px 10px; border-radius: 16px;
103 background: linear-gradient(135deg, color-mix(in srgb, var(--ovdac-warn) 15%, transparent), color-mix(in srgb, var(--ovdac-warn) 5%, transparent) 62%);
104 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovdac-warn) 22%, transparent), 0 12px 28px -20px var(--ovdac-warn); }
105 .ovdac-ask-k { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; color: color-mix(in srgb, var(--ovdac-warn) 74%, var(--ovdac-ink)); }
106 .ovdac-ask-k i { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: color-mix(in srgb, var(--ovdac-warn) 24%, transparent); }
107 .ovdac-ask-k svg { width: 16px; height: 16px; }
108 .ovdac-ask p { margin: 0; font-size: 14px; line-height: 1.45; color: var(--ovdac-ink); overflow-wrap: anywhere; }
109 .ovdac-ask .row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
110 .ovdac-btn { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 16px; border-radius: 999px;
111 border: 1px solid color-mix(in srgb, var(--ovdac-ink) 18%, transparent); background: transparent; color: var(--ovdac-ink);
112 font: inherit; font-size: 13.5px; font-weight: 700; cursor: pointer; white-space: nowrap; transition: background .15s ease, transform .12s ease; }
113 .ovdac-btn:hover { background: color-mix(in srgb, var(--ovdac-ink) 8%, transparent); }
114 .ovdac-btn.is-yes { background: var(--accent-strong, var(--accent, #2563eb)); border-color: transparent; color: var(--on-accent-strong, #fff); }
115 .ovdac-btn.is-yes:hover { background: var(--accent-strong, var(--accent, #2563eb)); filter: brightness(1.08); }
116 .ovdac-btn.is-quiet { padding: 0 10px; border-color: transparent; color: var(--text-secondary, #b6b6c0); font-weight: 650; }
117 .ovdac-more { display: inline-flex; align-items: center; min-height: 44px; margin: -8px 0 -8px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); text-decoration: underline; text-underline-offset: 3px; }
118
119 .ovdac-tell { position: relative; display: flex; }
120 .ovdac-tell input { flex: 1; min-width: 0; height: 52px; box-sizing: border-box; padding: 0 58px 0 18px; border-radius: 26px;
121 border: 1px solid var(--border, rgba(255,255,255,.14)); background: var(--bg-input, color-mix(in srgb, var(--ovdac-ink) 4%, transparent));
122 color: var(--ovdac-ink); font: inherit; font-size: 14.5px; outline: none; transition: border-color .15s ease, box-shadow .15s ease; }
123 .ovdac-tell input::placeholder { color: var(--text-muted, #8b8b96); }
124 .ovdac-tell input:focus { border-color: color-mix(in srgb, var(--accent, #3b82f6) 62%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); }
125 .ovdac-send { position: absolute; right: 4px; top: 4px; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 50%;
126 background: color-mix(in srgb, var(--ovdac-ink) 8%, transparent); color: var(--text-muted, #8b8b96); cursor: pointer;
127 transition: background .18s ease, color .18s ease, transform .12s ease, box-shadow .2s ease; }
128 .ovdac-tell.has-text .ovdac-send { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); box-shadow: 0 6px 16px -8px var(--accent, #3b82f6); }
129
130 .ovdac-foot { display: flex; align-items: center; gap: 10px; min-width: 0; margin-top: -6px; }
131 .ovdac-link { flex: none; display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 8px; margin-left: -8px; border: 0; border-radius: 10px;
132 background: none; color: var(--text-muted, #8b8b96); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; transition: color .15s ease; }
133 .ovdac-link:hover, .ovdac-link[aria-expanded="true"] { color: var(--ovdac-ink); }
134 .ovdac-link svg { width: 15px; height: 15px; }
135 .ovdac-note { flex: 1; min-width: 0; text-align: right; font-size: 12.5px; line-height: 1.35; color: var(--text-muted, #8b8b96); overflow-wrap: anywhere; }
136 .ovdac-note.is-ok { color: color-mix(in srgb, var(--ovdac-ok) 70%, var(--ovdac-ink)); }
137 .ovdac-note.is-bad { color: color-mix(in srgb, var(--danger, #ef4444) 74%, var(--ovdac-ink)); }
138 .ovdac-hist { list-style: none; margin: -4px 0 4px; padding: 0; display: grid; gap: 6px; font-size: 12.5px; line-height: 1.4; color: var(--text-secondary, #b6b6c0); overflow-wrap: anywhere; }
139 .ovdac-hist b { margin-right: 6px; font-weight: 650; color: var(--text-muted, #8b8b96); }
140
141 @keyframes ovdac-pulse { 50% { opacity: .45; } }
142 @keyframes ovdac-ring { 50% { opacity: .35; transform: scale(1.06); } }
143 @media (pointer: coarse) { .ovdac-tell input { font-size: 16px; } }
144 html[data-ov-motion=off] .ovdac *, html[data-ov-motion=off] .ovdac *::before, html[data-ov-motion=off] .ovdac *::after { animation: none !important; transition: none !important; }
145 `;
146 (d.head || d.documentElement).appendChild(st);
147 }
148
149 const money = (n) => { const v = Number(n) || 0; return v < 0.01 && v > 0 ? `$${v.toFixed(3)}` : `$${v.toFixed(2)}`; };
150 /** "in 40 min", "in 3 h", "tomorrow": when the next goal is due; '' when unknown or already due. */
151 function due(at, now = Date.now()) {
152 const t = Date.parse(at || '');
153 if (!Number.isFinite(t)) return '';
154 const min = Math.round((t - now) / 60000);
155 if (min < 1) return '';
156 if (min < 60) return `in ${min} min`;
157 if (min < 24 * 60) return `in ${Math.round(min / 60)} h`;
158 return min < 48 * 60 ? 'tomorrow' : `in ${Math.round(min / 1440)} days`;
159 }
160
161 function mount(container, opts = {}) {
162 const d = container.ownerDocument || document;
163 styles(d);
164 const o = { ...opts };
165 const h = (tag, cls, text) => { const n = d.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
166 const icon = (name) => { const t = d.createElement('template'); t.innerHTML = ICON[name]; return t.content.firstChild; };
167 const tip = (n, text) => { if (root.OVTip) n.setAttribute('data-ov-tip', text); else n.title = text; };
168 let gone = false, timer = 0, busy = false, shown = false, last = '', actor = null, noteTimer = 0;
169 container.replaceChildren();
170 container.classList.add('ovdac');
171 // Network's frame relay on a site; an explicit base reaches Actor itself (a test).
172 const direct = o.base ? String(o.base).replace(/\/+$/, '') : '';
173 const frameBase = String(o.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');
174 const endpoint = (path) => (direct ? direct + '/api/v1/me/actor' + path : frameBase + '/actor' + path);
175 // The way to the Actor's page is a navigation, never a fetch: Actor's own site, wherever the API is.
176 const openBase = direct || 'https://openvibe.actor';
177
178 async function api(path, init = {}) {
179 const t = typeof o.token === 'function' ? await o.token() : o.token;
180 if (!t) throw Object.assign(new Error('Sign in to use your Actor'), { status: 401 });
181 const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), authorization: `Bearer ${t}` };
182 const r = await fetch(endpoint(path), { method: init.method || 'GET', headers, credentials: 'omit', body: init.json ? JSON.stringify(init.body || {}) : undefined });
183 const b = await r.json().catch(() => ({}));
184 if (!r.ok) throw Object.assign(new Error(b.detail || `HTTP ${r.status}`), { status: r.status });
185 return b;
186 }
187
188 function stateOf(a) {
189 if (Array.isArray(a.asks) && a.asks.length) return 'waiting';
190 return STATE[a.status] ? a.status : 'paused';
191 }
192
193 // ── The parts, built once ────────────────────────────────────────────────────────────────────────────────
194 const head = h('div', 'ovdac-head');
195 const id = h('a', 'ovdac-id'); id.href = openBase + '/you'; id.target = '_blank'; id.rel = 'noopener';
196 function loadScript(file, ready) {
197 if (ready()) return Promise.resolve(true);
198 return new Promise((resolve) => {
199 const sid = `ovdac-${file}`;
200 let script = d.getElementById(sid);
201 if (!script) { script = d.createElement('script'); script.id = sid; script.src = SCRIPT_BASE + file + (ACTOR_REV[file] ? `?v=${ACTOR_REV[file]}` : ''); script.async = true; d.head.appendChild(script); }
202 script.addEventListener('load', () => resolve(!!ready()), { once: true });
203 script.addEventListener('error', () => resolve(false), { once: true });
204 });
205 }
206 // The row opens its sheet (agent-sheet.js in the one window: now, asks, work, schedule, memory, computer); the
207 // Actor's page is the sheet's "Open". A modified click, or a sheet that cannot load, goes to the page as before.
208 id.addEventListener('click', async (e) => {
209 if (!actor || !(actor.agent_id || actor.id) || e.metaKey || e.ctrlKey || e.shiftKey || e.button) return;
210 e.preventDefault();
211 const ok = (await Promise.all([loadScript('ov-window.js', () => !!root.OVWindow), loadScript('agent-sheet.js', () => !!root.OVAgentSheet)])).every(Boolean);
212 if (!ok) { d.defaultView.open(id.href, '_blank', 'noopener'); return; }
213 root.OVAgentSheet.open({ kind: 'actor', id: actor.agent_id || actor.id, name: actor.name || 'Your Actor', token: o.token,
214 frameBase: o.frameBase, base: direct || null, from: id, openHref: openBase + '/you' });
215 });
216 const face = h('span', 'ovdac-face');
217 const who = h('span', 'ovdac-who');
218 const name = h('span', 'ovdac-name'); const nameText = h('span'); name.append(nameText, icon('go'));
219 const line = h('span', 'ovdac-line'); const stateText = h('span', 'ovdac-state'); const spend = h('span', 'ovdac-spend'); line.append(stateText, spend);
220 who.append(name, line); id.append(face, who);
221 const go = h('button', 'ovdac-go'); go.type = 'button';
222 go.addEventListener('click', () => {
223 if (!actor) return;
224 const paused = actor.status === 'paused';
225 act(paused ? '/resume' : '/pause', {}, go, paused ? 'Resumed.' : 'Paused.');
226 });
227 head.append(id, go);
228
229 const rail = h('ul', 'ovdac-rail'); rail.hidden = true;
230 const askBox = h('div', 'ovdac-ask'); askBox.hidden = true;
231
232 const form = h('form', 'ovdac-tell');
233 const input = h('input'); input.type = 'text'; input.maxLength = 2000; input.autocomplete = 'off'; input.enterKeyHint = 'send';
234 input.placeholder = 'Tell your Actor something'; input.setAttribute('aria-label', 'Tell your Actor something');
235 const send = h('button', 'ovdac-send'); send.type = 'submit'; send.setAttribute('aria-label', 'Send'); send.append(icon('send'));
236 input.addEventListener('input', () => form.classList.toggle('has-text', !!input.value.trim()));
237 form.append(input, send);
238 form.addEventListener('submit', (e) => {
239 e.preventDefault();
240 const text = input.value.trim();
241 if (!text) { input.focus(); return; }
242 const paused = actor && actor.status === 'paused';
243 act('/tell', { text }, send, paused ? 'Sent. Your Actor reads it once you resume it.' : 'Sent. Your Actor reads it at its next step.')
244 .then((ok) => { if (ok) { input.value = ''; form.classList.remove('has-text'); } });
245 });
246
247 const foot = h('div', 'ovdac-foot');
248 const past = h('button', 'ovdac-link'); past.type = 'button'; past.setAttribute('aria-expanded', 'false'); past.append(icon('past'), d.createTextNode('Recent history'));
249 const note = h('div', 'ovdac-note'); note.setAttribute('role', 'status'); note.setAttribute('aria-live', 'polite');
250 const hist = h('ul', 'ovdac-hist'); hist.hidden = true;
251 foot.append(past, note);
252 // The button that opens the list closes it.
253 past.addEventListener('click', async () => {
254 if (!hist.hidden) { hist.hidden = true; past.setAttribute('aria-expanded', 'false'); return; }
255 past.disabled = true; past.classList.add('is-busy');
256 const one = (text) => { hist.replaceChildren(h('li', '', text)); };
257 try {
258 const t = typeof o.token === 'function' ? await o.token() : o.token;
259 if (!t) throw new Error('Sign in to read history.');
260 const url = String(o.networkBase || 'https://openvibe.network').replace(/\/+$/, '') + '/api/v1/history/agents/recent?limit=10';
261 const response = await fetch(url, { headers: { accept: 'application/json', authorization: `Bearer ${t}` }, credentials: 'omit' });
262 const result = await response.json().catch(() => ({}));
263 if (response.status === 403) one('Your agent history setting is off. Turn it on in My settings to allow this read.');
264 else if (!response.ok) one('Recent history is unavailable.');
265 else {
266 const items = (result.items || []).slice(0, 10);
267 if (!items.length) one('No recent history.');
268 else hist.replaceChildren(...items.map((x) => { const li = h('li'); li.append(h('b', '', String(x.type || '')), d.createTextNode(String(x.title || ''))); return li; }));
269 }
270 } catch { one('Recent history is unavailable.'); }
271 finally { past.disabled = false; past.classList.remove('is-busy'); }
272 hist.hidden = false; past.setAttribute('aria-expanded', 'true');
273 });
274
275 /** One line under the field: what just happened. A good word goes by itself after a moment; a problem stays. */
276 function say(text, tone) {
277 clearTimeout(noteTimer);
278 note.textContent = text || '';
279 note.className = 'ovdac-note' + (tone ? ` is-${tone}` : '');
280 if (text && tone === 'ok') noteTimer = setTimeout(() => { note.textContent = ''; note.className = 'ovdac-note'; }, 6000);
281 }
282
283 // ── A refresh repaints the row, the steps and the ask; the field and the history stay as they are ────────
284 function paint(a) {
285 if (!a || (a.enabled === false && !a.id)) {
286 actor = null; shown = false; last = '';
287 container.replaceChildren(); container.classList.remove('on'); container.removeAttribute('data-state');
288 return;
289 }
290 actor = a;
291 if (!shown) { container.replaceChildren(head, rail, askBox, form, foot, hist); container.classList.add('on'); shown = true; }
292 const sig = JSON.stringify([a.name, a.status, a.avatar, a.brain, a.caps, a.now, a.asks]);
293 if (sig === last) return;
294 last = sig;
295 const state = stateOf(a), now = a.now || {};
296 container.setAttribute('data-state', state);
297
298 // Who, and the one line about it.
299 const label = String(a.name || 'Your Actor');
300 const pic = a.avatar && typeof a.avatar.url === 'string' && /^https:\/\//.test(a.avatar.url) ? a.avatar.url : '';
301 if (pic) { const img = h('img'); img.alt = ''; img.src = pic; face.replaceChildren(img); } else face.replaceChildren(d.createTextNode(label.trim().charAt(0).toUpperCase() || 'A'));
302 nameText.textContent = label;
303 id.setAttribute('aria-label', `${label}, ${STATE[state]}. Open your Actor's page`);
304 stateText.textContent = STATE[state];
305 const cap = a.caps && Number(a.caps.usd) > 0 ? ` of ${money(a.caps.usd)}` : '';
306 spend.textContent = `${money(now.cost_today)}${cap} today`;
307 tip(spend, `Model: ${a.brain && a.brain !== 'auto' ? a.brain : 'Auto'}`);
308
309 // The one action that fits.
310 const paused = a.status === 'paused';
311 go.className = 'ovdac-go' + (paused ? ' is-main' : '');
312 go.replaceChildren(icon(paused ? 'play' : 'pause'), h('span', paused ? '' : 'sr', paused ? 'Resume' : 'Pause'));
313 tip(go, paused ? 'Your Actor does nothing until you resume it' : 'Pause your Actor');
314
315 // What it does: the step it is on, the steps before it, what comes next. Nothing when there is nothing to say.
316 const asks = Array.isArray(a.asks) ? a.asks : [];
317 const ask = asks[0];
318 const step = String(now.current_step || '').trim();
319 const live = a.status === 'working' && step && !NOT_A_STEP.test(step) && !(ask && step === String(ask.text || '').trim()) ? step : '';
320 const items = [];
321 if (live) {
322 const li = h('li', 'is-live'); li.append(d.createTextNode(live));
323 const looking = now.looking_at && (now.looking_at.title || now.looking_at);
324 if (looking) li.append(h('span', 'ovdac-look', `Looking at ${String(looking)}`));
325 items.push(li);
326 }
327 const before = (Array.isArray(now.last_three_steps) ? now.last_three_steps : []).map((x) => String((x && x.text) || x || '').trim()).filter((t) => t && t !== live).slice(0, 3);
328 if (!paused) before.forEach((t) => items.push(h('li', '', t)));
329 if (!paused && now.next && (now.next.text || typeof now.next === 'string')) {
330 const li = h('li', 'is-next'); const when = due(now.next.at);
331 li.append(h('b', '', 'Next: '), d.createTextNode(String(now.next.text || now.next) + (when ? `, ${when}` : '')));
332 items.push(li);
333 }
334 rail.replaceChildren(...items); rail.hidden = !items.length;
335
336 // The first thing it asks, with its own buttons.
337 if (ask) {
338 const k = h('div', 'ovdac-ask-k'); const chip = h('i'); chip.append(icon('ask')); k.append(chip, d.createTextNode(`${label} asks`));
339 const row = h('div', 'row');
340 const options = Array.isArray(ask.options) && ask.options.length ? ask.options : [{ id: 'yes', label: 'Yes' }];
341 options.slice(0, 3).forEach((op, i) => {
342 const b = h('button', `ovdac-btn${i === 0 ? ' is-yes' : ''}`, String(op.label || op)); b.type = 'button';
343 b.addEventListener('click', () => act(`/asks/${encodeURIComponent(ask.id)}/answer`, { option: op.id || op }, b, 'Answered.'));
344 row.append(b);
345 });
346 const no = h('button', 'ovdac-btn is-quiet', 'Not now'); no.type = 'button';
347 no.addEventListener('click', () => act(`/asks/${encodeURIComponent(ask.id)}/decline`, {}, no));
348 row.append(no);
349 askBox.replaceChildren(k, h('p', '', String(ask.text || '')), row);
350 if (asks.length > 1) { const more = h('a', 'ovdac-more', `${asks.length - 1} more on your Actor's page`); more.href = openBase + '/you'; more.target = '_blank'; more.rel = 'noopener'; askBox.append(more); }
351 } else askBox.replaceChildren();
352 askBox.hidden = !ask;
353 }
354
355 async function act(path, body, btn, done) {
356 if (busy) return false;
357 busy = true; if (btn) { btn.disabled = true; btn.classList.add('is-busy'); }
358 say('');
359 try {
360 await api(path, { method: 'POST', json: true, body });
361 last = '';
362 await refresh();
363 if (done) say(done, 'ok');
364 return true;
365 } catch (err) { say(`That did not go through: ${err.message}`, 'bad'); return false; }
366 finally { busy = false; if (btn) { btn.disabled = false; btn.classList.remove('is-busy'); } }
367 }
368
369 async function refresh() {
370 if (gone) return;
371 if (o.active && !o.active()) return;
372 try { const data = await api(''); if (!gone) paint(data); }
373 catch (err) { if (!gone && (err.status === 401 || err.status === 403 || err.status === 404)) paint(null); }
374 }
375 refresh();
376 timer = setInterval(refresh, POLL_MS);
377 return { refresh, destroy() { gone = true; clearInterval(timer); clearTimeout(noteTimer); container.replaceChildren(); container.classList.remove('on'); container.removeAttribute('data-state'); } };
378 }
379
380 root.OpenVibeDockActor = { mount };
381})(typeof window !== 'undefined' ? window : globalThis);
382