wai/wai-ui becomes wai/wai-app, the library `wai_app`, with no binary and no `cli` feature: `wai`, built with its feature `app`, is its command line and its MCP server. Its served files are named wai-app-* (the WebAssembly package wai_app), the recordings wai-app-*.json. - export.rs gives the files of a starter project for a program and the facts its project file holds (title, summary, `wai dev`'s mode for the app's room, a watch room's file, a show's audience); `wai create --from app.oui` writes them, the project file and the licence. The export no longer writes app.json, room.toml, relay.toml, package.json, check.mjs or serve.mjs: wai.json names the room, `wai dev` runs it and `wai app check` checks the project. Its README and AGENTS.md say so, and the README its licence. - runtime.rs reads a Create runtime by its listing (create/wai-app-runtime.json), checking each file's SHA-256. - bridge.rs keeps the bridge on 127.0.0.1 a coding agent streams a program into the page through, with a stream helper; the stdio server, its tools and the binary's test go (`wai mcp --toolset app` serves them). - The page's Export hands over app.oui, the program in its canonical form, and the command that makes it a project; the ZIP writer and the page's export in WebAssembly go. The page names `wai mcp --toolset app` for the coding agent. - joule-ui/docs/create.md §4 and §5: the export as `wai create --from app.oui`, and the agent's tools app_guide, app_check, app_stream and app_export. Co-Authored-By: Claude Opus 5.5 <[email protected]>
112 lines
4.1 KiB
JavaScript
112 lines
4.1 KiB
JavaScript
// The streaming renderer: each snapshot of a program (joule-ui's line language, a tree
|
|
// every widget of which the registry accepted) reconciled into the page by key. A widget
|
|
// keeps its instance, and so its state (a room joined, a chat typed), across snapshots
|
|
// while its key and its widget stay the same; only its props are updated. A widget that
|
|
// appears is created (and marked, so the page shows it arriving); one that is gone is
|
|
// destroyed.
|
|
//
|
|
// A component is `(ctx, props, node) => ({ el, slot?, update?(props), destroy?() })`:
|
|
// `el` its element, `slot` where its children go (it holds nothing else).
|
|
|
|
export class Renderer {
|
|
constructor(container, components, ctx) {
|
|
this.container = container;
|
|
this.components = components;
|
|
this.ctx = ctx;
|
|
this.inst = new Map();
|
|
this.created = 0;
|
|
this.renders = 0;
|
|
}
|
|
|
|
render(snap) {
|
|
this.renders++;
|
|
const tree = snap && snap.tree;
|
|
if (!tree) {
|
|
for (const [k, i] of [...this.inst]) this.destroy(k, i);
|
|
this.container.replaceChildren();
|
|
return;
|
|
}
|
|
const nodes = snap.nodes || [];
|
|
let idx = 0;
|
|
const seen = new Set();
|
|
const walk = (w) => {
|
|
const node = nodes[idx++] || { key: `#${idx}`, line: 0, statement: "" };
|
|
const key = node.key;
|
|
seen.add(key);
|
|
let inst = this.inst.get(key);
|
|
if (inst && inst.name !== w.name) {
|
|
this.destroy(key, inst);
|
|
inst = null;
|
|
}
|
|
const json = JSON.stringify(w.props || {});
|
|
const props = plainProps(w.props);
|
|
if (!inst) {
|
|
const make = this.components[w.name] || unknown(w.name);
|
|
const comp = make(this.ctx, props, node);
|
|
comp.el.classList.add("wai-w", "wai-new");
|
|
comp.el.dataset.key = key;
|
|
comp.el.dataset.widget = w.name;
|
|
comp.el.dataset.line = String(node.line);
|
|
setTimeout(() => comp.el.classList.remove("wai-new"), 1400);
|
|
inst = { name: w.name, comp, json };
|
|
this.inst.set(key, inst);
|
|
this.created++;
|
|
} else if (inst.json !== json) {
|
|
if (inst.comp.update) inst.comp.update(props);
|
|
inst.json = json;
|
|
}
|
|
inst.comp.el.dataset.line = String(node.line);
|
|
const kids = (w.children || []).map(walk);
|
|
if (inst.comp.slot) place(inst.comp.slot, kids.map((k) => k.comp.el));
|
|
return inst;
|
|
};
|
|
const root = walk(tree);
|
|
for (const [k, i] of [...this.inst]) if (!seen.has(k)) this.destroy(k, i);
|
|
if (this.container.children.length !== 1 || this.container.firstElementChild !== root.comp.el) this.container.replaceChildren(root.comp.el);
|
|
}
|
|
|
|
destroy(key, inst) {
|
|
try { if (inst.comp.destroy) inst.comp.destroy(); } catch { /* the component's own fault */ }
|
|
inst.comp.el.remove();
|
|
this.inst.delete(key);
|
|
}
|
|
|
|
// The widgets on the page now, by key.
|
|
keys() { return [...this.inst.keys()]; }
|
|
}
|
|
|
|
// A prop value in joule-ui's wire form (`{type, value}`) as a plain value: text, number,
|
|
// boolean, null, an array, an object, or an action `{ action, payload }`.
|
|
export function plain(v) {
|
|
if (!v || typeof v !== "object") return v;
|
|
switch (v.type) {
|
|
case "null": return null;
|
|
case "list": return (v.value || []).map(plain);
|
|
case "object": return Object.fromEntries(Object.entries(v.value || {}).map(([k, x]) => [k, plain(x)]));
|
|
case "action": return { action: v.value.id, payload: v.value.payload == null ? null : plain(v.value.payload) };
|
|
default: return v.value;
|
|
}
|
|
}
|
|
|
|
export function plainProps(props) {
|
|
return Object.fromEntries(Object.entries(props || {}).map(([k, v]) => [k, plain(v)]));
|
|
}
|
|
|
|
// Put `els` in `slot` in order, moving only what is out of place (an iframe moved in the
|
|
// document reloads, so nothing in place is touched).
|
|
export function place(slot, els) {
|
|
for (const c of [...slot.children]) if (!els.includes(c)) c.remove();
|
|
for (let i = 0; i < els.length; i++) {
|
|
const at = slot.children[i];
|
|
if (at !== els[i]) slot.insertBefore(els[i], at || null);
|
|
}
|
|
}
|
|
|
|
function unknown(name) {
|
|
return () => {
|
|
const el = document.createElement("div");
|
|
el.className = "wai-unknown";
|
|
el.textContent = `(${name}: no component for this widget on this page)`;
|
|
return { el };
|
|
};
|
|
}
|