feat: top-level nav and chat list view

This commit is contained in:
Joseph Doherty
2026-04-26 12:36:20 -04:00
parent fbb16c86b3
commit 0c08745194
14 changed files with 218 additions and 19 deletions
+2
View File
@@ -16,6 +16,7 @@ import chat.state.world # noqa: F401
from chat.web.bots import router as bots_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
@@ -36,6 +37,7 @@ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.include_router(bots_router)
app.include_router(kickoff_router)
app.include_router(settings_router)
app.include_router(nav_router)
@app.get("/health")
+33 -4
View File
@@ -4,11 +4,33 @@ body {
margin: 0;
color: #1c1c1c;
background: #fafafa;
display: flex;
min-height: 100vh;
}
.topbar {
padding: 12px 24px;
border-bottom: 1px solid #e5e5e5;
background: #fff;
.rail {
width: 200px;
background: #1c1c1c;
color: #fff;
padding: 16px;
flex-shrink: 0;
}
.rail a { color: #fff; text-decoration: none; }
.rail-brand {
font-weight: 600;
display: block;
padding-bottom: 16px;
border-bottom: 1px solid #333;
margin-bottom: 16px;
}
.rail ul { list-style: none; padding: 0; margin: 0; }
.rail li { margin: 4px 0; }
.rail li a { display: block; padding: 6px 8px; border-radius: 3px; }
.rail li a.active { background: #333; }
.content {
flex: 1;
padding: 24px;
background: #fafafa;
overflow: auto;
}
.brand { font-weight: 600; text-decoration: none; color: inherit; }
.container { max-width: 720px; margin: 24px auto; padding: 0 16px; }
@@ -29,6 +51,13 @@ h1 { margin-top: 0; }
.bot-form small { display: block; color: #666; margin-top: 2px; }
.bot-list { list-style: none; padding: 0; }
.bot-list li { padding: 8px 0; border-bottom: 1px solid #eee; }
.chat-list { list-style: none; padding: 0; margin: 0; }
.chat-row { border-bottom: 1px solid #eee; }
.chat-row a { display: block; padding: 12px 0; text-decoration: none; color: inherit; }
.chat-row a:hover { background: #f0f0f0; }
.chat-row-name { font-weight: 600; }
.chat-row-snippet { font-size: 14px; }
.chat-row-meta { font-size: 12px; }
.muted { color: #666; }
.error {
padding: 8px 12px; border: 1px solid #c33; background: #fdecea;
+1 -6
View File
@@ -8,11 +8,6 @@
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
</head>
<body>
<header class="topbar">
<a class="brand" href="/bots">chat</a>
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
{% block body %}{% endblock %}
</body>
</html>
+1 -1
View File
@@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "layout.html" %}
{% block title %}New bot - chat{% endblock %}
{% block content %}
<h1>New bot</h1>
+1 -1
View File
@@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "layout.html" %}
{% block title %}Bots - chat{% endblock %}
{% block content %}
<header class="page-header">
+26
View File
@@ -0,0 +1,26 @@
{% extends "layout.html" %}
{% block title %}Chats - chat{% endblock %}
{% block content %}
<header class="page-header">
<h1>Chats</h1>
<a class="btn" href="/bots/new">+ New bot</a>
</header>
{% if chats %}
<ul class="chat-list">
{% for chat in chats %}
<li class="chat-row">
<a href="/chats/{{ chat.id }}">
<div class="chat-row-name">{{ chat.host_bot_name }}</div>
<div class="chat-row-snippet muted">{{ chat.last_message_snippet or '—' }}</div>
<div class="chat-row-meta muted">
<span>{{ chat.time }}</span>
{% if chat.last_played_at %}<span>· {{ chat.last_played_at }}</span>{% endif %}
</div>
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No chats yet. <a href="/bots/new">Create a bot</a> to start.</p>
{% endif %}
{% endblock %}
+1 -1
View File
@@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "layout.html" %}
{% block title %}Confirm kickoff - chat{% endblock %}
{% block content %}
<h1>Confirm kickoff</h1>
+14
View File
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block body %}
<nav class="rail">
<a class="rail-brand" href="/chats">chat</a>
<ul>
<li><a href="/chats" class="{% if active_nav == 'chats' %}active{% endif %}">Chats</a></li>
<li><a href="/bots" class="{% if active_nav == 'bots' %}active{% endif %}">Bots</a></li>
<li><a href="/settings" class="{% if active_nav == 'settings' %}active{% endif %}">Settings</a></li>
</ul>
</nav>
<main class="content">
{% block content %}{% endblock %}
</main>
{% endblock %}
+1 -1
View File
@@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "layout.html" %}
{% block title %}Settings - chat{% endblock %}
{% block content %}
<h1>Settings</h1>
+6 -2
View File
@@ -66,12 +66,16 @@ def _split_traits(text: str) -> list[str]:
@router.get("/bots", response_class=HTMLResponse)
async def bots_list(request: Request, conn=Depends(get_conn)):
bots = list_bots(conn)
return TEMPLATES.TemplateResponse(request, "bot_list.html", {"bots": bots})
return TEMPLATES.TemplateResponse(
request, "bot_list.html", {"bots": bots, "active_nav": "bots"}
)
@router.get("/bots/new", response_class=HTMLResponse)
async def bot_form(request: Request):
return TEMPLATES.TemplateResponse(request, "bot_form.html", {"values": {}, "error": None})
return TEMPLATES.TemplateResponse(
request, "bot_form.html", {"values": {}, "error": None, "active_nav": "bots"}
)
@router.post("/bots/new")
+3 -1
View File
@@ -122,7 +122,9 @@ async def kickoff_get(
"edge_seed_summary": parsed.edge_seed_summary,
"edge_seed_knowledge_facts": "\n".join(parsed.edge_seed_knowledge_facts),
}
return TEMPLATES.TemplateResponse(request, "kickoff_confirm.html", {"values": values})
return TEMPLATES.TemplateResponse(
request, "kickoff_confirm.html", {"values": values, "active_nav": "bots"}
)
@router.post("/bots/{bot_id}/kickoff")
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from chat.web.bots import get_conn
from chat.state.world import list_chats
from chat.state.entities import get_bot
TEMPLATES = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
router = APIRouter()
@router.get("/", include_in_schema=False)
async def home():
return RedirectResponse(url="/chats", status_code=303)
@router.get("/chats", response_class=HTMLResponse)
async def chats_list(request: Request, conn=Depends(get_conn)):
chats = list_chats(conn)
# Annotate each chat with the host bot's name for display.
for ch in chats:
bot = get_bot(conn, ch["host_bot_id"])
ch["host_bot_name"] = bot["name"] if bot else ch["host_bot_id"]
# Last-message snippet and last-played-at are blank in v1; T19 fills them.
ch["last_message_snippet"] = ""
ch["last_played_at"] = None
return TEMPLATES.TemplateResponse(request, "chat_list.html", {
"chats": chats,
"active_nav": "chats",
})
+6 -2
View File
@@ -18,7 +18,9 @@ router = APIRouter()
async def settings_get(request: Request, conn=Depends(get_conn)):
you = get_you(conn) or {"name": "", "pronouns": "", "persona": ""}
return TEMPLATES.TemplateResponse(
request, "settings.html", {"values": you, "saved": False}
request,
"settings.html",
{"values": you, "saved": False, "active_nav": "settings"},
)
@@ -42,5 +44,7 @@ async def settings_post(
project(conn)
return TEMPLATES.TemplateResponse(
request, "settings.html", {"values": payload, "saved": True}
request,
"settings.html",
{"values": payload, "saved": True, "active_nav": "settings"},
)
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from chat.app import app
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
@pytest.fixture
def client(tmp_path, monkeypatch):
config_path = tmp_path / "config.toml"
config_path.write_text('featherless_api_key = "test"\n')
monkeypatch.setenv("CHAT_CONFIG_PATH", str(config_path))
monkeypatch.setenv("CHAT_DB_PATH", str(tmp_path / "test.db"))
with TestClient(app) as c:
yield c
def _author_bot_and_chat(db_path: Path, bot_id: str = "bot_a") -> None:
"""Insert a bot and a chat directly via the event log (skip kickoff route)."""
from chat.db.connection import open_db
with open_db(db_path) as conn:
append_event(
conn,
kind="bot_authored",
payload={
"id": bot_id,
"name": "BotA",
"persona": "thoughtful, observant",
"voice_samples": [],
"traits": ["shy"],
"backstory": "",
"initial_relationship_to_you": "coworker",
"kickoff_prose": "you stay late at the office; she's there too",
},
)
append_event(
conn,
kind="chat_created",
payload={
"id": f"chat_{bot_id}",
"host_bot_id": bot_id,
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
project(conn)
def test_root_redirects_to_chats(client):
response = client.get("/", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/chats"
def test_chats_list_empty_state(client):
response = client.get("/chats")
assert response.status_code == 200
body = response.text.lower()
# Empty state should mention there are no chats yet.
assert "no chats yet" in body
def test_chats_list_renders_existing_chats(client, tmp_path):
_author_bot_and_chat(tmp_path / "test.db", "bot_a")
response = client.get("/chats")
assert response.status_code == 200
body = response.text
# The bot's display name should appear in the chat row.
assert "BotA" in body
# The chat's in-fiction time should appear in the meta.
assert "2026-04-26T20:00:00+00:00" in body
def test_existing_template_routes_still_work_with_new_layout(client):
# Smoke test the layout reshuffle didn't break the existing pages.
for path in ("/bots", "/bots/new", "/settings"):
response = client.get(path)
assert response.status_code == 200, f"{path} returned {response.status_code}"
body = response.text
# Each page should now show the persistent left-rail brand link.
assert 'class="rail"' in body or "rail-brand" in body