diff --git a/src/dashboard/routes/agents.py b/src/dashboard/routes/agents.py
index a8fd7fba..14cc25e4 100644
--- a/src/dashboard/routes/agents.py
+++ b/src/dashboard/routes/agents.py
@@ -6,6 +6,7 @@ from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from timmy.agent import create_timmy
+from dashboard.store import message_log
router = APIRouter(prefix="/agents", tags=["agents"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
@@ -27,6 +28,25 @@ async def list_agents():
return {"agents": list(AGENT_REGISTRY.values())}
+@router.get("/timmy/history", response_class=HTMLResponse)
+async def get_history(request: Request):
+ return templates.TemplateResponse(
+ request,
+ "partials/history.html",
+ {"messages": message_log.all()},
+ )
+
+
+@router.delete("/timmy/history", response_class=HTMLResponse)
+async def clear_history(request: Request):
+ message_log.clear()
+ return templates.TemplateResponse(
+ request,
+ "partials/history.html",
+ {"messages": []},
+ )
+
+
@router.post("/timmy/chat", response_class=HTMLResponse)
async def chat_timmy(request: Request, message: str = Form(...)):
timestamp = datetime.now().strftime("%H:%M:%S")
@@ -40,6 +60,12 @@ async def chat_timmy(request: Request, message: str = Form(...)):
except Exception as exc:
error_text = f"Timmy is offline: {exc}"
+ message_log.append(role="user", content=message, timestamp=timestamp)
+ if response_text is not None:
+ message_log.append(role="agent", content=response_text, timestamp=timestamp)
+ else:
+ message_log.append(role="error", content=error_text, timestamp=timestamp)
+
return templates.TemplateResponse(
request,
"partials/chat_message.html",
diff --git a/src/dashboard/store.py b/src/dashboard/store.py
new file mode 100644
index 00000000..48ca3157
--- /dev/null
+++ b/src/dashboard/store.py
@@ -0,0 +1,31 @@
+from dataclasses import dataclass, field
+
+
+@dataclass
+class Message:
+ role: str # "user" | "agent" | "error"
+ content: str
+ timestamp: str
+
+
+class MessageLog:
+ """In-memory chat history for the lifetime of the server process."""
+
+ def __init__(self) -> None:
+ self._entries: list[Message] = []
+
+ def append(self, role: str, content: str, timestamp: str) -> None:
+ self._entries.append(Message(role=role, content=content, timestamp=timestamp))
+
+ def all(self) -> list[Message]:
+ return list(self._entries)
+
+ def clear(self) -> None:
+ self._entries.clear()
+
+ def __len__(self) -> int:
+ return len(self._entries)
+
+
+# Module-level singleton shared across the app
+message_log = MessageLog()
diff --git a/src/dashboard/templates/index.html b/src/dashboard/templates/index.html
index ca57bdcc..bb448a4f 100644
--- a/src/dashboard/templates/index.html
+++ b/src/dashboard/templates/index.html
@@ -46,15 +46,20 @@
-
-
-
-
-
TIMMY // SYSTEM
-
Mission Control initialized. Timmy ready — awaiting input.
-
+
+
+
+ {% elif msg.role == "agent" %}
+
+
TIMMY // {{ msg.timestamp }}
+
{{ msg.content }}
+
+ {% else %}
+
+
SYSTEM // {{ msg.timestamp }}
+
{{ msg.content }}
+
+ {% endif %}
+ {% endfor %}
+{% else %}
+
+
TIMMY // SYSTEM
+
Mission Control initialized. Timmy ready — awaiting input.
+
+{% endif %}
diff --git a/static/style.css b/static/style.css
index cebc0ff5..ea673e0b 100644
--- a/static/style.css
+++ b/static/style.css
@@ -204,6 +204,22 @@ body {
flex-shrink: 0;
}
+.mc-btn-clear {
+ background: transparent;
+ border: 1px solid var(--border);
+ border-radius: 2px;
+ color: var(--text-dim);
+ font-family: var(--font);
+ font-size: 9px;
+ font-weight: 700;
+ padding: 3px 8px;
+ letter-spacing: 0.12em;
+ cursor: pointer;
+ transition: border-color 0.15s, color 0.15s;
+ touch-action: manipulation;
+}
+.mc-btn-clear:hover { border-color: var(--red); color: var(--red); }
+
/* Bootstrap form-control overrides */
.mc-input {
background: var(--bg-deep) !important;
diff --git a/tests/conftest.py b/tests/conftest.py
index 3d60d2b7..5b603c1e 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -18,6 +18,15 @@ for _mod in [
sys.modules.setdefault(_mod, MagicMock())
+@pytest.fixture(autouse=True)
+def reset_message_log():
+ """Clear the in-memory chat log before and after every test."""
+ from dashboard.store import message_log
+ message_log.clear()
+ yield
+ message_log.clear()
+
+
@pytest.fixture
def client():
from dashboard.app import app
diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py
index 27d17f46..3bc5241c 100644
--- a/tests/test_dashboard.py
+++ b/tests/test_dashboard.py
@@ -108,3 +108,57 @@ def test_chat_timmy_ollama_offline(client):
def test_chat_timmy_requires_message(client):
response = client.post("/agents/timmy/chat", data={})
assert response.status_code == 422
+
+
+# ── History ────────────────────────────────────────────────────────────────────
+
+def test_history_empty_shows_init_message(client):
+ response = client.get("/agents/timmy/history")
+ assert response.status_code == 200
+ assert "Mission Control initialized" in response.text
+
+
+def test_history_records_user_and_agent_messages(client):
+ mock_agent = MagicMock()
+ mock_agent.run.return_value = MagicMock(content="I am operational.")
+
+ with patch("dashboard.routes.agents.create_timmy", return_value=mock_agent):
+ client.post("/agents/timmy/chat", data={"message": "status check"})
+
+ response = client.get("/agents/timmy/history")
+ assert "status check" in response.text
+ assert "I am operational." in response.text
+
+
+def test_history_records_error_when_offline(client):
+ with patch("dashboard.routes.agents.create_timmy", side_effect=Exception("refused")):
+ client.post("/agents/timmy/chat", data={"message": "ping"})
+
+ response = client.get("/agents/timmy/history")
+ assert "ping" in response.text
+ assert "Timmy is offline" in response.text
+
+
+def test_history_clear_resets_to_init_message(client):
+ mock_agent = MagicMock()
+ mock_agent.run.return_value = MagicMock(content="Acknowledged.")
+
+ with patch("dashboard.routes.agents.create_timmy", return_value=mock_agent):
+ client.post("/agents/timmy/chat", data={"message": "hello"})
+
+ response = client.delete("/agents/timmy/history")
+ assert response.status_code == 200
+ assert "Mission Control initialized" in response.text
+
+
+def test_history_empty_after_clear(client):
+ mock_agent = MagicMock()
+ mock_agent.run.return_value = MagicMock(content="OK.")
+
+ with patch("dashboard.routes.agents.create_timmy", return_value=mock_agent):
+ client.post("/agents/timmy/chat", data={"message": "test"})
+
+ client.delete("/agents/timmy/history")
+ response = client.get("/agents/timmy/history")
+ assert "test" not in response.text
+ assert "Mission Control initialized" in response.text