Files
timmy-tower/the-matrix/js/session.js
alexpaynex c7e3a9b853 Task #23: Workshop session mode UI — fund once, ask many (all review issues fixed)
## Changes

### the-matrix/js/session.js (new module)
- Full session lifecycle: create → invoice → deposit poll → active → request → topup → restore
- Presets + number input for deposit (200–10,000 sats) and topup amounts; reads from input on submit
- Input validation: 200–10,000 sats range enforced in JS before API call
- Auto-closes panel after deposit payment confirms (closePanel in _startDepositPolling success branch)
- Low-balance condition fixed: `isSessionActive()` (covers both 'active' and 'paused') not just `active`
- HUD: updates `#session-hud-balance` span with "Balance: X sats"; `#session-hud-topup` link clickable
- Topup reads from `#session-topup-input` number field, same validation
- localStorage restore: validates session via GET, restores macaroon + balance + UI state on reload
- Expired/401 sessions: clears storage, resets all UI
- Request in-flight guard prevents double-submit; send button disabled during request

### the-matrix/js/ui.js
- `setSessionSendHandler(fn)` — override input bar submit when session active
- `setInputBarSessionMode(active, placeholder)` — green border + placeholder swap
- `send()` routes to session handler when set, 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` with `#session-hud-balance` span + `#session-hud-topup` link (pointer-events: all)
- `#session-panel` (left slide-in): fund / invoice / active / topup steps
  - Fund + topup steps each have preset buttons AND a number input (200–10k range)
  - Added 10k preset button to both step grids
- `#visitor-input.session-active` green pulse border animation (3s keyframe)
- `#low-balance-notice` strip above input bar with inline Top Up button
- CSS: `.session-amount-input` green styled, spin buttons hidden; `.session-amount-row` flex layout
- CSS: `.primary-green` / `.muted` panel button variants for session panel theme

### the-matrix/js/main.js
- Import + call `initSessionPanel()` in firstInit block

## Verification
- npm run build: clean (0 errors, 15 modules)
- Testkit: 27/27 PASS (session tests 11–16, 22 all green)
2026-03-19 03:56:34 +00:00

