feat: top-level nav and chat list view
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "layout.html" %}
|
||||
{% block title %}New bot - chat{% endblock %}
|
||||
{% block content %}
|
||||
<h1>New bot</h1>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "layout.html" %}
|
||||
{% block title %}Bots - chat{% endblock %}
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "layout.html" %}
|
||||
{% block title %}Confirm kickoff - chat{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Confirm kickoff</h1>
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "layout.html" %}
|
||||
{% block title %}Settings - chat{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Settings</h1>
|
||||
|
||||
+6
-2
@@ -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
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user