A job that finishes reaches the page you are looking at

Three complaints, all downstream of background commands.

A finished job woke the model and not the browser. `jobs.wake` writes the
completion and calls `generation.ensure`, and nothing tells the page: the only
stream here is per-message, opened by the `sse-connect` on an incomplete
assistant bubble -- which is a bubble this page has not got, because the reply
that created it began somewhere else. `_queue_frames` proves the swap works and
can only ride a stream already open. So the reader sat on the chat, watched the
sidebar dot light up for the chat in front of them, and had to click it or
reload to see a reply that had been there for minutes.

`GET /api/chats/{id}/tail?after=` and a five-second poller is the answer, polled
for the reason `/unread` is: a second always-on connection per tab is a lot of
machinery for something that happens a few times a day. A cursor it cannot place
-- absent, from another chat, naming a row a rewind deleted -- is answered with
204 and never with the transcript, which the page still holds every bubble of.
The cut is read from the row so `_inject`'s restamp moves it too, and compared in
SQL, a row read back from SQLite being naive where one still in the session is
aware; the `id >` tie-break is not decoration, since under a bare `>` a row
sharing the cut's microsecond is skipped for ever.

The cursor comes from the DOM, because the DOM is the honest answer to what the
page has -- the composer's POST, the `done` frame and the last poll all move it,
and a variable would have to be updated by each of them, correctly, for ever. On
`htmx:configRequest` rather than `hx-vals="js:…"`: two of the three things that
handler does are cancellations, which `hx-vals` cannot express. Not
`article.msg:last-of-type` either -- that is per-parent, so on a compacted chat
it answers with the last article inside the `<details>` and the poll re-appends
half the conversation. It is silent while a reply streams, since that reply
delivers its own bubbles in the one frame that can get the order right, and a
`htmx:beforeSwap` listener drops any answer holding a bubble already on the page:
the race `hx-sync` cannot reach, and a duplicate there is a second `sse-connect`
for one message rather than a cosmetic one. The route clears `unread` on every
tick including the 204, because `_persist` marks a reply unread whenever
`followers == 0` and that is true of a job-woken reply with somebody watching it.

The completion also claimed the reader had sent it. The role is load-bearing --
`_inject` sends a queued turn verbatim and `build_messages` must keep seeing a
user turn -- so `Message.machine` marks the bubble instead and the request is
untouched. Their initial, their name and a pencil offering to rewrite what a
machine reported: the route refuses the edit too, a hidden button being a
courtesy. `_completion_text` is deliberately unchanged, `tool.background` quoting
its opening sentence to the model, and there is now a test holding the two
together.

And the panel. `.jobs__row` had no horizontal padding while `.picker__menu` has
none either, so every row ran flush into the border under a header inset by
--sp-3. `jobs__row--open` had been emitted since the panel shipped with no rule
anywhere, so the row whose log was on screen looked like the ones that were not.
The dot was keyed on `status`, and `done` is exit 0 and exit 2 alike -- green
beside the row's own "Failed, exit 2" -- so `JobView.tone` answers the colour and
the template goes on answering the wording, which is the half a class name cannot
carry. `duration` is empty for a running job on purpose: this panel is fetched
when somebody opens it and never polled, so a live figure would freeze the
instant it painted. Its stamps are normalised before subtracting, a job started
before a restart and finished after it having one naive and one aware.

