Files
timmy-tower/the-matrix/js/main.js
alexpaynex ad5ac0861d Task #23: Workshop session mode UI — fund once, ask many
## What was done
- **`the-matrix/js/session.js`** (new module): Full session mode UI lifecycle:
  - Create session flow: amount presets → POST /api/sessions → deposit invoice step
  - Deposit payment: stub simulate → 2s polling until state=active
  - macaroon + sessionId stored in localStorage (`timmy_session_v1`)
  - Request submission: intercepts input bar when session active → POST /api/sessions/:id/request
    → Timmy speech bubble shows result, balance updates in HUD
  - Low-balance (< 50 sats): paused state, low-balance notice shown, topup quick-button
  - Topup flow: preset amount → POST /api/sessions/:id/topup → topup invoice → stub pay → poll
  - Restore from localStorage on page reload: validates session via GET, restores full UI state
  - Session expiry / 401 macaroon rejection: clears storage, resets to unfunded state
- **`the-matrix/js/ui.js`**: Added `setSessionSendHandler(fn)` + `setInputBarSessionMode(active, placeholder)` exports; send() routes to session handler when active, falls back to WS visitor_message
- **`the-matrix/index.html`**:
  - `#top-buttons` flex container: " SUBMIT JOB" (blue) + " FUND SESSION" (teal) side-by-side
  - `#session-hud` balance line in HUD (green, hidden until session active)
  - `#session-panel` left-side slide-in panel: fund / invoice / active / topup steps
  - `.session-amount-btn` presets (200, 500, 1000, 2000, 5000 sats) with active state
  - `#visitor-input.session-active` CSS: green border + 3s pulse keyframe animation
  - `#low-balance-notice` strip above input bar with Top Up quick-button
  - `.primary-green` / `.muted` panel button variants for session panel theme
  - `#session-panel` inherits shared `.panel-btn`, `.invoice-box`, `.copy-btn` with green overrides
- **`the-matrix/js/main.js`**: Import + call `initSessionPanel()` in firstInit block

## Verification
- `npm run build` in the-matrix → clean build (0 errors)
- Full testkit: 27/27 PASS (all session tests 11–16, 22 still green)
2026-03-19 03:50:34 +00:00

121 lines
3.2 KiB
JavaScript

import { initWorld, onWindowResize, disposeWorld } from './world.js';
import {
initAgents, updateAgents, getAgentCount,
disposeAgents, getAgentStates, applyAgentStates,
getTimmyGroup, applySlap, getCameraShakeStrength,
} from './agents.js';
import { initEffects, updateEffects, disposeEffects } from './effects.js';
import { initUI, updateUI } from './ui.js';
import { initInteraction, disposeInteraction, registerSlapTarget } from './interaction.js';
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
import { initPaymentPanel } from './payment.js';
import { initSessionPanel } from './session.js';
let running = false;
let canvas = null;
function buildWorld(firstInit, stateSnapshot) {
const { scene, camera, renderer } = initWorld(canvas);
canvas = renderer.domElement;
initEffects(scene);
initAgents(scene);
if (stateSnapshot) applyAgentStates(stateSnapshot);
initInteraction(camera, renderer);
registerSlapTarget(getTimmyGroup(), applySlap);
if (firstInit) {
initUI();
initWebSocket(scene);
initPaymentPanel();
initSessionPanel();
}
const ac = new AbortController();
window.addEventListener('resize', () => onWindowResize(camera, renderer), { signal: ac.signal });
let frameCount = 0;
let lastFpsTime = performance.now();
let currentFps = 0;
running = true;
function animate() {
if (!running) return;
requestAnimationFrame(animate);
const now = performance.now();
frameCount++;
if (now - lastFpsTime >= 1000) {
currentFps = Math.round(frameCount * 1000 / (now - lastFpsTime));
frameCount = 0;
lastFpsTime = now;
}
updateEffects(now);
updateAgents(now);
updateUI({
fps: currentFps,
agentCount: getAgentCount(),
jobCount: getJobCount(),
connectionState: getConnectionState(),
});
// Camera shake — apply transient offset, render, then restore (no drift)
const shakeStr = getCameraShakeStrength();
let sx = 0, sy = 0;
if (shakeStr > 0) {
const mag = shakeStr * 0.22;
sx = (Math.random() - 0.5) * mag;
sy = (Math.random() - 0.5) * mag * 0.45;
camera.position.x += sx;
camera.position.y += sy;
}
renderer.render(scene, camera);
if (shakeStr > 0) {
camera.position.x -= sx;
camera.position.y -= sy;
}
}
animate();
return { scene, renderer, ac };
}
function teardown({ scene, renderer, ac }) {
running = false;
ac.abort();
disposeInteraction();
disposeEffects();
disposeAgents();
disposeWorld(renderer, scene);
}
function main() {
const $overlay = document.getElementById('webgl-recovery-overlay');
let handle = buildWorld(true, null);
canvas.addEventListener('webglcontextlost', event => {
event.preventDefault();
running = false;
if ($overlay) $overlay.style.display = 'flex';
});
canvas.addEventListener('webglcontextrestored', () => {
const snapshot = getAgentStates();
teardown(handle);
handle = buildWorld(false, snapshot);
if ($overlay) $overlay.style.display = 'none';
});
}
main();
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register(import.meta.env.BASE_URL + 'sw.js').catch(() => {});
});
}