feat: chat history persistence via localStorage (#13)

Tracks Gitea issue perplexity/the-matrix #4.

## Storage helpers (ui.js)
- loadChatHistory(agentId) — reads matrix:chat:<agentId> from localStorage,
  returns [] on missing/corrupt data
- saveChatHistory(agentId, messages) — writes capped array (slice -100) to
  localStorage; silently ignores QuotaExceededError
- In-memory chatHistory map per agentId populated from both helpers
- Storage keys: matrix:chat:alpha, matrix:chat:beta, matrix:chat:gamma,
  matrix:chat:delta, matrix:chat:sys

## Timestamps
- appendChatMessage now stamps each message with Date.now()
- formatTimestamp(ts) formats as HH:MM for display
- Rendered as <span class="chat-ts">[HH:MM]</span> before the agent name
- Timestamp stored in persisted message object for sort-on-restore

## Restore on load
- loadAllHistories() called from initUI() combines all five stores (4 agents
  + sys), sorts by timestamp, renders last MAX_CHAT_ENTRIES (12) into panel
- No flash: history is populated synchronously before first rAF tick

## Persist on receive (websocket.js)
- appendChatMessage for chat messages: passes def.id as agentId (4th param)
- logEvent (SYS messages): passes 'sys' as agentId
- No changes to WS connect/message/reconnect logic

## Clear button (index.html + ui.js)
- #chat-clear-btn: fixed position, outside #ui-overlay, pointer-events:all
  so it remains clickable while the overlay is pointer-events:none
- Positioned bottom-right, left of OFFLINE/CONNECTED indicator
- On click: removes all five localStorage keys, clears DOM, resets chatEntries
- Hover brightens from dim green to full #00ff41 glow for discoverability

## Deviation from spec
- Task assumed per-agent panels with selectAgent(). Current UI has one shared
  global panel. Per-agent storage is implemented exactly as specified; restore
  on load shows combined last-12 across all agents (best fit for single panel).
  Clear wipes all stored histories (no per-agent panel to scope it to).

## Verified
- npm run build exits 0, 14 modules, no new warnings
This commit is contained in:
alexpaynex
2026-03-18 23:57:07 +00:00
parent 885bd0fc64
commit 7d4e07854f
3 changed files with 115 additions and 19 deletions

View File

@@ -24,7 +24,6 @@
color: #00ff41; font-size: 11px; line-height: 1.8;
text-shadow: 0 0 6px #00ff41; max-width: 240px;
}
#status-panel .label { color: #007722; }
#chat-panel {
position: fixed; bottom: 16px; left: 16px; right: 16px;
max-height: 180px; overflow-y: auto;
@@ -34,11 +33,24 @@
}
.chat-entry { opacity: 0.8; }
.chat-entry .agent-name { color: #00ff88; font-weight: bold; }
.chat-ts { color: #004d18; font-size: 10px; }
#connection-status {
position: fixed; bottom: 16px; right: 16px;
font-size: 11px; color: #555;
pointer-events: none;
}
#connection-status.connected { color: #00ff41; text-shadow: 0 0 6px #00ff41; }
#chat-clear-btn {
position: fixed; bottom: 16px; right: 110px;
font-family: 'Courier New', monospace;
font-size: 10px; color: #004d18;
background: transparent; border: 1px solid #004d18;
padding: 2px 6px; cursor: pointer;
pointer-events: all; z-index: 20;
text-shadow: none;
transition: color 0.2s, border-color 0.2s;
}
#chat-clear-btn:hover { color: #00ff41; border-color: #00ff41; }
</style>
</head>
<body>
@@ -55,6 +67,7 @@
<div id="chat-panel"></div>
<div id="connection-status">OFFLINE</div>
</div>
<button id="chat-clear-btn" title="Clear chat history">CLEAR</button>
<script type="module" src="./js/main.js"></script>
</body>
</html>

View File

