phase: 166_hide_empty_answer_bubble
Build and Push Containers / build-and-push-app (push) Successful in 1m41s
Build and Push Containers / build-and-push-db (push) Successful in 11s

**Phase 166 — final verification pass: green.** All 3 task files already in `complete/`, so this was verification + defect/doc fixing.

- **Verified:** `.msg .bubble:empty { display: none; }` exists once in the Messages block with provenance; `frontend/` diff is styles.css only (**no app.js change**); `tests/unit/test_no_empty_answer_bubble.py` 26 pins pass; phase-158/161 anchor pins (thinking_timeline + tool_states + edit_message) **75 passed**.
- **Phase E2E in isolation:** `uv run pytest tests/e2e/test_no_empty_box.py --no-cov` → **5 passed**. Mutation check (rule removed) → 2 failures, so the suite genuinely pins the defect.
- **Regression set, isolated:** thinking_timeline 6 · turn_progress_loader 3 · chat_history 5 · share_chat 4 · save_share_ux 5 · failed_turn_retry 4 · chat_answer_clusters 4 · chat_error_honesty 4 → all pass.
- **Full gates:** `uv run pytest --cov=app --cov-report=term-missing` → **3880 passed, app/ 99%** (gate >90%); `uv run ruff check .` → clean; `uv run pyright` → **0 errors**.
- **Whole E2E tree swept file-by-file (150 files, own pytest process each).** 4 suites were broken by *earlier* phases; fixed and re-run green:
  - `test_kb_tree`, `test_sync_summary_visibility`, `test_folder_summary_voice` — `td:nth-child(4) span` became a strict-mode violation when phase 156 wrapped the Edit label in `.cell-clip` → scoped to `span.kb-desc-text`.
  - `test_search_tool_scope` — pinned the pre-`690fd98` per-run `<summary>`/`open` shape → re-pinned to the ONE `details.reasoning` container + `.tool-calls-summary`.
  - `test_header_consistency` — `.doc-meta` pixel bound (`<34px`) was broken by phase 153's 44px touch-target floor at ≤640px → replaced with a shape-first single-line check that still fails a real wrap.
