refactor: convert status updates to Jinjax component architecture

Replaced raw HTML string concatenation with proper Jinjax components
following the project's established component patterns.

Component Architecture:
- StatusItem.jinja: Individual status message component
  * Accepts: message, is_last, result, use_oob
  * Determines status type (error/loading/complete/info) automatically
  * Renders with appropriate icons and styling
  * Optional HTMX OOB swap for dynamic appending

- StatusTimeline.jinja: Container component for status items
  * Provides semantic HTML structure with ARIA labels
  * Optional HTMX OOB swap for initial insertion
  * Children appended via HTMX to #status-timeline

Backend Changes:
- Removed raw HTML string templates (anti-pattern)
- Use catalog.render() for all component rendering
- First poll: Render StatusTimeline + StatusItem components with OOB
- Subsequent polls: Render only new StatusItem components with OOB
- Clean separation of concerns (backend = logic, Jinjax = presentation)

Frontend Changes:
- Changed #status div to hx-swap="none" (all updates via OOB)
- Removed old status.html and status_item.html templates
- StatusItem and StatusTimeline now in components/snippets/

Benefits:
- No raw HTML in Python code
- Reusable Jinjax components
- Consistent with project patterns
- Easier to maintain and test
- Clean separation of presentation and logic

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 14:36:47 -05:00
parent 664cbca4b8
commit ce76f958f7
5 changed files with 71 additions and 70 deletions

View File

@ -94,47 +94,64 @@ async def analyze_url(
@user_frontend.get("/status", response_class=HTMLResponse)
async def get_status(request: Request):
"""
Returns new status messages since last poll (incremental updates)
First poll returns all messages + container, subsequent polls return
only new items with HTMX OOB swap
Returns new status messages since last poll using pure OOB swaps.
First poll returns the container + initial messages as OOB.
Subsequent polls return only new messages as OOB.
Uses Jinjax components for rendering.
:param request:
:return:
"""
session_id = request.session.get("analysis_id")
if not session_id:
return templates.TemplateResponse(
"status.html", {"request": request, "messages": [], "result": False}
)
return HTMLResponse(content="", status_code=200)
all_messages = status_store.get(session_id, [])
last_index = last_message_index.get(session_id, 0)
result = ANALYSIS_COMPLETE_MESSAGE in all_messages
# First poll: return full container with all messages
if last_index == 0:
last_message_index[session_id] = len(all_messages)
return templates.TemplateResponse(
"status.html",
{"request": request, "messages": all_messages, "result": result},
)
# No new messages, return empty response
if last_index > 0 and last_index >= len(all_messages):
return HTMLResponse(content="", status_code=200)
# Subsequent polls: return only new messages with OOB swap
# Determine which messages to send
new_messages = all_messages[last_index:]
last_message_index[session_id] = len(all_messages)
if not new_messages:
# No new messages, return empty response
return HTMLResponse(content="", status_code=200)
# Render new items with OOB swap
# First poll: return container + messages with OOB
if last_index == 0:
# Render StatusTimeline container with OOB
container_html = catalog.render("StatusTimeline", use_oob=True)
# Render all initial StatusItem components with OOB
items_html = ""
for idx, message in enumerate(new_messages):
is_last = idx == len(new_messages) - 1
item = catalog.render(
"StatusItem",
message=message,
is_last=is_last,
result=result,
use_oob=True,
)
items_html += item
return HTMLResponse(
content=container_html + items_html, status_code=200
)
# Subsequent polls: return only new StatusItem components with OOB swap
items_html = ""
for idx, message in enumerate(new_messages):
is_last = idx == len(new_messages) - 1
item = templates.get_template("status_item.html").render(
item = catalog.render(
"StatusItem",
message=message,
is_last=is_last,
result=result,
request=request,
use_oob=True,
)
items_html += item

View File

@ -1,14 +1,24 @@
{# Single status item template for HTMX OOB swaps #}
{# def
message: str,
is_last: bool = False,
result: bool = False,
use_oob: bool = True
#}
{# Determine status type and styling #}
{% set is_error = message.startswith('Error:') %}
{% set is_loading = is_last and not result %}
{% set is_tool_message = message.startswith('Using tool') %}
{% set is_complete = "complete" in message.lower() or "done" in message.lower() %}
{% set status_label = 'Error' if is_error else ('Complete' if is_complete else ('In Progress' if is_loading else ('Processing' if is_tool_message else 'Status'))) %}
{% set status_class = 'status-item--error' if is_error else ('status-item--success' if is_complete else ('status-item--loading' if is_loading else 'status-item--info')) %}
<article class="status-item {% if is_error %}status-item--error{% elif is_complete %}status-item--success{% elif is_loading %}status-item--loading{% else %}status-item--info{% endif %}"
{# Render status item with optional OOB swap #}
<article class="status-item {{ status_class }}"
role="article"
aria-label="{{ status_label }}: {{ message }}"
hx-swap-oob="beforeend:#status-timeline">
{% if use_oob %}hx-swap-oob="beforeend:#status-timeline"
{% endif %}>
<div class="status-item__indicator"
aria-hidden="true">
{% if is_error %}

View File

@ -0,0 +1,22 @@
{# def
use_oob: bool = True
#}
{# Status timeline container - can be rendered with or without OOB swap #}
<section class="section"
id="status-container"
role="region"
aria-label="Analysis Progress"
aria-live="polite"
{% if use_oob %}hx-swap-oob="beforeend:#status"
{% endif %}>
<div class="container"
style="max-width: 800px;">
<div class="status-timeline"
id="status-timeline"
role="feed"
aria-label="Status updates">
{# Child StatusItem components will be appended here via HTMX OOB swaps #}
</div>
</div>
</section>

View File

@ -108,7 +108,7 @@
id="status"
hx-get={{ url_for('get_status') }}
hx-trigger="every 1s[!document.querySelector('#result-container')]"
hx-swap="innerHTML"
hx-swap="none"
@htmx:after-request="
const response = $event.detail.xhr.response.trim();
if (response && response.length > 0) {

View File

@ -1,48 +0,0 @@
{% if messages %}
<section class="section"
id="status-container"
role="region"
aria-label="Analysis Progress"
aria-live="polite">
<div class="container"
style="max-width: 800px;">
<div class="status-timeline"
id="status-timeline"
role="feed"
aria-label="Status updates">
{% for message in messages %}
{% set is_error = message.startswith('Error:') %}
{% set is_loading = loop.last and not result %}
{% set is_tool_message = message.startswith('Using tool') %}
{% set is_complete = "complete" in message.lower() or "done" in message.lower() %}
{% set status_label = 'Error' if is_error else ('Complete' if is_complete else ('In Progress' if is_loading else ('Processing' if is_tool_message else 'Status'))) %}
<article class="status-item {% if is_error %}status-item--error{% elif is_complete %}status-item--success{% elif is_loading %}status-item--loading{% else %}status-item--info{% endif %}"
role="article"
aria-label="{{ status_label }}: {{ message }}">
<div class="status-item__indicator"
aria-hidden="true">
{% if is_error %}
<i class="fas fa-circle-xmark"></i>
{% elif is_complete %}
<i class="fas fa-circle-check"></i>
{% elif is_loading %}
<i class="fas fa-circle-notch fa-spin"></i>
{% else %}
<i class="fas fa-circle"></i>
{% endif %}
</div>
<div class="status-item__content">
<div class="status-item__header">
{{ status_label }}
</div>
<div class="status-item__message">
{{ message }}
</div>
</div>
</article>
{% endfor %}
</div>
</div>
</section>
{% endif %}