feat(chat): stop an in-flight answer — Send becomes Stop, the partial is kept and persisted, the model stream is torn down

This commit is contained in:
2026-08-29 17:27:04 -04:00
parent 6bf7f456d4
commit 1a60ecbd8b
186 changed files with 1738 additions and 7796 deletions
+19 -10
View File
@@ -9,7 +9,8 @@ Determinism comes from two mock-LLM behaviors (tests/e2e/mock_llm.py):
* ``pretend to think slowly`` in the user message → a 3s warm-up before
the first token, wide enough to assert the pre-token UI (typing dots +
busy "Thinking…" button) at a known timestamp;
the enabled Stop button — phase 48 revised the busy-button contract) at
a known timestamp;
* the ``POST /__shutdown__`` hook → the ``llm_down`` fixture stops the
shared mock to simulate an LLM outage, then restores a fresh instance
on the same port so later tests keep working.
@@ -205,8 +206,10 @@ def test_typing_indicator_during_slow_think(
def test_button_state_machine(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""AC1/AC3: disabled + spinner + 'Thinking…' while in flight; enabled
+ 'Send' + focused input after done."""
"""AC1/AC3 (phase 48 revised contract, owner-locked 2026-08-29):
in flight the button is the enabled Stop control — "Stop" label,
.is-stop class, spinner hidden; after done it is enabled + "Send"
(class removed) + the input is focused back."""
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
@@ -216,15 +219,17 @@ def test_button_state_machine(
btn = page.locator("#send-btn")
label = page.locator("#send-label")
expect(btn).to_be_disabled(timeout=500)
expect(label).to_have_text("Thinking…")
expect(btn.locator(".spinner")).to_be_visible()
expect(btn).to_be_enabled(timeout=500)
expect(label).to_have_text("Stop")
expect(btn).to_have_class(re.compile(r"is-stop"))
expect(btn.locator(".spinner")).to_be_hidden()
expect(page.locator("#send-status")).to_contain_text("thinking")
# Done: button recovers and the input is focused back.
# Done: button recovers to the Send state and the input is focused back.
page.locator(ANSWER).wait_for(state="visible", timeout=30_000)
expect(label).to_have_text("Send", timeout=30_000)
expect(btn).to_be_enabled()
expect(btn).not_to_have_class(re.compile(r"is-stop"))
expect(btn.locator(".spinner")).to_be_hidden()
expect(page.locator("#message-input")).to_be_focused()
@@ -266,8 +271,10 @@ def test_streaming_appends_live(
def test_reduced_motion_keeps_feedback(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""AC7: under prefers-reduced-motion the dots/spinner stay visible
(static/slower) — feedback is never removed, only calmed."""
"""AC7: under prefers-reduced-motion the typing dots stay visible
(static) — feedback is never removed, only calmed. Phase 48: the
in-flight button is the enabled Stop control (the spinner is no
longer part of the in-flight feedback)."""
_reset_db(mock_llm, seed=True)
page.emulate_media(reduced_motion="reduce")
page.set_default_timeout(30_000)
@@ -278,7 +285,9 @@ def test_reduced_motion_keeps_feedback(
expect(page.locator(TYPING)).to_be_visible(timeout=500)
expect(page.locator(TYPING).locator(".bubble span")).to_have_count(3)
expect(page.locator("#send-btn .spinner")).to_be_visible()
expect(page.locator("#send-btn")).to_have_class(re.compile(r"is-stop"))
expect(page.locator("#send-label")).to_have_text("Stop")
expect(page.locator("#send-btn .spinner")).to_be_hidden()
# And the turn still completes and recovers.
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)