From 3cd924b44c92a37862a2ab874d3830c2e238d883 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 23 Mar 2026 16:21:19 -0400 Subject: [PATCH] WIP: Claude Code progress on #31 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated salvage commit — agent session ended (exit 124). Work in progress, may need continuation. --- artifacts/api-server/src/routes/jobs.ts | 63 ++++++- the-matrix/index.html | 140 +++++++++++++++ the-matrix/js/history.js | 222 ++++++++++++++++++++++++ the-matrix/js/main.js | 2 + 4 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 the-matrix/js/history.js diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index 341ba68..588342b 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -1,7 +1,7 @@ import { Router, type Request, type Response } from "express"; import { randomUUID, createHash } from "crypto"; import { db, jobs, invoices, jobDebates, type Job } from "@workspace/db"; -import { eq, and } from "drizzle-orm"; +import { eq, and, desc } from "drizzle-orm"; import { CreateJobBody, GetJobParams } from "@workspace/api-zod"; import { lnbitsService } from "../lib/lnbits.js"; import { agentService } from "../lib/agent.js"; @@ -494,6 +494,67 @@ async function advanceJob(job: Job): Promise { return job; } +// ── GET /jobs ───────────────────────────────────────────────────────────────── +// Returns the caller's completed/rejected job history (requires Nostr token). + +router.get("/jobs", async (req: Request, res: Response) => { + const header = req.headers["x-nostr-token"]; + const raw = typeof header === "string" ? header.trim() : null; + if (!raw) { + res.status(401).json({ error: "X-Nostr-Token header required" }); + return; + } + const parsed = trustService.verifyToken(raw); + if (!parsed) { + res.status(401).json({ error: "Invalid or expired token" }); + return; + } + + try { + const rows = await db + .select({ + id: jobs.id, + request: jobs.request, + state: jobs.state, + workAmountSats: jobs.workAmountSats, + actualAmountSats: jobs.actualAmountSats, + result: jobs.result, + rejectionReason: jobs.rejectionReason, + freeTier: jobs.freeTier, + absorbedSats: jobs.absorbedSats, + createdAt: jobs.createdAt, + updatedAt: jobs.updatedAt, + }) + .from(jobs) + .where( + and( + eq(jobs.nostrPubkey, parsed.pubkey), + // Only terminal states are useful for history + ) + ) + .orderBy(desc(jobs.createdAt)) + .limit(50); + + res.json({ jobs: rows.map(j => ({ + id: j.id, + request: j.request, + state: j.state, + costSats: j.actualAmountSats ?? j.workAmountSats ?? null, + freeTier: j.freeTier, + absorbedSats: j.absorbedSats ?? null, + result: j.state === "complete" ? j.result : null, + rejectionReason: j.state === "rejected" ? j.rejectionReason : null, + createdAt: j.createdAt.toISOString(), + completedAt: (j.state === "complete" || j.state === "rejected") + ? j.updatedAt.toISOString() + : null, + })) }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to fetch jobs"; + res.status(500).json({ error: message }); + } +}); + // ── POST /jobs ──────────────────────────────────────────────────────────────── // ── Resolve Nostr pubkey from token header or body ──────────────────────────── diff --git a/the-matrix/index.html b/the-matrix/index.html index a58cfea..eaa297e 100644 --- a/the-matrix/index.html +++ b/the-matrix/index.html @@ -514,6 +514,132 @@ } #timmy-id-card .id-npub:hover { color: #88aadd; } #timmy-id-card .id-zaps { color: #556688; font-size: 9px; } + + /* ── History panel (bottom sheet) ────────────────────────────────── */ + #open-history-btn { + font-family: 'Courier New', monospace; font-size: 11px; font-weight: bold; + color: #ccaaff; background: rgba(25, 10, 45, 0.85); border: 1px solid #7755aa55; + padding: 7px 18px; cursor: pointer; letter-spacing: 1px; + box-shadow: 0 0 14px #5533aa22; + 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(40, 18, 70, 0.95); + box-shadow: 0 0 20px #7755aa44; + color: #eeddff; + } + + #history-panel { + position: fixed; bottom: -100%; left: 0; right: 0; + height: 80vh; + background: rgba(5, 3, 14, 0.97); + border-top: 1px solid #1a1030; + padding: 0; + overflow: hidden; 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(60, 30, 100, 0.18); + display: flex; flex-direction: column; + } + #history-panel.open { bottom: 0; } + + #history-panel-header { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 20px 10px; + border-bottom: 1px solid #1a1030; + flex-shrink: 0; + } + #history-panel-header h2 { + font-size: 13px; letter-spacing: 3px; color: #9966dd; + text-shadow: 0 0 10px #5533aa; + margin: 0; + } + #history-panel-actions { + display: flex; gap: 8px; align-items: center; + } + #history-refresh-btn { + background: transparent; border: 1px solid #2a1a44; + color: #7755aa; font-family: 'Courier New', monospace; + font-size: 11px; padding: 4px 10px; cursor: pointer; + transition: all 0.15s; letter-spacing: 1px; + } + #history-refresh-btn:hover { border-color: #9966dd; color: #ccaaff; } + #history-close { + background: transparent; border: 1px solid #1a1030; + color: #554477; font-family: 'Courier New', monospace; + font-size: 16px; width: 28px; height: 28px; + cursor: pointer; transition: color 0.2s, border-color 0.2s; + } + #history-close:hover { color: #9966dd; border-color: #7755aa; } + + #history-status { + font-size: 11px; padding: 12px 20px; + color: #334466; letter-spacing: 1px; min-height: 20px; + flex-shrink: 0; + } + + #history-list { + flex: 1; overflow-y: auto; + padding: 0 0 16px; + -webkit-overflow-scrolling: touch; + } + + .history-row { + border-bottom: 1px solid #100a20; + } + .history-row-header { + padding: 12px 20px; + cursor: default; + } + .history-row-header.history-expandable { + cursor: pointer; + } + .history-row-header.history-expandable:active { + background: rgba(100, 60, 160, 0.08); + } + .history-prompt { + color: #aabbdd; font-size: 12px; line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin-bottom: 6px; + } + .history-row-open .history-prompt { + -webkit-line-clamp: unset; + overflow: visible; + } + .history-meta { + display: flex; gap: 8px; align-items: center; flex-wrap: wrap; + } + .history-agent { + font-size: 9px; letter-spacing: 2px; color: #7755aa; + border: 1px solid #3a2555; padding: 1px 5px; + } + .history-cost { font-size: 10px; color: #ffcc44; letter-spacing: 1px; } + .history-cost-free { color: #44dd88; } + .history-time { font-size: 10px; color: #445566; flex: 1; } + .history-state { font-size: 12px; font-weight: bold; } + .state-complete { color: #44dd88; } + .state-rejected { color: #dd6644; } + .state-pending { color: #ffcc44; } + + .history-row-body { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + .history-result { + margin: 0 20px 12px; + background: #060310; border: 1px solid #1a1030; + color: #aabbdd; padding: 12px; + font-size: 11px; line-height: 1.6; + white-space: pre-wrap; word-break: break-word; + font-family: 'Courier New', monospace; + } + .history-result-rejected { color: #dd8866; border-color: #3a1a10; } @@ -541,6 +667,7 @@
+ ⚙ RELAY ADMIN
@@ -690,6 +817,19 @@
+ +
+
+

