New workspace components: - emit_card: structured detail card with title, subtitle, fields, actions Fields can be clickable links (action property) Used for: entity details (Kunde, Objekt, Auftrag) - emit_list: vertical list of cards for multiple entities Used for: search results, navigation lists - "WHEN TO USE WHAT" guide in expert prompt Frontend rendering: - renderCard() with key-value fields, clickable links, action buttons - List container with title + stacked cards - Full CSS: dark theme cards, hover states, link styling Pipeline: - ExpertNode handles emit_card/emit_list in tool execution - UINode passes card/list through as-is (not wrapped in display) - Test runner: check_actions supports "has card", "has list", "has X or Y" Workspace components test: 22/22 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
168 lines
6.2 KiB
JavaScript
168 lines
6.2 KiB
JavaScript
/** Dashboard: workspace controls rendering (buttons, tables, labels, displays, machines). */
|
|
|
|
import { esc } from './util.js';
|
|
import { addTrace } from './trace.js';
|
|
import { setDashboard } from './chat.js';
|
|
|
|
let _ws = null;
|
|
|
|
export function setWs(ws) { _ws = ws; }
|
|
|
|
export function dockControls(controls) {
|
|
setDashboard(controls); // S3*: remember what's rendered
|
|
const body = document.getElementById('workspace-body');
|
|
if (!body) return;
|
|
body.innerHTML = '';
|
|
const container = document.createElement('div');
|
|
container.className = 'controls-container';
|
|
|
|
for (const ctrl of controls) {
|
|
if (ctrl.type === 'button') {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'control-btn';
|
|
btn.textContent = ctrl.label;
|
|
btn.onclick = () => {
|
|
if (_ws && _ws.readyState === 1) {
|
|
_ws.send(JSON.stringify({ type: 'action', action: ctrl.action, data: ctrl.payload || ctrl.data || {} }));
|
|
addTrace('runtime', 'action', ctrl.action);
|
|
}
|
|
};
|
|
container.appendChild(btn);
|
|
} else if (ctrl.type === 'table') {
|
|
const table = document.createElement('table');
|
|
table.className = 'control-table';
|
|
if (ctrl.columns) {
|
|
const thead = document.createElement('tr');
|
|
for (const col of ctrl.columns) {
|
|
const th = document.createElement('th');
|
|
th.textContent = col;
|
|
thead.appendChild(th);
|
|
}
|
|
table.appendChild(thead);
|
|
}
|
|
for (const row of (ctrl.data || [])) {
|
|
const tr = document.createElement('tr');
|
|
if (Array.isArray(row)) {
|
|
for (const cell of row) {
|
|
const td = document.createElement('td'); td.textContent = cell; tr.appendChild(td);
|
|
}
|
|
} else if (typeof row === 'object') {
|
|
for (const col of (ctrl.columns || Object.keys(row))) {
|
|
const td = document.createElement('td'); td.textContent = row[col] ?? ''; tr.appendChild(td);
|
|
}
|
|
}
|
|
table.appendChild(tr);
|
|
}
|
|
container.appendChild(table);
|
|
} else if (ctrl.type === 'label') {
|
|
const lbl = document.createElement('div');
|
|
lbl.className = 'control-label';
|
|
lbl.innerHTML = '<span class="cl-text">' + esc(ctrl.text || '') + '</span><span class="cl-value">' + esc(String(ctrl.value ?? '')) + '</span>';
|
|
container.appendChild(lbl);
|
|
} else if (ctrl.type === 'display') {
|
|
const disp = document.createElement('div');
|
|
const dt = ctrl.display_type || 'text';
|
|
const style = ctrl.style ? ' display-' + ctrl.style : '';
|
|
disp.className = 'control-display display-' + dt + style;
|
|
if (dt === 'progress') {
|
|
const pct = Math.min(100, Math.max(0, Number(ctrl.value) || 0));
|
|
disp.innerHTML = '<span class="cd-label">' + esc(ctrl.label) + '</span>'
|
|
+ '<div class="cd-bar"><div class="cd-fill" style="width:' + pct + '%"></div></div>'
|
|
+ '<span class="cd-pct">' + pct + '%</span>';
|
|
} else if (dt === 'status') {
|
|
disp.innerHTML = '<span class="cd-icon">' + (ctrl.style === 'success' ? '\u2713' : ctrl.style === 'error' ? '\u2717' : '\u2139') + '</span>'
|
|
+ '<span class="cd-label">' + esc(ctrl.label) + '</span>';
|
|
} else {
|
|
disp.innerHTML = '<span class="cd-label">' + esc(ctrl.label) + '</span>'
|
|
+ (ctrl.value ? '<span class="cd-value">' + esc(String(ctrl.value)) + '</span>' : '');
|
|
}
|
|
container.appendChild(disp);
|
|
} else if (ctrl.type === 'card') {
|
|
container.appendChild(renderCard(ctrl));
|
|
} else if (ctrl.type === 'list') {
|
|
const listEl = document.createElement('div');
|
|
listEl.className = 'ws-list';
|
|
if (ctrl.title) {
|
|
const h = document.createElement('div');
|
|
h.className = 'ws-list-title';
|
|
h.textContent = ctrl.title;
|
|
listEl.appendChild(h);
|
|
}
|
|
for (const item of (ctrl.items || [])) {
|
|
item.type = item.type || 'card';
|
|
listEl.appendChild(renderCard(item));
|
|
}
|
|
container.appendChild(listEl);
|
|
}
|
|
}
|
|
body.appendChild(container);
|
|
}
|
|
|
|
function renderCard(card) {
|
|
const el = document.createElement('div');
|
|
el.className = 'ws-card';
|
|
if (card.action) {
|
|
el.classList.add('ws-card-clickable');
|
|
el.onclick = () => {
|
|
if (_ws && _ws.readyState === 1) {
|
|
_ws.send(JSON.stringify({ type: 'action', action: card.action, data: card.payload || {} }));
|
|
addTrace('runtime', 'action', card.action);
|
|
}
|
|
};
|
|
}
|
|
|
|
let html = '';
|
|
if (card.title) html += '<div class="ws-card-title">' + esc(card.title) + '</div>';
|
|
if (card.subtitle) html += '<div class="ws-card-subtitle">' + esc(card.subtitle) + '</div>';
|
|
|
|
if (card.fields && card.fields.length) {
|
|
html += '<div class="ws-card-fields">';
|
|
for (const f of card.fields) {
|
|
const val = f.action
|
|
? '<span class="ws-card-link" data-action="' + esc(f.action) + '">' + esc(String(f.value ?? '')) + '</span>'
|
|
: '<span class="ws-card-val">' + esc(String(f.value ?? '')) + '</span>';
|
|
html += '<div class="ws-card-field"><span class="ws-card-key">' + esc(f.label || '') + '</span>' + val + '</div>';
|
|
}
|
|
html += '</div>';
|
|
}
|
|
|
|
if (card.actions && card.actions.length) {
|
|
html += '<div class="ws-card-actions">';
|
|
for (const a of card.actions) {
|
|
html += '<button class="control-btn ws-card-btn" data-action="' + esc(a.action || '') + '">' + esc(a.label || '') + '</button>';
|
|
}
|
|
html += '</div>';
|
|
}
|
|
|
|
el.innerHTML = html;
|
|
|
|
// Wire up field links and action buttons
|
|
el.querySelectorAll('.ws-card-link').forEach(link => {
|
|
link.onclick = (e) => {
|
|
e.stopPropagation();
|
|
const action = link.dataset.action;
|
|
if (_ws && _ws.readyState === 1) {
|
|
_ws.send(JSON.stringify({ type: 'action', action, data: {} }));
|
|
addTrace('runtime', 'action', action);
|
|
}
|
|
};
|
|
});
|
|
el.querySelectorAll('.ws-card-btn').forEach(btn => {
|
|
btn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
const action = btn.dataset.action;
|
|
if (_ws && _ws.readyState === 1) {
|
|
_ws.send(JSON.stringify({ type: 'action', action, data: {} }));
|
|
addTrace('runtime', 'action', action);
|
|
}
|
|
};
|
|
});
|
|
|
|
return el;
|
|
}
|
|
|
|
export function clearDashboard() {
|
|
const body = document.getElementById('workspace-body');
|
|
if (body) body.innerHTML = '';
|
|
}
|