openvibers/openvibe.shared Commits
Rich embeds in the agent session: the images and files a step read, wrote or produced (#195)
Changes
@@@ -6,6 +6,20 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 4.50.0 — 2026-10-30++++- **Rich embeds in the agent session.** The owner, 2026-10-30: "When it generates or reads or analyzed images or visuals++ or files it should show a rich embed in chat for them like in Claude, Codex, ChatGPT." A step or a message may now++ carry `attachments: [{ url, name, mime, size?, kind, role }]`; the component draws them and never builds a URL itself.++ - A step's attachments show in its group's header, visible while the group is folded: images as 136 × 92 thumbnails++ with their name, everything else as a chip with an icon. A message's show under its text.++ - A tap opens a preview sheet: the image at full width (pinch to zoom on a phone), video and audio in their players,++ a PDF in a frame, and text as code, Markdown, a CSV table or pretty JSON, highlighted — plus Open and Download.++ - The thumbnails load lazily, a broken image hides itself, and the sheet is keyboard reachable (Escape closes it).++ - Safety: an attachment loads only from the product's own origin or Media's, never from a third-party address the++ model printed; SVG is only ever an `<img>`; text is set as text, never as markup.++ - `test/agent-session-embeds-chrome.test.js` covers it in a real Chrome.++## 4.49.0 — 2026-10-30- **Shared motion tokens for production patterns.** Adds standard and back easing curves plus fast, base, slow, and reduced durations (180, 260, 360, and 0 ms).@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.49.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.50.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.49.0",++ "version": "4.50.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -32,7 +32,7 @@.ovas-plan li[data-s="in_progress"]::before { border-color: var(--accent, #3b82f6); border-right-color: transparent; animation: ovas-spin .9s linear infinite; }.ovas-plan li[data-s="completed"] { color: var(--ovas-mut); text-decoration: line-through; }.ovas-plan li[data-s="completed"]::before { background: var(--ovas-ok); border-color: var(--ovas-ok); }--.ovas-bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }++.ovas-bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; min-width: 0; }.ovas-chip { height: 30px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--ovas-b); background: transparent; color: var(--ovas-sec); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; }.ovas-chip[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, #fff); }.ovas-chip.ovas-follow { margin-left: auto; }@@@ -89,7 +89,7 @@ details[open] > summary .ovas-chev { transform: rotate(90deg); }.ovas-hand-row { display: inline-flex; align-items: center; gap: 6px; }.ovas-hand-row .ovas-ic { color: var(--ovas-mut); }.ovas-hand small { font-size: 12.5px; color: var(--ovas-mut); line-height: 1.4; }--.ovas-note { display: flex; gap: 8px; align-items: flex-start; padding: 9px 12px; border-radius: 12px; font-size: 13.5px; background: color-mix(in srgb, #f59e0b 12%, transparent); color: var(--text-primary, #e6edf7); }++.ovas-note { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-start; padding: 9px 12px; border-radius: 12px; font-size: 13.5px; background: color-mix(in srgb, #f59e0b 12%, transparent); color: var(--text-primary, #e6edf7); }.ovas-note[data-level="error"] { background: color-mix(in srgb, #ef4444 13%, transparent); }.ovas-note[data-level="info"] { background: color-mix(in srgb, #22c55e 10%, transparent); }.ovas-note[data-level="info"] .ovas-ic { color: var(--ovas-ok); }@@@ -97,8 +97,9 @@ details[open] > summary .ovas-chev { transform: rotate(90deg); }.ovas-done { display: grid; gap: 6px; padding: 12px 14px; border-radius: 14px; border: 1px solid color-mix(in srgb, #22c55e 40%, transparent); background: color-mix(in srgb, #22c55e 8%, transparent); }.ovas-done[data-ok="0"] { border-color: color-mix(in srgb, #ef4444 40%, transparent); background: color-mix(in srgb, #ef4444 8%, transparent); }.ovas-done > b { display: flex; align-items: center; gap: 6px; font-size: 13px; }--.ovas-line { display: flex; align-items: center; gap: 8px; padding: 0 4px; font-size: 12.5px; color: var(--ovas-mut); }++.ovas-line { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 0 4px; font-size: 12.5px; color: var(--ovas-mut); }.ovas-line::before { content: ''; flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--ovas-mut); opacity: .7; }++.ovas-extra { display: grid; gap: 8px; padding: 8px 12px; border-radius: 12px; border: 1px solid var(--ovas-b); background: var(--ovas-s); font-size: 13.5px; color: var(--ovas-sec); }.ovas-empty { padding: 18px; text-align: center; color: var(--ovas-mut); font-size: 13.5px; border: 1px dashed var(--ovas-b); border-radius: 14px; }.ovas [hidden] { display: none !important; }@keyframes ovas-spin { to { transform: rotate(360deg); } }@@@ -106,7 +107,7 @@ details[open] > summary .ovas-chev { transform: rotate(90deg); }@media (max-width: 640px) {.ovas-head { padding: 10px 12px; border-radius: 14px; } .ovas-msg { font-size: 14.5px; }.ovas-code, .ovas-out, .ovas-diff { font-size: 12px; max-height: 45vh; }-- .ovas-bar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin: 0 -14px; padding: 0 14px; } .ovas-bar::-webkit-scrollbar { display: none; }++ .ovas-bar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding: 0 2px; } .ovas-bar::-webkit-scrollbar { display: none; }.ovas-chip { flex: none; } .ovas-icons .ovas-kind + .ovas-kind { display: none; }}@media (prefers-reduced-motion: reduce) { .ovas-spin, .ovas-plan li::before { animation: none; } }@@@ -136,3 +137,66 @@ details[open] > summary .ovas-chev { transform: rotate(90deg); }.ovas-act.is-danger { color: var(--ovas-bad); border-color: color-mix(in srgb, var(--ovas-bad) 45%, var(--ovas-b)); }.ovas-act:disabled { opacity: .55; cursor: default; }.ovas-act:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++++/* Attachments (4.50.0): the images and files a step read, wrote or produced. A group's header shows them while the++ group is folded; a message's sit under its text. The URLs come from the product; only the page's own origin and++ Media's load. */++.ovas-group > summary, .ovas-step > summary { flex-wrap: wrap; row-gap: 8px; }++.ovas-att-slot { flex-basis: 100%; order: 9; display: block; min-width: 0; }++.ovas-att-slot:empty { display: none; }++.ovas-att { display: grid; gap: 8px; width: 100%; min-width: 0; max-width: 100%; }++.ovas-att-row { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px; min-width: 0; }++.ovas-att-img { box-sizing: border-box; display: block; width: 136px; flex: none; padding: 0; border: 1px solid var(--ovas-b); border-radius: 12px; overflow: hidden; background: var(--ovas-t); color: inherit; font: inherit; text-align: left; cursor: pointer; }++.ovas-att-img img { display: block; width: 136px; height: 92px; object-fit: cover; background: var(--ovas-t); }++.ovas-att-chip { box-sizing: border-box; display: inline-flex; align-items: center; gap: 7px; flex: none; max-width: 220px; min-height: 36px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--ovas-b); background: var(--ovas-t); color: var(--ovas-sec); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; }++.ovas-att-chip .ovas-ic { width: 15px; height: 15px; flex: none; color: var(--accent-light, var(--accent, #60a5fa)); }++.ovas-att-chip[data-role="read"] .ovas-ic { color: #38bdf8; }++.ovas-att-chip[data-role="wrote"] .ovas-ic { color: #4ade80; }++.ovas-att-cap { display: block; min-width: 0; font-size: 11.5px; line-height: 1.3; color: var(--ovas-mut); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.ovas-att-img .ovas-att-cap { padding: 5px 7px; }++.ovas-att-chip .ovas-att-cap { padding: 0; }++.ovas-att:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++.ovas-att-img:hover, .ovas-att-chip:hover { background: var(--bg-hover, rgba(255,255,255,.06)); }++/* The preview sheet: one attachment, opened by a tap. */++.ovas-sheet { box-sizing: border-box; position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 16px; background: color-mix(in srgb, var(--bg-primary, #0b1020) 80%, transparent); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }++.ovas-sheet[hidden] { display: none; }++.ovas-sheet-panel { box-sizing: border-box; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; gap: 10px; width: min(960px, 92vw); max-height: min(92vh, 920px); padding: 14px; border-radius: 18px; border: 1px solid var(--ovas-b); background: var(--ovas-s); color: var(--text-primary, #e6edf7); box-shadow: 0 24px 70px rgba(0,0,0,.45); }++.ovas-sheet-bar { display: flex; align-items: center; gap: 10px; min-width: 0; }++.ovas-sheet-bar b { flex: 1; min-width: 0; font-size: 14px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovas-sheet-x { width: 40px; height: 40px; flex: none; border-radius: 999px; border: 1px solid var(--ovas-b); background: transparent; color: inherit; font: inherit; font-size: 15px; line-height: 1; cursor: pointer; }++.ovas-sheet-x:focus-visible, .ovas-sheet-foot a:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++.ovas-sheet-body { min-width: 0; min-height: 0; overflow: auto; display: grid; }++.ovas-prev { min-width: 0; display: grid; }++.ovas-prev-img { display: block; width: 100%; height: auto; max-height: 74vh; object-fit: contain; border-radius: 12px; background: var(--ovas-t); touch-action: none; }++.ovas-prev-media { display: block; width: 100%; max-height: 74vh; border-radius: 12px; background: #000; }++.ovas-prev-audio { width: 100%; }++.ovas-prev-pdf { width: 100%; height: 72vh; border: 0; border-radius: 12px; background: #fff; }++.ovas-prev-code { margin: 0; padding: 12px; border-radius: 12px; border: 1px solid var(--ovas-b); background: var(--ovas-code); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; line-height: 1.5; overflow: auto; max-height: 70vh; white-space: pre; }++.ovas-prev-text { min-width: 0; }++.ovas-prev-text .ovas-msg { padding: 0; }++.ovas-prev-note { margin: 0 0 10px; font-size: 13px; color: var(--ovas-mut); }++.ovas-sheet-foot { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }++.ovas-sheet-foot a { display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--ovas-b); background: transparent; color: inherit; text-decoration: none; font-size: 13px; font-weight: 700; }++.ovas-sheet-foot .ovas-sheet-open { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, #fff); }++.ovas-csv { width: 100%; border-collapse: collapse; font-size: 12.5px; }++.ovas-csv th, .ovas-csv td { border: 1px solid var(--ovas-b); padding: 5px 8px; text-align: left; white-space: nowrap; }++.ovas-csv th { background: var(--ovas-t); font-weight: 700; }++.ovas-tok-key { color: var(--accent-light, var(--accent, #60a5fa)); }++.ovas-tok-str { color: var(--ovas-ok); }++.ovas-tok-num { color: #fbbf24; }++.ovas-tok-kw { color: #c084fc; }++.ovas-tok-com { color: var(--ovas-mut); font-style: italic; }++++@media (max-width: 760px) {++ /* A phone: the head does not stick, so it never floats over the steps it sits above. */++ .ovas-head { position: static; }++}++@media (max-width: 640px) {++ /* Phones: bigger tap targets, and the preview sheet takes the whole screen. */++ .ovas-att-chip { min-height: 44px; padding: 0 14px; }++ .ovas-att-chip .ovas-att-cap { font-size: 12px; }++ .ovas-sheet { padding: 0; align-items: stretch; }++ .ovas-sheet-panel { width: 100%; max-height: 100vh; height: 100vh; border-radius: 0; border: 0; padding: 12px; }++ .ovas-sheet-x, .ovas-sheet-foot a { min-height: 44px; }++ .ovas-prev-img { max-height: 68vh; }++}@@@ -36,6 +36,15 @@* { t: 'step', text } the job's own progress (cloning, the branch, files changed, pushing)* { t: 'usage', input_tokens, output_tokens }* { t: 'done', ok, text, duration_ms, cost_usd, turns }++ * A step or a message may carry the images and files the agent read, wrote or produced:++ * attachments: [{ url, name, mime, size?, kind: 'image'|'video'|'audio'|'pdf'|'text'|'file', role: 'read'|'wrote'|'output' }]++ * The product supplies the URLs; the component never builds one from a local path. Any line may carry attachments,++ * and the component shows them for every line it draws. A line of a type it does not know (Codes' proof lines) is++ * drawn as a minimal row: its label or summary, then its attachments. A step's attachments show in its++ * group's header, visible while the group is folded; a message's show under its text. A tap opens a preview sheet: the++ * image at full width (pinch to zoom), video and audio in their players, a PDF in a frame, text as code, Markdown, a CSV++ * table or pretty JSON, plus Open and Download. An attachment loads only from the product's own origin or Media's,++ * never a third-party address the model printed; SVG is only ever an <img>, and text is set as text, never as markup.* A line from a sub-agent carries `parent`: the id of the Task step that started it, so it nests under that step.* Any line may carry `agent` (the head then follows who is working); a step without an id gets one, and the next result* of the same tool without an id is its result. { t: 'state', state } moves the head (Actor's task states).@@@ -84,6 +93,12 @@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"/>',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"/>',chevron: '<path d="m9 6 6 6-6 6"/>',++ 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"/>',++ video: '<rect x="3" y="5" width="13" height="14" rx="2"/><path d="m16 10 5-3v10l-5-3z"/>',++ 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"/>',++ doc: '<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4"/><path d="M9 12.5h6M9 16h6"/>',++ code: '<path d="m9 8-5 4 5 4M15 8l5 4-5 4"/>',++ file: '<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4"/>',};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>`;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' };@@@ -104,12 +119,13 @@if (e.kind === 'state' || e.kind === 'end') return e.state ? [{ t: 'state', state: String(e.state), ...agent }] : [];if (e.kind !== 'output') return [];const text = cut(e.text, MAX_TEXT);-- if (e.step === 'text') return [e.is_error ? { t: 'note', level: 'error', text, ...agent } : { t: 'text', text, ...agent }];++ const att = e.attachments ? { attachments: e.attachments } : {};++ if (e.step === 'text') return [e.is_error ? { t: 'note', level: 'error', text, ...agent } : { t: 'text', text, ...att, ...agent }];if (e.step === 'plan') return [{ t: 'step', text, ...agent }];if (e.step === 'handoff') return [{ t: 'handoff', from: null, to: e.agent || null, reason: text, ...agent }];if (e.step === 'check') return [{ t: 'note', level: /^checked/i.test(text) ? 'info' : 'warning', text, ...agent }];-- 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) : '' }, ...agent }];-- if (e.step === 'tool_result') return [{ t: 'tool_result', name: String(e.tool || 'tool'), ok: !e.is_error, output: cut(e.text, 8000), ...agent }];++ 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 }];++ 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 }];return text ? [{ t: 'text', text, ...agent }] : [];}function normalize(e) {@@@ -150,6 +166,188 @@}const base = (p) => String(p || '').split('/').filter(Boolean).pop() || String(p || '');++ // ── Attachments: the images and files a step read, wrote or produced (4.50.0) ──────────++ // The shape a step or a message carries:++ // attachments: [{ url, name, mime, size?, kind: 'image'|'video'|'audio'|'pdf'|'text'|'file', role: 'read'|'wrote'|'output' }]++ // The product supplies the URLs; the component never builds one from a local path.++ const ATT_KINDS = new Set(['image', 'video', 'audio', 'pdf', 'text', 'file']);++ const ATT_ROLES = new Set(['read', 'wrote', 'output']);++ const ATT_ROLE_LABEL = { read: 'read', wrote: 'wrote', output: 'produced' };++ const ATT_ICON = { image: 'img', video: 'video', audio: 'audio', pdf: 'doc', text: 'code', file: 'file' };++ const ATT_EXT = {++ image: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'bmp', 'ico'],++ video: ['mp4', 'webm', 'mov', 'm4v', 'ogv'],++ audio: ['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac'],++ pdf: ['pdf'],++ text: ['md', 'markdown', 'txt', 'json', 'jsonl', 'csv', 'tsv', 'log', 'yaml', 'yml', 'html', 'css', 'js', 'mjs', 'cjs',++ 'ts', 'tsx', 'jsx', 'py', 'go', 'rs', 'sh', 'sql', 'xml', 'toml', 'ini', 'env', 'conf'],++ };++ const attExt = (name) => (String(name || '').split('?')[0].split('#')[0].split('.').pop() || '').toLowerCase();++ /** What kind of thing an attachment is: what it says, or what its name ends in. */++ function attKind(mime, name, kind) {++ if (ATT_KINDS.has(kind)) return kind;++ const m = String(mime || '').toLowerCase().split(';')[0].trim();++ if (m.startsWith('image/')) return 'image';++ if (m.startsWith('video/')) return 'video';++ if (m.startsWith('audio/')) return 'audio';++ if (m === 'application/pdf') return 'pdf';++ if (m.startsWith('text/')) return 'text';++ if (m === 'application/json' || m === 'application/xml' || m === 'application/x-yaml') return 'text';++ const ext = attExt(name);++ for (const k of Object.keys(ATT_EXT)) if (ATT_EXT[k].includes(ext)) return k;++ return 'file';++ }++ /** The attachments of a step or a message, in the shape the component draws them. */++ function normAttachments(list) {++ const out = [], seen = new Set();++ for (const a of Array.isArray(list) ? list : []) {++ if (!a || typeof a !== 'object') continue;++ const url = String(a.url || '').trim();++ if (!url || seen.has(url)) continue;++ seen.add(url);++ const name = String(a.name || '').trim() || base(String(url).split('?')[0]) || 'file';++ 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' });++ }++ return out;++ }++ /** The page's own origin, or Media's: an attachment loads from there and nowhere else. */++ function attAllowed(url, extra) {++ const raw = String(url || '').trim();++ if (!raw) return false;++ if (/^(blob:|data:image\/(png|jpe?g|gif|webp);)/i.test(raw)) return true; // the page made it itself++ if (raw.startsWith('/') && !raw.startsWith('//')) return true; // the product's own origin++ const here = (typeof location !== 'undefined' && location && location.hostname) ? String(location.hostname).toLowerCase() : '';++ let host = '';++ try { host = new URL(raw, here ? 'https://' + here : 'https://openvibe.network/').hostname.toLowerCase(); } catch { return false; }++ if (here && host === here) return true;++ if (host === 'openvibe.media' || host.endsWith('.openvibe.media')) return true;++ for (const o of Array.isArray(extra) ? extra : []) {++ const h = String(o || '').toLowerCase().replace(/^https?:\/\//, '').split('/')[0].split(':')[0];++ if (h && (host === h || host.endsWith('.' + h))) return true;++ }++ return false;++ }++ /** The tiles of a line: the images in one row, the other files as chips in a row under them. */++ function attStrip(list, onOpen) {++ const strip = el('div', 'ovas-att');++ const imgs = el('div', 'ovas-att-row');++ const chips = el('div', 'ovas-att-row');++ for (const a of list) {++ const b = el('button', a.kind === 'image' ? 'ovas-att-img' : 'ovas-att-chip');++ b.type = 'button';++ b.dataset.kind = a.kind;++ b.dataset.role = a.role;++ const role = ATT_ROLE_LABEL[a.role] || 'produced';++ b.title = role + ': ' + a.name;++ b.setAttribute('aria-label', 'Open ' + a.name + ' (' + role + ')');++ if (a.kind === 'image') {++ const img = el('img');++ img.loading = 'lazy'; img.decoding = 'async'; img.alt = a.name; img.src = a.url;++ img.addEventListener('error', () => { img.remove(); b.hidden = true; });++ b.appendChild(img);++ imgs.appendChild(b);++ } else {++ b.insertAdjacentHTML('beforeend', svg(ATT_ICON[a.kind] || 'file'));++ chips.appendChild(b);++ }++ b.appendChild(el('span', 'ovas-att-cap', a.name));++ b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onOpen(a); });++ }++ if (imgs.childNodes.length) strip.appendChild(imgs);++ if (chips.childNodes.length) strip.appendChild(chips);++ return strip;++ }++++ /** A text file as the preview shows it: a CSV as a table, JSON pretty-printed, Markdown read, code highlighted. */++ function attText(name, text, linkOk) {++ const ext = attExt(name);++ let body = String(text == null ? '' : text);++ if (ext === 'json' || ext === 'jsonl') { try { body = JSON.stringify(JSON.parse(body), null, 2); } catch { /* not one document */ } }++ if (ext === 'csv' || ext === 'tsv') { const table = attCsv(body, ext === 'tsv' ? '\t' : ','); if (table) return table; }++ if (ext === 'md' || ext === 'markdown') {++ const frag = markdown(body, linkOk);++ frag.querySelectorAll('pre code').forEach((code) => { const hi = attHighlight(code.textContent, ''); code.textContent = ''; code.appendChild(hi); });++ return frag;++ }++ const pre = el('pre', 'ovas-prev-code');++ const code = el('code');++ code.appendChild(attHighlight(body, ext === 'json' ? 'json' : ''));++ pre.appendChild(code);++ return pre;++ }++ /** Pinch to zoom on a phone, double tap to double, so a screenshot can be read. */++ function attZoom(img) {++ let scale = 1, start = null;++ const apply = () => { img.style.transform = scale > 1 ? 'scale(' + scale + ')' : ''; };++ img.addEventListener('touchstart', (e) => {++ 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 }; }++ }, { passive: true });++ img.addEventListener('touchmove', (e) => {++ if (!start || e.touches.length !== 2) return;++ e.preventDefault();++ const a = e.touches[0], b = e.touches[1];++ const d = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);++ scale = Math.min(4, Math.max(1, start.s * (d / (start.d || 1)))); apply();++ }, { passive: false });++ img.addEventListener('touchend', () => { start = null; });++ img.addEventListener('dblclick', () => { scale = scale > 1 ? 1 : 2; apply(); });++ img.addEventListener('wheel', (e) => { if (!e.ctrlKey) return; e.preventDefault(); scale = Math.min(4, Math.max(1, scale - e.deltaY / 200)); apply(); }, { passive: false });++ }++ /** A small highlighter for the preview: strings, numbers, keywords and comments, as spans (never markup). */++ function attHighlight(text, lang) {++ const frag = document.createDocumentFragment();++ const json = lang === 'json';++ const re = json++ ? /("(?:[^"\\]|\\.)*")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g++ : /(\/\/[^\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;++ const push = (s, cls) => { if (!s) return; frag.appendChild(cls ? el('span', cls, s) : document.createTextNode(s)); };++ let at = 0, m;++ while ((m = re.exec(text))) {++ push(text.slice(at, m.index));++ if (json) {++ if (m[1] != null) { push(m[1], m[2] != null ? 'ovas-tok-key' : 'ovas-tok-str'); if (m[2] != null) push(m[2], ''); }++ else if (m[3] != null) push(m[3], 'ovas-tok-kw');++ else push(m[4], 'ovas-tok-num');++ } else if (m[1] != null) push(m[1], 'ovas-tok-com');++ else if (m[2] != null) push(m[2], 'ovas-tok-str');++ else if (m[3] != null) push(m[3], 'ovas-tok-kw');++ else push(m[4], 'ovas-tok-num');++ at = m.index + m[0].length;++ }++ push(text.slice(at));++ return frag;++ }++ /** A CSV as a table (quoted cells kept whole). */++ function attCsv(text, delim) {++ const rows = String(text || '').split(/\r?\n/).filter((l) => l.trim() !== '');++ if (!rows.length) return null;++ const split = (line) => {++ const out = []; let cur = '', q = false;++ for (let i = 0; i < line.length; i++) {++ const ch = line[i];++ if (q) { if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else q = false; } else cur += ch; }++ else if (ch === '"') q = true;++ else if (ch === delim) { out.push(cur.trim()); cur = ''; }++ else cur += ch;++ }++ out.push(cur.trim());++ return out;++ };++ const head = split(rows[0]);++ const table = el('table', 'ovas-csv');++ const thead = el('thead'), tr = el('tr');++ head.forEach((h) => tr.appendChild(el('th', '', h || ' ')));++ thead.appendChild(tr); table.appendChild(thead);++ const tb = el('tbody');++ for (const line of rows.slice(1, 200)) {++ const r = el('tr'), cells = split(line);++ for (let i = 0; i < head.length; i++) r.appendChild(el('td', '', cells[i] == null ? '' : cells[i]));++ tb.appendChild(r);++ }++ table.appendChild(tb);++ return table;++ }++/** Light Markdown, text-safe: paragraphs, bullet and numbered lists, `code`, ```blocks```, **bold**, https links (only* those `linkOk(url)` allows; the others stay text). */function markdown(text, linkOk) {@@@ -243,6 +441,10 @@function mount(host, opts = {}) {styles();const linkOk = typeof opts.link === 'function' ? (u) => { try { return !!opts.link(u); } catch { return false; } } : null;++ // Attachments load from the product's own origin or Media's; a product that serves files from another host++ // says so: mount(el, { origins: ['files.openvibe.codes'] }).++ const origins = Array.isArray(opts.origins) ? opts.origins : (opts.origins ? [opts.origins] : []);++ const safeAtts = (list) => normAttachments(list).filter((a) => attAllowed(a.url, origins));const LIVE = ['running', 'placed', 'queued', 'verifying'];// The clock runs from startedAt, or from now for a run that is live as it mounts; a finished run replayed without// a start time shows the time only once its done line says it.@@@ -273,6 +475,63 @@});const empty = el('li', 'ovas-empty', opts.empty || 'Waiting for the agent to start…'); feed.appendChild(empty);++ // ── The preview sheet: one attachment, opened by a tap ──────────────────────────++ const sheet = el('div', 'ovas-sheet');++ sheet.hidden = true;++ 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>';++ host.appendChild(sheet);++ let sheetBack = null;++ function closeSheet() {++ if (sheet.hidden) return;++ sheet.hidden = true;++ sheet.querySelector('.ovas-sheet-body').textContent = '';++ if (sheetBack && sheetBack.focus) sheetBack.focus();++ sheetBack = null;++ }++ function openSheet(a) {++ const body = sheet.querySelector('.ovas-sheet-body');++ body.textContent = '';++ sheet.querySelector('.ovas-sheet-name').textContent = a.name;++ const open = sheet.querySelector('.ovas-sheet-open'); open.href = a.url; open.setAttribute('aria-label', 'Open ' + a.name);++ const dl = sheet.querySelector('.ovas-sheet-dl'); dl.href = a.url; dl.setAttribute('download', a.name);++ const wrap = el('div', 'ovas-prev');++ if (a.kind === 'image') {++ const img = el('img', 'ovas-prev-img'); img.alt = a.name; img.src = a.url; wrap.appendChild(img); attZoom(img);++ } else if (a.kind === 'video') {++ const v = el('video', 'ovas-prev-media'); v.controls = true; v.playsInline = true; v.preload = 'metadata'; v.src = a.url; wrap.appendChild(v);++ } else if (a.kind === 'audio') {++ const au = el('audio', 'ovas-prev-audio'); au.controls = true; au.preload = 'metadata'; au.src = a.url; wrap.appendChild(au);++ } else if (a.kind === 'pdf') {++ const f = el('iframe', 'ovas-prev-pdf'); f.src = a.url; f.title = a.name; wrap.appendChild(f);++ } else if (a.kind === 'text') {++ const holder = el('div', 'ovas-prev-text');++ holder.appendChild(el('p', 'ovas-prev-note', 'Loading…'));++ wrap.appendChild(holder);++ fetch(a.url, { credentials: 'same-origin' })++ .then((r) => (r.ok ? r.text() : Promise.reject(new Error('HTTP ' + r.status))))++ .then((t) => { holder.textContent = ''; holder.appendChild(attText(a.name, t, linkOk)); })++ .catch((err) => { holder.textContent = 'Could not open it: ' + err.message; });++ } else {++ wrap.appendChild(el('p', 'ovas-prev-note', 'There is no preview for this file. Open it or download it.'));++ }++ body.appendChild(wrap);++ sheetBack = (typeof document !== 'undefined' && document.activeElement) || null;++ sheet.hidden = false;++ const x = sheet.querySelector('.ovas-sheet-x'); if (x && x.focus) x.focus();++ }++ sheet.querySelector('.ovas-sheet-x').addEventListener('click', closeSheet);++ sheet.addEventListener('click', (e) => { if (e.target === sheet) closeSheet(); });++ document.addEventListener('keydown', (e) => {++ if (sheet.hidden) return;++ if (e.key === 'Escape') { e.preventDefault(); closeSheet(); return; }++ if (e.key !== 'Tab') return;++ const f = [...sheet.querySelectorAll('button, a[href], iframe')].filter((n) => !n.disabled && n.offsetParent !== null);++ if (!f.length) return;++ const first = f[0], last = f[f.length - 1];++ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }++ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }++ });++function applyFilter() {const files = S.filter === 'files';feed.hidden = files; filesEl.hidden = !files;@@@ -340,7 +599,7 @@d.dataset.id = step.id;const sum = el('summary');const info = describe(step); step.info = info;-- 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>`;++ 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>`;sum.querySelector('b').textContent = info.title;sum.querySelector('small').textContent = info.sub;if (!info.sub) sum.querySelector('small').hidden = true;@@@ -383,7 +642,7 @@if (last && last.classList.contains('ovas-group-li')) return last;const li = el('li', 'ovas-group-li'); li.dataset.k = 'steps';const g = el('details', 'ovas-group');-- 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></summary><ol></ol>`;++ 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>`;li.appendChild(g); parentFeed.appendChild(li);li.steps = [];return li;@@@ -408,13 +667,42 @@// One step alone reads as itself, not as a group of one.g.classList.toggle('is-one', steps.length === 1);if (steps.length === 1) g.open = true;++ // The group's attachments, in its header row, so they show while the group is folded.++ const atts = [], seenAtt = new Set();++ for (const s of steps) for (const a of (s.attachments || [])) { if (!seenAtt.has(a.url)) { seenAtt.add(a.url); atts.push(a); } }++ const slot = g.querySelector('.ovas-att-slot');++ slot.textContent = '';++ // A group of one hides its header, so there the step's own row carries the attachments instead.++ if (atts.length && steps.length > 1) slot.appendChild(attStrip(atts, openSheet));++ // A group of one shows the step's own row; there the attachments belong to the step.++ for (const s of steps) {++ const ss = s.el && s.el.querySelector(':scope>summary .ovas-att-slot');++ if (!ss) continue;++ ss.textContent = '';++ if (steps.length === 1 && s.attachments && s.attachments.length) ss.appendChild(attStrip(s.attachments, openSheet));++ }}function feedFor(parent) { const p = parent && S.steps.get(parent); return p && p.subEl ? p.subEl : feed; }function appendLi(target, li) { if (target === feed) { if (empty.parentNode) empty.remove(); feed.appendChild(li); } else target.appendChild(li); }const pending = new Map(); // tool name → ids of its steps still waiting for a result (events without ids)++ // Any line may carry attachments, and a line of a type this component does not know still shows: a minimal++ // row with its label or summary, then its attachments (Codes' proof lines, for one).++ const KNOWN = new Set(['agent', 'text', 'tool', 'tool_progress', 'tool_result', 'handoff', 'note', 'step', 'usage', 'done', 'state']);++ function extraLine(x, target) {++ const li = el('li', 'ovas-extra');++ li.dataset.k = 'steps'; // it is part of the work, so the Steps filter keeps it++ const label = String(x.label || x.kind || '');++ const summary = String(x.summary || x.text || '');++ const text = label && summary && label !== summary ? label + ' — ' + summary : (summary || label);++ if (text) li.appendChild(el('span', '', text));++ const atts = safeAtts(x.attachments);++ if (atts.length) li.appendChild(attStrip(atts, openSheet));++ appendLi(target, li);++ }function onLine(x) {const target = feedFor(x.parent);++ if (!KNOWN.has(x.t)) { extraLine(x, target); return; }// Who is working: a line that names a different agent than the last moves the head along.// A done line reports an agent's cost and time; it never moves the head.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(); }@@@ -427,11 +715,14 @@paintHead(); return;}if (x.t === 'text') {-- const li = el('li', 'ovas-msg'); li.dataset.k = 'msg'; li.appendChild(markdown(x.text, linkOk)); appendLi(target, li); return;++ const li = el('li', 'ovas-msg'); li.dataset.k = 'msg'; li.appendChild(markdown(x.text, linkOk));++ const atts = safeAtts(x.attachments);++ if (atts.length) li.appendChild(attStrip(atts, openSheet));++ appendLi(target, li); return;}if (x.t === 'tool') {const kind = kindOf(x.name);-- const step = { id: x.id || `s${S.steps.size}`, name: x.name, kind, input: x.input || {}, what: x.what || '', started: Date.now(), done: false, ok: null, out: '' };++ const step = { id: x.id || `s${S.steps.size}`, name: x.name, kind, input: x.input || {}, what: x.what || '', started: Date.now(), done: false, ok: null, out: '', attachments: safeAtts(x.attachments) };if (kind === 'plan') { if (Array.isArray(step.input.todos)) { S.plan = step.input.todos; paintPlan(); } step.hidden = true; S.steps.set(step.id, step); return; }S.steps.set(step.id, step);const g = groupFor(target === feed ? feed : target);@@@ -447,6 +738,12 @@}if (x.t === 'tool_result') {const step = S.steps.get(String(x.id)); if (!step || step.hidden) return;++ // A result may carry the files the step produced; the group's header shows them.++ const extra = safeAtts(x.attachments);++ if (extra.length) {++ const have = step.attachments || (step.attachments = []);++ for (const a of extra) if (!have.some((b) => b.url === a.url)) have.push(a);++ }step.done = true; step.ok = x.ok !== false; step.ended = x.duration_ms > 0 ? step.started + Number(x.duration_ms) : Date.now();if (Number(x.cost_usd) > 0) step.cost = Number(x.cost_usd);if (step.outEl) {@@@ -466,15 +763,23 @@if (x.to && (!S.agents.length || S.agents[S.agents.length - 1].agent !== x.to)) S.agents.push({ agent: x.to });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'));s.appendChild(row); if (x.reason) s.appendChild(el('small', '', x.reason));++ const handAtts = safeAtts(x.attachments);++ if (handAtts.length) s.appendChild(attStrip(handAtts, openSheet));li.appendChild(s); appendLi(feed, li); S.problems++; paintHead(); return;}if (x.t === 'note') {const li = el('li', 'ovas-note'); li.dataset.k = 'note'; li.dataset.level = x.level === 'warning' || x.level === 'info' ? x.level : 'error';if (li.dataset.level !== 'info') li.dataset.p = '1';-- li.innerHTML = svg(li.dataset.level === 'info' ? 'ok' : 'warn'); li.appendChild(el('span', '', x.text)); appendLi(target, li); if (li.dataset.level !== 'info') S.problems++; paintHead(); return;++ li.innerHTML = svg(li.dataset.level === 'info' ? 'ok' : 'warn'); li.appendChild(el('span', '', x.text));++ const noteAtts = safeAtts(x.attachments);++ if (noteAtts.length) li.appendChild(attStrip(noteAtts, openSheet));++ appendLi(target, li); if (li.dataset.level !== 'info') S.problems++; paintHead(); return;}if (x.t === 'step') {-- const li = el('li', 'ovas-line'); li.dataset.k = 'line'; li.appendChild(el('span', '', x.text)); appendLi(feed, li); return;++ const li = el('li', 'ovas-line'); li.dataset.k = 'line'; li.appendChild(el('span', '', x.text));++ const lineAtts = safeAtts(x.attachments);++ if (lineAtts.length) li.appendChild(attStrip(lineAtts, openSheet));++ appendLi(feed, li); return;}if (x.t === 'usage') { S.tokens.in += Number(x.input_tokens) || 0; S.tokens.out += Number(x.output_tokens) || 0; return; }if (x.t === 'done') {@@@ -486,6 +791,8 @@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)}` : ''}`));li.appendChild(b);if (x.text) { const m = el('div', 'ovas-msg'); m.appendChild(markdown(x.text, linkOk)); li.appendChild(m); }++ const doneAtts = safeAtts(x.attachments);++ if (doneAtts.length) li.appendChild(attStrip(doneAtts, openSheet));if (!x.ok) li.dataset.p = '1';appendLi(feed, li); paintHead();}@@@ -62,6 +62,38 @@ agents… in a smart way… way better than this idea."**Links:** `mount(el, { link: (url) => … })` decides which https addresses in the agents' text become links; the reststay text. A site whose agents read untrusted pages should link only its own (Actor links only the person's desk).++## Attachments++++A step or a message may carry the images and files the agent read, wrote or produced:++++```js++attachments: [{ url, name, mime, size?, kind: 'image'|'video'|'audio'|'pdf'|'text'|'file', role: 'read'|'wrote'|'output' }]++```++++The product supplies the URLs; the component never builds one from a local path. `kind` may be left out — the mime type++and the name's ending decide. `role` says what the agent did with it (`read`, `wrote`, `output`).++++- **Any line may carry them.** The component shows a line's attachments for every line it draws.++- A **step's** attachments show in its group's header row, so they are visible while the group is folded. Images are++ 136 × 92 thumbnails with their name; everything else is a chip with an icon.++- A **message's** attachments show under its text: the images in one row, the other files as chips in a row under them.++- A line of a **type the component does not know** (Codes' `t: 'proof'`) is drawn as a minimal row: its label or++ summary, then its attachments, instead of being dropped.++- A tap opens a **preview sheet**: the image at full width (pinch to zoom on a phone), video and audio in their++ players, a PDF in a frame, and text as code, Markdown, a CSV table or pretty JSON, highlighted. There are Open and++ Download links too.++- The thumbnails load lazily, and a broken image hides itself.++- The sheet is keyboard reachable: a button opens it, Escape closes it, and the focus stays inside it.++++**Safety.** An attachment loads only from the product's own origin or from Media's — never from a third-party address++the model printed. A product that serves files from another host says so: `mount(el, { origins: ['files.openvibe.codes'] })`.++SVG is only ever an `<img>`, never inline. Text is fetched and set as text, never as markup, so a file can never run++code in the page.++++The lines that carry them: a Codes job line `{ t: 'tool', …, attachments }` (the step), `{ t: 'text', …, attachments }`++(the message), `{ t: 'tool_result', …, attachments }` (added to the step it ends), `{ t: 'done', …, attachments }` (the++end card); and an Actor task event `{ kind: 'output', step: 'text' | 'tool_call' | 'tool_result', …, attachments }`.++## The shapes it reads**1. Claude Code's stream-json**, the shape every Codes harness adapter emits. See OpenVibe.Codes' `harness/events.js`:@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.49.0",++ "version": "4.50.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {nnew file mode 100644@@@ -0,0 +1,231 @@++'use strict';++// agent-session.js in a real headless Chrome: the attachments of a step and a message (4.50.0). A group's header++// shows them while the group is folded; a message's sit under its text. A tap opens a preview sheet that shows the++// file the way its kind asks for — an image, a video, an audio, a PDF, text — with Open and Download. Text is set as++// text (never as markup), an attachment loads only from the page's own origin, and a broken image hides itself.++// Skipped without Chrome.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const zlib = require('zlib');++const h = require('../browser-harness');++++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('agent-session-embeds-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++const ROOT = path.join(__dirname, '..');++++// A small PNG, made here so the test needs nothing else.++function crc32(buf) {++ let c, crc = 0xffffffff;++ for (let n = 0; n < buf.length; n++) {++ c = (crc ^ buf[n]) & 0xff;++ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;++ crc = c ^ (crc >>> 8);++ }++ return (crc ^ 0xffffffff) >>> 0;++}++function chunk(type, data) {++ const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);++ const t = Buffer.from(type, 'ascii');++ const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([t, data])), 0);++ return Buffer.concat([len, t, data, crc]);++}++function png(w, h, rgb) {++ const raw = Buffer.alloc((w * 3 + 1) * h);++ let o = 0;++ for (let y = 0; y < h; y++) { raw[o++] = 0; for (let x = 0; x < w; x++) { raw[o++] = rgb[0]; raw[o++] = rgb[1]; raw[o++] = rgb[2]; } }++ const ihdr = Buffer.alloc(13);++ ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;++ return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),++ chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);++}++const SHOT = png(240, 150, [64, 132, 214]);++const SHOT2 = png(240, 150, [46, 160, 96]);++const NOTES = '# Proof\n\n<script>window.__pwned = 1</script>\n\n<img src=x onerror="window.__pwned=2">\n\n| a | b |\n';++const CODE = 'const a = "<script>alert(1)</script>";\n// a comment\n';++const CSV = 'name,size\nshot.png,12\nother.png,34\n';++const JSONF = '{"a":1,"b":[2,3]}';++++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Embeds</title><link rel="icon" href="data:,"></head>++<body style="margin:0;padding:0 16px"><div id="s"></div><script src="/agent-session.js"></script>++<script>window.v = OpenVibeAgentSession.mount(document.getElementById('s'), { title: 'Fix the stats', status: 'running' });</script></body></html>`;++++const server = http.createServer((req, res) => {++ const u = req.url.split('?')[0];++ if (u === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE); }++ if (u === '/agent-session.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, 'agent-session.js'))); }++ if (u === '/agent-session.css') { res.writeHead(200, { 'content-type': 'text/css' }); return res.end(fs.readFileSync(path.join(ROOT, 'agent-session.css'))); }++ if (u === '/shot.png') { res.writeHead(200, { 'content-type': 'image/png' }); return res.end(SHOT); }++ if (u === '/shot2.png') { res.writeHead(200, { 'content-type': 'image/png' }); return res.end(SHOT2); }++ if (u === '/broken.png') { res.writeHead(404, { 'content-type': 'text/plain' }); return res.end('no'); }++ if (u === '/notes.md') { res.writeHead(200, { 'content-type': 'text/markdown' }); return res.end(NOTES); }++ if (u === '/code.js') { res.writeHead(200, { 'content-type': 'text/javascript' }); return res.end(CODE); }++ if (u === '/table.csv') { res.writeHead(200, { 'content-type': 'text/csv' }); return res.end(CSV); }++ if (u === '/data.json') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSONF); }++ if (u === '/movie.mp4') { res.writeHead(200, { 'content-type': 'video/mp4' }); return res.end(Buffer.alloc(16)); }++ if (u === '/sound.mp3') { res.writeHead(200, { 'content-type': 'audio/mpeg' }); return res.end(Buffer.alloc(16)); }++ if (u === '/doc.pdf') { res.writeHead(200, { 'content-type': 'application/pdf' }); return res.end('%PDF-1.4\n%%EOF\n'); }++ res.writeHead(404); return res.end();++});++++const att = (url, name, kind, role, mime) => ({ url, name, mime: mime || '', kind, role: role || 'output' });++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-ovas-embeds-'));++ const browser = await h.launch({ tmpDir });++ const push = (p, e) => p.evaluate(`window.v.push(${JSON.stringify(e)}), 1`);++ try {++ const context = await browser.newContext();++ const p = await h.openPage(browser, context, { width: 390, height: 900 });++ await p.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 8000 });++++ // A step's attachment shows in its group's header, while the group is folded.++ await push(p, { t: 'tool', id: 'r1', name: 'Read', input: { file_path: 'shot.png' }, attachments: [att('/shot.png', 'shot.png', 'image', 'read', 'image/png')] });++ await push(p, { t: 'tool', id: 'c1', name: 'Bash', input: { command: 'npm test' } });++ await p.evaluate(`(() => { const g = document.querySelector('.ovas-group'); g.open = false; return 1 })()`);++ let v = await p.evaluate(`(() => { const g = document.querySelector('.ovas-group'); const tile = g.querySelector('summary .ovas-att-img'); const img = tile && tile.querySelector('img'); return {++ open: g.open, tile: !!tile, src: img && img.getAttribute('src'), cap: tile && tile.querySelector('.ovas-att-cap').textContent,++ inSummary: !!(tile && tile.closest('summary')), height: tile ? tile.getBoundingClientRect().height : 0 } })()`);++ assert.ok(!v.open, 'the group is folded');++ assert.ok(v.tile && v.inSummary, "a step's attachment shows in its group's header, folded or not");++ assert.strictEqual(v.src, '/shot.png', 'the thumbnail is the URL the product gave');++ assert.strictEqual(v.cap, 'shot.png', 'the tile carries the name');++ assert.ok(v.height > 0, 'the thumbnail is drawn while the group is folded');++++ // A message's attachment shows under its text.++ await push(p, { t: 'text', text: 'Here is the proof.', attachments: [att('/shot.png', 'proof.png', 'image', 'output', 'image/png')] });++ v = await p.evaluate(`(() => { const m = [...document.querySelectorAll('.ovas-msg')].pop(); const img = m.querySelector('.ovas-att-img');++ const kids = [...m.children]; const strip = m.querySelector('.ovas-att'); const para = m.querySelector('p');++ return { text: (para && para.textContent || '').trim(), img: !!img, after: !!(strip && para && kids.indexOf(strip) > kids.indexOf(para)) } })()`);++ assert.ok(v.img && v.after, "a message's attachment shows under its text");++++ // The images in one row, the other files as chips in a row under them.++ await push(p, { t: 'text', text: 'Two kinds', attachments: [++ att('/shot.png', 'one.png', 'image', 'read', 'image/png'), att('/shot2.png', 'two.png', 'image', 'read', 'image/png'),++ att('/data.json', 'three.json', 'text', 'read', 'application/json')] });++ v = await p.evaluate(`(() => { const m = [...document.querySelectorAll('.ovas-msg')].pop(); const rows = m.querySelectorAll('.ovas-att-row');++ return { rows: rows.length, imgs: rows[0] ? rows[0].querySelectorAll('.ovas-att-img').length : 0, chips: rows[1] ? rows[1].querySelectorAll('.ovas-att-chip').length : 0 } })()`);++ assert.deepStrictEqual([v.rows, v.imgs, v.chips], [2, 2, 1], 'the images in one row, the chips in a row under them');++++ // A line of a type the component does not know still shows: its label or summary, then its attachments.++ await push(p, { t: 'proof', kind: 'screenshots', label: 'screenshots', ok: true, summary: '2 screenshots of 1 page at 390px, clean',++ attachments: [att('/shot.png', 'proof.png', 'image', 'output', 'image/png')] });++ v = await p.evaluate(`(() => { const row = document.querySelector('.ovas-extra'); return {++ there: !!row, text: row ? row.textContent : '', img: !!(row && row.querySelector('.ovas-att-img')) } })()`);++ assert.ok(v.there && v.img, 'an unknown line shows its attachments');++ assert.ok(v.text.includes('screenshots') && v.text.includes('390px'), 'and says what it is');++++ // A tap opens the preview sheet.++ await p.evaluate(`document.querySelector('.ovas-msg .ovas-att-img').click(), 1`);++ v = await p.evaluate(`(() => { const s = document.querySelector('.ovas-sheet'); return {++ open: !s.hidden, img: !!s.querySelector('.ovas-prev-img'), src: s.querySelector('.ovas-prev-img') && s.querySelector('.ovas-prev-img').getAttribute('src'),++ name: s.querySelector('.ovas-sheet-name').textContent, openHref: s.querySelector('.ovas-sheet-open').getAttribute('href'),++ dl: s.querySelector('.ovas-sheet-dl').getAttribute('download'), focused: document.activeElement.className } })()`);++ assert.ok(v.open && v.img, 'the preview opens with the image');++ assert.strictEqual(v.src, '/shot.png', 'the preview shows the image at full width');++ assert.strictEqual(v.name, 'proof.png', 'the sheet says which file it is');++ assert.strictEqual(v.openHref, '/shot.png', 'and offers to open it');++ assert.strictEqual(v.dl, 'proof.png', 'and to download it');++ assert.ok(/ovas-sheet-x/.test(v.focused), 'the keyboard goes to the close button');++++ // Escape closes it.++ await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })), 1`);++ v = await p.evaluate(`document.querySelector('.ovas-sheet').hidden`);++ assert.strictEqual(v, true, 'Escape closes the preview');++++ // One step, one file of every kind: each opens the way its kind asks for.++ await push(p, { t: 'tool', id: 'k1', name: 'Read', input: { file_path: 'bundle' }, attachments: [++ att('/shot.png', 'shot.png', 'image', 'read', 'image/png'), att('/movie.mp4', 'movie.mp4', 'video', 'output', 'video/mp4'),++ att('/sound.mp3', 'sound.mp3', 'audio', 'output', 'audio/mpeg'), att('/doc.pdf', 'doc.pdf', 'pdf', 'output', 'application/pdf'),++ att('/code.js', 'code.js', 'text', 'wrote', 'text/javascript')] });++ const kinds = await p.evaluate(`(() => { const gs = document.querySelectorAll('.ovas-group'); const g = gs[gs.length - 1]; const tiles = g.querySelectorAll('.ovas-att [data-kind]'); return [...tiles].map((t) => t.dataset.kind) })()`);++ assert.deepStrictEqual(kinds, ['image', 'video', 'audio', 'pdf', 'text'], 'every kind of file is a tile of its own');++ const openBy = async (name) => {++ await p.evaluate(`(() => { const t = [...document.querySelectorAll('.ovas-att [data-kind]')].find((n) => n.querySelector('.ovas-att-cap').textContent === ${JSON.stringify(name)}); if (!t) throw new Error('no tile ' + ${JSON.stringify(name)}); t.click(); return 1 })()`);++ // A text file is fetched, so the sheet fills a moment later.++ for (let i = 0; i < 30; i++) {++ const loading = await p.evaluate(`(() => { const b = document.querySelector('.ovas-sheet-body'); return !b || b.textContent.indexOf('Loading') === 0; })()`);++ if (!loading) break;++ await p.evaluate(`new Promise((r) => setTimeout(r, 100)), 1`);++ }++ return p.evaluate(`(() => { const s = document.querySelector('.ovas-sheet'); const b = s.querySelector('.ovas-sheet-body'); return {++ video: !!b.querySelector('video'), audio: !!b.querySelector('audio'), pdf: !!b.querySelector('iframe'),++ code: !!b.querySelector('pre code'), text: b.textContent, scripts: b.querySelectorAll('script').length,++ pwned: window.__pwned === undefined } })()`);++ };++ v = await openBy('movie.mp4');++ assert.ok(v.video, 'a video opens in its player');++ v = await openBy('sound.mp3');++ assert.ok(v.audio, 'audio opens in its player');++ v = await openBy('doc.pdf');++ assert.ok(v.pdf, 'a PDF opens in a frame');++ v = await openBy('code.js');++ assert.ok(v.code, 'text opens as code');++ assert.ok(v.text.includes('<script>alert(1)</script>'), 'the code is there as text');++ assert.strictEqual(v.scripts, 0, 'and never as markup');++ assert.ok(v.pwned, 'the text never ran');++++ // A CSV as a table, JSON pretty-printed.++ await push(p, { t: 'tool', id: 'k2', name: 'Read', input: { file_path: 'table.csv' }, attachments: [att('/table.csv', 'table.csv', 'text', 'read', 'text/csv')] });++ await openBy('table.csv');++ for (let i = 0; i < 30 && !(await p.evaluate(`!!document.querySelector('.ovas-sheet-body .ovas-csv')`)); i++) await p.evaluate(`new Promise((r) => setTimeout(r, 100)), 1`);++ v = await p.evaluate(`(() => { const b = document.querySelector('.ovas-sheet-body'); const t = b.querySelector('.ovas-csv'); return {++ table: !!t, head: t && t.querySelector('th').textContent, rows: t ? t.querySelectorAll('tbody tr').length : 0,++ cell: t && t.querySelector('tbody td').textContent } })()`);++ assert.ok(v.table && v.head === 'name' && v.rows === 2 && v.cell === 'shot.png', 'a CSV opens as a table');++ await push(p, { t: 'tool', id: 'k3', name: 'Read', input: { file_path: 'data.json' }, attachments: [att('/data.json', 'data.json', 'text', 'read', 'application/json')] });++ await openBy('data.json');++ for (let i = 0; i < 30 && !(await p.evaluate(`document.querySelector('.ovas-sheet-body').textContent.includes('"a"')`)); i++) await p.evaluate(`new Promise((r) => setTimeout(r, 100)), 1`);++ v = await p.evaluate(`document.querySelector('.ovas-sheet-body').textContent`);++ assert.ok(v.includes('"a": 1'), 'JSON is pretty-printed (got: ' + v.slice(0, 200) + ')');++++ // A third-party address the model printed never loads.++ await push(p, { t: 'text', text: 'Not mine', attachments: [att('https://evil.example.com/x.png', 'x.png', 'image', 'output', 'image/png')] });++ v = await p.evaluate(`(() => { const m = [...document.querySelectorAll('.ovas-msg')].pop(); return {++ tiles: m.querySelectorAll('.ovas-att [data-kind]').length, srcs: [...document.querySelectorAll('.ovas-att img')].map((i) => i.getAttribute('src')) } })()`);++ assert.strictEqual(v.tiles, 0, "a third-party address is not shown");++ assert.ok(!v.srcs.includes('https://evil.example.com/x.png'), 'and never loaded');++++ // A broken image hides itself.++ await push(p, { t: 'text', text: 'Broken', attachments: [att('/broken.png', 'broken.png', 'image', 'output', 'image/png')] });++ await p.evaluate(`(() => { const m = [...document.querySelectorAll('.ovas-msg')].pop(); m.scrollIntoView(); const img = m.querySelector('.ovas-att-img img'); if (img) img.loading = 'eager'; return 1 })()`);++ let gone = false;++ for (let i = 0; i < 60 && !gone; i++) {++ await p.evaluate(`new Promise((r) => setTimeout(r, 100)), 1`);++ gone = await p.evaluate(`(() => { const m = [...document.querySelectorAll('.ovas-msg')].pop(); const b = m.querySelector('.ovas-att [data-kind]'); return !!(b && b.hidden && !b.querySelector('img')); })()`);++ }++ assert.ok(gone, 'a broken image hides itself');++++ // The phone: nothing sideways, and every target big enough to tap.++ v = await p.evaluate(`(() => { const doc = document.documentElement; const chips = [...document.querySelectorAll('.ovas-att-chip')]; return {++ wide: doc.scrollWidth, chips: chips.map((c) => Math.round(c.getBoundingClientRect().height)) } })()`);++ assert.ok(v.wide <= 390, 'nothing scrolls sideways at 390 px (' + v.wide + ')');++ const sticky = await p.evaluate(`getComputedStyle(document.querySelector('.ovas-head')).position`);++ assert.strictEqual(sticky, 'static', 'the head does not stick on a phone, so it never covers the steps');++ assert.ok(v.chips.every((h) => h >= 44), 'a chip is a 44 px target on a phone');++++ // At 320 the filter row scrolls inside its own box, and the page does not scroll sideways.++ const narrow = await h.openPage(browser, context, { width: 320, height: 800 });++ await narrow.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 8000 });++ await narrow.evaluate(`window.v.push(${JSON.stringify({ t: 'text', text: 'A line of text.' })}), 1`);++ const nv = await narrow.evaluate(`(() => { const bar = document.querySelector('.ovas-bar'); return {++ wide: document.documentElement.scrollWidth, bar: Math.round(bar.getBoundingClientRect().width),++ box: Math.round(document.querySelector('.ovas').getBoundingClientRect().width),++ scrolls: bar.scrollWidth > bar.clientWidth } })()`);++ assert.ok(nv.wide <= 320, 'nothing scrolls sideways at 320 px (' + nv.wide + ')');++ assert.ok(nv.bar <= nv.box + 1, 'the filter row stays inside the session (' + nv.bar + ' in ' + nv.box + ')');++ await narrow.close();++++ await p.close();++ console.log('agent-session-embeds-chrome: attachments, previews and safety ok');++ } finally {++ await browser.close().catch(() => {});++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e && e.stack || e); process.exit(1); });
@@ -6,6 +6,20 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,20 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.50.0 — 2026-10-30
+
+- **Rich embeds in the agent session.** The owner, 2026-10-30: "When it generates or reads or analyzed images or visuals
+ or files it should show a rich embed in chat for them like in Claude, Codex, ChatGPT." A step or a message may now
+ carry `attachments: [{ url, name, mime, size?, kind, role }]`; the component draws them and never builds a URL itself.
+ - A step's attachments show in its group's header, visible while the group is folded: images as 136 × 92 thumbnails
+ with their name, everything else as a chip with an icon. A message's show under its text.
+ - A tap opens a preview sheet: the image at full width (pinch to zoom on a phone), video and audio in their players,
+ a PDF in a frame, and text as code, Markdown, a CSV table or pretty JSON, highlighted — plus Open and Download.
+ - The thumbnails load lazily, a broken image hides itself, and the sheet is keyboard reachable (Escape closes it).
+ - Safety: an attachment loads only from the product's own origin or Media's, never from a third-party address the
+ model printed; SVG is only ever an `<img>`; text is set as text, never as markup.
+ - `test/agent-session-embeds-chrome.test.js` covers it in a real Chrome.
+
## 4.49.0 — 2026-10-30
## 4.49.0 — 2026-10-30
- **Shared motion tokens for production patterns.** Adds standard and back easing curves plus fast, base, slow, and reduced durations (180, 260, 360, and 0 ms).
- **Shared motion tokens for production patterns.** Adds standard and back easing curves plus fast, base, slow, and reduced durations (180, 260, 360, and 0 ms).
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.49.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.50.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.49.0",
+ "version": "4.50.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -32,7 +32,7 @@
@@ -32,7 +32,7 @@
.ovas-plan li[data-s="in_progress"]::before { border-color: var(--accent, #3b82f6); border-right-color: transparent; animation: ovas-spin .9s linear infinite; }
.ovas-plan li[data-s="in_progress"]::before { border-color: var(--accent, #3b82f6); border-right-color: transparent; animation: ovas-spin .9s linear infinite; }
.ovas-plan li[data-s="completed"] { color: var(--ovas-mut); text-decoration: line-through; }
.ovas-plan li[data-s="completed"] { color: var(--ovas-mut); text-decoration: line-through; }
.ovas-plan li[data-s="completed"]::before { background: var(--ovas-ok); border-color: var(--ovas-ok); }
.ovas-plan li[data-s="completed"]::before { background: var(--ovas-ok); border-color: var(--ovas-ok); }
-.ovas-bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
+.ovas-bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; min-width: 0; }
.ovas-chip { height: 30px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--ovas-b); background: transparent; color: var(--ovas-sec); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; }
.ovas-chip { height: 30px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--ovas-b); background: transparent; color: var(--ovas-sec); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; }
.ovas-chip[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, #fff); }
.ovas-chip[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, #fff); }
.ovas-chip.ovas-follow { margin-left: auto; }
.ovas-chip.ovas-follow { margin-left: auto; }
@@ -89,7 +89,7 @@ details[open] > summary .ovas-chev { transform: rotate(90deg); }
@@ -89,7 +89,7 @@ details[open] > summary .ovas-chev { transform: rotate(90deg); }
.ovas-hand-row { display: inline-flex; align-items: center; gap: 6px; }
.ovas-hand-row { display: inline-flex; align-items: center; gap: 6px; }
.ovas-hand-row .ovas-ic { color: var(--ovas-mut); }
.ovas-hand-row .ovas-ic { color: var(--ovas-mut); }
.ovas-hand small { font-size: 12.5px; color: var(--ovas-mut); line-height: 1.4; }
.ovas-hand small { font-size: 12.5px; color: var(--ovas-mut); line-height: 1.4; }
-.ovas-note { display: flex; gap: 8px; align-items: flex-start; padding: 9px 12px; border-radius: 12px; font-size: 13.5px; background: color-mix(in srgb, #f59e0b 12%, transparent); color: var(--text-primary, #e6edf7); }
+.ovas-note { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-start; padding: 9px 12px; border-radius: 12px; font-size: 13.5px; background: color-mix(in srgb, #f59e0b 12%, transparent); color: var(--text-primary, #e6edf7); }
.ovas-note[data-level="error"] { background: color-mix(in srgb, #ef4444 13%, transparent); }
.ovas-note[data-level="error"] { background: color-mix(in srgb, #ef4444 13%, transparent); }
.ovas-note[data-level="info"] { background: color-mix(in srgb, #22c55e 10%, transparent); }
.ovas-note[data-level="info"] { background: color-mix(in srgb, #22c55e 10%, transparent); }
.ovas-note[data-level="info"] .ovas-ic { color: var(--ovas-ok); }
.ovas-note[data-level="info"] .ovas-ic { color: var(--ovas-ok); }
@@ -97,8 +97,9 @@ details[open] > summary .ovas-chev { transform: rotate(90deg); }
@@ -97,8 +97,9 @@ details[open] > summary .ovas-chev { transform: rotate(90deg); }
.ovas-done { display: grid; gap: 6px; padding: 12px 14px; border-radius: 14px; border: 1px solid color-mix(in srgb, #22c55e 40%, transparent); background: color-mix(in srgb, #22c55e 8%, transparent); }
.ovas-done { display: grid; gap: 6px; padding: 12px 14px; border-radius: 14px; border: 1px solid color-mix(in srgb, #22c55e 40%, transparent); background: color-mix(in srgb, #22c55e 8%, transparent); }
.ovas-done[data-ok="0"] { border-color: color-mix(in srgb, #ef4444 40%, transparent); background: color-mix(in srgb, #ef4444 8%, transparent); }
.ovas-done[data-ok="0"] { border-color: color-mix(in srgb, #ef4444 40%, transparent); background: color-mix(in srgb, #ef4444 8%, transparent); }
.ovas-done > b { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.ovas-done > b { display: flex; align-items: center; gap: 6px; font-size: 13px; }
-.ovas-line { display: flex; align-items: center; gap: 8px; padding: 0 4px; font-size: 12.5px; color: var(--ovas-mut); }
+.ovas-line { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 0 4px; font-size: 12.5px; color: var(--ovas-mut); }
.ovas-line::before { content: ''; flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--ovas-mut); opacity: .7; }
.ovas-line::before { content: ''; flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--ovas-mut); opacity: .7; }
+.ovas-extra { display: grid; gap: 8px; padding: 8px 12px; border-radius: 12px; border: 1px solid var(--ovas-b); background: var(--ovas-s); font-size: 13.5px; color: var(--ovas-sec); }
.ovas-empty { padding: 18px; text-align: center; color: var(--ovas-mut); font-size: 13.5px; border: 1px dashed var(--ovas-b); border-radius: 14px; }
.ovas-empty { padding: 18px; text-align: center; color: var(--ovas-mut); font-size: 13.5px; border: 1px dashed var(--ovas-b); border-radius: 14px; }
.ovas [hidden] { display: none !important; }
.ovas [hidden] { display: none !important; }
@keyframes ovas-spin { to { transform: rotate(360deg); } }
@keyframes ovas-spin { to { transform: rotate(360deg); } }
@@ -106,7 +107,7 @@ details[open] > summary .ovas-chev { transform: rotate(90deg); }
@@ -106,7 +107,7 @@ details[open] > summary .ovas-chev { transform: rotate(90deg); }
@media (max-width: 640px) {
@media (max-width: 640px) {
.ovas-head { padding: 10px 12px; border-radius: 14px; } .ovas-msg { font-size: 14.5px; }
.ovas-head { padding: 10px 12px; border-radius: 14px; } .ovas-msg { font-size: 14.5px; }
.ovas-code, .ovas-out, .ovas-diff { font-size: 12px; max-height: 45vh; }
.ovas-code, .ovas-out, .ovas-diff { font-size: 12px; max-height: 45vh; }
- .ovas-bar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin: 0 -14px; padding: 0 14px; } .ovas-bar::-webkit-scrollbar { display: none; }
+ .ovas-bar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding: 0 2px; } .ovas-bar::-webkit-scrollbar { display: none; }
.ovas-chip { flex: none; } .ovas-icons .ovas-kind + .ovas-kind { display: none; }
.ovas-chip { flex: none; } .ovas-icons .ovas-kind + .ovas-kind { display: none; }
}
}
@media (prefers-reduced-motion: reduce) { .ovas-spin, .ovas-plan li::before { animation: none; } }
@media (prefers-reduced-motion: reduce) { .ovas-spin, .ovas-plan li::before { animation: none; } }
@@ -136,3 +137,66 @@ details[open] > summary .ovas-chev { transform: rotate(90deg); }
@@ -136,3 +137,66 @@ details[open] > summary .ovas-chev { transform: rotate(90deg); }
.ovas-act.is-danger { color: var(--ovas-bad); border-color: color-mix(in srgb, var(--ovas-bad) 45%, var(--ovas-b)); }
.ovas-act.is-danger { color: var(--ovas-bad); border-color: color-mix(in srgb, var(--ovas-bad) 45%, var(--ovas-b)); }
.ovas-act:disabled { opacity: .55; cursor: default; }
.ovas-act:disabled { opacity: .55; cursor: default; }
.ovas-act:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
.ovas-act:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+
+/* Attachments (4.50.0): the images and files a step read, wrote or produced. A group's header shows them while the
+ group is folded; a message's sit under its text. The URLs come from the product; only the page's own origin and
+ Media's load. */
+.ovas-group > summary, .ovas-step > summary { flex-wrap: wrap; row-gap: 8px; }
+.ovas-att-slot { flex-basis: 100%; order: 9; display: block; min-width: 0; }
+.ovas-att-slot:empty { display: none; }
+.ovas-att { display: grid; gap: 8px; width: 100%; min-width: 0; max-width: 100%; }
+.ovas-att-row { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px; min-width: 0; }
+.ovas-att-img { box-sizing: border-box; display: block; width: 136px; flex: none; padding: 0; border: 1px solid var(--ovas-b); border-radius: 12px; overflow: hidden; background: var(--ovas-t); color: inherit; font: inherit; text-align: left; cursor: pointer; }
+.ovas-att-img img { display: block; width: 136px; height: 92px; object-fit: cover; background: var(--ovas-t); }
+.ovas-att-chip { box-sizing: border-box; display: inline-flex; align-items: center; gap: 7px; flex: none; max-width: 220px; min-height: 36px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--ovas-b); background: var(--ovas-t); color: var(--ovas-sec); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; }
+.ovas-att-chip .ovas-ic { width: 15px; height: 15px; flex: none; color: var(--accent-light, var(--accent, #60a5fa)); }
+.ovas-att-chip[data-role="read"] .ovas-ic { color: #38bdf8; }
+.ovas-att-chip[data-role="wrote"] .ovas-ic { color: #4ade80; }
+.ovas-att-cap { display: block; min-width: 0; font-size: 11.5px; line-height: 1.3; color: var(--ovas-mut); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.ovas-att-img .ovas-att-cap { padding: 5px 7px; }
+.ovas-att-chip .ovas-att-cap { padding: 0; }
+.ovas-att:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+.ovas-att-img:hover, .ovas-att-chip:hover { background: var(--bg-hover, rgba(255,255,255,.06)); }
+/* The preview sheet: one attachment, opened by a tap. */
+.ovas-sheet { box-sizing: border-box; position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 16px; background: color-mix(in srgb, var(--bg-primary, #0b1020) 80%, transparent); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
+.ovas-sheet[hidden] { display: none; }
+.ovas-sheet-panel { box-sizing: border-box; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; gap: 10px; width: min(960px, 92vw); max-height: min(92vh, 920px); padding: 14px; border-radius: 18px; border: 1px solid var(--ovas-b); background: var(--ovas-s); color: var(--text-primary, #e6edf7); box-shadow: 0 24px 70px rgba(0,0,0,.45); }
+.ovas-sheet-bar { display: flex; align-items: center; gap: 10px; min-width: 0; }
+.ovas-sheet-bar b { flex: 1; min-width: 0; font-size: 14px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovas-sheet-x { width: 40px; height: 40px; flex: none; border-radius: 999px; border: 1px solid var(--ovas-b); background: transparent; color: inherit; font: inherit; font-size: 15px; line-height: 1; cursor: pointer; }
+.ovas-sheet-x:focus-visible, .ovas-sheet-foot a:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+.ovas-sheet-body { min-width: 0; min-height: 0; overflow: auto; display: grid; }
+.ovas-prev { min-width: 0; display: grid; }
+.ovas-prev-img { display: block; width: 100%; height: auto; max-height: 74vh; object-fit: contain; border-radius: 12px; background: var(--ovas-t); touch-action: none; }
+.ovas-prev-media { display: block; width: 100%; max-height: 74vh; border-radius: 12px; background: #000; }
+.ovas-prev-audio { width: 100%; }
+.ovas-prev-pdf { width: 100%; height: 72vh; border: 0; border-radius: 12px; background: #fff; }
+.ovas-prev-code { margin: 0; padding: 12px; border-radius: 12px; border: 1px solid var(--ovas-b); background: var(--ovas-code); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; line-height: 1.5; overflow: auto; max-height: 70vh; white-space: pre; }
+.ovas-prev-text { min-width: 0; }
+.ovas-prev-text .ovas-msg { padding: 0; }
+.ovas-prev-note { margin: 0 0 10px; font-size: 13px; color: var(--ovas-mut); }
+.ovas-sheet-foot { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
+.ovas-sheet-foot a { display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--ovas-b); background: transparent; color: inherit; text-decoration: none; font-size: 13px; font-weight: 700; }
+.ovas-sheet-foot .ovas-sheet-open { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, #fff); }
+.ovas-csv { width: 100%; border-collapse: collapse; font-size: 12.5px; }
+.ovas-csv th, .ovas-csv td { border: 1px solid var(--ovas-b); padding: 5px 8px; text-align: left; white-space: nowrap; }
+.ovas-csv th { background: var(--ovas-t); font-weight: 700; }
+.ovas-tok-key { color: var(--accent-light, var(--accent, #60a5fa)); }
+.ovas-tok-str { color: var(--ovas-ok); }
+.ovas-tok-num { color: #fbbf24; }
+.ovas-tok-kw { color: #c084fc; }
+.ovas-tok-com { color: var(--ovas-mut); font-style: italic; }
+
+@media (max-width: 760px) {
+ /* A phone: the head does not stick, so it never floats over the steps it sits above. */
+ .ovas-head { position: static; }
+}
+@media (max-width: 640px) {
+ /* Phones: bigger tap targets, and the preview sheet takes the whole screen. */
+ .ovas-att-chip { min-height: 44px; padding: 0 14px; }
+ .ovas-att-chip .ovas-att-cap { font-size: 12px; }
+ .ovas-sheet { padding: 0; align-items: stretch; }
+ .ovas-sheet-panel { width: 100%; max-height: 100vh; height: 100vh; border-radius: 0; border: 0; padding: 12px; }
+ .ovas-sheet-x, .ovas-sheet-foot a { min-height: 44px; }
+ .ovas-prev-img { max-height: 68vh; }
+}
@@ -36,6 +36,15 @@
@@ -36,6 +36,15 @@
* { t: 'step', text } the job's own progress (cloning, the branch, files changed, pushing)
* { t: 'step', text } the job's own progress (cloning, the branch, files changed, pushing)
* { t: 'usage', input_tokens, output_tokens }
* { t: 'usage', input_tokens, output_tokens }
* { t: 'done', ok, text, duration_ms, cost_usd, turns }
* { t: 'done', ok, text, duration_ms, cost_usd, turns }
+ * A step or a message may carry the images and files the agent read, wrote or produced:
+ * attachments: [{ url, name, mime, size?, kind: 'image'|'video'|'audio'|'pdf'|'text'|'file', role: 'read'|'wrote'|'output' }]
+ * The product supplies the URLs; the component never builds one from a local path. Any line may carry attachments,
+ * and the component shows them for every line it draws. A line of a type it does not know (Codes' proof lines) is
+ * drawn as a minimal row: its label or summary, then its attachments. A step's attachments show in its
+ * group's header, visible while the group is folded; a message's show under its text. A tap opens a preview sheet: the
+ * image at full width (pinch to zoom), video and audio in their players, a PDF in a frame, text as code, Markdown, a CSV
+ * table or pretty JSON, plus Open and Download. An attachment loads only from the product's own origin or Media's,
+ * never a third-party address the model printed; SVG is only ever an <img>, and text is set as text, never as markup.
* A line from a sub-agent carries `parent`: the id of the Task step that started it, so it nests under that step.
* A line from a sub-agent carries `parent`: the id of the Task step that started it, so it nests under that step.
* Any line may carry `agent` (the head then follows who is working); a step without an id gets one, and the next result
* Any line may carry `agent` (the head then follows who is working); a step without an id gets one, and the next result
* of the same tool without an id is its result. { t: 'state', state } moves the head (Actor's task states).
* of the same tool without an id is its result. { t: 'state', state } moves the head (Actor's task states).
@@ -84,6 +93,12 @@
@@ -84,6 +93,12 @@
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"/>',
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"/>',
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"/>',
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"/>',
chevron: '<path d="m9 6 6 6-6 6"/>',
chevron: '<path d="m9 6 6 6-6 6"/>',
+ 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"/>',
+ video: '<rect x="3" y="5" width="13" height="14" rx="2"/><path d="m16 10 5-3v10l-5-3z"/>',
+ 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"/>',
+ doc: '<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4"/><path d="M9 12.5h6M9 16h6"/>',
+ code: '<path d="m9 8-5 4 5 4M15 8l5 4-5 4"/>',
+ file: '<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4"/>',
};
};
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>`;
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>`;
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' };
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' };
@@ -104,12 +119,13 @@
@@ -104,12 +119,13 @@
if (e.kind === 'state' || e.kind === 'end') return e.state ? [{ t: 'state', state: String(e.state), ...agent }] : [];
if (e.kind === 'state' || e.kind === 'end') return e.state ? [{ t: 'state', state: String(e.state), ...agent }] : [];
if (e.kind !== 'output') return [];
if (e.kind !== 'output') return [];
const text = cut(e.text, MAX_TEXT);
const text = cut(e.text, MAX_TEXT);
- if (e.step === 'text') return [e.is_error ? { t: 'note', level: 'error', text, ...agent } : { t: 'text', text, ...agent }];
+ const att = e.attachments ? { attachments: e.attachments } : {};
+ if (e.step === 'text') return [e.is_error ? { t: 'note', level: 'error', text, ...agent } : { t: 'text', text, ...att, ...agent }];
if (e.step === 'plan') return [{ t: 'step', text, ...agent }];
if (e.step === 'plan') return [{ t: 'step', text, ...agent }];
if (e.step === 'handoff') return [{ t: 'handoff', from: null, to: e.agent || null, reason: text, ...agent }];
if (e.step === 'handoff') return [{ t: 'handoff', from: null, to: e.agent || null, reason: text, ...agent }];
if (e.step === 'check') return [{ t: 'note', level: /^checked/i.test(text) ? 'info' : 'warning', text, ...agent }];
if (e.step === 'check') return [{ t: 'note', level: /^checked/i.test(text) ? 'info' : 'warning', text, ...agent }];
- 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) : '' }, ...agent }];
- if (e.step === 'tool_result') return [{ t: 'tool_result', name: String(e.tool || 'tool'), ok: !e.is_error, output: cut(e.text, 8000), ...agent }];
+ 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 }];
+ 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 }];
return text ? [{ t: 'text', text, ...agent }] : [];
return text ? [{ t: 'text', text, ...agent }] : [];
}
}
function normalize(e) {
function normalize(e) {
@@ -150,6 +166,188 @@
@@ -150,6 +166,188 @@
}
}
const base = (p) => String(p || '').split('/').filter(Boolean).pop() || String(p || '');
const base = (p) => String(p || '').split('/').filter(Boolean).pop() || String(p || '');
+ // ── Attachments: the images and files a step read, wrote or produced (4.50.0) ──────────
+ // The shape a step or a message carries:
+ // attachments: [{ url, name, mime, size?, kind: 'image'|'video'|'audio'|'pdf'|'text'|'file', role: 'read'|'wrote'|'output' }]
+ // The product supplies the URLs; the component never builds one from a local path.
+ const ATT_KINDS = new Set(['image', 'video', 'audio', 'pdf', 'text', 'file']);
+ const ATT_ROLES = new Set(['read', 'wrote', 'output']);
+ const ATT_ROLE_LABEL = { read: 'read', wrote: 'wrote', output: 'produced' };
+ const ATT_ICON = { image: 'img', video: 'video', audio: 'audio', pdf: 'doc', text: 'code', file: 'file' };
+ const ATT_EXT = {
+ image: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'bmp', 'ico'],
+ video: ['mp4', 'webm', 'mov', 'm4v', 'ogv'],
+ audio: ['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac'],
+ pdf: ['pdf'],
+ text: ['md', 'markdown', 'txt', 'json', 'jsonl', 'csv', 'tsv', 'log', 'yaml', 'yml', 'html', 'css', 'js', 'mjs', 'cjs',
+ 'ts', 'tsx', 'jsx', 'py', 'go', 'rs', 'sh', 'sql', 'xml', 'toml', 'ini', 'env', 'conf'],
+ };
+ const attExt = (name) => (String(name || '').split('?')[0].split('#')[0].split('.').pop() || '').toLowerCase();
+ /** What kind of thing an attachment is: what it says, or what its name ends in. */
+ function attKind(mime, name, kind) {
+ if (ATT_KINDS.has(kind)) return kind;
+ const m = String(mime || '').toLowerCase().split(';')[0].trim();
+ if (m.startsWith('image/')) return 'image';
+ if (m.startsWith('video/')) return 'video';
+ if (m.startsWith('audio/')) return 'audio';
+ if (m === 'application/pdf') return 'pdf';
+ if (m.startsWith('text/')) return 'text';
+ if (m === 'application/json' || m === 'application/xml' || m === 'application/x-yaml') return 'text';
+ const ext = attExt(name);
+ for (const k of Object.keys(ATT_EXT)) if (ATT_EXT[k].includes(ext)) return k;
+ return 'file';
+ }
+ /** The attachments of a step or a message, in the shape the component draws them. */
+ function normAttachments(list) {
+ const out = [], seen = new Set();
+ for (const a of Array.isArray(list) ? list : []) {
+ if (!a || typeof a !== 'object') continue;
+ const url = String(a.url || '').trim();
+ if (!url || seen.has(url)) continue;
+ seen.add(url);
+ const name = String(a.name || '').trim() || base(String(url).split('?')[0]) || 'file';
+ 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' });
+ }
+ return out;
+ }
+ /** The page's own origin, or Media's: an attachment loads from there and nowhere else. */
+ function attAllowed(url, extra) {
+ const raw = String(url || '').trim();
+ if (!raw) return false;
+ if (/^(blob:|data:image\/(png|jpe?g|gif|webp);)/i.test(raw)) return true; // the page made it itself
+ if (raw.startsWith('/') && !raw.startsWith('//')) return true; // the product's own origin
+ const here = (typeof location !== 'undefined' && location && location.hostname) ? String(location.hostname).toLowerCase() : '';
+ let host = '';
+ try { host = new URL(raw, here ? 'https://' + here : 'https://openvibe.network/').hostname.toLowerCase(); } catch { return false; }
+ if (here && host === here) return true;
+ if (host === 'openvibe.media' || host.endsWith('.openvibe.media')) return true;
+ for (const o of Array.isArray(extra) ? extra : []) {
+ const h = String(o || '').toLowerCase().replace(/^https?:\/\//, '').split('/')[0].split(':')[0];
+ if (h && (host === h || host.endsWith('.' + h))) return true;
+ }
+ return false;
+ }
+ /** The tiles of a line: the images in one row, the other files as chips in a row under them. */
+ function attStrip(list, onOpen) {
+ const strip = el('div', 'ovas-att');
+ const imgs = el('div', 'ovas-att-row');
+ const chips = el('div', 'ovas-att-row');
+ for (const a of list) {
+ const b = el('button', a.kind === 'image' ? 'ovas-att-img' : 'ovas-att-chip');
+ b.type = 'button';
+ b.dataset.kind = a.kind;
+ b.dataset.role = a.role;
+ const role = ATT_ROLE_LABEL[a.role] || 'produced';
+ b.title = role + ': ' + a.name;
+ b.setAttribute('aria-label', 'Open ' + a.name + ' (' + role + ')');
+ if (a.kind === 'image') {
+ const img = el('img');
+ img.loading = 'lazy'; img.decoding = 'async'; img.alt = a.name; img.src = a.url;
+ img.addEventListener('error', () => { img.remove(); b.hidden = true; });
+ b.appendChild(img);
+ imgs.appendChild(b);
+ } else {
+ b.insertAdjacentHTML('beforeend', svg(ATT_ICON[a.kind] || 'file'));
+ chips.appendChild(b);
+ }
+ b.appendChild(el('span', 'ovas-att-cap', a.name));
+ b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onOpen(a); });
+ }
+ if (imgs.childNodes.length) strip.appendChild(imgs);
+ if (chips.childNodes.length) strip.appendChild(chips);
+ return strip;
+ }
+
+ /** A text file as the preview shows it: a CSV as a table, JSON pretty-printed, Markdown read, code highlighted. */
+ function attText(name, text, linkOk) {
+ const ext = attExt(name);
+ let body = String(text == null ? '' : text);
+ if (ext === 'json' || ext === 'jsonl') { try { body = JSON.stringify(JSON.parse(body), null, 2); } catch { /* not one document */ } }
+ if (ext === 'csv' || ext === 'tsv') { const table = attCsv(body, ext === 'tsv' ? '\t' : ','); if (table) return table; }
+ if (ext === 'md' || ext === 'markdown') {
+ const frag = markdown(body, linkOk);
+ frag.querySelectorAll('pre code').forEach((code) => { const hi = attHighlight(code.textContent, ''); code.textContent = ''; code.appendChild(hi); });
+ return frag;
+ }
+ const pre = el('pre', 'ovas-prev-code');
+ const code = el('code');
+ code.appendChild(attHighlight(body, ext === 'json' ? 'json' : ''));
+ pre.appendChild(code);
+ return pre;
+ }
+ /** Pinch to zoom on a phone, double tap to double, so a screenshot can be read. */
+ function attZoom(img) {
+ let scale = 1, start = null;
+ const apply = () => { img.style.transform = scale > 1 ? 'scale(' + scale + ')' : ''; };
+ img.addEventListener('touchstart', (e) => {
+ 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 }; }
+ }, { passive: true });
+ img.addEventListener('touchmove', (e) => {
+ if (!start || e.touches.length !== 2) return;
+ e.preventDefault();
+ const a = e.touches[0], b = e.touches[1];
+ const d = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
+ scale = Math.min(4, Math.max(1, start.s * (d / (start.d || 1)))); apply();
+ }, { passive: false });
+ img.addEventListener('touchend', () => { start = null; });
+ img.addEventListener('dblclick', () => { scale = scale > 1 ? 1 : 2; apply(); });
+ img.addEventListener('wheel', (e) => { if (!e.ctrlKey) return; e.preventDefault(); scale = Math.min(4, Math.max(1, scale - e.deltaY / 200)); apply(); }, { passive: false });
+ }
+ /** A small highlighter for the preview: strings, numbers, keywords and comments, as spans (never markup). */
+ function attHighlight(text, lang) {
+ const frag = document.createDocumentFragment();
+ const json = lang === 'json';
+ const re = json
+ ? /("(?:[^"\\]|\\.)*")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g
+ : /(\/\/[^\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;
+ const push = (s, cls) => { if (!s) return; frag.appendChild(cls ? el('span', cls, s) : document.createTextNode(s)); };
+ let at = 0, m;
+ while ((m = re.exec(text))) {
+ push(text.slice(at, m.index));
+ if (json) {
+ if (m[1] != null) { push(m[1], m[2] != null ? 'ovas-tok-key' : 'ovas-tok-str'); if (m[2] != null) push(m[2], ''); }
+ else if (m[3] != null) push(m[3], 'ovas-tok-kw');
+ else push(m[4], 'ovas-tok-num');
+ } else if (m[1] != null) push(m[1], 'ovas-tok-com');
+ else if (m[2] != null) push(m[2], 'ovas-tok-str');
+ else if (m[3] != null) push(m[3], 'ovas-tok-kw');
+ else push(m[4], 'ovas-tok-num');
+ at = m.index + m[0].length;
+ }
+ push(text.slice(at));
+ return frag;
+ }
+ /** A CSV as a table (quoted cells kept whole). */
+ function attCsv(text, delim) {
+ const rows = String(text || '').split(/\r?\n/).filter((l) => l.trim() !== '');
+ if (!rows.length) return null;
+ const split = (line) => {
+ const out = []; let cur = '', q = false;
+ for (let i = 0; i < line.length; i++) {
+ const ch = line[i];
+ if (q) { if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else q = false; } else cur += ch; }
+ else if (ch === '"') q = true;
+ else if (ch === delim) { out.push(cur.trim()); cur = ''; }
+ else cur += ch;
+ }
+ out.push(cur.trim());
+ return out;
+ };
+ const head = split(rows[0]);
+ const table = el('table', 'ovas-csv');
+ const thead = el('thead'), tr = el('tr');
+ head.forEach((h) => tr.appendChild(el('th', '', h || ' ')));
+ thead.appendChild(tr); table.appendChild(thead);
+ const tb = el('tbody');
+ for (const line of rows.slice(1, 200)) {
+ const r = el('tr'), cells = split(line);
+ for (let i = 0; i < head.length; i++) r.appendChild(el('td', '', cells[i] == null ? '' : cells[i]));
+ tb.appendChild(r);
+ }
+ table.appendChild(tb);
+ return table;
+ }
+
/** Light Markdown, text-safe: paragraphs, bullet and numbered lists, `code`, ```blocks```, **bold**, https links (only
/** Light Markdown, text-safe: paragraphs, bullet and numbered lists, `code`, ```blocks```, **bold**, https links (only
* those `linkOk(url)` allows; the others stay text). */
* those `linkOk(url)` allows; the others stay text). */
function markdown(text, linkOk) {
function markdown(text, linkOk) {
@@ -243,6 +441,10 @@
@@ -243,6 +441,10 @@
function mount(host, opts = {}) {
function mount(host, opts = {}) {
styles();
styles();
const linkOk = typeof opts.link === 'function' ? (u) => { try { return !!opts.link(u); } catch { return false; } } : null;
const linkOk = typeof opts.link === 'function' ? (u) => { try { return !!opts.link(u); } catch { return false; } } : null;
+ // Attachments load from the product's own origin or Media's; a product that serves files from another host
+ // says so: mount(el, { origins: ['files.openvibe.codes'] }).
+ const origins = Array.isArray(opts.origins) ? opts.origins : (opts.origins ? [opts.origins] : []);
+ const safeAtts = (list) => normAttachments(list).filter((a) => attAllowed(a.url, origins));
const LIVE = ['running', 'placed', 'queued', 'verifying'];
const LIVE = ['running', 'placed', 'queued', 'verifying'];
// The clock runs from startedAt, or from now for a run that is live as it mounts; a finished run replayed without
// The clock runs from startedAt, or from now for a run that is live as it mounts; a finished run replayed without
// a start time shows the time only once its done line says it.
// a start time shows the time only once its done line says it.
@@ -273,6 +475,63 @@
@@ -273,6 +475,63 @@
});
});
const empty = el('li', 'ovas-empty', opts.empty || 'Waiting for the agent to start…'); feed.appendChild(empty);
const empty = el('li', 'ovas-empty', opts.empty || 'Waiting for the agent to start…'); feed.appendChild(empty);
+ // ── The preview sheet: one attachment, opened by a tap ──────────────────────────
+ const sheet = el('div', 'ovas-sheet');
+ sheet.hidden = true;
+ 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>';
+ host.appendChild(sheet);
+ let sheetBack = null;
+ function closeSheet() {
+ if (sheet.hidden) return;
+ sheet.hidden = true;
+ sheet.querySelector('.ovas-sheet-body').textContent = '';
+ if (sheetBack && sheetBack.focus) sheetBack.focus();
+ sheetBack = null;
+ }
+ function openSheet(a) {
+ const body = sheet.querySelector('.ovas-sheet-body');
+ body.textContent = '';
+ sheet.querySelector('.ovas-sheet-name').textContent = a.name;
+ const open = sheet.querySelector('.ovas-sheet-open'); open.href = a.url; open.setAttribute('aria-label', 'Open ' + a.name);
+ const dl = sheet.querySelector('.ovas-sheet-dl'); dl.href = a.url; dl.setAttribute('download', a.name);
+ const wrap = el('div', 'ovas-prev');
+ if (a.kind === 'image') {
+ const img = el('img', 'ovas-prev-img'); img.alt = a.name; img.src = a.url; wrap.appendChild(img); attZoom(img);
+ } else if (a.kind === 'video') {
+ const v = el('video', 'ovas-prev-media'); v.controls = true; v.playsInline = true; v.preload = 'metadata'; v.src = a.url; wrap.appendChild(v);
+ } else if (a.kind === 'audio') {
+ const au = el('audio', 'ovas-prev-audio'); au.controls = true; au.preload = 'metadata'; au.src = a.url; wrap.appendChild(au);
+ } else if (a.kind === 'pdf') {
+ const f = el('iframe', 'ovas-prev-pdf'); f.src = a.url; f.title = a.name; wrap.appendChild(f);
+ } else if (a.kind === 'text') {
+ const holder = el('div', 'ovas-prev-text');
+ holder.appendChild(el('p', 'ovas-prev-note', 'Loading…'));
+ wrap.appendChild(holder);
+ fetch(a.url, { credentials: 'same-origin' })
+ .then((r) => (r.ok ? r.text() : Promise.reject(new Error('HTTP ' + r.status))))
+ .then((t) => { holder.textContent = ''; holder.appendChild(attText(a.name, t, linkOk)); })
+ .catch((err) => { holder.textContent = 'Could not open it: ' + err.message; });
+ } else {
+ wrap.appendChild(el('p', 'ovas-prev-note', 'There is no preview for this file. Open it or download it.'));
+ }
+ body.appendChild(wrap);
+ sheetBack = (typeof document !== 'undefined' && document.activeElement) || null;
+ sheet.hidden = false;
+ const x = sheet.querySelector('.ovas-sheet-x'); if (x && x.focus) x.focus();
+ }
+ sheet.querySelector('.ovas-sheet-x').addEventListener('click', closeSheet);
+ sheet.addEventListener('click', (e) => { if (e.target === sheet) closeSheet(); });
+ document.addEventListener('keydown', (e) => {
+ if (sheet.hidden) return;
+ if (e.key === 'Escape') { e.preventDefault(); closeSheet(); return; }
+ if (e.key !== 'Tab') return;
+ const f = [...sheet.querySelectorAll('button, a[href], iframe')].filter((n) => !n.disabled && n.offsetParent !== null);
+ if (!f.length) return;
+ const first = f[0], last = f[f.length - 1];
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
+ });
+
function applyFilter() {
function applyFilter() {
const files = S.filter === 'files';
const files = S.filter === 'files';
feed.hidden = files; filesEl.hidden = !files;
feed.hidden = files; filesEl.hidden = !files;
@@ -340,7 +599,7 @@
@@ -340,7 +599,7 @@
d.dataset.id = step.id;
d.dataset.id = step.id;
const sum = el('summary');
const sum = el('summary');
const info = describe(step); step.info = info;
const info = describe(step); step.info = info;
- 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>`;
+ 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>`;
sum.querySelector('b').textContent = info.title;
sum.querySelector('b').textContent = info.title;
sum.querySelector('small').textContent = info.sub;
sum.querySelector('small').textContent = info.sub;
if (!info.sub) sum.querySelector('small').hidden = true;
if (!info.sub) sum.querySelector('small').hidden = true;
@@ -383,7 +642,7 @@
@@ -383,7 +642,7 @@
if (last && last.classList.contains('ovas-group-li')) return last;
if (last && last.classList.contains('ovas-group-li')) return last;
const li = el('li', 'ovas-group-li'); li.dataset.k = 'steps';
const li = el('li', 'ovas-group-li'); li.dataset.k = 'steps';
const g = el('details', 'ovas-group');
const g = el('details', 'ovas-group');
- 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></summary><ol></ol>`;
+ 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>`;
li.appendChild(g); parentFeed.appendChild(li);
li.appendChild(g); parentFeed.appendChild(li);
li.steps = [];
li.steps = [];
return li;
return li;
@@ -408,13 +667,42 @@
@@ -408,13 +667,42 @@
// One step alone reads as itself, not as a group of one.
// One step alone reads as itself, not as a group of one.
g.classList.toggle('is-one', steps.length === 1);
g.classList.toggle('is-one', steps.length === 1);
if (steps.length === 1) g.open = true;
if (steps.length === 1) g.open = true;
+ // The group's attachments, in its header row, so they show while the group is folded.
+ const atts = [], seenAtt = new Set();
+ for (const s of steps) for (const a of (s.attachments || [])) { if (!seenAtt.has(a.url)) { seenAtt.add(a.url); atts.push(a); } }
+ const slot = g.querySelector('.ovas-att-slot');
+ slot.textContent = '';
+ // A group of one hides its header, so there the step's own row carries the attachments instead.
+ if (atts.length && steps.length > 1) slot.appendChild(attStrip(atts, openSheet));
+ // A group of one shows the step's own row; there the attachments belong to the step.
+ for (const s of steps) {
+ const ss = s.el && s.el.querySelector(':scope>summary .ovas-att-slot');
+ if (!ss) continue;
+ ss.textContent = '';
+ if (steps.length === 1 && s.attachments && s.attachments.length) ss.appendChild(attStrip(s.attachments, openSheet));
+ }
}
}
function feedFor(parent) { const p = parent && S.steps.get(parent); return p && p.subEl ? p.subEl : feed; }
function feedFor(parent) { const p = parent && S.steps.get(parent); return p && p.subEl ? p.subEl : feed; }
function appendLi(target, li) { if (target === feed) { if (empty.parentNode) empty.remove(); feed.appendChild(li); } else target.appendChild(li); }
function appendLi(target, li) { if (target === feed) { if (empty.parentNode) empty.remove(); feed.appendChild(li); } else target.appendChild(li); }
const pending = new Map(); // tool name → ids of its steps still waiting for a result (events without ids)
const pending = new Map(); // tool name → ids of its steps still waiting for a result (events without ids)
+ // Any line may carry attachments, and a line of a type this component does not know still shows: a minimal
+ // row with its label or summary, then its attachments (Codes' proof lines, for one).
+ const KNOWN = new Set(['agent', 'text', 'tool', 'tool_progress', 'tool_result', 'handoff', 'note', 'step', 'usage', 'done', 'state']);
+ function extraLine(x, target) {
+ const li = el('li', 'ovas-extra');
+ li.dataset.k = 'steps'; // it is part of the work, so the Steps filter keeps it
+ const label = String(x.label || x.kind || '');
+ const summary = String(x.summary || x.text || '');
+ const text = label && summary && label !== summary ? label + ' — ' + summary : (summary || label);
+ if (text) li.appendChild(el('span', '', text));
+ const atts = safeAtts(x.attachments);
+ if (atts.length) li.appendChild(attStrip(atts, openSheet));
+ appendLi(target, li);
+ }
function onLine(x) {
function onLine(x) {
const target = feedFor(x.parent);
const target = feedFor(x.parent);
+ if (!KNOWN.has(x.t)) { extraLine(x, target); return; }
// Who is working: a line that names a different agent than the last moves the head along.
// Who is working: a line that names a different agent than the last moves the head along.
// A done line reports an agent's cost and time; it never moves the head.
// A done line reports an agent's cost and time; it never moves the head.
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(); }
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(); }
@@ -427,11 +715,14 @@
@@ -427,11 +715,14 @@
paintHead(); return;
paintHead(); return;
}
}
if (x.t === 'text') {
if (x.t === 'text') {
- const li = el('li', 'ovas-msg'); li.dataset.k = 'msg'; li.appendChild(markdown(x.text, linkOk)); appendLi(target, li); return;
+ const li = el('li', 'ovas-msg'); li.dataset.k = 'msg'; li.appendChild(markdown(x.text, linkOk));
+ const atts = safeAtts(x.attachments);
+ if (atts.length) li.appendChild(attStrip(atts, openSheet));
+ appendLi(target, li); return;
}
}
if (x.t === 'tool') {
if (x.t === 'tool') {
const kind = kindOf(x.name);
const kind = kindOf(x.name);
- const step = { id: x.id || `s${S.steps.size}`, name: x.name, kind, input: x.input || {}, what: x.what || '', started: Date.now(), done: false, ok: null, out: '' };
+ const step = { id: x.id || `s${S.steps.size}`, name: x.name, kind, input: x.input || {}, what: x.what || '', started: Date.now(), done: false, ok: null, out: '', attachments: safeAtts(x.attachments) };
if (kind === 'plan') { if (Array.isArray(step.input.todos)) { S.plan = step.input.todos; paintPlan(); } step.hidden = true; S.steps.set(step.id, step); return; }
if (kind === 'plan') { if (Array.isArray(step.input.todos)) { S.plan = step.input.todos; paintPlan(); } step.hidden = true; S.steps.set(step.id, step); return; }
S.steps.set(step.id, step);
S.steps.set(step.id, step);
const g = groupFor(target === feed ? feed : target);
const g = groupFor(target === feed ? feed : target);
@@ -447,6 +738,12 @@
@@ -447,6 +738,12 @@
}
}
if (x.t === 'tool_result') {
if (x.t === 'tool_result') {
const step = S.steps.get(String(x.id)); if (!step || step.hidden) return;
const step = S.steps.get(String(x.id)); if (!step || step.hidden) return;
+ // A result may carry the files the step produced; the group's header shows them.
+ const extra = safeAtts(x.attachments);
+ if (extra.length) {
+ const have = step.attachments || (step.attachments = []);
+ for (const a of extra) if (!have.some((b) => b.url === a.url)) have.push(a);
+ }
step.done = true; step.ok = x.ok !== false; step.ended = x.duration_ms > 0 ? step.started + Number(x.duration_ms) : Date.now();
step.done = true; step.ok = x.ok !== false; step.ended = x.duration_ms > 0 ? step.started + Number(x.duration_ms) : Date.now();
if (Number(x.cost_usd) > 0) step.cost = Number(x.cost_usd);
if (Number(x.cost_usd) > 0) step.cost = Number(x.cost_usd);
if (step.outEl) {
if (step.outEl) {
@@ -466,15 +763,23 @@
@@ -466,15 +763,23 @@
if (x.to && (!S.agents.length || S.agents[S.agents.length - 1].agent !== x.to)) S.agents.push({ agent: x.to });
if (x.to && (!S.agents.length || S.agents[S.agents.length - 1].agent !== x.to)) S.agents.push({ agent: x.to });
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'));
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'));
s.appendChild(row); if (x.reason) s.appendChild(el('small', '', x.reason));
s.appendChild(row); if (x.reason) s.appendChild(el('small', '', x.reason));
+ const handAtts = safeAtts(x.attachments);
+ if (handAtts.length) s.appendChild(attStrip(handAtts, openSheet));
li.appendChild(s); appendLi(feed, li); S.problems++; paintHead(); return;
li.appendChild(s); appendLi(feed, li); S.problems++; paintHead(); return;
}
}
if (x.t === 'note') {
if (x.t === 'note') {
const li = el('li', 'ovas-note'); li.dataset.k = 'note'; li.dataset.level = x.level === 'warning' || x.level === 'info' ? x.level : 'error';
const li = el('li', 'ovas-note'); li.dataset.k = 'note'; li.dataset.level = x.level === 'warning' || x.level === 'info' ? x.level : 'error';
if (li.dataset.level !== 'info') li.dataset.p = '1';
if (li.dataset.level !== 'info') li.dataset.p = '1';
- li.innerHTML = svg(li.dataset.level === 'info' ? 'ok' : 'warn'); li.appendChild(el('span', '', x.text)); appendLi(target, li); if (li.dataset.level !== 'info') S.problems++; paintHead(); return;
+ li.innerHTML = svg(li.dataset.level === 'info' ? 'ok' : 'warn'); li.appendChild(el('span', '', x.text));
+ const noteAtts = safeAtts(x.attachments);
+ if (noteAtts.length) li.appendChild(attStrip(noteAtts, openSheet));
+ appendLi(target, li); if (li.dataset.level !== 'info') S.problems++; paintHead(); return;
}
}
if (x.t === 'step') {
if (x.t === 'step') {
- const li = el('li', 'ovas-line'); li.dataset.k = 'line'; li.appendChild(el('span', '', x.text)); appendLi(feed, li); return;
+ const li = el('li', 'ovas-line'); li.dataset.k = 'line'; li.appendChild(el('span', '', x.text));
+ const lineAtts = safeAtts(x.attachments);
+ if (lineAtts.length) li.appendChild(attStrip(lineAtts, openSheet));
+ appendLi(feed, li); return;
}
}
if (x.t === 'usage') { S.tokens.in += Number(x.input_tokens) || 0; S.tokens.out += Number(x.output_tokens) || 0; return; }
if (x.t === 'usage') { S.tokens.in += Number(x.input_tokens) || 0; S.tokens.out += Number(x.output_tokens) || 0; return; }
if (x.t === 'done') {
if (x.t === 'done') {
@@ -486,6 +791,8 @@
@@ -486,6 +791,8 @@
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)}` : ''}`));
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)}` : ''}`));
li.appendChild(b);
li.appendChild(b);
if (x.text) { const m = el('div', 'ovas-msg'); m.appendChild(markdown(x.text, linkOk)); li.appendChild(m); }
if (x.text) { const m = el('div', 'ovas-msg'); m.appendChild(markdown(x.text, linkOk)); li.appendChild(m); }
+ const doneAtts = safeAtts(x.attachments);
+ if (doneAtts.length) li.appendChild(attStrip(doneAtts, openSheet));
if (!x.ok) li.dataset.p = '1';
if (!x.ok) li.dataset.p = '1';
appendLi(feed, li); paintHead();
appendLi(feed, li); paintHead();
}
}
@@ -62,6 +62,38 @@ agents… in a smart way… way better than this idea."
@@ -62,6 +62,38 @@ agents… in a smart way… way better than this idea."
**Links:** `mount(el, { link: (url) => … })` decides which https addresses in the agents' text become links; the rest
**Links:** `mount(el, { link: (url) => … })` decides which https addresses in the agents' text become links; the rest
stay text. A site whose agents read untrusted pages should link only its own (Actor links only the person's desk).
stay text. A site whose agents read untrusted pages should link only its own (Actor links only the person's desk).
+## Attachments
+
+A step or a message may carry the images and files the agent read, wrote or produced:
+
+```js
+attachments: [{ url, name, mime, size?, kind: 'image'|'video'|'audio'|'pdf'|'text'|'file', role: 'read'|'wrote'|'output' }]
+```
+
+The product supplies the URLs; the component never builds one from a local path. `kind` may be left out — the mime type
+and the name's ending decide. `role` says what the agent did with it (`read`, `wrote`, `output`).
+
+- **Any line may carry them.** The component shows a line's attachments for every line it draws.
+- A **step's** attachments show in its group's header row, so they are visible while the group is folded. Images are
+ 136 × 92 thumbnails with their name; everything else is a chip with an icon.
+- A **message's** attachments show under its text: the images in one row, the other files as chips in a row under them.
+- A line of a **type the component does not know** (Codes' `t: 'proof'`) is drawn as a minimal row: its label or
+ summary, then its attachments, instead of being dropped.
+- A tap opens a **preview sheet**: the image at full width (pinch to zoom on a phone), video and audio in their
+ players, a PDF in a frame, and text as code, Markdown, a CSV table or pretty JSON, highlighted. There are Open and
+ Download links too.
+- The thumbnails load lazily, and a broken image hides itself.
+- The sheet is keyboard reachable: a button opens it, Escape closes it, and the focus stays inside it.
+
+**Safety.** An attachment loads only from the product's own origin or from Media's — never from a third-party address
+the model printed. A product that serves files from another host says so: `mount(el, { origins: ['files.openvibe.codes'] })`.
+SVG is only ever an `<img>`, never inline. Text is fetched and set as text, never as markup, so a file can never run
+code in the page.
+
+The lines that carry them: a Codes job line `{ t: 'tool', …, attachments }` (the step), `{ t: 'text', …, attachments }`
+(the message), `{ t: 'tool_result', …, attachments }` (added to the step it ends), `{ t: 'done', …, attachments }` (the
+end card); and an Actor task event `{ kind: 'output', step: 'text' | 'tool_call' | 'tool_result', …, attachments }`.
+
## The shapes it reads
## The shapes it reads
**1. Claude Code's stream-json**, the shape every Codes harness adapter emits. See OpenVibe.Codes' `harness/events.js`:
**1. Claude Code's stream-json**, the shape every Codes harness adapter emits. See OpenVibe.Codes' `harness/events.js`:
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.49.0",
+ "version": "4.50.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,231 @@
@@ -0,0 +1,231 @@
+'use strict';
+// agent-session.js in a real headless Chrome: the attachments of a step and a message (4.50.0). A group's header
+// shows them while the group is folded; a message's sit under its text. A tap opens a preview sheet that shows the
+// file the way its kind asks for — an image, a video, an audio, a PDF, text — with Open and Download. Text is set as
+// text (never as markup), an attachment loads only from the page's own origin, and a broken image hides itself.
+// Skipped without Chrome.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const zlib = require('zlib');
+const h = require('../browser-harness');
+
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('agent-session-embeds-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+const ROOT = path.join(__dirname, '..');
+
+// A small PNG, made here so the test needs nothing else.
+function crc32(buf) {
+ let c, crc = 0xffffffff;
+ for (let n = 0; n < buf.length; n++) {
+ c = (crc ^ buf[n]) & 0xff;
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ crc = c ^ (crc >>> 8);
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+}
+function chunk(type, data) {
+ const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
+ const t = Buffer.from(type, 'ascii');
+ const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([t, data])), 0);
+ return Buffer.concat([len, t, data, crc]);
+}
+function png(w, h, rgb) {
+ const raw = Buffer.alloc((w * 3 + 1) * h);
+ let o = 0;
+ for (let y = 0; y < h; y++) { raw[o++] = 0; for (let x = 0; x < w; x++) { raw[o++] = rgb[0]; raw[o++] = rgb[1]; raw[o++] = rgb[2]; } }
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
+ return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
+ chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
+}
+const SHOT = png(240, 150, [64, 132, 214]);
+const SHOT2 = png(240, 150, [46, 160, 96]);
+const NOTES = '# Proof\n\n<script>window.__pwned = 1</script>\n\n<img src=x onerror="window.__pwned=2">\n\n| a | b |\n';
+const CODE = 'const a = "<script>alert(1)</script>";\n// a comment\n';
+const CSV = 'name,size\nshot.png,12\nother.png,34\n';
+const JSONF = '{"a":1,"b":[2,3]}';
+
+const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Embeds</title><link rel="icon" href="data:,"></head>
+<body style="margin:0;padding:0 16px"><div id="s"></div><script src="/agent-session.js"></script>
+<script>window.v = OpenVibeAgentSession.mount(document.getElementById('s'), { title: 'Fix the stats', status: 'running' });</script></body></html>`;
+
+const server = http.createServer((req, res) => {
+ const u = req.url.split('?')[0];
+ if (u === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE); }
+ if (u === '/agent-session.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, 'agent-session.js'))); }
+ if (u === '/agent-session.css') { res.writeHead(200, { 'content-type': 'text/css' }); return res.end(fs.readFileSync(path.join(ROOT, 'agent-session.css'))); }
+ if (u === '/shot.png') { res.writeHead(200, { 'content-type': 'image/png' }); return res.end(SHOT); }
+ if (u === '/shot2.png') { res.writeHead(200, { 'content-type': 'image/png' }); return res.end(SHOT2); }
+ if (u === '/broken.png') { res.writeHead(404, { 'content-type': 'text/plain' }); return res.end('no'); }
+ if (u === '/notes.md') { res.writeHead(200, { 'content-type': 'text/markdown' }); return res.end(NOTES); }
+ if (u === '/code.js') { res.writeHead(200, { 'content-type': 'text/javascript' }); return res.end(CODE); }
+ if (u === '/table.csv') { res.writeHead(200, { 'content-type': 'text/csv' }); return res.end(CSV); }
+ if (u === '/data.json') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSONF); }
+ if (u === '/movie.mp4') { res.writeHead(200, { 'content-type': 'video/mp4' }); return res.end(Buffer.alloc(16)); }
+ if (u === '/sound.mp3') { res.writeHead(200, { 'content-type': 'audio/mpeg' }); return res.end(Buffer.alloc(16)); }
+ if (u === '/doc.pdf') { res.writeHead(200, { 'content-type': 'application/pdf' }); return res.end('%PDF-1.4\n%%EOF\n'); }
+ res.writeHead(404); return res.end();
+});
+
+const att = (url, name, kind, role, mime) => ({ url, name, mime: mime || '', kind, role: role || 'output' });
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-ovas-embeds-'));
+ const browser = await h.launch({ tmpDir });
+ const push = (p, e) => p.evaluate(`window.v.push(${JSON.stringify(e)}), 1`);
+ try {
+ const context = await browser.newContext();
+ const p = await h.openPage(browser, context, { width: 390, height: 900 });
+ await p.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 8000 });
+
+ // A step's attachment shows in its group's header, while the group is folded.
+ await push(p, { t: 'tool', id: 'r1', name: 'Read', input: { file_path: 'shot.png' }, attachments: [att('/shot.png', 'shot.png', 'image', 'read', 'image/png')] });
+ await push(p, { t: 'tool', id: 'c1', name: 'Bash', input: { command: 'npm test' } });
+ await p.evaluate(`(() => { const g = document.querySelector('.ovas-group'); g.open = false; return 1 })()`);
+ let v = await p.evaluate(`(() => { const g = document.querySelector('.ovas-group'); const tile = g.querySelector('summary .ovas-att-img'); const img = tile && tile.querySelector('img'); return {
+ open: g.open, tile: !!tile, src: img && img.getAttribute('src'), cap: tile && tile.querySelector('.ovas-att-cap').textContent,
+ inSummary: !!(tile && tile.closest('summary')), height: tile ? tile.getBoundingClientRect().height : 0 } })()`);
+ assert.ok(!v.open, 'the group is folded');
+ assert.ok(v.tile && v.inSummary, "a step's attachment shows in its group's header, folded or not");
+ assert.strictEqual(v.src, '/shot.png', 'the thumbnail is the URL the product gave');
+ assert.strictEqual(v.cap, 'shot.png', 'the tile carries the name');
+ assert.ok(v.height > 0, 'the thumbnail is drawn while the group is folded');
+
+ // A message's attachment shows under its text.
+ await push(p, { t: 'text', text: 'Here is the proof.', attachments: [att('/shot.png', 'proof.png', 'image', 'output', 'image/png')] });
+ v = await p.evaluate(`(() => { const m = [...document.querySelectorAll('.ovas-msg')].pop(); const img = m.querySelector('.ovas-att-img');
+ const kids = [...m.children]; const strip = m.querySelector('.ovas-att'); const para = m.querySelector('p');
+ return { text: (para && para.textContent || '').trim(), img: !!img, after: !!(strip && para && kids.indexOf(strip) > kids.indexOf(para)) } })()`);
+ assert.ok(v.img && v.after, "a message's attachment shows under its text");
+
+ // The images in one row, the other files as chips in a row under them.
+ await push(p, { t: 'text', text: 'Two kinds', attachments: [
+ att('/shot.png', 'one.png', 'image', 'read', 'image/png'), att('/shot2.png', 'two.png', 'image', 'read', 'image/png'),
+ att('/data.json', 'three.json', 'text', 'read', 'application/json')] });
+ v = await p.evaluate(`(() => { const m = [...document.querySelectorAll('.ovas-msg')].pop(); const rows = m.querySelectorAll('.ovas-att-row');
+ return { rows: rows.length, imgs: rows[0] ? rows[0].querySelectorAll('.ovas-att-img').length : 0, chips: rows[1] ? rows[1].querySelectorAll('.ovas-att-chip').length : 0 } })()`);
+ assert.deepStrictEqual([v.rows, v.imgs, v.chips], [2, 2, 1], 'the images in one row, the chips in a row under them');
+
+ // A line of a type the component does not know still shows: its label or summary, then its attachments.
+ await push(p, { t: 'proof', kind: 'screenshots', label: 'screenshots', ok: true, summary: '2 screenshots of 1 page at 390px, clean',
+ attachments: [att('/shot.png', 'proof.png', 'image', 'output', 'image/png')] });
+ v = await p.evaluate(`(() => { const row = document.querySelector('.ovas-extra'); return {
+ there: !!row, text: row ? row.textContent : '', img: !!(row && row.querySelector('.ovas-att-img')) } })()`);
+ assert.ok(v.there && v.img, 'an unknown line shows its attachments');
+ assert.ok(v.text.includes('screenshots') && v.text.includes('390px'), 'and says what it is');
+
+ // A tap opens the preview sheet.
+ await p.evaluate(`document.querySelector('.ovas-msg .ovas-att-img').click(), 1`);
+ v = await p.evaluate(`(() => { const s = document.querySelector('.ovas-sheet'); return {
+ open: !s.hidden, img: !!s.querySelector('.ovas-prev-img'), src: s.querySelector('.ovas-prev-img') && s.querySelector('.ovas-prev-img').getAttribute('src'),
+ name: s.querySelector('.ovas-sheet-name').textContent, openHref: s.querySelector('.ovas-sheet-open').getAttribute('href'),
+ dl: s.querySelector('.ovas-sheet-dl').getAttribute('download'), focused: document.activeElement.className } })()`);
+ assert.ok(v.open && v.img, 'the preview opens with the image');
+ assert.strictEqual(v.src, '/shot.png', 'the preview shows the image at full width');
+ assert.strictEqual(v.name, 'proof.png', 'the sheet says which file it is');
+ assert.strictEqual(v.openHref, '/shot.png', 'and offers to open it');
+ assert.strictEqual(v.dl, 'proof.png', 'and to download it');
+ assert.ok(/ovas-sheet-x/.test(v.focused), 'the keyboard goes to the close button');
+
+ // Escape closes it.
+ await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })), 1`);
+ v = await p.evaluate(`document.querySelector('.ovas-sheet').hidden`);
+ assert.strictEqual(v, true, 'Escape closes the preview');
+
+ // One step, one file of every kind: each opens the way its kind asks for.
+ await push(p, { t: 'tool', id: 'k1', name: 'Read', input: { file_path: 'bundle' }, attachments: [
+ att('/shot.png', 'shot.png', 'image', 'read', 'image/png'), att('/movie.mp4', 'movie.mp4', 'video', 'output', 'video/mp4'),
+ att('/sound.mp3', 'sound.mp3', 'audio', 'output', 'audio/mpeg'), att('/doc.pdf', 'doc.pdf', 'pdf', 'output', 'application/pdf'),
+ att('/code.js', 'code.js', 'text', 'wrote', 'text/javascript')] });
+ const kinds = await p.evaluate(`(() => { const gs = document.querySelectorAll('.ovas-group'); const g = gs[gs.length - 1]; const tiles = g.querySelectorAll('.ovas-att [data-kind]'); return [...tiles].map((t) => t.dataset.kind) })()`);
+ assert.deepStrictEqual(kinds, ['image', 'video', 'audio', 'pdf', 'text'], 'every kind of file is a tile of its own');
+ const openBy = async (name) => {
+ await p.evaluate(`(() => { const t = [...document.querySelectorAll('.ovas-att [data-kind]')].find((n) => n.querySelector('.ovas-att-cap').textContent === ${JSON.stringify(name)}); if (!t) throw new Error('no tile ' + ${JSON.stringify(name)}); t.click(); return 1 })()`);
+ // A text file is fetched, so the sheet fills a moment later.
+ for (let i = 0; i < 30; i++) {
+ const loading = await p.evaluate(`(() => { const b = document.querySelector('.ovas-sheet-body'); return !b || b.textContent.indexOf('Loading') === 0; })()`);
+ if (!loading) break;
+ await p.evaluate(`new Promise((r) => setTimeout(r, 100)), 1`);
+ }
+ return p.evaluate(`(() => { const s = document.querySelector('.ovas-sheet'); const b = s.querySelector('.ovas-sheet-body'); return {
+ video: !!b.querySelector('video'), audio: !!b.querySelector('audio'), pdf: !!b.querySelector('iframe'),
+ code: !!b.querySelector('pre code'), text: b.textContent, scripts: b.querySelectorAll('script').length,
+ pwned: window.__pwned === undefined } })()`);
+ };
+ v = await openBy('movie.mp4');
+ assert.ok(v.video, 'a video opens in its player');
+ v = await openBy('sound.mp3');
+ assert.ok(v.audio, 'audio opens in its player');
+ v = await openBy('doc.pdf');
+ assert.ok(v.pdf, 'a PDF opens in a frame');
+ v = await openBy('code.js');
+ assert.ok(v.code, 'text opens as code');
+ assert.ok(v.text.includes('<script>alert(1)</script>'), 'the code is there as text');
+ assert.strictEqual(v.scripts, 0, 'and never as markup');
+ assert.ok(v.pwned, 'the text never ran');
+
+ // A CSV as a table, JSON pretty-printed.
+ await push(p, { t: 'tool', id: 'k2', name: 'Read', input: { file_path: 'table.csv' }, attachments: [att('/table.csv', 'table.csv', 'text', 'read', 'text/csv')] });
+ await openBy('table.csv');
+ for (let i = 0; i < 30 && !(await p.evaluate(`!!document.querySelector('.ovas-sheet-body .ovas-csv')`)); i++) await p.evaluate(`new Promise((r) => setTimeout(r, 100)), 1`);
+ v = await p.evaluate(`(() => { const b = document.querySelector('.ovas-sheet-body'); const t = b.querySelector('.ovas-csv'); return {
+ table: !!t, head: t && t.querySelector('th').textContent, rows: t ? t.querySelectorAll('tbody tr').length : 0,
+ cell: t && t.querySelector('tbody td').textContent } })()`);
+ assert.ok(v.table && v.head === 'name' && v.rows === 2 && v.cell === 'shot.png', 'a CSV opens as a table');
+ await push(p, { t: 'tool', id: 'k3', name: 'Read', input: { file_path: 'data.json' }, attachments: [att('/data.json', 'data.json', 'text', 'read', 'application/json')] });
+ await openBy('data.json');
+ for (let i = 0; i < 30 && !(await p.evaluate(`document.querySelector('.ovas-sheet-body').textContent.includes('"a"')`)); i++) await p.evaluate(`new Promise((r) => setTimeout(r, 100)), 1`);
+ v = await p.evaluate(`document.querySelector('.ovas-sheet-body').textContent`);
+ assert.ok(v.includes('"a": 1'), 'JSON is pretty-printed (got: ' + v.slice(0, 200) + ')');
+
+ // A third-party address the model printed never loads.
+ await push(p, { t: 'text', text: 'Not mine', attachments: [att('https://evil.example.com/x.png', 'x.png', 'image', 'output', 'image/png')] });
+ v = await p.evaluate(`(() => { const m = [...document.querySelectorAll('.ovas-msg')].pop(); return {
+ tiles: m.querySelectorAll('.ovas-att [data-kind]').length, srcs: [...document.querySelectorAll('.ovas-att img')].map((i) => i.getAttribute('src')) } })()`);
+ assert.strictEqual(v.tiles, 0, "a third-party address is not shown");
+ assert.ok(!v.srcs.includes('https://evil.example.com/x.png'), 'and never loaded');
+
+ // A broken image hides itself.
+ await push(p, { t: 'text', text: 'Broken', attachments: [att('/broken.png', 'broken.png', 'image', 'output', 'image/png')] });
+ await p.evaluate(`(() => { const m = [...document.querySelectorAll('.ovas-msg')].pop(); m.scrollIntoView(); const img = m.querySelector('.ovas-att-img img'); if (img) img.loading = 'eager'; return 1 })()`);
+ let gone = false;
+ for (let i = 0; i < 60 && !gone; i++) {
+ await p.evaluate(`new Promise((r) => setTimeout(r, 100)), 1`);
+ gone = await p.evaluate(`(() => { const m = [...document.querySelectorAll('.ovas-msg')].pop(); const b = m.querySelector('.ovas-att [data-kind]'); return !!(b && b.hidden && !b.querySelector('img')); })()`);
+ }
+ assert.ok(gone, 'a broken image hides itself');
+
+ // The phone: nothing sideways, and every target big enough to tap.
+ v = await p.evaluate(`(() => { const doc = document.documentElement; const chips = [...document.querySelectorAll('.ovas-att-chip')]; return {
+ wide: doc.scrollWidth, chips: chips.map((c) => Math.round(c.getBoundingClientRect().height)) } })()`);
+ assert.ok(v.wide <= 390, 'nothing scrolls sideways at 390 px (' + v.wide + ')');
+ const sticky = await p.evaluate(`getComputedStyle(document.querySelector('.ovas-head')).position`);
+ assert.strictEqual(sticky, 'static', 'the head does not stick on a phone, so it never covers the steps');
+ assert.ok(v.chips.every((h) => h >= 44), 'a chip is a 44 px target on a phone');
+
+ // At 320 the filter row scrolls inside its own box, and the page does not scroll sideways.
+ const narrow = await h.openPage(browser, context, { width: 320, height: 800 });
+ await narrow.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 8000 });
+ await narrow.evaluate(`window.v.push(${JSON.stringify({ t: 'text', text: 'A line of text.' })}), 1`);
+ const nv = await narrow.evaluate(`(() => { const bar = document.querySelector('.ovas-bar'); return {
+ wide: document.documentElement.scrollWidth, bar: Math.round(bar.getBoundingClientRect().width),
+ box: Math.round(document.querySelector('.ovas').getBoundingClientRect().width),
+ scrolls: bar.scrollWidth > bar.clientWidth } })()`);
+ assert.ok(nv.wide <= 320, 'nothing scrolls sideways at 320 px (' + nv.wide + ')');
+ assert.ok(nv.bar <= nv.box + 1, 'the filter row stays inside the session (' + nv.bar + ' in ' + nv.box + ')');
+ await narrow.close();
+
+ await p.close();
+ console.log('agent-session-embeds-chrome: attachments, previews and safety ok');
+ } finally {
+ await browser.close().catch(() => {});
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e && e.stack || e); process.exit(1); });