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 @@
@@ -789,6 +893,16 @@
+
+
+
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));