feat: cross-chat search UX (top-bar + results page) (T100)

Wires T93's `search_all_memories` service into a small read-only HTML
surface so users can find a memory across every chat in the database.

* `chat/web/search.py` (new): GET `/search?q=...` runs the FTS service
  with k=50, hydrates each row with bot name + scene timestamp, and
  renders `search.html`. Empty `q` short-circuits to no results so the
  top-bar form can submit even with an empty input.
* `chat/templates/search.html` (new): empty-state placeholder, results
  list with chat-level "Open chat" links (`/chats/{chat_id}` — memories
  don't carry an event_id today, so no per-turn anchor).
* `chat/templates/layout.html`: append a small `<form>` to the rail
  nav, additive only.
* `chat/app.py`: register `search_router` (additive import + include).
* `tests/test_search_ux.py`: 3 tests — multi-chat results, empty-query
  placeholder, chat link.
This commit is contained in:
Joseph Doherty
2026-04-27 03:46:52 -04:00
parent 3dbe1a01ff
commit 0a2c5924f9
5 changed files with 273 additions and 0 deletions
+2
View File
@@ -32,6 +32,7 @@ from chat.web.drawer import router as drawer_router
from chat.web.kickoff import router as kickoff_router
from chat.web.middleware import FirstRunRedirectMiddleware
from chat.web.nav import router as nav_router
from chat.web.search import router as search_router
from chat.web.settings import router as settings_router
from chat.web.sse import router as sse_router
from chat.web.turns import router as turns_router
@@ -140,6 +141,7 @@ app.include_router(settings_router)
app.include_router(nav_router)
app.include_router(chat_router)
app.include_router(drawer_router)
app.include_router(search_router)
app.include_router(sse_router)
app.include_router(turns_router)
+7
View File
@@ -7,6 +7,13 @@
<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>
{# T100: cross-chat search box. GET /search so the URL is shareable
and back-button friendly; the results page itself re-renders this
form with the query pre-filled. #}
<form class="rail-search" action="/search" method="get" role="search">
<input type="search" name="q" placeholder="Search" aria-label="Search memories">
<button type="submit">Go</button>
</form>
</nav>
<main class="content">
{% block content %}{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
{% extends "layout.html" %}
{% block title %}Search - chat{% endblock %}
{% block content %}
<header class="page-header">
<h1>Search</h1>
</header>
<form class="search-page-form" action="/search" method="get">
<input type="text" name="q" value="{{ query|default('', true) }}"
placeholder="Search memories across all chats" autofocus>
<button type="submit">Search</button>
</form>
{% if not query %}
{# Empty-state placeholder: the top-bar form submits to /search even
with no input, so this page must render cleanly with no query. #}
<p class="muted search-empty">Enter a query to search memories across all chats.</p>
{% elif not results %}
<p class="muted">No matches for &ldquo;{{ query }}&rdquo;.</p>
{% else %}
<ul class="search-results">
{% for r in results %}
<li class="search-result">
<a class="search-result-link" href="/chats/{{ r.chat_id }}">
<div class="search-result-meta muted">
<strong>{{ r.owner_name }}</strong>
<span>&middot; {{ r.chat_id }}</span>
{% if r.chat_name %}<span>&middot; {{ r.chat_name }}</span>{% endif %}
{% if r.scene_label %}<span>&middot; scene {{ r.scene_label }}</span>{% endif %}
</div>
<div class="search-result-summary">{{ r.pov_summary }}</div>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
+92
View File
@@ -0,0 +1,92 @@
"""T100 (Phase 4): cross-chat search UX route.
Wraps T93's :func:`chat.services.cross_chat_search.search_all_memories`
in a small read-only HTML surface so the top-bar search input has
somewhere to land. The route does no filtering of its own beyond the
empty-query fast-path that T93 already implements; ranking, owner
scope, and witness scope all live in the service layer.
For each match we hydrate just enough metadata to render a row:
* the owner bot's display name (so users see "BOTA" not "bot_a"),
* the originating ``chat_id`` (the link target — there's no per-turn
anchor today because memories don't carry an ``event_id`` column,
so we deep-link to the chat as a whole),
* the originating scene title when one exists,
* and the ``pov_summary`` itself.
We deliberately keep this module synchronous and template-only — no
HTMX swaps, no JSON API — because the search box is a "leave the
current chat to look something up" surface, not an inline drawer.
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from chat.services.cross_chat_search import search_all_memories
from chat.state.entities import get_bot
from chat.state.world import get_chat, get_scene
from chat.web.bots import get_conn
TEMPLATES = Jinja2Templates(
directory=str(Path(__file__).resolve().parent.parent / "templates")
)
router = APIRouter()
@router.get("/search", response_class=HTMLResponse)
async def search(request: Request, q: str = "", conn=Depends(get_conn)):
"""Render ``search.html`` with up to 50 cross-chat FTS matches.
``q`` is intentionally allowed to be empty — that path renders the
page's "enter a query" placeholder rather than a 400, because the
top-bar form submits to this URL even with an empty input. T93's
service short-circuits whitespace-only queries to ``[]`` so there
is no FTS5 ``MATCH ''`` syntax error to guard against here.
"""
raw_results = search_all_memories(conn, query=q, k=50) if q else []
# Hydrate display fields per row. We do this in the route (not the
# service) so the service stays a pure FTS shim that other UIs
# can reuse.
results = []
for row in raw_results:
bot = get_bot(conn, row["owner_id"])
chat = get_chat(conn, row["chat_id"])
scene = get_scene(conn, row["scene_id"]) if row["scene_id"] else None
results.append(
{
"memory_id": row["memory_id"],
"owner_id": row["owner_id"],
"owner_name": bot["name"] if bot else row["owner_id"],
"chat_id": row["chat_id"],
"chat_name": (
chat.get("narrative_anchor") if chat else None
),
"scene_id": row["scene_id"],
# Scenes have no ``title`` column today; surface the
# ``started_at`` timestamp as a human-friendly label
# when a scene is set, otherwise leave it blank.
"scene_label": (
scene.get("started_at") if scene else None
),
"pov_summary": row["pov_summary"],
"significance": row["significance"],
"ts": row["ts"],
}
)
return TEMPLATES.TemplateResponse(
request,
"search.html",
{
"query": q,
"results": results,
"active_nav": "search",
},
)
+135
View File
@@ -0,0 +1,135 @@
"""T100 (Phase 4): cross-chat search UX (top-bar + results page).
Verifies the FastAPI ``/search`` route that wraps T93's
``search_all_memories`` service:
* ``/search?q=...`` returns 200 + an HTML page that lists matches drawn
from MULTIPLE chats (not just the current one) and links each result
back to ``/chats/{chat_id}``.
* ``/search`` with no query renders the page in its empty state with a
"enter a query" placeholder and no result rows (avoids hitting the
FTS index with an invalid empty MATCH).
* Result links navigate to the originating chat so users can pick up
the thread where the memory came from.
"""
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
import chat.state.memory # noqa: F401 (registers memory_written handler)
@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 _seed_two_chats_with_memories(db_path: Path) -> None:
"""Seed: a ``you_entity``, two bots, two chats, and one ``rabbit``
memory per chat. Two-chat seeding lets the cross-chat assertion
actually distinguish "both chats appear" from "only the current
one does"."""
with open_db(db_path) as conn:
append_event(
conn,
kind="you_authored",
payload={"name": "Me", "pronouns": "", "persona": ""},
)
for bot_id, chat_id in (("bot_a", "chat_a"), ("bot_b", "chat_b")):
append_event(
conn,
kind="bot_authored",
payload={
"id": bot_id,
"name": bot_id.upper(),
"persona": "thoughtful",
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "friend",
"kickoff_prose": "kickoff",
},
)
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": "",
},
)
append_event(
conn,
kind="memory_written",
payload={
"owner_id": bot_id,
"chat_id": chat_id,
"pov_summary": f"the rabbit darted across {chat_id}",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 0,
"source": "direct",
"reliability": 1.0,
"significance": 1,
"pinned": 0,
"auto_pinned": 0,
},
)
project(conn)
def test_search_returns_results_from_multiple_chats(client, tmp_path):
"""A single ``/search?q=rabbit`` must surface matches from BOTH
chats — the whole point of the cross-chat search box is that it
isn't owner-scoped."""
_seed_two_chats_with_memories(tmp_path / "test.db")
resp = client.get("/search?q=rabbit")
assert resp.status_code == 200
body = resp.text
# Both chats' memory snippets must appear in the rendered page.
assert "chat_a" in body
assert "chat_b" in body
assert "rabbit" in body.lower()
def test_empty_query_renders_placeholder_not_results(client, tmp_path):
"""``/search`` with no query renders the page in its empty state.
The placeholder copy is a contract with the user — they should see
"enter a query" rather than an empty result list that looks like a
no-match. Also: the FTS short-circuit means there are no result
rows to leak into the body."""
_seed_two_chats_with_memories(tmp_path / "test.db")
resp = client.get("/search")
assert resp.status_code == 200
body = resp.text.lower()
assert "enter a query" in body
# Seeded "rabbit" memories must NOT appear: empty query => no results.
assert "the rabbit darted" not in resp.text
def test_result_links_navigate_to_chat(client, tmp_path):
"""Each result links back to its originating chat so the user can
reopen the thread where the memory was first witnessed."""
_seed_two_chats_with_memories(tmp_path / "test.db")
resp = client.get("/search?q=rabbit")
assert resp.status_code == 200
# The link target is chat-level (memories don't carry an event_id
# column today, so we don't deep-link to a specific turn).
assert 'href="/chats/chat_a"' in resp.text