Replaces the marketing landing page with a minimal, full-screen chat interface that connects to a running Timmy instance. Mobile-first design with single vertical scroll direction, looping scroll, no zoom, no buttons — just type and press Enter to talk to Timmy. - docs/index.html: full rewrite as a clean chat UI with dark terminal theme, looping infinite scroll, markdown rendering, connection status, and /connect, /clear, /help slash commands - src/dashboard/app.py: add CORS middleware so the GitHub Pages site can reach a local Timmy server cross-origin - src/config.py: add cors_origins setting (defaults to ["*"]) https://claude.ai/code/session_01AWLxg6KDWsfCATiuvsRMGr
535 lines
13 KiB
HTML
535 lines
13 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
|
<title>Timmy</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
:root {
|
|
--bg: #080412;
|
|
--bg-msg: #110820;
|
|
--bg-user: #1a0e30;
|
|
--border: #2a1545;
|
|
--text: #c8b0e0;
|
|
--dim: #6b4a8a;
|
|
--bright: #ede0ff;
|
|
--accent: #ff7a2a;
|
|
--green: #00e87a;
|
|
--red: #ff4455;
|
|
--font: 'JetBrains Mono', monospace;
|
|
}
|
|
|
|
html, body {
|
|
height: 100%;
|
|
overflow: hidden;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: var(--font);
|
|
font-size: 14px;
|
|
line-height: 1.6;
|
|
touch-action: pan-y;
|
|
overscroll-behavior: none;
|
|
-webkit-text-size-adjust: 100%;
|
|
}
|
|
|
|
/* ── Full-screen layout ── */
|
|
#app {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
height: 100dvh;
|
|
max-width: 720px;
|
|
margin: 0 auto;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* ── Header ── */
|
|
#header {
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
#header h1 {
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.2em;
|
|
color: var(--accent);
|
|
}
|
|
|
|
#status {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 11px;
|
|
color: var(--dim);
|
|
letter-spacing: 0.05em;
|
|
cursor: default;
|
|
}
|
|
|
|
#status-dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
background: var(--dim);
|
|
transition: background 0.3s;
|
|
}
|
|
|
|
#status-dot.online { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
|
#status-dot.offline { background: var(--red); }
|
|
#status-dot.trying { background: var(--accent); animation: pulse 1s infinite; }
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.3; }
|
|
}
|
|
|
|
/* ── Messages ── */
|
|
#messages {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
padding: 16px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
-webkit-overflow-scrolling: touch;
|
|
scroll-behavior: smooth;
|
|
}
|
|
|
|
.msg {
|
|
max-width: 88%;
|
|
padding: 10px 14px;
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
word-wrap: break-word;
|
|
overflow-wrap: break-word;
|
|
}
|
|
|
|
.msg.timmy {
|
|
align-self: flex-start;
|
|
background: var(--bg-msg);
|
|
border-left: 2px solid var(--accent);
|
|
color: var(--text);
|
|
}
|
|
|
|
.msg.user {
|
|
align-self: flex-end;
|
|
background: var(--bg-user);
|
|
border-right: 2px solid var(--dim);
|
|
color: var(--bright);
|
|
}
|
|
|
|
.msg.system {
|
|
align-self: center;
|
|
text-align: center;
|
|
font-size: 11px;
|
|
color: var(--dim);
|
|
max-width: 90%;
|
|
padding: 8px;
|
|
}
|
|
|
|
.msg .label {
|
|
font-size: 10px;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--dim);
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.msg.timmy .label { color: var(--accent); }
|
|
|
|
.msg .body p { margin-bottom: 0.5em; }
|
|
.msg .body p:last-child { margin-bottom: 0; }
|
|
.msg .body code {
|
|
background: rgba(255, 255, 255, 0.06);
|
|
padding: 1px 5px;
|
|
font-size: 12px;
|
|
}
|
|
.msg .body pre {
|
|
background: rgba(0, 0, 0, 0.3);
|
|
padding: 10px;
|
|
overflow-x: auto;
|
|
font-size: 12px;
|
|
margin: 6px 0;
|
|
}
|
|
.msg .body pre code {
|
|
background: none;
|
|
padding: 0;
|
|
}
|
|
|
|
/* Thinking indicator */
|
|
.thinking {
|
|
align-self: flex-start;
|
|
padding: 10px 14px;
|
|
font-size: 13px;
|
|
color: var(--dim);
|
|
border-left: 2px solid var(--accent);
|
|
background: var(--bg-msg);
|
|
}
|
|
|
|
.thinking span {
|
|
animation: blink 1.4s infinite;
|
|
}
|
|
.thinking span:nth-child(2) { animation-delay: 0.2s; }
|
|
.thinking span:nth-child(3) { animation-delay: 0.4s; }
|
|
|
|
@keyframes blink {
|
|
0%, 80%, 100% { opacity: 0.2; }
|
|
40% { opacity: 1; }
|
|
}
|
|
|
|
/* ── Input ── */
|
|
#input-area {
|
|
flex-shrink: 0;
|
|
padding: 12px 16px;
|
|
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
|
border-top: 1px solid var(--border);
|
|
background: var(--bg);
|
|
}
|
|
|
|
#input {
|
|
width: 100%;
|
|
background: var(--bg-msg);
|
|
border: 1px solid var(--border);
|
|
color: var(--bright);
|
|
font-family: var(--font);
|
|
font-size: 16px;
|
|
padding: 12px 14px;
|
|
outline: none;
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
border-radius: 0;
|
|
}
|
|
|
|
#input::placeholder {
|
|
color: var(--dim);
|
|
font-size: 13px;
|
|
}
|
|
|
|
#input:focus {
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
/* ── Scrollbar ── */
|
|
#messages::-webkit-scrollbar { width: 4px; }
|
|
#messages::-webkit-scrollbar-track { background: transparent; }
|
|
#messages::-webkit-scrollbar-thumb { background: var(--border); }
|
|
|
|
/* ── No horizontal anything ── */
|
|
* { max-width: 100%; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div id="app">
|
|
<div id="header">
|
|
<h1>TIMMY</h1>
|
|
<div id="status">
|
|
<span id="status-text">connecting</span>
|
|
<span id="status-dot" class="trying"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="messages"></div>
|
|
|
|
<div id="input-area">
|
|
<input id="input"
|
|
type="text"
|
|
placeholder="talk to timmy..."
|
|
autocomplete="off"
|
|
autocorrect="off"
|
|
autocapitalize="none"
|
|
spellcheck="false"
|
|
enterkeyhint="send">
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function() {
|
|
var SERVER = localStorage.getItem('timmy-server') || 'http://localhost:8000';
|
|
var messages = document.getElementById('messages');
|
|
var input = document.getElementById('input');
|
|
var dot = document.getElementById('status-dot');
|
|
var stxt = document.getElementById('status-text');
|
|
var connected = false;
|
|
var sending = false;
|
|
|
|
function setStatus(state) {
|
|
dot.className = state;
|
|
if (state === 'online') stxt.textContent = 'online';
|
|
else if (state === 'trying') stxt.textContent = 'connecting';
|
|
else stxt.textContent = 'offline';
|
|
}
|
|
|
|
// ── Looping scroll ──
|
|
// Messages live inside two duplicate containers.
|
|
// When scroll crosses the midpoint we silently reposition,
|
|
// creating an infinite one-direction loop.
|
|
var loopEnabled = false;
|
|
var suppressLoop = false;
|
|
|
|
function buildLoop() {
|
|
// Clone all messages into a second set so the container is 2x tall
|
|
var existing = messages.querySelectorAll('.msg, .thinking');
|
|
if (existing.length < 2) { loopEnabled = false; return; }
|
|
|
|
// Remove any previous clone zone
|
|
var oldClone = document.getElementById('clone-zone');
|
|
if (oldClone) oldClone.remove();
|
|
|
|
var zone = document.createElement('div');
|
|
zone.id = 'clone-zone';
|
|
for (var i = 0; i < existing.length; i++) {
|
|
zone.appendChild(existing[i].cloneNode(true));
|
|
}
|
|
messages.appendChild(zone);
|
|
loopEnabled = true;
|
|
}
|
|
|
|
messages.addEventListener('scroll', function() {
|
|
if (!loopEnabled || suppressLoop) return;
|
|
var half = messages.scrollHeight / 2;
|
|
if (messages.scrollTop >= half) {
|
|
suppressLoop = true;
|
|
messages.scrollTop -= half;
|
|
suppressLoop = false;
|
|
} else if (messages.scrollTop <= 0) {
|
|
suppressLoop = true;
|
|
messages.scrollTop += half;
|
|
suppressLoop = false;
|
|
}
|
|
});
|
|
|
|
function scrollDown() {
|
|
requestAnimationFrame(function() {
|
|
// Scroll to latest message (just before clone zone)
|
|
var clone = document.getElementById('clone-zone');
|
|
if (clone) {
|
|
messages.scrollTop = clone.offsetTop - messages.clientHeight + 40;
|
|
} else {
|
|
messages.scrollTop = messages.scrollHeight;
|
|
}
|
|
});
|
|
}
|
|
|
|
function rebuildAndScroll() {
|
|
// Small delay so DOM settles, then rebuild loop and scroll
|
|
requestAnimationFrame(function() {
|
|
buildLoop();
|
|
scrollDown();
|
|
});
|
|
}
|
|
|
|
function addMsg(role, text) {
|
|
// Remove old clone zone before adding new message
|
|
var oldClone = document.getElementById('clone-zone');
|
|
if (oldClone) oldClone.remove();
|
|
|
|
var div = document.createElement('div');
|
|
div.className = 'msg ' + role;
|
|
|
|
if (role === 'system') {
|
|
div.textContent = text;
|
|
} else {
|
|
var label = document.createElement('div');
|
|
label.className = 'label';
|
|
label.textContent = role === 'timmy' ? 'TIMMY' : 'YOU';
|
|
|
|
var body = document.createElement('div');
|
|
body.className = 'body';
|
|
|
|
if (role === 'timmy') {
|
|
body.innerHTML = renderMarkdown(text);
|
|
} else {
|
|
body.textContent = text;
|
|
}
|
|
|
|
div.appendChild(label);
|
|
div.appendChild(body);
|
|
}
|
|
|
|
messages.appendChild(div);
|
|
rebuildAndScroll();
|
|
return div;
|
|
}
|
|
|
|
function showThinking() {
|
|
var oldClone = document.getElementById('clone-zone');
|
|
if (oldClone) oldClone.remove();
|
|
|
|
var div = document.createElement('div');
|
|
div.className = 'thinking';
|
|
div.id = 'thinking';
|
|
div.innerHTML = '<span>.</span><span>.</span><span>.</span>';
|
|
messages.appendChild(div);
|
|
rebuildAndScroll();
|
|
}
|
|
|
|
function hideThinking() {
|
|
var el = document.getElementById('thinking');
|
|
if (el) el.remove();
|
|
// Also remove from clone zone
|
|
var cloneThinking = document.querySelector('#clone-zone .thinking');
|
|
if (cloneThinking) cloneThinking.remove();
|
|
}
|
|
|
|
// Simple markdown rendering (safe — no innerHTML with user content)
|
|
function renderMarkdown(text) {
|
|
// Escape HTML first
|
|
var safe = text
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
|
|
// Code blocks
|
|
safe = safe.replace(/```(\w*)\n?([\s\S]*?)```/g, function(m, lang, code) {
|
|
return '<pre><code>' + code.trim() + '</code></pre>';
|
|
});
|
|
|
|
// Inline code
|
|
safe = safe.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
|
|
// Bold
|
|
safe = safe.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
|
|
// Italic
|
|
safe = safe.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
|
|
|
// Paragraphs
|
|
safe = safe.replace(/\n\n+/g, '</p><p>');
|
|
safe = '<p>' + safe + '</p>';
|
|
|
|
// Line breaks within paragraphs
|
|
safe = safe.replace(/\n/g, '<br>');
|
|
|
|
return safe;
|
|
}
|
|
|
|
// Probe the server for connectivity
|
|
function probe() {
|
|
setStatus('trying');
|
|
fetch(SERVER + '/health/status', { mode: 'cors', signal: AbortSignal.timeout(4000) })
|
|
.then(function(r) {
|
|
if (r.ok) {
|
|
connected = true;
|
|
setStatus('online');
|
|
} else {
|
|
connected = false;
|
|
setStatus('offline');
|
|
}
|
|
})
|
|
.catch(function() {
|
|
connected = false;
|
|
setStatus('offline');
|
|
});
|
|
}
|
|
|
|
// Send message to Timmy
|
|
function send(text) {
|
|
if (sending) return;
|
|
|
|
// Special commands
|
|
if (text.startsWith('/connect ')) {
|
|
var url = text.slice(9).trim().replace(/\/+$/, '');
|
|
SERVER = url;
|
|
localStorage.setItem('timmy-server', SERVER);
|
|
addMsg('system', 'server set to ' + SERVER);
|
|
probe();
|
|
return;
|
|
}
|
|
|
|
if (text === '/clear') {
|
|
messages.innerHTML = '';
|
|
addMsg('system', 'chat cleared');
|
|
return;
|
|
}
|
|
|
|
if (text === '/help') {
|
|
addMsg('system', '/connect <url> — set server · /clear — clear chat');
|
|
return;
|
|
}
|
|
|
|
addMsg('user', text);
|
|
|
|
if (!connected) {
|
|
addMsg('system', 'not connected — start timmy (make dev) or /connect <url>');
|
|
return;
|
|
}
|
|
|
|
sending = true;
|
|
showThinking();
|
|
|
|
var form = new FormData();
|
|
form.append('message', text);
|
|
|
|
fetch(SERVER + '/agents/timmy/chat', {
|
|
method: 'POST',
|
|
body: form,
|
|
mode: 'cors',
|
|
})
|
|
.then(function(r) {
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
return r.text();
|
|
})
|
|
.then(function(html) {
|
|
hideThinking();
|
|
// Parse the response HTML to extract Timmy's reply
|
|
var parser = new DOMParser();
|
|
var doc = parser.parseFromString(html, 'text/html');
|
|
|
|
var agentBody = doc.querySelector('.chat-message.agent .msg-body');
|
|
var errorBody = doc.querySelector('.chat-message.error-msg .msg-body');
|
|
|
|
if (agentBody) {
|
|
addMsg('timmy', agentBody.textContent.trim());
|
|
} else if (errorBody) {
|
|
addMsg('system', errorBody.textContent.trim());
|
|
} else {
|
|
addMsg('system', 'no response');
|
|
}
|
|
})
|
|
.catch(function(err) {
|
|
hideThinking();
|
|
addMsg('system', 'error: ' + err.message);
|
|
probe();
|
|
})
|
|
.finally(function() {
|
|
sending = false;
|
|
});
|
|
}
|
|
|
|
// Input handling — Enter to send, no buttons
|
|
input.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
var text = input.value.trim();
|
|
if (!text) return;
|
|
input.value = '';
|
|
send(text);
|
|
}
|
|
});
|
|
|
|
// Initial welcome
|
|
addMsg('system', 'timmy time — sovereign ai');
|
|
addMsg('timmy', "What's on your mind?");
|
|
|
|
// Probe on load
|
|
probe();
|
|
|
|
// Re-probe periodically
|
|
setInterval(probe, 15000);
|
|
})();
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|