forked from Rockachopa/Timmy-time-dashboard
The chat WebSocket return path was broken by two bugs that prevented
Timmy's responses from appearing in the live chat feed:
1. Frontend checked msg.type instead of msg.event for 'timmy_response'
events — the WSEvent dataclass uses 'event' as the field name.
2. Frontend accessed msg.response instead of msg.data.response — the
response payload is nested in the data field.
Additional fixes:
- Queue acknowledgment ("Message queued...") no longer logged as an
agent message in chat history; the real response is logged by the
task processor when it completes, eliminating duplicate messages.
- Chat message template now carries data-task-id so the WS handler
can find and replace the placeholder with the actual response.
- appendMessage() uses DOM APIs (textContent) instead of innerHTML
for safer content insertion before markdown rendering.
- Fixed chat_message.html script targeting when queue-status div is
present between the agent message and the inline script.
https://claude.ai/code/session_011cJfexqBBuGhSRQU8qwKcR
Co-authored-by: Claude <noreply@anthropic.com>
205 lines
7.8 KiB
HTML
205 lines
7.8 KiB
HTML
<div id="main-panel" class="col-12 col-md-9 d-flex flex-column mc-chat-panel">
|
|
<div class="card mc-panel flex-grow-1 d-flex flex-column min-h-0">
|
|
|
|
<div class="card-header mc-panel-header d-flex justify-content-between align-items-center">
|
|
<span class="d-flex align-items-center gap-2">
|
|
{% if agent %}
|
|
<span class="status-dot {{ 'green' if agent.status == 'idle' else 'amber' }}"></span>
|
|
{% endif %}
|
|
// TIMMY INTERFACE
|
|
<span id="timmy-status" class="ms-2" style="font-size: 0.75rem; color: #888;">
|
|
<span class="htmx-indicator">checking...</span>
|
|
</span>
|
|
</span>
|
|
<button class="mc-btn-clear"
|
|
hx-delete="/agents/timmy/history"
|
|
hx-target="#chat-log"
|
|
hx-swap="innerHTML"
|
|
hx-confirm="Clear conversation history?">CLEAR</button>
|
|
</div>
|
|
|
|
<div id="current-task-banner" class="current-task-banner" style="display: none; background: #1a1a2e; padding: 8px 12px; border-bottom: 1px solid #333;">
|
|
<small style="color: #00ff88;">● WORKING:</small>
|
|
<span id="current-task-title" style="color: #fff;"></span>
|
|
</div>
|
|
|
|
<div class="chat-log flex-grow-1 overflow-auto p-3" id="chat-log"
|
|
hx-get="/agents/timmy/history"
|
|
hx-trigger="load"
|
|
hx-swap="innerHTML"
|
|
hx-on::after-settle="scrollChat()"></div>
|
|
|
|
<div class="card-footer mc-chat-footer">
|
|
<form hx-post="/agents/timmy/chat"
|
|
hx-target="#chat-log"
|
|
hx-swap="beforeend"
|
|
hx-indicator="#send-indicator"
|
|
hx-sync="this:drop"
|
|
hx-disabled-elt="find button"
|
|
hx-on::after-settle="scrollChat()"
|
|
hx-on::after-request="if(event.detail.successful){this.querySelector('[name=message]').value='';}"
|
|
class="d-flex gap-2"
|
|
id="timmy-chat-form">
|
|
<input type="text"
|
|
name="message"
|
|
class="form-control mc-input"
|
|
placeholder="send a message to timmy..."
|
|
autocomplete="off"
|
|
autocorrect="off"
|
|
autocapitalize="none"
|
|
spellcheck="false"
|
|
enterkeyhint="send"
|
|
required
|
|
id="timmy-chat-input" />
|
|
<button type="submit" class="btn mc-btn-send">
|
|
SEND
|
|
<span id="send-indicator" class="htmx-indicator">◼</span>
|
|
</button>
|
|
<button type="button"
|
|
class="btn"
|
|
style="background: #1a1a2e; color: #00ff88; border: 1px solid #00ff88;
|
|
font-size: 0.7rem; white-space: nowrap; padding: 4px 10px;"
|
|
onclick="askGrok()"
|
|
title="Send directly to Grok (xAI)">
|
|
GROK
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
scrollChat();
|
|
|
|
function askGrok() {
|
|
var input = document.getElementById('timmy-chat-input');
|
|
if (!input || !input.value.trim()) return;
|
|
var form = document.getElementById('timmy-chat-form');
|
|
// Temporarily redirect form to Grok endpoint
|
|
var originalAction = form.getAttribute('hx-post');
|
|
form.setAttribute('hx-post', '/grok/chat');
|
|
htmx.process(form);
|
|
htmx.trigger(form, 'submit');
|
|
// Restore original action after submission
|
|
setTimeout(function() {
|
|
form.setAttribute('hx-post', originalAction);
|
|
htmx.process(form);
|
|
}, 100);
|
|
}
|
|
|
|
// Poll for Timmy's queue status (fallback) + WebSocket for real-time
|
|
(function() {
|
|
var statusEl = document.getElementById('timmy-status');
|
|
var banner = document.getElementById('current-task-banner');
|
|
var taskTitle = document.getElementById('current-task-title');
|
|
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
var ws;
|
|
var chatLog = document.getElementById('chat-log');
|
|
|
|
function updateFromData(data) {
|
|
if (data.is_working && data.current_task) {
|
|
statusEl.innerHTML = '<span style="color: #ffaa00;">working...</span>';
|
|
banner.style.display = 'block';
|
|
taskTitle.textContent = data.current_task.title;
|
|
} else if (data.tasks_ahead > 0) {
|
|
statusEl.innerHTML = '<span style="color: #888;">queue: ' + data.tasks_ahead + ' ahead</span>';
|
|
banner.style.display = 'none';
|
|
} else {
|
|
statusEl.innerHTML = '<span style="color: #00ff88;">ready</span>';
|
|
banner.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function fetchStatus() {
|
|
fetch('/api/queue/status?assigned_to=timmy')
|
|
.then(r => r.json())
|
|
.then(updateFromData)
|
|
.catch(() => {});
|
|
}
|
|
|
|
function appendMessage(role, content, timestamp, taskId) {
|
|
// If this is a response for a queued task, replace the placeholder
|
|
if (taskId) {
|
|
var placeholder = chatLog.querySelector('.chat-message.agent[data-task-id="' + taskId + '"]');
|
|
if (placeholder) {
|
|
placeholder.removeAttribute('data-task-id');
|
|
var body = placeholder.querySelector('.msg-body');
|
|
if (body) {
|
|
body.textContent = content;
|
|
body.className = 'msg-body timmy-md';
|
|
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
|
body.innerHTML = DOMPurify.sanitize(marked.parse(body.textContent));
|
|
if (typeof hljs !== 'undefined') {
|
|
body.querySelectorAll('pre code').forEach(function(block) { hljs.highlightElement(block); });
|
|
}
|
|
}
|
|
}
|
|
placeholder.querySelector('.msg-meta').textContent = 'TIMMY // ' + timestamp;
|
|
// Remove queue-status indicator if present
|
|
var qs = placeholder.nextElementSibling;
|
|
if (qs && qs.classList.contains('queue-status')) qs.remove();
|
|
chatLog.scrollTop = chatLog.scrollHeight;
|
|
return;
|
|
}
|
|
}
|
|
var div = document.createElement('div');
|
|
div.className = 'chat-message ' + role;
|
|
var meta = document.createElement('div');
|
|
meta.className = 'msg-meta';
|
|
meta.textContent = (role === 'user' ? 'YOU' : 'TIMMY') + ' // ' + timestamp;
|
|
var body = document.createElement('div');
|
|
body.className = 'msg-body timmy-md';
|
|
body.textContent = content;
|
|
div.appendChild(meta);
|
|
div.appendChild(body);
|
|
chatLog.appendChild(div);
|
|
// Render markdown if available
|
|
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
|
body.innerHTML = DOMPurify.sanitize(marked.parse(body.textContent));
|
|
if (typeof hljs !== 'undefined') {
|
|
body.querySelectorAll('pre code').forEach(function(block) { hljs.highlightElement(block); });
|
|
}
|
|
}
|
|
// Scroll to bottom
|
|
chatLog.scrollTop = chatLog.scrollHeight;
|
|
}
|
|
|
|
function connectWs() {
|
|
try {
|
|
ws = new WebSocket(protocol + '//' + window.location.host + '/swarm/live');
|
|
ws.onmessage = function(event) {
|
|
try {
|
|
var msg = JSON.parse(event.data);
|
|
// Refresh on task events
|
|
if (msg.type === 'task_event') {
|
|
fetchStatus();
|
|
} else if (msg.type === 'timmy_thought') {
|
|
// Timmy thought - could show in UI
|
|
} else if (msg.event === 'task_created' || msg.event === 'task_completed' ||
|
|
msg.event === 'task_approved') {
|
|
fetchStatus();
|
|
} else if (msg.event === 'timmy_response' && msg.data) {
|
|
// Timmy pushed a response via task processor
|
|
var now = new Date();
|
|
var ts = now.getHours().toString().padStart(2,'0') + ':' + now.getMinutes().toString().padStart(2,'0') + ':' + now.getSeconds().toString().padStart(2,'0');
|
|
appendMessage('agent', msg.data.response, ts, msg.data.task_id);
|
|
fetchStatus();
|
|
}
|
|
} catch(e) {}
|
|
};
|
|
ws.onclose = function() {
|
|
setTimeout(connectWs, 5000);
|
|
};
|
|
} catch(e) {}
|
|
}
|
|
|
|
// Initial fetch + start WebSocket
|
|
fetchStatus();
|
|
connectWs();
|
|
|
|
// Also poll periodically as fallback (every 10s)
|
|
setInterval(fetchStatus, 10000);
|
|
})();
|
|
</script>
|