Files
timmy-tower/the-matrix/js/websocket.js
Alexander Whitestone ac553cb6b4
Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 0s
feat: task decomposition view during execution (#5)
At the start of job execution, a Haiku call decomposes the user's
request into 2-4 named sub-steps. Steps are broadcast via WebSocket
as `job_steps` and `job_step_update` events. The Workshop renders a
live checklist panel near Gamma that checks off steps as streaming
progresses using a character-count heuristic, then collapses to
"Done" on completion. Steps are stored with the job record.

- agent.ts: add `decomposeRequest` (Haiku, stub-safe)
- event-bus.ts: add `DecompositionEvent` types (job:steps, job:step_update)
- jobs.ts: call decompose before streaming; advance steps heuristically
- events.ts: translate new bus events to WS messages
- websocket.js: handle job_steps / job_step_update / collapse on complete
- ui.js: showJobSteps / updateJobStep / collapseJobSteps panel
- jobs schema: decomposition_steps text column
- migration 0010: add decomposition_steps column

Fixes #5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 16:37:50 -04:00

184 lines
5.0 KiB
JavaScript

import { setAgentState, setSpeechBubble, applyAgentStates, setMood } from './agents.js';
import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker, showJobSteps, updateJobStep, collapseJobSteps } from './ui.js';
import { sentiment } from './edge-worker-client.js';
import { setLabelState } from './hud-labels.js';
function resolveWsUrl() {
const explicit = import.meta.env.VITE_WS_URL;
if (explicit) return explicit;
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${window.location.host}/api/ws`;
}
const WS_URL = resolveWsUrl();
let ws = null;
let connectionState = 'disconnected';
let jobCount = 0;
let reconnectTimer = null;
let visitorId = null;
const RECONNECT_DELAY_MS = 5000;
export function initWebSocket(_scene) {
visitorId = crypto.randomUUID();
connect();
}
function connect() {
if (ws) { ws.onclose = null; ws.close(); }
connectionState = 'connecting';
try {
ws = new WebSocket(WS_URL);
} catch {
connectionState = 'disconnected';
scheduleReconnect();
return;
}
ws.onopen = () => {
connectionState = 'connected';
clearTimeout(reconnectTimer);
send({ type: 'visitor_enter', visitorId, visitorName: 'visitor' });
};
ws.onmessage = event => {
try { handleMessage(JSON.parse(event.data)); } catch { /* ignore */ }
};
ws.onerror = () => { connectionState = 'disconnected'; };
ws.onclose = () => { connectionState = 'disconnected'; scheduleReconnect(); };
}
function scheduleReconnect() {
clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS);
}
function send(payload) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
}
function handleMessage(msg) {
switch (msg.type) {
case 'ping':
send({ type: 'pong' });
break;
case 'world_state': {
if (msg.agentStates) applyAgentStates(msg.agentStates);
if (msg.recentEvents) {
const last = msg.recentEvents.slice(-3);
last.forEach(ev => appendSystemMessage(ev.summary || ev.type));
}
break;
}
case 'timmy_state': {
break;
}
case 'agent_state': {
if (msg.agentId && msg.state) {
setAgentState(msg.agentId, msg.state);
setLabelState(msg.agentId, msg.state);
}
break;
}
case 'job_started': {
jobCount++;
if (msg.agentId) {
setAgentState(msg.agentId, 'active');
setLabelState(msg.agentId, 'active');
}
appendSystemMessage(`job ${(msg.jobId || '').slice(0, 8)} started`);
break;
}
case 'job_completed': {
if (jobCount > 0) jobCount--;
if (msg.agentId) {
setAgentState(msg.agentId, 'idle');
setLabelState(msg.agentId, 'idle');
}
appendSystemMessage(`job ${(msg.jobId || '').slice(0, 8)} complete`);
collapseJobSteps();
break;
}
case 'chat': {
if (msg.agentId === 'timmy') {
// Timmy's AI reply: show in speech bubble + event log
if (msg.text) setSpeechBubble(msg.text);
appendSystemMessage('Timmy: ' + (msg.text || '').slice(0, 80));
// Sentiment-driven facial expression on inbound Timmy messages
if (msg.text) {
sentiment(msg.text).then(s => {
setMood(s.label);
setTimeout(() => setMood(null), 10_000);
}).catch(() => {});
}
} else if (msg.agentId === 'visitor') {
// Another visitor's message: event log only (don't hijack the speech bubble)
appendSystemMessage((msg.text || '').slice(0, 80));
} else {
// System agent messages (delta payment confirmations, etc.): speech bubble
if (msg.text) setSpeechBubble(msg.text);
}
break;
}
case 'agent_debate': {
// Debate messages from Beta-A, Beta-B, or final verdict (#21)
const isVerdict = msg.position === 'verdict';
appendDebateMessage(msg.agent, msg.argument, isVerdict, msg.accepted);
if (isVerdict) {
setSpeechBubble(msg.argument);
}
break;
}
case 'job_steps': {
// Task decomposition (#5): render checklist panel with step labels
if (Array.isArray(msg.steps) && msg.steps.length > 0) {
showJobSteps(msg.steps);
}
break;
}
case 'job_step_update': {
// Task decomposition (#5): advance active step and mark completed ones
updateJobStep(msg.activeStep, msg.completedSteps || []);
break;
}
case 'cost_update': {
// Real-time cost ticker (#68): show estimated cost when job starts,
// update to final charged amount when job completes.
if (msg.isFinal) {
updateCostTicker(msg.sats, true);
} else {
showCostTicker(msg.sats);
}
break;
}
case 'agent_count':
case 'visitor_count':
break;
default:
break;
}
}
export function sendVisitorMessage(text) {
send({ type: 'visitor_message', visitorId, text });
}
export function getConnectionState() { return connectionState; }
export function getJobCount() { return jobCount; }