@@ -1,18 +1,96 @@
import { getAgentDefs } from './agents.js';
import { colorToCss } from './agent-defs.js';
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
const $agentCount = document.getElementById('agent-count');
const $activeJobs = document.getElementById('active-jobs');
const $fps = document.getElementById('fps');
const $agentList = document.getElementById('agent-list');
const $connStatus = document.getElementById('connection-status');
const $chatPanel = document.getElementById('chat-panel');
const $agentCount = document.getElementById('agent-count');
const $activeJobs = document.getElementById('active-jobs');
const $fps = document.getElementById('fps');
const $agentList = document.getElementById('agent-list');
const $connStatus = document.getElementById('connection-status');
const $chatPanel = document.getElementById('chat-panel');
const $clearBtn = document.getElementById('chat-clear-btn');
const MAX_CHAT_ENTRIES = 12;
const MAX_STORED = 100;
const STORAGE_PREFIX = 'matrix:chat:';
const chatEntries = [];
const chatHistory = {};
function storageKey(agentId) {
return STORAGE_PREFIX + agentId;
}
export function loadChatHistory(agentId) {
try {
const raw = localStorage.getItem(storageKey(agentId));
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
export function saveChatHistory(agentId, messages) {
try {
localStorage.setItem(storageKey(agentId), JSON.stringify(messages.slice(-MAX_STORED)));
} catch {
}
}
function formatTimestamp(ts) {
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}
function buildChatEntry(agentLabel, message, cssColor, timestamp) {
const color = cssColor || '#00ff41';
const entry = document.createElement('div');
entry.className = 'chat-entry';
const ts = timestamp ? `<span class="chat-ts">[${formatTimestamp(timestamp)}]</span> ` : '';
entry.innerHTML = `${ts}<span class="agent-name" style="color:${color}">${agentLabel}</span>: ${escapeHtml(message)}`;
return entry;
}
function loadAllHistories() {
const all = [];
const agentIds = [...AGENT_DEFS.map(d => d.id), 'sys'];
for (const id of agentIds) {
const msgs = loadChatHistory(id);
chatHistory[id] = msgs;
all.push(...msgs);
}
all.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
for (const msg of all.slice(-MAX_CHAT_ENTRIES)) {
const entry = buildChatEntry(msg.agentLabel, msg.text, msg.cssColor, msg.timestamp);
chatEntries.push(entry);
$chatPanel.appendChild(entry);
}
$chatPanel.scrollTop = $chatPanel.scrollHeight;
}
function clearAllHistories() {
const agentIds = [...AGENT_DEFS.map(d => d.id), 'sys'];
for (const id of agentIds) {
localStorage.removeItem(storageKey(id));
chatHistory[id] = [];
}
while ($chatPanel.firstChild) $chatPanel.removeChild($chatPanel.firstChild);
chatEntries.length = 0;
}
export function initUI() {
renderAgentList();
loadAllHistories();
if ($clearBtn) {
$clearBtn.addEventListener('click', clearAllHistories);
}
}
function renderAgentList() {
@@ -55,16 +133,15 @@ export function updateUI({ fps, agentCount, jobCount, connectionState }) {
}
/**
* Append a line to the chat panel.
* @param {string} agentLabel — display name
* @param {string} message message text (HTML-escaped before insertion)
* @param {string} cssColor — CSS color string, e.g. '#00ff88'
* Append a message to the chat panel and optionally persist it.
* @param {string} agentLabel — display name
* @param {string} message — raw text (HTML-escaped before insertion)
* @param {string} cssColor — CSS color string e.g. '#00ff88'
* @param {string} [agentId] — storage key; omit to skip persistence
*/
export function appendChatMessage(agentLabel, message, cssColor) {
const color = cssColor || '#00ff41';
const entry = document.createElement('div');
entry.className = 'chat-entry';
entry.innerHTML = `<span class="agent-name" style="color:${color}">${agentLabel}</span>: ${escapeHtml(message)}`;
export function appendChatMessage(agentLabel, message, cssColor, agentId) {
const timestamp = Date.now();
const entry = buildChatEntry(agentLabel, message, cssColor, timestamp);
chatEntries.push(entry);
if (chatEntries.length > MAX_CHAT_ENTRIES) {
@@ -74,6 +151,12 @@ export function appendChatMessage(agentLabel, message, cssColor) {
$chatPanel.appendChild(entry);
$chatPanel.scrollTop = $chatPanel.scrollHeight;
if (agentId) {
if (!chatHistory[agentId]) chatHistory[agentId] = [];
chatHistory[agentId].push({ agentLabel, text: message, cssColor, agentId, timestamp });
saveChatHistory(agentId, chatHistory[agentId]);
}
}
function escapeHtml(str) {

View File

@@ -91,7 +91,7 @@ function handleMessage(msg) {
case 'chat': {
const def = agentById[msg.agentId];
if (def && msg.text) {
appendChatMessage(def.label, msg.text, colorToCss(def.color));
appendChatMessage(def.label, msg.text, colorToCss(def.color), def.id);
}
break;
}
@@ -103,7 +103,7 @@ function handleMessage(msg) {
}
function logEvent(text) {
appendChatMessage('SYS', text, colorToCss(0x003300));
appendChatMessage('SYS', text, colorToCss(0x003300), 'sys');
}
export function getConnectionState() {