openvibers/openvibe.shared agent-session.js
agent-session.js
| 1 | /** |
| 2 | * openvibe-shared/agent-session.js — a coding agent's session as people read it: what it said, the steps it took |
| 3 | * (commands, files read and changed, searches, sub-agents) grouped and folded, each step's command, diff and OUTPUT |
| 4 | * (live while it runs), hand-offs between agents, the plan it keeps, and a summary that stays at the top (4.24.0). |
| 5 | * |
| 6 | * The owner, 2026-10-28, showing the Claude app's session view ("Ran 3 commands", a Bash sheet saying "Output |
| 7 | * unavailable"): "OpenVibe.Codes should… actually show the output from the agent… more user friendly… handle that |
| 8 | * between all agents… in a smart way… way better than this idea." |
| 9 | * |
| 10 | * const view = OpenVibeAgentSession.mount(el, { title, status: 'running', startedAt, link }) |
| 11 | * link(url) → boolean: which https addresses in the agents' text become links (default: |
| 12 | * all; a site whose agents read untrusted pages links only its own, e.g. a person's desk) |
| 13 | * view.push(event) one event: a Codes job line ({ t: 'tool', … }), any Claude Code stream-json event |
| 14 | * ({ type: 'assistant', … }) or an OpenVibe.Actor task event ({ kind: 'output', step, … }, |
| 15 | * actor.task-event@1); every OpenVibe.Codes harness adapter (Claude Code, Codex, OpenCode, |
| 16 | * Command Code, Aider, DeepSeek, OpenVibe.AI, your own model) and every Actor agent speaks one |
| 17 | * view.pushAll(events) |
| 18 | * view.status('succeeded' | 'failed' | 'cancelled' | 'running' | 'queued' | …, label?) |
| 19 | * view.actions([{ id, label, kind: 'primary' | 'danger' | undefined }], note?) |
| 20 | * the sticky bar at the bottom (Approve, Continue, Stop…); opts.onAction(id) runs when one is |
| 21 | * pressed (a returned promise keeps the buttons disabled until it settles); [] hides it |
| 22 | * view.summary() → { agents, steps, commands, files, added, removed, problems, costUsd, durationMs } |
| 23 | * view.destroy() |
| 24 | * OpenVibeAgentSession.normalize(event) → [line] the stream-json → line mapping, for servers and other readers |
| 25 | * |
| 26 | * The lines (Codes' job protocol 1.1, additive to 1.0): |
| 27 | * { t: 'agent', agent, model } an agent started (a hand-off starts the next one) |
| 28 | * { t: 'text', text, parent? } what it said (light Markdown: paragraphs, lists, code, links) |
| 29 | * { t: 'tool', id, name, what, input?, parent? } a step began: Bash, Read, Edit, Write, Grep, Glob, LS, |
| 30 | * WebFetch, WebSearch, Task (a sub-agent), TodoWrite (the plan) |
| 31 | * { t: 'tool_progress', id, chunk } output while it runs (agents whose steps the harness runs) |
| 32 | * { t: 'tool_result', id, ok, output, cost_usd?, duration_ms? } how it ended, with its output (and its cost, |
| 33 | * when the agent reports one: shown on the step's row) |
| 34 | * { t: 'handoff', from, to?, reason } one agent stopped and the next took over |
| 35 | * { t: 'note', text, level? } a warning or an error along the way |
| 36 | * { t: 'step', text } the job's own progress (cloning, the branch, files changed, pushing) |
| 37 | * { t: 'usage', input_tokens, output_tokens } |
| 38 | * { t: 'done', ok, text, duration_ms, cost_usd, turns } |
| 39 | * A step or a message may carry the images and files the agent read, wrote or produced: |
| 40 | * attachments: [{ url, name, mime, size?, kind: 'image'|'video'|'audio'|'pdf'|'text'|'file', role: 'read'|'wrote'|'output' }] |
| 41 | * The product supplies the URLs; the component never builds one from a local path. Any line may carry attachments, |
| 42 | * and the component shows them for every line it draws. A line of a type it does not know (Codes' proof lines) is |
| 43 | * drawn as a minimal row: its label or summary, then its attachments. A step's attachments show in its |
| 44 | * group's header, visible while the group is folded; a message's show under its text. A tap opens a preview sheet: the |
| 45 | * image at full width (pinch to zoom), video and audio in their players, a PDF in a frame, text as code, Markdown, a CSV |
| 46 | * table or pretty JSON, plus Open and Download. An attachment loads only from the product's own origin or Media's, |
| 47 | * never a third-party address the model printed; SVG is only ever an <img>, and text is set as text, never as markup. |
| 48 | * A line from a sub-agent carries `parent`: the id of the Task step that started it, so it nests under that step. |
| 49 | * Any line may carry `agent` (the head then follows who is working); a step without an id gets one, and the next result |
| 50 | * of the same tool without an id is its result. { t: 'state', state } moves the head (Actor's task states). |
| 51 | * |
| 52 | * Everything is text: no line's content ever becomes markup (links are https only). The look is agent-session.css |
| 53 | * (4.32.0), linked from beside this script unless the page links it; it follows the page's theme tokens, light or dark. |
| 54 | * The Files tab lists every file edited or written with its +/−; each step keeps its raw input (folded); the head shows |
| 55 | * each agent's cost from its done line. |
| 56 | */ |
| 57 | (function (root) { |
| 58 | ; |
| 59 | |
| 60 | // ── What each step is ───────────────────────────────────── |
| 61 | const KIND = { |
| 62 | Bash: 'command', bash: 'command', run_command: 'command', shell: 'command', exec: 'command', |
| 63 | Read: 'read', read_file: 'read', Grep: 'search', grep: 'search', Glob: 'search', LS: 'search', list_files: 'search', search: 'search', |
| 64 | Edit: 'edit', MultiEdit: 'edit', edit_file: 'edit', NotebookEdit: 'edit', Write: 'write', write_file: 'write', apply_patch: 'edit', |
| 65 | WebFetch: 'web', WebSearch: 'web', Task: 'agent', Agent: 'agent', TodoWrite: 'plan', |
| 66 | }; |
| 67 | // A tool no table knows is judged by the words of its name (run_shell, readFile, web-fetch), never by letters inside |
| 68 | // a word ("Frobnicate" is not a cat). |
| 69 | function kindOf(name) { |
| 70 | if (KIND[name]) return KIND[name]; |
| 71 | const w = new Set(String(name || '').replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z]+/).filter(Boolean)); |
| 72 | const any = (...xs) => xs.some((x) => w.has(x)); |
| 73 | // The web first: web_search and web_fetch are looking things up, not searching files. |
| 74 | return any('web', 'fetch', 'http', 'browse', 'url', 'internet') ? 'web' : any('bash', 'shell', 'command', 'exec', 'run', 'terminal') ? 'command' : any('edit', 'patch', 'replace') ? 'edit' |
| 75 | : any('write', 'create') ? 'write' : any('read', 'cat', 'view', 'open') ? 'read' : any('grep', 'glob', 'find', 'search', 'list', 'ls') ? 'search' : 'tool'; |
| 76 | } |
| 77 | const NOUN = { command: ['command', 'commands'], read: ['read', 'reads'], search: ['search', 'searches'], edit: ['edit', 'edits'], write: ['new file', 'new files'], web: ['lookup', 'lookups'], agent: ['helper', 'helpers'], plan: ['plan', 'plan'], tool: ['tool', 'tools'] }; |
| 78 | const VERB = { |
| 79 | command: ['Ran a command', 'Ran %d commands'], read: ['Read a file', 'Read %d files'], search: ['Searched once', 'Searched %d times'], |
| 80 | edit: ['Edited a file', 'Edited %d files'], write: ['Wrote a file', 'Wrote %d files'], web: ['Looked something up', 'Looked up %d things'], |
| 81 | agent: ['Started a helper', 'Started %d helpers'], plan: ['Updated the plan', 'Updated the plan'], tool: ['Used a tool', 'Used %d tools'], |
| 82 | }; |
| 83 | const ICON = { |
| 84 | command: '<path d="M4 6l5 5-5 5"/><path d="M11 17h9"/>', |
| 85 | read: '<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4"/>', |
| 86 | search: '<circle cx="11" cy="11" r="6"/><path d="m20 20-4.2-4.2"/>', |
| 87 | edit: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13 7 4 4"/>', |
| 88 | write: '<path d="M6 3h8l4 4v14H6z"/><path d="M12 11v6M9 14h6"/>', |
| 89 | web: '<circle cx="12" cy="12" r="8.5"/><path d="M3.5 12h17M12 3.5c2.5 2.6 2.5 14.4 0 17M12 3.5c-2.5 2.6-2.5 14.4 0 17"/>', |
| 90 | agent: '<rect x="5" y="7" width="14" height="11" rx="3"/><path d="M12 3v4M9 12h.01M15 12h.01"/>', |
| 91 | plan: '<path d="M9 6h11M9 12h11M9 18h11"/><path d="m3 6 1.5 1.5L7 5M3 12l1.5 1.5L7 11M3 18l1.5 1.5L7 17"/>', |
| 92 | tool: '<path d="M14 7a4 4 0 0 0-5.6 5.1L4 16.5 7.5 20l4.4-4.4A4 4 0 0 0 17 10l-2.5 2.5-3-3z"/>', |
| 93 | ok: '<path d="m5 12.5 4.5 4.5L19 7.5"/>', fail: '<path d="M6 6l12 12M18 6 6 18"/>', copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/>', |
| 94 | handoff: '<path d="M4 12h13"/><path d="m13 7 5 5-5 5"/>', warn: '<path d="M12 4 2.5 20h19z"/><path d="M12 10v4M12 17h.01"/>', |
| 95 | chevron: '<path d="m9 6 6 6-6 6"/>', |
| 96 | img: '<rect x="3" y="4.5" width="18" height="15" rx="2"/><circle cx="8.5" cy="9.5" r="1.6"/><path d="m4 17 4.5-4.5 3.5 3.5 3-3L20 17"/>', |
| 97 | video: '<rect x="3" y="5" width="13" height="14" rx="2"/><path d="m16 10 5-3v10l-5-3z"/>', |
| 98 | audio: '<path d="M9 17V6l10-2v11"/><circle cx="6.5" cy="17.5" r="2.5"/><circle cx="16.5" cy="15.5" r="2.5"/>', |
| 99 | doc: '<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4"/><path d="M9 12.5h6M9 16h6"/>', |
| 100 | code: '<path d="m9 8-5 4 5 4M15 8l5 4-5 4"/>', |
| 101 | file: '<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4"/>', |
| 102 | }; |
| 103 | const svg = (name, cls) => `<svg class="${cls || 'ovas-ic'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICON[name] || ICON.tool}</svg>`; |
| 104 | const AGENT_NAME = { claude: 'Claude Code', 'claude-code': 'Claude Code', codex: 'Codex', opencode: 'OpenCode', 'command-code': 'Command Code', cmd: 'Command Code', aider: 'Aider', deepseek: 'DeepSeek', 'openvibe-ai': 'OpenVibe.AI', ai: 'OpenVibe.AI', own: 'Your model' }; |
| 105 | const agentName = (a) => AGENT_NAME[String(a || '').toLowerCase()] || String(a || 'Agent'); |
| 106 | |
| 107 | // ── stream-json → lines (the same mapping as harness/job.js) ─ |
| 108 | const MAX_TEXT = 6000; |
| 109 | const cut = (s, n) => { const t = String(s == null ? '' : s); return t.length > n ? `${t.slice(0, n - 1)}…` : t; }; |
| 110 | function resultText(content) { |
| 111 | if (typeof content === 'string') return content; |
| 112 | if (Array.isArray(content)) return content.map((c) => (c && typeof c.text === 'string' ? c.text : '')).join('\n'); |
| 113 | return content == null ? '' : String(content); |
| 114 | } |
| 115 | // OpenVibe.Actor's task events (actor.task-event@1): { kind: 'state' | 'end', state } and { kind: 'output', step, agent, |
| 116 | // text, tool, input, is_error }, step one of plan, text, handoff, check, tool_call, tool_result. |
| 117 | function fromActor(e) { |
| 118 | const agent = e.agent ? { agent: String(e.agent) } : {}; |
| 119 | if (e.kind === 'state' || e.kind === 'end') return e.state ? [{ t: 'state', state: String(e.state), ...agent }] : []; |
| 120 | if (e.kind !== 'output') return []; |
| 121 | const text = cut(e.text, MAX_TEXT); |
| 122 | const att = e.attachments ? { attachments: e.attachments } : {}; |
| 123 | if (e.step === 'text') return [e.is_error ? { t: 'note', level: 'error', text, ...agent } : { t: 'text', text, ...att, ...agent }]; |
| 124 | if (e.step === 'plan') return [{ t: 'step', text, ...agent }]; |
| 125 | if (e.step === 'handoff') return [{ t: 'handoff', from: null, to: e.agent || null, reason: text, ...agent }]; |
| 126 | if (e.step === 'check') return [{ t: 'note', level: /^checked/i.test(text) ? 'info' : 'warning', text, ...agent }]; |
| 127 | if (e.step === 'tool_call') return [{ t: 'tool', name: String(e.tool || 'tool'), what: typeof e.input === 'string' ? cut(e.input, 300) : '', input: e.input && typeof e.input === 'object' ? e.input : { description: typeof e.input === 'string' ? cut(e.input, 300) : '' }, ...att, ...agent }]; |
| 128 | if (e.step === 'tool_result') return [{ t: 'tool_result', name: String(e.tool || 'tool'), ok: !e.is_error, output: cut(e.text, 8000), ...att, ...agent }]; |
| 129 | return text ? [{ t: 'text', text, ...agent }] : []; |
| 130 | } |
| 131 | |
| 132 | // ── AG-UI 1.0 (protocol 1.2, ask 354, 2026-11-11): every OpenVibe agent run streams AG-UI events (docs.ag-ui.com, |
| 133 | // spec 1.0 of 2026-11-07) with OpenVibe's own words in metadata.ov (title, kind, sources, …), so another tool's agent |
| 134 | // renders here and ours in theirs. Deltas (text, tool arguments, thinking) carry their ids; the view joins them. |
| 135 | const pick = (e, ...k) => { for (const n of k) if (e[n] != null) return e[n]; return null; }; |
| 136 | function fromAgui(e) { |
| 137 | const ov = (e.metadata && typeof e.metadata === 'object' && e.metadata.ov && typeof e.metadata.ov === 'object') ? e.metadata.ov : {}; |
| 138 | const src = Array.isArray(ov.sources) && ov.sources.length ? [{ t: 'sources', items: ov.sources }] : []; |
| 139 | const id = (...k) => String(pick(e, ...k) || ''); |
| 140 | switch (e.type) { |
| 141 | case 'RUN_STARTED': return [{ t: 'state', state: 'running' }]; |
| 142 | case 'RUN_FINISHED': return [...src, { t: 'done', ok: true, text: cut(typeof e.result === 'string' ? e.result : '', MAX_TEXT) }]; |
| 143 | case 'RUN_ERROR': return [{ t: 'done', ok: false, text: cut(String(e.message || 'The run failed.'), 2000) }]; |
| 144 | case 'STEP_STARTED': return [{ t: 'phase', id: id('stepName', 'step_name'), title: String(ov.title || pick(e, 'stepName', 'step_name') || 'Step'), status: 'running' }]; |
| 145 | case 'STEP_FINISHED': return [{ t: 'phase', id: id('stepName', 'step_name'), status: 'done' }]; |
| 146 | case 'TEXT_MESSAGE_START': return [{ t: 'text_start', id: id('messageId', 'message_id') }]; |
| 147 | case 'TEXT_MESSAGE_CONTENT': case 'TEXT_MESSAGE_CHUNK': return [{ t: 'text_delta', id: id('messageId', 'message_id'), delta: String(e.delta || '') }]; |
| 148 | case 'TOOL_CALL_START': return [{ t: 'tool', id: id('toolCallId', 'tool_call_id'), name: String(pick(e, 'toolCallName', 'tool_call_name') || 'tool'), input: {}, title: ov.title ? cut(ov.title, 160) : '', kind: ov.kind || null }]; |
| 149 | case 'TOOL_CALL_ARGS': case 'TOOL_CALL_CHUNK': return [{ t: 'tool_args', id: id('toolCallId', 'tool_call_id'), delta: String(e.delta || '') }]; |
| 150 | case 'TOOL_CALL_RESULT': return [...src, { t: 'tool_result', id: id('toolCallId', 'tool_call_id'), ok: !ov.error, output: cut(typeof e.content === 'string' ? e.content : JSON.stringify(e.content || ''), 8000), duration_ms: Number(ov.duration_ms) || 0, cost_usd: Number(ov.cost_usd) || 0 }]; |
| 151 | case 'REASONING_START': case 'REASONING_MESSAGE_START': return [{ t: 'thinking', id: id('messageId', 'message_id') || 'r' }]; |
| 152 | case 'REASONING_MESSAGE_CONTENT': case 'REASONING_MESSAGE_CHUNK': return [{ t: 'thinking_delta', id: id('messageId', 'message_id') || 'r', delta: String(e.delta || '') }]; |
| 153 | case 'REASONING_END': case 'REASONING_MESSAGE_END': return [{ t: 'thinking', id: id('messageId', 'message_id') || 'r', done: true }]; |
| 154 | case 'REASONING_ENCRYPTED_VALUE': return [{ t: 'thinking', id: id('messageId', 'message_id') || 'r', hidden: true }]; |
| 155 | case 'STATE_SNAPSHOT': { |
| 156 | const plan = e.snapshot && Array.isArray(e.snapshot.plan) ? e.snapshot.plan : null; |
| 157 | return plan ? [{ t: 'phases', items: plan.slice(0, 30).map((p) => ({ title: String(p.title || p.content || ''), status: String(p.status || 'pending') })) }] : []; |
| 158 | } |
| 159 | case 'ACTIVITY_SNAPSHOT': return e.content && e.content.title ? [{ t: 'activity', text: cut(String(e.content.title), 200) }] : []; |
| 160 | case 'CUSTOM': { |
| 161 | if (e.name === 'ov.sources' && Array.isArray(e.value)) return [{ t: 'sources', items: e.value }]; |
| 162 | if (e.name === 'ov.handoff' && e.value) return [{ t: 'handoff', from: e.value.from, to: e.value.to || null, reason: cut(e.value.reason, 600) }]; |
| 163 | if (e.name === 'ov.approval' && e.value) return [{ t: 'note', level: 'warning', text: cut(e.value.question || 'Waiting for your approval.', 600) }]; |
| 164 | return []; |
| 165 | } |
| 166 | default: return src; |
| 167 | } |
| 168 | } |
| 169 | function normalize(e) { |
| 170 | if (!e || typeof e !== 'object') return []; |
| 171 | if (e.t) return [e]; |
| 172 | if (typeof e.type === 'string' && /^[A-Z][A-Z_]+$/.test(e.type)) return fromAgui(e); |
| 173 | if (e.kind) return fromActor(e); |
| 174 | const parent = e.parent_tool_use_id || null; |
| 175 | if (e.type === 'system' && e.subtype === 'init') return [{ t: 'agent', agent: e.agent || 'claude', model: e.model || null }]; |
| 176 | if (e.type === 'system' && e.subtype === 'handoff') return [{ t: 'handoff', from: e.from, to: e.to || null, reason: cut(e.reason, 600) }]; |
| 177 | if (e.type === 'system' && (e.subtype === 'error' || e.subtype === 'warning')) return [{ t: 'note', level: e.subtype, text: cut(e.text, 600) }]; |
| 178 | if (e.type === 'system' && e.subtype === 'tool_progress') return [{ t: 'tool_progress', id: String(e.tool_use_id || ''), chunk: String(e.text || '') }]; |
| 179 | if (e.type === 'system' && e.subtype === 'usage' && e.usage) return [{ t: 'usage', input_tokens: e.usage.input_tokens || 0, output_tokens: e.usage.output_tokens || 0 }]; |
| 180 | if (e.type === 'assistant' && e.message) { |
| 181 | const out = []; |
| 182 | for (const c of e.message.content || []) { |
| 183 | if (c.type === 'text' && c.text) out.push({ t: 'text', text: cut(c.text, MAX_TEXT), parent }); |
| 184 | if (c.type === 'tool_use') out.push({ t: 'tool', id: String(c.id || ''), name: String(c.name || ''), input: c.input || {}, parent }); |
| 185 | } |
| 186 | if (e.message.usage) out.push({ t: 'usage', input_tokens: e.message.usage.input_tokens || 0, output_tokens: e.message.usage.output_tokens || 0 }); |
| 187 | return out; |
| 188 | } |
| 189 | if (e.type === 'user' && e.message && Array.isArray(e.message.content)) { |
| 190 | return e.message.content.filter((c) => c && c.type === 'tool_result').map((c) => ({ t: 'tool_result', id: String(c.tool_use_id || ''), ok: !c.is_error, output: cut(resultText(c.content), 8000) })); |
| 191 | } |
| 192 | if (e.type === 'result') return [{ t: 'done', ok: !e.is_error, text: cut(e.result, MAX_TEXT), duration_ms: e.duration_ms || 0, cost_usd: e.total_cost_usd || 0, turns: e.num_turns || 0 }]; |
| 193 | return []; |
| 194 | } |
| 195 | |
| 196 | // ── Small helpers ───────────────────────────────────────── |
| 197 | const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); |
| 198 | const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }; |
| 199 | const plural = (k, n) => (n === 1 ? VERB[k][0] : VERB[k][1].replace('%d', n)); |
| 200 | function dur(ms) { |
| 201 | const s = Math.max(0, Math.round(ms / 1000)); |
| 202 | if (s < 60) return `${s} s`; |
| 203 | const m = Math.floor(s / 60); |
| 204 | return m < 60 ? `${m} min ${s % 60 ? `${s % 60} s` : ''}`.trim() : `${Math.floor(m / 60)} h ${m % 60} min`; |
| 205 | } |
| 206 | const base = (p) => String(p || '').split('/').filter(Boolean).pop() || String(p || ''); |
| 207 | |
| 208 | // ── Attachments: the images and files a step read, wrote or produced (4.50.0) ────────── |
| 209 | // The shape a step or a message carries: |
| 210 | // attachments: [{ url, name, mime, size?, kind: 'image'|'video'|'audio'|'pdf'|'text'|'file', role: 'read'|'wrote'|'output' }] |
| 211 | // The product supplies the URLs; the component never builds one from a local path. |
| 212 | const ATT_KINDS = new Set(['image', 'video', 'audio', 'pdf', 'text', 'file']); |
| 213 | const ATT_ROLES = new Set(['read', 'wrote', 'output']); |
| 214 | const ATT_ROLE_LABEL = { read: 'read', wrote: 'wrote', output: 'produced' }; |
| 215 | const ATT_ICON = { image: 'img', video: 'video', audio: 'audio', pdf: 'doc', text: 'code', file: 'file' }; |
| 216 | const ATT_EXT = { |
| 217 | image: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'bmp', 'ico'], |
| 218 | video: ['mp4', 'webm', 'mov', 'm4v', 'ogv'], |
| 219 | audio: ['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac'], |
| 220 | pdf: ['pdf'], |
| 221 | text: ['md', 'markdown', 'txt', 'json', 'jsonl', 'csv', 'tsv', 'log', 'yaml', 'yml', 'html', 'css', 'js', 'mjs', 'cjs', |
| 222 | 'ts', 'tsx', 'jsx', 'py', 'go', 'rs', 'sh', 'sql', 'xml', 'toml', 'ini', 'env', 'conf'], |
| 223 | }; |
| 224 | const attExt = (name) => (String(name || '').split('?')[0].split('#')[0].split('.').pop() || '').toLowerCase(); |
| 225 | /** What kind of thing an attachment is: what it says, or what its name ends in. */ |
| 226 | function attKind(mime, name, kind) { |
| 227 | if (ATT_KINDS.has(kind)) return kind; |
| 228 | const m = String(mime || '').toLowerCase().split(';')[0].trim(); |
| 229 | if (m.startsWith('image/')) return 'image'; |
| 230 | if (m.startsWith('video/')) return 'video'; |
| 231 | if (m.startsWith('audio/')) return 'audio'; |
| 232 | if (m === 'application/pdf') return 'pdf'; |
| 233 | if (m.startsWith('text/')) return 'text'; |
| 234 | if (m === 'application/json' || m === 'application/xml' || m === 'application/x-yaml') return 'text'; |
| 235 | const ext = attExt(name); |
| 236 | for (const k of Object.keys(ATT_EXT)) if (ATT_EXT[k].includes(ext)) return k; |
| 237 | return 'file'; |
| 238 | } |
| 239 | /** The attachments of a step or a message, in the shape the component draws them. */ |
| 240 | function normAttachments(list) { |
| 241 | const out = [], seen = new Set(); |
| 242 | for (const a of Array.isArray(list) ? list : []) { |
| 243 | if (!a || typeof a !== 'object') continue; |
| 244 | const url = String(a.url || '').trim(); |
| 245 | if (!url || seen.has(url)) continue; |
| 246 | seen.add(url); |
| 247 | const name = String(a.name || '').trim() || base(String(url).split('?')[0]) || 'file'; |
| 248 | out.push({ url, name, mime: String(a.mime || ''), size: Number(a.size) || 0, kind: attKind(a.mime, name, a.kind), role: ATT_ROLES.has(a.role) ? a.role : 'output' }); |
| 249 | } |
| 250 | return out; |
| 251 | } |
| 252 | /** The page's own origin, or Media's: an attachment loads from there and nowhere else. */ |
| 253 | function attAllowed(url, extra) { |
| 254 | const raw = String(url || '').trim(); |
| 255 | if (!raw) return false; |
| 256 | if (/^(blob:|data:image\/(png|jpe?g|gif|webp);)/i.test(raw)) return true; // the page made it itself |
| 257 | if (raw.startsWith('/') && !raw.startsWith('//')) return true; // the product's own origin |
| 258 | const here = (typeof location !== 'undefined' && location && location.hostname) ? String(location.hostname).toLowerCase() : ''; |
| 259 | let host = ''; |
| 260 | try { host = new URL(raw, here ? 'https://' + here : 'https://openvibe.network/').hostname.toLowerCase(); } catch { return false; } |
| 261 | if (here && host === here) return true; |
| 262 | if (host === 'openvibe.media' || host.endsWith('.openvibe.media')) return true; |
| 263 | for (const o of Array.isArray(extra) ? extra : []) { |
| 264 | const h = String(o || '').toLowerCase().replace(/^https?:\/\//, '').split('/')[0].split(':')[0]; |
| 265 | if (h && (host === h || host.endsWith('.' + h))) return true; |
| 266 | } |
| 267 | return false; |
| 268 | } |
| 269 | /** The tiles of a line: the images in one row, the other files as chips in a row under them. */ |
| 270 | function attStrip(list, onOpen) { |
| 271 | const strip = el('div', 'ovas-att'); |
| 272 | const imgs = el('div', 'ovas-att-row'); |
| 273 | const chips = el('div', 'ovas-att-row'); |
| 274 | for (const a of list) { |
| 275 | const b = el('button', a.kind === 'image' ? 'ovas-att-img' : 'ovas-att-chip'); |
| 276 | b.type = 'button'; |
| 277 | b.dataset.kind = a.kind; |
| 278 | b.dataset.role = a.role; |
| 279 | const role = ATT_ROLE_LABEL[a.role] || 'produced'; |
| 280 | b.title = role + ': ' + a.name; |
| 281 | b.setAttribute('aria-label', 'Open ' + a.name + ' (' + role + ')'); |
| 282 | if (a.kind === 'image') { |
| 283 | const img = el('img'); |
| 284 | img.loading = 'lazy'; img.decoding = 'async'; img.alt = a.name; img.src = a.url; |
| 285 | img.addEventListener('error', () => { img.remove(); b.hidden = true; }); |
| 286 | b.appendChild(img); |
| 287 | imgs.appendChild(b); |
| 288 | } else { |
| 289 | b.insertAdjacentHTML('beforeend', svg(ATT_ICON[a.kind] || 'file')); |
| 290 | chips.appendChild(b); |
| 291 | } |
| 292 | b.appendChild(el('span', 'ovas-att-cap', a.name)); |
| 293 | b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onOpen(a); }); |
| 294 | } |
| 295 | if (imgs.childNodes.length) strip.appendChild(imgs); |
| 296 | if (chips.childNodes.length) strip.appendChild(chips); |
| 297 | return strip; |
| 298 | } |
| 299 | |
| 300 | /** A text file as the preview shows it: a CSV as a table, JSON pretty-printed, Markdown read, code highlighted. */ |
| 301 | function attText(name, text, linkOk) { |
| 302 | const ext = attExt(name); |
| 303 | let body = String(text == null ? '' : text); |
| 304 | if (ext === 'json' || ext === 'jsonl') { try { body = JSON.stringify(JSON.parse(body), null, 2); } catch { /* not one document */ } } |
| 305 | if (ext === 'csv' || ext === 'tsv') { const table = attCsv(body, ext === 'tsv' ? '\t' : ','); if (table) return table; } |
| 306 | if (ext === 'md' || ext === 'markdown') { |
| 307 | const frag = markdown(body, linkOk); |
| 308 | frag.querySelectorAll('pre code').forEach((code) => { const hi = attHighlight(code.textContent, ''); code.textContent = ''; code.appendChild(hi); }); |
| 309 | return frag; |
| 310 | } |
| 311 | const pre = el('pre', 'ovas-prev-code'); |
| 312 | const code = el('code'); |
| 313 | code.appendChild(attHighlight(body, ext === 'json' ? 'json' : '')); |
| 314 | pre.appendChild(code); |
| 315 | return pre; |
| 316 | } |
| 317 | /** Pinch to zoom on a phone, double tap to double, so a screenshot can be read. */ |
| 318 | function attZoom(img) { |
| 319 | let scale = 1, start = null; |
| 320 | const apply = () => { img.style.transform = scale > 1 ? 'scale(' + scale + ')' : ''; }; |
| 321 | img.addEventListener('touchstart', (e) => { |
| 322 | if (e.touches.length === 2) { const a = e.touches[0], b = e.touches[1]; start = { d: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY), s: scale }; } |
| 323 | }, { passive: true }); |
| 324 | img.addEventListener('touchmove', (e) => { |
| 325 | if (!start || e.touches.length !== 2) return; |
| 326 | e.preventDefault(); |
| 327 | const a = e.touches[0], b = e.touches[1]; |
| 328 | const d = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); |
| 329 | scale = Math.min(4, Math.max(1, start.s * (d / (start.d || 1)))); apply(); |
| 330 | }, { passive: false }); |
| 331 | img.addEventListener('touchend', () => { start = null; }); |
| 332 | img.addEventListener('dblclick', () => { scale = scale > 1 ? 1 : 2; apply(); }); |
| 333 | img.addEventListener('wheel', (e) => { if (!e.ctrlKey) return; e.preventDefault(); scale = Math.min(4, Math.max(1, scale - e.deltaY / 200)); apply(); }, { passive: false }); |
| 334 | } |
| 335 | /** A small highlighter for the preview: strings, numbers, keywords and comments, as spans (never markup). */ |
| 336 | function attHighlight(text, lang) { |
| 337 | const frag = document.createDocumentFragment(); |
| 338 | const json = lang === 'json'; |
| 339 | const re = json |
| 340 | ? /("(?:[^"\\]|\\.)*")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g |
| 341 | : /(\/\/[^\n]*|#[^\n]*|--[^\n]*)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|\b(const|let|var|function|return|if|else|for|while|do|import|export|from|default|class|extends|new|await|async|try|catch|finally|throw|def|then|null|true|false|None|True|False)\b|(\b\d+(?:\.\d+)?\b)/g; |
| 342 | const push = (s, cls) => { if (!s) return; frag.appendChild(cls ? el('span', cls, s) : document.createTextNode(s)); }; |
| 343 | let at = 0, m; |
| 344 | while ((m = re.exec(text))) { |
| 345 | push(text.slice(at, m.index)); |
| 346 | if (json) { |
| 347 | if (m[1] != null) { push(m[1], m[2] != null ? 'ovas-tok-key' : 'ovas-tok-str'); if (m[2] != null) push(m[2], ''); } |
| 348 | else if (m[3] != null) push(m[3], 'ovas-tok-kw'); |
| 349 | else push(m[4], 'ovas-tok-num'); |
| 350 | } else if (m[1] != null) push(m[1], 'ovas-tok-com'); |
| 351 | else if (m[2] != null) push(m[2], 'ovas-tok-str'); |
| 352 | else if (m[3] != null) push(m[3], 'ovas-tok-kw'); |
| 353 | else push(m[4], 'ovas-tok-num'); |
| 354 | at = m.index + m[0].length; |
| 355 | } |
| 356 | push(text.slice(at)); |
| 357 | return frag; |
| 358 | } |
| 359 | /** A CSV as a table (quoted cells kept whole). */ |
| 360 | function attCsv(text, delim) { |
| 361 | const rows = String(text || '').split(/\r?\n/).filter((l) => l.trim() !== ''); |
| 362 | if (!rows.length) return null; |
| 363 | const split = (line) => { |
| 364 | const out = []; let cur = '', q = false; |
| 365 | for (let i = 0; i < line.length; i++) { |
| 366 | const ch = line[i]; |
| 367 | if (q) { if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else q = false; } else cur += ch; } |
| 368 | else if (ch === '"') q = true; |
| 369 | else if (ch === delim) { out.push(cur.trim()); cur = ''; } |
| 370 | else cur += ch; |
| 371 | } |
| 372 | out.push(cur.trim()); |
| 373 | return out; |
| 374 | }; |
| 375 | const head = split(rows[0]); |
| 376 | const table = el('table', 'ovas-csv'); |
| 377 | const thead = el('thead'), tr = el('tr'); |
| 378 | head.forEach((h) => tr.appendChild(el('th', '', h || ' '))); |
| 379 | thead.appendChild(tr); table.appendChild(thead); |
| 380 | const tb = el('tbody'); |
| 381 | for (const line of rows.slice(1, 200)) { |
| 382 | const r = el('tr'), cells = split(line); |
| 383 | for (let i = 0; i < head.length; i++) r.appendChild(el('td', '', cells[i] == null ? '' : cells[i])); |
| 384 | tb.appendChild(r); |
| 385 | } |
| 386 | table.appendChild(tb); |
| 387 | return table; |
| 388 | } |
| 389 | |
| 390 | /** Light Markdown, text-safe: paragraphs, bullet and numbered lists, `code`, ```blocks```, **bold**, https links (only |
| 391 | * those `linkOk(url)` allows; the others stay text). */ |
| 392 | function markdown(text, linkOk) { |
| 393 | const frag = document.createDocumentFragment(); |
| 394 | const inline = (s, into) => { |
| 395 | const re = /(`[^`]+`|\*\*[^*]+\*\*|https:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/g; |
| 396 | let at = 0, m; |
| 397 | while ((m = re.exec(s))) { |
| 398 | if (m.index > at) into.appendChild(document.createTextNode(s.slice(at, m.index))); |
| 399 | const tok = m[0]; |
| 400 | if (tok[0] === '`') into.appendChild(el('code', '', tok.slice(1, -1))); |
| 401 | else if (tok.startsWith('**')) into.appendChild(el('strong', '', tok.slice(2, -2))); |
| 402 | else if (linkOk && !linkOk(tok)) into.appendChild(document.createTextNode(tok)); |
| 403 | else { const a = el('a', '', tok); a.href = tok; a.target = '_blank'; a.rel = 'noopener noreferrer'; into.appendChild(a); } |
| 404 | at = m.index + tok.length; |
| 405 | } |
| 406 | if (at < s.length) into.appendChild(document.createTextNode(s.slice(at))); |
| 407 | }; |
| 408 | const blocks = String(text || '').replace(/\r/g, '').split(/\n```/); |
| 409 | String(text || '').replace(/\r/g, '').split(/(```[\s\S]*?(?:```|$))/).forEach((part) => { |
| 410 | if (!part) return; |
| 411 | if (part.startsWith('```')) { const pre = el('pre', 'ovas-code'); pre.appendChild(el('code', '', part.replace(/^```[^\n]*\n?/, '').replace(/```$/, ''))); frag.appendChild(pre); return; } |
| 412 | part.split(/\n{2,}/).forEach((para) => { |
| 413 | const lines = para.split('\n').filter((l) => l.trim()); |
| 414 | if (!lines.length) return; |
| 415 | if (lines.every((l) => /^\s*([-*•]|\d+[.)])\s+/.test(l))) { |
| 416 | const ol = /^\s*\d/.test(lines[0]); |
| 417 | const list = el(ol ? 'ol' : 'ul'); |
| 418 | lines.forEach((l) => { const li = el('li'); inline(l.replace(/^\s*([-*•]|\d+[.)])\s+/, ''), li); list.appendChild(li); }); |
| 419 | frag.appendChild(list); |
| 420 | } else { const p = el('p'); lines.forEach((l, i) => { if (i) p.appendChild(el('br')); inline(l.replace(/^#{1,6}\s+/, ''), p); }); frag.appendChild(p); } |
| 421 | }); |
| 422 | }); |
| 423 | void blocks; |
| 424 | return frag; |
| 425 | } |
| 426 | |
| 427 | /** A line diff of two texts (LCS for small inputs, both sides whole beyond): [['=', line] | ['-', line] | ['+', line]]. */ |
| 428 | function diffLines(a, b) { |
| 429 | const x = String(a || '').split('\n'), y = String(b || '').split('\n'); |
| 430 | if (x.length * y.length > 90000) return x.map((l) => ['-', l]).concat(y.map((l) => ['+', l])); |
| 431 | const n = x.length, m = y.length, L = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1)); |
| 432 | for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) L[i][j] = x[i] === y[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]); |
| 433 | const out = []; let i = 0, j = 0; |
| 434 | while (i < n && j < m) { if (x[i] === y[j]) { out.push(['=', x[i]]); i++; j++; } else if (L[i + 1][j] >= L[i][j + 1]) out.push(['-', x[i++]]); else out.push(['+', y[j++]]); } |
| 435 | while (i < n) out.push(['-', x[i++]]); |
| 436 | while (j < m) out.push(['+', y[j++]]); |
| 437 | return out; |
| 438 | } |
| 439 | |
| 440 | /** What a step shows on its row and inside: title, the thing it touched, a diff or content, the counts. */ |
| 441 | function describe(step) { |
| 442 | const i = step.input || {}; |
| 443 | const k = step.kind; |
| 444 | const out = { title: '', sub: '', command: '', diff: null, preview: '', added: 0, removed: 0 }; |
| 445 | if (k === 'command') { out.command = String(i.command || i.cmd || step.what || ''); out.title = String(i.description || '') || out.command.split('\n')[0]; out.sub = i.description ? out.command.split('\n')[0] : ''; } |
| 446 | else if (k === 'read') { const p = i.file_path || i.path || step.what; out.title = `Read ${base(p)}`; out.sub = String(p || ''); } |
| 447 | else if (k === 'search') { out.title = i.pattern ? `Searched for ${i.pattern}` : `Listed ${base(i.path || step.what) || 'files'}`; out.sub = String(i.path || i.glob || step.what || ''); } |
| 448 | else if (k === 'edit' || k === 'write') { |
| 449 | const p = i.file_path || i.path || i.notebook_path || step.what; |
| 450 | out.title = `${k === 'write' ? 'Wrote' : 'Edited'} ${base(p) || 'a file'}`; out.sub = String(p || ''); |
| 451 | const edits = Array.isArray(i.edits) ? i.edits : (i.old_string != null || i.new_string != null) ? [{ old_string: i.old_string, new_string: i.new_string }] : []; |
| 452 | if (edits.length) { out.diff = []; edits.forEach((e, n) => { if (n) out.diff.push(['@', '…']); out.diff.push(...diffLines(e.old_string, e.new_string)); }); } |
| 453 | else if (typeof i.content === 'string') { out.preview = i.content; out.diff = i.content.split('\n').map((l) => ['+', l]); } |
| 454 | if (out.diff) out.diff.forEach(([op]) => { if (op === '+') out.added++; else if (op === '-') out.removed++; }); |
| 455 | } else if (k === 'web') { |
| 456 | const asked = i.query || (!i.url && !/^https?:\/\//.test(String(i.description || step.what || '')) ? i.description || step.what : ''); |
| 457 | const url = i.url || (/^https?:\/\//.test(String(i.description || step.what || '')) ? i.description || step.what : ''); |
| 458 | out.title = asked ? `Looked up ${asked}` : `Opened ${String(url).replace(/^https?:\/\//, '').slice(0, 60)}`; out.sub = asked ? '' : String(url); |
| 459 | } |
| 460 | else if (k === 'agent') { out.title = String(i.description || 'A helper agent'); out.sub = i.subagent_type ? `${i.subagent_type} helper` : 'helper'; out.preview = String(i.prompt || ''); } |
| 461 | else if (k === 'plan') { out.title = 'Updated the plan'; } |
| 462 | else { out.title = String(step.name || 'Tool'); out.sub = String(step.what || ''); } |
| 463 | if (!out.title) out.title = String(step.what || step.name || 'Step'); |
| 464 | if (step.title) out.title = String(step.title); // the agent's own words for it (AG-UI metadata.ov.title) |
| 465 | return out; |
| 466 | } |
| 467 | |
| 468 | // ── Styles: agent-session.css, from beside this script (theme tokens; light and dark) ───────────────── |
| 469 | const SELF_SRC = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || ''; |
| 470 | function styles() { |
| 471 | if (typeof document === 'undefined' || document.getElementById('openvibe-agent-session-styles') || document.querySelector('link[href*="agent-session.css"]')) return; |
| 472 | const m = /^(https?:\/\/[^?#]*\/)agent-session\.js(?:[?#]|$)/.exec(SELF_SRC); |
| 473 | const link = document.createElement('link'); |
| 474 | link.id = 'openvibe-agent-session-styles'; |
| 475 | link.rel = 'stylesheet'; |
| 476 | link.href = `${m ? m[1] : 'https://openvibe.network/shared/'}agent-session.css`; |
| 477 | document.head.appendChild(link); |
| 478 | } |
| 479 | |
| 480 | // ── The view ────────────────────────────────────────────── |
| 481 | function mount(host, opts = {}) { |
| 482 | styles(); |
| 483 | const linkOk = typeof opts.link === 'function' ? (u) => { try { return !!opts.link(u); } catch { return false; } } : null; |
| 484 | // Attachments load from the product's own origin or Media's; a product that serves files from another host |
| 485 | // says so: mount(el, { origins: ['files.openvibe.codes'] }). |
| 486 | const origins = Array.isArray(opts.origins) ? opts.origins : (opts.origins ? [opts.origins] : []); |
| 487 | const safeAtts = (list) => normAttachments(list).filter((a) => attAllowed(a.url, origins)); |
| 488 | const LIVE = ['running', 'placed', 'queued', 'verifying']; |
| 489 | // The clock runs from startedAt, or from now for a run that is live as it mounts; a finished run replayed without |
| 490 | // a start time shows the time only once its done line says it. |
| 491 | const clock = Boolean(opts.startedAt) || LIVE.includes(opts.status || 'running'); |
| 492 | const S = { phases: [], thinks: new Map(), msgs: new Map(), sources: new Map(), activity: '', ownPlan: false, status: opts.status || 'running', label: opts.label || '', startedAt: opts.startedAt || Date.now(), endedAt: null, agents: [], steps: new Map(), plan: null, tokens: { in: 0, out: 0 }, cost: 0, agentCost: new Map(), durationMs: 0, problems: 0, filter: 'all', follow: opts.follow !== false, lastGroup: null }; |
| 493 | host.textContent = ''; |
| 494 | const box = el('div', 'ovas'); |
| 495 | box.innerHTML = `<div class="ovas-head"><div class="ovas-top"><span class="ovas-pill" data-s="${esc(S.status)}"></span><span class="ovas-title"></span></div><div class="ovas-meta"><span class="ovas-agents"></span><span data-m="now" hidden></span><span data-m="time"></span><span data-m="steps"></span><span data-m="files"></span><span data-m="cost"></span></div><details class="ovas-plan" hidden><summary><span class="ovas-plan-now"></span><span class="ovas-plan-n"></span></summary><div class="ovas-plan-bar"><i style="width:0"></i></div><ol></ol></details></div>` |
| 496 | + '<div class="ovas-bar" role="toolbar" aria-label="Show"></div><ol class="ovas-files" hidden></ol><ol class="ovas-feed" aria-live="polite"></ol><div class="ovas-actions" hidden></div>'; |
| 497 | host.appendChild(box); |
| 498 | const head = box.querySelector('.ovas-head'), feed = box.querySelector('.ovas-feed'), bar = box.querySelector('.ovas-bar'); |
| 499 | const filesEl = box.querySelector('.ovas-files'), actBar = box.querySelector('.ovas-actions'); |
| 500 | // The sources strip (protocol 1.2) under the feed; card mode (opts.mode 'card': a chat message, the Dock, a feed card) |
| 501 | // keeps the head, the plan row, the newest three lines and the sources, with a way to the whole run (opts.href). |
| 502 | const srcBox = el('details', 'ovas-sources'); srcBox.hidden = true; |
| 503 | srcBox.innerHTML = '<summary><span class="ovas-src-tiles"></span><span class="ovas-src-n"></span></summary><ol></ol>'; |
| 504 | box.insertBefore(srcBox, actBar); |
| 505 | if (opts.mode === 'card') { |
| 506 | box.classList.add('ovas--card'); |
| 507 | if (typeof opts.href === 'string' && /^(https:\/\/|\/)/.test(opts.href)) { const more = el('a', 'ovas-open', 'Open the whole run'); more.href = opts.href; box.insertBefore(more, actBar); } |
| 508 | } |
| 509 | head.querySelector('.ovas-title').textContent = opts.title || 'Agent session'; |
| 510 | const FILTERS = [['all', 'Everything'], ['said', 'What it said'], ['steps', 'Steps'], ['files', 'Files'], ['problems', 'Problems']]; |
| 511 | FILTERS.forEach(([id, label]) => { const b = el('button', 'ovas-chip', label); b.type = 'button'; b.dataset.f = id; b.setAttribute('aria-pressed', String(id === 'all')); bar.appendChild(b); }); |
| 512 | const follow = el('button', 'ovas-chip ovas-follow', 'Follow'); follow.type = 'button'; follow.setAttribute('aria-pressed', String(S.follow)); follow.title = 'Keep the newest step in view'; bar.appendChild(follow); |
| 513 | bar.addEventListener('click', (e) => { |
| 514 | const b = e.target.closest('button'); if (!b) return; |
| 515 | if (b === follow) { S.follow = !S.follow; follow.setAttribute('aria-pressed', String(S.follow)); return; } |
| 516 | S.filter = b.dataset.f; bar.querySelectorAll('[data-f]').forEach((x) => x.setAttribute('aria-pressed', String(x === b))); applyFilter(); |
| 517 | }); |
| 518 | box.addEventListener('click', (e) => { |
| 519 | const c = e.target.closest('.ovas-copy'); if (!c) return; |
| 520 | e.preventDefault(); e.stopPropagation(); |
| 521 | const pre = c.parentElement && c.parentElement.nextElementSibling; |
| 522 | const text = pre ? pre.textContent : ''; |
| 523 | try { navigator.clipboard.writeText(text); c.lastChild.textContent = 'Copied'; setTimeout(() => { c.lastChild.textContent = 'Copy'; }, 1400); } catch { /* no clipboard */ } |
| 524 | }); |
| 525 | const empty = el('li', 'ovas-empty', opts.empty || 'Waiting for the agent to start…'); feed.appendChild(empty); |
| 526 | // The run is still working: three dots after the newest line (gone when it ends). |
| 527 | const working = el('div', 'ovas-working'); working.setAttribute('aria-hidden', 'true'); working.innerHTML = '<i></i><i></i><i></i>'; |
| 528 | working.hidden = !LIVE.includes(S.status); box.insertBefore(working, box.querySelector('.ovas-actions')); |
| 529 | |
| 530 | // ── The preview sheet: one attachment, opened by a tap ────────────────────────── |
| 531 | const sheet = el('div', 'ovas-sheet'); |
| 532 | sheet.hidden = true; |
| 533 | sheet.innerHTML = '<div class="ovas-sheet-panel" role="dialog" aria-modal="true" aria-label="Preview"><div class="ovas-sheet-bar"><b class="ovas-sheet-name"></b><button type="button" class="ovas-sheet-x" aria-label="Close the preview">✕</button></div><div class="ovas-sheet-body"></div><div class="ovas-sheet-foot"><a class="ovas-sheet-open" target="_blank" rel="noopener noreferrer">Open</a><a class="ovas-sheet-dl" download>Download</a></div></div>'; |
| 534 | host.appendChild(sheet); |
| 535 | let sheetBack = null; |
| 536 | function closeSheet() { |
| 537 | if (sheet.hidden) return; |
| 538 | sheet.hidden = true; |
| 539 | sheet.querySelector('.ovas-sheet-body').textContent = ''; |
| 540 | if (sheetBack && sheetBack.focus) sheetBack.focus(); |
| 541 | sheetBack = null; |
| 542 | } |
| 543 | function openSheet(a) { |
| 544 | const body = sheet.querySelector('.ovas-sheet-body'); |
| 545 | body.textContent = ''; |
| 546 | sheet.querySelector('.ovas-sheet-name').textContent = a.name; |
| 547 | const open = sheet.querySelector('.ovas-sheet-open'); open.href = a.url; open.setAttribute('aria-label', 'Open ' + a.name); |
| 548 | const dl = sheet.querySelector('.ovas-sheet-dl'); dl.href = a.url; dl.setAttribute('download', a.name); |
| 549 | const wrap = el('div', 'ovas-prev'); |
| 550 | if (a.kind === 'image') { |
| 551 | const img = el('img', 'ovas-prev-img'); img.alt = a.name; img.src = a.url; wrap.appendChild(img); attZoom(img); |
| 552 | } else if (a.kind === 'video') { |
| 553 | const v = el('video', 'ovas-prev-media'); v.controls = true; v.playsInline = true; v.preload = 'metadata'; v.src = a.url; wrap.appendChild(v); |
| 554 | } else if (a.kind === 'audio') { |
| 555 | const au = el('audio', 'ovas-prev-audio'); au.controls = true; au.preload = 'metadata'; au.src = a.url; wrap.appendChild(au); |
| 556 | } else if (a.kind === 'pdf') { |
| 557 | const f = el('iframe', 'ovas-prev-pdf'); f.src = a.url; f.title = a.name; wrap.appendChild(f); |
| 558 | } else if (a.kind === 'text') { |
| 559 | const holder = el('div', 'ovas-prev-text'); |
| 560 | holder.appendChild(el('p', 'ovas-prev-note', 'Loading…')); |
| 561 | wrap.appendChild(holder); |
| 562 | fetch(a.url, { credentials: 'same-origin' }) |
| 563 | .then((r) => (r.ok ? r.text() : Promise.reject(new Error('HTTP ' + r.status)))) |
| 564 | .then((t) => { holder.textContent = ''; holder.appendChild(attText(a.name, t, linkOk)); }) |
| 565 | .catch((err) => { holder.textContent = 'Could not open it: ' + err.message; }); |
| 566 | } else { |
| 567 | wrap.appendChild(el('p', 'ovas-prev-note', 'There is no preview for this file. Open it or download it.')); |
| 568 | } |
| 569 | body.appendChild(wrap); |
| 570 | sheetBack = (typeof document !== 'undefined' && document.activeElement) || null; |
| 571 | sheet.hidden = false; |
| 572 | const x = sheet.querySelector('.ovas-sheet-x'); if (x && x.focus) x.focus(); |
| 573 | } |
| 574 | sheet.querySelector('.ovas-sheet-x').addEventListener('click', closeSheet); |
| 575 | sheet.addEventListener('click', (e) => { if (e.target === sheet) closeSheet(); }); |
| 576 | document.addEventListener('keydown', (e) => { |
| 577 | if (sheet.hidden) return; |
| 578 | if (e.key === 'Escape') { e.preventDefault(); closeSheet(); return; } |
| 579 | if (e.key !== 'Tab') return; |
| 580 | const f = [...sheet.querySelectorAll('button, a[href], iframe')].filter((n) => !n.disabled && n.offsetParent !== null); |
| 581 | if (!f.length) return; |
| 582 | const first = f[0], last = f[f.length - 1]; |
| 583 | if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } |
| 584 | else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } |
| 585 | }); |
| 586 | |
| 587 | function applyFilter() { |
| 588 | const files = S.filter === 'files'; |
| 589 | feed.hidden = files; filesEl.hidden = !files; |
| 590 | if (files) { paintFiles(); return; } |
| 591 | feed.querySelectorAll(':scope > li').forEach((li) => { |
| 592 | const k = li.dataset.k || ''; |
| 593 | li.hidden = S.filter === 'all' ? false : S.filter === 'said' ? !(k === 'msg' || k === 'done' || k === 'handoff') : S.filter === 'steps' ? !(k === 'steps' || k === 'handoff') : !(li.dataset.p === '1'); |
| 594 | }); |
| 595 | } |
| 596 | // The Files tab: one row per file, newest change last; a tap shows everything and opens that file's last step. |
| 597 | function changedFiles() { |
| 598 | const by = new Map(); |
| 599 | for (const st of S.steps.values()) { |
| 600 | if (st.hidden || (st.kind !== 'edit' && st.kind !== 'write') || !st.info) continue; |
| 601 | const p = st.info.sub || st.info.title; |
| 602 | const f = by.get(p) || { path: p, added: 0, removed: 0, n: 0, last: null, wrote: false }; |
| 603 | f.added += st.info.added || 0; f.removed += st.info.removed || 0; f.n++; f.last = st; f.wrote = f.wrote || st.kind === 'write'; |
| 604 | by.set(p, f); |
| 605 | } |
| 606 | return [...by.values()]; |
| 607 | } |
| 608 | function paintFiles() { |
| 609 | filesEl.textContent = ''; |
| 610 | const list = changedFiles(); |
| 611 | if (!list.length) { filesEl.appendChild(el('li', 'ovas-empty', 'No files changed yet.')); return; } |
| 612 | for (const f of list) { |
| 613 | const li = el('li'); |
| 614 | const b = el('button', 'ovas-file'); b.type = 'button'; b.dataset.step = f.last.id; |
| 615 | b.innerHTML = `<span class="ovas-kind" data-k="${f.wrote ? 'write' : 'edit'}">${svg(f.wrote ? 'write' : 'edit')}</span><span class="ovas-sum"><b></b><small></small></span><span class="ovas-delta"><span class="a">+${f.added}</span><span class="r">−${f.removed}</span></span><span class="ovas-file-n"></span>`; |
| 616 | b.querySelector('b').textContent = base(f.path) || f.path; |
| 617 | b.querySelector('small').textContent = f.path; |
| 618 | b.querySelector('.ovas-file-n').textContent = f.n > 1 ? `${f.n} changes` : ''; |
| 619 | li.appendChild(b); filesEl.appendChild(li); |
| 620 | } |
| 621 | } |
| 622 | filesEl.addEventListener('click', (e) => { |
| 623 | const b = e.target.closest('.ovas-file'); if (!b) return; |
| 624 | const st = S.steps.get(b.dataset.step); if (!st || !st.el) return; |
| 625 | S.filter = 'all'; bar.querySelectorAll('[data-f]').forEach((x) => x.setAttribute('aria-pressed', String(x.dataset.f === 'all'))); applyFilter(); |
| 626 | for (let p = st.el; p; p = p.parentElement) if (p.tagName === 'DETAILS') p.open = true; |
| 627 | if (st.el.scrollIntoView) st.el.scrollIntoView({ block: 'center', behavior: 'smooth' }); |
| 628 | }); |
| 629 | |
| 630 | // The action bar: what the person can do now (the page decides; this only draws and reports the press). |
| 631 | function paintActions(list, note) { |
| 632 | actBar.textContent = ''; |
| 633 | const acts = (Array.isArray(list) ? list : []).filter((a) => a && a.id && a.label).slice(0, 4); |
| 634 | actBar.hidden = !acts.length; |
| 635 | if (!acts.length) return; |
| 636 | if (note) actBar.appendChild(el('p', '', note)); |
| 637 | for (const a of acts) { const b = el('button', `ovas-act${a.kind === 'primary' ? ' is-primary' : a.kind === 'danger' ? ' is-danger' : ''}`, a.label); b.type = 'button'; b.dataset.act = a.id; actBar.appendChild(b); } |
| 638 | } |
| 639 | actBar.addEventListener('click', async (e) => { |
| 640 | const b = e.target.closest('.ovas-act'); if (!b || typeof opts.onAction !== 'function') return; |
| 641 | const all = [...actBar.querySelectorAll('.ovas-act')]; all.forEach((x) => { x.disabled = true; }); |
| 642 | try { await opts.onAction(b.dataset.act); } catch { /* the page says what went wrong */ } finally { all.forEach((x) => { x.disabled = false; }); } |
| 643 | }); |
| 644 | if (Array.isArray(opts.actions)) paintActions(opts.actions, opts.actionsNote); |
| 645 | |
| 646 | const keep = () => { if (S.follow && S.status === 'running') { const last = feed.lastElementChild; if (last && last.scrollIntoView) last.scrollIntoView({ block: 'end', behavior: 'smooth' }); } }; |
| 647 | |
| 648 | // A step's element: <details> with the summary row and, once opened, its detail. |
| 649 | function stepEl(step) { |
| 650 | const d = el('details', 'ovas-step'); |
| 651 | d.dataset.id = step.id; |
| 652 | const sum = el('summary'); |
| 653 | const info = describe(step); step.info = info; |
| 654 | sum.innerHTML = `${svg('chevron', 'ovas-chev')}<span class="ovas-kind" data-k="${esc(step.kind)}">${svg(step.kind)}</span><span class="ovas-sum"><b></b><small></small></span>${info.added || info.removed ? `<span class="ovas-delta"><span class="a">+${info.added}</span><span class="r">−${info.removed}</span></span>` : ''}<span class="ovas-state" data-s="run"><span class="ovas-spin" aria-label="Running"></span><span data-t></span></span><span class="ovas-att-slot"></span>`; |
| 655 | sum.querySelector('b').textContent = info.title; |
| 656 | sum.querySelector('small').textContent = info.sub; |
| 657 | if (!info.sub) sum.querySelector('small').hidden = true; |
| 658 | d.appendChild(sum); |
| 659 | const detail = el('div', 'ovas-detail'); d.appendChild(detail); |
| 660 | const section = (label, node, copy) => { const l = el('div', 'ovas-label'); l.appendChild(el('span', '', label)); if (copy) { const c = el('button', 'ovas-copy'); c.type = 'button'; c.innerHTML = `${svg('copy')}<span>Copy</span>`; l.appendChild(c); } detail.append(l, node); }; |
| 661 | if (step.kind === 'command' && info.command) { const pre = el('pre', 'ovas-code'); pre.textContent = info.command; section('Command', pre, true); } |
| 662 | if (info.diff) { const pre = el('pre', 'ovas-diff'); info.diff.slice(0, 600).forEach(([op, line]) => { const s = el('span', op === '+' ? 'a' : op === '-' ? 'r' : op === '@' ? 'h' : '', `${op === '=' ? ' ' : op === '@' ? '' : op} ${line}`); pre.appendChild(s); }); section(step.kind === 'write' ? 'New file' : 'Change', pre, false); } |
| 663 | if (step.kind === 'agent' && info.preview) { const p = el('div', 'ovas-msg'); p.appendChild(markdown(info.preview.slice(0, 1600), linkOk)); section('Asked to', p, false); } |
| 664 | if (step.kind !== 'plan' && step.input && typeof step.input === 'object' && Object.keys(step.input).length) { |
| 665 | const raw = el('details', 'ovas-raw'); |
| 666 | raw.innerHTML = `<summary>${svg('chevron', 'ovas-chev')}<span>Input</span></summary>`; |
| 667 | const l = el('div', 'ovas-label'); l.appendChild(el('span', '', 'What the agent sent')); const c = el('button', 'ovas-copy'); c.type = 'button'; c.innerHTML = `${svg('copy')}<span>Copy</span>`; l.appendChild(c); |
| 668 | let text = ''; try { text = JSON.stringify(step.input, null, 2); } catch { text = String(step.input); } |
| 669 | const pre = el('pre', 'ovas-code'); pre.textContent = text.length > 8000 ? `${text.slice(0, 8000)}\n…` : text; |
| 670 | raw.append(l, pre); detail.appendChild(raw); |
| 671 | } |
| 672 | if (step.kind === 'agent') { const sub = el('div', 'ovas-sub'); step.subEl = sub; d.appendChild(sub); d.open = true; } |
| 673 | if (step.kind !== 'agent' && step.kind !== 'plan') { |
| 674 | const pre = el('pre', 'ovas-out'); pre.hidden = true; step.outEl = pre; |
| 675 | const wait = el('div', 'ovas-wait'); wait.innerHTML = '<span class="ovas-spin"></span><span>Running — the output shows here as it arrives</span>'; step.waitEl = wait; |
| 676 | const l = el('div', 'ovas-label'); l.appendChild(el('span', '', 'Output')); const c = el('button', 'ovas-copy'); c.type = 'button'; c.innerHTML = `${svg('copy')}<span>Copy</span>`; l.appendChild(c); step.outLabel = l; |
| 677 | detail.append(l, pre, wait); |
| 678 | } |
| 679 | step.el = d; step.stateEl = sum.querySelector('.ovas-state'); |
| 680 | return d; |
| 681 | } |
| 682 | function setStepState(step) { |
| 683 | const st = step.stateEl; if (!st) return; |
| 684 | const t = step.ended ? dur(step.ended - step.started) : dur(Date.now() - step.started); |
| 685 | if (step.done) { |
| 686 | st.dataset.s = step.ok ? 'ok' : 'fail'; |
| 687 | const c = step.cost > 0 ? `$${step.cost < 0.01 ? step.cost.toFixed(4).replace(/0+$/, '') : step.cost.toFixed(2)}` : ''; |
| 688 | st.innerHTML = `${c ? `<span class="ovas-cost">${esc(c)}</span>` : ''}${svg(step.ok ? 'ok' : 'fail')}<span>${esc(step.ended - step.started > 1500 ? t : '')}</span>`; |
| 689 | } else { const tt = st.querySelector('[data-t]'); if (tt && Date.now() - step.started > 2000) tt.textContent = t; } |
| 690 | } |
| 691 | // The group a new step goes in: the last item of its feed when that is a group of steps, else a new one. |
| 692 | function groupFor(parentFeed) { |
| 693 | const last = parentFeed.lastElementChild; |
| 694 | if (last && last.classList.contains('ovas-group-li')) return last; |
| 695 | const li = el('li', 'ovas-group-li'); li.dataset.k = 'steps'; |
| 696 | const g = el('details', 'ovas-group'); |
| 697 | g.innerHTML = `<summary>${svg('chevron', 'ovas-chev')}<span class="ovas-icons"></span><span class="ovas-sum"><b></b><small></small></span><span class="ovas-delta" hidden><span class="a"></span><span class="r"></span></span><span class="ovas-state"></span><span class="ovas-att-slot"></span></summary><ol></ol>`; |
| 698 | li.appendChild(g); parentFeed.appendChild(li); |
| 699 | li.steps = []; |
| 700 | return li; |
| 701 | } |
| 702 | function paintGroup(li) { |
| 703 | const steps = li.steps, g = li.firstChild, count = {}; |
| 704 | steps.forEach((s) => { count[s.kind] = (count[s.kind] || 0) + 1; }); |
| 705 | const order = ['command', 'edit', 'write', 'read', 'search', 'web', 'agent', 'plan', 'tool']; |
| 706 | const kinds = order.filter((k) => count[k]); |
| 707 | g.querySelector('.ovas-icons').innerHTML = kinds.slice(0, 3).map((k) => `<span class="ovas-kind" data-k="${k}">${svg(k)}</span>`).join(''); |
| 708 | // A few steps read as what was done ("Ran 2 commands"); more as counts ("2 commands · 1 edit · 3 reads"). |
| 709 | // Steps the agent named itself (AG-UI metadata.ov.title) read as its words: the newest title and how many there are. |
| 710 | const titled = steps.filter((s2) => s2.title); |
| 711 | // Rows under one shared title say what each one did instead of the title again: its address, query or file. |
| 712 | const sharedTitle = titled.length > 1 && titled.every((s2) => s2.title === titled[0].title); |
| 713 | for (const s2 of steps) { |
| 714 | const b2 = s2.el && s2.el.querySelector(':scope>summary .ovas-sum b'); |
| 715 | if (!b2 || !s2.info) continue; |
| 716 | // An address reads as its page and its site ("OpenVibe.Codes · github.com"), anything else as it is. |
| 717 | let own = String(s2.info.sub || ''); |
| 718 | try { if (/^https?:\/\//.test(own)) { const u = new URL(own); const leaf = decodeURIComponent(u.pathname.split('/').filter(Boolean).pop() || ''); own = leaf ? `${leaf} · ${u.hostname.replace(/^www\./, '')}` : u.hostname.replace(/^www\./, ''); } } catch { /* as it is */ } |
| 719 | b2.textContent = sharedTitle && own ? own : s2.info.title; |
| 720 | } |
| 721 | g.querySelector('.ovas-sum b').textContent = titled.length |
| 722 | ? `${titled[titled.length - 1].title}${steps.length > 1 ? ` · ${steps.length}` : ''}` |
| 723 | : kinds.length === 1 ? plural(kinds[0], count[kinds[0]]) : kinds.map((k) => `${count[k]} ${NOUN[k][count[k] === 1 ? 0 : 1]}`).join(' · '); |
| 724 | const running = steps.find((s) => !s.done) || steps[steps.length - 1]; |
| 725 | const small = g.querySelector('.ovas-sum small'); |
| 726 | small.textContent = running ? (running.info.sub || running.info.title) : ''; |
| 727 | const added = steps.reduce((n, s) => n + (s.info.added || 0), 0), removed = steps.reduce((n, s) => n + (s.info.removed || 0), 0); |
| 728 | const delta = g.querySelector('.ovas-delta'); delta.hidden = !(added || removed); delta.firstChild.textContent = `+${added}`; delta.lastChild.textContent = `−${removed}`; |
| 729 | const st = g.querySelector('.ovas-state'), fails = steps.filter((s) => s.done && !s.ok).length, live = steps.some((s) => !s.done); |
| 730 | st.dataset.s = live ? 'run' : fails ? 'fail' : 'ok'; |
| 731 | st.innerHTML = live ? '<span class="ovas-spin" aria-label="Running"></span>' : fails ? `${svg('fail')}<span>${fails} failed</span>` : svg('ok'); |
| 732 | if (fails) li.dataset.p = '1'; |
| 733 | // One step alone reads as itself, not as a group of one. |
| 734 | g.classList.toggle('is-one', steps.length === 1); |
| 735 | if (steps.length === 1) g.open = true; |
| 736 | // The group's attachments, in its header row, so they show while the group is folded. |
| 737 | const atts = [], seenAtt = new Set(); |
| 738 | for (const s of steps) for (const a of (s.attachments || [])) { if (!seenAtt.has(a.url)) { seenAtt.add(a.url); atts.push(a); } } |
| 739 | const slot = g.querySelector('.ovas-att-slot'); |
| 740 | slot.textContent = ''; |
| 741 | // A group of one hides its header, so there the step's own row carries the attachments instead. |
| 742 | if (atts.length && steps.length > 1) slot.appendChild(attStrip(atts, openSheet)); |
| 743 | // A group of one shows the step's own row; there the attachments belong to the step. |
| 744 | for (const s of steps) { |
| 745 | const ss = s.el && s.el.querySelector(':scope>summary .ovas-att-slot'); |
| 746 | if (!ss) continue; |
| 747 | ss.textContent = ''; |
| 748 | if (steps.length === 1 && s.attachments && s.attachments.length) ss.appendChild(attStrip(s.attachments, openSheet)); |
| 749 | } |
| 750 | } |
| 751 | function feedFor(parent) { const p = parent && S.steps.get(parent); return p && p.subEl ? p.subEl : feed; } |
| 752 | function appendLi(target, li) { if (target === feed) { if (empty.parentNode) empty.remove(); feed.appendChild(li); } else target.appendChild(li); } |
| 753 | |
| 754 | const pending = new Map(); // tool name → ids of its steps still waiting for a result (events without ids) |
| 755 | // Any line may carry attachments, and a line of a type this component does not know still shows: a minimal |
| 756 | // row with its label or summary, then its attachments (Codes' proof lines, for one). |
| 757 | const KNOWN = new Set(['agent', 'text', 'tool', 'tool_progress', 'tool_result', 'handoff', 'note', 'step', 'usage', 'done', 'state', |
| 758 | 'phase', 'phases', 'activity', 'thinking', 'thinking_delta', 'text_start', 'text_delta', 'tool_args', 'sources']); |
| 759 | function extraLine(x, target) { |
| 760 | const li = el('li', 'ovas-extra'); |
| 761 | li.dataset.k = 'steps'; // it is part of the work, so the Steps filter keeps it |
| 762 | const label = String(x.label || x.kind || ''); |
| 763 | const summary = String(x.summary || x.text || ''); |
| 764 | const text = label && summary && label !== summary ? label + ' — ' + summary : (summary || label); |
| 765 | if (text) li.appendChild(el('span', '', text)); |
| 766 | const atts = safeAtts(x.attachments); |
| 767 | if (atts.length) li.appendChild(attStrip(atts, openSheet)); |
| 768 | appendLi(target, li); |
| 769 | } |
| 770 | function onLine(x) { |
| 771 | const target = feedFor(x.parent); |
| 772 | if (!KNOWN.has(x.t)) { extraLine(x, target); return; } |
| 773 | // Who is working: a line that names a different agent than the last moves the head along. |
| 774 | // A done line reports an agent's cost and time; it never moves the head. |
| 775 | if (x.agent && x.t !== 'agent' && x.t !== 'handoff' && x.t !== 'done' && (!S.agents.length || S.agents[S.agents.length - 1].agent !== x.agent)) { S.agents.push({ agent: x.agent }); paintHead(); } |
| 776 | if (x.t === 'tool' && !x.id) { x = { ...x, id: `a${S.steps.size + 1}` }; if (!pending.has(x.name)) pending.set(x.name, []); pending.get(x.name).push(x.id); } |
| 777 | if (x.t === 'tool_result' && !x.id) { const q = pending.get(x.name); x = { ...x, id: q && q.length ? q.shift() : '' }; } |
| 778 | if (x.t === 'state') { status(x.state); return; } |
| 779 | if (x.t === 'agent') { |
| 780 | const last = S.agents[S.agents.length - 1]; |
| 781 | if (last && last.agent === x.agent) { if (x.model) last.model = x.model; } else S.agents.push({ agent: x.agent, model: x.model }); |
| 782 | paintHead(); return; |
| 783 | } |
| 784 | if (x.t === 'text') { |
| 785 | const li = el('li', 'ovas-msg'); li.dataset.k = 'msg'; li.appendChild(markdown(x.text, linkOk)); |
| 786 | const atts = safeAtts(x.attachments); |
| 787 | if (atts.length) li.appendChild(attStrip(atts, openSheet)); |
| 788 | appendLi(target, li); return; |
| 789 | } |
| 790 | if (x.t === 'tool') { |
| 791 | const kind = x.kind && VERB[x.kind] ? x.kind : kindOf(x.name); |
| 792 | const step = { id: x.id || `s${S.steps.size}`, name: x.name, kind, input: x.input || {}, what: x.what || '', title: x.title || '', started: Date.now(), done: false, ok: null, out: '', attachments: safeAtts(x.attachments) }; |
| 793 | if (kind === 'plan') { if (Array.isArray(step.input.todos)) { S.ownPlan = true; S.plan = step.input.todos; paintPlan(); } step.hidden = true; S.steps.set(step.id, step); return; } |
| 794 | S.steps.set(step.id, step); |
| 795 | const g = groupFor(target === feed ? feed : target); |
| 796 | if (target === feed && empty.parentNode) empty.remove(); |
| 797 | g.steps.push(step); |
| 798 | g.firstChild.querySelector('ol').appendChild(stepEl(step)); |
| 799 | paintGroup(g); step.group = g; paintHead(); return; |
| 800 | } |
| 801 | if (x.t === 'phase' || x.t === 'phases' || x.t === 'activity') { onPhase(x, target); return; } |
| 802 | if (x.t === 'thinking' || x.t === 'thinking_delta') { onThinking(x, target); return; } |
| 803 | if (x.t === 'text_start' || x.t === 'text_delta') { |
| 804 | let m = S.msgs.get(x.id); |
| 805 | if (!m) { const li = el('li', 'ovas-msg'); li.dataset.k = 'msg'; m = { li, text: '' }; S.msgs.set(x.id, m); appendLi(target, li); } |
| 806 | if (x.t === 'text_delta') { m.text = (m.text + x.delta).slice(0, MAX_TEXT); m.li.textContent = ''; m.li.appendChild(markdown(m.text, linkOk)); } |
| 807 | return; |
| 808 | } |
| 809 | if (x.t === 'tool_args') { |
| 810 | const step = S.steps.get(String(x.id)); if (!step) return; |
| 811 | step.argText = (step.argText || '') + x.delta; |
| 812 | try { step.input = JSON.parse(step.argText); step.info = describe(step); if (step.group) paintGroup(step.group); } catch { /* not whole yet */ } |
| 813 | return; |
| 814 | } |
| 815 | if (x.t === 'sources') { addSources(x.items); return; } |
| 816 | if (x.t === 'tool_progress') { |
| 817 | const step = S.steps.get(String(x.id)); if (!step || !step.outEl) return; |
| 818 | step.out = (step.out + x.chunk).slice(-20000); step.outEl.textContent = step.out; step.outEl.hidden = false; step.waitEl.hidden = true; |
| 819 | step.outEl.scrollTop = step.outEl.scrollHeight; return; |
| 820 | } |
| 821 | if (x.t === 'tool_result') { |
| 822 | const step = S.steps.get(String(x.id)); if (!step || step.hidden) return; |
| 823 | // A result may carry the files the step produced; the group's header shows them. |
| 824 | const extra = safeAtts(x.attachments); |
| 825 | if (extra.length) { |
| 826 | const have = step.attachments || (step.attachments = []); |
| 827 | for (const a of extra) if (!have.some((b) => b.url === a.url)) have.push(a); |
| 828 | } |
| 829 | step.done = true; step.ok = x.ok !== false; step.ended = x.duration_ms > 0 ? step.started + Number(x.duration_ms) : Date.now(); |
| 830 | if (Number(x.cost_usd) > 0) step.cost = Number(x.cost_usd); |
| 831 | if (step.outEl) { |
| 832 | const out = String(x.output == null ? '' : x.output) || step.out || ''; |
| 833 | step.waitEl.hidden = true; |
| 834 | if (out.trim()) { step.outEl.textContent = out; step.outEl.hidden = false; step.outEl.classList.toggle('is-fail', !step.ok); } |
| 835 | else { step.outEl.hidden = true; step.outLabel.hidden = true; } |
| 836 | } |
| 837 | if (!step.ok) { S.problems++; if (step.el) step.el.open = true; } |
| 838 | setStepState(step); if (step.group) paintGroup(step.group); paintHead(); return; |
| 839 | } |
| 840 | if (x.t === 'handoff') { |
| 841 | const li = el('li', 'ovas-handoff'); li.dataset.k = 'handoff'; li.dataset.p = '1'; |
| 842 | const s = el('div', 'ovas-hand'); |
| 843 | const row = el('div', 'ovas-hand-row'); |
| 844 | const from = x.from || (S.agents.length ? S.agents[S.agents.length - 1].agent : ''); |
| 845 | if (x.to && (!S.agents.length || S.agents[S.agents.length - 1].agent !== x.to)) S.agents.push({ agent: x.to }); |
| 846 | row.appendChild(el('span', 'ovas-agent', agentName(from))); row.insertAdjacentHTML('beforeend', svg('handoff')); row.appendChild(el('span', 'ovas-agent is-now', x.to ? agentName(x.to) : 'the next agent')); |
| 847 | s.appendChild(row); if (x.reason) s.appendChild(el('small', '', x.reason)); |
| 848 | const handAtts = safeAtts(x.attachments); |
| 849 | if (handAtts.length) s.appendChild(attStrip(handAtts, openSheet)); |
| 850 | li.appendChild(s); appendLi(feed, li); S.problems++; paintHead(); return; |
| 851 | } |
| 852 | if (x.t === 'note') { |
| 853 | const li = el('li', 'ovas-note'); li.dataset.k = 'note'; li.dataset.level = x.level === 'warning' || x.level === 'info' ? x.level : 'error'; |
| 854 | if (li.dataset.level !== 'info') li.dataset.p = '1'; |
| 855 | li.innerHTML = svg(li.dataset.level === 'info' ? 'ok' : 'warn'); li.appendChild(el('span', '', x.text)); |
| 856 | const noteAtts = safeAtts(x.attachments); |
| 857 | if (noteAtts.length) li.appendChild(attStrip(noteAtts, openSheet)); |
| 858 | appendLi(target, li); if (li.dataset.level !== 'info') S.problems++; paintHead(); return; |
| 859 | } |
| 860 | if (x.t === 'step') { |
| 861 | const li = el('li', 'ovas-line'); li.dataset.k = 'line'; li.appendChild(el('span', '', x.text)); |
| 862 | const lineAtts = safeAtts(x.attachments); |
| 863 | if (lineAtts.length) li.appendChild(attStrip(lineAtts, openSheet)); |
| 864 | appendLi(feed, li); return; |
| 865 | } |
| 866 | if (x.t === 'usage') { S.tokens.in += Number(x.input_tokens) || 0; S.tokens.out += Number(x.output_tokens) || 0; return; } |
| 867 | if (x.t === 'done') { |
| 868 | if (x.parent) return; |
| 869 | S.cost += Number(x.cost_usd) || 0; S.durationMs += Number(x.duration_ms) || 0; |
| 870 | const who = x.agent || (S.agents[S.agents.length - 1] || {}).agent; |
| 871 | if (who && Number(x.cost_usd) > 0) S.agentCost.set(who, (S.agentCost.get(who) || 0) + Number(x.cost_usd)); |
| 872 | const li = el('li', 'ovas-done'); li.dataset.k = 'done'; li.dataset.ok = x.ok ? '1' : '0'; |
| 873 | const b = el('b'); b.innerHTML = svg(x.ok ? 'ok' : 'fail'); b.appendChild(document.createTextNode(`${agentName((S.agents[S.agents.length - 1] || {}).agent)} ${x.ok ? 'finished' : 'stopped'}${x.duration_ms ? ` in ${dur(x.duration_ms)}` : ''}`)); |
| 874 | li.appendChild(b); |
| 875 | if (x.text) { const m = el('div', 'ovas-msg'); m.appendChild(markdown(x.text, linkOk)); li.appendChild(m); } |
| 876 | const doneAtts = safeAtts(x.attachments); |
| 877 | if (doneAtts.length) li.appendChild(attStrip(doneAtts, openSheet)); |
| 878 | if (!x.ok) li.dataset.p = '1'; |
| 879 | appendLi(feed, li); paintHead(); |
| 880 | } |
| 881 | } |
| 882 | // Phases: named parts of the run (AG-UI steps). The plan row in the head shows where the run is ("2 of 5"), and a |
| 883 | // phase that starts is a divider in the feed. A plan the agent keeps itself (TodoWrite) wins over phases. |
| 884 | function onPhase(x, target) { |
| 885 | if (x.t === 'activity') { S.activity = x.text; paintPlan(); return; } |
| 886 | if (x.t === 'phases') { S.phases = x.items.map((p, i) => ({ id: `p${i}`, title: p.title, status: p.status })); } |
| 887 | else { |
| 888 | const same = (a, b) => String(a || '').trim().toLowerCase() === String(b || '').trim().toLowerCase(); |
| 889 | let p = S.phases.find((q) => q.id === x.id) || S.phases.find((q) => same(q.title, x.title || x.id)); |
| 890 | if (!p) { p = { id: x.id, title: x.title || x.id, status: 'running' }; S.phases.push(p); } |
| 891 | if (x.title) p.title = x.title; |
| 892 | p.status = x.status; |
| 893 | if (x.status === 'running' && !p.li) { |
| 894 | const li = el('li', 'ovas-phase'); li.dataset.k = 'phase'; |
| 895 | li.appendChild(el('span', 'ovas-phase-dot')); li.appendChild(el('b', '', p.title)); |
| 896 | p.li = li; appendLi(target === feed ? feed : target, li); |
| 897 | } |
| 898 | if (p.li) { p.li.dataset.s = p.status; p.li.querySelector('b').textContent = p.title; } |
| 899 | } |
| 900 | if (!S.ownPlan) { |
| 901 | S.plan = S.phases.map((p) => ({ content: p.title, status: /done|complete/.test(p.status) ? 'completed' : /run|progress/.test(p.status) ? 'in_progress' : 'pending' })); |
| 902 | paintPlan(); |
| 903 | } |
| 904 | } |
| 905 | // Thinking: folded to one line, "Thinking…" while it streams and "Thought for 1m 20s" after; its words open on a tap. |
| 906 | function onThinking(x, target) { |
| 907 | let th = S.thinks.get(x.id); |
| 908 | if (!th) { |
| 909 | const li = el('li', 'ovas-think'); li.dataset.k = 'msg'; |
| 910 | const d = el('details'); const sm = el('summary'); sm.appendChild(el('span', 'ovas-think-dots')); |
| 911 | const label = el('span', '', 'Thinking…'); sm.appendChild(label); d.appendChild(sm); |
| 912 | const body = el('div', 'ovas-think-body'); d.appendChild(body); li.appendChild(d); |
| 913 | th = { li, label, body, text: '', started: Date.now() }; S.thinks.set(x.id, th); appendLi(target, li); |
| 914 | } |
| 915 | if (x.t === 'thinking_delta') { th.text = (th.text + x.delta).slice(-20000); th.body.textContent = th.text; } |
| 916 | if (x.hidden) th.body.textContent = 'This model keeps its thinking to itself.'; |
| 917 | if (x.done || x.hidden) { th.li.classList.add('is-done'); th.label.textContent = `Thought for ${dur(x.duration_ms || Date.now() - th.started)}`; } |
| 918 | } |
| 919 | // Sources: every page the run read, once each, in one strip under the feed: the sites as letter tiles and a count; |
| 920 | // opened, the titles as links (https only, a new tab). |
| 921 | function addSources(items) { |
| 922 | for (const it of (items || []).slice(0, 200)) { |
| 923 | const url = it && String(it.url || ''); |
| 924 | if (!/^https:\/\//.test(url) || S.sources.has(url)) continue; |
| 925 | let site = String(it.site || ''); try { if (!site) site = new URL(url).hostname.replace(/^www\./, ''); } catch { continue; } |
| 926 | S.sources.set(url, { url, site, title: cut(String(it.title || site), 160) }); |
| 927 | } |
| 928 | paintSources(); |
| 929 | } |
| 930 | function paintSources() { |
| 931 | const list = [...S.sources.values()]; |
| 932 | srcBox.hidden = !list.length; if (!list.length) return; |
| 933 | const tiles = srcBox.querySelector('.ovas-src-tiles'); tiles.textContent = ''; |
| 934 | const sites = [...new Set(list.map((s) => s.site))]; |
| 935 | sites.slice(0, 5).forEach((site) => { const t = el('span', 'ovas-src-tile', site.charAt(0).toUpperCase()); t.title = site; let h = 0; for (const c of site) h = (h * 31 + c.charCodeAt(0)) % 360; t.style.setProperty('--h', String(h)); tiles.appendChild(t); }); |
| 936 | srcBox.querySelector('.ovas-src-n').textContent = `${list.length} source${list.length === 1 ? '' : 's'}${sites.length > 1 ? ` from ${sites.length} sites` : ''}`; |
| 937 | const ol = srcBox.querySelector('ol'); ol.textContent = ''; |
| 938 | list.forEach((s) => { const li = el('li'); const a = el('a', '', s.title); a.href = s.url; a.target = '_blank'; a.rel = 'noopener noreferrer'; li.appendChild(a); li.appendChild(el('small', '', s.site)); ol.appendChild(li); }); |
| 939 | } |
| 940 | function push(event) { normalize(event).forEach((x) => { try { onLine(x); } catch { /* one odd line never stops the view */ } }); applyFilter(); keep(); } |
| 941 | |
| 942 | function paintPlan() { |
| 943 | const box2 = head.querySelector('.ovas-plan'); const todos = (S.plan || []).filter((t) => t && (t.content || t.activeForm)); |
| 944 | if (!todos.length && S.activity) { box2.hidden = false; box2.querySelector('.ovas-plan-now').textContent = S.activity; box2.querySelector('.ovas-plan-n').textContent = ''; box2.querySelector('i').style.width = '0%'; return; } |
| 945 | box2.hidden = !todos.length; if (!todos.length) return; |
| 946 | const doneN = todos.filter((t) => t.status === 'completed').length; |
| 947 | box2.querySelector('i').style.width = `${Math.round((doneN / todos.length) * 100)}%`; |
| 948 | const now = todos.find((t) => t.status === 'in_progress') || todos.find((t) => t.status !== 'completed'); |
| 949 | box2.querySelector('.ovas-plan-now').textContent = now ? (now.status === 'in_progress' && now.activeForm ? now.activeForm : now.content || now.activeForm) : 'Plan done'; |
| 950 | box2.querySelector('.ovas-plan-n').textContent = `${doneN} of ${todos.length}`; |
| 951 | const ol = box2.querySelector('ol'); ol.textContent = ''; |
| 952 | todos.slice(0, 12).forEach((t) => { const li = el('li', '', t.status === 'in_progress' && t.activeForm ? t.activeForm : t.content || t.activeForm); li.dataset.s = t.status || 'pending'; ol.appendChild(li); }); |
| 953 | } |
| 954 | function summary() { |
| 955 | const steps = [...S.steps.values()].filter((s) => !s.hidden); |
| 956 | const files = new Set(steps.filter((s) => s.kind === 'edit' || s.kind === 'write').map((s) => s.info && s.info.sub).filter(Boolean)); |
| 957 | return { |
| 958 | agents: S.agents.map((a) => agentName(a.agent)), steps: steps.length, commands: steps.filter((s) => s.kind === 'command').length, files: files.size, |
| 959 | added: steps.reduce((n, s) => n + ((s.info && s.info.added) || 0), 0), removed: steps.reduce((n, s) => n + ((s.info && s.info.removed) || 0), 0), |
| 960 | problems: S.problems, costUsd: Math.round(S.cost * 1e4) / 1e4, costByAgent: Object.fromEntries([...S.agentCost].map(([k, v]) => [agentName(k), Math.round(v * 1e4) / 1e4])), durationMs: S.durationMs || (clock ? (S.endedAt || Date.now()) - S.startedAt : 0), tokens: { ...S.tokens }, |
| 961 | }; |
| 962 | } |
| 963 | const LABEL = { queued: 'Waiting', placed: 'Starting', running: 'Running', verifying: 'Checking the answer', succeeded: 'Finished', failed: 'Failed', cancelled: 'Cancelled', expired: 'Expired' }; |
| 964 | function paintHead() { |
| 965 | const sm = summary(); |
| 966 | const pill = head.querySelector('.ovas-pill'); |
| 967 | pill.dataset.s = S.status; |
| 968 | pill.innerHTML = `${S.status === 'running' || S.status === 'placed' || S.status === 'verifying' ? '<span class="ovas-spin"></span>' : S.status === 'succeeded' ? svg('ok') : S.status === 'failed' || S.status === 'expired' ? svg('fail') : ''}<span></span>`; |
| 969 | pill.lastChild.textContent = S.label || LABEL[S.status] || S.status; |
| 970 | const ag = head.querySelector('.ovas-agents'); ag.textContent = ''; |
| 971 | const shown = new Set(); |
| 972 | S.agents.forEach((a, i) => { |
| 973 | if (i) { const to = el('span', 'ovas-hand'); to.setAttribute('aria-hidden', 'true'); to.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="14" height="14" style="vertical-align:-2px"><path d="M3.2 12h16.7M13.7 5.7l6.3 6.3l-6.3 6.3"/></svg>'; ag.appendChild(to); } |
| 974 | const c = el('span', `ovas-agent${i === S.agents.length - 1 ? ' is-now' : ''}`, agentName(a.agent)); if (a.model) c.title = a.model; |
| 975 | // An agent's cost once, on its last chip (an agent may come back after a hand-off). |
| 976 | const cost = S.agentCost.get(a.agent); |
| 977 | if (cost && !shown.has(a.agent) && !S.agents.slice(i + 1).some((b) => b.agent === a.agent)) { shown.add(a.agent); c.appendChild(el('span', 'ovas-agent-cost', `$${cost < 0.01 ? cost.toFixed(4).replace(/0+$/, '') : cost.toFixed(2)}`)); } |
| 978 | ag.appendChild(c); |
| 979 | }); |
| 980 | const fc = bar.querySelector('[data-f="files"]'); if (fc) fc.textContent = sm.files ? `Files ${sm.files}` : 'Files'; |
| 981 | const m = (k, html) => { const n = head.querySelector(`[data-m="${k}"]`); n.innerHTML = html; n.hidden = !html; }; |
| 982 | // What it is doing now (the portal's sticky line, 2026-11-11): the newest step that has not ended. |
| 983 | const nowStep = LIVE.includes(S.status) ? [...S.steps.values()].reverse().find((st) => !st.done && !st.hidden) : null; |
| 984 | m('now', nowStep && nowStep.info ? `<span class="ovas-now">${esc(String(nowStep.info.command || nowStep.info.title || '').split('\n')[0].slice(0, 90))}</span>` : ''); |
| 985 | m('time', sm.durationMs ? `<b>${esc(dur(sm.durationMs))}</b>` : ''); |
| 986 | m('steps', sm.steps ? `<b>${sm.steps}</b> step${sm.steps === 1 ? '' : 's'}${sm.commands ? ` · <b>${sm.commands}</b> command${sm.commands === 1 ? '' : 's'}` : ''}` : ''); |
| 987 | m('files', sm.files ? `<b>${sm.files}</b> file${sm.files === 1 ? '' : 's'} <span style="color:var(--ovas-ok)">+${sm.added}</span> <span style="color:var(--ovas-bad)">−${sm.removed}</span>` : ''); |
| 988 | m('cost', sm.costUsd ? `<b>$${sm.costUsd.toFixed(sm.costUsd < 1 ? 3 : 2)}</b>` : ''); |
| 989 | } |
| 990 | function status(s, label) { |
| 991 | S.status = s; S.label = label || ''; |
| 992 | working.hidden = !LIVE.includes(s); |
| 993 | if (!LIVE.includes(s)) { S.endedAt = S.endedAt || Date.now(); empty.textContent = 'Nothing was recorded for this run.'; } |
| 994 | paintHead(); |
| 995 | } |
| 996 | // Talking to the run while it works (opts.onSend(text, { files, uploads }) → a promise; the product sends it to its |
| 997 | // agent: Codes' job message, Actor's task input, the portal's session). The box is the one composer on every site |
| 998 | // (owner, 2026-11-11: "uses our custom modular composer across all sites"): files by picker, paste and drop, "/" |
| 999 | // commands and the person's own, drafts, voice and their settings; loaded from beside this file when the page has |
| 1000 | // not loaded it. opts.composer: false keeps a plain box. |
| 1001 | if (typeof opts.onSend === 'function') { |
| 1002 | // The one composer's markup (thread.js composer()): the box, then a row with its tools and Send, then its note, |
| 1003 | // so ov-composer.js puts its + menu (files), "/" commands, voice and settings where every site has them. |
| 1004 | const form = el('form', 'ovas-say ovt-composer'); form.setAttribute('data-ovt-composer', ''); |
| 1005 | const ta = el('textarea'); ta.name = 'text'; ta.rows = 1; ta.maxLength = 4000; ta.placeholder = opts.sayPlaceholder || 'Message the agent'; ta.setAttribute('aria-label', 'Message the agent'); ta.setAttribute('data-ovt-text', ''); |
| 1006 | const row = el('div', 'ovt-c-row'); const toolsEl = el('div', 'ovt-c-tools'); |
| 1007 | const send = el('button', 'ovas-say-send ovt-send'); send.type = 'submit'; send.setAttribute('aria-label', 'Send'); send.setAttribute('data-ovt-send', ''); send.innerHTML = svg('handoff'); |
| 1008 | const noteEl = el('p', 'ovt-c-note'); noteEl.setAttribute('data-ovt-note', ''); noteEl.setAttribute('role', 'status'); |
| 1009 | row.appendChild(toolsEl); row.appendChild(send); |
| 1010 | // A file input makes the box one that takes files (the composer's + menu, paste and drop); the files go to onSend. |
| 1011 | if (opts.files !== false) { |
| 1012 | // The shared "+" (thread.js attachHtml): a label holding the file input, first in the tools row. |
| 1013 | const plus = el('label', 'ovt-btn ovt-attach'); plus.title = 'Attach files'; |
| 1014 | plus.innerHTML = '<svg class="ovt-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg><span class="ovt-sr">Attach files</span>'; |
| 1015 | const fi = el('input'); fi.type = 'file'; fi.name = 'files'; fi.multiple = true; fi.hidden = true; fi.setAttribute('data-ovt-files', ''); if (opts.accept) fi.accept = String(opts.accept); |
| 1016 | plus.appendChild(fi); toolsEl.insertBefore(plus, toolsEl.firstChild); |
| 1017 | } |
| 1018 | form.appendChild(ta); form.appendChild(row); form.appendChild(noteEl); |
| 1019 | box.appendChild(form); |
| 1020 | ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit ? form.requestSubmit() : form.dispatchEvent(new Event('submit', { cancelable: true })); } }); |
| 1021 | form.addEventListener('submit', async (e) => { |
| 1022 | e.preventDefault(); |
| 1023 | const ovc = form.__ovc; |
| 1024 | const files = ovc ? ovc.files() : [], uploads = ovc && ovc.uploads ? ovc.uploads() : []; |
| 1025 | const text = ta.value.trim(); if ((!text && !files.length) || form.classList.contains('is-busy')) return; |
| 1026 | form.classList.add('is-busy'); |
| 1027 | const li = el('li', 'ovas-you'); li.dataset.k = 'msg'; li.appendChild(el('span', '', text)); |
| 1028 | if (files.length) li.appendChild(el('small', 'ovas-you-files', files.map((f) => f.name || 'a file').join(' · '))); |
| 1029 | appendLi(feed, li); keep(); |
| 1030 | try { await opts.onSend(text, { files, uploads }); if (ovc && ovc.clear) ovc.clear(); else ta.value = ''; li.classList.add('is-sent'); } |
| 1031 | catch (err) { li.classList.add('is-failed'); li.title = (err && err.message) || 'Not sent'; } |
| 1032 | finally { form.classList.remove('is-busy'); } |
| 1033 | }); |
| 1034 | if (opts.composer !== false) { |
| 1035 | form.id = form.id || `ovas-say-${Math.random().toString(36).slice(2, 8)}`; |
| 1036 | const join = () => { try { if (root.OpenVibeComposer && form.isConnected) root.OpenVibeComposer.enhance(form); } catch { /* a plain box still sends */ } }; |
| 1037 | if (root.OpenVibeComposer) join(); |
| 1038 | else { |
| 1039 | const m = /^(https?:\/\/[^?#]*\/)agent-session(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(SELF_SRC); |
| 1040 | if (m) { |
| 1041 | let sc = document.getElementById('ov-composer-script'); |
| 1042 | if (!sc) { sc = document.createElement('script'); sc.id = 'ov-composer-script'; sc.async = true; sc.src = m[1] + 'ov-composer.js'; document.head.appendChild(sc); } |
| 1043 | sc.addEventListener('load', join, { once: true }); |
| 1044 | } |
| 1045 | } |
| 1046 | } |
| 1047 | } |
| 1048 | const tick = setInterval(() => { if (S.status === 'running' || S.status === 'placed') { S.steps.forEach((st) => { if (!st.done) setStepState(st); }); paintHead(); } }, 1000); |
| 1049 | paintHead(); |
| 1050 | return { push, pushAll: (list) => (list || []).forEach(push), status, summary, actions: (list, note) => paintActions(list, note), files: () => changedFiles().map((f) => ({ path: f.path, added: f.added, removed: f.removed, changes: f.n })), destroy() { clearInterval(tick); host.textContent = ''; } }; |
| 1051 | } |
| 1052 | |
| 1053 | const api = { mount, normalize, diffLines, kindOf }; |
| 1054 | root.OpenVibeAgentSession = api; |
| 1055 | if (typeof module === 'object' && module.exports) module.exports = api; |
| 1056 | })(typeof globalThis !== 'undefined' ? globalThis : this); |
| 1057 |