From 15fec51bb749da51ce6513213ab9032c0e5760ad Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 23 Mar 2026 15:56:44 -0400 Subject: [PATCH] feat: add real-time cost ticker for Workshop interactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a live cost ticker (⚡ ~N sats) visible in the top-right HUD during active paid interactions. The ticker appears when a work invoice is issued with the estimated cost, updates to the final charged amount when the job completes, and auto-hides after 5s. Changes: - event-bus.ts: add CostEvent type { cost:update, jobId, sats, phase, isFinal } - events.ts: translate cost:update bus events → cost_update WS messages - jobs.ts: emit cost:update with estimated sats on invoice creation, and again with actual sats when work completes (paid jobs only) - sessions.ts: emit cost:update with debitedSats after each session request - ui.js: add showCostTicker/updateCostTicker/hideCostTicker (fixed-position HUD badge) - websocket.js: handle cost_update messages, drive the cost ticker Fixes #68 Co-Authored-By: Claude Sonnet 4.6 --- artifacts/api-server/src/lib/event-bus.ts | 5 ++- artifacts/api-server/src/routes/events.ts | 10 +++++ artifacts/api-server/src/routes/jobs.ts | 6 +++ artifacts/api-server/src/routes/sessions.ts | 5 +++ the-matrix/js/ui.js | 48 +++++++++++++++++++++ the-matrix/js/websocket.js | 13 +++++- 6 files changed, 85 insertions(+), 2 deletions(-) diff --git a/artifacts/api-server/src/lib/event-bus.ts b/artifacts/api-server/src/lib/event-bus.ts index de4655c..79f8edb 100644 --- a/artifacts/api-server/src/lib/event-bus.ts +++ b/artifacts/api-server/src/lib/event-bus.ts @@ -15,7 +15,10 @@ export type DebateEvent = | { type: "debate:argument"; jobId: string; agent: "Beta-A" | "Beta-B"; position: "accept" | "reject"; argument: string } | { type: "debate:verdict"; jobId: string; accepted: boolean; reason: string }; -export type BusEvent = JobEvent | SessionEvent | DebateEvent; +export type CostEvent = + | { type: "cost:update"; jobId: string; sats: number; phase: "eval" | "work" | "session"; isFinal: boolean }; + +export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent; class EventBus extends EventEmitter { emit(event: "bus", data: BusEvent): boolean; diff --git a/artifacts/api-server/src/routes/events.ts b/artifacts/api-server/src/routes/events.ts index e9151a1..1ef6511 100644 --- a/artifacts/api-server/src/routes/events.ts +++ b/artifacts/api-server/src/routes/events.ts @@ -247,6 +247,16 @@ function translateEvent(ev: BusEvent): object | null { }; } + // ── Real-time cost ticker (#68) ─────────────────────────────────────────── + case "cost:update": + return { + type: "cost_update", + jobId: ev.jobId, + sats: ev.sats, + phase: ev.phase, + isFinal: ev.isFinal, + }; + default: return null; } diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index d063846..341ba68 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -205,6 +205,8 @@ async function runEvalInBackground( // to avoid economic DoS where pool is reserved before the user ever pays. eventBus.publish({ type: "job:state", jobId, state: "awaiting_work_payment" }); + // Emit estimated cost so the UI ticker can show ~N sats before payment + eventBus.publish({ type: "cost:update", jobId, sats: invoiceSats, phase: "work", isFinal: false }); } else { await db .update(jobs) @@ -307,6 +309,10 @@ async function runWorkInBackground( refundState, }); eventBus.publish({ type: "job:completed", jobId, result: workResult.result }); + // Emit final actual cost for the UI cost ticker + if (!isFree && actualAmountSats > 0) { + eventBus.publish({ type: "cost:update", jobId, sats: actualAmountSats, phase: "work", isFinal: true }); + } // Credit the generosity pool from paid interactions if (!isFree && workAmountSats > 0) { diff --git a/artifacts/api-server/src/routes/sessions.ts b/artifacts/api-server/src/routes/sessions.ts index 479c8b9..6d459b7 100644 --- a/artifacts/api-server/src/routes/sessions.ts +++ b/artifacts/api-server/src/routes/sessions.ts @@ -465,6 +465,11 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => { } }); + // Emit real-time cost update for the UI cost ticker (#68) + if (finalState === "complete" && debitedSats > 0) { + eventBus.publish({ type: "cost:update", jobId: requestId, sats: debitedSats, phase: "session", isFinal: true }); + } + // ── Trust scoring ──────────────────────────────────────────────────────── if (session.nostrPubkey) { if (finalState === "complete") { diff --git a/the-matrix/js/ui.js b/the-matrix/js/ui.js index 5e47fbb..70f293e 100644 --- a/the-matrix/js/ui.js +++ b/the-matrix/js/ui.js @@ -132,6 +132,54 @@ function _scheduleCostPreview(text) { _estimateTimer = setTimeout(() => _fetchEstimate(text), 300); } +// ── Live cost ticker ────────────────────────────────────────────────────────── +// Shown in the top-right HUD during active paid interactions. +// Updated via WebSocket `cost_update` messages from the backend. + +let $costTicker = null; +let _tickerHideTimer = null; + +function _ensureCostTicker() { + if ($costTicker) return $costTicker; + $costTicker = document.getElementById('timmy-cost-ticker'); + if (!$costTicker) { + $costTicker = document.createElement('div'); + $costTicker.id = 'timmy-cost-ticker'; + $costTicker.style.cssText = [ + 'position:fixed;top:36px;right:16px', + 'font-size:11px;font-family:"Courier New",monospace', + 'color:#ffcc44;text-shadow:0 0 6px #aa8822', + 'letter-spacing:1px', + 'pointer-events:none;z-index:10', + 'transition:opacity .4s;opacity:0', + ].join(';'); + document.body.appendChild($costTicker); + } + return $costTicker; +} + +export function showCostTicker(sats) { + clearTimeout(_tickerHideTimer); + const el = _ensureCostTicker(); + el.textContent = `⚡ ~${sats} sats`; + el.style.opacity = '1'; +} + +export function updateCostTicker(sats, isFinal = false) { + clearTimeout(_tickerHideTimer); + const el = _ensureCostTicker(); + el.textContent = isFinal ? `⚡ ${sats} sats charged` : `⚡ ~${sats} sats`; + el.style.opacity = '1'; + if (isFinal) { + _tickerHideTimer = setTimeout(hideCostTicker, 5000); + } +} + +export function hideCostTicker() { + if (!$costTicker) return; + $costTicker.style.opacity = '0'; +} + // ── Input bar ───────────────────────────────────────────────────────────────── export function initUI() { diff --git a/the-matrix/js/websocket.js b/the-matrix/js/websocket.js index 7efd1a3..ad1d412 100644 --- a/the-matrix/js/websocket.js +++ b/the-matrix/js/websocket.js @@ -1,5 +1,5 @@ import { setAgentState, setSpeechBubble, applyAgentStates, setMood } from './agents.js'; -import { appendSystemMessage, appendDebateMessage } from './ui.js'; +import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker } from './ui.js'; import { sentiment } from './edge-worker-client.js'; import { setLabelState } from './hud-labels.js'; @@ -140,6 +140,17 @@ function handleMessage(msg) { 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; -- 2.43.0