v0.16.0: Workspace component system — cards, lists, structured display

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>
This commit is contained in:
Nico 2026-03-29 20:54:47 +02:00
parent d8ab778257
commit 217d1a57d9
6 changed files with 194 additions and 24 deletions

View File

@ -38,28 +38,39 @@ Given a job description, produce a JSON tool sequence to accomplish it.
Available tools: Available tools:
- query_db(query, database) SQL SELECT/DESCRIBE/SHOW only - query_db(query, database) SQL SELECT/DESCRIBE/SHOW only
- emit_card(card) show a detail card on the workspace:
{{"title": "...", "subtitle": "...", "fields": [{{"label": "Kunde", "value": "Mahnke GmbH", "action": "show_kunde_42"}}], "actions": [{{"label": "Geraete zeigen", "action": "show_geraete"}}]}}
Use for: single entity details, summaries, overviews.
Fields with "action" become clickable links.
- emit_list(list) show a list of cards:
{{"title": "Auftraege morgen", "items": [{{"title": "21479", "subtitle": "Mahnke - Goetheplatz 7", "fields": [{{"label":"Typ","value":"Ablesung"}}], "action": "show_auftrag_21479"}}]}}
Use for: multiple entities, search results, navigation lists.
- emit_actions(actions) show buttons [{{label, action, payload?}}] - emit_actions(actions) show buttons [{{label, action, payload?}}]
- set_state(key, value) persistent key-value - set_state(key, value) persistent key-value
- emit_display(items) formatted data [{{type, label, value?, style?}}] - emit_display(items) simple text/badge display [{{type, label, value?}}]
- create_machine(id, initial, states) interactive UI with navigation - create_machine(id, initial, states) interactive UI navigation
states: {{"state_name": {{"actions": [...], "display": [...]}}}} - add_state / reset_machine / destroy_machine machine lifecycle
- add_state(id, state, buttons, content) add state to machine
- reset_machine(id) reset to initial WHEN TO USE WHAT:
- destroy_machine(id) remove machine - Single entity detail (Kunde, Objekt, Auftrag) emit_card
- Multiple entities (list of Objekte, Auftraege) emit_list (few items) or query_db with table (many rows)
- Tabular data (Geraete, Verbraeuche) query_db (renders as table automatically)
- User choices / next steps emit_actions (buttons)
Output ONLY valid JSON: Output ONLY valid JSON:
{{ {{
"tool_sequence": [ "tool_sequence": [
{{"tool": "query_db", "args": {{"query": "SELECT ...", "database": "{database}"}}}}, {{"tool": "query_db", "args": {{"query": "SELECT ...", "database": "{database}"}}}},
{{"tool": "emit_actions", "args": {{"actions": [{{"label": "...", "action": "..."}}]}}}} {{"tool": "emit_card", "args": {{"card": {{"title": "...", "fields": [...], "actions": [...]}}}}}}
], ],
"response_hint": "How to phrase the result for the user" "response_hint": "How to phrase the result for the user"
}} }}
Rules: Rules:
- NEVER guess column names. If unsure, DESCRIBE first. - NEVER guess column names. Use ONLY columns from the schema.
- Max 5 tools. Keep it focused. - Max 5 tools. Keep it focused.
- The job is self-contained all context you need is in the job description.""" - The job is self-contained all context you need is in the job description.
- Prefer emit_card for entity details over raw text."""
RESPONSE_SYSTEM = """You are a domain expert summarizing results for the user. RESPONSE_SYSTEM = """You are a domain expert summarizing results for the user.
@ -125,6 +136,14 @@ Write a concise, natural response. 1-3 sentences.
if tool == "emit_actions": if tool == "emit_actions":
actions.extend(args.get("actions", [])) actions.extend(args.get("actions", []))
elif tool == "emit_card":
card = args.get("card", args)
card["type"] = "card"
display_items.append(card)
elif tool == "emit_list":
lst = args.get("list", args)
lst["type"] = "list"
display_items.append(lst)
elif tool == "set_state": elif tool == "set_state":
key = args.get("key", "") key = args.get("key", "")
if key: if key:

View File

