feat: add real-time cost ticker for Workshop interactions
Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 0s

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 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-23 15:56:44 -04:00
parent fb847b6e53
commit 15fec51bb7
6 changed files with 85 additions and 2 deletions

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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") {

View File

@@ -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() {

View File

@@ -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;