ouroboros/web/modules/widgets.js
Anton Razzhigaev 50756edcf0 feat(v6.71.0): UI polish — a shared rich-content contract, one composer dock, calm charts, and whole-row disclosures
Rendered markdown and the Skills review history/findings disclosures adopt one
.ui-rich-content contract (reserved list gutter, anywhere-wrapping, no marker
bleed outside the card); chat-bubble messages are deliberately untouched. The
skill-review bubble gets a symmetric bottom timestamp inset. Live-line
disclosures toggle from the WHOLE non-interactive row surface via the pure
exported guard liveLineRowToggleKey (nested interactive elements and active
text selection never toggle; focus returns to the real toggle button and
survives the full-output re-render; the button path with Enter/Space/
aria-expanded is unchanged), with a larger expand label and cursor affordance.
Project/side-chat composers reuse the main chat's absolute dock contract (same
bottom fade, --chat-input-reserve transcript padding, mobile safe-area inset —
no second fade layer). Widget charts render in a bounded .widget-chart-canvas
box (clamp 260-360px) and a data-only re-render adopts the live chart wrapper
(Chart.js watches the canvas PARENT for responsive resize) updating chart.data
in place with fresh config attributes mirrored — no destroy/recreate flicker.
Poll refetches are SWR: content stays with a thin 'refreshing' indicator
(declared loading label reused), 'loading' only when there is nothing to show,
and a failed background refetch keeps the stale content while the status
reports the error. New node tests pin the disclosure guards (token-exact
stubs); the declarative Playwright smoke gains a markdown fixture with
geometry asserts, a bounded-canvas assert, and a consumer-flow poll test
proving canvas identity survives a live refetch. CREATING_SKILLS.md documents
the observable 'refreshing' status state.

Gate: triad+scope external review PASSED 2x with zero criticals
($19.91/$19.67; run 1 was an advisory parse_failure infrastructure block,
$0). Adversarial subagent round + final codex review applied. Browser suite
12/12, node 19/19, full pytest green (non-serial + serial). Carried phase-2
verifications: the Skills submit button click created a REAL /api/tasks task
on a live server, and a secret-shaped token logged through the live root
logger is masked in server.log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:49:01 +00:00

1393 lines
67 KiB
JavaScript

