openvibers/openvibe.shared Commits

Rich embeds in the agent session: the images and files a step read, wrote or produced (#195)

OP OpenVibers committed 14 days ago · c3c3782e2d · 8 files, +664 −16

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 5f7191e..07d368c 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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).
ddiff --git a/README.md b/README.md
iindex 2d40367..cb11966 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
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
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 481cbd4..299b9a9 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/agent-session.css b/agent-session.css
iindex a91b4a6..380bc0c 100644
---- a/agent-session.css
++++ b/agent-session.css
@@@ -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; }
++}
ddiff --git a/agent-session.js b/agent-session.js
iindex 192ac57..4c170d1 100644
---- a/agent-session.js
++++ b/agent-session.js
@@@ -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();
}
ddiff --git a/docs/agent-session.md b/docs/agent-session.md
iindex 6cc09ff..895b244 100644
---- a/docs/agent-session.md
++++ b/docs/agent-session.md
@@@ -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
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
**1. Claude Code's stream-json**, the shape every Codes harness adapter emits. See OpenVibe.Codes' `harness/events.js`:
ddiff --git a/package.json b/package.json
iindex b451138..834f71c 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/test/agent-session-embeds-chrome.test.js b/test/agent-session-embeds-chrome.test.js
nnew file mode 100644
iindex 0000000..02c1ff9
---- /dev/null
++++ b/test/agent-session-embeds-chrome.test.js
@@@ -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); });