WIP: Claude Code progress on #17
Automated salvage commit — agent session ended (exit 124). Work in progress, may need continuation.
This commit is contained in:
@@ -15,6 +15,8 @@ let _canvas = null;
|
||||
let _camera = null;
|
||||
let _timmyGroup = null;
|
||||
let _applySlap = null;
|
||||
let _meterObjects = [];
|
||||
let _meterCallback = null;
|
||||
|
||||
const _raycaster = new THREE.Raycaster();
|
||||
const _pointer = new THREE.Vector2();
|
||||
@@ -33,6 +35,11 @@ export function registerSlapTarget(timmyGroup, applyFn) {
|
||||
_applySlap = applyFn;
|
||||
}
|
||||
|
||||
export function registerMeterTarget(objects, onClickFn) {
|
||||
_meterObjects = objects || [];
|
||||
_meterCallback = onClickFn;
|
||||
}
|
||||
|
||||
export function initInteraction(camera, renderer) {
|
||||
_camera = camera;
|
||||
_canvas = renderer.domElement;
|
||||
@@ -49,9 +56,11 @@ export function disposeInteraction() {
|
||||
_canvas.removeEventListener('pointerdown', _onPointerDown, { capture: true });
|
||||
_canvas = null;
|
||||
}
|
||||
_camera = null;
|
||||
_timmyGroup = null;
|
||||
_applySlap = null;
|
||||
_camera = null;
|
||||
_timmyGroup = null;
|
||||
_applySlap = null;
|
||||
_meterObjects = [];
|
||||
_meterCallback = null;
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────────────────────
|
||||
@@ -71,6 +80,17 @@ function _distPx(sx, sy, screenPos) {
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
function _hitTestMeter(clientX, clientY) {
|
||||
if (!_meterObjects.length || !_meterCallback || !_camera || !_canvas) return false;
|
||||
const rect = _canvas.getBoundingClientRect();
|
||||
_pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
||||
_pointer.y = ((clientY - rect.top) / rect.height) * -2 + 1;
|
||||
_raycaster.setFromCamera(_pointer, _camera);
|
||||
const hits = _raycaster.intersectObjects(_meterObjects, false);
|
||||
if (hits.length > 0) { _meterCallback(); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
function _hitTestTimmy(clientX, clientY) {
|
||||
if (!_timmyGroup || !_applySlap || !_camera || !_canvas) return false;
|
||||
|
||||
@@ -135,7 +155,8 @@ function _onPointerDown(event) {
|
||||
const dt = Date.now() - _tapStart;
|
||||
const moved = Math.hypot(upEvent.clientX - _tapX, upEvent.clientY - _tapY);
|
||||
if (dt < 220 && moved < 18) {
|
||||
// Short tap — try slap then inspect
|
||||
// Short tap — try meter, then slap, then inspect
|
||||
if (_hitTestMeter(upEvent.clientX, upEvent.clientY)) return;
|
||||
if (!_hitTestTimmy(upEvent.clientX, upEvent.clientY)) {
|
||||
_tryAgentInspect(upEvent.clientX, upEvent.clientY);
|
||||
}
|
||||
@@ -150,6 +171,17 @@ function _onPointerDown(event) {
|
||||
// Cast from screen center in FPS mode
|
||||
_pointer.set(0, 0);
|
||||
_raycaster.setFromCamera(_pointer, _camera);
|
||||
|
||||
// Check power meter first
|
||||
if (_meterObjects.length && _meterCallback) {
|
||||
const meterHits = _raycaster.intersectObjects(_meterObjects, false);
|
||||
if (meterHits.length > 0) {
|
||||
_meterCallback();
|
||||
event.stopImmediatePropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_timmyGroup && _applySlap) {
|
||||
const hits = _raycaster.intersectObject(_timmyGroup, true);
|
||||
if (hits.length > 0) {
|
||||
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
} from './agents.js';
|
||||
import { initEffects, updateEffects, disposeEffects } from './effects.js';
|
||||
import { initUI, updateUI } from './ui.js';
|
||||
import { initInteraction, disposeInteraction, registerSlapTarget } from './interaction.js';
|
||||
import { initInteraction, disposeInteraction, registerSlapTarget, registerMeterTarget } from './interaction.js';
|
||||
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
|
||||
import { initPaymentPanel } from './payment.js';
|
||||
import { initSessionPanel } from './session.js';
|
||||
import { initSessionPanel, openSessionPanel, syncPowerMeterState } from './session.js';
|
||||
import { initNostrIdentity } from './nostr-identity.js';
|
||||
import { warmup as warmupEdgeWorker, onReady as onEdgeWorkerReady } from './edge-worker-client.js';
|
||||
import { setEdgeWorkerReady } from './ui.js';
|
||||
@@ -18,6 +18,14 @@ import { initTimmyId } from './timmy-id.js';
|
||||
import { AGENT_DEFS } from './agent-defs.js';
|
||||
import { initNavigation, updateNavigation, disposeNavigation } from './navigation.js';
|
||||
import { initHudLabels, updateHudLabels, disposeHudLabels } from './hud-labels.js';
|
||||
import {
|
||||
initPowerMeter,
|
||||
updatePowerMeter,
|
||||
disposePowerMeter,
|
||||
getPowerMeterObjects,
|
||||
triggerPowerMeterClick,
|
||||
onPowerMeterClick,
|
||||
} from './power-meter.js';
|
||||
|
||||
let running = false;
|
||||
let canvas = null;
|
||||
@@ -29,6 +37,7 @@ function buildWorld(firstInit, stateSnapshot) {
|
||||
|
||||
initEffects(scene);
|
||||
initAgents(scene);
|
||||
initPowerMeter(scene, renderer);
|
||||
|
||||
if (stateSnapshot) applyAgentStates(stateSnapshot);
|
||||
|
||||
@@ -38,6 +47,10 @@ function buildWorld(firstInit, stateSnapshot) {
|
||||
initInteraction(camera, renderer);
|
||||
registerSlapTarget(getTimmyGroup(), applySlap);
|
||||
|
||||
// Power meter click → open session panel
|
||||
onPowerMeterClick(openSessionPanel);
|
||||
registerMeterTarget(getPowerMeterObjects(), triggerPowerMeterClick);
|
||||
|
||||
// AR floating labels
|
||||
initHudLabels(camera, AGENT_DEFS, TIMMY_WORLD_POS);
|
||||
|
||||
@@ -50,6 +63,9 @@ function buildWorld(firstInit, stateSnapshot) {
|
||||
warmupEdgeWorker();
|
||||
onEdgeWorkerReady(() => setEdgeWorkerReady());
|
||||
void initTimmyId();
|
||||
} else {
|
||||
// On scene rebuild, re-apply session state to the fresh meter
|
||||
syncPowerMeterState();
|
||||
}
|
||||
|
||||
const ac = new AbortController();
|
||||
@@ -81,6 +97,7 @@ function buildWorld(firstInit, stateSnapshot) {
|
||||
|
||||
updateEffects(now);
|
||||
updateAgents(now);
|
||||
updatePowerMeter(now, camera, renderer);
|
||||
updateUI({
|
||||
fps: currentFps,
|
||||
agentCount: getAgentCount(),
|
||||
@@ -120,6 +137,7 @@ function teardown({ scene, renderer, ac }) {
|
||||
disposeNavigation();
|
||||
disposeInteraction();
|
||||
disposeHudLabels();
|
||||
disposePowerMeter();
|
||||
disposeEffects();
|
||||
disposeAgents();
|
||||
disposeWorld(renderer, scene);
|
||||
|
||||
296
the-matrix/js/power-meter.js
Normal file
296
the-matrix/js/power-meter.js
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* power-meter.js — 3D Session Power Meter
|
||||
*
|
||||
* A glowing orb positioned in the Workshop scene that fills proportionally to
|
||||
* the session balance. Pulses on payment received, drains on job deduction.
|
||||
* Clicking the meter (or its HUD label) opens the session panel.
|
||||
*
|
||||
* Public API:
|
||||
* initPowerMeter(scene, renderer)
|
||||
* updatePowerMeter(time, camera, renderer)
|
||||
* setPowerMeterBalance(sats, maxSats)
|
||||
* pulsePowerMeter()
|
||||
* drainPowerMeter()
|
||||
* showPowerMeter(visible)
|
||||
* getPowerMeterObjects() — for raycasting in interaction.js
|
||||
* triggerPowerMeterClick() — called by interaction.js on hit
|
||||
* onPowerMeterClick(callback)
|
||||
* disposePowerMeter()
|
||||
*/
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
// Scene position: right side, near desk level, visible from default camera
|
||||
const METER_POS = new THREE.Vector3(5.0, 1.0, -1.0);
|
||||
const SPHERE_R = 0.52;
|
||||
|
||||
// Fill colors: empty → mid → full
|
||||
const COLOR_EMPTY = new THREE.Color(0xff3300);
|
||||
const COLOR_MID = new THREE.Color(0xffaa00);
|
||||
const COLOR_FULL = new THREE.Color(0x00ffcc);
|
||||
const COLOR_PULSE = new THREE.Color(0xffffff);
|
||||
const COLOR_DRAIN = new THREE.Color(0xff2200);
|
||||
|
||||
let _scene = null;
|
||||
let _group = null;
|
||||
let _fillMesh = null;
|
||||
let _clipPlane = null;
|
||||
let _glowLight = null;
|
||||
let _hudEl = null;
|
||||
let _ringMesh = null;
|
||||
|
||||
let _fillLevel = 0.0; // currently rendered fill (0–1), smoothed
|
||||
let _targetFill = 0.0; // target, set from balance
|
||||
let _currentSats = 0;
|
||||
let _visible = false;
|
||||
|
||||
// Pulse / drain animation timers (decay each frame)
|
||||
let _pulseT = 0.0;
|
||||
let _drainT = 0.0;
|
||||
|
||||
const _raycastObjects = [];
|
||||
let _clickCallback = null;
|
||||
const _projVec = new THREE.Vector3();
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function initPowerMeter(scene, renderer) {
|
||||
_scene = scene;
|
||||
renderer.localClippingEnabled = true;
|
||||
|
||||
_group = new THREE.Group();
|
||||
_group.position.copy(METER_POS);
|
||||
_group.visible = false;
|
||||
scene.add(_group);
|
||||
|
||||
// Clipping plane: show fill from bottom up.
|
||||
// Plane normal (0,-1,0), constant c → keeps points where -y + c ≥ 0 → y ≤ c.
|
||||
// At fillLevel f: c = R*(2f-1). f=0 → c=-R (nothing shown), f=1 → c=R (all shown).
|
||||
_clipPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), -SPHERE_R);
|
||||
|
||||
// Outer shell — transparent glass orb
|
||||
const outerGeo = new THREE.SphereGeometry(SPHERE_R, 24, 18);
|
||||
const outerMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x8899cc,
|
||||
transparent: true,
|
||||
opacity: 0.13,
|
||||
roughness: 0.05,
|
||||
metalness: 0.4,
|
||||
side: THREE.FrontSide,
|
||||
});
|
||||
const outerMesh = new THREE.Mesh(outerGeo, outerMat);
|
||||
_group.add(outerMesh);
|
||||
_raycastObjects.push(outerMesh);
|
||||
|
||||
// Equatorial ring glow
|
||||
const ringGeo = new THREE.TorusGeometry(SPHERE_R, 0.022, 8, 44);
|
||||
const ringMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x44aaff,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
});
|
||||
_ringMesh = new THREE.Mesh(ringGeo, ringMat);
|
||||
_ringMesh.rotation.x = Math.PI / 2;
|
||||
_group.add(_ringMesh);
|
||||
|
||||
// Fill sphere — clipped from bottom
|
||||
const fillGeo = new THREE.SphereGeometry(SPHERE_R * 0.91, 24, 18);
|
||||
const fillMat = new THREE.MeshStandardMaterial({
|
||||
color: COLOR_EMPTY.clone(),
|
||||
emissive: COLOR_EMPTY.clone(),
|
||||
emissiveIntensity: 0.5,
|
||||
roughness: 0.25,
|
||||
metalness: 0.1,
|
||||
clippingPlanes: [_clipPlane],
|
||||
clipShadows: true,
|
||||
});
|
||||
_fillMesh = new THREE.Mesh(fillGeo, fillMat);
|
||||
_group.add(_fillMesh);
|
||||
_raycastObjects.push(_fillMesh);
|
||||
|
||||
// Lightning bolt icon below the orb
|
||||
_buildBolt(_group);
|
||||
|
||||
// Point light — intensity tied to fill level
|
||||
_glowLight = new THREE.PointLight(COLOR_EMPTY.clone(), 0, 5);
|
||||
_group.add(_glowLight);
|
||||
|
||||
// HTML overlay for balance text (pointer-events: auto so it's clickable)
|
||||
_hudEl = document.createElement('div');
|
||||
_hudEl.id = 'power-meter-hud';
|
||||
_hudEl.style.cssText = [
|
||||
'position:fixed;transform:translate(-50%,0)',
|
||||
'z-index:20;text-align:center;cursor:pointer',
|
||||
'font-family:Courier New,monospace;font-size:11px;letter-spacing:1px',
|
||||
'color:#ffe566;text-shadow:0 0 8px #ffaa0088',
|
||||
'background:rgba(0,0,0,0.6);padding:2px 8px;border-radius:4px',
|
||||
'border:1px solid rgba(255,200,0,0.3)',
|
||||
'display:none;user-select:none',
|
||||
].join(';');
|
||||
_hudEl.addEventListener('click', () => { if (_clickCallback) _clickCallback(); });
|
||||
document.body.appendChild(_hudEl);
|
||||
}
|
||||
|
||||
function _buildBolt(group) {
|
||||
const mat = new THREE.MeshBasicMaterial({ color: 0xffcc00 });
|
||||
|
||||
// Upper segment — tilted box
|
||||
const top = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.2, 0.055), mat);
|
||||
top.position.set(0.03, -(SPHERE_R + 0.17), 0);
|
||||
top.rotation.z = -0.32;
|
||||
group.add(top);
|
||||
|
||||
// Lower segment
|
||||
const bot = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.2, 0.055), mat.clone());
|
||||
bot.position.set(-0.03, -(SPHERE_R + 0.40), 0);
|
||||
bot.rotation.z = -0.32;
|
||||
group.add(bot);
|
||||
}
|
||||
|
||||
// ── Per-frame update ──────────────────────────────────────────────────────────
|
||||
|
||||
export function updatePowerMeter(time, camera, renderer) {
|
||||
if (!_group || !_fillMesh) return;
|
||||
|
||||
const t = time * 0.001; // seconds
|
||||
|
||||
// Smooth fill animation (faster when far away)
|
||||
const diff = _targetFill - _fillLevel;
|
||||
_fillLevel += diff * 0.07;
|
||||
|
||||
// Update clip plane
|
||||
_clipPlane.constant = SPHERE_R * (2 * _fillLevel - 1);
|
||||
|
||||
// Base fill color
|
||||
const col = _lerpFillColor(_fillLevel);
|
||||
|
||||
// Pulse overlay (payment received — bright white-yellow flash)
|
||||
if (_pulseT > 0) {
|
||||
col.lerp(COLOR_PULSE, _pulseT * 0.8);
|
||||
_pulseT = Math.max(0, _pulseT - 0.035);
|
||||
}
|
||||
|
||||
// Drain overlay (job cost — red flash)
|
||||
if (_drainT > 0) {
|
||||
col.lerp(COLOR_DRAIN, _drainT * 0.55);
|
||||
_drainT = Math.max(0, _drainT - 0.045);
|
||||
}
|
||||
|
||||
_fillMesh.material.color.copy(col);
|
||||
_fillMesh.material.emissive.copy(col);
|
||||
|
||||
// Pulsing emissive glow
|
||||
const baseEmit = 0.25 + _fillLevel * 0.6;
|
||||
const pulseGlow = Math.sin(t * 2.2) * 0.08 + _pulseT * 1.8;
|
||||
_fillMesh.material.emissiveIntensity = baseEmit + pulseGlow;
|
||||
|
||||
// Ring color matches fill
|
||||
if (_ringMesh) _ringMesh.material.color.copy(col);
|
||||
|
||||
// Point light
|
||||
_glowLight.color.copy(col);
|
||||
_glowLight.intensity = _visible
|
||||
? (0.4 + _fillLevel * 2.2 + _pulseT * 3.5)
|
||||
: 0;
|
||||
|
||||
// Gentle sway
|
||||
_group.rotation.y = Math.sin(t * 0.45) * 0.07;
|
||||
|
||||
// Project HUD overlay to screen
|
||||
if (_visible && _hudEl && camera && renderer) {
|
||||
_projVec.copy(_group.position);
|
||||
_projVec.y -= SPHERE_R + 0.55; // place label below bolt icon
|
||||
_projVec.project(camera);
|
||||
|
||||
if (_projVec.z < 1.0) {
|
||||
const W = renderer.domElement.clientWidth || window.innerWidth;
|
||||
const H = renderer.domElement.clientHeight || window.innerHeight;
|
||||
const sx = (_projVec.x * 0.5 + 0.5) * W;
|
||||
const sy = (_projVec.y * -0.5 + 0.5) * H;
|
||||
_hudEl.style.left = `${sx}px`;
|
||||
_hudEl.style.top = `${sy}px`;
|
||||
_hudEl.style.display = '';
|
||||
} else {
|
||||
_hudEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _lerpFillColor(f) {
|
||||
const c = new THREE.Color();
|
||||
if (f < 0.5) {
|
||||
c.lerpColors(COLOR_EMPTY, COLOR_MID, f * 2);
|
||||
} else {
|
||||
c.lerpColors(COLOR_MID, COLOR_FULL, (f - 0.5) * 2);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update balance and recompute fill ratio.
|
||||
* @param {number} sats current balance
|
||||
* @param {number} maxSats session deposit max (denominator)
|
||||
*/
|
||||
export function setPowerMeterBalance(sats, maxSats) {
|
||||
_currentSats = sats;
|
||||
_targetFill = maxSats > 0 ? Math.max(0, Math.min(1, sats / maxSats)) : 0;
|
||||
if (_hudEl) _hudEl.textContent = `${sats} ⚡`;
|
||||
}
|
||||
|
||||
/** Bright flash — call when a payment is received. */
|
||||
export function pulsePowerMeter() {
|
||||
_pulseT = 1.0;
|
||||
}
|
||||
|
||||
/** Red flash — call when a job cost is deducted. */
|
||||
export function drainPowerMeter() {
|
||||
_drainT = 1.0;
|
||||
}
|
||||
|
||||
/** Show or hide the meter based on session active state. */
|
||||
export function showPowerMeter(visible) {
|
||||
_visible = visible;
|
||||
if (_group) _group.visible = visible;
|
||||
if (_hudEl) {
|
||||
if (!visible) _hudEl.style.display = 'none';
|
||||
// If visible, updatePowerMeter will set display on next frame
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns meshes used for click raycasting in interaction.js. */
|
||||
export function getPowerMeterObjects() {
|
||||
return _raycastObjects;
|
||||
}
|
||||
|
||||
/** Called by interaction.js when the meter is clicked in FPS mode. */
|
||||
export function triggerPowerMeterClick() {
|
||||
if (_clickCallback) _clickCallback();
|
||||
}
|
||||
|
||||
/** Register callback that opens the session panel. */
|
||||
export function onPowerMeterClick(cb) {
|
||||
_clickCallback = cb;
|
||||
}
|
||||
|
||||
export function disposePowerMeter() {
|
||||
if (_group) {
|
||||
_group.traverse(obj => {
|
||||
if (obj.geometry) obj.geometry.dispose();
|
||||
if (obj.material) {
|
||||
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
||||
mats.forEach(m => m.dispose());
|
||||
}
|
||||
});
|
||||
_scene?.remove(_group);
|
||||
_group = null;
|
||||
}
|
||||
_hudEl?.remove();
|
||||
_hudEl = null;
|
||||
_fillMesh = null;
|
||||
_ringMesh = null;
|
||||
_glowLight = null;
|
||||
_clipPlane = null;
|
||||
_raycastObjects.length = 0;
|
||||
}
|
||||
@@ -16,6 +16,12 @@ import { setSpeechBubble, setMood } from './agents.js';
|
||||
import { appendSystemMessage, setSessionSendHandler, setInputBarSessionMode } from './ui.js';
|
||||
import { getOrRefreshToken } from './nostr-identity.js';
|
||||
import { sentiment } from './edge-worker-client.js';
|
||||
import {
|
||||
setPowerMeterBalance,
|
||||
pulsePowerMeter,
|
||||
drainPowerMeter,
|
||||
showPowerMeter,
|
||||
} from './power-meter.js';
|
||||
|
||||
const API = '/api';
|
||||
const LS_KEY = 'timmy_session_v1';
|
||||
@@ -33,6 +39,7 @@ let _pollTimer = null;
|
||||
let _inFlight = false;
|
||||
let _selectedSats = 500; // deposit amount selection
|
||||
let _topupSats = 500; // topup amount selection
|
||||
let _sessionMaxSats = 500; // max sats for fill ratio (initial deposit)
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -105,6 +112,18 @@ export function isSessionActive() {
|
||||
return _sessionState === 'active' || _sessionState === 'paused';
|
||||
}
|
||||
|
||||
export function openSessionPanel() {
|
||||
_openPanel();
|
||||
}
|
||||
|
||||
/** Re-sync power meter after scene rebuild. */
|
||||
export function syncPowerMeterState() {
|
||||
showPowerMeter(isSessionActive());
|
||||
if (isSessionActive()) {
|
||||
setPowerMeterBalance(_balanceSats, _sessionMaxSats);
|
||||
}
|
||||
}
|
||||
|
||||
// Called by ui.js when user submits the input bar while session is active
|
||||
export async function sessionSendHandler(text) {
|
||||
if (!_sessionId || !_macaroon || _inFlight) return;
|
||||
@@ -156,6 +175,7 @@ export async function sessionSendHandler(text) {
|
||||
_sessionState = _balanceSats < MIN_BALANCE ? 'paused' : 'active';
|
||||
_saveToStorage();
|
||||
_applySessionUI();
|
||||
drainPowerMeter();
|
||||
|
||||
const reply = data.result || data.reason || '…';
|
||||
setSpeechBubble(reply);
|
||||
@@ -285,11 +305,13 @@ function _startDepositPolling() {
|
||||
const data = await res.json();
|
||||
|
||||
if (data.state === 'active') {
|
||||
_macaroon = data.macaroon;
|
||||
_balanceSats = data.balanceSats;
|
||||
_sessionState = 'active';
|
||||
_macaroon = data.macaroon;
|
||||
_balanceSats = data.balanceSats;
|
||||
_sessionMaxSats = data.balanceSats; // initial deposit becomes the max
|
||||
_sessionState = 'active';
|
||||
_saveToStorage();
|
||||
_applySessionUI();
|
||||
pulsePowerMeter();
|
||||
_closePanel(); // panel auto-closes; user types in input bar
|
||||
appendSystemMessage(`Session active — ${_balanceSats} sats`);
|
||||
return;
|
||||
@@ -401,11 +423,13 @@ function _startTopupPolling() {
|
||||
const data = await res.json();
|
||||
|
||||
if (data.balanceSats > prevBalance || data.state === 'active') {
|
||||
_sessionMaxSats = Math.max(_sessionMaxSats, data.balanceSats);
|
||||
_balanceSats = data.balanceSats;
|
||||
_macaroon = data.macaroon || _macaroon;
|
||||
_sessionState = data.state === 'active' ? 'active' : _sessionState;
|
||||
_saveToStorage();
|
||||
_applySessionUI();
|
||||
pulsePowerMeter();
|
||||
_setStep('active');
|
||||
_updateActiveStep();
|
||||
_setStatus('active', `⚡ Topped up! ${_balanceSats} sats`, '#22aa66');
|
||||
@@ -486,6 +510,12 @@ function _applySessionUI() {
|
||||
// Show low-balance overlay whenever session exists but balance is too low
|
||||
const lowBal = anyActive && _balanceSats < MIN_BALANCE;
|
||||
|
||||
// 3D power meter
|
||||
showPowerMeter(anyActive);
|
||||
if (anyActive) {
|
||||
setPowerMeterBalance(_balanceSats, _sessionMaxSats);
|
||||
}
|
||||
|
||||
// HUD balance: "Balance: X sats ⚡ Top Up"
|
||||
const $hud = document.getElementById('session-hud');
|
||||
if ($hud) {
|
||||
@@ -526,11 +556,13 @@ function _updateActiveStep() {
|
||||
}
|
||||
|
||||
function _clearSession() {
|
||||
_sessionId = null;
|
||||
_macaroon = null;
|
||||
_balanceSats = 0;
|
||||
_sessionState = null;
|
||||
_sessionId = null;
|
||||
_macaroon = null;
|
||||
_balanceSats = 0;
|
||||
_sessionState = null;
|
||||
_sessionMaxSats = 500;
|
||||
localStorage.removeItem(LS_KEY);
|
||||
showPowerMeter(false);
|
||||
setInputBarSessionMode(false);
|
||||
setSessionSendHandler(null);
|
||||
const $hud = document.getElementById('session-hud');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { setAgentState, setSpeechBubble, applyAgentStates, setMood } from './age
|
||||
import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker } from './ui.js';
|
||||
import { sentiment } from './edge-worker-client.js';
|
||||
import { setLabelState } from './hud-labels.js';
|
||||
import { setPowerMeterBalance, pulsePowerMeter, drainPowerMeter } from './power-meter.js';
|
||||
|
||||
function resolveWsUrl() {
|
||||
const explicit = import.meta.env.VITE_WS_URL;
|
||||
@@ -151,6 +152,17 @@ function handleMessage(msg) {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'session_balance_update': {
|
||||
// Server-pushed balance update (e.g. after payment confirmed or job deducted)
|
||||
if (typeof msg.balanceSats === 'number') {
|
||||
const maxSats = msg.maxSats ?? 0;
|
||||
setPowerMeterBalance(msg.balanceSats, maxSats);
|
||||
if (msg.reason === 'payment') pulsePowerMeter();
|
||||
if (msg.reason === 'deduction') drainPowerMeter();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'agent_count':
|
||||
case 'visitor_count':
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user