1
0
This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Timmy-time-dashboard/docs/index.html

535 lines
13 KiB
HTML
Raw Normal View History

<!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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// 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>