Files
chat/chat/templates/search.html
T
Joseph Doherty 9987da2c07 feat: cross-chat search deep-links to turn via memories.event_id (T111.2)
Add ``m.event_id`` (T109's nullable column from migration 0014) to
``search_all_memories``'s SELECT, propagate it through the route's
template context, and have ``search.html`` build result links as
``/chats/{chat_id}#turn-{event_id}`` — matching the ``id="turn-{event_id}"``
anchor that Phase 3.5 T86 stamps on each turn DOM node so the chat page
scrolls to the originating turn on load. Memory rows projected before
the 0014 migration ran read NULL ``event_id``; the template falls back
to a chat-level link in that case so we never emit ``#turn-None``.

Pre-existing tests that asserted on the bare ``href="/chats/{chat_id}"``
contract are updated to assert on the ``href="/chats/{chat_id}#turn-``
prefix to reflect the new deep-link.
2026-04-27 05:42:17 -04:00

53 lines
2.3 KiB
HTML

{% 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">
{# T111.2: deep-link to the originating turn via the
``id="turn-{event_id}"`` anchor stamped by Phase 3.5 T86.
``event_id`` may be NULL for memory rows projected before the
0014 migration ran (T109 did not backfill historical rows); in
that case fall back to a chat-level link with no anchor so we
never emit ``#turn-None``. #}
<a class="search-result-link"
href="/chats/{{ r.chat_id }}{% if r.event_id %}#turn-{{ r.event_id }}{% endif %}">
<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>
{# T111.1: ``r.snippet`` is the FTS5 ``snippet()`` excerpt with
each match wrapped in ``<mark>...</mark>``. ``|safe`` is
required so the marker tags survive Jinja's auto-escape; the
snippet is built by SQLite from indexed text, so the only
HTML in the string is the ``<mark>`` we configured (any
special chars from the source content are passed through as
literal text, NOT as HTML). This is the only ``|safe`` filter
on the page — chat_id, owner_name, etc. remain auto-escaped. #}
<div class="search-result-summary">{{ r.snippet|safe }}</div>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}