◷ JOB HISTORY

+
+ + +
+
+
+
+
+
diff --git a/the-matrix/js/history.js b/the-matrix/js/history.js new file mode 100644 index 0000000..14c448c --- /dev/null +++ b/the-matrix/js/history.js @@ -0,0 +1,222 @@ +/** + * history.js — Job history panel for Timmy Tower mobile. + * + * Shows completed jobs from GET /api/jobs in reverse chronological order. + * Each row is expandable to reveal the full result. + * Supports pull-to-refresh (scroll to top + overscroll) and a refresh button. + */ + +import { getOrRefreshToken } from './nostr-identity.js'; + +const API_BASE = '/api'; + +// Deterministic agent label from job id (purely cosmetic — no real agent tracking) +const AGENT_LABELS = ['ALPHA', 'BETA', 'GAMMA', 'DELTA']; +function _agentForId(id) { + let sum = 0; + for (let i = 0; i < Math.min(8, id.length); i++) sum += id.charCodeAt(i); + return AGENT_LABELS[sum % AGENT_LABELS.length]; +} + +function _relativeTime(isoString) { + if (!isoString) return ''; + const diff = Date.now() - new Date(isoString).getTime(); + const sec = Math.floor(diff / 1000); + if (sec < 60) return `${sec}s ago`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min} min ago`; + const hrs = Math.floor(min / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +} + +let _panel = null; +let _list = null; +let _status = null; +let _loading = false; + +// Pull-to-refresh state +let _ptStart = 0; +let _ptActive = false; +const PT_THRESHOLD = 60; // px + +export function initHistoryPanel() { + _panel = document.getElementById('history-panel'); + _list = document.getElementById('history-list'); + _status = document.getElementById('history-status'); + if (!_panel) return; + + document.getElementById('open-history-btn') + ?.addEventListener('click', openHistoryPanel); + document.getElementById('history-close') + ?.addEventListener('click', closeHistoryPanel); + document.getElementById('history-refresh-btn') + ?.addEventListener('click', () => loadHistory()); + + // Pull-to-refresh on the list container + if (_list) { + _list.addEventListener('touchstart', _onPtStart, { passive: true }); + _list.addEventListener('touchmove', _onPtMove, { passive: true }); + _list.addEventListener('touchend', _onPtEnd, { passive: true }); + } +} + +function openHistoryPanel() { + if (!_panel) return; + _panel.classList.add('open'); + loadHistory(); +} + +function closeHistoryPanel() { + _panel?.classList.remove('open'); +} + +// ── Pull-to-refresh ─────────────────────────────────────────────────────────── + +function _onPtStart(e) { + if (_list.scrollTop === 0 && e.touches.length === 1) { + _ptStart = e.touches[0].clientY; + _ptActive = true; + } +} +function _onPtMove(e) { + if (!_ptActive) return; + const dy = e.touches[0].clientY - _ptStart; + if (dy > PT_THRESHOLD) { + _ptActive = false; + if (_status) _status.textContent = 'Refreshing…'; + loadHistory(); + } +} +function _onPtEnd() { _ptActive = false; } + +// ── Data loading ────────────────────────────────────────────────────────────── + +async function loadHistory() { + if (_loading) return; + _loading = true; + if (_status) { _status.textContent = 'Loading…'; _status.style.color = '#5577aa'; } + + try { + const token = await getOrRefreshToken('/api'); + if (!token) { + renderEmpty('Sign in with Nostr to view your job history.'); + return; + } + + const res = await fetch(`${API_BASE}/jobs`, { + headers: { 'X-Nostr-Token': token }, + }); + + if (res.status === 401) { + renderEmpty('Session expired — reload the page to sign in again.'); + return; + } + if (!res.ok) { + const data = await res.json().catch(() => ({})); + if (_status) { _status.textContent = data.error || 'Failed to load history.'; _status.style.color = '#994444'; } + return; + } + + const data = await res.json(); + renderJobs(data.jobs ?? []); + } catch (err) { + if (_status) { _status.textContent = 'Network error: ' + err.message; _status.style.color = '#994444'; } + } finally { + _loading = false; + } +} + +function renderEmpty(msg) { + if (_list) { _list.innerHTML = ''; } + if (_status) { _status.textContent = msg; _status.style.color = '#334466'; } +} + +function renderJobs(jobList) { + if (!_list) return; + _list.innerHTML = ''; + if (_status) _status.textContent = ''; + + if (!jobList.length) { + renderEmpty('No completed jobs yet. Submit a job to get started!'); + return; + } + + for (const job of jobList) { + _list.appendChild(_buildJobRow(job)); + } +} + +function _buildJobRow(job) { + const isComplete = job.state === 'complete'; + const isRejected = job.state === 'rejected'; + const hasContent = isComplete || isRejected; + + const row = document.createElement('div'); + row.className = 'history-row'; + + // ── Header (always visible) ──────────────────────────────────────────────── + const header = document.createElement('div'); + header.className = 'history-row-header'; + + const prompt = document.createElement('div'); + prompt.className = 'history-prompt'; + prompt.textContent = job.request; + + const meta = document.createElement('div'); + meta.className = 'history-meta'; + + const agentSpan = document.createElement('span'); + agentSpan.className = 'history-agent'; + agentSpan.textContent = _agentForId(job.id); + + const costSpan = document.createElement('span'); + costSpan.className = 'history-cost'; + if (job.freeTier) { + costSpan.textContent = 'FREE'; + costSpan.classList.add('history-cost-free'); + } else { + costSpan.textContent = job.costSats != null ? `${job.costSats} sats` : '— sats'; + } + + const timeSpan = document.createElement('span'); + timeSpan.className = 'history-time'; + timeSpan.textContent = _relativeTime(job.completedAt ?? job.createdAt); + + const stateSpan = document.createElement('span'); + stateSpan.className = 'history-state'; + if (isComplete) { stateSpan.textContent = '✓'; stateSpan.classList.add('state-complete'); } + else if (isRejected) { stateSpan.textContent = '✗'; stateSpan.classList.add('state-rejected'); } + else { stateSpan.textContent = '…'; stateSpan.classList.add('state-pending'); } + + meta.append(agentSpan, costSpan, timeSpan, stateSpan); + header.append(prompt, meta); + + // ── Expandable result ────────────────────────────────────────────────────── + const body = document.createElement('div'); + body.className = 'history-row-body'; + + if (hasContent) { + const content = isComplete ? (job.result || '') : (job.rejectionReason || 'Request rejected.'); + const pre = document.createElement('pre'); + pre.className = 'history-result'; + pre.textContent = content; + if (isRejected) pre.classList.add('history-result-rejected'); + body.appendChild(pre); + + header.classList.add('history-expandable'); + header.addEventListener('click', () => { + const isOpen = row.classList.toggle('history-row-open'); + // Animate body height + if (isOpen) { + body.style.maxHeight = body.scrollHeight + 'px'; + } else { + body.style.maxHeight = '0'; + } + }); + } + + row.append(header, body); + return row; +} diff --git a/the-matrix/js/main.js b/the-matrix/js/main.js index 3c25033..dfb0b44 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());