- **Completion criteria:** mid-turn box hidden ✅ · bubble appears at first delta, phase-130 rows below unchanged ✅ · restored/shared empty answer paints no box ✅ · no app.js change + anchors + full gates ✅ · commit/`complete/` move = harness's job (I ran no git index/history commands).
- **Docs:** README — added the phase-166 "no empty answer box mid-turn" note; fixed a **stale** Thinking lead ("collapsible Thinking blocks", pre-dated the 2026-10-06 one-disclosure refinement); QA section now states E2E isolation is mandatory (shared Postgres + fixed ports 8123/8901) with the per-file sweep loop. AGENTS.md — rule 5 UI checklist now names the phase-166 suppression rule.
- **Notable:** running all of `tests/e2e/` in one pytest session is not supported (fixture/port interleaving causes ~126 spurious failures) — documented in README. Restored the tracked `.agents/screenshots/doc_modal_scrollbar_1280.png` that `test_document_viewer.py` rewrites on every run.
- **Next pending phase:** `167_api_payload_diet`.
This commit is contained in:
2026-10-08 04:27:03 -04:00
parent b0c7aed613
commit ab9391d019
36 changed files with 2348 additions and 93 deletions
@@ -0,0 +1,57 @@
# Phase 166 — No empty answer box before the first token
**Source:** `TODO.md` L4 — "There's an empty box where the LLM response will go that shows up before the LLM responds. It looks ugly." (the owner attached `image.png`: mid-turn the chat shows `Thinking` ▸, `Tool calls (2)` ▸, an expanded `Thinking` run with text, and BELOW it a bordered, padded, empty rounded box ≈ 40px tall)
**Story:** n/a (owner-directed UI defect)
**Context:** `frontend/assets/styles.css` (`.bubble` ~L501, `.bubble typing`, `.msg-body` flex gap ~L489), `frontend/assets/app.js` (`addMessage` L1386, `addTyping` L1433, `ensureReasoningWrap` L1610-1625, the `if (!wrap) wrap = addMessage("brain", "")` sites at ~4063 thinking / ~4164 tool / ~4269 first delta, `finalizeFailedTurn` ~3841, the restore path ~2864), `frontend/assets/shared.js:113` (the same `.msg/.msg-body/.bubble` structure), phases 06 (loading feedback), 17/117/158/161 (the per-run timeline that inserts BEFORE `.bubble`), 120/164 (failed-turn bubbles).
## Objective
While a turn is in flight, nothing paints an empty answer bubble: the box appears **at the first answer token**, and every visible-feedback affordance the app already owns (the typing indicator, the reasoning container with its live status, `#send-status`, the elapsed clock) keeps working exactly as before. Same rule covers the shared page's empty persisted answer, which renders the same empty box today.
## Why the box exists (traced 2026-10-08)
The first `thinking`/`tool` frame of a turn calls `addMessage("brain", "")` (`app.js:4063` / `:4164`) — an EMPTY bubble is created on purpose, because `ensureReasoningWrap` uses it as the DOM anchor (`body.insertBefore(cont, body.querySelector(".bubble"))`). `.bubble` carries `padding: 0.7rem 1rem` + `border` + `background` + `box-shadow`, so an empty one paints a box; the `.msg-body` flex gap adds the visible separation. `renderMarkdown("")` returns `""` (verified), so the restore path produces the same empty element — and the shared page (`shared.js:113`) too.
## Design (shared by all tasks — the executor reads this, not the chat)
**D1 — hide it in CSS, do not restructure the JS (task 01, LOCKED A1).** `frontend/assets/styles.css`, in the Messages block right after `.bubble p`:
```css
.msg .bubble:empty { display: none; }
```
`:empty` matches only an element with NO child nodes, which is exactly what `addMessage("brain", "")` produces (`<div class="bubble"></div>`); the first delta writes `innerHTML` and the box returns on the same frame. `display: none` also drops the bubble from the `.msg-body` flex layout, so the gap collapses (no orphan whitespace). The typing indicator is `.bubble typing` WITH three `<span>` children, so it is never `:empty` and its animation is untouched. Scoped to `.msg .bubble` — the viewer's `.doc-*` blocks and the composer are unaffected.
**D2 — the JS anchor stays (task 02).** Creating the empty wrap and anchoring the reasoning container before it is the phase-158/161 contract, pinned by `tests/unit/test_frontend_tool_states.py:61`, `tests/unit/test_thinking_timeline.py:475,522`, `tests/unit/test_frontend_edit_message.py:971,1072,1177`. Changing it (create-the-bubble-at-first-delta) would re-own those anchors for zero user-visible gain over D1 — so `app.js` is **not** modified by this phase; task 02's job is to prove the feedback contract holds with the box hidden.
**D3 — §7.4 feedback contract, verified not assumed (task 02).** With the box hidden, a live turn still shows, in order: `#typing-indicator` (role=status, `TYPING_LABEL()`, elapsed-seconds suffix from `thinkingClock`) → the open `details.reasoning` container with its live status span ("Thinking…" / "Calling <name>…" / "N steps") → the answer bubble at the first delta. Nothing else in the app depends on the empty bubble being painted: `scrollReveal` measures the `.msg` wrap (not the bubble), the phase-161 window pins measure `.reasoning-body`, the phase-18 follow-bottom logic is user-intent-gated, and `#messages` remains the single `aria-live="polite"` region (an `aria-live` announcement fires when content lands — at which point the bubble is visible again).
**D4 — verification (task 03).** Dedicated suite `tests/e2e/test_no_empty_box.py`: stub `POST /api/chat` with a controllable SSE stream (`thinking` → `tool` → a pause → `delta`), assert `page.locator(".msg.brain .bubble").first` is `not_to_be_visible()` (or has zero box) during the pre-delta window while `#typing-indicator` / `details.reasoning[open]` / `#send-label` ARE in their expected states, and that after the first `delta` the same locator becomes visible and holds the token. Plus a restore case (a saved chat whose last brain record has empty text renders no empty box) and the shared-page case.
## Dependencies
- `161_reasoning_bubble` + `158_thinking_timeline` (complete) — the container-before-the-bubble anchor this phase keeps, and the unit pins that must stay green untouched.
- `06_story_loading_feedback` + `109_turn_progress_loader` + `120_failed_turn_retry` (complete) — the visible-feedback states that must not regress when the box stops painting.
- `130_chat_answer_clusters` (complete) — `.msg-meta` rows are siblings of `.bubble`, never children: hiding an empty bubble never hides a chip/action row (pin it).
## Tasks
1. `01_hide_empty_bubble.md` — the CSS rule + its provenance comment + the unit pins (styles.css source assertions + the typing-indicator non-emptiness pin).
2. `02_feedback_contract_check.md` — prove §7.4 endures: the existing state-machine pins stay green, the shared/restore empty-answer corners are covered, and nothing measures the hidden box.
3. `03_e2e_no_empty_box.md` — `tests/e2e/test_no_empty_box.py` + the regression set + the full gates + the commit.
## Testing & Quality
- Unit: new `tests/unit/test_no_empty_answer_bubble.py` (the house "read the source file, assert on the rules" style — see `tests/unit/test_thinking_scroll.py`): the `.msg .bubble:empty { display: none; }` declaration exists exactly once and sits in the Messages block; `.bubble` itself keeps `padding`/`border`/`background`/`box-shadow` (the rule hides, it does not restyle); `addTyping`'s markup still contains the three `<span>` dots (⇒ never `:empty`); `app.js` still contains the three `addMessage("brain", "")` anchors (D2 — the rule is CSS-only).
- E2E: `tests/e2e/test_no_empty_box.py` green **in isolation**; the owning regression set green in isolation: `test_thinking_timeline.py`, `test_turn_progress_loader.py`, `test_chat_history.py`, `test_share_chat.py`, `test_save_share_ux.py`, `test_failed_turn_retry.py`, `test_chat_answer_clusters.py`, `test_chat_error_honesty.py`.
- Coverage: **>90%** on `app/` (no `app/` change; the gate must not move).
## Completion Criteria
- [ ] Mid-turn (thinking and/or tool frames, zero deltas) NO empty answer box paints — the reasoning container and the typing indicator are the only brain-side boxes.
- [ ] The answer bubble appears with the first delta, in the same DOM position, with the source chips / actions / figures below it exactly as phase 130 arranged them.
- [ ] A restored or shared answer whose persisted text is empty paints no empty box either; every non-empty bubble is byte-identical.
- [ ] No `app.js` change; the phase-158/161 anchor unit pins pass untouched; `uv run pytest` green, coverage > 90%, ruff + pyright clean.
- [ ] One `--no-gpg-sign` Conventional Commit; the phase dir moves to `complete/`.
## Locked decisions
- **A1 — CSS-only suppression** (`.msg .bubble:empty { display: none }`); the JS continues to create the empty bubble as the reasoning anchor (owner-confirmed 2026-10-07: "hide until the first token", not "restructure the anchor").
- **A2 — nothing replaces the box**: no skeleton, no shimmer — the existing loading feedback (typing indicator → reasoning container → status label) is the turn's visible state (PLAN §7.4), owner-confirmed 2026-10-07.
- **A3 — the rule extends to the shared page and the restore path** (same `.msg .bubble` structure; an empty persisted answer is the same defect), owner-confirmed 2026-10-07.
## Commit
```bash
git add frontend/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "fix(chat): stop painting an empty answer bubble before the first token"
```
@@ -0,0 +1,44 @@
# Task 01 — `.msg .bubble:empty { display: none }`: the empty answer bubble stops painting (phase 166 D1)
**Phase:** `166_hide_empty_answer_bubble` · **Source:** `TODO.md:4` — "There's an empty box where the LLM response will go that shows up before the LLM responds. It looks ugly." (attached `image.png`: Thinking ▸ / Tool calls (2) / an expanded Thinking run, then an empty bordered box)
**Story:** n/a
## Objective
One CSS rule suppresses the empty answer bubble during a turn (and for an empty persisted answer), with the box returning the instant the first token lands — no JS change, no restyle of a non-empty bubble, no change to any loading affordance.
## Work
1. `frontend/assets/styles.css` — in the `---------- Messages ----------` block, directly after `.bubble p { margin: 0.2rem 0; }` (~L509), add:
```css
/* Phase 166 (TODO L4): the answer bubble an in-flight turn creates is
EMPTY by construction — app.js:4063/:4164 call addMessage("brain","")
so ensureReasoningWrap has an anchor to insert the per-run timeline
BEFORE. With .bubble's own padding + border + background + shadow an
empty one paints a ~40px box, which is the "empty box before the LLM
responds" the owner reported. :empty matches only a child-less
element — exactly innerHTML === "" (renderMarkdown("") is "", so the
restore and the shared page take the same path) — and the first
delta writes content, so the box appears AT the first token.
display:none also drops the flex item, so .msg-body's gap collapses.
The typing indicator is never :empty (three dot <span>s) — its
animation is untouched. Scoped to .msg .bubble: the viewer's .doc-*
blocks and the composer never match. */
.msg .bubble:empty { display: none; }
```
No new tokens, no animation (nothing for `prefers-reduced-motion` to still).
2. `tests/unit/test_no_empty_answer_bubble.py` (new — static source-assertion style, copy the harness of `tests/unit/test_thinking_scroll.py` / `tests/unit/test_markdown_tables.py`):
- the declaration appears **exactly once**, spelled `.msg .bubble:empty { display: none; }`, and sits after `.bubble {` and before `.bubble pre {` (provenance ordering, the `test_date_editor.py:411` `css.find(...)` idiom);
- the `.bubble {` rule still declares `padding`, `border-radius`, `background`, `border`, `box-shadow` (the fix HIDES, it never restyles);
- `.bubble typing` / the typing-dot rules are still present and unchanged (the indicator stays visible);
- `frontend/assets/app.js` still contains all three `addMessage("brain", "")` call sites and `ensureReasoningWrap` still does `body.insertBefore(cont, body.querySelector(".bubble"))` (D2: the anchor survives — this is the pin that stops a future "cleanup" from moving the fix into JS and breaking the timeline);
- `frontend/assets/markdown.js` — `renderMarkdown("")` and `renderMarkdown(" ")` both return `""` (execute the file the way the existing markdown unit tests do), which is WHY the restore/shared paths are covered by the same rule;
- no CDN / no `url(http` introduced (the AGENTS.md rule-6 idiom these files already assert).
3. `frontend/assets/app.js` — **no change** (explicitly). If the executor believes a JS change is needed, stop and flag it: LOCKED A1 (the reasoning-container anchor and the phase-158/161 pins are the reason this is CSS-only).
## Testing & Quality
- Unit: the new file; then `uv run pytest tests/unit -k "thinking_timeline or tool_states or edit_message or frontend" -v` — the phase-158/161/159 anchor pins pass untouched.
- Coverage: **>90%** on `app/` unchanged (no `app/` change).
## Completion Criteria
- [ ] The rule exists exactly once, scoped, with the provenance comment naming TODO L4, `image.png`, and the anchor it preserves.
- [ ] `addTyping()`'s bubble is provably non-empty (unit-pinned) so the loading state never disappears.
- [ ] `git diff` for this task touches `frontend/assets/styles.css` + one new unit test file only.
@@ -0,0 +1,32 @@
# Task 02 — §7.4 audit: the turn is never feedback-less with the box hidden (phase 166 D3)
**Phase:** `166_hide_empty_answer_bubble` · **Source:** `TODO.md:4` — hiding the empty box must not remove the owner's only mid-turn signal; PLAN §7.4 ("never stale": every state has a defined UI) and AGENTS.md rule 10
**Story:** n/a
## Objective
Prove — with tests, not prose — that every mid-turn state still has a visible affordance with the empty bubble suppressed, that no code path measures the hidden box, and that the restore and shared-page corners render no empty box either.
## Work
1. `tests/unit/test_no_empty_answer_bubble.py` (extend) — the **feedback inventory** pins, each naming the state it guarantees:
- `addTyping()` markup contains `role="status"`, `aria-label="${TYPING_LABEL()}"` and the three `<span></span>` dots (the pre-thinking state);
- `thinkingClock` still writes the `Ns` `.typing-elapsed` suffix into `#typing-indicator .bubble` (the long-tool-call state) — and note in the comment that this bubble is never `:empty`, so the clock keeps painting;
- `setReasoningStatus("Thinking…")` / `"Calling "` / the settle `"N steps"` literals are still wired at their three call sites (the thinking/tool state), and `ensureReasoningWrap` sets `cont.open = true` (the container is visible while live);
- `#send-status` / `#send-label` state copy is untouched (the composer-side signal), and `#messages` keeps `aria-live="polite"` (`frontend/index.html:167`) — the ONE conversation live region; an announcement fires when content lands, at which point the bubble is visible again (comment this reasoning).
2. `tests/unit/test_no_empty_answer_bubble.py` (extend) — the **"nothing measures the hidden box"** audit, executed as source assertions with the finding recorded per site:
- `scrollReveal(wrap)` receives the `.msg` WRAP (not the bubble) — pin the call site `if (scroll) scrollReveal(wrap);`;
- the phase-161 window pins operate on `.reasoning-body` (`isReasoningPinned` / `scrollReasoningToBottom`) — pin that neither takes a `.bubble`;
- `.bubble` is only ever read for CONTENT (`bubble.textContent`, `bubble.innerHTML`, `bubble.querySelector(".msg-image")`, `bubble.classList`) in `app.js`/`shared.js` — grep-assert there is no `getBoundingClientRect`/`offsetHeight`/`clientHeight` on a `.bubble` selector;
- `.msg-meta` (chips, actions, figures, `.failed-note`, `.stopped-note`) are appended to `.msg-body` — pin one append site each so a future refactor cannot move them INTO the bubble (where hiding an empty one would hide them).
3. `tests/unit/test_no_empty_answer_bubble.py` (extend) — the **restore / shared corners**:
- `app.js` restore renders brain bubbles through `addMessage("brain", renderMarkdown(m.text), true)` — with `m.text === ""` the bubble is child-less ⇒ `:empty` ⇒ hidden (assert the call shape, plus the `renderMarkdown("")` fact already pinned in task 01);
- `shared.js` builds the identical `<div class="bubble">${html}</div>` structure (its line ~113), so the same `.msg .bubble:empty` scope covers the shared page — assert the rule's scope prefix `.msg ` matches both (the shared page uses the same `.msg`/`.msg-body` classes).
4. Findings note: append a short `AUDIT NOTE:` block to the test module docstring listing the four mid-turn states and the affordance that carries each after this phase (idle/typing → typing dots + clock; thinking/tool → reasoning container + status span; first token → the bubble appears; terminal → answer/deflection/stopped/failed copy). This is the record the next agent reads instead of re-deriving it.
## Testing & Quality
- Unit: the extended file + `uv run pytest tests/unit -k "frontend or reasoning or thinking or tool" -v` green; no `app/` change, coverage gate unmoved.
## Completion Criteria
- [ ] Every mid-turn state has a pinned, visible affordance with the empty bubble suppressed (the four-state inventory is in the test docstring).
- [ ] No code path measures a `.bubble` box (grep-pinned), so `display:none` cannot shift a scroll or a pin.
- [ ] The restore and shared paths are covered by the same scope (pinned), with no `shared.js` change needed.
- [ ] Full `uv run pytest` green.
@@ -0,0 +1,34 @@
# Task 03 — `tests/e2e/test_no_empty_box.py`: the mid-turn proof, a paced mock turn, and the final gates (phase 166 D4)
**Phase:** `166_hide_empty_answer_bubble` · **Source:** `TODO.md:4` — "There's an empty box where the LLM response will go that shows up before the LLM responds. It looks ugly."
**Story:** n/a
## Objective
An isolated Playwright suite proves the defect is gone with a race-free timeline: during the pre-delta window (thinking frames + a tool call, no answer token) the brain message paints NO answer box, the reasoning container and the typing/loading affordances DO paint, and the box appears exactly with the first delta. Plus the deterministic mock turn that makes that window long enough to assert, the regression set, and the phase gates.
## Work
1. `tests/e2e/mock_llm.py` — a new dedicated paced trigger (the `TURN_PROGRESS_TRIGGER` precedent, its block documents the convention): `EMPTY_BUBBLE_TRIGGER = "paint the pre-answer window"` (verify the phrase appears in NO existing E2E question and NO fixture file — the phase-109 suite's disjointness check, grep before committing), streaming only, with baked-in delays so the windows are ≥ 1 s:
- request 1 (tools offered, no tool results): `TURN_*`-style local constants (defined next to the trigger, never inline magic): a ~1.2 s pre-delay, then 3 `reasoning_content` chunks at ~0.4 s (≈ 1.2 s of thinking with **no** `delta.content`), then one synthetic `ls` tool call (`call_0`, `finish_reason: "tool_calls"`) — i.e. request 1 NEVER emits a content delta;
- request 2 (a `tool`-role result present): a ~2.0 s gap, then 3 short `delta.content` chunks (0.05 s apart) + `finish_reason: "stop"`;
- the module docstring's trigger inventory gains the entry; every other suite's classification is untouched (stateless discrimination from the messages, like the phase-109 flow).
2. New `tests/e2e/test_no_empty_box.py` (module-scoped `app_server`/`app_url` + `_reset_db(mock_llm, seed=True)` + `login()` copied from `tests/e2e/test_reasoning_bubble.py`, which is the closest live-stream suite — read its `_submit`/`_wait_answer` helpers first):
- **the defect window**: submit `EMPTY_BUBBLE_TRIGGER` question; while the thinking/tool frames stream (`expect(page.locator("details.reasoning")).to_be_visible()` first, as the sync point) assert on `.msg.brain .bubble`: `be_not_visible()` AND a zero-height box (`evaluate("el => el.getBoundingClientRect().height") === 0`) — the box does not paint; assert `details.reasoning[open]` is visible, its status span carries the live literal (`Thinking…` / `Calling `), and `#send-label` reads the in-flight state;
- **the settle**: after `done`, the SAME locator is visible, `height > 0`, and contains the answer text; the container folds (`details.reasoning:not([open])`), the chips/actions rows are present below the bubble (phase 130 ordering survives);
- **no-thinking control**: a plain answer question (no thinking, no tools) shows `#typing-indicator` before the settle (the typing bubble has dots ⇒ visible) and no empty box at any point — assert `#typing-indicator .bubble` height > 0 while in flight;
- **restore**: save the answered chat, reload `/?chat=<id>` — the answered turn renders its bubble normally (non-empty), and NO `.bubble:empty` exists in the DOM (`page.locator(".msg .bubble:empty").count() === 0`);
- **shared page**: share the chat, open `/shared/<token>` — the same zero-`:empty` assertion (LOCKED A3);
- screenshot of the mid-turn state (the owner's `image.png` before/after record) into the suite's screenshot dir the way `test_document_viewer.py` does.
3. Regression run (each **in isolation**, `--no-cov`): `tests/e2e/test_thinking_timeline.py`, `test_reasoning_bubble.py`, `test_turn_progress_loader.py`, `test_chat_history.py`, `test_share_chat.py`, `test_save_share_ux.py`, `test_failed_turn_retry.py`, `test_chat_answer_clusters.py`, `test_chat_error_honesty.py`, `test_markdown_tables.py` (the `.bubble pre` scroller), `test_wide_desktop_column.py` (the chat column metrics).
4. Docs + gates: `README.md` — one sentence in the chat-turn/loading section (the answer bubble appears at the first token; mid-turn feedback is the typing indicator + the reasoning container). Then `uv run pytest`, `uv run pytest --cov=app --cov-report=term-missing` (> 90%), `uv run ruff check . && uv run pyright`, the phase E2E in isolation.
5. Move `.agents/phases/todo/166_hide_empty_answer_bubble/` → `.agents/phases/complete/` and commit with the `00_phase.md` message (`--no-gpg-sign`).
## Testing & Quality
- E2E: the new suite green in isolation (its windows are ≥ 1 s by construction — no `sleep`-style flake), plus the ten regression suites above.
- Mock: the new trigger changes NO other suite (grep-proven disjoint phrase; the full E2E smoke `tests/e2e/test_smoke.py` green).
- Coverage: **>90%** on `app/` (mock + tests only here; no `app/` change in the phase).
## Completion Criteria
- [ ] Mid-turn: `height === 0` / not visible on `.msg.brain .bubble` while thinking + tool frames stream; the reasoning container, the status literal, and the composer state are all visible (screenshotted).
- [ ] The box appears with the first delta and holds the answer; chips/actions/figures keep their positions below it.
- [ ] Zero `.bubble:empty` elements after a restore and on the shared page.
- [ ] `uv run pytest` green, coverage > 90%, ruff + pyright clean, all ten regression suites green in isolation, one atomic `--no-gpg-sign` commit, phase dir in `complete/`.