diff --git a/chat/static/app.css b/chat/static/app.css
index 86d8b72..66e76ef 100644
--- a/chat/static/app.css
+++ b/chat/static/app.css
@@ -113,3 +113,13 @@ code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
.memory-list li { padding: 4px 0; font-size: 13px; }
.sig { display: inline-block; min-width: 16px; }
.sig-3 { color: #d4af37; }
+/* Streaming UX (T34): typing indicator, Stop button, disconnect banner. */
+.streaming { opacity: 0.85; }
+.streaming-text:after {
+ content: "\025AE";
+ margin-left: 2px;
+ animation: blink 1s steps(2, start) infinite;
+}
+@keyframes blink { to { visibility: hidden; } }
+.stop-streaming { background: #c33; border-color: #a00; margin-bottom: 8px; align-self: flex-start; }
+.connection-lost { margin-bottom: 8px; }
diff --git a/chat/templates/chat.html b/chat/templates/chat.html
index 0cadfd6..4d9d24b 100644
--- a/chat/templates/chat.html
+++ b/chat/templates/chat.html
@@ -46,4 +46,107 @@ document.querySelector('.drawer-toggle')?.addEventListener('click', (e) => {
e.target.setAttribute('aria-expanded', String(isHidden));
});
+
{% endblock %}
diff --git a/chat/web/turns.py b/chat/web/turns.py
index 4105704..7a7db0e 100644
--- a/chat/web/turns.py
+++ b/chat/web/turns.py
@@ -58,6 +58,14 @@ from chat.web.render import render_turn_html as _render_turn_html
router = APIRouter()
+# Module-level registry of in-flight streaming tasks, keyed by chat_id.
+# The POST /chats//turns/cancel route looks up the task and calls
+# .cancel(); the streaming coroutine in post_turn catches the resulting
+# CancelledError, commits the partial as truncated, and unregisters.
+# Single-process v1 only — sufficient for one user with multiple tabs.
+_in_flight_tasks: dict[str, asyncio.Task] = {}
+
+
def _strip_ooc_for_prompt(parsed: ParsedTurn) -> str:
"""Concatenate non-OOC segments back to a prose string for the prompt.
@@ -70,16 +78,17 @@ def _strip_ooc_for_prompt(parsed: ParsedTurn) -> str:
def _read_recent_dialogue(conn, chat_id: str, limit: int = 200) -> list[dict]:
- """Return ``user_turn`` and ``assistant_turn`` events for ``chat_id``.
+ """Return user-side and assistant_turn events for ``chat_id``.
- Ordered oldest-first. Skips superseded and hidden rows so regenerated
- turns (T29) drop out of the rendered timeline. Each entry is shaped
- ``{"speaker": , "text": }`` for the prompt
- assembler and the chat-detail template.
+ Includes ``user_turn``, ``user_turn_edit`` (T29 edited prose), and
+ ``assistant_turn``. Ordered oldest-first; superseded/hidden rows are
+ skipped so regenerated turns (T29) drop out of the rendered timeline.
+ Each entry is shaped ``{"speaker": , "text": }``
+ for the prompt assembler and the chat-detail template.
"""
cur = conn.execute(
"SELECT id, kind, payload_json FROM event_log "
- "WHERE kind IN ('user_turn', 'assistant_turn') "
+ "WHERE kind IN ('user_turn', 'user_turn_edit', 'assistant_turn') "
" AND superseded_by IS NULL AND hidden = 0 "
"ORDER BY id DESC LIMIT ?",
(limit,),
@@ -91,7 +100,9 @@ def _read_recent_dialogue(conn, chat_id: str, limit: int = 200) -> list[dict]:
p = json.loads(payload_json)
if p.get("chat_id") != chat_id:
continue
- if kind == "user_turn":
+ if kind in ("user_turn", "user_turn_edit"):
+ # Edited prose substitutes for the original user_turn (the
+ # original is marked superseded_by and filtered above).
out.append({"speaker": "you", "text": p.get("prose", "")})
else:
out.append(
@@ -173,11 +184,16 @@ async def post_turn(
budget_hard=settings.narrative_budget_hard,
)
- # 5. Stream and accumulate tokens.
+ # 5. Stream and accumulate tokens. The stream runs as a Task so the
+ # /turns/cancel route can invoke ``Task.cancel()`` to abort it
+ # mid-stream. ``accumulated`` is a closure over the inner coroutine,
+ # so when the await on ``stream_task`` raises CancelledError below
+ # we still see whatever tokens were appended before cancellation.
accumulated: list[str] = []
truncated = False
cancelled = False
- try:
+
+ async def _stream() -> None:
async for chunk in client.stream(
messages, model=settings.narrative_model
):
@@ -190,6 +206,11 @@ async def post_turn(
"speaker_id": host_bot["id"],
},
)
+
+ stream_task = asyncio.create_task(_stream())
+ _in_flight_tasks[chat_id] = stream_task
+ try:
+ await stream_task
except asyncio.CancelledError:
# Preserve the partial output before letting the cancellation
# propagate so the transcript reflects what the user actually saw.
@@ -198,6 +219,9 @@ async def post_turn(
except Exception:
# Surface as a truncated turn rather than losing the partial output.
truncated = True
+ finally:
+ # Always unregister so a subsequent turn can register a fresh task.
+ _in_flight_tasks.pop(chat_id, None)
full_text = "".join(accumulated)
@@ -403,6 +427,29 @@ async def post_turn(
return Response(status_code=204)
+# ---------------------------------------------------------------------------
+# Cancel route (Task 34).
+#
+# Fire-and-forget: the Stop button POSTs here, we mark the in-flight
+# streaming Task as cancelled, and return 204 immediately. The cancel
+# propagates into the streaming coroutine on its next await, the
+# CancelledError handler in ``post_turn`` catches it, and the partial
+# is committed with ``truncated=True``. No body is needed — the SSE
+# channel is the conveyor of state. If no turn is in flight (or the
+# task already completed), we 204 silently so the client can fire the
+# Stop button without a precondition check.
+# ---------------------------------------------------------------------------
+
+
+@router.post("/chats/{chat_id}/turns/cancel")
+async def cancel_turn(chat_id: str, request: Request):
+ task = _in_flight_tasks.get(chat_id)
+ if task is None or task.done():
+ return Response(status_code=204)
+ task.cancel()
+ return Response(status_code=204)
+
+
# ---------------------------------------------------------------------------
# Rewind routes (Task 28).
#
diff --git a/tests/test_streaming_ux.py b/tests/test_streaming_ux.py
new file mode 100644
index 0000000..45bf773
--- /dev/null
+++ b/tests/test_streaming_ux.py
@@ -0,0 +1,176 @@
+"""Streaming UX tests (T34): cancel route, recent-dialogue user_turn_edit
+inclusion, and the chat-shell embeds the streaming JS hooks.
+
+The cancel route is exercised at the no-op level only — the full mid-stream
+cancel path is covered indirectly by T19's CancelledError handling. We
+verify here that the route itself is registered and silently 204s when no
+in-flight task exists, since the JS Stop button fires unconditionally.
+
+The user_turn_edit inclusion test is the T29 follow-up fix: without it,
+the original user_turn drops out of the timeline (correctly) but the
+edited prose never lands (incorrectly), so the rendered chat detail is
+missing the user's most recent words.
+"""
+
+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
+
+
+@pytest.fixture
+def client(tmp_path, monkeypatch):
+ cfg = tmp_path / "config.toml"
+ cfg.write_text('featherless_api_key = "test"\n')
+ monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
+ db = tmp_path / "test.db"
+ monkeypatch.setenv("CHAT_DB_PATH", str(db))
+ with TestClient(app) as c:
+ # Disable the lifespan-managed background worker so it doesn't
+ # try to score significance through Featherless with the fake key.
+ worker = getattr(app.state, "background_worker", None)
+ if worker is not None:
+ worker.enabled = False
+ yield c
+
+
+def _seed_chat(
+ db_path: Path,
+ bot_id: str = "bot_a",
+ chat_id: str = "chat_bot_a",
+) -> None:
+ """Seed a bot + chat with the activity rows the prompt assembler expects."""
+ with open_db(db_path) as conn:
+ append_event(
+ conn,
+ kind="bot_authored",
+ payload={
+ "id": bot_id,
+ "name": "BotA",
+ "persona": "...",
+ "voice_samples": [],
+ "traits": [],
+ "backstory": "",
+ "initial_relationship_to_you": "",
+ "kickoff_prose": "...",
+ },
+ )
+ 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="edge_update",
+ payload={
+ "source_id": bot_id,
+ "target_id": "you",
+ "chat_id": chat_id,
+ },
+ )
+ append_event(
+ conn,
+ kind="edge_update",
+ payload={
+ "source_id": "you",
+ "target_id": bot_id,
+ "chat_id": chat_id,
+ },
+ )
+ append_event(
+ conn,
+ kind="activity_change",
+ payload={
+ "entity_id": "you",
+ "posture": "sitting",
+ "action": {"verb": "talking"},
+ },
+ )
+ append_event(
+ conn,
+ kind="activity_change",
+ payload={
+ "entity_id": bot_id,
+ "posture": "sitting",
+ "action": {"verb": "listening"},
+ },
+ )
+ project(conn)
+
+
+def test_cancel_route_no_op_when_no_in_flight(client, tmp_path):
+ """Hitting cancel with nothing streaming returns 204 silently."""
+ _seed_chat(tmp_path / "test.db")
+ response = client.post("/chats/chat_bot_a/turns/cancel")
+ assert response.status_code == 204
+
+
+def test_user_turn_edit_appears_in_recent_dialogue(client, tmp_path):
+ """The chat-detail timeline includes a user_turn_edit's prose.
+
+ Original user_turn is superseded by the edit, so it drops out, but
+ the edit's prose should render in its place.
+ """
+ db_path = tmp_path / "test.db"
+ _seed_chat(db_path)
+ with open_db(db_path) as conn:
+ ut_id = append_event(
+ conn,
+ kind="user_turn",
+ payload={
+ "chat_id": "chat_bot_a",
+ "prose": "OriginalUserText",
+ "segments": [],
+ },
+ )
+ edit_id = append_event(
+ conn,
+ kind="user_turn_edit",
+ payload={
+ "chat_id": "chat_bot_a",
+ "prose": "EditedUserText",
+ "supersedes_user_turn_id": ut_id,
+ },
+ )
+ conn.execute(
+ "UPDATE event_log SET superseded_by = ? WHERE id = ?",
+ (edit_id, ut_id),
+ )
+ conn.commit()
+ # No project() call — user_turn / user_turn_edit have no projector
+ # handlers (transcript-only kinds), and re-projecting would replay
+ # chat_created and trip its UNIQUE constraint.
+
+ response = client.get("/chats/chat_bot_a")
+ assert response.status_code == 200
+ body = response.text
+ assert "EditedUserText" in body
+ # The original (now-superseded) prose must not render.
+ assert "OriginalUserText" not in body
+
+
+def test_chat_html_includes_stop_streaming_script(client, tmp_path):
+ """The chat shell embeds the streaming-JS hooks (Stop button + send-lock)."""
+ _seed_chat(tmp_path / "test.db")
+ response = client.get("/chats/chat_bot_a")
+ assert response.status_code == 200
+ body = response.text
+ # Either the CSS class for the Stop button or the JS state flag must
+ # appear in the embedded script — both are load-bearing for T34.
+ assert "stop-streaming" in body or "isStreaming" in body
+ # Cancel route reference must be wired so the Stop button can call it.
+ assert "/turns/cancel" in body