Driven under the DOM stub before committing, per the standing rule: two listeners
on document.body for events dispatched at a requesting element are exactly the
shape a regex cannot check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-05 12:19:07 +02:00
parent 4643d1b584
commit e9fab9d858
14 changed files with 1105 additions and 11 deletions
+99 -1
View File
@@ -12,7 +12,7 @@ from types import SimpleNamespace
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from sqlalchemy import func, select
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import Db, RequiredUser, require_permission
@@ -723,6 +723,91 @@ async def unread_poll(db: Db, user: RequiredUser) -> Response:
return response
@router.get("/{chat_id}/tail")
async def thread_tail(db: Db, user: RequiredUser, chat_id: str, after: str = "") -> Response:
"""Turns this page has not got yet, appended to the transcript it is showing.
A reply can begin without a request from the browser: `jobs.wake` writes a
completion turn and calls `generation.ensure` when a background job finishes
on an idle chat. There is no channel to tell the page about it. The only
stream here is per-message and it is opened by the `sse-connect` on an
incomplete assistant bubble -- a bubble this page does not have, because the
reply that created it started somewhere else. `_queue_frames` proves the swap
works, but it can only ride a stream that is already open.
So the page asks. Polled for the same reason `/unread` is: a second always-on
connection per tab is a great deal of machinery for something that happens a
few times a day. The cursor comes from the browser -- see `app.js`, which
reads the last bubble in `#thread`, the honest answer to what this page
already holds.
"""
chat = _owned_chat(db, chat_id, user.id)
# Somebody is looking at this chat, which is what `unread` means the absence
# of. `_persist` marks a reply unread whenever `generation.followers == 0`,
# and that is true of a job-woken reply even with the reader watching it --
# so today the toast announces a chat that is already on screen. This is
# `pages.chat_detail` said again for as long as the page stays open rather
# than once when it loads, and it is cleared whether or not anything arrived:
# the claim being made is that somebody is here.
#
# Not airtight, and not pretending to be: the sidebar polls on 10s and this
# on 5s, so this usually wins, but a badly timed tick can still raise one
# toast for the chat in front of you.
if chat.unread or chat.unread_notified:
chat.unread = False
chat.unread_notified = False
db.commit()
# No cursor, a cursor from another chat, or one naming a row a rewind has
# since deleted. Answering with the transcript would append a second copy of
# every bubble the page still holds, and a page whose history was rewritten
# underneath it is one only a reload can reconcile -- which is not this
# route's decision to make, with a half-typed message possibly in the box.
cut = db.get(Message, after) if after else None
if cut is None or cut.chat_id != chat.id:
return Response(status_code=status.HTTP_204_NO_CONTENT)
# The cut is read from the row rather than taken as a timestamp on the wire,
# which is what makes `_inject`'s restamp harmless: if the page's last bubble
# was the assistant placeholder and the placeholder moved, the cut moves with
# it. Compared in SQL and never in Python, for the reason `compaction.moment`
# exists -- a row read back from SQLite is naive and one still in the session
# is aware, and `>` between them raises.
#
# The id clause is not decoration. Under a bare `>` a row sharing the cut's
# microsecond is skipped forever; with it, at most the one sorting lower is.
fresh = list(
db.scalars(
select(Message)
.where(
Message.chat_id == chat.id,
or_(
Message.created_at > cut.created_at,
and_(Message.created_at == cut.created_at, Message.id > cut.id),
),
)
.order_by(Message.created_at, Message.id)
)
)
if not fresh:
# 204 and not an empty 200: htmx does not swap on a 204, where an empty
# body would still fire a swap and a settle on every open page every
# five seconds.
return Response(status_code=status.HTTP_204_NO_CONTENT)
# Queued turns come too, unfiltered. A completion waiting behind a running
# reply is exactly what the reader wants to watch arrive, and its bubble can
# never carry `sse-connect` -- `_message.html` requires the assistant role
# for that. When the running reply ends, `_queue_frames` deletes the stale
# node out of band and re-renders it in place, so arriving early costs
# nothing.
#
# No `just_finished`: that flag is what read-aloud-automatically keys off,
# and a bubble the page merely missed must not start talking.
return HTMLResponse("".join(_render_bubble(db, chat, user, row) for row in fresh))
@router.post("/{chat_id}/messages")
async def post_message(
request: Request,
@@ -1230,6 +1315,11 @@ async def edit_form(
message = db.get(Message, message_id)
if message is None or message.chat_id != chat.id or message.role != ROLE_USER:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
# See `edit_message` for why, and for why this is not the same sentence.
if message.machine:
raise HTTPException(
status.HTTP_404_NOT_FOUND, "A background job's message cannot be edited."
)
return templates.TemplateResponse(
request,
@@ -1287,6 +1377,14 @@ async def edit_message(
message = db.get(Message, message_id)
if message is None or message.chat_id != chat.id or message.role != ROLE_USER:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
# The bubble hides the pencil, but a hidden button is a courtesy and this is
# the rule: editing rewinds and re-sends under the reader's own authority,
# and what a machine reported is not theirs to rewrite. Its own sentence,
# because "no longer exists" would be false and would leave nothing to do.
if message.machine:
raise HTTPException(
status.HTTP_404_NOT_FOUND, "A background job's message cannot be edited."
)
content = content.strip()
if not content and not message.attachments:
+10
View File
@@ -333,6 +333,16 @@ class Message(UUIDPrimaryKey, Timestamps, Base):
# of tool calls -- is the only thing that clears it.
queued: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Written by the application rather than by the person whose bubble this
# would otherwise be. `agent/jobs.py:wake` is the one writer: a background
# job finishing is a new turn in the *user* role, and that role is
# load-bearing -- `_inject` sends a queued turn verbatim and `build_messages`
# has to keep seeing a user turn -- but it is not the reader speaking, and
# rendering it under their name with their initial beside it is the
# application putting words in their mouth. Nothing about the request
# changes; only the bubble does.
machine: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
chat: Mapped[Chat] = relationship(back_populates="messages")
attachments: Mapped[list[Attachment]] = relationship( # noqa: F821
back_populates="message",
+71 -3
View File
@@ -42,6 +42,7 @@ import re
import time
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
@@ -381,6 +382,68 @@ class JobView:
def running(self) -> bool:
return self.status == "running"
@property
def tone(self) -> str:
"""What colour this job is, which is not the question `status` answers.
`done` is two outcomes. The row beside the dot already tells them apart
in words -- "Finished" against "Failed, exit 2" -- so a dot keyed on the
status would be green next to a sentence saying the opposite.
The *wording* stays in the template's if-chain rather than moving here
beside the colour. Authored text belongs in the file somebody reads to
change it, and saving one branch is not worth taking five phrases out of
it; this is the half that cannot be said in a class name.
"""
if self.running:
return "running"
if self.status != "done":
return self.status # killed, lost
return "ok" if not self.exit_status else "failed"
@property
def duration(self) -> str:
"""How long it took, once it is over. Empty while it is still running.
Empty on purpose rather than for want of an answer. This panel is
fetched when somebody opens it and is never polled -- the chip beside
the composer is what refreshes on a timer -- so a live "running for
2m 05s" would be stale the instant it painted and stay stale until the
reader pressed something. The chip says something is still going; this
says how long the finished ones took, which is true forever.
Both stamps are normalised before subtracting, for the reason
`compaction.moment` normalises: SQLite stores no offset, so a row read
back from disk is naive while one still in the session's identity map
keeps its tzinfo, and subtracting one from the other raises. `moment`
itself is not reused because it takes a `Message`, not a stamp.
"""
if self.running or self.started_at is None or self.finished_at is None:
return ""
seconds = (_aware(self.finished_at) - _aware(self.started_at)).total_seconds()
return _short_duration(seconds) if seconds >= 0 else ""
def _aware(stamp: datetime) -> datetime:
"""A stamp that can be subtracted from another. See `JobView.duration`."""
return stamp if stamp.tzinfo is not None else stamp.replace(tzinfo=UTC)
def _short_duration(seconds: float) -> str:
"""A wall-clock span, at the precision somebody reading a log cares about.
Deliberately not `steps._short_duration`. That one takes milliseconds, tops
out at minutes and is tuned to a label repainting beside an animating word;
a three-hour build through it reads `184m 12s`. This one is written for a
span that can be hours and is only ever rendered once it is final.
"""
total = int(seconds)
if total < 60:
return f"{total}s"
if total < 3600:
return f"{total // 60}m {total % 60:02d}s"
return f"{total // 3600}h {(total % 3600) // 60:02d}m"
def listing(db, chat_id: str) -> list[JobView]:
"""Every job this chat has, newest first.
@@ -453,8 +516,6 @@ def clear() -> None:
# chat, a transient database hiccup) still runs and is still tracked in-process;
# it just will not survive a restart, which is the row's only purpose.
def _persist_row(job: JobState) -> None:
from datetime import UTC, datetime
from lembas.db.models import Job
from lembas.db.session import session_scope
@@ -614,7 +675,14 @@ async def wake(
chat = db.get(Chat, chat_id)
if chat is None:
return
chat_service.create_message(db, chat, ROLE_USER, content, queued=running)
# `machine` changes the bubble and nothing else: the role stays
# `user` because `_inject` sends a queued turn verbatim and the
# request must carry a user turn, and the framing the *model*
# reads is in the words `_completion_text` wrote. What it stops
# is the transcript claiming the reader typed this.
chat_service.create_message(
db, chat, ROLE_USER, content, queued=running, machine=True
)
if not running:
assistant = chat_service.create_message(
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
+2
View File
@@ -530,6 +530,7 @@ def create_message(
complete_: bool = True,
model_id: str = "",
queued: bool = False,
machine: bool = False,
) -> Message:
message = Message(
chat_id=chat.id,
@@ -538,6 +539,7 @@ def create_message(
complete=complete_,
model_id=model_id,
queued=queued,
machine=machine,
)
db.add(message)
db.commit()
+54 -2
View File
@@ -740,6 +740,30 @@
.msg:focus-within .msg__actions { opacity: 1; }
.msg__actions .is-copied { color: var(--success); }
/* --- A turn nobody typed ---------------------------------------------------
A background job finishing is a new turn in the user role, because that is
what the request needs it to be -- but the bubble is not the reader's, and it
must not be wearing their initial or their name. Sunken and quiet rather than
the user bubble's raised fill: this is a report, not something somebody said.
`.msg--user.msg--machine` is a compound out of necessity, not for emphasis:
`.msg--user .msg__body--plain` above already sets `--bubble-user`, and one
class cannot beat two without matching its specificity.
The body font stays the page's. The first lines are a sentence and only the
tail is a log, so setting the whole block in mono to suit the log makes the
sentence worse without making the log better. */
.msg--machine .msg__gutter { background: var(--surface-active); color: var(--ink-faint); }
.msg--machine .msg__author { color: var(--ink-muted); font-weight: 500; }
.msg--user.msg--machine .msg__body--plain {
background: var(--bg-sunken);
border-inline-start: 2px solid var(--border-strong);
border-start-start-radius: var(--radius-sm);
border-end-start-radius: var(--radius-sm);
color: var(--ink-muted);
font-size: var(--text-sm);
}
/* --- A turn that is waiting to be sent ------------------------------------
Its actions do not fade in on hover like the others: they are the only way
to withdraw something that has not happened yet, and a control you have to
@@ -1086,8 +1110,29 @@
.picker__menu--jobs { width: min(34rem, 92vw); }
.jobs__row { padding: var(--sp-2) 0; border-bottom: 1px solid var(--border); }
/* Inset to match `.picker__group` and `.picker__lede` above them. The menu has
no padding of its own (app.css), so a row with none ran flush into the border
while the header over it sat --sp-3 in. The padding goes inside the row and
the border stays on it, so the divider is still full-bleed -- which is what
makes a stack of rows read as a list rather than as paragraphs. */
.jobs__row { padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); }
.jobs__row:last-child { border-bottom: 0; }
/* Which row's log is on screen. An inset shadow rather than a
`border-inline-start`, which would take its 2px out of the row's width and
shift the open row's text against every closed one above it. */
.jobs__row--open { background: var(--surface-active); box-shadow: inset 2px 0 0 var(--accent); }
/* The whole row lights up, driven by the one thing in it you can press. The
command button spans the row's content but not its padding, so hovering the
band between two rows would otherwise light nothing. `:has()` is already how
`.interaction__option` follows its own input.
No `:focus-visible` rule here, and that is not an omission: app.css gives
every focusable element an accent outline, and a second one written locally
is a copy that drifts. */
.jobs__row:has(.jobs__command:hover) { background: var(--surface-hover); }
.jobs__head { display: flex; align-items: center; gap: var(--sp-2); }
.jobs__dot {
@@ -1097,9 +1142,15 @@
border-radius: var(--radius-full);
background: var(--ink-muted);
}
/* Keyed on `JobView.tone`, not on `status`: `done` is both exit 0 and exit 2.
`lost` is muted rather than red to match what the row says in words -- the
host rebooted or /tmp was cleared, so nothing failed and we simply cannot say
how it ended. */
.jobs__dot--running { background: var(--accent); }
.jobs__dot--ok { background: var(--success); }
.jobs__dot--failed { background: var(--danger); }
.jobs__dot--killed { background: var(--warning); }
.jobs__dot--lost { background: var(--danger); }
.jobs__dot--lost { background: var(--ink-muted); }
/* The command is the button: the row is wide, the affordance should be too.
`min-width: 0` or the flex item will not shrink below its content and the
@@ -1122,6 +1173,7 @@
white-space: nowrap;
font-size: var(--text-xs);
}
.jobs__command:hover code { color: var(--accent); }
.jobs__stop { flex: none; }
.jobs__meta {
+74
View File
@@ -752,6 +752,80 @@
scrollThread(false);
});
/* --- The transcript tail -----------------------------------------------
A reply can begin without a request from this page: a background job
finishing wakes the chat server-side. There is no chat-level channel to
hear about it on -- the only stream is per-message, and it is opened by a
bubble this page has not got. So `#thread-tail` polls, and this is where it
is told what the page already holds.
The cursor is read from the DOM rather than from a variable rendered into
the page, because the DOM is the honest answer to that question. Every path
that appends a bubble moves it -- the composer's own POST, the `done`
frame's out-of-band swaps, the last poll -- and a variable would have to be
updated by each of them, correctly, forever.
`htmx:configRequest` and not `hx-vals="js:…"`: two of the three things here
are *cancellations*, which `hx-vals` cannot express, and splitting the read
from the cancellations would put one decision in two files. (It is also the
only string htmx would ever be handed to evaluate in this project, and it
would die silently under a CSP.) */
document.body.addEventListener("htmx:configRequest", function (event) {
var elt = (event.detail && event.detail.elt) || event.target;
if (!elt || elt.id !== "thread-tail") return;
var thread = document.getElementById("thread");
if (!thread) return event.preventDefault();
/* Quiet while this page is following a reply. That reply delivers its own
bubbles through the `done` frame, which is the only channel that can get
the *order* right -- and it is the one window in which the transcript's
order moves underneath us, since `_inject` restamps the placeholder to
sort after a prompt taken into it. Asking during it is how a page appends
a bubble it already has.
Exact rather than approximate: a page holding an incomplete assistant
bubble always carries this attribute, which is the state machine
`_message.html` documents. */
if (thread.querySelector("[sse-connect]")) return event.preventDefault();
/* Deliberately not `article.msg:last-of-type`. That is per-parent, and
`querySelector` returns the first match in document order -- so on a
compacted chat it answers with the last article inside
`<details class="compacted">` rather than the newest message. The last of
everything matching is what "the last bubble this page holds" means. */
var articles = thread.querySelectorAll("article.msg");
var last = articles.length ? articles[articles.length - 1] : null;
if (!last || last.id.indexOf("msg-") !== 0) return event.preventDefault();
event.detail.parameters.after = last.id.slice(4);
});
/* Whatever the reason -- the composer's POST committing between this request
going out and its answer coming back, a `done` frame landing first -- if any
bubble in the answer is already on the page then the page has moved on since
the question was asked, and swapping would duplicate it. A duplicate here is
not cosmetic: it would carry a second `sse-connect` for one message.
This is the race `hx-sync` cannot reach, since the two requests come from
different elements. The whole answer is dropped rather than filtered: the
next poll is five seconds away and recomputes its cursor from a DOM that has
settled, which is a correct page one tick late instead of a wrong one now. */
document.body.addEventListener("htmx:beforeSwap", function (event) {
var elt = (event.detail && event.detail.elt) || event.target;
if (!elt || elt.id !== "thread-tail" || !event.detail) return;
var seen = /\bid="(msg-[^"]+)"/g;
var body = event.detail.serverResponse || "";
var match;
while ((match = seen.exec(body)) !== null) {
if (document.getElementById(match[1])) {
event.detail.shouldSwap = false;
return;
}
}
});
/* Tokens arriving over SSE are appended outside the normal swap cycle.
Narrowed to frames that land in the thread. `metrics`, `status`, `ask` and
@@ -22,7 +22,10 @@
{% for job in jobs %}
<div class="jobs__row{{ ' jobs__row--open' if open_job == job.id }}">
<div class="jobs__head">
<span class="jobs__dot jobs__dot--{{ job.status }}"
{# `tone`, not `status`: `done` covers exit 0 and exit 2 alike, and a green
dot beside the "Failed, exit 2" below would be the dot contradicting the
sentence. The words stay here; only the colour is decided in Python. #}
<span class="jobs__dot jobs__dot--{{ job.tone }}"
title="{{ job.status }}{% if job.exit_status is not none %} ({{ job.exit_status }}){% endif %}"></span>
{# The whole command in the title, a truncated one on screen. A command line
@@ -61,6 +64,9 @@
Finished
{% endif %}
{% if job.started_at %}· started {{ job.started_at.strftime("%H:%M") }}{% endif %}
{# Only ever on a finished job -- see JobView.duration for why a running one
says nothing rather than saying something frozen. #}
{% if job.duration %} · took {{ job.duration }}{% endif %}
</p>
{% if open_job == job.id %}
+38 -2
View File
@@ -20,8 +20,17 @@
second concurrent reply the queue exists to prevent.
#}
{% set queued = (message.role == "user" and message.queued) %}
{#
Written by the application, in the user role, and not by the person whose
bubble this would otherwise be -- a background job reporting that it finished.
The wire role is deliberately unchanged (see Message.machine), so this is the
only place in the whole request the difference exists. `msg--user` stays on the
article as well, so the bubble keeps the layout it already had and
`msg--machine` only overrides what should differ.
#}
{% set machine = (message.role == "user" and message.machine) %}
<article class="msg msg--{{ message.role }}{{ ' msg--queued' if queued }}"
<article class="msg msg--{{ message.role }}{{ ' msg--machine' if machine }}{{ ' msg--queued' if queued }}"
id="msg-{{ message.id }}"
{% if streaming %}
hx-ext="sse"
@@ -39,6 +48,12 @@
{% else %}
{{ mark(cls="msg__mark", uid="m" ~ message.id) }}
{% endif %}
{% elif machine %}
{# `terminal` rather than `clock`: clock already means "waiting" twice in
this interface -- the jobs chip and the "Waiting to be sent" note -- and
this turn is neither. The gutter is aria-hidden; the header below is
what carries the meaning to a screen reader. #}
{{ icon("terminal", "icon--sm") }}
{% else %}
<span class="msg__initial">{{ (user.name or "?")[0]|upper }}</span>
{% endif %}
@@ -49,6 +64,8 @@
<span class="msg__author">
{% if message.role == "assistant" %}
{{ speaking_model.label if speaking_model else "LLeMbas" }}
{% elif machine %}
Background job
{% else %}
{{ user.name or "You" }}
{% endif %}
@@ -194,6 +211,14 @@
{% if message.stopped %}
<p class="msg__note">{{ icon("x", "icon--sm") }} Stopped. This reply is cut short.</p>
{% endif %}
{% elif machine and message.content %}
{# Jinja's own escaping, not `tokens`: that filter marks `@name` as a
reference to this reader's files and people, and an `@` inside a
command line or a log is neither. Nothing here goes through
services/markdown.py either -- the fence in the content stays a fence
on screen, which is the rule `_jobs_panel.html` states about the log
it shows for the same reason: it came off somebody else's machine. #}
<div class="msg__body msg__body--plain">{{ message.content }}</div>
{% elif message.content %}
{# `tokens` escapes and then marks up: @mentions read as references
rather than as punctuation. It must stay `pre-wrap` -- the newlines
@@ -226,6 +251,13 @@
a pencil. Discard and retype is the honest affordance. #}
<footer class="msg__actions msg__actions--queued">
<span class="msg__note">{{ icon("clock", "icon--sm") }} Waiting to be sent</span>
{# Copy is here as well as on a delivered turn: a queued machine event
holds a job's output, which is the one waiting bubble somebody actually
wants to paste somewhere. The hidden source div is already below. #}
<button class="btn btn--icon btn--sm" type="button"
data-copy="msg-body-{{ message.id }}" aria-label="Copy message">
{{ icon("copy", "icon--sm") }}
</button>
<button class="btn btn--sm" type="button"
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/send-now"
hx-target="#thread" hx-swap="innerHTML">Send now</button>
@@ -241,7 +273,11 @@
data-copy="msg-body-{{ message.id }}" aria-label="Copy message">
{{ icon("copy", "icon--sm") }}
</button>
{% if message.role == "user" %}
{# Not on a machine event: editing rewinds the transcript and starts a
reply from the edited words, so a pencil here offers to rewrite what a
machine reported and re-send it under the reader's own authority. The
route refuses it too -- this only removes the button. #}
{% if message.role == "user" and not machine %}
<button class="btn btn--icon btn--sm" type="button"
hx-get="/api/chats/{{ chat.id }}/messages/{{ message.id }}/edit"
hx-target="#msg-{{ message.id }}" hx-swap="outerHTML"
+28
View File
@@ -324,6 +324,34 @@
</div>
</div>
{% if chat %}
{#
Where a reply that started outside a request arrives. A background job
finishing wakes the chat server-side and there is no channel to say so:
the only stream is per-message, opened by a bubble this page has not got.
See `GET /api/chats/{id}/tail`, and `app.js` for the cursor -- which is
read from the DOM on `htmx:configRequest`, not rendered here, because the
DOM is the honest answer to what this page already holds.
OUTSIDE `#thread`, not in it: a rewind and a compaction both swap
`chat/_thread.html` into that container with `innerHTML`, and a poller
living inside would be swapped away by the first one and never fire
again. OUTSIDE the composer's form as well, and it names its own target
regardless -- htmx inherits `hx-target`, the form carries `#thread`, and
the jobs chip has already demonstrated once what an unstated target does
to a transcript.
`hx-sync="this:drop"` because a poll fires whether or not the last one
has come back, and two answers computed from the same cursor are two
copies of one bubble.
#}
<div hidden id="thread-tail"
hx-get="/api/chats/{{ chat.id }}/tail"
hx-trigger="every 5s"
hx-target="#thread" hx-swap="beforeend"
hx-sync="this:drop"></div>
{% endif %}
{% include "chat/_composer.html" %}
{% endif %}
</main>