546 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* session.js — Workshop Session Mode UI (Fund once, ask many)
*
* Flow:
* 1. User clicks "⚡ FUND SESSION" → left panel slides in
* 2. Picks amount (2005000 sats) → POST /api/sessions → deposit invoice
* 3. Pays invoice (stub: "Simulate Payment") → 2 s polling until state=active
* 4. Macaroon + sessionId stored in localStorage; input bar activates (green border)
* 5. Every Enter/Send in the input bar → POST /api/sessions/:id/request
* 6. Timmy's response shown in speech bubble, balance ticks down in HUD
* 7. Balance < 50 sats → low-balance notice; Top Up button reopens panel topup step
* 8. On page reload: localStorage is read, session is validated, UI restored
*/
import { setSpeechBubble, setAgentState } from './agents.js';
import { appendSystemMessage, setSessionSendHandler, setInputBarSessionMode } from './ui.js';
const API = '/api';
const LS_KEY = 'timmy_session_v1';
const POLL_MS = 2000;
const POLL_TIMEOUT = 60_000;
const MIN_BALANCE = 50;
// ── Module state ──────────────────────────────────────────────────────────────
let _panel = null;
let _sessionId = null;
let _macaroon = null;
let _balanceSats = 0;
let _sessionState = null; // null | 'awaiting_payment' | 'active' | 'paused' | 'expired'
let _pollTimer = null;
let _inFlight = false;
let _selectedSats = 500; // deposit amount selection
let _topupSats = 500; // topup amount selection
// ── Public API ────────────────────────────────────────────────────────────────
export function initSessionPanel() {
_panel = document.getElementById('session-panel');
if (!_panel) return;
// Buttons
_on('open-session-btn', 'click', _openPanel);
_on('session-close', 'click', _closePanel);
_on('session-create-btn', 'click', _createSession);
_on('session-pay-btn', 'click', _payDeposit);
_on('session-topup-btn', 'click', () => _setStep('topup'));
_on('session-topup-create-btn','click', _createTopup);
_on('session-topup-pay-btn', 'click', _payTopup);
_on('session-back-btn', 'click', () => _setStep('active'));
_on('topup-quick-btn', 'click', () => { _openPanel(); _setStep('topup'); });
_on('session-hud-topup', 'click', (e) => { e.preventDefault(); _openPanel(); _setStep('topup'); });
// Amount preset buttons — deposit (quick-fill the number input)
_panel.querySelectorAll('[data-session-step="fund"] .session-amount-btn').forEach(btn => {
btn.addEventListener('click', () => {
_panel.querySelectorAll('[data-session-step="fund"] .session-amount-btn')
.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_selectedSats = parseInt(btn.dataset.sats, 10);
const inp = document.getElementById('session-amount-input');
if (inp) inp.value = _selectedSats;
});
});
// Free-text number input — deposit
document.getElementById('session-amount-input')?.addEventListener('input', () => {
const v = parseInt(document.getElementById('session-amount-input').value, 10);
if (Number.isFinite(v)) {
_selectedSats = v;
_panel.querySelectorAll('[data-session-step="fund"] .session-amount-btn')
.forEach(b => b.classList.toggle('active', parseInt(b.dataset.sats, 10) === v));
}
});
// Amount preset buttons — topup (quick-fill the topup number input)
_panel.querySelectorAll('[data-session-step="topup"] .session-amount-btn').forEach(btn => {
btn.addEventListener('click', () => {
_panel.querySelectorAll('[data-session-step="topup"] .session-amount-btn')
.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_topupSats = parseInt(btn.dataset.sats, 10);
const inp = document.getElementById('session-topup-input');
if (inp) inp.value = _topupSats;
});
});
// Free-text number input — topup
document.getElementById('session-topup-input')?.addEventListener('input', () => {
const v = parseInt(document.getElementById('session-topup-input').value, 10);
if (Number.isFinite(v)) {
_topupSats = v;
_panel.querySelectorAll('[data-session-step="topup"] .session-amount-btn')
.forEach(b => b.classList.toggle('active', parseInt(b.dataset.sats, 10) === v));
}
});
// Try to restore from localStorage on init
_tryRestore();
}
export function isSessionActive() {
return _sessionState === 'active' || _sessionState === 'paused';
}
// Called by ui.js when user submits the input bar while session is active
export async function sessionSendHandler(text) {
if (!_sessionId || !_macaroon || _inFlight) return;
_inFlight = true;
_setSendBusy(true);
appendSystemMessage(`you: ${text}`);
try {
const res = await fetch(`${API}/sessions/${_sessionId}/request`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${_macaroon}`,
},
body: JSON.stringify({ request: text }),
});
const data = await res.json();
if (res.status === 402) {
_balanceSats = data.balance ?? 0;
_sessionState = 'paused';
_saveToStorage();
_applySessionUI();
setSpeechBubble('My energy is depleted... top me up to continue!');
appendSystemMessage('⚡ Low balance — tap Top Up');
return;
}
if (res.status === 401) {
// Macaroon rejected — stale session, clear and reset
_clearSession();
appendSystemMessage('Session expired — fund a new one.');
return;
}
if (!res.ok) {
appendSystemMessage('Session error: ' + (data.error || res.status));
return;
}
_balanceSats = data.balanceRemaining ?? 0;
_sessionState = _balanceSats < MIN_BALANCE ? 'paused' : 'active';
_saveToStorage();
_applySessionUI();
const reply = data.result || data.reason || '…';
setSpeechBubble(reply);
appendSystemMessage('Timmy: ' + reply.slice(0, 80));
// Update active-step balance if panel is open
_updateActiveStep();
} catch (err) {
appendSystemMessage('Session error: ' + err.message);
} finally {
_inFlight = false;
_setSendBusy(false);
}
}
// ── Panel open/close ──────────────────────────────────────────────────────────
function _openPanel() {
if (!_panel) return;
_panel.classList.add('open');
_clearError();
if (_sessionState === 'active') {
_setStep('active');
_updateActiveStep();
} else if (_sessionState === 'paused') {
_setStep('topup');
} else {
_setStep('fund');
}
}
function _closePanel() {
_panel?.classList.remove('open');
_stopPolling();
}
// ── Create session ────────────────────────────────────────────────────────────
async function _createSession() {
_clearError();
// Read from the number input (may differ from the last preset clicked)
const inp = document.getElementById('session-amount-input');
if (inp) {
const v = parseInt(inp.value, 10);
if (Number.isFinite(v)) _selectedSats = v;
}
if (_selectedSats < 200 || _selectedSats > 10_000) {
_setError('Amount must be 20010,000 sats.');
return;
}
_setStatus('fund', 'creating session…', '#ffaa00');
_btn('session-create-btn', true);
try {
const res = await fetch(`${API}/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount_sats: _selectedSats }),
});
const data = await res.json();
if (!res.ok) { _setError(data.error || 'Failed to create session.'); return; }
_sessionId = data.sessionId;
// Show deposit invoice
const inv = data.invoice;
_el('session-invoice-amount').textContent = inv.amountSats + ' sats';
_el('session-invoice-pr').textContent = inv.paymentRequest || '';
const hashEl = _el('session-invoice-hash');
if (hashEl) hashEl.dataset.hash = inv.paymentHash || '';
_setStep('invoice');
_setStatus('invoice', '⚡ Awaiting deposit…', '#ffaa00');
} catch (err) {
_setError('Network error: ' + err.message);
} finally {
_btn('session-create-btn', false);
}
}
// ── Pay deposit (stub) ────────────────────────────────────────────────────────
async function _payDeposit() {
const hash = _el('session-invoice-hash')?.dataset.hash;
if (!hash) { _setError('No payment hash — use real Lightning?'); return; }
_btn('session-pay-btn', true);
_setStatus('invoice', 'Simulating payment…', '#ffaa00');
try {
const res = await fetch(`${API}/dev/stub/pay/${hash}`, { method: 'POST' });
const ok = (await res.json()).ok;
if (!ok) { _setError('Payment simulation failed.'); return; }
_startDepositPolling();
} catch (err) {
_setError('Payment error: ' + err.message);
} finally {
_btn('session-pay-btn', false);
}
}
// ── Deposit polling ───────────────────────────────────────────────────────────
function _startDepositPolling() {
_stopPolling();
const deadline = Date.now() + POLL_TIMEOUT;
async function poll() {
if (!_sessionId) return;
try {
const res = await fetch(`${API}/sessions/${_sessionId}`);
const data = await res.json();
if (data.state === 'active') {
_macaroon = data.macaroon;
_balanceSats = data.balanceSats;
_sessionState = 'active';
_saveToStorage();
_applySessionUI();
_closePanel(); // panel auto-closes; user types in input bar
appendSystemMessage(`Session active — ${_balanceSats} sats`);
return;
}
} catch { /* network hiccup */ }
if (Date.now() > deadline) {
_setError('Timed out waiting for payment.');
return;
}
_pollTimer = setTimeout(poll, POLL_MS);
}
_pollTimer = setTimeout(poll, POLL_MS);
}
// ── Topup ─────────────────────────────────────────────────────────────────────
async function _createTopup() {
if (!_sessionId || !_macaroon) return;
_clearError();
// Read from the topup number input
const inp = document.getElementById('session-topup-input');
if (inp) {
const v = parseInt(inp.value, 10);
if (Number.isFinite(v)) _topupSats = v;
}
if (_topupSats < 200 || _topupSats > 10_000) {
_setError('Topup amount must be 20010,000 sats.');
return;
}
_setStatus('topup', 'creating topup invoice…', '#ffaa00');
_btn('session-topup-create-btn', true);
try {
const res = await fetch(`${API}/sessions/${_sessionId}/topup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${_macaroon}`,
},
body: JSON.stringify({ amount_sats: _topupSats }),
});
const data = await res.json();
if (!res.ok) {
// Pending topup already exists — surface it
const pending = data.pendingTopup;
if (pending) {
_el('session-topup-pr').textContent = pending.paymentRequest || '';
const th = _el('session-topup-hash');
if (th) th.dataset.hash = pending.paymentHash || '';
_el('session-topup-pr-row').style.display = '';
_setStatus('topup', '⚡ Previous invoice still pending', '#ffaa00');
return;
}
_setError(data.error || 'Failed to create topup.');
return;
}
const inv = data.topup;
_el('session-topup-pr').textContent = inv.paymentRequest || '';
const th = _el('session-topup-hash');
if (th) th.dataset.hash = inv.paymentHash || '';
_el('session-topup-pr-row').style.display = '';
_setStatus('topup', '⚡ Awaiting topup payment…', '#ffaa00');
} catch (err) {
_setError('Topup error: ' + err.message);
} finally {
_btn('session-topup-create-btn', false);
}
}
async function _payTopup() {
const hash = _el('session-topup-hash')?.dataset.hash;
if (!hash) { _setError('No topup hash.'); return; }
_btn('session-topup-pay-btn', true);
_setStatus('topup', 'Simulating topup payment…', '#ffaa00');
try {
const res = await fetch(`${API}/dev/stub/pay/${hash}`, { method: 'POST' });
const ok = (await res.json()).ok;
if (!ok) { _setError('Topup simulation failed.'); return; }
_startTopupPolling();
} catch (err) {
_setError('Topup payment error: ' + err.message);
} finally {
_btn('session-topup-pay-btn', false);
}
}
function _startTopupPolling() {
_stopPolling();
const deadline = Date.now() + POLL_TIMEOUT;
const prevBalance = _balanceSats;
async function poll() {
if (!_sessionId) return;
try {
const res = await fetch(`${API}/sessions/${_sessionId}`);
const data = await res.json();
if (data.balanceSats > prevBalance || data.state === 'active') {
_balanceSats = data.balanceSats;
_macaroon = data.macaroon || _macaroon;
_sessionState = data.state === 'active' ? 'active' : _sessionState;
_saveToStorage();
_applySessionUI();
_setStep('active');
_updateActiveStep();
_setStatus('active', `⚡ Topped up! ${_balanceSats} sats`, '#22aa66');
return;
}
} catch { /* ignore */ }
if (Date.now() > deadline) { _setError('Topup timed out.'); return; }
_pollTimer = setTimeout(poll, POLL_MS);
}
_pollTimer = setTimeout(poll, POLL_MS);
}
// ── Restore from localStorage ─────────────────────────────────────────────────
async function _tryRestore() {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return;
const { sessionId, macaroon, balanceSats } = JSON.parse(raw);
if (!sessionId || !macaroon) return;
// Validate the session is still live
const res = await fetch(`${API}/sessions/${sessionId}`);
if (!res.ok) { localStorage.removeItem(LS_KEY); return; }
const data = await res.json();
if (data.state !== 'active' && data.state !== 'paused') {
localStorage.removeItem(LS_KEY);
return;
}
_sessionId = sessionId;
_macaroon = data.macaroon || macaroon;
_balanceSats = data.balanceSats ?? balanceSats;
_sessionState = data.state;
_saveToStorage();
_applySessionUI();
appendSystemMessage(`Session restored (${_balanceSats} sats)`);
} catch {
// Ignore restore errors silently
}
}
// ── Session UI helpers ────────────────────────────────────────────────────────
function _applySessionUI() {
const anyActive = isSessionActive(); // true for both 'active' and 'paused'
const canSend = _sessionState === 'active';
// Show low-balance overlay whenever session exists but balance is too low
const lowBal = anyActive && _balanceSats < MIN_BALANCE;
// HUD balance: "Balance: X sats ⚡ Top Up"
const $hud = document.getElementById('session-hud');
if ($hud) {
$hud.style.display = anyActive ? '' : 'none';
const $span = document.getElementById('session-hud-balance');
if ($span) $span.textContent = `Balance: ${_balanceSats} sats`;
}
// Top button label
const $btn = document.getElementById('open-session-btn');
if ($btn) {
$btn.textContent = anyActive
? `SESSION: ${_balanceSats}`
: '⚡ FUND SESSION';
$btn.style.background = anyActive ? '#1a4a30' : '';
}
// Input bar green border + placeholder when session can send
setInputBarSessionMode(canSend, 'Ask Timmy (session active)…');
// Route input bar to session handler only when active (not paused)
setSessionSendHandler(canSend ? sessionSendHandler : null);
// Low balance notice above input bar
const $notice = document.getElementById('low-balance-notice');
if ($notice) $notice.style.display = lowBal ? '' : 'none';
}
function _updateActiveStep() {
const el = document.getElementById('session-active-amount');
if (el) {
el.textContent = _balanceSats + ' sats';
el.style.color = _balanceSats < MIN_BALANCE ? '#ff8844' : '#44dd88';
}
// Reset topup pr row visibility
const prRow = document.getElementById('session-topup-pr-row');
if (prRow) prRow.style.display = 'none';
}
function _clearSession() {
_sessionId = null;
_macaroon = null;
_balanceSats = 0;
_sessionState = null;
localStorage.removeItem(LS_KEY);
setInputBarSessionMode(false);
setSessionSendHandler(null);
const $hud = document.getElementById('session-hud');
if ($hud) $hud.style.display = 'none';
const $btn = document.getElementById('open-session-btn');
if ($btn) { $btn.textContent = '⚡ FUND SESSION'; $btn.style.background = ''; }
const $notice = document.getElementById('low-balance-notice');
if ($notice) $notice.style.display = 'none';
}
function _saveToStorage() {
try {
localStorage.setItem(LS_KEY, JSON.stringify({
sessionId: _sessionId,
macaroon: _macaroon,
balanceSats: _balanceSats,
}));
} catch { /* storage unavailable */ }
}
// ── DOM helpers ───────────────────────────────────────────────────────────────
function _el(id) { return document.getElementById(id); }
function _on(id, ev, fn) {
_el(id)?.addEventListener(ev, fn);
}
function _btn(id, disabled) {
const el = _el(id);
if (el) el.disabled = disabled;
}
function _setSendBusy(busy) {
const btn = document.getElementById('send-btn');
if (btn) btn.disabled = busy;
}
function _setStep(step) {
if (!_panel) return;
_panel.querySelectorAll('[data-session-step]').forEach(el => {
el.style.display = el.dataset.sessionStep === step ? '' : 'none';
});
}
function _setStatus(step, msg, color = '#22aa66') {
const el = document.getElementById(`session-status-${step}`);
if (el) { el.textContent = msg; el.style.color = color; }
}
function _setError(msg) {
const el = _el('session-error');
if (el) el.textContent = msg;
}
function _clearError() {
const el = _el('session-error');
if (el) el.textContent = '';
}
function _stopPolling() {
clearTimeout(_pollTimer);
_pollTimer = null;
}