diff --git a/the-matrix/index.html b/the-matrix/index.html index f3ac471..5679e6a 100644 --- a/the-matrix/index.html +++ b/the-matrix/index.html @@ -599,6 +599,109 @@ #activity-heatmap #heatmap-bar { display: none; } #heatmap-icon-btn { display: block; } } + + /* ── History button ──────────────────────────────────────────────── */ + #open-history-btn { + font-family: 'Courier New', monospace; font-size: 11px; font-weight: bold; + color: #aabbdd; background: rgba(20, 16, 50, 0.85); border: 1px solid #2a2a44; + padding: 7px 18px; cursor: pointer; letter-spacing: 2px; + box-shadow: 0 0 14px #2244aa22; + transition: background 0.15s, box-shadow 0.15s, color 0.15s; + border-radius: 2px; + min-height: 36px; + } + #open-history-btn:hover, #open-history-btn:active { + background: rgba(35, 28, 80, 0.95); + box-shadow: 0 0 20px #3355aa44; + color: #ccddff; + } + + /* ── History panel (bottom sheet) ───────────────────────────────── */ + #history-panel { + position: fixed; bottom: -100%; left: 0; right: 0; + height: 70vh; + background: rgba(5, 3, 12, 0.97); + border-top: 1px solid #1a1a2e; + z-index: 100; + font-family: 'Courier New', monospace; + transition: bottom 0.35s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 -8px 32px rgba(40, 60, 120, 0.18); + display: flex; flex-direction: column; + } + #history-panel.open { bottom: 60px; } + + .hist-header { + display: flex; align-items: center; gap: 8px; + padding: 14px 20px 10px; + border-bottom: 1px solid #1a1a2e; + font-size: 12px; letter-spacing: 3px; color: #5577aa; + flex-shrink: 0; + } + .hist-header span { flex: 1; text-shadow: 0 0 8px #2244aa66; } + #history-refresh-btn, #history-close { + background: transparent; border: 1px solid #1a1a2e; + color: #334466; font-family: 'Courier New', monospace; + font-size: 11px; padding: 3px 10px; cursor: pointer; + transition: color 0.2s, border-color 0.2s; letter-spacing: 1px; + border-radius: 2px; + } + #history-refresh-btn:hover { color: #5577aa; border-color: #334466; } + #history-refresh-btn:disabled { opacity: 0.4; cursor: default; } + #history-close { font-size: 14px; padding: 3px 8px; } + #history-close:hover { color: #6688bb; border-color: #4466aa; } + + #history-list { + flex: 1; overflow-y: auto; padding: 12px 16px; + overscroll-behavior: contain; + } + + .hist-empty { + color: #334466; font-size: 11px; letter-spacing: 1px; + line-height: 1.8; text-align: center; + margin-top: 40px; padding: 0 20px; + } + + .hist-row { + border: 1px solid #1a1a2e; border-radius: 2px; + margin-bottom: 10px; overflow: hidden; + background: #060310; + } + .hist-row.hist-rejected { border-color: #331111; } + .hist-row-header { + padding: 10px 12px; cursor: pointer; + transition: background 0.15s; + } + .hist-row-header:hover { background: rgba(30, 25, 60, 0.6); } + .hist-prompt { + color: #aabbdd; font-size: 12px; line-height: 1.5; + display: -webkit-box; -webkit-line-clamp: 2; + -webkit-box-orient: vertical; overflow: hidden; + margin-bottom: 6px; + } + .hist-meta { + display: flex; gap: 12px; align-items: center; + } + .hist-cost { font-size: 10px; color: #ffcc44; letter-spacing: 1px; } + .hist-rejected .hist-cost { color: #994444; } + .hist-time { font-size: 10px; color: #334466; letter-spacing: 0.5px; flex: 1; } + .hist-chevron { font-size: 10px; color: #334466; transition: color 0.15s; } + .hist-row-header:hover .hist-chevron { color: #5577aa; } + + .hist-row-body { + max-height: 0; overflow: hidden; + transition: max-height 0.3s ease-out; + } + .hist-row.expanded .hist-row-body { + max-height: 400px; + border-top: 1px solid #1a1a2e; + } + .hist-result { + color: #aabbdd; font-family: 'Courier New', monospace; + font-size: 11px; line-height: 1.6; + white-space: pre-wrap; word-break: break-word; + padding: 12px; margin: 0; + max-height: 400px; overflow-y: auto; + } @@ -640,6 +743,7 @@
+ ⚙ RELAY ADMIN
@@ -789,6 +893,16 @@
+ +
+
+ ⏱ JOB HISTORY + + +
+
+
+
diff --git a/the-matrix/js/history.js b/the-matrix/js/history.js new file mode 100644 index 0000000..02f61da --- /dev/null +++ b/the-matrix/js/history.js @@ -0,0 +1,222 @@ +/** + * history.js — Job history panel for Timmy Tower Workshop. + * + * Persists completed jobs in localStorage and renders them in a + * bottom-sheet panel with expandable results and pull-to-refresh. + * + * Public API: + * addHistoryEntry(entry) — called by payment.js / session.js on completion + * initHistoryPanel() — wire up DOM (call once from main.js) + */ + +const LS_KEY = 'timmy_history_v1'; +const MAX_ENTRIES = 50; + +// ── Persistence ─────────────────────────────────────────────────────────────── + +function _loadEntries() { + try { + const raw = localStorage.getItem(LS_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +function _saveEntries(entries) { + try { + localStorage.setItem(LS_KEY, JSON.stringify(entries)); + } catch { /* storage full — oldest already trimmed */ } +} + +/** + * Record a completed job. + * @param {object} entry + * @param {string} entry.jobId + * @param {string} entry.request — user prompt + * @param {number} entry.costSats — sats charged (0 for free/session) + * @param {string} entry.result — AI answer or rejection reason + * @param {string} entry.state — 'complete' | 'rejected' | 'failed' + * @param {string} [entry.completedAt] — ISO timestamp (defaults to now) + */ +export function addHistoryEntry({ jobId, request, costSats, result, state, completedAt }) { + const entries = _loadEntries(); + const entry = { + jobId: jobId ?? `local-${Date.now()}`, + request: request ?? '', + costSats: costSats ?? 0, + result: result ?? '', + state: state ?? 'complete', + completedAt: completedAt ?? new Date().toISOString(), + }; + const idx = entries.findIndex(e => e.jobId === entry.jobId); + if (idx >= 0) { + entries[idx] = entry; + } else { + entries.unshift(entry); // newest first + if (entries.length > MAX_ENTRIES) entries.length = MAX_ENTRIES; + } + _saveEntries(entries); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function _relativeTime(isoString) { + try { + const diff = Date.now() - new Date(isoString).getTime(); + const secs = Math.floor(diff / 1000); + if (secs < 60) return `${secs}s ago`; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins} min ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; + } catch { + return ''; + } +} + +function _escHtml(text) { + return String(text) + .replace(/&/g, '&') + .replace(//g, '>'); +} + +function _truncate(text, maxLen) { + if (!text) return ''; + return text.length > maxLen ? text.slice(0, maxLen) + '…' : text; +} + +// ── Rendering ───────────────────────────────────────────────────────────────── + +function _renderEntries(entries, container) { + container.innerHTML = ''; + + if (!entries.length) { + const empty = document.createElement('div'); + empty.className = 'hist-empty'; + empty.textContent = 'No completed jobs yet. Submit a job to see your history here.'; + container.appendChild(empty); + return; + } + + entries.forEach(entry => { + const row = document.createElement('div'); + row.className = 'hist-row' + (entry.state === 'rejected' ? ' hist-rejected' : ''); + + // ── Header (always visible) ──────────────────────────────────────────── + const header = document.createElement('div'); + header.className = 'hist-row-header'; + + const promptEl = document.createElement('div'); + promptEl.className = 'hist-prompt'; + promptEl.textContent = _truncate(entry.request, 140); + + const metaEl = document.createElement('div'); + metaEl.className = 'hist-meta'; + + const costEl = document.createElement('span'); + costEl.className = 'hist-cost'; + if (entry.state === 'rejected') { + costEl.textContent = 'rejected'; + } else if (entry.costSats > 0) { + costEl.textContent = `⚡ ${entry.costSats} sats`; + } else { + costEl.textContent = 'free'; + } + + const timeEl = document.createElement('span'); + timeEl.className = 'hist-time'; + timeEl.textContent = _relativeTime(entry.completedAt); + + const chevronEl = document.createElement('span'); + chevronEl.className = 'hist-chevron'; + chevronEl.textContent = '▸'; + + metaEl.appendChild(costEl); + metaEl.appendChild(timeEl); + metaEl.appendChild(chevronEl); + + header.appendChild(promptEl); + header.appendChild(metaEl); + + // ── Body (expandable) ────────────────────────────────────────────────── + const body = document.createElement('div'); + body.className = 'hist-row-body'; + + const pre = document.createElement('pre'); + pre.className = 'hist-result'; + pre.textContent = entry.result || '(no result)'; + body.appendChild(pre); + + // ── Toggle ───────────────────────────────────────────────────────────── + let expanded = false; + header.addEventListener('click', () => { + expanded = !expanded; + row.classList.toggle('expanded', expanded); + chevronEl.textContent = expanded ? '▾' : '▸'; + }); + + row.appendChild(header); + row.appendChild(body); + container.appendChild(row); + }); +} + +// ── Panel state ─────────────────────────────────────────────────────────────── + +let _panel = null; +let _list = null; +let _refreshBtn = null; + +function _open() { + if (!_panel) return; + _panel.classList.add('open'); + _refresh(); +} + +function _close() { + _panel?.classList.remove('open'); +} + +function _refresh() { + if (!_list) return; + const entries = _loadEntries(); + _renderEntries(entries, _list); + if (_refreshBtn) { + _refreshBtn.textContent = '↺ REFRESH'; + _refreshBtn.disabled = false; + } +} + +export function initHistoryPanel() { + _panel = document.getElementById('history-panel'); + _list = document.getElementById('history-list'); + _refreshBtn = document.getElementById('history-refresh-btn'); + if (!_panel) return; + + document.getElementById('open-history-btn')?.addEventListener('click', _open); + document.getElementById('history-close')?.addEventListener('click', _close); + + if (_refreshBtn) { + _refreshBtn.addEventListener('click', () => { + _refreshBtn.textContent = '↺ …'; + _refreshBtn.disabled = true; + setTimeout(_refresh, 150); + }); + } + + // Pull-to-refresh: detect downward drag when already scrolled to top + let _touchStartY = 0; + _list?.addEventListener('touchstart', e => { + _touchStartY = e.touches[0].clientY; + }, { passive: true }); + _list?.addEventListener('touchend', e => { + const dy = e.changedTouches[0].clientY - _touchStartY; + if (dy > 60 && _list.scrollTop === 0) { + _refresh(); + } + }, { passive: true }); +} diff --git a/the-matrix/js/main.js b/the-matrix/js/main.js index 4c89cc6..97b5a87 100644 --- a/the-matrix/js/main.js +++ b/the-matrix/js/main.js @@ -11,6 +11,7 @@ import { initInteraction, disposeInteraction, registerSlapTarget } from './inter 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'; @@ -46,6 +47,7 @@ function buildWorld(firstInit, stateSnapshot) { initWebSocket(scene); initPaymentPanel(); initSessionPanel(); + initHistoryPanel(); void initNostrIdentity('/api'); warmupEdgeWorker(); onEdgeWorkerReady(() => setEdgeWorkerReady()); diff --git a/the-matrix/js/payment.js b/the-matrix/js/payment.js index d1dfe19..aec12ea 100644 --- a/the-matrix/js/payment.js +++ b/the-matrix/js/payment.js @@ -10,6 +10,7 @@ */ import { getOrRefreshToken } from './nostr-identity.js'; +import { addHistoryEntry } from './history.js'; const API_BASE = '/api'; const POLL_INTERVAL_MS = 2000; @@ -18,6 +19,7 @@ const POLL_TIMEOUT_MS = 60000; let panel = null; let closeBtn = null; let currentJobId = null; +let currentRequest = ''; let pollTimer = null; export function initPaymentPanel() { @@ -96,6 +98,7 @@ async function submitJob() { if (!res.ok) { setError(data.error || 'Failed to create job.'); return; } currentJobId = data.jobId; + currentRequest = request; showEvalInvoice(data.evalInvoice); } catch (err) { setError('Network error: ' + err.message); @@ -167,7 +170,7 @@ function startPolling() { const pollHeaders = token ? { 'X-Nostr-Token': token } : {}; const res = await fetch(`${API_BASE}/jobs/${currentJobId}`, { headers: pollHeaders }); const data = await res.json(); - const { state, workInvoice, result, reason } = data; + const { state, workInvoice, result, reason, costLedger, completedAt } = data; if (state === 'awaiting_work_payment' && workInvoice) { showWorkInvoice(workInvoice); @@ -175,10 +178,26 @@ function startPolling() { return; } if (state === 'complete') { + addHistoryEntry({ + jobId: currentJobId, + request: currentRequest, + costSats: costLedger?.workAmountSats ?? costLedger?.actualAmountSats ?? 0, + result, + state: 'complete', + completedAt: completedAt ?? new Date().toISOString(), + }); showResult(result, 'complete'); return; } if (state === 'rejected') { + addHistoryEntry({ + jobId: currentJobId, + request: currentRequest, + costSats: 0, + result: reason, + state: 'rejected', + completedAt: completedAt ?? new Date().toISOString(), + }); showResult(reason, 'rejected'); return; } diff --git a/the-matrix/js/session.js b/the-matrix/js/session.js index b505770..132f297 100644 --- a/the-matrix/js/session.js +++ b/the-matrix/js/session.js @@ -16,6 +16,7 @@ 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 { addHistoryEntry } from './history.js'; const API = '/api'; const LS_KEY = 'timmy_session_v1'; @@ -152,12 +153,21 @@ export async function sessionSendHandler(text) { return; } + const prevBalance = _balanceSats; _balanceSats = data.balanceRemaining ?? 0; _sessionState = _balanceSats < MIN_BALANCE ? 'paused' : 'active'; _saveToStorage(); _applySessionUI(); const reply = data.result || data.reason || '…'; + const costSats = Math.max(0, prevBalance - _balanceSats); + addHistoryEntry({ + request: text, + costSats, + result: reply, + state: data.reason && !data.result ? 'rejected' : 'complete', + completedAt: new Date().toISOString(), + }); setSpeechBubble(reply); appendSystemMessage('Timmy: ' + reply.slice(0, 80));