openvibers/openvibe.shared dock-timeline.js
dock-timeline.js
| 1 | // ═══════════════════════════════════════════════════════════════ |
| 2 | // OpenVibe — the Dock's Agent panel (design 2026-11-07: one agent, one timeline behind the Dock), Shared 4.126.0 |
| 3 | // |
| 4 | // OpenVibeDockTimeline.mount(container, { token, base, active, focus }) → { refresh(), destroy() } |
| 5 | // |
| 6 | // `token` is the person's own Network token: a string, or the function the navbar hands over (which |
| 7 | // resolves it when it is needed). Nothing is asked without it: a signed-out page can never read the timeline. |
| 8 | // OpenVibe.Actor's API is GET /api/v1/agent/timeline (the person's own steps, newest first, with the cursor and the |
| 9 | // mode and needs-you filters), POST /api/v1/agent/approvals/:product/:id/decide (an approval is answered in the mode |
| 10 | // that owns it) and POST /api/v1/agent/kill-switch (stop what is open and pause the agent). On a site every call goes |
| 11 | // through Network's same-origin relay (/api/frame/agent…, /api/frame/tasks…) because every site's connect-src allows |
| 12 | // openvibe.network and refuses openvibe.actor; `base` reaches Actor itself in a test, and `frameBase` names the relay. |
| 13 | // `focus` is the id of the step to point at (the task id a site opened the Dock with): the entry that |
| 14 | // carries it is marked and brought into view. |
| 15 | // |
| 16 | // The panel is what the person reads: |
| 17 | // Now one card for what the agent is doing this moment: the mode's word, the step in plain words, how long, |
| 18 | // and the mode's own controls (Stop, and Resume when it is paused). Nothing running: "Your agent is idle." |
| 19 | // Needs you every approval or question from any mode, oldest first, with its buttons in place. |
| 20 | // Timeline one entry per step, newest first: the mode, the title, where it ran and why, the outcome, the time |
| 21 | // and the cost, and a link to the step's own page. A mode change is a line of its own. |
| 22 | // Folding today's entries open; earlier days are counted folds ("Yesterday (14)"); a long run of one mode folds |
| 23 | // into "12 more Code steps". |
| 24 | // Filters a chip for each mode and for "Needs you", in the URL of the panel; a filter never hides without a |
| 25 | // count. |
| 26 | // |
| 27 | // Everything is set as text, never as markup: a title, a reason or a link label never becomes HTML. |
| 28 | // ═══════════════════════════════════════════════════════════════ |
| 29 | (function (root) { |
| 30 | ; |
| 31 | if (typeof document === 'undefined' || root.OpenVibeDockTimeline) return; |
| 32 | |
| 33 | const POLL_MS = 15000; |
| 34 | const MODES = [ |
| 35 | { id: 'talk', word: 'Talk' }, { id: 'act', word: 'Act' }, { id: 'run', word: 'Run' }, |
| 36 | { id: 'code', word: 'Code' }, { id: 'browse', word: 'Browse' }, { id: 'watch', word: 'Watch' }, |
| 37 | { id: 'stream', word: 'Stream' }, |
| 38 | ]; |
| 39 | const word = (mode) => (MODES.find((m) => m.id === mode) || { word: 'Agent' }).word; |
| 40 | const OUTCOME = { done: 'Done', failed: 'Failed', stopped: 'Stopped by you', running: 'Running', waiting: 'Waiting', needs_you: 'Needs you' }; |
| 41 | |
| 42 | function el(doc, tag, cls, text) { |
| 43 | const n = doc.createElement(tag); |
| 44 | if (cls) n.className = cls; |
| 45 | if (text !== undefined && text !== null) n.textContent = String(text); |
| 46 | return n; |
| 47 | } |
| 48 | |
| 49 | function since(startedAt) { |
| 50 | const t = Date.parse(String(startedAt || '')); |
| 51 | if (!Number.isFinite(t)) return ''; |
| 52 | const s = Math.max(0, Math.round((Date.now() - t) / 1000)); |
| 53 | if (s < 60) return s + 's'; |
| 54 | if (s < 3600) return Math.round(s / 60) + ' min'; |
| 55 | return Math.round(s / 3600) + ' h'; |
| 56 | } |
| 57 | |
| 58 | function dayKey(iso) { |
| 59 | const d = new Date(String(iso || '')); |
| 60 | if (Number.isNaN(d.getTime())) return ''; |
| 61 | return d.toISOString().slice(0, 10); |
| 62 | } |
| 63 | |
| 64 | function styles(doc) { |
| 65 | if (!doc || doc.getElementById('ovdt-styles')) return; |
| 66 | const st = doc.createElement('style'); |
| 67 | st.id = 'ovdt-styles'; |
| 68 | st.textContent = [ |
| 69 | // The site's own theme (Opus, 11-07): the Dock's tokens, so the panel is the navy of a dark site and the paper |
| 70 | // of a light one, never a grey card of its own. The site's font, a gutter that clears the panel's edge, one |
| 71 | // primary button per card, and a breathing dot only on what is really running. |
| 72 | '.ovdt{font:inherit;font-size:14px;line-height:1.45;color:var(--text-primary,#ececf1);padding:4px 12px 20px}', |
| 73 | '.ovdt h3{margin:14px 2px 8px;font-size:11px;font-weight:800;letter-spacing:.07em;text-transform:uppercase;color:var(--text-muted,#93a0b4)}', |
| 74 | '.ovdt h3:first-child{margin-top:6px}', |
| 75 | '.ovdt-card{border:1px solid var(--border,rgba(255,255,255,.1));border-radius:14px;padding:12px 14px;margin:0 0 8px;background:color-mix(in srgb,var(--text-primary,#ececf1) 4%,transparent)}', |
| 76 | '.ovdt-needs .ovdt-card{border-color:color-mix(in srgb,var(--warning,#f5a524) 38%,var(--border,rgba(255,255,255,.1)));background:color-mix(in srgb,var(--warning,#f5a524) 7%,transparent)}', |
| 77 | '.ovdt-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}', |
| 78 | '.ovdt-needs .ovdt-card>.ovdt-mode{margin-bottom:2px;font-size:11.5px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;color:color-mix(in srgb,var(--warning,#f5a524) 70%,var(--text-primary,#ececf1))}', |
| 79 | '.ovdt-mode{font-weight:750}', |
| 80 | '.ovdt-title{flex:1 1 100%;min-width:0;overflow-wrap:anywhere;font-size:14.5px;font-weight:550}', |
| 81 | '.ovdt-meta{color:var(--text-muted,#93a0b4);font-size:12.5px;overflow-wrap:anywhere}', |
| 82 | '.ovdt-outcome{display:inline-flex;align-items:center;gap:6px;padding:2px 9px;border-radius:999px;font-size:11px;font-weight:750;letter-spacing:.04em;text-transform:uppercase;color:var(--text-secondary,#a8b3c4);background:color-mix(in srgb,var(--text-primary,#ececf1) 7%,transparent)}', |
| 83 | '.ovdt-outcome[data-s="running"]{color:color-mix(in srgb,var(--accent,#3b82f6) 72%,var(--text-primary,#ececf1));background:color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent)}', |
| 84 | '.ovdt-outcome[data-s="running"]::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor;animation:ovdt-breathe 1.8s ease-in-out infinite}', |
| 85 | '.ovdt-outcome[data-s="failed"]{color:var(--danger-light,#f87171);background:color-mix(in srgb,var(--danger,#ef4444) 14%,transparent)}', |
| 86 | '@keyframes ovdt-breathe{50%{opacity:.35;transform:scale(.8)}}', |
| 87 | '.ovdt-buttons{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}', |
| 88 | '.ovdt-btn{min-height:44px;min-width:44px;padding:0 16px;border-radius:12px;border:1px solid var(--border,rgba(255,255,255,.14));background:transparent;color:inherit;font:inherit;font-weight:650;cursor:pointer}', |
| 89 | '.ovdt-btn.is-primary{border-color:transparent;background:var(--accent,#3b82f6);color:var(--on-accent,#fff)}', |
| 90 | '.ovdt-btn.is-stop{border-color:color-mix(in srgb,var(--danger,#ef4444) 45%,transparent);color:var(--danger-light,#f87171)}', |
| 91 | '.ovdt-btn:disabled{opacity:.55;cursor:default}', |
| 92 | '.ovdt-btn:focus-visible,.ovdt-chip:focus-visible,.ovdt-fold:focus-visible{outline:2px solid var(--accent-light,var(--accent,#3b82f6));outline-offset:1px}', |
| 93 | '@media (hover:hover){.ovdt-btn:not(.is-primary):hover,.ovdt-chip:hover{background:color-mix(in srgb,var(--text-primary,#ececf1) 7%,transparent)}.ovdt-btn.is-primary:hover{filter:brightness(1.08)}}', |
| 94 | '.ovdt-chip{min-height:44px;padding:0 14px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.12));background:transparent;color:var(--text-secondary,#a8b3c4);font:inherit;font-size:13.5px;font-weight:650;cursor:pointer;white-space:nowrap;flex:none}', |
| 95 | '.ovdt-chip[aria-pressed="true"]{color:color-mix(in srgb,var(--accent,#3b82f6) 72%,var(--text-primary,#ececf1));border-color:color-mix(in srgb,var(--accent,#3b82f6) 55%,transparent);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent)}', |
| 96 | '.ovdt-chips{display:flex;gap:8px;overflow-x:auto;margin:4px -12px 0;padding:6px 12px 10px;scrollbar-width:none;-webkit-overflow-scrolling:touch}', |
| 97 | '.ovdt-chips::-webkit-scrollbar{display:none}', |
| 98 | '.ovdt-fold{width:100%;text-align:left;min-height:44px;margin:0 0 8px;padding:0 14px;border-radius:12px;border:1px dashed var(--border,rgba(255,255,255,.16));background:transparent;color:var(--text-secondary,#a8b3c4);font:inherit;font-weight:650;cursor:pointer}', |
| 99 | '.ovdt-card.ovdt-focus{border-color:color-mix(in srgb,var(--accent,#3b82f6) 55%,transparent)}', |
| 100 | '.ovdt-empty{color:var(--text-muted,#93a0b4);padding:4px 2px 6px}', |
| 101 | '.ovdt-card.ovdt-empty{padding:12px 14px}', |
| 102 | '.ovdt-link{color:color-mix(in srgb,var(--accent,#3b82f6) 72%,var(--text-primary,#ececf1));font-weight:650;text-decoration:none}', |
| 103 | '@media (max-width:640px){.ovdt-link{display:inline-flex;align-items:center;min-height:44px;padding:0 4px}}', |
| 104 | '', |
| 105 | ].join(''); |
| 106 | (doc.head || doc.documentElement).appendChild(st); |
| 107 | } |
| 108 | |
| 109 | function mount(container, opts = {}) { |
| 110 | const doc = container.ownerDocument || document; |
| 111 | styles(doc); |
| 112 | // Network's frame relay on a site; an explicit base reaches Actor itself (a test). |
| 113 | const direct = opts.base ? String(opts.base).replace(/\/+$/, '') : ''; |
| 114 | const frameBase = String(opts.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, ''); |
| 115 | const endpoint = (path) => (direct ? direct + path : frameBase + path.replace(/^\/api\/v1/, '')); |
| 116 | const rawToken = opts.token; |
| 117 | const active = typeof opts.active === 'function' ? opts.active : () => true; |
| 118 | const focus = typeof opts.focus === 'function' ? opts.focus : () => opts.focus || null; |
| 119 | const filters = new Set(); |
| 120 | let timer = null; |
| 121 | let destroyed = false; |
| 122 | |
| 123 | // The navbar hands the Dock a function (the token may change when the person signs in again), |
| 124 | // so it is resolved when a call is made, never read once at mount. |
| 125 | async function bearer() { |
| 126 | const t = typeof rawToken === 'function' ? await rawToken() : rawToken; |
| 127 | return t ? 'Bearer ' + t : null; |
| 128 | } |
| 129 | |
| 130 | const wrap = el(doc, 'div', 'ovdt'); |
| 131 | const nowHead = el(doc, 'h3', null, 'Now'); |
| 132 | const nowBox = el(doc, 'div', 'ovdt-now'); |
| 133 | const needsHead = el(doc, 'h3', null, 'Needs you'); |
| 134 | const needsBox = el(doc, 'div', 'ovdt-needs'); |
| 135 | const chips = el(doc, 'div', 'ovdt-chips'); |
| 136 | const list = el(doc, 'div', 'ovdt-list'); |
| 137 | wrap.append(nowHead, nowBox, needsHead, needsBox, chips, list); |
| 138 | container.appendChild(wrap); |
| 139 | |
| 140 | async function api(path, options = {}) { |
| 141 | const auth = await bearer(); |
| 142 | if (!auth) throw new Error('Sign in to see your agent'); |
| 143 | const res = await fetch(endpoint(path), Object.assign({ headers: { Accept: 'application/json', Authorization: auth } }, options)); |
| 144 | const body = await res.json().catch(() => ({})); |
| 145 | if (!res.ok) throw new Error((body && (body.detail || body.title)) || ('HTTP ' + res.status)); |
| 146 | return body; |
| 147 | } |
| 148 | |
| 149 | function chip(id, label, count) { |
| 150 | const b = el(doc, 'button', 'ovdt-chip', label + (count === undefined || count === null ? '' : ' (' + count + ')')); |
| 151 | b.type = 'button'; |
| 152 | b.setAttribute('aria-pressed', filters.has(id) ? 'true' : 'false'); |
| 153 | b.addEventListener('click', () => { |
| 154 | if (filters.has(id)) filters.delete(id); else filters.add(id); |
| 155 | refresh(); |
| 156 | }); |
| 157 | return b; |
| 158 | } |
| 159 | |
| 160 | /** The step's state as a small pill; data-s lets a running step breathe and a failed one show red. */ |
| 161 | function outcome(status) { |
| 162 | const o = el(doc, 'span', 'ovdt-outcome', OUTCOME[status] || status); |
| 163 | o.setAttribute('data-s', String(status || '').toLowerCase()); |
| 164 | return o; |
| 165 | } |
| 166 | |
| 167 | function renderNow(now) { |
| 168 | nowBox.textContent = ''; |
| 169 | if (!now) { |
| 170 | nowBox.appendChild(el(doc, 'div', 'ovdt-card ovdt-empty', 'Your agent is idle. Ask it something in Talk.')); |
| 171 | return; |
| 172 | } |
| 173 | const card = el(doc, 'div', 'ovdt-card'); |
| 174 | const row = el(doc, 'div', 'ovdt-row'); |
| 175 | row.appendChild(el(doc, 'span', 'ovdt-mode', word(now.mode))); |
| 176 | row.appendChild(outcome(now.status)); |
| 177 | row.appendChild(el(doc, 'span', 'ovdt-title', now.title)); |
| 178 | row.appendChild(el(doc, 'span', 'ovdt-meta', since(now.started_at))); |
| 179 | card.appendChild(row); |
| 180 | if (now.placement && (now.placement.where || now.placement.why)) { |
| 181 | card.appendChild(el(doc, 'div', 'ovdt-meta', [now.placement.where, now.placement.why].filter(Boolean).join(': '))); |
| 182 | } |
| 183 | // The Computer this step works on (plan 2026-11-07): the same machine and cost the task record carries. |
| 184 | if (now.computer) card.appendChild(el(doc, 'div', 'ovdt-meta', [now.computer.machine, now.computer.cost_hint].filter(Boolean).join(' · '))); |
| 185 | const buttons = el(doc, 'div', 'ovdt-buttons'); |
| 186 | // Take over / Give it back: one API on the task at Actor, the same hand-off the agent's own |
| 187 | // hand_to_person tool uses. Only a step that names its task (ref) and its Computer offers it. |
| 188 | if (now.computer && now.ref) { |
| 189 | const driving = now.computer.driving === true || /^You took over/.test(String(now.title || '')); |
| 190 | const take = el(doc, 'button', 'ovdt-btn is-primary', driving ? 'Give it back' : 'Take over'); |
| 191 | take.type = 'button'; |
| 192 | take.addEventListener('click', async () => { |
| 193 | take.disabled = true; |
| 194 | const auth = await bearer(); |
| 195 | try { |
| 196 | await api('/api/v1/tasks/' + encodeURIComponent(now.ref) + (driving ? '/giveback' : '/takeover'), { |
| 197 | method: 'POST', headers: { 'Content-Type': 'application/json', ...(auth ? { Authorization: auth } : {}) }, body: '{}', |
| 198 | }); |
| 199 | } catch (err) { /* the panel refreshes either way */ } |
| 200 | refresh(); |
| 201 | }); |
| 202 | buttons.appendChild(take); |
| 203 | } |
| 204 | const stop = el(doc, 'button', 'ovdt-btn is-stop', 'Stop'); |
| 205 | stop.type = 'button'; |
| 206 | stop.addEventListener('click', async () => { |
| 207 | stop.disabled = true; |
| 208 | const auth = await bearer(); |
| 209 | try { await api('/api/v1/agent/kill-switch', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(auth ? { Authorization: auth } : {}) }, body: JSON.stringify({ action: 'stop' }) }); } catch (err) { /* the panel refreshes either way */ } |
| 210 | refresh(); |
| 211 | }); |
| 212 | buttons.appendChild(stop); |
| 213 | card.appendChild(buttons); |
| 214 | nowBox.appendChild(card); |
| 215 | } |
| 216 | |
| 217 | function renderNeeds(needs) { |
| 218 | needsBox.textContent = ''; |
| 219 | if (!needs.length) { |
| 220 | needsBox.appendChild(el(doc, 'div', 'ovdt-empty', 'Nothing needs you.')); |
| 221 | return; |
| 222 | } |
| 223 | for (const item of needs) { |
| 224 | const card = el(doc, 'div', 'ovdt-card'); |
| 225 | card.appendChild(el(doc, 'div', 'ovdt-mode', word(item.mode) + ' needs you')); |
| 226 | card.appendChild(el(doc, 'div', 'ovdt-title', item.title)); |
| 227 | if (item.reason) card.appendChild(el(doc, 'div', 'ovdt-meta', item.reason)); |
| 228 | const buttons = el(doc, 'div', 'ovdt-buttons'); |
| 229 | for (const [label, decision] of [['Approve', 'approve'], ['Decline', 'decline']]) { |
| 230 | const b = el(doc, 'button', decision === 'approve' ? 'ovdt-btn is-primary' : 'ovdt-btn', label); |
| 231 | b.type = 'button'; |
| 232 | b.addEventListener('click', async () => { |
| 233 | b.disabled = true; |
| 234 | const approval = item.approval || {}; |
| 235 | const auth = await bearer(); |
| 236 | try { |
| 237 | await api('/api/v1/agent/approvals/' + encodeURIComponent(approval.product || item.product) + '/' + encodeURIComponent(approval.id) + '/decide', { |
| 238 | method: 'POST', |
| 239 | headers: { 'Content-Type': 'application/json', ...(auth ? { Authorization: auth } : {}) }, |
| 240 | body: JSON.stringify({ decision }), |
| 241 | }); |
| 242 | } catch (err) { /* the panel refreshes either way */ } |
| 243 | refresh(); |
| 244 | }); |
| 245 | buttons.appendChild(b); |
| 246 | } |
| 247 | card.appendChild(buttons); |
| 248 | needsBox.appendChild(card); |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | function entryNode(entry) { |
| 253 | const card = el(doc, 'div', 'ovdt-card'); |
| 254 | const row = el(doc, 'div', 'ovdt-row'); |
| 255 | row.appendChild(el(doc, 'span', 'ovdt-mode', word(entry.mode))); |
| 256 | row.appendChild(outcome(entry.status)); |
| 257 | row.appendChild(el(doc, 'span', 'ovdt-title', entry.title)); |
| 258 | const when = new Date(entry.started_at || 0); |
| 259 | row.appendChild(el(doc, 'span', 'ovdt-meta', Number.isNaN(when.getTime()) ? '' : when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))); |
| 260 | card.appendChild(row); |
| 261 | if (entry.transition) card.appendChild(el(doc, 'div', 'ovdt-meta', entry.transition)); |
| 262 | else if (entry.placement && (entry.placement.where || entry.placement.why)) { |
| 263 | card.appendChild(el(doc, 'div', 'ovdt-meta', [entry.placement.where, entry.placement.why].filter(Boolean).join(': '))); |
| 264 | } |
| 265 | if (entry.computer) card.appendChild(el(doc, 'div', 'ovdt-meta', [entry.computer.machine, entry.computer.cost_hint].filter(Boolean).join(' · '))); |
| 266 | if (entry.reason) card.appendChild(el(doc, 'div', 'ovdt-meta', entry.reason)); |
| 267 | if (entry.cost_usd !== null && entry.cost_usd !== undefined) card.appendChild(el(doc, 'div', 'ovdt-meta', '$' + Number(entry.cost_usd).toFixed(2))); |
| 268 | if (entry.link) { |
| 269 | const a = el(doc, 'a', 'ovdt-link', 'Open'); |
| 270 | a.href = entry.link; |
| 271 | a.rel = 'noopener noreferrer'; |
| 272 | a.target = '_blank'; |
| 273 | card.appendChild(a); |
| 274 | } |
| 275 | // The step a site opened the Dock at (its task id): it is marked, so the person sees where they landed. |
| 276 | const wanted = focusId(); |
| 277 | if (wanted && (entry.id === wanted || entry.ref === wanted)) card.classList.add('ovdt-focus'); |
| 278 | return card; |
| 279 | } |
| 280 | |
| 281 | /** The step to point at: the id (a task id) a site opened the Dock with, if it is still there. */ |
| 282 | function focusId() { |
| 283 | try { return String(focus() || '').trim() || null; } catch (err) { return null; } |
| 284 | } |
| 285 | |
| 286 | /** Bring the focused step into view (the Dock's panel is short; the step may be below the fold). */ |
| 287 | function scrollToFocus() { |
| 288 | const wanted = focusId(); |
| 289 | if (!wanted) return; |
| 290 | const node = list.querySelector('.ovdt-focus'); |
| 291 | if (!node || !node.scrollIntoView) return; |
| 292 | try { node.scrollIntoView({ block: 'nearest' }); } catch (err) { node.scrollIntoView(); } |
| 293 | } |
| 294 | |
| 295 | function renderList(entries) { |
| 296 | list.textContent = ''; |
| 297 | if (!entries.length) { |
| 298 | list.appendChild(el(doc, 'div', 'ovdt-empty', 'Nothing yet. The agent\u2019s steps show up here as it works.')); |
| 299 | return; |
| 300 | } |
| 301 | // Today's entries are open; earlier days are counted folds. |
| 302 | const today = new Date().toISOString().slice(0, 10); |
| 303 | const groups = new Map(); |
| 304 | for (const entry of entries) { |
| 305 | const key = dayKey(entry.started_at); |
| 306 | if (!groups.has(key)) groups.set(key, []); |
| 307 | groups.get(key).push(entry); |
| 308 | } |
| 309 | for (const [key, group] of groups) { |
| 310 | if (key === today) { |
| 311 | for (const entry of group) list.appendChild(entryNode(entry)); |
| 312 | continue; |
| 313 | } |
| 314 | const fold = el(doc, 'button', 'ovdt-fold', labelOf(key, group.length)); |
| 315 | fold.type = 'button'; |
| 316 | fold.setAttribute('aria-expanded', 'false'); |
| 317 | fold.addEventListener('click', () => { |
| 318 | const open = fold.getAttribute('aria-expanded') === 'true'; |
| 319 | fold.setAttribute('aria-expanded', open ? 'false' : 'true'); |
| 320 | const shown = fold.nextElementSibling; |
| 321 | if (shown && shown.classList.contains('ovdt-fold-body')) shown.remove(); |
| 322 | if (!open) { |
| 323 | const box = el(doc, 'div', 'ovdt-fold-body'); |
| 324 | for (const entry of group) box.appendChild(entryNode(entry)); |
| 325 | fold.after(box); |
| 326 | } |
| 327 | }); |
| 328 | list.appendChild(fold); |
| 329 | } |
| 330 | // A long run of one mode folds into "12 more Code steps". |
| 331 | let run = 0; |
| 332 | let runMode = null; |
| 333 | const runs = []; |
| 334 | for (const entry of entries) { |
| 335 | if (entry.mode === runMode) { run += 1; continue; } |
| 336 | if (run >= 3) runs.push({ mode: runMode, count: run }); |
| 337 | runMode = entry.mode; |
| 338 | run = 1; |
| 339 | } |
| 340 | if (run >= 3) runs.push({ mode: runMode, count: run }); |
| 341 | for (const r of runs) { |
| 342 | const more = el(doc, 'div', 'ovdt-meta', r.count + ' more ' + word(r.mode) + ' steps'); |
| 343 | list.appendChild(more); |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | function labelOf(day, count) { |
| 348 | const today = new Date(); |
| 349 | const d = new Date(day + 'T00:00:00Z'); |
| 350 | const diff = Math.round((Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()) - d.getTime()) / 86400000); |
| 351 | if (diff === 1) return 'Yesterday (' + count + ')'; |
| 352 | if (diff < 7) return 'This week (' + count + ')'; |
| 353 | return day + ' (' + count + ')'; |
| 354 | } |
| 355 | |
| 356 | function renderChips(counts) { |
| 357 | chips.textContent = ''; |
| 358 | for (const m of MODES) { |
| 359 | const n = counts && counts[m.id]; |
| 360 | if (!n) continue; |
| 361 | chips.appendChild(chip(m.id, m.word, n)); |
| 362 | } |
| 363 | if (counts && counts.needs_you) chips.appendChild(chip('needs_you', 'Needs you', counts.needs_you)); |
| 364 | } |
| 365 | |
| 366 | async function refresh() { |
| 367 | if (destroyed || !active()) return; |
| 368 | const mode = [...filters].find((f) => f !== 'needs_you'); |
| 369 | const query = []; |
| 370 | if (mode) query.push('mode=' + encodeURIComponent(mode)); |
| 371 | if (filters.has('needs_you')) query.push('needs_you=1'); |
| 372 | const path = '/api/v1/agent/timeline' + (query.length ? '?' + query.join('&') : ''); |
| 373 | let data; |
| 374 | try { data = await api(path); } catch (err) { |
| 375 | if (destroyed) return; |
| 376 | container.hidden = true; |
| 377 | return; |
| 378 | } |
| 379 | if (destroyed) return; |
| 380 | container.hidden = !data.now && !(data.needs_you || []).length && !(data.entries || []).length; |
| 381 | renderNow(data.now); |
| 382 | renderNeeds(data.needs_you || []); |
| 383 | renderChips(data.counts || {}); |
| 384 | renderList(data.entries || []); |
| 385 | scrollToFocus(); |
| 386 | writeUrl(); |
| 387 | } |
| 388 | |
| 389 | function writeUrl() { |
| 390 | try { |
| 391 | const url = new URL(doc.defaultView.location.href); |
| 392 | url.searchParams.delete('mode'); |
| 393 | url.searchParams.delete('needs_you'); |
| 394 | for (const f of filters) { |
| 395 | if (f === 'needs_you') url.searchParams.set('needs_you', '1'); |
| 396 | else url.searchParams.set('mode', f); |
| 397 | } |
| 398 | doc.defaultView.history.replaceState(null, '', url.toString()); |
| 399 | } catch (err) { /* the panel keeps its filters in memory */ } |
| 400 | } |
| 401 | |
| 402 | function start() { |
| 403 | if (timer) return; |
| 404 | timer = setInterval(refresh, POLL_MS); |
| 405 | if (timer.unref) timer.unref(); |
| 406 | } |
| 407 | |
| 408 | refresh(); |
| 409 | start(); |
| 410 | |
| 411 | return { |
| 412 | refresh, |
| 413 | destroy() { |
| 414 | destroyed = true; |
| 415 | if (timer) clearInterval(timer); |
| 416 | timer = null; |
| 417 | wrap.remove(); |
| 418 | }, |
| 419 | }; |
| 420 | } |
| 421 | |
| 422 | root.OpenVibeDockTimeline = { mount }; |
| 423 | }(typeof window !== 'undefined' ? window : globalThis)); |
| 424 |