@ -306,12 +306,17 @@ class UINode(Node):
"value": str(value), "value": str(value),
}) })
# 4. Add display items from Thinker's emit_display() calls # 4. Add display items (cards, lists, or simple display)
if thought.display_items: if thought.display_items:
for item in thought.display_items: for item in thought.display_items:
item_type = item.get("type", "text")
if item_type in ("card", "list"):
# Pass through structured components as-is
controls.append(item)
else:
controls.append({ controls.append({
"type": "display", "type": "display",
"display_type": item.get("type", "text"), "display_type": item_type,
"label": item.get("label", ""), "label": item.get("label", ""),
"value": item.get("value", ""), "value": item.get("value", ""),
"style": item.get("style", ""), "style": item.get("style", ""),

View File

@ -255,14 +255,24 @@ def check_actions(actions: list, check: str) -> tuple[bool, str]:
return True, f"{len(actions)} actions >= {expected}" return True, f"{len(actions)} actions >= {expected}"
return False, f"{len(actions)} actions < {expected}" return False, f"{len(actions)} actions < {expected}"
# has table # has TYPE or has TYPE1 or TYPE2
if check.strip() == "has table": m = re.match(r'has\s+(.+)', check)
if m:
types = [t.strip() for t in m.group(1).split(" or has ")]
# Also handle "card or has table" → ["card", "table"]
types = [t.replace("has ", "") for t in types]
for a in actions: for a in actions:
if isinstance(a, dict) and a.get("type") == "table": if isinstance(a, dict) and a.get("type") in types:
cols = a.get("columns", []) atype = a.get("type")
rows = len(a.get("data", [])) if atype == "table":
return True, f"table found: {len(cols)} cols, {rows} rows" return True, f"table found: {len(a.get('columns', []))} cols, {len(a.get('data', []))} rows"
return False, f"no table in {len(actions)} controls" elif atype == "card":
return True, f"card found: {a.get('title', '?')}, {len(a.get('fields', []))} fields"
elif atype == "list":
return True, f"list found: {a.get('title', '?')}, {len(a.get('items', []))} items"
else:
return True, f"{atype} found"
return False, f"no {' or '.join(types)} in {len(actions)} controls ({[a.get('type','?') for a in actions if isinstance(a, dict)]})"
# any action contains "foo" or "bar" — searches buttons only # any action contains "foo" or "bar" — searches buttons only
m = re.match(r'any action contains\s+"?(.+?)"?\s*$', check) m = re.match(r'any action contains\s+"?(.+?)"?\s*$', check)

View File

@ -77,11 +77,90 @@ export function dockControls(controls) {
+ (ctrl.value ? '<span class="cd-value">' + esc(String(ctrl.value)) + '</span>' : ''); + (ctrl.value ? '<span class="cd-value">' + esc(String(ctrl.value)) + '</span>' : '');
} }
container.appendChild(disp); 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); 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() { export function clearDashboard() {
const body = document.getElementById('workspace-body'); const body = document.getElementById('workspace-body');
if (body) body.innerHTML = ''; if (body) body.innerHTML = '';

View File

@ -143,6 +143,23 @@ button:hover { background: #1d4ed8; }
.cd-label { color: #888; } .cd-label { color: #888; }
.cd-value { color: #e0e0e0; margin-left: 0.5rem; } .cd-value { color: #e0e0e0; margin-left: 0.5rem; }
/* Workspace cards */
.ws-card { background: #111; border: 1px solid #222; border-radius: 0.4rem; padding: 0.5rem 0.6rem; width: 100%; }
.ws-card-clickable { cursor: pointer; }
.ws-card-clickable:hover { border-color: #2563eb; background: #0a1628; }
.ws-card-title { font-size: 0.85rem; font-weight: 700; color: #e0e0e0; }
.ws-card-subtitle { font-size: 0.7rem; color: #888; margin-top: 0.1rem; }
.ws-card-fields { margin-top: 0.4rem; display: flex; flex-direction: column; gap: 0.15rem; }
.ws-card-field { display: flex; justify-content: space-between; font-size: 0.75rem; padding: 0.1rem 0; }
.ws-card-key { color: #888; }
.ws-card-val { color: #e0e0e0; font-weight: 500; }
.ws-card-link { color: #60a5fa; cursor: pointer; font-weight: 500; }
.ws-card-link:hover { text-decoration: underline; }
.ws-card-actions { margin-top: 0.4rem; display: flex; gap: 0.3rem; flex-wrap: wrap; }
.ws-card-btn { font-size: 0.7rem; padding: 0.2rem 0.5rem; }
.ws-list { display: flex; flex-direction: column; gap: 0.3rem; width: 100%; }
.ws-list-title { font-size: 0.75rem; font-weight: 700; color: #888; text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 0.2rem; }
/* Login overlay */ /* Login overlay */
#login-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.85); display: flex; align-items: center; justify-content: center; z-index: 1000; } #login-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.85); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.login-card { background: #1a1a1a; padding: 2rem; border-radius: 0.6rem; text-align: center; } .login-card { background: #1a1a1a; padding: 2rem; border-radius: 0.6rem; text-align: center; }

View File

@ -0,0 +1,40 @@
# Workspace Components
Tests that the expert emits structured UI components (cards, lists, tables)
instead of dumping text or raw SQL. The workspace should show domain-aware displays.
## Setup
- clear history
## Steps
### 1. Detail card for a single entity
- send: zeig mir Details zu Kunde 2
- expect_trace: has tool_call
- expect_actions: has card
- expect_response: not contains "SELECT" or "JOIN"
- expect_response: length > 10
### 2. List of items with navigation
- send: zeig mir alle Objekte von Kunde 2
- expect_trace: has tool_call
- expect_actions: has card or has table
- expect_response: length > 10
### 3. Table for tabular data
- send: zeig mir die Geraete von Objekt 4
- expect_trace: has tool_call
- expect_actions: has table
- expect_response: length > 10
### 4. Card with actions (drill-down buttons)
- send: zeig mir Auftrag 21479
- expect_trace: has tool_call
- expect_actions: length >= 1
- expect_response: length > 10
### 5. Summary card with key metrics
- send: gib mir eine Zusammenfassung von Objekt 4
- expect_trace: has tool_call
- expect_actions: has card
- expect_response: length > 20