openvibers/openvibe.shared dock.js

dock.js

main 59.2 KB · 755 lines · javascript

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 'use strict';
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