import { renderPageHeader } from './page_header.js';
import { PAGE_ICONS } from './page_icons.js';
import { applyMasonry } from './masonry.js';
import {
apiClient,
apiFetch,
cleanExtensionRoute,
extensionRoutePath,
extensionRoutePrefix,
} from './api_client.js';
import {
escapeHtmlAttr as escapeHtml,
renderMarkdownSafe,
} from './utils.js';
import {
collectSafeFieldValues,
downloadViaHostBridge,
normalizeTone,
renderSafeField,
} from './ui_helpers.js';
function pageTemplate() {
return `
<section class="page app-page-glass" id="page-widgets">
${renderPageHeader({
title: 'Widgets',
icon: PAGE_ICONS.widgets,
description: 'Reviewed extension UI surfaces live here, separate from the skill catalogue.',
actionsHtml: '<button id="widgets-refresh" class="btn btn-default btn-sm">Refresh</button>',
})}
<div class="widgets-scroll scroll-fade-y">
<div id="widgets-list" class="widgets-list"></div>
</div>
</section>
`;
}
function renderShell(host, tabs) {
if (!tabs.length) {
host.innerHTML = '<div class="muted">No live widgets yet. Review and enable an extension that registers a UI tab.</div>';
return;
}
host.innerHTML = tabs.map((tab) => {
// Avoid leaking internal "skill:tab_id"; show skill only as needed.
const title = tab.title || tab.tab_id || tab.skill;
const subtitle = tab.skill && tab.skill !== title
? `<span class="widgets-card-source">from ${escapeHtml(tab.skill)}</span>`
: '';
const span = Number(tab.span || tab.grid_span || 1);
const spanClass = span >= 2 ? ' widgets-card-span-2' : '';
return `
<article class="widgets-card${spanClass}" data-widget-key="${escapeHtml(tab.key || `${tab.skill}:${tab.tab_id}`)}">
<div class="widgets-card-head">
<div class="widgets-card-title">
<strong>${escapeHtml(title)}</strong>
${subtitle}
</div>
<button class="widgets-card-drag" type="button" data-widget-reorder-handle title="Move widget: drag or use arrow keys" aria-label="Move widget: drag or use arrow keys">↕</button>
</div>
<div class="widgets-card-body" data-widget-mount></div>
</article>
`;
}).join('');
}
function widgetKey(tab) {
return tab.key || `${tab.skill}:${tab.tab_id}`;
}
function normalizeWidgetOrder(value) {
if (!Array.isArray(value)) return [];
const seen = new Set();
return value
.map((item) => String(item || '').trim())
.filter((item) => {
if (!item || seen.has(item)) return false;
seen.add(item);
return true;
});
}
function sortTabsByWidgetOrder(tabs, order) {
const rank = new Map(normalizeWidgetOrder(order).map((key, idx) => [key, idx]));
return tabs.map((tab, originalIndex) => ({ tab, originalIndex })).sort((a, b) => {
const aRank = rank.has(widgetKey(a.tab)) ? rank.get(widgetKey(a.tab)) : Number.MAX_SAFE_INTEGER;
const bRank = rank.has(widgetKey(b.tab)) ? rank.get(widgetKey(b.tab)) : Number.MAX_SAFE_INTEGER;
if (aRank !== bRank) return aRank - bRank;
return a.originalIndex - b.originalIndex;
}).map((item) => item.tab);
}
function currentWidgetOrderFromDom(list) {
return Array.from(list.querySelectorAll('[data-widget-key]'))
.map((card) => card.dataset.widgetKey || '')
.filter(Boolean);
}
function bindWidgetCardReorder(list, onOrderChange) {
if (!list) return;
let draggedKey = '';
const clearDragState = () => {
list.querySelectorAll('.widgets-card.dragging, .widgets-card.drag-over').forEach((card) => {
card.classList.remove('dragging', 'drag-over');
});
draggedKey = '';
};
const finishReorder = () => {
applyMasonry(list);
onOrderChange(currentWidgetOrderFromDom(list));
};
list.querySelectorAll('[data-widget-reorder-handle]').forEach((handle) => {
const card = handle.closest('[data-widget-key]');
if (!card) return;
handle.setAttribute('draggable', 'true');
handle.addEventListener('dragstart', (event) => {
draggedKey = card.dataset.widgetKey || '';
if (!draggedKey) return;
card.classList.add('dragging');
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', draggedKey);
}
});
handle.addEventListener('dragend', clearDragState);
handle.addEventListener('keydown', (event) => {
let moved = false;
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
const previous = card.previousElementSibling;
if (previous?.classList.contains('widgets-card')) {
previous.before(card);
moved = true;
}
} else if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
const next = card.nextElementSibling;
if (next?.classList.contains('widgets-card')) {
next.after(card);
moved = true;
}
} else if (event.key === 'Home') {
const first = list.querySelector('.widgets-card');
if (first && first !== card) {
first.before(card);
moved = true;
}
} else if (event.key === 'End') {
const cards = list.querySelectorAll('.widgets-card');
const last = cards[cards.length - 1];
if (last && last !== card) {
last.after(card);
moved = true;
}
}
if (!moved) return;
event.preventDefault();
clearDragState();
finishReorder();
handle.focus();
});
});
list.querySelectorAll('.widgets-card').forEach((card) => {
card.addEventListener('dragover', (event) => {
if (!draggedKey || card.dataset.widgetKey === draggedKey) return;
event.preventDefault();
card.classList.add('drag-over');
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
});
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
card.addEventListener('drop', (event) => {
if (!draggedKey || card.dataset.widgetKey === draggedKey) return;
event.preventDefault();
const dragged = list.querySelector(`[data-widget-key="${CSS.escape(draggedKey)}"]`);
if (!dragged) return;
const cards = Array.from(list.querySelectorAll('.widgets-card'));
const draggedIdx = cards.indexOf(dragged);
const targetIdx = cards.indexOf(card);
if (draggedIdx < 0 || targetIdx < 0) return;
if (draggedIdx < targetIdx) card.after(dragged);
else card.before(dragged);
clearDragState();
finishReorder();
});
});
}
function getPath(root, path, fallback = '') {
if (!path) return root ?? fallback;
let current = root;
for (const part of String(path).split('.').filter(Boolean)) {
if (current == null || typeof current !== 'object') return fallback;
current = current[part];
}
return current ?? fallback;
}
function safeMediaSrc(tab, spec, state, effectiveTarget = '') {
const route = spec.route || spec.api_route || '';
if (route) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(spec.query || {})) {
params.set(key, String(value ?? ''));
}
return extensionRoutePath(tab.skill, route, params);
}
const value = getPath(state[effectiveTarget || spec.target || 'result'], spec.path || '', spec.src || '');
const text = String(value || '').trim();
if (/^data:(image\/(?:png|jpeg|jpg|gif|webp)|audio\/(?:mpeg|wav|ogg)|video\/(?:mp4|webm|ogg));base64,[A-Za-z0-9+/=]+$/i.test(text)) {
return text;
}
if (text.startsWith('/api/extensions/')) {
try {
const parsed = new URL(text, window.location.origin);
const expectedPrefix = extensionRoutePrefix(tab.skill);
if (parsed.origin === window.location.origin && parsed.pathname.startsWith(expectedPrefix)) {
return parsed.pathname + parsed.search;
}
} catch {
return '';
}
}
return '';
}
function routePrefixToMediaSpec(routePrefix, value, itemType = 'image') {
const text = String(value || '').trim();
const prefix = String(routePrefix || '').trim();
if (!prefix || !text) return { type: itemType, src: text };
const [route, queryKey = 'path'] = prefix.split('?', 2);
const key = queryKey.endsWith('=') ? queryKey.slice(0, -1) : queryKey;
return {
type: itemType,
route,
query: { [key || 'path']: text },
};
}
function filenameFromWidgetUrl(url, fallback = 'download') {
try {
const parsed = new URL(url, window.location.origin);
for (const key of ['filename', 'image_id', 'clip_id']) {
const value = parsed.searchParams.get(key);
if (value) return value.split('/').pop() || fallback;
}
const base = parsed.pathname.split('/').filter(Boolean).pop();
return base || fallback;
} catch {
return fallback;
}
}
function componentIdentity(component, treePath) {
const explicitId = String(component?.id || '').trim();
return explicitId ? `id:${explicitId}` : `path:${treePath}`;
}
function indexComponentTree(components) {
const entries = [];
const byKey = new Map();
const visit = (component, path) => {
if (!component || typeof component !== 'object') return;
const key = componentIdentity(component, path);
if (byKey.has(key)) throw new Error(`duplicate declarative widget component identity: ${key}`);
const entry = { component, key, path };
byKey.set(key, entry);
entries.push(entry);
if (String(component.type || '') === 'group') {
(Array.isArray(component.components) ? component.components : []).forEach((child, idx) => {
visit(child, `${path}.components.${idx}`);
});
}
if (String(component.type || '') === 'tabs') {
(Array.isArray(component.tabs) ? component.tabs : []).forEach((item, tabIdx) => {
(Array.isArray(item?.components) ? item.components : []).forEach((child, idx) => {
visit(child, `${path}.tabs.${tabIdx}.components.${idx}`);
});
});
}
if (String(component.type || '') === 'subscription') {
(Array.isArray(component.render) ? component.render : []).forEach((child, idx) => {
visit(child, `${path}.render.${idx}`);
});
}
};
(Array.isArray(components) ? components : []).forEach((component, idx) => visit(component, `components.${idx}`));
return { entries, byKey };
}
function safeTableHref(value) {
const raw = String(value || '').trim();
if (!raw) return '';
try {
const parsed = new URL(raw, window.location.origin);
return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : '';
} catch {
return '';
}
}
function formatNumber(value, precision) {
if (value === null || value === undefined || value === '') return '—';
const numeric = Number(value);
if (!Number.isFinite(numeric)) return '—';
const parsedPrecision = Number(precision);
if (precision === undefined || precision === null || precision === '' || !Number.isFinite(parsedPrecision)) {
return numeric.toLocaleString(undefined, { maximumFractionDigits: 12 });
}
const digits = Math.max(0, Math.min(12, parsedPrecision));
return numeric.toLocaleString(undefined, { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function renderTableCell(row, column) {
const presentation = String(column.presentation || column.format || 'plain');
const raw = getPath(row, column.path, '');
if (presentation === 'number') {
const rendered = formatNumber(raw, column.precision);
return `${escapeHtml(rendered)}${rendered !== '—' && column.unit ? ` ${escapeHtml(column.unit)}` : ''}`;
}
if (presentation === 'status') {
const label = raw && typeof raw === 'object' ? (raw.label ?? raw.value ?? raw.status ?? '') : raw;
const toneValue = raw && typeof raw === 'object' ? raw.tone : getPath(row, column.tone_path || '', 'muted');
const tone = normalizeTone(toneValue);
return `<span class="widget-table-status" data-tone="${escapeHtml(tone)}">${escapeHtml(label || '—')}</span>`;
}
if (presentation === 'link') {
const rawHref = getPath(row, column.href_path || column.path, '');
const href = safeTableHref(rawHref);
const label = column.label_path ? getPath(row, column.label_path, rawHref) : raw;
if (!href) return escapeHtml(label || rawHref || '—');
return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(label || href)}</a>`;
}
return escapeHtml(raw ?? '');
}
const CHART_PALETTE = [
['#e85d6f', 'rgba(232, 93, 111, 0.22)'],
['#60a5fa', 'rgba(96, 165, 250, 0.22)'],
['#34d399', 'rgba(52, 211, 153, 0.22)'],
['#fbbf24', 'rgba(251, 191, 36, 0.22)'],
];
export function finiteChartValue(value) {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value !== 'string' || !value.trim()) return null;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function chartConfig(component, data) {
const type = ['line', 'bar'].includes(component.chart_type) ? component.chart_type : 'line';
const labels = component.labels || getPath(data, component.labels_path || 'labels', []);
const datasets = component.datasets || getPath(data, component.datasets_path || 'datasets', []);
const unit = String(component.unit || '');
return {
type,
data: {
labels: Array.isArray(labels) ? labels.map((item) => String(item ?? '')) : [],
datasets: Array.isArray(datasets) ? datasets.map((dataset, idx) => {
const [borderColor, backgroundColor] = CHART_PALETTE[idx % CHART_PALETTE.length];
return {
label: String(dataset?.label ?? 'Series'),
data: Array.isArray(dataset?.data) ? dataset.data.map(finiteChartValue) : [],
borderColor,
backgroundColor,
spanGaps: false,
};
}) : [],
},
options: {
responsive: true,
maintainAspectRatio: false,
spanGaps: false,
plugins: { legend: { display: true } },
scales: {
x: { grid: { color: 'rgba(255, 255, 255, 0.06)' } },
y: {
grid: { color: 'rgba(255, 255, 255, 0.06)' },
title: { display: Boolean(unit), text: unit },
},
},
},
};
}
function renderChartDataTable(config, label, expanded) {
const labels = config.data.labels || [];
const datasets = config.data.datasets || [];
const rows = labels.map((item, idx) => `<tr><th scope="row">${escapeHtml(item)}</th>${datasets.map((dataset) => `<td data-label="${escapeHtml(dataset.label)}">${escapeHtml(dataset.data[idx] ?? '—')}</td>`).join('')}</tr>`).join('');
return `<details class="widget-chart-data"${expanded ? ' open' : ''}><summary>View ${escapeHtml(label)} data</summary><div class="widget-table-wrap"><table class="widget-table"><thead><tr><th>Label</th>${datasets.map((dataset) => `<th>${escapeHtml(dataset.label)}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div></details>`;
}
function renderComponent(tab, component, view, treePath, inheritedTarget = '') {
const { state, status, componentState, formValues, pendingActions, visibleKeys } = view;
const type = String(component.type || '');
const target = component.target || inheritedTarget || 'result';
const data = state[target] ?? {};
const key = componentIdentity(component, treePath);
if (component.condition_key && !getPath(data, component.condition_key, false)) {
return '';
}
visibleKeys.add(key);
if (type === 'form') {
const busy = status[target] === 'loading' || pendingActions.has(key);
const disabled = Boolean(component.disabled) || busy;
const columns = Math.max(1, Math.min(4, Number(component.columns) || 1));
const fields = (Array.isArray(component.fields) ? component.fields : []).map((field) => renderSafeField(
field,
formValues[key] || {},
{ disabled, maxSpan: columns, spanClassPrefix: 'widget-field-span-' },
)).join('');
const label = busy ? (component.busy_label || 'Working…') : (component.submit_label || 'Submit');
const heading = component.title || component.label || '';
return `<form class="widget-form" data-widget-form="${escapeHtml(key)}" aria-busy="${busy ? 'true' : 'false'}"${heading ? ` aria-label="${escapeHtml(heading)}"` : ''}>${heading ? `<h4>${escapeHtml(heading)}</h4>` : ''}<div class="widget-form-fields widget-grid-cols-${columns}">${fields}</div><button class="btn btn-primary" type="submit"${disabled ? ' disabled' : ''}>${escapeHtml(label)}</button></form>`;
}
if (type === 'action') {
const busy = status[target] === 'loading' || pendingActions.has(key);
const label = busy ? (component.busy_label || 'Working…') : (component.label || 'Run');
return `<button type="button" class="btn btn-default" data-widget-action="${escapeHtml(key)}"${component.disabled || busy ? ' disabled' : ''}>${escapeHtml(label)}</button>`;
}
if (type === 'poll') {
const busy = status[target] === 'loading';
return `<button type="button" class="btn btn-default" data-widget-poll="${escapeHtml(key)}"${busy ? ' disabled' : ''}>${escapeHtml(busy ? (component.busy_label || 'Polling…') : (component.label || 'Start polling'))}</button>`;
}
if (type === 'group') {
const layout = ['stack', 'grid', 'cluster'].includes(component.layout) ? component.layout : 'stack';
const columns = Math.max(1, Math.min(4, Number(component.columns) || 2));
const passiveTarget = inheritedTarget ? target : '';
const children = (Array.isArray(component.components) ? component.components : []).map((child, idx) => (
renderComponent(tab, child, view, `${treePath}.components.${idx}`, passiveTarget)
)).join('');
const heading = component.title ? `<h4>${escapeHtml(component.title)}</h4>` : '';
const description = component.description ? `<p>${escapeHtml(component.description)}</p>` : '';
const columnClass = layout === 'grid' ? ` widget-grid-cols-${columns}` : '';
return `<section class="widget-group widget-group-${layout}${columnClass}">${heading}${description}<div class="widget-group-components">${children}</div></section>`;
}
if (type === 'metric') {
const raw = Object.prototype.hasOwnProperty.call(component, 'value')
? component.value
: getPath(data, component.path || '', undefined);
const text = typeof raw === 'string' ? raw.trim() : '';
const numericValue = text ? Number(text) : Number.NaN;
const numericText = Number.isFinite(numericValue);
const nonFiniteText = Boolean(text) && (
(!Number.isNaN(numericValue) && !Number.isFinite(numericValue))
|| ['nan', 'inf', '+inf', '-inf'].includes(text.toLowerCase())
);
const structured = raw !== null && typeof raw === 'object';
const missing = raw === undefined
|| raw === null
|| (typeof raw === 'string' && !text)
|| (typeof raw === 'number' && !Number.isFinite(raw))
|| nonFiniteText
|| structured;
const rendered = missing
? '—'
: (typeof raw === 'number' || numericText ? formatNumber(raw, component.precision) : String(raw));
const tone = normalizeTone(component.tone);
return `<div class="widget-metric" data-tone="${escapeHtml(tone)}"><span>${escapeHtml(component.label || '')}</span><strong>${escapeHtml(rendered)}${!missing && component.unit ? ` <small>${escapeHtml(component.unit)}</small>` : ''}</strong></div>`;
}
if (type === 'callout') {
const value = Object.prototype.hasOwnProperty.call(component, 'text')
? component.text
: getPath(data, component.path || '', '');
return `<div class="widget-callout" data-tone="${escapeHtml(normalizeTone(component.tone, 'info'))}">${escapeHtml(value ?? '')}</div>`;
}
if (type === 'status') {
const current = status[target] || 'idle';
const label = component[current]
|| (current === 'refreshing' ? component.loading : '')
|| current;
return `<div class="widget-status" data-state="${escapeHtml(current)}">${escapeHtml(label)}</div>`;
}
if (type === 'kv') {
const fields = component.fields || [];
const rows = fields.map((field) => {
const label = escapeHtml(field.label || field.path || '');
const value = getPath(data, field.path, '—');
return `<div class="widget-kv-row"><span>${label}</span><strong>${escapeHtml(value)}</strong></div>`;
}).join('');
return `<div class="widget-kv">${rows || '<div class="muted">No data.</div>'}</div>`;
}
if (type === 'key_value') {
const rows = getPath(data, component.items_key || component.path || '', []);
if (!Array.isArray(rows) || !rows.length) return '';
return `<div class="widget-kv">${rows.map((row) => `<div class="widget-kv-row"><span>${escapeHtml(row?.key || row?.label || '')}</span><strong>${escapeHtml(row?.value ?? '')}</strong></div>`).join('')}</div>`;
}
if (type === 'table') {
const rows = getPath(data, component.path || '', []);
const cols = component.columns || [];
if (!Array.isArray(rows)) return '<div class="muted">No rows.</div>';
return `<div class="widget-table-wrap"><table class="widget-table"><thead><tr>${cols.map((c) => `<th>${escapeHtml(c.label || c.path || '')}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${cols.map((c) => `<td data-label="${escapeHtml(c.label || c.path || '')}">${renderTableCell(row, c)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
}
if (type === 'markdown') {
const value = component.text ?? getPath(data, component.path || '', '');
return `<div class="widget-markdown ui-rich-content">${renderMarkdownSafe(value)}</div>`;
}
if (type === 'json') {
const value = component.path ? getPath(data, component.path, {}) : data;
return `<details class="widget-json"><summary>${escapeHtml(component.label || 'JSON')}</summary><pre>${escapeHtml(JSON.stringify(value, null, 2))}</pre></details>`;
}
if (type === 'code') {
const value = component.text ?? getPath(data, component.path || '', '');
const label = component.label ? `<div class="widget-code-label">${escapeHtml(component.label)}</div>` : '';
return `<div class="widget-code">${label}<pre><code>${escapeHtml(value)}</code></pre></div>`;
}
if (type === 'chart') {
const config = chartConfig(component, component.path ? getPath(data, component.path, {}) : data);
const label = String(component.aria_label || component.label || component.title || 'Chart');
const chartAvailable = typeof Chart !== 'undefined';
const canvas = chartAvailable ? `<div class="widget-chart-canvas"><canvas role="img" aria-label="${escapeHtml(label)}" data-widget-chart-key="${escapeHtml(key)}" data-widget-chart-config="${escapeHtml(JSON.stringify(config))}"></canvas></div>` : '';
return `<div class="widget-chart${chartAvailable ? '' : ' widget-chart-fallback'}">${canvas}${renderChartDataTable(config, label, !chartAvailable)}</div>`;
}
if (type === 'tabs') {
const tabs = Array.isArray(component.tabs) ? component.tabs : [];
const stateKey = `tab:${key}`;
const active = Math.max(0, Math.min(Number(componentState[stateKey] || 0), Math.max(tabs.length - 1, 0)));
const buttons = tabs.map((item, idx) => (
`<button type="button" class="widget-tab-btn ${idx === active ? 'active' : ''}" data-widget-tab-key="${escapeHtml(stateKey)}" data-widget-tab-idx="${idx}" aria-selected="${idx === active ? 'true' : 'false'}">${escapeHtml(item.label || `Tab ${idx + 1}`)}</button>`
)).join('');
const activeTab = tabs[active] || {};
const passiveTarget = inheritedTarget ? target : '';
const body = (activeTab.components || [])
.map((child, idx) => renderComponent(tab, child, view, `${treePath}.tabs.${active}.components.${idx}`, passiveTarget))
.join('');
return `<div class="widget-tabs"><div class="widget-tab-list">${buttons}</div><div class="widget-tab-body">${body || '<div class="muted">No content.</div>'}</div></div>`;
}
if (type === 'stream') {
const current = status[target] || 'idle';
return `<div class="widget-stream" data-state="${escapeHtml(current)}">${escapeHtml(component[current] || component.label || current)}</div>`;
}
if (['image', 'audio', 'video', 'file'].includes(type)) {
const src = safeMediaSrc(tab, component, state, target);
const label = escapeHtml(component.label || component.alt || type);
if (!src) return `<div class="muted">${label}: no safe media source.</div>`;
if (type === 'image') return `<figure class="widget-media"><img src="${escapeHtml(src)}" alt="${escapeHtml(component.alt || label)}"><figcaption>${label}</figcaption></figure>`;
if (type === 'audio') return `<div class="widget-media"><div>${label}</div><audio controls src="${escapeHtml(src)}"></audio></div>`;
if (type === 'video') return `<div class="widget-media"><div>${label}</div><video controls src="${escapeHtml(src)}"></video></div>`;
const filename = escapeHtml(component.filename || filenameFromWidgetUrl(src, label || 'download'));
return `<button class="btn btn-default widget-download" type="button" data-widget-download-url="${escapeHtml(src)}" data-widget-download-filename="${filename}">${label}</button>`;
}
if (type === 'gallery') {
let items = component.items || getPath(data, component.path || component.items_key || '', []);
if (!Array.isArray(items)) return '<div class="muted">No media items.</div>';
if (component.items_key && component.route_prefix) {
items = items.map((item) => routePrefixToMediaSpec(
component.route_prefix,
typeof item === 'object' ? (item.path || item.src || item.url || '') : item,
component.item_type || 'image',
));
}
const passiveTarget = inheritedTarget ? target : '';
return `<div class="widget-gallery">${items.map((item, idx) => renderComponent(tab, { ...item, type: item.type || 'image' }, view, `${treePath}.gallery.${idx}`, passiveTarget)).join('')}</div>`;
}
if (type === 'progress') {
const value = Number(getPath(data, component.path || component.value_key || 'progress', 0));
const bounded = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0;
const label = component.label_key ? getPath(data, component.label_key, '') : '';
return `<div class="widget-progress"><progress max="100" value="${bounded}"></progress><span>${bounded}%${label ? ` · ${escapeHtml(label)}` : ''}</span></div>`;
}
// Host-owned map renderer; no skill-supplied JS reaches the SPA origin.
if (type === 'map') {
const markers = Array.isArray(component.markers) ? component.markers : [];
const list = markers.length
? `<ul class="widget-map-list">${markers.map((m) => `<li><strong>${escapeHtml(m.label || `${m.lat}, ${m.lon}`)}</strong>${m.popup ? `${escapeHtml(m.popup)}` : ''}</li>`).join('')}</ul>`
: '<div class="muted">No map markers.</div>';
return `<div class="widget-map" data-widget-map-config="${escapeHtml(JSON.stringify({ tiles_url: component.tiles_url, markers }))}">${list}</div>`;
}
if (type === 'calendar') {
const items = Array.isArray(component.items) ? component.items : (Array.isArray(getPath(data, component.path || '', [])) ? getPath(data, component.path || '', []) : []);
if (!items.length) return '<div class="muted">No calendar entries.</div>';
const rows = items.map((item) => `<li class="widget-calendar-row"><strong>${escapeHtml(item.label || '—')}</strong>${item.start ? ` <span class="muted">${escapeHtml(item.start)}${item.end ? ' → ' + escapeHtml(item.end) : ''}</span>` : ''}${item.row ? ` <em>${escapeHtml(item.row)}</em>` : ''}</li>`).join('');
return `<div class="widget-calendar"><ul class="widget-calendar-list">${rows}</ul></div>`;
}
if (type === 'kanban') {
const columns = Array.isArray(component.columns) ? component.columns : [];
if (!columns.length) return '<div class="muted">Kanban has no columns.</div>';
const rawMoveRoute = component.on_move?.route || '';
const moveRoute = cleanExtensionRoute(rawMoveRoute) ? rawMoveRoute : '';
const cardsByCol = new Map();
for (const col of columns) cardsByCol.set(col.id || col.label, []);
const cardsList = Array.isArray(component.cards) ? component.cards : (Array.isArray(getPath(data, component.path || '', [])) ? getPath(data, component.path || '', []) : []);
for (const card of cardsList) {
const colKey = card.column || card.col || columns[0]?.id || columns[0]?.label;
if (!cardsByCol.has(colKey)) cardsByCol.set(colKey, []);
cardsByCol.get(colKey).push(card);
}
const colHtml = columns.map((col) => {
const colKey = col.id || col.label;
const cards = cardsByCol.get(colKey) || [];
const busy = status[target] === 'loading' || pendingActions.has(`kanban:${key}`);
const empty = cards.length
? ''
: '<div class="widget-kanban-empty">No cards</div>';
return `<div class="widget-kanban-col${cards.length ? '' : ' is-empty'}" data-widget-kanban-col="${escapeHtml(colKey)}">
<div class="widget-kanban-col-head"><strong>${escapeHtml(col.label || colKey)}</strong></div>
${cards.map((c, idx) => {
const cardId = c.id || `${colKey}-${idx}`;
const move = moveRoute ? `<label class="widget-kanban-move"><span>Move to</span><select data-widget-kanban-move data-widget-kanban-card-id="${escapeHtml(cardId)}"${busy ? ' disabled' : ''}>${columns.map((option) => {
const optionKey = option.id || option.label;
return `<option value="${escapeHtml(optionKey)}"${String(optionKey) === String(colKey) ? ' selected' : ''}>${escapeHtml(option.label || optionKey)}</option>`;
}).join('')}</select></label>` : '';
return `<div class="widget-kanban-card" draggable="${busy ? 'false' : 'true'}" data-widget-kanban-card="${escapeHtml(cardId)}"><span>${escapeHtml(c.label || c.title || '—')}</span>${move}</div>`;
}).join('')}${empty}
</div>`;
}).join('');
const error = status[target] === 'error' ? `<div class="widget-status" data-state="error">${escapeHtml(state[target]?.error || 'Move failed.')}</div>` : '';
return `<div class="widget-kanban" data-widget-kanban-key="${escapeHtml(key)}" data-widget-kanban-route="${escapeHtml(moveRoute || '')}" aria-busy="${status[target] === 'loading' ? 'true' : 'false'}">${colHtml}${error}</div>`;
}
if (type === 'subscription') {
const children = Array.isArray(component.render) ? component.render : [];
if (!children.length) return '';
return `<div class="widget-subscription-render">${children.map((child, idx) => {
if (!child || typeof child !== 'object') return '';
return renderComponent(tab, child, view, `${treePath}.render.${idx}`, target);
}).join('')}</div>`;
}
return '';
}
const widgetDisposers = new Map();
const widgetMessageHandlers = new Set();
const widgetSessionState = new Map();
let widgetsWsBridgeBound = false;
function boundedNumber(value, fallback, min, max) {
const parsed = Number(value);
const safe = Number.isFinite(parsed) ? parsed : fallback;
return Math.max(min, Math.min(safe, max));
}
async function callWidgetRoute(tab, spec, values, signal) {
const method = String(spec.method || 'GET').toUpperCase();
const params = new URLSearchParams();
for (const [key, value] of Object.entries(values || {})) {
params.set(key, String(value ?? ''));
}
const noBody = method === 'GET' || method === 'HEAD';
const url = extensionRoutePath(tab.skill, spec.route || spec.api_route, noBody ? params : null);
if (!url) throw new Error('invalid widget route');
const init = noBody
? { method, signal }
: {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values || {}),
signal,
};
const resp = await apiFetch(url, init);
const contentType = resp.headers.get('content-type') || '';
const data = contentType.includes('application/json')
? await resp.json().catch(() => ({}))
: { text: await resp.text() };
if (!resp.ok || data.error) throw new Error(data.error || `HTTP ${resp.status}`);
return data;
}
async function mountDeclarativeWidget(mount, tab, render) {
const components = Array.isArray(render.components) ? render.components : [];
const { entries: componentEntries, byKey: componentByKey } = indexComponentTree(components);
const componentSpec = (key) => componentByKey.get(key)?.component || null;
const persistenceKey = tab.key || `${tab.skill}:${tab.tab_id}`;
const saved = widgetSessionState.get(persistenceKey) || {};
const state = { ...(saved.state || {}) };
const status = { ...(saved.status || {}) };
const formValues = { ...(saved.formValues || {}) };
const componentState = { ...(saved.componentState || {}) };
const timers = new Set();
const controllers = new Set();
const chartInstances = new Map();
const chartShapes = new Map();
const eventSources = new Map();
const activePolls = new Set();
const activeJobs = new Set();
const autoStarted = new Set();
const messageHandlers = new Set();
const subscribed = new Set();
const pendingActions = new Set();
let disposed = false;
const downloadWidgetFile = async (url, filename) => {
const resolvedUrl = new URL(url, window.location.origin);
const expectedPrefix = extensionRoutePrefix(tab.skill);
if (resolvedUrl.origin !== window.location.origin || !resolvedUrl.pathname.startsWith(expectedPrefix)) {
throw new Error('download URL is outside this widget extension');
}
const safeName = filenameFromWidgetUrl(resolvedUrl.toString(), filename || 'download');
await downloadViaHostBridge(resolvedUrl.pathname + resolvedUrl.search, safeName, { fetchOptions: { credentials: 'include' } });
};
const schedule = (fn, delay) => {
if (disposed) return null;
const timer = setTimeout(() => {
timers.delete(timer);
fn();
}, delay);
timers.add(timer);
return timer;
};
const dispose = () => {
widgetSessionState.set(persistenceKey, {
state: { ...state },
status: { ...status },
formValues: { ...formValues },
componentState: { ...componentState },
});
disposed = true;
controllers.forEach((controller) => controller.abort());
controllers.clear();
chartInstances.forEach((chart) => chart.destroy());
chartInstances.clear();
chartShapes.clear();
eventSources.forEach((source) => source.close());
eventSources.clear();
timers.forEach((timer) => clearTimeout(timer));
timers.clear();
activePolls.clear();
activeJobs.clear();
pendingActions.clear();
messageHandlers.forEach((handler) => widgetMessageHandlers.delete(handler));
messageHandlers.clear();
subscribed.clear();
};
const callRoute = async (spec, values) => {
if (disposed) throw new Error('widget disposed');
const controller = new AbortController();
controllers.add(controller);
try {
return await callWidgetRoute(tab, spec, values, controller.signal);
} finally {
controllers.delete(controller);
}
};
const rememberFormValues = () => {
mount.querySelectorAll('[data-widget-form]').forEach((form) => {
const key = form.dataset.widgetForm || '';
const spec = componentSpec(key);
if (!spec) return;
formValues[key] = collectSafeFieldValues(form, spec.fields || [], { includePasswords: false });
});
};
const startPoll = (key) => {
if (disposed || activePolls.has(key)) return;
const spec = componentSpec(key);
if (!spec) return;
const target = spec.target || 'result';
const maxTicks = boundedNumber(spec.max_ticks, 20, 1, 100);
const intervalMs = boundedNumber(spec.interval_ms, 2000, 1000, 30000);
let ticks = 0;
activePolls.add(key);
const poll = async () => {
if (disposed) return;
ticks += 1;
// SWR (v6.71.0): 'loading' only when there is nothing to show yet;
// a background refetch keeps the content and shows a thin indicator.
const hadData = state[target] !== undefined;
status[target] = hadData ? 'refreshing' : 'loading';
renderAll();
try {
state[target] = await callRoute(spec, {});
if (disposed) return;
status[target] = 'success';
} catch (err) {
// SWR: a failed background refetch keeps the stale content —
// only the status reports the error; a failed FIRST load still
// surfaces the error payload (there is nothing else to show).
if (!hadData) state[target] = { error: err.message || String(err) };
status[target] = 'error';
}
const stopValue = getPath(state[target], spec.stop_path || '', undefined);
if (ticks < maxTicks && String(stopValue) !== String(spec.stop_value ?? 'done')) {
schedule(poll, intervalMs);
} else {
activePolls.delete(key);
}
renderAll();
};
poll();
};
// A job's progress is fed by two writers — the status poll and the WS
// subscription. Keep the percent monotonic per job so a stale poll tick or an
// out-of-order WS event can never move the bar backward. Resets when the job id
// changes. The percent key is whatever the progress component(s) read
// (`value_key`), so this works regardless of the skill's field name.
const progressValueKeys = (() => {
const keys = [];
for (const { component: c } of componentEntries) {
if (String(c?.type || '') !== 'progress') continue;
const k = String(c.path || c.value_key || 'progress');
if (k && !k.includes('.') && !keys.includes(k)) keys.push(k);
}
return keys.length ? keys : ['progress_pct', 'progress'];
})();
const clampMonotonicProgress = (target, jobId, nextObj) => {
if (!nextObj || typeof nextObj !== 'object') return nextObj;
let pctKey = '';
let pct;
for (const k of progressValueKeys) {
if (typeof nextObj[k] === 'number' && Number.isFinite(nextObj[k])) { pct = nextObj[k]; pctKey = k; break; }
}
if (pctKey === '') return nextObj;
const stateKey = `progress-clamp:${target}`;
const prev = componentState[stateKey];
if (prev && prev.jobId === jobId && prev.pct > pct) {
nextObj[pctKey] = prev.pct;
return nextObj;
}
componentState[stateKey] = { jobId, pct: nextObj[pctKey] };
return nextObj;
};
const startJobPoll = (key, jobId) => {
if (disposed || !jobId || activeJobs.has(key)) return;
const spec = componentSpec(key);
if (!spec) return;
const target = spec.target || 'result';
const statusRoute = spec.status_route || spec.job_status_route || 'status';
const intervalMs = boundedNumber(spec.interval_ms, 2000, 1000, 30000);
const maxTicks = boundedNumber(spec.max_ticks, 240, 1, 1000);
let ticks = 0;
activeJobs.add(key);
componentState[`job:${key}`] = { job_id: jobId, status_route: statusRoute };
const pollJob = async () => {
if (disposed) return;
ticks += 1;
try {
const data = await callRoute({ route: statusRoute, method: 'GET' }, { job_id: jobId });
if (disposed) return;
const currentStatus = String(data.status || data.state || '').toLowerCase();
if (currentStatus === 'done' || currentStatus === 'succeeded' || currentStatus === 'success') {
state[target] = data.result && typeof data.result === 'object' ? data.result : data;
status[target] = 'success';
delete componentState[`job:${key}`];
activeJobs.delete(key);
renderAll();
return;
}
if (currentStatus === 'error' || currentStatus === 'failed') {
state[target] = { error: data.error || 'job failed' };
status[target] = 'error';
delete componentState[`job:${key}`];
activeJobs.delete(key);
renderAll();
return;
}
// Merge the whole flat status payload so the renderer's value_key
// (e.g. `progress_pct`) is surfaced — cherry-picking `data.progress`
// dropped the percent and broke the poll fallback when WS hiccuped.
state[target] = clampMonotonicProgress(target, jobId, {
...(state[target] || {}),
...data,
job_id: jobId,
});
status[target] = 'loading';
renderAll();
if (ticks < maxTicks) {
schedule(pollJob, intervalMs);
} else {
state[target] = { error: 'job timed out waiting for result' };
status[target] = 'error';
delete componentState[`job:${key}`];
activeJobs.delete(key);
renderAll();
}
} catch (err) {
state[target] = { error: err.message || String(err) };
status[target] = 'error';
delete componentState[`job:${key}`];
activeJobs.delete(key);
renderAll();
}
};
pollJob();
};
const renderAll = () => {
if (disposed) return;
rememberFormValues();
widgetSessionState.set(persistenceKey, {
state: { ...state },
status: { ...status },
formValues: { ...formValues },
componentState: { ...componentState },
});
// In-place chart updates (v6.71.0): keep live canvases so a data-only
// re-render updates chart.data instead of destroy/recreate flicker.
const liveChartCanvases = new Map();
mount.querySelectorAll('canvas[data-widget-chart-key]').forEach((liveCanvas) => {
liveChartCanvases.set(liveCanvas.dataset.widgetChartKey || '', liveCanvas);
});
const visibleKeys = new Set();
const view = { state, status, componentState, formValues, pendingActions, visibleKeys };
mount.innerHTML = components.map((component, idx) => renderComponent(tab, component, view, `components.${idx}`)).join('');
mount.querySelectorAll('[data-widget-form]').forEach((form) => {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const key = form.dataset.widgetForm || '';
const spec = componentSpec(key);
if (!spec || spec.disabled || pendingActions.has(key)) return;
const target = spec.target || 'result';
const values = collectSafeFieldValues(form, spec.fields || []);
pendingActions.add(key);
status[target] = 'loading';
renderAll();
try {
const data = await callRoute(spec, values);
if (disposed) return;
if (spec.job === true || spec.mode === 'job') {
const jobId = data.job_id || data.id;
if (!jobId) throw new Error('job response missing job_id');
state[target] = { job_id: jobId, message: data.message || 'Job started.' };
status[target] = 'loading';
startJobPoll(key, jobId);
} else {
state[target] = data;
status[target] = 'success';
}
} catch (err) {
state[target] = { error: err.message || String(err) };
status[target] = 'error';
} finally {
pendingActions.delete(key);
renderAll();
}
});
});
mount.querySelectorAll('[data-widget-action]').forEach((button) => {
button.addEventListener('click', async () => {
const key = button.dataset.widgetAction || '';
const spec = componentSpec(key);
if (!spec || spec.disabled || pendingActions.has(key)) return;
const target = spec.target || 'result';
pendingActions.add(key);
status[target] = 'loading';
renderAll();
try {
const data = await callRoute(spec, spec.body || {});
if (disposed) return;
if (spec.job === true || spec.mode === 'job') {
const jobId = data.job_id || data.id;
if (!jobId) throw new Error('job response missing job_id');
state[target] = { job_id: jobId, message: data.message || 'Job started.' };
status[target] = 'loading';
startJobPoll(key, jobId);
} else {
state[target] = data;
status[target] = 'success';
}
} catch (err) {
state[target] = { error: err.message || String(err) };
status[target] = 'error';
} finally {
pendingActions.delete(key);
renderAll();
}
});
});
mount.querySelectorAll('[data-widget-poll]').forEach((button) => {
button.addEventListener('click', () => startPoll(button.dataset.widgetPoll || ''));
});
mount.querySelectorAll('[data-widget-tab-key]').forEach((button) => {
button.addEventListener('click', () => {
componentState[button.dataset.widgetTabKey] = Number(button.dataset.widgetTabIdx || 0);
renderAll();
});
});
mount.querySelectorAll('[data-widget-download-url]').forEach((button) => {
button.addEventListener('click', async (event) => {
event.preventDefault();
button.disabled = true;
try {
await downloadWidgetFile(button.dataset.widgetDownloadUrl || '', button.dataset.widgetDownloadFilename || 'download');
} catch (err) {
state.download = { error: err.message || String(err) };
status.download = 'error';
renderAll();
} finally {
button.disabled = false;
}
});
});
mount.querySelectorAll('[data-widget-kanban-key]').forEach((board) => {
const key = board.dataset.widgetKanbanKey || '';
const spec = componentSpec(key);
if (!spec) return;
const target = spec.target || 'result';
const pendingKey = `kanban:${key}`;
let draggedCardId = '';
const moveCard = async (cardId, columnId) => {
const route = board.dataset.widgetKanbanRoute || '';
if (!route || !cardId || !columnId || pendingActions.has(pendingKey)) return;
pendingActions.add(pendingKey);
status[target] = 'loading';
renderAll();
try {
state[target] = await callRoute(
{ route, method: spec.on_move?.method || 'POST' },
{ card_id: cardId, column_id: columnId },
);
if (disposed) return;
status[target] = 'success';
} catch (err) {
state[target] = { error: err.message || String(err) };
status[target] = 'error';
} finally {
pendingActions.delete(pendingKey);
renderAll();
}
};
board.querySelectorAll('[data-widget-kanban-card]').forEach((card) => {
card.addEventListener('dragstart', (event) => {
if (pendingActions.has(pendingKey)) {
event.preventDefault();
return;
}
draggedCardId = card.dataset.widgetKanbanCard || '';
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', draggedCardId);
}
});
});
board.querySelectorAll('[data-widget-kanban-col]').forEach((column) => {
column.addEventListener('dragover', (event) => {
if (!board.dataset.widgetKanbanRoute) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
});
column.addEventListener('drop', (event) => {
if (!board.dataset.widgetKanbanRoute) return;
event.preventDefault();
const cardId = event.dataTransfer?.getData('text/plain') || draggedCardId;
const columnId = column.dataset.widgetKanbanCol || '';
moveCard(cardId, columnId);
});
});
board.querySelectorAll('[data-widget-kanban-move]').forEach((select) => {
select.addEventListener('change', () => {
moveCard(select.dataset.widgetKanbanCardId || '', select.value || '');
});
});
});
const mountedChartKeys = new Set();
mount.querySelectorAll('[data-widget-chart-config]').forEach((canvas) => {
if (typeof Chart === 'undefined') return;
const chartKey = canvas.dataset.widgetChartKey || '';
mountedChartKeys.add(chartKey);
try {
const config = JSON.parse(canvas.dataset.widgetChartConfig || '{}');
const existing = chartInstances.get(chartKey);
const liveCanvas = liveChartCanvases.get(chartKey);
const shape = JSON.stringify({ type: config.type, options: config.options || {} });
const liveWrap = liveCanvas ? liveCanvas.parentElement : null;
const newWrap = canvas.parentElement;
if (existing && liveCanvas && existing.canvas === liveCanvas && chartShapes.get(chartKey) === shape
&& liveWrap && newWrap && liveWrap.classList.contains('widget-chart-canvas')
&& newWrap.classList.contains('widget-chart-canvas')) {
// Same chart shape: adopt the live WRAPPER (Chart.js observes
// the canvas's parent for responsive resize — adopting the bare
// canvas would leave the ResizeObserver on a detached node) and
// update data in place — no destroy/recreate flicker on poll
// ticks. Mirror the fresh config attributes so the live DOM
// never lies about the rendered chart.
newWrap.replaceWith(liveWrap);
liveCanvas.dataset.widgetChartConfig = canvas.dataset.widgetChartConfig;
liveCanvas.setAttribute('aria-label', canvas.getAttribute('aria-label') || '');
existing.data = config.data;
existing.update();
return;
}
if (existing) existing.destroy();
chartInstances.set(chartKey, new Chart(canvas, config));
chartShapes.set(chartKey, shape);
} catch (err) {
console.warn('widgets: chart render failed', err);
}
});
chartInstances.forEach((chart, chartKey) => {
if (!mountedChartKeys.has(chartKey)) {
chart.destroy();
chartInstances.delete(chartKey);
chartShapes.delete(chartKey);
}
});
visibleKeys.forEach((key) => {
const component = componentSpec(key);
if (!component) return;
if (String(component.type || '') === 'poll' && component.auto_start === true && !autoStarted.has(key)) {
autoStarted.add(key);
queueMicrotask(() => startPoll(key));
}
if (component.job === true || component.mode === 'job') {
const savedJob = componentState[`job:${key}`];
const jobId = savedJob && savedJob.job_id;
if (jobId && status[component.target || 'result'] === 'loading') {
queueMicrotask(() => startJobPoll(key, jobId));
}
}
if (String(component.type || '') !== 'stream' || eventSources.has(key)) return;
const url = extensionRoutePath(tab.skill, component.route || component.api_route, new URLSearchParams());
if (!url || typeof EventSource === 'undefined') return;
const target = component.target || 'result';
const source = new EventSource(url);
eventSources.set(key, source);
status[target] = 'loading';
source.onmessage = (event) => {
if (disposed) return;
try {
state[target] = JSON.parse(event.data);
} catch {
state[target] = { text: event.data || '' };
}
status[target] = 'success';
renderAll();
};
source.onerror = () => {
if (disposed) return;
status[target] = 'error';
renderAll();
};
});
visibleKeys.forEach((key) => {
const component = componentSpec(key);
if (!component || String(component.type || '') !== 'subscription' || subscribed.has(key)) return;
const event = String(component.event || component.message_type || '').trim();
const prefix = String(tab.ws_prefix || '').trim();
if (!event || !prefix) return;
const expectedType = `${prefix}${event}`;
const target = component.target || 'result';
const handler = (msg) => {
if (disposed || msg?.type !== expectedType) return;
const data = msg.data || {};
// Same monotonic guard as the poll writer: an out-of-order WS event
// must not rewind the bar.
state[target] = clampMonotonicProgress(target, data.job_id || '', { ...data });
status[target] = 'success';
renderAll();
};
subscribed.add(key);
messageHandlers.add(handler);
widgetMessageHandlers.add(handler);
});
};
renderAll();
return dispose;
}
async function mountTab(card, tab) {
const mount = card.querySelector('[data-widget-mount]');
const render = tab.render || {};
if (!mount) return;
if (render.kind === 'iframe' && render.route) {
const src = extensionRoutePath(tab.skill, render.route);
if (!src) throw new Error('invalid widget iframe route');
mount.innerHTML = `<iframe class="widgets-frame" sandbox="" src="${src}"></iframe>`;
return;
}
if (render.kind === 'declarative') {
return mountDeclarativeWidget(mount, tab, render);
}
if (render.kind === 'module' && render.entry) {
// Reviewed JS runs in an opaque iframe; parent fetch bridge only allows
// this skill's extension route prefix, preserving route IO without cookies.
const entryName = String(render.entry).replace(/[^A-Za-z0-9._-]/g, '');
const entryUrl = `${extensionRoutePrefix(tab.skill)}module/${encodeURIComponent(entryName)}`;
const resp = await apiFetch(entryUrl, { cache: 'no-store' });
const moduleSource = await resp.text();
if (!resp.ok) {
mount.innerHTML = `<div class="skills-load-error">module load failed: ${escapeHtml(moduleSource || `HTTP ${resp.status}`)}</div>`;
return;
}
const expectedPrefix = extensionRoutePrefix(tab.skill);
const nonce = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const csp = [
"default-src 'none'",
"script-src 'unsafe-inline'",
"style-src 'unsafe-inline'",
"img-src data:",
].join('; ');
const escapeScript = (value) => String(value || '')
.replace(/<\/script/gi, '<\\/script')
.replace(/<!--/g, '<\\!--');
const bridge = `
(() => {
const nonce = ${JSON.stringify(nonce)};
let seq = 0;
const pending = new Map();
window.addEventListener('message', (event) => {
const msg = event.data || {};
if (msg.type !== 'ouro-widget-fetch-result' || msg.nonce !== nonce) return;
const item = pending.get(msg.id);
if (!item) return;
pending.delete(msg.id);
if (msg.error) {
item.reject(new Error(msg.error));
return;
}
item.resolve(new Response(msg.body || '', {
status: msg.status || 200,
headers: msg.headers || {},
}));
});
window.fetch = (url, init = {}) => {
const id = ++seq;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
window.parent.postMessage({
type: 'ouro-widget-fetch',
nonce,
id,
url: String(url || ''),
init: {
method: init.method || 'GET',
headers: init.headers || {},
body: init.body || null,
},
}, '*');
});
};
window.OuroborosWidget = { fetch: window.fetch };
})();
`;
const srcdoc = `<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="${csp}"></head><body><div id="root"></div><script>${bridge}</script><script>${escapeScript(moduleSource)}</script></body></html>`;
mount.innerHTML = `<iframe class="widgets-frame" sandbox="allow-scripts" srcdoc="${escapeHtml(srcdoc)}"></iframe>`;
const iframe = mount.querySelector('iframe');
const onMessage = async (event) => {
if (event.source !== iframe.contentWindow) return;
const msg = event.data || {};
if (msg.type !== 'ouro-widget-fetch' || msg.nonce !== nonce) return;
try {
const parsed = new URL(String(msg.url || ''), window.location.origin);
if (parsed.origin !== window.location.origin || !parsed.pathname.startsWith(expectedPrefix)) {
throw new Error('module widget fetch outside extension route prefix');
}
const r = await apiFetch(parsed.pathname + parsed.search, {
method: String(msg.init?.method || 'GET').toUpperCase(),
headers: msg.init?.headers || {},
body: msg.init?.body || undefined,
credentials: 'same-origin',
});
const body = await r.text();
iframe.contentWindow?.postMessage({
type: 'ouro-widget-fetch-result',
nonce,
id: msg.id,
status: r.status,
headers: { 'content-type': r.headers.get('content-type') || '' },
body,
}, '*');
} catch (err) {
iframe.contentWindow?.postMessage({
type: 'ouro-widget-fetch-result',
nonce,
id: msg.id,
error: err.message || String(err),
}, '*');
}
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}
mount.innerHTML = `<div class="muted">Widget render kind <code>${escapeHtml(render.kind || 'unknown')}</code> is not supported yet.</div>`;
return null;
}
function disposeMountedWidgets() {
widgetDisposers.forEach((dispose) => {
try {
dispose();
} catch (err) {
console.warn('widgets: dispose failed', err);
}
});
widgetDisposers.clear();
}
async function mountTrackedTab(card, tab) {
const key = tab.key || `${tab.skill}:${tab.tab_id}`;
const existing = widgetDisposers.get(key);
if (existing) {
existing();
widgetDisposers.delete(key);
}
const dispose = await mountTab(card, tab);
if (typeof dispose === 'function') {
widgetDisposers.set(key, dispose);
return;
}
}
export function initWidgets(ctx = {}) {
const page = document.createElement('div');
page.innerHTML = pageTemplate();
document.getElementById('content').appendChild(page.firstElementChild);
const list = document.getElementById('widgets-list');
const refreshBtn = document.getElementById('widgets-refresh');
let renderGeneration = 0;
let widgetsVisible = false;
let widgetsMounted = false;
// Last good payload keeps revisits and slow refreshes from blanking the page.
let lastTabs = null;
let uiPreferences = { widget_order: [], nested_subagents_expanded: false };
if (ctx.ws && !widgetsWsBridgeBound) {
widgetsWsBridgeBound = true;
ctx.ws.on('message', (msg) => {
widgetMessageHandlers.forEach((handler) => handler(msg));
});
}
async function render(force = false) {
const generation = ++renderGeneration;
widgetsVisible = true;
if (widgetsMounted && !force) return;
refreshBtn.disabled = true;
refreshBtn.classList.add('is-loading');
disposeMountedWidgets();
if (lastTabs) {
renderShell(list, lastTabs);
bindWidgetCardReorder(list, persistWidgetOrder);
applyMasonry(list);
} else {
list.innerHTML = '<div class="muted">Loading widgets…</div>';
}
try {
const [data, prefs] = await Promise.all([
apiClient.extensions(),
apiClient.uiPreferences().catch(() => null),
]);
if (!widgetsVisible || generation !== renderGeneration) return;
if (prefs) {
uiPreferences = {
widget_order: normalizeWidgetOrder(prefs.widget_order),
nested_subagents_expanded: prefs.nested_subagents_expanded === true,
};
}
const tabs = sortTabsByWidgetOrder(
Array.isArray(data.live?.ui_tabs) ? data.live.ui_tabs : [],
uiPreferences.widget_order,
);
lastTabs = tabs;
renderShell(list, tabs);
bindWidgetCardReorder(list, persistWidgetOrder);
applyMasonry(list);
widgetsMounted = true;
for (const tab of tabs) {
if (!widgetsVisible || generation !== renderGeneration) return;
const key = widgetKey(tab);
const card = list.querySelector(`[data-widget-key="${CSS.escape(key)}"]`);
if (!card) continue;
try {
await mountTrackedTab(card, tab);
applyMasonry(list);
} catch (err) {
const mount = card.querySelector('[data-widget-mount]');
if (mount) mount.innerHTML = `<div class="skills-load-error">widget failed: ${escapeHtml(err.message || err)}</div>`;
applyMasonry(list);
}
}
applyMasonry(list);
} catch (err) {
if (!widgetsVisible || generation !== renderGeneration) return;
// Preserve cached widgets on transient fetch errors.
if (!lastTabs) {
list.innerHTML = `<div class="skills-load-error">Failed to load widgets: ${escapeHtml(err.message || err)}</div>`;
}
widgetsMounted = false;
} finally {
if (widgetsVisible && generation === renderGeneration) {
refreshBtn.disabled = false;
refreshBtn.classList.remove('is-loading');
}
}
}
function persistWidgetOrder(order) {
const normalized = normalizeWidgetOrder(order);
uiPreferences = { ...uiPreferences, widget_order: normalized };
if (lastTabs) {
lastTabs = sortTabsByWidgetOrder(lastTabs, normalized);
}
apiClient.saveUiPreferences({ widget_order: normalized }).catch((err) => {
console.warn('Failed to save widget order', err);
});
}
refreshBtn.addEventListener('click', () => render(true));
window.addEventListener('ouro:page-shown', (event) => {
if (event.detail?.page === 'widgets') {
render();
} else {
// Hide stops stale paints; next render reuses lastTabs for instant repaint.
widgetsVisible = false;
widgetsMounted = false;
disposeMountedWidgets();
}
});
}