Files
timmy-tower/the-matrix/js/main.js
Alexander Whitestone 7b10d088ec
Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 0s
feat: add job history tab with localStorage persistence (#31)
Add a bottom-sheet History panel that shows completed jobs in reverse
chronological order with expandable results.

- New history.js module: persists up to 50 jobs in localStorage
  (timmy_history_v1), renders rows with prompt/cost/relative-time,
  smooth expand/collapse animation, pull-to-refresh and refresh button
- index.html: History panel HTML + CSS (bottom sheet slides up from
  bottom edge), "⏱ HISTORY" button added to top-buttons bar
- payment.js: calls addHistoryEntry() when a Lightning job reaches
  complete or rejected state; tracks currentRequest across async flow
- session.js: calls addHistoryEntry() after each session request
  completes, computing cost from balance delta
- main.js: imports and calls initHistoryPanel() on first init

Fixes #31

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

157 lines
4.4 KiB
JavaScript

import { initWorld, onWindowResize, disposeWorld } from './world.js';
import {
initAgents, updateAgents, getAgentCount,
disposeAgents, getAgentStates, applyAgentStates,
getTimmyGroup, applySlap, getCameraShakeStrength,
TIMMY_WORLD_POS,
} from './agents.js';
import { initEffects, updateEffects, disposeEffects, updateJobIndicators } 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';
import { initHistoryPanel } from './history.js';
import { initNostrIdentity } from './nostr-identity.js';
import { warmup as warmupEdgeWorker, onReady as onEdgeWorkerReady } from './edge-worker-client.js';
import { setEdgeWorkerReady } from './ui.js';
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';
let running = false;
let canvas = null;
let _lastTime = performance.now();
function buildWorld(firstInit, stateSnapshot) {
const { scene, camera, renderer } = initWorld(canvas);
canvas = renderer.domElement;
initEffects(scene);
initAgents(scene);
if (stateSnapshot) applyAgentStates(stateSnapshot);
// Navigation replaces OrbitControls
initNavigation(camera, renderer);
initInteraction(camera, renderer);
registerSlapTarget(getTimmyGroup(), applySlap);
// AR floating labels
initHudLabels(camera, AGENT_DEFS, TIMMY_WORLD_POS);
if (firstInit) {
initUI();
initWebSocket(scene);
initPaymentPanel();
initSessionPanel();
initHistoryPanel();
void initNostrIdentity('/api');
warmupEdgeWorker();
onEdgeWorkerReady(() => setEdgeWorkerReady());
void initTimmyId();
}
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();
const deltaMs = now - _lastTime;
_lastTime = now;
frameCount++;
if (now - lastFpsTime >= 1000) {
currentFps = Math.round(frameCount * 1000 / (now - lastFpsTime));
frameCount = 0;
lastFpsTime = now;
}
// FPS navigation
updateNavigation(deltaMs);
updateEffects(now);
updateAgents(now);
updateJobIndicators(now);
updateUI({
fps: currentFps,
agentCount: getAgentCount(),
jobCount: getJobCount(),
connectionState: getConnectionState(),
});
// Camera shake
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;
}
// AR label positions (after render so NDC is current)
updateHudLabels(camera, renderer);
}
animate();
return { scene, renderer, ac };
}
function teardown({ scene, renderer, ac }) {
running = false;
ac.abort();
disposeNavigation();
disposeInteraction();
disposeHudLabels();
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);
_lastTime = performance.now();
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(() => {});
});
}