From e79f4d8d222ecc3695972e4496b937993d3a467e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 12:39:15 -0400 Subject: [PATCH] feat: chat shell page rendering --- chat/app.py | 2 + chat/static/app.css | 11 ++++++ chat/templates/chat.html | 42 ++++++++++++++++++++ chat/web/chat.py | 54 ++++++++++++++++++++++++++ tests/test_chat_shell.py | 83 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 192 insertions(+) create mode 100644 chat/templates/chat.html create mode 100644 chat/web/chat.py create mode 100644 tests/test_chat_shell.py diff --git a/chat/app.py b/chat/app.py index a0924d4..a2033cf 100644 --- a/chat/app.py +++ b/chat/app.py @@ -15,6 +15,7 @@ import chat.state.memory # noqa: F401 import chat.state.world # noqa: F401 from chat.web.bots import router as bots_router +from chat.web.chat import router as chat_router from chat.web.kickoff import router as kickoff_router from chat.web.nav import router as nav_router from chat.web.settings import router as settings_router @@ -38,6 +39,7 @@ app.include_router(bots_router) app.include_router(kickoff_router) app.include_router(settings_router) app.include_router(nav_router) +app.include_router(chat_router) @app.get("/health") diff --git a/chat/static/app.css b/chat/static/app.css index 4645d19..f40bd40 100644 --- a/chat/static/app.css +++ b/chat/static/app.css @@ -68,3 +68,14 @@ h1 { margin-top: 0; } color: #1f5c2a; border-radius: 3px; } code { font-family: ui-monospace, "SF Mono", Menlo, monospace; } +.chat-shell { display: flex; flex-direction: column; height: 100%; max-width: 760px; margin: 0 auto; } +.chat-header { display: flex; align-items: center; gap: 16px; border-bottom: 1px solid #e5e5e5; padding-bottom: 8px; margin-bottom: 16px; } +.chat-header h1 { margin: 0; flex: 1; } +.chat-meta { font-size: 13px; } +.drawer-toggle { padding: 4px 10px; border: 1px solid #ccc; background: #fff; color: #1c1c1c; border-radius: 3px; cursor: pointer; } +.timeline { flex: 1; overflow-y: auto; min-height: 200px; padding: 8px 0; } +.turn { margin: 12px 0; } +.turn-input { display: flex; flex-direction: column; gap: 8px; padding-top: 12px; border-top: 1px solid #e5e5e5; } +.turn-input textarea { padding: 8px; font: inherit; border: 1px solid #ccc; border-radius: 3px; resize: vertical; } +.drawer { position: fixed; top: 0; right: 0; width: 360px; height: 100vh; background: #fff; border-left: 1px solid #e5e5e5; padding: 16px; overflow-y: auto; z-index: 10; } +.drawer[hidden] { display: none; } diff --git a/chat/templates/chat.html b/chat/templates/chat.html new file mode 100644 index 0000000..9aa04b4 --- /dev/null +++ b/chat/templates/chat.html @@ -0,0 +1,42 @@ +{% extends "layout.html" %} +{% block title %}{{ host_bot.name }} - chat{% endblock %} +{% block content %} +
+
+

{{ host_bot.name }}

+
{{ chat.time }}
+ +
+ +
+ {% if not turns %} +

No turns yet. Start typing below.

+ {% else %} + {% for turn in turns %} +
+ {{ turn.speaker }} +

{{ turn.text }}

+
+ {% endfor %} + {% endif %} +
+ +
+ + +
+ + +
+ +{% endblock %} diff --git a/chat/web/chat.py b/chat/web/chat.py new file mode 100644 index 0000000..35d12ef --- /dev/null +++ b/chat/web/chat.py @@ -0,0 +1,54 @@ +"""Chat detail (shell) page. + +Renders ``/chats/``: the title (host bot's name), a timeline placeholder, +the user-input form, and the drawer toggle. Turn handling lives in T19; this +module only sets up the structural shell. +""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from chat.state.entities import get_bot +from chat.state.world import get_chat +from chat.web.bots import get_conn + +TEMPLATES = Jinja2Templates( + directory=str(Path(__file__).resolve().parent.parent / "templates") +) + +router = APIRouter() + + +@router.get("/chats/{chat_id}", response_class=HTMLResponse) +async def chat_detail(chat_id: str, request: Request, conn=Depends(get_conn)): + chat = get_chat(conn, chat_id) + if chat is None: + raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}") + + host_bot = get_bot(conn, chat["host_bot_id"]) + if host_bot is None: + # Defensive: chat row references a bot that doesn't exist. Treat as 404 + # rather than crashing the template render. + raise HTTPException( + status_code=404, detail=f"host bot not found: {chat['host_bot_id']}" + ) + + # Phase 1, T15: timeline starts empty. T19 will populate from event_log + # by reading user_turn / assistant_turn events for this chat. + turns: list[dict] = [] + + return TEMPLATES.TemplateResponse( + request, + "chat.html", + { + "chat": chat, + "host_bot": host_bot, + "turns": turns, + "active_nav": "chats", + }, + ) diff --git a/tests/test_chat_shell.py b/tests/test_chat_shell.py new file mode 100644 index 0000000..5cd3577 --- /dev/null +++ b/tests/test_chat_shell.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from chat.app import app +from chat.db.connection import open_db +from chat.eventlog.log import append_event +from chat.eventlog.projector import project + + +@pytest.fixture +def client(tmp_path, monkeypatch): + cfg = tmp_path / "config.toml" + cfg.write_text('featherless_api_key = "test"\n') + monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg)) + db = tmp_path / "test.db" + monkeypatch.setenv("CHAT_DB_PATH", str(db)) + with TestClient(app) as c: + yield c + + +def _seed_chat(db_path: Path, bot_id: str = "bot_a", chat_id: str = "chat_bot_a") -> None: + """Author a bot, create a chat with default state.""" + with open_db(db_path) as conn: + append_event( + conn, + kind="bot_authored", + payload={ + "id": bot_id, + "name": "BotA", + "persona": "...", + "voice_samples": [], + "traits": [], + "backstory": "", + "initial_relationship_to_you": "", + "kickoff_prose": "...", + }, + ) + append_event( + conn, + kind="chat_created", + payload={ + "id": chat_id, + "host_bot_id": bot_id, + "initial_time": "2026-04-26T20:00:00+00:00", + "narrative_anchor": "Day 1", + "weather": "", + }, + ) + project(conn) + + +def test_get_chat_404_when_missing(client): + response = client.get("/chats/no_such_chat") + assert response.status_code == 404 + + +def test_get_chat_renders_shell_with_host_bot_name(client, tmp_path): + _seed_chat(tmp_path / "test.db") + response = client.get("/chats/chat_bot_a") + assert response.status_code == 200 + body = response.text + assert "BotA" in body + assert "