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 INTERFACE
- -
-
-
TIMMY // SYSTEM
-
Mission Control initialized. Timmy ready — awaiting input.
-
+
+ // TIMMY INTERFACE +
+
+ + {% 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