openvibers/openvibe.shared dock.js
dock.js
| 1 | /** |
| 2 | * openvibe-shared/dock.js — the panels behind the menu's Chat and Vibe tabs: your conversations (Chat), and Vibe, your |
| 3 | * coding tasks and your agents (Vibe's three parts). 4.220.0 (the owner, 11-12: "there should only be the one unified |
| 4 | * UI … delete … the old tab system"): the floating Dock — its pill, its card and tab bar, corners, the phone sheet and |
| 5 | * the window of its own — is gone. The menu (search.js) places these panels with mountInto on every screen. |
| 6 | * |
| 7 | * OpenVibeDock.mount(opts) a signed-in person's state and counts (navbar.js; null when signed out) |
| 8 | * OpenVibeDock.mountInto(host, { tab }) the panel body in the menu ('vibe' | 'code' | 'agent' | 'chats') |
| 9 | * OpenVibeDock.mountInto(host, { tab: 'agent', part: 'top' }) 4.225.0: only the top of Agents (Now and the |
| 10 | * timeline, your Actor, the live agent view), for the menu's Vibe, which |
| 11 | * draws the runs and the Action Center's work itself (ov-run.js) |
| 12 | * OpenVibeDock.open({ tab }) / ask({ text }) / share({ text, title }) open the menu there (opts.openMenu) |
| 13 | * What the old pill said (an agent needs you, agents working, a timer's minutes) goes to opts.status, which navbar.js |
| 14 | * paints on the bar's menu button. |
| 15 | */ |
| 16 | (function (root) { |
| 17 | ; |
| 18 | if (typeof document === 'undefined' || root.OpenVibeDock) return; |
| 19 | |
| 20 | const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`; |
| 21 | const G = { |
| 22 | code: '<path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/>', |
| 23 | spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>', |
| 24 | bell: '<path d="M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 2h-14z"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0"/>', |
| 25 | close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>', |
| 26 | open: '<path d="M13.5 5.5h5v5M18.5 5.5l-8 8M16.5 13.5v4a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1h4"/>', |
| 27 | chat: '<path d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5z"/>', |
| 28 | back: '<path d="M14.5 6.5L9 12l5.5 5.5"/>', |
| 29 | send: '<path d="M12 20.7V4.2M3.7 12.4l8.3-8.3l8.3 8.3"/>', // 4.220.0: the registry's (openvibe-shared/glyphs), as Vibe's box |
| 30 | vibe: '<path d="M12 4.5a7.5 7.5 0 0 1 6.4 11.4L19.5 19.5l-3.6-1.1A7.5 7.5 0 1 1 12 4.5z"/><path d="M9 11.5h.01M12 11.5h.01M15 11.5h.01"/>', |
| 31 | clock: '<circle cx="12" cy="13" r="7.5"/><path d="M12 9.5V13l2.5 1.5M9.5 3h5"/>', |
| 32 | // 4.220.0: the registry's pencil (openvibe-shared/glyphs), for a message's Edit. |
| 33 | pencil: '<path d="M11.9 19.6h8.4M16.1 4.2a2 2 0 0 1 2.8 2.8L7.2 18.7l-3.7 0.9l0.9-3.7Z"/>', |
| 34 | }; |
| 35 | // Vibe first (L48 V2): the person's AI, on every site. |
| 36 | const ROBOT = '<rect x="5" y="8" width="14" height="10" rx="3"/><path d="M12 8V4.5M9.5 13h.01M14.5 13h.01M3 12.5v2M21 12.5v2"/>'; // as in the menu's row |
| 37 | const TABS = [['vibe', 'Vibe', G.spark], ['code', 'Code', G.code], ['agent', 'Agents', ROBOT], ['chats', 'Chat', G.chat]]; |
| 38 | const DEFAULT_STATE = { tab: 'vibe', agent: null, chat: null, vibe: null }; |
| 39 | |
| 40 | let bodyEl = null, inline = null, panelEpoch = 0, o = {}, doc = null, tab = 'vibe', saveT = 0, pending = {}; |
| 41 | let part = null; // 'top': only the top of the Agents panel is lent (mountInto … part: 'top', 4.225.0) |
| 42 | const mounts = {}; |
| 43 | |
| 44 | const state = () => (doc && doc.state) || DEFAULT_STATE; |
| 45 | const counts = () => (doc && doc.counts) || {}; |
| 46 | const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`); |
| 47 | // K-D3: the agent stream is OpenVibe.Codes' (it mints the view ticket with the person's own token) and the one |
| 48 | // timeline behind the Dock is OpenVibe.Actor's (design 2026-11-07). On a site both go through Network's frame |
| 49 | // relay: every site's connect-src allows openvibe.network and refuses openvibe.codes and openvibe.actor. A test |
| 50 | // or the extension passes agentBase/actorBase and reaches the product itself; actorBase also names the Actor's |
| 51 | // pages in the run list. |
| 52 | const frameBase = () => String(o.frameBase || (String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '') + '/api/frame')).replace(/\/+$/, ''); |
| 53 | const directBase = (v) => (v ? String(v).replace(/\/+$/, '') : ''); |
| 54 | const actorBase = () => String(o.actorBase || 'https://openvibe.actor').replace(/\/+$/, ''); |
| 55 | |
| 56 | async function call(path, init = {}) { |
| 57 | const base = String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, ''); |
| 58 | const t = await tokenOf(); |
| 59 | const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) }; |
| 60 | const r = await fetch(base + path, { ...init, headers, credentials: 'omit' }); |
| 61 | return r.ok ? r.json() : null; |
| 62 | } |
| 63 | /** A token may be a string, a sync function or an async function (the extension refreshes it). */ |
| 64 | async function tokenOf() { return typeof o.token === 'function' ? await o.token() : o.token; } |
| 65 | const notify = () => { store(doc); paintStatus(); }; |
| 66 | |
| 67 | /** A change to the state: painted now, sent to the Network a beat later (several changes go as one PUT). */ |
| 68 | function save(patch) { |
| 69 | const st = state(); |
| 70 | const next = { ...st, ...patch }; |
| 71 | doc = { ...(doc || {}), state: next, counts: counts() }; |
| 72 | pending = { ...pending, ...patch }; |
| 73 | notify(); |
| 74 | clearTimeout(saveT); |
| 75 | saveT = setTimeout(flush, 700); |
| 76 | } |
| 77 | async function flush() { |
| 78 | const body = pending; pending = {}; |
| 79 | if (!Object.keys(body).length) return; |
| 80 | try { |
| 81 | const r = await call('/api/v1/me/dock', { method: 'PUT', body: JSON.stringify(body) }); |
| 82 | if (r && r.state && !Object.keys(pending).length) { doc = { ...doc, state: r.state, counts: r.counts || counts() }; notify(); } |
| 83 | } catch { /* offline, or no route yet: the copy stands and the next change tries again */ } |
| 84 | } |
| 85 | |
| 86 | // Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL. |
| 87 | const DOCK_REV = { 'dock-vibe.js': '39bffcfd6f09', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'ee1f6f21dbba', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' }; |
| 88 | function load(src, test) { |
| 89 | return new Promise((ok) => { |
| 90 | if (test()) return ok(true); |
| 91 | const id = `ov-dock-${src.replace(/\W/g, '')}`; |
| 92 | let sc = document.getElementById(id); |
| 93 | if (!sc) { sc = document.createElement('script'); sc.id = id; sc.src = sibling(src) + (DOCK_REV[src] ? `?v=${DOCK_REV[src]}` : ''); sc.async = true; document.head.appendChild(sc); } |
| 94 | sc.addEventListener('load', () => ok(test())); |
| 95 | sc.addEventListener('error', () => ok(false)); |
| 96 | }); |
| 97 | } |
| 98 | |
| 99 | function styles() { |
| 100 | if (document.getElementById('ovdk-styles')) return; |
| 101 | const st = document.createElement('style'); |
| 102 | st.id = 'ovdk-styles'; |
| 103 | st.textContent = ` |
| 104 | /* The bar's menu button carries the status: navbar.js paints .ovnav-st, which shows only once this file has loaded. */ |
| 105 | .ovnav-st { position: absolute; top: -4px; right: -6px; z-index: 1; display: inline-flex; align-items: center; justify-content: center; min-width: 10px; height: 18px; padding: 0 6px; |
| 106 | box-sizing: border-box; border-radius: 999px; font: normal 750 10.5px/1 var(--font-sans, inherit); font-variant-numeric: tabular-nums; letter-spacing: .01em; white-space: nowrap; |
| 107 | --st: var(--accent-strong, var(--accent, #3b82f6)); color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--st); box-shadow: 0 0 0 2px var(--bg-primary, #0b0f19); pointer-events: none; } |
| 108 | [data-ov-status="running"] > .ovnav-st, [data-ov-status="waiting"] > .ovnav-st { width: 10px; min-width: 10px; height: 10px; padding: 0; top: 1px; right: 1px; } |
| 109 | [data-ov-status^="timer"] > .ovnav-st { top: auto; bottom: -8px; right: 50%; transform: translateX(50%); height: 15px; padding: 0 5px; font-size: 9.5px; } |
| 110 | [data-ov-status="waiting"] > .ovnav-st { --st: var(--warning, #f5a524); } |
| 111 | [data-ov-status="timer-done"] > .ovnav-st { --st: var(--success, #22c55e); color: #052e14; } |
| 112 | [data-ov-status="running"] > .ovnav-st { animation: ovnav-st-breathe 1.8s ease-in-out infinite; } |
| 113 | [data-ov-status="waiting"] > .ovnav-st { animation: ovnav-st-breathe 1.2s ease-in-out infinite; } |
| 114 | @keyframes ovnav-st-breathe { 50% { box-shadow: 0 0 0 2px var(--bg-primary, #0b0f19), 0 0 0 6px color-mix(in srgb, var(--st) 30%, transparent); } } |
| 115 | html[data-ov-motion=calm] [data-ov-status] > .ovnav-st { animation-duration: 3.2s; } |
| 116 | html[data-ov-motion=off] .ovnav-st { animation: none; } |
| 117 | /* 4.220.0: the panels live in the menu only (the floating card, its pill and tab bar are gone). */ |
| 118 | .ovdk { position: relative; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box; width: 100%; height: 100%; min-height: 0; |
| 119 | color: var(--text-primary, #ececf1); font-family: inherit; container-type: inline-size; } |
| 120 | .ovdk svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } |
| 121 | /* The menu button's drawing (navbar.js) has its own fills: the line style above is not its. */ |
| 122 | .ovdk svg.ovmb { fill: currentColor; stroke: none; stroke-width: 0; } |
| 123 | { transform: translateX(16px); } |
| 124 | .ovdk-body { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; } |
| 125 | .ovdk-panel { flex: 1; min-height: 0; display: flex; flex-direction: column; } |
| 126 | .ovdk-panel[hidden] { display: none; } |
| 127 | .ovdk-scroll { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; } |
| 128 | .ovdk-foot { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 14px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12.5px; } |
| 129 | .ovdk-foot a, .ovdk-foot button { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0; border: 0; background: none; font: inherit; font-weight: 700; cursor: pointer; text-decoration: none; color: var(--accent-light, var(--accent, #60a5fa)); } |
| 130 | .ovdk-foot button { color: var(--text-secondary, #b6b6c0); } |
| 131 | .ovdk-foot button:hover { color: var(--text-primary, #ececf1); } |
| 132 | .ovdk-foot svg { width: 15px; height: 15px; } |
| 133 | .ovdk-wait { display: flex; justify-content: center; padding: 36px; } |
| 134 | .ovdk-wait span { position: relative; display: inline-block; width: 6px; height: 6px; margin: 0 10px; border: 0; border-radius: 50%; background: var(--accent, #3b82f6); animation: ovdk-hop 1s ease-in-out .15s infinite; } |
| 135 | .ovdk-wait span::before, .ovdk-wait span::after { content: ''; position: absolute; top: 0; width: 6px; height: 6px; border-radius: 50%; background: inherit; animation: ovdk-hop 1s ease-in-out infinite; } |
| 136 | .ovdk-wait span::before { left: -10px; } .ovdk-wait span::after { left: 10px; animation-delay: .3s; } |
| 137 | @keyframes ovdk-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-4px); opacity: 1; } } |
| 138 | .ovdk-none { padding: 40px 24px; text-align: center; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; } |
| 139 | .ovdk-muted { color: var(--text-muted, #8b8b96); } |
| 140 | .ovdk-i { display: inline-grid; place-items: center; } |
| 141 | .ovdk-i svg { width: 18px; height: 18px; } |
| 142 | .ovdk-bar { flex: none; display: flex; justify-content: flex-end; gap: 4px; padding: 4px 8px 0; } |
| 143 | .ovdk-ib { flex: none; width: 40px; height: 40px; display: grid; place-items: center; border: 0; border-radius: 12px; background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; text-decoration: none; } |
| 144 | .ovdk-ib svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; } |
| 145 | .ovdk-ib:hover, .ovdk-ib:focus-visible { background: var(--hover-bg, rgba(127,127,127,.12)); color: var(--text-primary, #ececf1); } |
| 146 | .ovdk-thread-h .ovdk-ib { margin-left: auto; } |
| 147 | .ovdk-chats { list-style: none; margin: 0; padding: 6px; display: grid; gap: 2px; } |
| 148 | .ovdk-chat { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; align-items: center; gap: 12px; width: 100%; min-height: 60px; padding: 8px 10px; border: 0; border-radius: 14px; |
| 149 | background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; } |
| 150 | .ovdk-chat:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 5%, transparent); } |
| 151 | .ovdk-chat:focus-visible { outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: -2px; } |
| 152 | .ovdk-av { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; overflow: hidden; font-weight: 800; font-size: 15px; |
| 153 | color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); } |
| 154 | .ovdk-av img { width: 100%; height: 100%; object-fit: cover; } |
| 155 | .ovdk-av-vibe { color: #fff; background: radial-gradient(120% 120% at 30% 20%, color-mix(in srgb, var(--accent, #3b82f6) 70%, #a78bfa), var(--accent-strong, var(--accent, #2563eb))); } |
| 156 | .ovdk-av-vibe svg { width: 22px; height: 22px; } |
| 157 | .ovdk-av-group { display: flex; align-items: center; overflow: visible; background: transparent; } |
| 158 | .ovdk-av-group .ovdk-av { width: 27px; height: 27px; margin-left: -12px; border: 2px solid var(--bg-elevated, var(--bg-secondary, #1d1f27)); font-size: 11px; } |
| 159 | .ovdk-av-group .ovdk-av:first-child { margin-left: 0; } |
| 160 | .ovdk-chat-b { display: grid; gap: 1px; min-width: 0; } |
| 161 | .ovdk-chat-b b { font-size: 14px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 162 | .ovdk-chat-p { font-size: 12.5px; color: var(--text-secondary, #b6b6c0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 163 | .is-unread .ovdk-chat-b b, .is-unread .ovdk-chat-p { color: var(--text-primary, #ececf1); font-weight: 750; } |
| 164 | .ovdk-chat-m { display: grid; justify-items: end; gap: 4px; font-size: 11.5px; color: var(--text-muted, #8b8b96); font-variant-numeric: tabular-nums; } |
| 165 | .ovdk-unread { min-width: 20px; padding: 1px 6px; box-sizing: border-box; border-radius: 999px; text-align: center; font-size: 11px; font-weight: 800; |
| 166 | color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); } |
| 167 | .ovdk-thread-h { flex: none; display: flex; align-items: center; gap: 6px; padding: 6px 10px 6px 6px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); } |
| 168 | .ovdk-thread-h b { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; } |
| 169 | .ovdk-back { width: 40px; height: 40px; flex: none; border: 0; border-radius: 12px; background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; display: grid; place-items: center; } |
| 170 | .ovdk-back:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); color: var(--text-primary, #ececf1); } |
| 171 | .ovdk-msgs { list-style: none; margin: 0; padding: 12px; display: flex; flex-direction: column; gap: 3px; } |
| 172 | .ovdk-msg { display: grid; justify-items: start; max-width: 84%; margin-top: 8px; } |
| 173 | .ovdk-msg.is-cont { margin-top: 0; } |
| 174 | .ovdk-msg.is-mine { align-self: flex-end; justify-items: end; } |
| 175 | .ovdk-who { margin: 0 0 3px 10px; font-size: 11.5px; font-weight: 700; color: var(--text-muted, #8b8b96); } |
| 176 | .ovdk-text { padding: 8px 12px; border-radius: 16px; font-size: 14px; line-height: 1.42; white-space: pre-wrap; overflow-wrap: anywhere; |
| 177 | background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); } |
| 178 | .ovdk-msg.is-mine .ovdk-text { color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); } |
| 179 | .ovdk-send { flex: none; display: flex; align-items: flex-end; gap: 8px; margin: 8px 10px 10px; padding: 4px 4px 4px 12px; border-radius: 16px; |
| 180 | border: 1px solid var(--border-light, var(--border, rgba(255,255,255,.14))); background: var(--bg-primary, #12141b); } |
| 181 | .ovdk-send:focus-within { border-color: var(--accent, #3b82f6); } |
| 182 | .ovdk-send textarea { flex: 1; min-width: 0; box-sizing: border-box; min-height: 36px; max-height: 140px; padding: 8px 0; resize: none; border: 0; outline: none; background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 14px; line-height: 1.4; } |
| 183 | .ovdk-go { width: 44px; height: 44px; flex: none; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center; |
| 184 | color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); } |
| 185 | .ovdk-go:disabled { opacity: .55; cursor: default; } |
| 186 | /* 4.220.0: a message's actions at its corner (openvibe.design rule 138; the owner, 11-12, on chat's Edit: "hidden |
| 187 | until you tap"): on hover or focus on a computer, a tap on a phone; never under the words. "edited" is one |
| 188 | quiet word in the bubble. Editing happens in the box, under a slim bar that says so. */ |
| 189 | .ovdk-msg.has-acts { position: relative; } |
| 190 | /* Beside the bubble, on its empty side (a bubble is at most 84% wide), so the cluster never covers anyone's words. */ |
| 191 | .ovdk-acts { position: absolute; top: 50%; transform: translateY(-50%); right: calc(100% + 6px); z-index: 2; display: none; align-items: center; gap: 2px; padding: 2px; border-radius: 10px; |
| 192 | border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-secondary, #1b1d26); box-shadow: 0 6px 18px rgba(0,0,0,.28); } |
| 193 | .ovdk-msg:not(.is-mine) > .ovdk-acts { right: auto; left: calc(100% + 6px); } |
| 194 | .ovdk-msg.has-acts:hover > .ovdk-acts, .ovdk-msg.has-acts:focus-within > .ovdk-acts, .ovdk-msg.is-acting > .ovdk-acts { display: inline-flex; } |
| 195 | .ovdk-act { width: 30px; height: 28px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; } |
| 196 | .ovdk-act:hover, .ovdk-act:focus-visible { color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); outline: none; } |
| 197 | .ovdk-act .ovdk-i svg { width: 16px; height: 16px; } |
| 198 | @media (hover: none) { |
| 199 | .ovdk-msg.has-acts:hover:not(.is-acting):not(:focus-within) > .ovdk-acts { display: none; } |
| 200 | .ovdk-act { width: 44px; height: 40px; border-radius: 9px; } |
| 201 | .ovdk-act .ovdk-i svg { width: 18px; height: 18px; } |
| 202 | } |
| 203 | .ovdk-msg.is-acting .ovdk-text, .ovdk-msg.is-editing .ovdk-text { box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); } |
| 204 | .ovdk-edited { margin-left: 6px; font-size: .74em; color: var(--text-muted, #8b8b96); white-space: nowrap; } |
| 205 | .ovdk-msg.is-mine .ovdk-edited { color: inherit; opacity: .72; } |
| 206 | .ovdk-edit-bar { flex: none; display: flex; align-items: center; gap: 8px; margin: 8px 10px -2px; padding: 2px 2px 2px 12px; min-height: 36px; border-radius: 12px; |
| 207 | font-size: 12.5px; font-weight: 650; color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); } |
| 208 | .ovdk-edit-bar[hidden] { display: none; } |
| 209 | .ovdk-edit-bar span:not(.ovdk-i) { flex: 1; min-width: 0; } |
| 210 | .ovdk-edit-bar .ovdk-i svg { width: 15px; height: 15px; } |
| 211 | .ovdk-edit-bar button { width: 36px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 9px; background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; } |
| 212 | .ovdk-edit-bar button:hover { color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--text-primary, #ececf1) 8%, transparent); } |
| 213 | .ovdk-send.is-editing { border-color: var(--accent, #3b82f6); } |
| 214 | /* One send for every box in the Dock: round, as Vibe's. */ |
| 215 | .ovdk-send { border-radius: 22px; padding: 4px 4px 4px 14px; } |
| 216 | .ovdk-send .ovdk-go { width: 40px; height: 40px; border-radius: 50%; } |
| 217 | .ovdk-err { margin: 0 14px; font-size: 12.5px; color: color-mix(in srgb, var(--live-red, #e5484d) 60%, var(--text-primary, #ececf1)); } |
| 218 | .ovdk-err[hidden] { display: none; } |
| 219 | .ovdk-none b { display: block; margin-bottom: 4px; font-size: 15px; color: var(--text-primary, #ececf1); } |
| 220 | @media (max-width: 640px) { .ovdk-foot a, .ovdk-foot button { min-height: 44px; } } |
| 221 | `; |
| 222 | document.head.appendChild(st); |
| 223 | } |
| 224 | |
| 225 | /** The panels' body, once: the menu (search.js) places it with mountInto. */ |
| 226 | function build() { |
| 227 | styles(); |
| 228 | bodyEl = document.createElement('div'); |
| 229 | bodyEl.className = 'ovdk-body'; |
| 230 | bodyEl.innerHTML = TABS.map(([id, label]) => `<div class="ovdk-panel" role="region" aria-label="${label}" id="ovdk-panel-${id}" data-panel="${id}" hidden></div>`).join(''); |
| 231 | } |
| 232 | |
| 233 | |
| 234 | // ── Tabs ───────────────────────────────────────────────── |
| 235 | function setTab(id, remember) { |
| 236 | if (id === 'agents') id = 'agent'; |
| 237 | if (inline && tab !== id) disposePanel(); |
| 238 | tab = TABS.some(([t]) => t === id) ? id : 'vibe'; |
| 239 | if (tab !== 'chats') clearTimeout(chats.timer); |
| 240 | if (tab !== 'vibe' && vibe.api) vibe.api.pause(); |
| 241 | bodyEl.querySelectorAll('[data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; }); |
| 242 | if (remember && state().tab !== tab) save({ tab }); |
| 243 | fill(tab); |
| 244 | if (inline && tab === 'chats') readUnread(); |
| 245 | } |
| 246 | const panelActive = (id) => tab === id && !!inline; |
| 247 | function disposePanel() { |
| 248 | panelEpoch++; |
| 249 | clearTimeout(chats.timer); clearTimeout(chats.unreadT); |
| 250 | if (vibe.api) { vibe.api.pause(); vibe.api = null; } |
| 251 | for (const k of Object.keys(mounts)) { if (mounts[k] && mounts[k].destroy) mounts[k].destroy(); delete mounts[k]; } |
| 252 | chats.panel = null; chats.open = null; vibe.panel = null; |
| 253 | if (bodyEl) bodyEl.querySelectorAll('[data-panel]').forEach((p) => { p.replaceChildren(); p.hidden = true; }); |
| 254 | } |
| 255 | |
| 256 | async function fill(id) { |
| 257 | const epoch = panelEpoch; |
| 258 | const panel = bodyEl.querySelector(`[data-panel="${id}"]`); |
| 259 | if (id === 'vibe') { |
| 260 | vibe.panel = panel; |
| 261 | if (vibe.api) return vibe.api.refresh(); |
| 262 | panel.innerHTML = '<div class="ovdk-wait"><span></span></div>'; |
| 263 | const ok = await load('dock-vibe.js', () => !!(root.OpenVibeDockVibe && root.OpenVibeDockVibe.mount)); |
| 264 | if (epoch !== panelEpoch || !panelActive(id)) return; |
| 265 | if (!ok) { panel.innerHTML = '<div class="ovdk-none">Vibe did not load. Check your connection, then open it again.</div>'; return; } |
| 266 | vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => panelActive('vibe'), state, save, wantList: () => { const v = vibeOnList; vibeOnList = false; return v; }, styles: () => document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send, clock: G.clock, bell: G.bell }, refresh: () => read() }); |
| 267 | return; |
| 268 | } |
| 269 | if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; if (chats.draft) return chatList(); return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); } |
| 270 | // The Agent panel reads the step. |
| 271 | if (mounts[id]) { if (mounts[id].refresh) mounts[id].refresh(); if (mounts[id + 'Timeline']) mounts[id + 'Timeline'].refresh(); return; } |
| 272 | mounts[id] = { refresh: null }; |
| 273 | panel.innerHTML = '<div class="ovdk-wait"><span></span></div>'; |
| 274 | if (id === 'agent' && part === 'top') { |
| 275 | // 4.225.0: only the top is lent (the menu's Vibe draws the runs and the Action Center's work itself). |
| 276 | panel.innerHTML = '<div class="ovdk-actor"></div><div class="ovdk-timeline"></div><div class="ovdk-agent"></div>'; |
| 277 | agentTop(id, panel, epoch); |
| 278 | mounts[id] = { refresh() { ['Timeline', 'Actor', 'View'].forEach((k) => { const m = mounts[id + k]; if (m && m.refresh) m.refresh(); }); } }; |
| 279 | return; |
| 280 | } |
| 281 | if (id === 'code') { |
| 282 | const ok = await load('dock-code.js', () => !!(root.OpenVibeDockCode && root.OpenVibeDockCode.mount)); |
| 283 | if (epoch !== panelEpoch || !panelActive(id)) { delete mounts[id]; return; } |
| 284 | if (!ok) return failed(panel, id); |
| 285 | mounts[id] = root.OpenVibeDockCode.mount(panel, { token: o.token, frameBase: frameBase(), active: () => panelActive('code') }); |
| 286 | return; |
| 287 | } |
| 288 | if (id === 'agent') { |
| 289 | const ok = await load('dock-runs.js', () => !!(root.OpenVibeDockRuns && root.OpenVibeDockRuns.mount)); |
| 290 | if (epoch !== panelEpoch || !panelActive(id)) { delete mounts[id]; return; } |
| 291 | if (!ok) return failed(panel, id); |
| 292 | // K-D3: the person's own agent stream above the list; it hides itself when there is none. |
| 293 | panel.innerHTML = '<div class="ovdk-actor"></div><div class="ovdk-timeline"></div><div class="ovdk-agent"></div><div class="ovdk-scroll"></div><div class="ovdk-runs-foot ovdk-foot"></div>'; |
| 294 | agentTop(id, panel, epoch); |
| 295 | const m = root.OpenVibeDockRuns.mount(panel.querySelector('.ovdk-scroll'), { |
| 296 | call, state, save, scopes: o.scopes, active: () => panelActive('agent'), actorBase: actorBase(), loadMarks: () => load('ai-marks-client.js', () => !!root.OpenVibeAIMarks), |
| 297 | styles: () => document.getElementById('ovdk-styles'), |
| 298 | foot: panel.querySelector('.ovdk-runs-foot'), |
| 299 | onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; notify(); }, |
| 300 | }); |
| 301 | mounts[id] = m; |
| 302 | return; |
| 303 | } |
| 304 | delete mounts[id]; |
| 305 | } |
| 306 | /** The top of the Agents panel: Now and the one timeline (design 2026-11-07, first, so on a phone Now and Needs you |
| 307 | * are on screen before anything else), your Actor (Actor v3 A3; nothing for a person without one) and your own |
| 308 | * agent's live view (K-D3; it hides itself when there is none). Each loads on its own. */ |
| 309 | function agentTop(id, panel, epoch) { |
| 310 | load('dock-timeline.js', () => !!(root.OpenVibeDockTimeline && root.OpenVibeDockTimeline.mount)).then((on) => { |
| 311 | if (on && epoch === panelEpoch && !mounts[id + 'Timeline'] && panel.isConnected && panelActive(id)) mounts[id + 'Timeline'] = root.OpenVibeDockTimeline.mount(panel.querySelector('.ovdk-timeline'), { token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), active: () => panelActive('agent'), focus: () => o.task || null }); |
| 312 | }); |
| 313 | load('dock-actor.js', () => !!(root.OpenVibeDockActor && root.OpenVibeDockActor.mount)).then((on) => { |
| 314 | if (on && epoch === panelEpoch && !mounts[id + 'Actor'] && panel.isConnected && panelActive(id)) mounts[id + 'Actor'] = root.OpenVibeDockActor.mount(panel.querySelector('.ovdk-actor'), { token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), networkBase: o.apiBase || 'https://openvibe.network', active: () => panelActive('agent') }); |
| 315 | }); |
| 316 | load('dock-agent.js', () => !!(root.OpenVibeDockAgent && root.OpenVibeDockAgent.mount)).then((on) => { |
| 317 | if (on && epoch === panelEpoch && !mounts[id + 'View'] && panel.isConnected && panelActive(id)) mounts[id + 'View'] = root.OpenVibeDockAgent.mount(panel.querySelector('.ovdk-agent'), { token: o.token, base: directBase(o.agentBase), frameBase: frameBase(), active: () => panelActive('agent') }); |
| 318 | }); |
| 319 | } |
| 320 | function failed(panel, id) { |
| 321 | mounts[id] = null; |
| 322 | panel.innerHTML = `<div class="ovdk-none">That part did not load. Check your connection, then open it again.</div>`; |
| 323 | } |
| 324 | |
| 325 | // ── Chats (Dock plan K-D2) ─────────────────────────────── |
| 326 | // The person's conversations and DMs, from OpenVibe.Chat's own API with their Network token (Chat checks they are |
| 327 | // in each one). Everything people wrote is set as text, never markup; only https pictures are shown. A thread polls |
| 328 | // for new messages every 5 s while it is open in the Dock, and is marked read as it is shown. |
| 329 | const chats = { panel: null, list: null, open: null, msgs: [], last: 0, timer: 0, edits: null, unread: 0, unreadT: 0, busy: false, draft: null, vibeSummary: null, filter: '' }; |
| 330 | const chatBase = () => String(o.chatBase || 'https://openvibe.chat').replace(/\/+$/, ''); |
| 331 | const me = () => String(o.me || ''); |
| 332 | const scopes = () => Array.isArray(o.scopes) ? o.scopes : null; |
| 333 | const canWriteChat = () => !scopes() || scopes().includes('chat.write'); |
| 334 | const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }; |
| 335 | const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; }; |
| 336 | const toChat = (href) => { const a = h('a', 'ovdk-ib ovdk-away'); a.href = href; a.target = '_blank'; a.rel = 'noopener'; a.title = 'Open in Chat'; a.setAttribute('aria-label', 'Open in Chat'); a.setAttribute('data-ov-tip', 'Open in Chat'); a.innerHTML = svg('<path d="M13.5 5.5h5v5M18.5 5.5l-7.5 7.5M17 13.5v4a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 5 17.5v-9A1.5 1.5 0 0 1 6.5 7h4"/>'); return a; }; |
| 337 | async function chatCall(path, init = {}) { |
| 338 | const t = await tokenOf(); |
| 339 | const headers = { accept: 'application/json', ...(init.body ? { 'content-type': init.type || 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) }; |
| 340 | const { type: _type, ...rest } = init; |
| 341 | return fetch(chatBase() + path, { ...rest, headers, credentials: 'omit' }).then(async (r) => { |
| 342 | const b = await r.json().catch(() => ({})); |
| 343 | if (!r.ok) { const e = new Error(b.error || `HTTP ${r.status}`); e.status = r.status; e.code = b.code || null; throw e; } |
| 344 | return b; |
| 345 | }); |
| 346 | } |
| 347 | function readUnread() { |
| 348 | clearTimeout(chats.unreadT); |
| 349 | chatCall('/api/dm/unread').then((b) => { chats.unread = Number(b && b.unread) || 0; }).catch(() => { /* Chat away: no count */ }); |
| 350 | if (panelActive('chats')) chats.unreadT = setTimeout(readUnread, 30000); |
| 351 | } |
| 352 | const when = (iso) => { |
| 353 | const t = Date.parse(iso || ''); if (!Number.isFinite(t)) return ''; |
| 354 | const m = Math.max(0, Math.round((Date.now() - t) / 60000)); |
| 355 | return m < 1 ? 'now' : m < 60 ? `${m} min` : m < 1440 ? `${Math.floor(m / 60)} h` : `${Math.floor(m / 1440)} d`; |
| 356 | }; |
| 357 | // "answered 4 min ago", and "answered just now" when it was this minute. |
| 358 | const answered = (iso) => { const w = when(iso); return !w ? '' : w === 'now' ? 'answered just now' : `answered ${w} ago`; }; |
| 359 | function avatar(p, group) { |
| 360 | const a = h('span', 'ovdk-av'); |
| 361 | const url = p && /^https:\/\//.test(String(p.avatar_url || '')) ? p.avatar_url : ''; |
| 362 | if (url && !group) { const img = h('img'); img.src = url; img.alt = ''; img.loading = 'lazy'; img.referrerPolicy = 'no-referrer'; a.append(img); } |
| 363 | else a.textContent = group ? '#' : String((p && (p.display_name || p.username)) || '?').trim().charAt(0).toUpperCase() || '?'; |
| 364 | return a; |
| 365 | } |
| 366 | function groupAvatar(c) { |
| 367 | const people = others(c).slice(0, 3); |
| 368 | if (!people.length) return avatar({}, false); |
| 369 | const stack = h('span', 'ovdk-av ovdk-av-group'); |
| 370 | people.forEach((p) => stack.append(avatar(p, false))); |
| 371 | return stack; |
| 372 | } |
| 373 | const others = (c) => (c.participants || []).filter((p) => p.username !== me()); |
| 374 | const titleOf = (c) => c.name || others(c).map((p) => p.display_name || p.username).join(', ') || 'Just you'; |
| 375 | async function chatList() { |
| 376 | clearTimeout(chats.timer); |
| 377 | if (chats.edits) { chats.edits.stop(); chats.edits = null; } |
| 378 | chats.open = null; |
| 379 | const panel = chats.panel; if (!panel) return; |
| 380 | if (!chats.list) { panel.innerHTML = '<div class="ovdk-wait"><span></span></div>'; } |
| 381 | const sharing = chats.draft && canWriteChat() ? load('dock-share.js', () => !!(root.OpenVibeDockShare && root.OpenVibeDockShare.bar)) : null; |
| 382 | try { |
| 383 | const [dm, ai] = await Promise.all([chatCall('/api/dm/conversations'), chatCall('/api/vibe/conversations').catch(() => null)]); |
| 384 | chats.list = dm.conversations || []; |
| 385 | chats.vibeSummary = ai && ai.count ? { kind: 'vibe', count: ai.count, latest: ai.conversations && ai.conversations[0], last_message_at: ai.conversations && ai.conversations[0] && ai.conversations[0].last_message_at } : null; |
| 386 | } catch (e) { if (panel !== chats.panel || !panelActive('chats')) return; panel.textContent = ''; panel.append(h('div', 'ovdk-none', e.status === 401 ? 'Sign in to Chat to see your conversations.' : 'Your chats did not load. Check your connection, then open them again.')); chatFoot(panel); return; } |
| 387 | if (sharing) await sharing; |
| 388 | if (panel !== chats.panel || !panelActive('chats') || chats.open) return; |
| 389 | panel.textContent = ''; |
| 390 | if (chats.draft && canWriteChat() && root.OpenVibeDockShare) panel.append(root.OpenVibeDockShare.bar({ draft: chats.draft, h, chatCall, host: inline, cancel: () => { chats.draft = null; chatList(); }, open: (id) => { chats.list = null; return openChat(String(id), true); } })); |
| 391 | // Open in Chat, as an icon over your conversations (ask 336). |
| 392 | const bar = h('div', 'ovdk-bar'); bar.append(toChat('https://openvibe.chat/messages')); panel.append(bar); |
| 393 | const scroll = h('div', 'ovdk-scroll'); |
| 394 | if (!chats.list.length && !chats.vibeSummary) { |
| 395 | const none = h('div', 'ovdk-none'); |
| 396 | none.append(h('b', '', 'No conversations yet'), document.createTextNode('A message to someone on OpenVibe starts one, and it shows here on every site.')); |
| 397 | scroll.append(none); |
| 398 | } else { |
| 399 | const ul = h('ul', 'ovdk-chats'); |
| 400 | [...chats.list.filter((c) => !chats.filter || titleOf(c).toLowerCase().includes(chats.filter)), ...(chats.vibeSummary ? [chats.vibeSummary] : [])].sort((a, b) => String(b.last_message_at || b.updated_at || '').localeCompare(String(a.last_message_at || a.updated_at || ''))).forEach((c) => { |
| 401 | if (c.kind === 'vibe') { |
| 402 | const li = h('li'), b = h('button', 'ovdk-chat'); b.type = 'button'; |
| 403 | const body = h('span', 'ovdk-chat-b'); body.append(h('b', '', 'Vibe'), h('span', 'ovdk-chat-p', `${c.count} chat${c.count === 1 ? '' : 's'}${c.latest && c.latest.name ? ` · ${c.latest.name}` : ''}`)); |
| 404 | const meta = h('span', 'ovdk-chat-m'); const at = c.latest && c.latest.last_answer_at; |
| 405 | const t = h('time', '', when(at || c.last_message_at)); if (at) t.title = answered(at); meta.append(t); |
| 406 | const av = h('span', 'ovdk-av ovdk-av-vibe'); av.append(icon(G.vibe)); |
| 407 | b.append(av, body, meta); b.setAttribute('aria-label', 'Open Vibe'); |
| 408 | b.addEventListener('click', () => { |
| 409 | if (!b.dispatchEvent(new CustomEvent('openvibe:open-vibe', { bubbles: true, cancelable: true }))) return; |
| 410 | vibeOnList = true; save({ vibe: null }); setTab('vibe', true); |
| 411 | }); |
| 412 | li.append(b); ul.append(li); return; |
| 413 | } |
| 414 | const li = h('li'), b = h('button', 'ovdk-chat'); |
| 415 | b.type = 'button'; |
| 416 | const unread = Number(c.unread_count) || 0; |
| 417 | const mine = (c.participants || []).some((p) => p.id === c.last_sender_id && p.username === me()); |
| 418 | const body = h('span', 'ovdk-chat-b'); |
| 419 | body.append(h('b', '', titleOf(c)), h('span', 'ovdk-chat-p', c.last_message ? `${mine ? 'You: ' : ''}${String(c.last_message).replace(/\s+/g, ' ').slice(0, 140)}` : 'No messages yet')); |
| 420 | const meta = h('span', 'ovdk-chat-m'); |
| 421 | meta.append(h('time', '', when(c.last_message_at || c.updated_at))); |
| 422 | if (unread) meta.append(h('b', 'ovdk-unread', unread > 99 ? '99+' : String(unread))); |
| 423 | b.append(c.is_group ? groupAvatar(c) : avatar(others(c)[0] || {}, false), body, meta); |
| 424 | b.setAttribute('aria-label', `${titleOf(c)}${unread ? `, ${unread} unread` : ''}`); |
| 425 | if (unread) li.className = 'is-unread'; |
| 426 | b.addEventListener('click', () => openChat(String(c.id), true)); |
| 427 | li.append(b); ul.append(li); |
| 428 | }); |
| 429 | scroll.append(ul); |
| 430 | } |
| 431 | panel.append(scroll); |
| 432 | } |
| 433 | /** "edited", one quiet word after a message's words; when, in its title. */ |
| 434 | function editedMark(at) { |
| 435 | const mark = h('small', 'ovdk-edited', 'edited'); |
| 436 | const d = new Date(String(at || '').replace(' ', 'T')); |
| 437 | if (!Number.isNaN(d.getTime())) mark.title = `Edited ${d.toLocaleString()}`; |
| 438 | return mark; |
| 439 | } |
| 440 | /** An edit arrived (mine, saved here, or another of my devices'): the words in place, marked, the card again. */ |
| 441 | function applyEdit(list, m) { |
| 442 | const old = chats.msgs.find((x) => Number(x.id) === Number(m && m.id)); |
| 443 | const li = old && list.querySelector(`[data-id="${Number(m.id)}"]`); |
| 444 | if (!li || !m.edited_at || (old.edited_at && old.edited_at >= m.edited_at)) return; |
| 445 | Object.assign(old, { message: m.message, card: m.card, edited_at: m.edited_at }); |
| 446 | const words = li.querySelector('.ovdk-text'); |
| 447 | words.textContent = String(m.message || ''); words.append(editedMark(m.edited_at)); |
| 448 | const card = li.querySelector('.ovdk-card'); if (card) card.remove(); |
| 449 | if (m.card) load('dock-card.js', () => !!root.OpenVibeDockCard).then((ok) => { if (ok && li.isConnected) { const c = root.OpenVibeDockCard(m.card, h); if (c) li.append(c); } }); |
| 450 | } |
| 451 | function bubble(m, prev) { |
| 452 | const mine = m.username === me(); |
| 453 | const li = h('li', `ovdk-msg${mine ? ' is-mine' : ''}${prev && prev.username === m.username ? ' is-cont' : ''}`); |
| 454 | if (!mine && !(prev && prev.username === m.username)) li.append(h('span', 'ovdk-who', m.display_name || m.username || '')); |
| 455 | li.dataset.id = String(m.id); |
| 456 | const words = h('span', 'ovdk-text', String(m.message || '')); |
| 457 | if (m.edited_at) words.append(editedMark(m.edited_at)); |
| 458 | li.append(words); |
| 459 | if (mine && canWriteChat()) { |
| 460 | const acts = h('span', 'ovdk-acts'); |
| 461 | const edit = h('button', 'ovdk-act ovdk-edit'); edit.type = 'button'; edit.setAttribute('aria-label', 'Edit message'); edit.setAttribute('data-ov-tip', 'Edit'); edit.append(icon(G.pencil)); |
| 462 | acts.append(edit); li.append(acts); li.classList.add('has-acts'); |
| 463 | } |
| 464 | // A known OpenVibe link's card (Chat's link cards): dock-card.js draws it, loaded the first time one shows. |
| 465 | if (m.card) load('dock-card.js', () => !!root.OpenVibeDockCard).then((ok) => { const c = ok && root.OpenVibeDockCard(m.card, h); if (c) li.append(c); }); |
| 466 | li.title = new Date(m.created_at).toLocaleString(); |
| 467 | return li; |
| 468 | } |
| 469 | async function openChat(id, remember) { |
| 470 | clearTimeout(chats.timer); |
| 471 | if (chats.edits) { chats.edits.stop(); chats.edits = null; } |
| 472 | const panel = chats.panel; if (!panel) return; |
| 473 | chats.open = String(id); chats.msgs = []; chats.last = 0; |
| 474 | const draft = chats.draft && canWriteChat() ? chats.draft.text : ''; |
| 475 | chats.draft = null; |
| 476 | if (remember) save({ chat: chats.open }); |
| 477 | const conv = (chats.list || []).find((c) => String(c.id) === chats.open); |
| 478 | panel.textContent = ''; |
| 479 | const head = h('div', 'ovdk-thread-h'); |
| 480 | const back = h('button', 'ovdk-back'); back.type = 'button'; back.setAttribute('aria-label', 'Back to your chats'); back.append(icon(G.back)); |
| 481 | back.addEventListener('click', () => { save({ chat: null }); chatList(); }); |
| 482 | const title = h('b', '', conv ? titleOf(conv) : 'Conversation'); |
| 483 | head.append(back, title, toChat('https://openvibe.chat/messages')); |
| 484 | // Opened from the Dock's state on another page: the list is not here yet, so the title comes with it. |
| 485 | if (!conv) chatCall('/api/dm/conversations').then((b) => { chats.list = b.conversations || []; const c = chats.list.find((x) => String(x.id) === String(id)); if (c && chats.open === String(id)) title.textContent = titleOf(c); }).catch(() => { /* the title stays */ }); |
| 486 | const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite'); |
| 487 | const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list); |
| 488 | const form = h('form', 'ovdk-send'); |
| 489 | const box = h('textarea'); box.rows = 1; box.maxLength = 4000; box.placeholder = 'Message'; box.setAttribute('aria-label', 'Message'); |
| 490 | const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send)); |
| 491 | const err = h('p', 'ovdk-err'); err.hidden = true; |
| 492 | form.append(box, go); |
| 493 | panel.append(head, scroll, err); |
| 494 | // Editing one of mine: the box holds its words, a slim bar over it says so, Escape or × lets go. |
| 495 | const bar = h('div', 'ovdk-edit-bar'); bar.hidden = true; |
| 496 | const stopX = h('button'); stopX.type = 'button'; stopX.setAttribute('aria-label', 'Stop editing'); stopX.setAttribute('data-ov-tip', 'Stop editing'); stopX.append(icon(G.close)); |
| 497 | bar.append(icon(G.pencil), h('span', '', 'Editing your message'), stopX); |
| 498 | let editing = null; |
| 499 | const stopEditing = () => { |
| 500 | if (!editing) return; |
| 501 | if (editing.li) editing.li.classList.remove('is-editing'); |
| 502 | editing = null; bar.hidden = true; form.classList.remove('is-editing'); |
| 503 | box.value = ''; box.dispatchEvent(new Event('input')); go.setAttribute('aria-label', 'Send'); |
| 504 | }; |
| 505 | const startEditing = (li) => { |
| 506 | const old = li && chats.msgs.find((m) => Number(m.id) === Number(li.dataset.id)); |
| 507 | if (!old) return; |
| 508 | if (editing && editing.li) editing.li.classList.remove('is-editing'); |
| 509 | list.querySelectorAll('.is-acting').forEach((x) => x.classList.remove('is-acting')); |
| 510 | editing = { id: old.id, was: String(old.message || ''), li }; |
| 511 | li.classList.add('is-editing'); bar.hidden = false; form.classList.add('is-editing'); go.setAttribute('aria-label', 'Save'); |
| 512 | box.value = editing.was; box.dispatchEvent(new Event('input')); |
| 513 | try { box.focus(); box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } |
| 514 | }; |
| 515 | stopX.addEventListener('click', () => { stopEditing(); box.focus(); }); |
| 516 | list.addEventListener('click', (e) => { |
| 517 | const edit = e.target.closest && e.target.closest('.ovdk-edit'); |
| 518 | if (edit) { e.preventDefault(); startEditing(edit.closest('[data-id]')); return; } |
| 519 | // A finger: a tap on my bubble shows its actions (a second tap, or a tap on another, puts them away). |
| 520 | const li = e.target.closest && e.target.closest('.ovdk-msg.has-acts'); |
| 521 | if (!li || !(typeof matchMedia === 'function' && matchMedia('(hover: none)').matches) || (e.target.closest('a, button'))) return; |
| 522 | const was = li.classList.contains('is-acting'); |
| 523 | list.querySelectorAll('.is-acting').forEach((x) => x.classList.remove('is-acting')); |
| 524 | if (!was) li.classList.add('is-acting'); |
| 525 | }); |
| 526 | if (canWriteChat()) { |
| 527 | panel.append(bar, form); |
| 528 | const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; }; |
| 529 | box.addEventListener('input', fit); |
| 530 | if (draft && root.OpenVibeDockShare) root.OpenVibeDockShare.ready({ draft, h, panel, form, box, fit }); |
| 531 | box.addEventListener('keydown', (e) => { |
| 532 | if (e.key === 'Escape' && editing) { e.preventDefault(); e.stopPropagation(); stopEditing(); return; } |
| 533 | if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } |
| 534 | }); |
| 535 | form.addEventListener('submit', async (e) => { |
| 536 | e.preventDefault(); |
| 537 | const text = box.value.trim(); |
| 538 | if (!text || chats.busy) return; |
| 539 | if (editing) { |
| 540 | const ed = editing; |
| 541 | if (text === ed.was.trim()) { stopEditing(); return; } |
| 542 | chats.busy = true; go.disabled = true; err.hidden = true; |
| 543 | try { |
| 544 | const out = await chatCall(`/api/dm/conversations/${encodeURIComponent(chats.open)}/messages/${encodeURIComponent(ed.id)}`, { method: 'PATCH', body: JSON.stringify({ message: text }) }); |
| 545 | if (out && out.message) applyEdit(list, out.message); |
| 546 | if (editing === ed) stopEditing(); |
| 547 | } catch (x) { |
| 548 | err.textContent = x.status === 429 ? 'Slow down a little, then save it again.' : x.status === 403 ? 'That message can no longer be edited.' : 'The edit did not save. Try again.'; |
| 549 | err.hidden = false; |
| 550 | } finally { chats.busy = false; go.disabled = false; box.focus(); } |
| 551 | return; |
| 552 | } |
| 553 | chats.busy = true; go.disabled = true; err.hidden = true; |
| 554 | try { |
| 555 | await chatCall(`/api/dm/conversations/${encodeURIComponent(chats.open)}/messages`, { method: 'POST', body: JSON.stringify({ message: text }) }); |
| 556 | box.value = ''; fit(); |
| 557 | await pull(list, scroll); |
| 558 | } catch (x) { |
| 559 | err.textContent = x.status === 429 ? 'Slow down a little, then send it again.' : x.status === 403 ? 'You can’t send messages in this conversation.' : 'That did not send. Try again.'; |
| 560 | err.hidden = false; |
| 561 | } finally { chats.busy = false; go.disabled = false; box.focus(); } |
| 562 | }); |
| 563 | } else { |
| 564 | panel.append(h('div', 'ovdk-none', 'Reading only. Answer in OpenVibe.Chat.')); |
| 565 | } |
| 566 | list.append(h('li', 'ovdk-wait', null)); |
| 567 | list.firstChild.innerHTML = '<span></span>'; |
| 568 | await pull(list, scroll, true); |
| 569 | load('dock-chat-edits.js', () => !!root.OpenVibeDockChatEdits).then((ok) => { if (ok && chats.open === String(id)) chats.edits = root.OpenVibeDockChatEdits({ id: String(id), apply: (m) => applyEdit(list, m), tokenOf, chatBase, active: () => chats.open === String(id) && panelActive('chats') }); }); |
| 570 | if (canWriteChat() && !(typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)) setTimeout(() => { try { box.focus({ preventScroll: true }); if (draft) box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } }, 0); |
| 571 | } |
| 572 | /** New messages of the open thread (all of them the first time), drawn in place; read once shown. */ |
| 573 | async function pull(list, scroll, first) { |
| 574 | const id = chats.open; |
| 575 | let got = []; |
| 576 | try { got = (await chatCall(`/api/dm/conversations/${encodeURIComponent(id)}/messages?limit=50${chats.last ? `&after=${chats.last}` : ''}`)).messages || []; } catch (e) { |
| 577 | if (first && panelActive('chats')) { list.textContent = ''; list.append(h('li', 'ovdk-none', e.status === 403 ? 'This conversation is not yours.' : 'The messages did not load. Try again.')); } |
| 578 | return; |
| 579 | } |
| 580 | if (id !== chats.open || !panelActive('chats')) return; |
| 581 | if (first) list.textContent = ''; |
| 582 | const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40; |
| 583 | got.forEach((m) => { if (chats.msgs.some((x) => x.id === m.id)) return; list.append(bubble(m, chats.msgs[chats.msgs.length - 1])); chats.msgs.push(m); chats.last = Math.max(chats.last, Number(m.id) || 0); }); |
| 584 | if (first && !chats.msgs.length) list.append(h('li', 'ovdk-none', 'No messages yet. Say hello.')); |
| 585 | if (first || atEnd) scroll.scrollTop = scroll.scrollHeight; |
| 586 | if (got.length || first) chatCall(`/api/dm/conversations/${encodeURIComponent(id)}/read`, { method: 'POST', body: '{}' }).then(readUnread).catch(() => { /* */ }); |
| 587 | clearTimeout(chats.timer); |
| 588 | if (panelActive('chats') && chats.open === id) chats.timer = setTimeout(() => pull(list, scroll), 5000); |
| 589 | } |
| 590 | |
| 591 | // ── Vibe (plan L48 V2): the person's Vibe, in dock-vibe.js, loaded the first time the tab opens (the Dock is on |
| 592 | // every page for signed-in people; Vibe's conversation and mic are not needed until then). Alt+V talks to Vibe from |
| 593 | // any page: it opens the Dock on Vibe and starts listening (only where the browser can listen). |
| 594 | const vibe = { panel: null, api: null }; |
| 595 | // The Chats tab's AI chats row opens the Vibe tab on its list, not on the last conversation. |
| 596 | let vibeOnList = false; |
| 597 | const canListen = () => !!(root.SpeechRecognition || root.webkitSpeechRecognition); |
| 598 | document.addEventListener('keydown', (e) => { |
| 599 | if (!e.altKey || e.ctrlKey || e.metaKey || String(e.key).toLowerCase() !== 'v' || !canListen() || !o || typeof o.token !== 'function') return; |
| 600 | e.preventDefault(); |
| 601 | const talk = (n = 0) => { if (vibe.api && vibe.api.talk && panelActive('vibe')) vibe.api.talk(); else if (n < 40) setTimeout(() => talk(n + 1), 100); }; |
| 602 | if (!panelActive('vibe')) open({ tab: 'vibe' }); |
| 603 | talk(); |
| 604 | }); |
| 605 | |
| 606 | // ── The timer (plan L48 R2): dock-timer.js, loaded when the state carries one or the person opens its menu ── |
| 607 | let timerMod = null, timerLoading = null; |
| 608 | function withTimer(fn) { |
| 609 | if (timerMod) return fn(timerMod); |
| 610 | timerLoading = timerLoading || load('dock-timer.js', () => !!(root.OpenVibeDockTimer && root.OpenVibeDockTimer.attach)).then((ok) => { |
| 611 | if (ok && !timerMod) timerMod = root.OpenVibeDockTimer.attach({ state, save, host: () => inline, repaint: () => paintStatus() }); |
| 612 | return timerMod; |
| 613 | }); |
| 614 | return timerLoading.then((m) => (m ? fn(m) : null)); |
| 615 | } |
| 616 | |
| 617 | // ── Status (4.220.0): what the pill used to say rides on the bar's menu button (navbar.js passes o.status) ── |
| 618 | let readT = 0, bound = false, off = false; |
| 619 | function store(v) { |
| 620 | try { if (v === undefined) return JSON.parse(localStorage.getItem('ov-dock') || 'null'); localStorage.setItem('ov-dock', JSON.stringify(v)); } catch { /* */ } |
| 621 | return null; |
| 622 | } |
| 623 | /** An agent needs you (first), a timer's minutes, or agents working; null when there is nothing to say. */ |
| 624 | function status() { |
| 625 | if (off || !doc) return null; |
| 626 | const c = counts(), st = state(), w = c.waiting || 0, r = c.running || 0; |
| 627 | if (st.timer && !timerMod) withTimer((m) => m.sync()); |
| 628 | const tm = timerMod && !w ? timerMod.pillText() : null; |
| 629 | if (w) return { tone: 'waiting', text: 'Needs you', label: `${w} need${w > 1 ? '' : 's'} you`, part: 'agent' }; |
| 630 | if (tm) return { tone: tm.tone, text: tm.text, label: tm.label, part: 'vibe' }; |
| 631 | if (r) return { tone: 'running', text: r > 1 ? `${r} working` : 'Working', label: `${r} working`, part: 'agent' }; |
| 632 | return null; |
| 633 | } |
| 634 | /** The status on the bar's menu button, for a bar that asked (navbar.js passes o.status): a dot while agents work, an |
| 635 | * amber one when one needs you, a timer's minutes in a small label under it; the tooltip and the name say it in words. */ |
| 636 | function paintBar(s) { |
| 637 | document.querySelectorAll('.openvibe-navbar #openvibe-search-btn, .openvibe-navbar #openvibe-burger.ovnav-orb').forEach((b) => { |
| 638 | let chip = b.querySelector('.ovnav-st'); |
| 639 | if (!s) { if (chip) chip.remove(); b.removeAttribute('data-ov-status'); if (b.dataset.ovLabel) { b.setAttribute('aria-label', b.dataset.ovLabel); b.setAttribute('data-ov-tip', b.dataset.ovTip || b.dataset.ovLabel); } return; } |
| 640 | // An i, not a span: the old burger draws its spans as lines. |
| 641 | if (!chip) { chip = document.createElement('i'); chip.className = 'ovnav-st'; chip.setAttribute('aria-hidden', 'true'); b.appendChild(chip); } |
| 642 | chip.textContent = /^timer/.test(s.tone) ? (s.tone === 'timer-done' ? 'Done' : s.text) : ''; |
| 643 | b.setAttribute('data-ov-status', s.tone); |
| 644 | if (!b.dataset.ovLabel) { b.dataset.ovLabel = b.getAttribute('aria-label') || 'Menu'; b.dataset.ovTip = b.getAttribute('data-ov-tip') || ''; } |
| 645 | b.setAttribute('aria-label', `${b.dataset.ovLabel}: ${s.label}`); |
| 646 | b.setAttribute('data-ov-tip', s.label); |
| 647 | }); |
| 648 | } |
| 649 | function paintStatus() { |
| 650 | if (typeof o.status !== 'function') return; |
| 651 | const s = status(); |
| 652 | try { o.status(s); } catch { /* the bar is optional */ } |
| 653 | paintBar(s); |
| 654 | } |
| 655 | async function read() { |
| 656 | clearTimeout(readT); |
| 657 | if (off || !o.token || document.hidden) return; |
| 658 | const t = await tokenOf(); |
| 659 | if (!t) return; |
| 660 | try { |
| 661 | const r = await fetch(`${String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '')}/api/v1/me/dock`, { headers: { authorization: `Bearer ${t}`, accept: 'application/json' }, credentials: 'omit' }); |
| 662 | if (r.status === 404) { off = true; store({ off: Date.now() + 864e5 }); paintStatus(); return; } |
| 663 | const next = r.ok ? await r.json() : null; |
| 664 | if (!next || !next.state) return; |
| 665 | update(next); |
| 666 | const c = next.counts || {}; |
| 667 | if (c.running || c.waiting || next.state.timer) readT = setTimeout(read, 30000); |
| 668 | } catch { /* offline: the copy stands */ } |
| 669 | } |
| 670 | /** A signed-in person's state and counts (navbar.js), or none (null: signed out, or the site turned it off). */ |
| 671 | function mount(opts) { |
| 672 | if (!opts) { |
| 673 | if (mountHandle) mountHandle.destroy(); |
| 674 | clearTimeout(readT); |
| 675 | const was = o.status; o = {}; doc = null; |
| 676 | if (typeof was === 'function') { try { was(null); } catch { /* */ } paintBar(null); } |
| 677 | return; |
| 678 | } |
| 679 | o = { ...o, ...opts }; |
| 680 | styles(); |
| 681 | const kept = store(); |
| 682 | off = !!(kept && kept.off > Date.now()); |
| 683 | if (kept && kept.state && !doc) doc = kept; |
| 684 | paintStatus(); |
| 685 | if (!bound) { bound = true; document.addEventListener('visibilitychange', () => { if (!document.hidden) read(); }); } |
| 686 | read(); |
| 687 | } |
| 688 | |
| 689 | // ── Open: the menu, on one of its places ── |
| 690 | // Ask your agent (4.135.0): Vibe opens with the question in its box, unsent. |
| 691 | function ask({ text = '' } = {}, n = 0) { |
| 692 | if (!n && !panelActive('vibe')) open({ tab: 'vibe' }); if (vibe.api && vibe.api.ask && panelActive('vibe')) vibe.api.ask(text); else if (n < 40) setTimeout(() => ask({ text }, n + 1), 100); } |
| 693 | function isOpen() { return !!inline; } |
| 694 | function share({ text = '', title = '' } = {}) { |
| 695 | const t = String(text || '').trim().slice(0, 4000); |
| 696 | if (!t) return; |
| 697 | chats.draft = { text: t, title: String(title || '').trim().slice(0, 140) }; chats.open = null; |
| 698 | // A bar left from an earlier share would show the old words until the list reloads: it goes now. |
| 699 | const stale = chats.panel && chats.panel.querySelector('.ovdk-share'); if (stale) stale.remove(); |
| 700 | if (panelActive('chats')) { chatList(); return; } |
| 701 | open({ tab: 'chats' }); |
| 702 | } |
| 703 | /** The menu opens on that place (navbar.js passes o.openMenu), and the menu mounts the panel with mountInto. */ |
| 704 | function open(opts = {}) { |
| 705 | const { tab: want, doc: given, ...rest } = opts || {}; |
| 706 | o = { ...o, ...rest }; |
| 707 | if (given) doc = given; |
| 708 | const c = counts(); |
| 709 | let id = want || (c.waiting || c.running ? 'agent' : state().tab || 'vibe'); |
| 710 | if (id === 'agents') id = 'agent'; |
| 711 | if (!TABS.some(([t]) => t === id)) id = 'vibe'; |
| 712 | if (inline && id === tab && part !== 'top') return; |
| 713 | if (typeof o.openMenu === 'function') o.openMenu(id); |
| 714 | } |
| 715 | function close() { if (typeof o.closeMenu === 'function') o.closeMenu(); } |
| 716 | /** A fresh copy from the Network: counts, and anything changed elsewhere (the timer, the open chat). */ |
| 717 | function update(next) { |
| 718 | if (!next || !next.state) return; |
| 719 | doc = Object.keys(pending).length ? { ...next, state: state() } : next; |
| 720 | store(doc); |
| 721 | if (timerMod) timerMod.sync(); |
| 722 | paintStatus(); |
| 723 | } |
| 724 | |
| 725 | let mountHandle = null; |
| 726 | /** Place the same panel body under another sheet's head. The helper and its layout load on first use. */ |
| 727 | function mountInto(host, { tab: first = 'vibe', part: lend = null } = {}) { |
| 728 | if (!(host instanceof Element)) throw new TypeError('mountInto needs a host element'); |
| 729 | if (mountHandle) mountHandle.destroy(); |
| 730 | let api = null, gone = false, wanted = first; |
| 731 | const handle = { |
| 732 | show(id) { if (gone) return; wanted = id; if (api) api.show(id); }, |
| 733 | destroy() { if (gone) return; gone = true; if (api) api.destroy(); if (mountHandle === handle) mountHandle = null; }, |
| 734 | }; |
| 735 | mountHandle = handle; |
| 736 | load('dock-mount.js', () => !!root.OpenVibeDockMount).then((ok) => { |
| 737 | if (ok && !gone) api = root.OpenVibeDockMount.mount(host, wanted, { |
| 738 | start(wrap) { if (!bodyEl) build(); part = lend === 'top' ? 'top' : null; wrap.classList.toggle('ovdk-part-top', part === 'top'); inline = wrap; wrap.append(bodyEl); }, |
| 739 | // A lent top is not a place the person chose: the menu's Vibe remembers its own (remember()). |
| 740 | show(id) { setTab(id, part !== 'top'); }, |
| 741 | end() { disposePanel(); inline = null; part = null; if (bodyEl) bodyEl.remove(); paintStatus(); }, |
| 742 | }); |
| 743 | }); |
| 744 | return handle; |
| 745 | } |
| 746 | |
| 747 | root.OpenVibeDock = { mount, mountInto, open, close, isOpen, update, ask, share, |
| 748 | // The menu's search box filters the conversations. The menu redraws when its own loads arrive (and calls this with the |
| 749 | // same words): an unchanged filter does nothing, so an open chat is never swapped for the list under the person. |
| 750 | filterChats: (v) => { const f = String(v || '').toLowerCase(); if (f === chats.filter) return; chats.filter = f; chatList(); }, |
| 751 | // The timer's menu (the menu's Vibe tab has its button): presets, a focus round, your own. |
| 752 | timer: (anchor) => withTimer((t) => t.menu(anchor || null)), more: (a) => { if (a === 'timer') withTimer((t) => t.menu(null)); }, |
| 753 | status, canSend: () => canWriteChat(), _state: () => ({ doc, tab, pending }) }; |
| 754 | })(typeof window !== 'undefined' ? window : globalThis); |
| 755 |