docs(phases): convert TODO.md L3-L4 into phases 170-171 (edit bubble, settings layout)

170_edit_bubble_sizing — the edit form renders inside the user bubble
(same box, no shrink) and its submit label reads "Send", not "Save"
(TODO.md L3).
171_admin_settings_layout — settings page CSS fixes: #settings-content
vertical rhythm (search/table overlap + smushed action row), distinct
group bands, a properly disabled Save cursor (TODO.md L4).

Numbering note: 169 (embedding generation migration) was created,
executed and abandoned before this conversion; its number is not
reused. TODO.md is cleared — the items now live in .agents/phases/.

LLM-Generated: true
This commit is contained in:
2026-10-09 23:42:15 -04:00
parent 542e568d25
commit abe7388dda
9 changed files with 327 additions and 0 deletions
@@ -0,0 +1,47 @@
# Phase 170 — Edit bubble: the editor lives inside the user bubble, "Send" re-asks
**Source:** `TODO.md` L3 — "the edit bubble is tiny compared to the message bubble. When you click "edit" it collapses down to this super small text field. It should stay the same size as the user message bubble. Also the save button should read "send" rather than "save" because you're not saving it you're re-sending the message."
**Story:** n/a (TODO-derived, owner roadmap confirmation 2026-10-10)
**Context:** `frontend/assets/app.js` — `openEditor(wrap, toggleBtn)` (L1210–1357: label + `textarea[rows=3][maxlength=4000]` prefilled through `.value`, `editSaveBtn` "Save" L1238 + cancel, `closeEditor()` L1252 (form.remove + `.is-editing` remove + focus), the submit handler L1265–1345 (in-flight guard, empty guard, replace-in-place, tail truncation + DOM drop, `lastBrainWrap = null`, `bubble.innerHTML = renderMarkdown(text)` + the `.msg-image` re-prepend L1322–1325, `saveConversation()` + `persistConversation()`, `markLastRetryable()`, `closeEditor()` L1337, `runTurn(text, { reask: true })`), the open-tail L1355–1357 (`classList.add("is-editing")` + `body.appendChild(form)` + `ta.focus()`)), `appendEditButton` / `markLastEditable` (L793–871, the phase-159 single-target discipline), `tests/unit/test_frontend_edit_message.py` (house source pins: `editSaveBtn.textContent = "Save"` L348; the `.is-editing` add L365 / remove L387 / ordering L372; the `body.appendChild(form)` pin near L699), `tests/e2e/test_edit_and_resend.py` (the phase-159 suite: `page.click(".edit-form .edit-save")` L337 — class-based, so the label rename touches only its docstrings; the composer's `#send-label` "Send" pins L312/L533 are a different control), `frontend/assets/styles.css` — `.msg.user .bubble.is-editing { display: none; }` (L1411), `.edit-form` (L1412–1420 — its own `--surface` box + border + padding), `.edit-form label` (L1421–1425), `.edit-form textarea` (L1426–1436, `resize: vertical`, `min-height: 2.6rem`), `.edit-form-actions` (L1437), `.edit-save` (L1438–1455, incl. `:disabled { opacity: 0.6; cursor: wait; }` L1454), `.edit-cancel` (L1456–1469), `.edit-error` (L1470–1479), `.bubble` (L501–508), `.msg.user .bubble` (L592–596 — the `--brand` fill + `--bg` text).
## Objective
Clicking **Edit** no longer swaps the user bubble for a shrunken form: the editor lives *inside* the bubble itself (inheriting its box, background, and padding, so it keeps the bubble's dimensions) and the submit button reads **Send** — the action re-sends the question, it does not save a note. Everything else of the phase-159 flow (prefill, empty guard, tail truncation, the `reask` re-ask, one-editor-at-a-time, image survival, the in-flight guards) stays behavior-identical.
## Design (shared by all tasks — the executor reads this, not the chat)
- **The form IS the bubble's content (LOCKED A1).** `openEditor` stops appending the form as a sibling of `.bubble` in `.msg-body` and stops hiding the bubble. It keeps the `.msg-image` child in a closure variable, keeps `bubble.classList.add("is-editing")` (L1355 — the state class survives, the unit pins keep it), and replaces `body.appendChild(form)` (L1356) with the form appended **into** the bubble, the rendered markdown cleared first. The bubble keeps its own `--brand` fill, border, padding, and width — "the same size as the user message bubble" holds by construction (the box IS the box), with zero measurement/matching code. `.msg.user .bubble.is-editing { display: none; }` (styles.css L1411) is **deleted** — the bubble stays visible while the editor is open.
- **The form is transparent.** `.edit-form` drops its own `background` / `border` / `padding` (the bubble provides them) and keeps `display:flex; flex-direction:column; gap: 0.5rem`. The label and the action text take the bubble's `--bg`-family ink (contrast documented in a comment — the B5 text-state rule); the textarea keeps its own readable `--surface` fill + `--ink` text (an edit field on a `--brand` bubble) and gains `width: 100%`. `rows=3` and `maxlength=4000` are untouched — the *outer* box is the bubble's, the textarea is the inner field.
- **Cancel = restore the markdown.** `closeEditor()` keeps exactly three jobs: `form.remove()`, `bubble.classList.remove("is-editing")`, `toggleBtn.focus()` — no content change. The cancel branch (Escape + the Cancel button) gains a restore step: `bubble.innerHTML = renderMarkdown(conversation[editIdx].text)` + re-prepend the saved image (the mirror of the submit re-render L1322–1325), then `closeEditor()`.
- **Submit ordering.** The submit handler currently re-renders (`bubble.innerHTML = renderMarkdown(text)` L1322) *before* `closeEditor()` (L1337). With the form inside the bubble, `closeEditor()` (which detaches the form) must run **before** the innerHTML re-render; the rest of the handler — truncation, `lastBrainWrap = null` / `leavePartialIndex` reset, persistence, `markLastRetryable()`, the in-flight re-check, `runTurn(text, { reask: true })` — is byte-untouched.
- **Label (LOCKED A2).** `editSaveBtn.textContent = "Send"` (app.js L1238) — matching the composer's `#send-label` "Send" and the "Cancel" sibling. The phase-168 "Save settings" button is a different control and keeps its name.
- **NOT touched:** `appendEditButton` / `markLastEditable`, `runTurn`, `retryLastTurn`, `handleSend`, `rememberBrainTurn`, the record-truncation + persistence path, `MAX_QUESTION_CHARS` / `updateCharCount`, the composer (the unsent draft survives the edit), the SSE contract (A15), the shared-chat page, `app/` server code (A10 stateless holds).
## Dependencies
- `159_edit_and_resend_message` (complete) — the inline editor + re-ask flow this phase re-houses and re-labels.
- `166_hide_empty_answer_bubble` (complete) — `.msg .bubble:empty { display: none }` (styles.css L524): while the editor is open the bubble holds the form (never `:empty`), and on cancel the re-rendered markdown is never empty (`min_length=1` pinned client- and server-side) — no interaction, the pin stays green.
## Tasks
1. `01_editor_inside_bubble.md` — `openEditor` builds the form **inside** `.bubble` (transparent form, `display:none` deleted), the cancel re-render, the submit ordering; the unit pins updated.
2. `02_save_label_send.md` — the submit label "Save" → "Send" (app.js L1238), the unit pin, the phase-159 E2E docstring alignment.
3. `03_e2e_edit_bubble.md` — `tests/e2e/test_edit_bubble_sizing.py` (the phase's dedicated Playwright suite, AGENTS rule 4), the regression set, the full gates, the atomic commit.
## Testing & Quality
- **Unit:** `tests/unit/test_frontend_edit_message.py` (house source pins, updated in tasks 01/02): the form is appended to the bubble (not `.msg-body`), the `display:none` rule's absence pinned in the CSS, `.is-editing` still added (L365) / removed (L387), the cancel restore (markdown re-render + image re-prepend) and the submit ordering (form detached before the re-render), `editSaveBtn.textContent = "Send"`, and `runTurn` / `retryLastTurn` / `handleSend` byte-unchanged.
- **E2E (AGENTS rule 4, task 03):** `tests/e2e/test_edit_bubble_sizing.py`, run **in isolation** — the size/label contract plus the regression flows.
- **Coverage:** **>90%** on `app/` (this phase changes no `app/` code — the gate must stay green, not drop).
- **Lint/types:** `uv run ruff check . && uv run pyright`.
## Completion Criteria
- [ ] Clicking Edit on the last user bubble shows the editor **inside** the bubble: the bubble is visible (computed `display` ≠ `none`), keeps its `--brand` fill, and its box width is unchanged by the edit (the E2E bounding-box pin).
- [ ] The submit button reads "Send"; Cancel / `Escape` restore the rendered question; trim + Send re-asks with the truncated shape and the "Question too long" driver case still resolves.
- [ ] `uv run pytest` green, `app/` coverage >90% (`uv run pytest --cov=app --cov-report=term-missing`), ruff + pyright clean.
- [ ] `uv run pytest tests/e2e/test_edit_bubble_sizing.py -v --no-cov` green **in isolation**; regression set green in isolation: `test_edit_and_resend.py`, `test_retry_answer.py`, `test_failed_turn_retry.py`, `test_chat_persistence.py`.
- [ ] One atomic `--no-gpg-sign` Conventional Commit; the phase dir moves to `.agents/phases/complete/`.
## Locked decisions (owner-confirmed 2026-10-10, roadmap confirmation)
- **A1 — the editor lives inside the bubble:** "stay the same size as the user message bubble" = the editor occupies the bubble element itself (inherits its box/styling), not a pixel-matched sibling form. `rows=3` + `maxlength=4000` stay.
- **A2 — the label is "Send"** (capital S, matching the composer's `#send-label` "Send" and "Cancel"); only the edit-form label changes — the phase-168 "Save settings" button keeps its name.
## Commit
```bash
git add frontend/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "feat(chat): edit the last question inside its bubble and re-send it with Send"
```
@@ -0,0 +1,40 @@
# Task 01 — the editor renders inside the user bubble (same box, no shrink)
**Phase:** `170_edit_bubble_sizing` · **Source:** `TODO.md:3` — "the edit bubble is tiny compared to the message bubble. When you click "edit" it collapses down to this super small text field. It should stay the same size as the user message bubble."
**Story:** n/a
## Objective
`openEditor` renders its form **inside** `.bubble` instead of as a hidden-bubble + small-sibling-form: the editor inherits the bubble's own box, background, and padding, so it keeps the bubble's dimensions by construction. The open / cancel / submit behavior of the phase-159 flow is otherwise unchanged.
## Work
1. `frontend/assets/app.js` — `openEditor` (L1210–1357), three surgical changes:
- **Open:** keep `if (bubble) bubble.classList.add("is-editing");` (L1355); replace `body.appendChild(form);` (L1356) with: grab the `.msg-image` child into a closure variable, clear the bubble's rendered markdown (`bubble.innerHTML = "";` or `bubble.replaceChildren(form)`), append the form **into** the bubble; `ta.focus()` (L1357) stays.
- **Cancel:** `closeEditor()` (L1252–1257) keeps exactly `form.remove()` + `bubble.classList.remove("is-editing")` + `toggleBtn.focus()` — no content change. The cancel paths (the `Escape` keydown branch L1258–1261 and the Cancel-button listener) gain a restore step BEFORE `closeEditor()`: `bubble.innerHTML = renderMarkdown(conversation[editIdx].text)` + re-prepend the saved image (the exact mirror of the submit re-render L1322–1325).
- **Submit:** reorder — move `closeEditor()` (L1337) to **before** the `if (bubble) { … bubble.innerHTML = renderMarkdown(text); … }` re-render block (L1322–1325), so the form is detached cleanly first. Everything else in the handler — the in-flight guard, the empty guard, `conversation[editIdx].text = text`, the tail truncation + DOM drop, `lastBrainWrap = null` / `leavePartialIndex` reset, `saveConversation()` + `persistConversation()`, `markLastRetryable()`, the second in-flight re-check, `runTurn(text, { reask: true })` — is byte-untouched.
- Update the `openEditor` comment block (L1154–1195): the "While open the rendered .bubble is hidden (.is-editing — the question appears ONCE)" sentence becomes the new contract (the form renders inside the bubble; the bubble stays visible; `.is-editing` is the state class); keep the rest of the block.
2. `frontend/assets/styles.css`:
- **Delete** `.msg.user .bubble.is-editing { display: none; }` (L1411) — the bubble stays visible while the editor is open.
- `.edit-form` (L1412–1420): drop the own `background: var(--surface);`, `border: 1px solid var(--brand-soft);`, and `padding: 0.75rem 0.85rem;` (the bubble provides them); keep `display: flex; flex-direction: column; gap: 0.5rem;`.
- `.edit-form label` (L1421–1425): the color becomes the bubble's `--bg`-family ink — on the `--brand` fill, `--bg` is the documented 1.0-against-brand pairing inverted (the user-bubble text pairing, L592–596); document the ratio in the comment (B5: text states it, never color alone); keep `font-size: 0.85rem; font-weight: 600;`.
- `.edit-form textarea` (L1426–1436): add `width: 100%;` (the field fills the bubble's width — the "not tiny" fix); keep `font-size: 0.9rem`, the `--surface` fill + `--ink` text (a readable edit field on the `--brand` bubble), `border`, `resize: vertical`, `min-height: 2.6rem`.
- `.edit-error` (L1470–1479): the `--err-bg` / `--err-ink` pair is documented against `--surface`; on the `--brand` bubble it needs a check — if the pair fails ≥4.5:1 there, scope a `.msg.user .bubble .edit-error` variant on the bubble's own ink pairing (`--bg` text on a `--brand`-darkened fill) and document the ratio; the `role="alert"` behavior is untouched.
- The `:disabled` wait-cursor on `.edit-save` (L1454) is the phase-159 in-flight dim — leave it (task 03 of THIS phase does not touch it; the owner's cursor complaint is the settings page, phase 171).
3. `tests/unit/test_frontend_edit_message.py` — update the pins (house source-pin style):
- the form is appended to the bubble: replace the `body.appendChild(form)` pin (near L699) with the bubble-append pin (e.g. `bubble.replaceChildren(form)` or the innerHTML-clear + append, whichever lands in app.js);
- the `.is-editing` add (L365) / remove (L387) / ordering (L372) pins **stay** (the class is still the state marker);
- new pins: the cancel restore (`renderMarkdown(conversation[editIdx].text)` on cancel + the image re-prepend) and the submit ordering (`closeEditor()` before the `bubble.innerHTML = renderMarkdown(text)` re-render);
- a CSS pin: `.msg.user .bubble.is-editing { display: none; }` is **absent** from `styles.css`;
- `runTurn` / `retryLastTurn` / `handleSend` pins byte-unchanged.
- ASSUMPTION: A1 (phase-locked) — "stay the same size as the user message bubble" is implemented by the editor occupying the bubble element itself (it inherits the bubble's box/styling), not by pixel-matching a sibling form; `rows=3` + `maxlength=4000` stay.
## Testing & Quality
- Unit: `tests/unit/test_frontend_edit_message.py` green (`uv run pytest tests/unit/test_frontend_edit_message.py -v --no-cov`).
- Coverage: **>90%** on `app/` (unchanged code — the gate must stay green).
- Lint/types: `uv run ruff check . && uv run pyright` (the JS is not linted by ruff — the unit pins are the JS gate).
## Completion Criteria
- [ ] In a live browser (or the E2E from task 03): clicking Edit shows the form inside the visible bubble — no `display:none`, no shrunken sibling box.
- [ ] Cancel / `Escape` restore the rendered markdown + the attachment; the record is unchanged.
- [ ] `uv run pytest tests/unit/test_frontend_edit_message.py -v --no-cov` green; `uv run pytest` green; ruff + pyright clean.
- [ ] No behavior change in `runTurn` / `retryLastTurn` / `handleSend` (byte-unchanged pins green).
@@ -0,0 +1,25 @@
# Task 02 — the edit-form submit label reads "Send", not "Save"
**Phase:** `170_edit_bubble_sizing` · **Source:** `TODO.md:3` — "Also the save button should read "send" rather than "save" because you're not saving it you're re-sending the message."
**Story:** n/a
## Objective
The edit form's submit button reads **Send** — the action re-sends the question (it never persists a standalone note, A10 stateless holds), so "Save" was a lie. The label matches the composer's `#send-label` "Send" and the "Cancel" sibling; nothing else in the flow changes.
## Work
1. `frontend/assets/app.js` L1238 — `editSaveBtn.textContent = "Save";` → `editSaveBtn.textContent = "Send";`. The `edit-save` class, the `type="submit"` wiring, the Ctrl/Cmd+Enter submit, and the whole submit handler are untouched.
2. `tests/unit/test_frontend_edit_message.py` L348 — the pin `assert 'editSaveBtn.textContent = "Save";' in body` → `"Send"`; keep the surrounding pin block (the `editSaveBtn.type` / `editSaveBtn.className` pins) and align the module docstring's "Save/Cancel" phrasing (L15) to "Send/Cancel" where it names the **edit form** (the docstring lines that name the composer's send flow stay — that control was always "Send").
3. `tests/e2e/test_edit_and_resend.py` — the flow drives the edit submit by class (`page.click(".edit-form .edit-save")` L337) — **no code change**. Align the module docstring / comments that name the edit form's "Save" action (L29, L32, L51, L79, L255, L341, L383, L510) to "Send"; the composer's `#send-label` "Send" assertions (L312, L533) are a different control and stay byte-identical.
- ASSUMPTION: A2 (phase-locked) — the label is "Send" (capital S, matching the composer's `#send-label` and "Cancel"); only the edit-form label changes — the phase-168 "Save settings" button (a different control) keeps its name.
## Testing & Quality
- Unit: `tests/unit/test_frontend_edit_message.py` green.
- E2E (regression, in isolation): `uv run pytest tests/e2e/test_edit_and_resend.py -v --no-cov` — the phase-159 suite now runs the flow through the "Send" label (the click is class-based, so the suite is code-identical; the docstrings are aligned).
- Coverage: **>90%** on `app/` (unchanged).
- Lint/types: `uv run ruff check . && uv run pyright`.
## Completion Criteria
- [ ] The edit form's submit button reads "Send" in the live UI; "Save" appears nowhere in the edit form.
- [ ] `uv run pytest tests/unit/test_frontend_edit_message.py -v --no-cov` green; `uv run pytest tests/e2e/test_edit_and_resend.py -v --no-cov` green in isolation.
- [ ] The phase-168 "Save settings" button (settings view) is byte-untouched.
@@ -0,0 +1,44 @@
# Task 03 — `tests/e2e/test_edit_bubble_sizing.py`: the in-bubble editor, proven in a browser
**Phase:** `170_edit_bubble_sizing` · **Source:** `TODO.md:3` — "the edit bubble is tiny compared to the message bubble … It should stay the same size as the user message bubble. Also the save button should read "send" rather than "save" …"
**Story:** n/a
## Objective
The phase's dedicated Playwright suite (AGENTS rule 4/9): the editor opens **inside** the user bubble with the bubble's dimensions, the submit label reads "Send", and the whole phase-159 flow (trim + re-ask, cancel restore, the "Question too long" driver case) works through the new housing. Then the regression set, the full gates, and the one atomic commit.
## Work
1. `tests/e2e/test_edit_bubble_sizing.py` (new) — copy the module-scoped app fixture from `tests/e2e/test_edit_and_resend.py` verbatim (mock LLM, `BOR_ENVIRONMENT=e2e`, the E2E admin/secret pins) and change exactly: the module docstring (what the suite proves + the Source line `TODO.md:3` — the "in-bubble editor + Send label" contract) and the test list below. DB isolation: this suite writes no `app_settings` / theme rows; the `saved_chats` rows it creates follow the phase-159 suite's convention (no extra teardown needed beyond what that fixture already does — if the fixture lacks a `saved_chats` cleanup, add the same one it would need, mirroring `test_chat_persistence.py`).
2. Tests (names pinned; helpers `login(page, app_url)` from `tests/e2e/auth_helpers.py` where a gate is crossed — the chat is public, so most tests need no login, exactly like the phase-159 suite):
- `test_editor_renders_inside_the_bubble` — send a fixture question, wait for the grounded answer (the mock-LLM settle convention), capture the user bubble's `bounding_box()` + computed `background-color`, click Edit: the form is a **child of the bubble** (`bubble.evaluate("el => el.querySelector('.edit-form') !== null")`), the bubble's computed `display` is not `none`, its `background-color` is unchanged (the `--brand` fill), the textarea prefills the EXACT question, and `.is-editing` is on the bubble.
- `test_editor_box_keeps_the_bubble_dimensions` — the pre-edit bubble `bounding_box()` vs the open-editor bubble: width is ≥ 95 % of the pre-edit width (the "no longer tiny" pin — the old shrunken sibling form failed this by construction) and height is ≥ the pre-edit height (the box grows, never collapses); at 375 px the bubble has no horizontal overflow (§7.1 reading column).
- `test_submit_label_reads_send` — `.edit-save`'s text is "Send" (and not "Save"); `.edit-cancel`'s is "Cancel".
- `test_trim_and_send_reasks_in_place` — the phase-159 flow through the new label: append " in one line", click `.edit-form .edit-save` (the "Send" button): the truncated shape `[user(edited), brain]` (the `bor.chat.v1` localStorage pin, the phase-159 convention), the re-ask streams the grounded answer, no duplicated question, the UNSSENT composer draft survives.
- `test_cancel_restores_the_bubble` — open the editor, Cancel: the rendered markdown is back (the question text is visible in the bubble, the form is gone, `.is-editing` off), the `conversation` record is byte-identical; a second pass with `Escape` (a fresh turn) pins the same restore through the keydown path.
- `test_failed_turn_still_editable` — the phase-114 driver case (the 4000+ char question → the "Question too long — trim it and re-ask" error frame, the phase-120 failed record): the editor opens inside the bubble, trimming the question to the cap + "Send" streams the grounded answer (the failed record is replaced).
3. **Regression set, all in isolation** (`--no-cov`): `test_edit_and_resend.py` (the phase-159 suite — now running against the in-bubble editor + the "Send" label), `test_retry_answer.py`, `test_failed_turn_retry.py`, `test_chat_persistence.py`.
4. **Full gates** (AGENTS rule 9, all green before the commit):
```bash
uv run pytest
uv run pytest --cov=app --cov-report=term-missing # >90% on app/
uv run pytest tests/e2e/test_edit_bubble_sizing.py -v --no-cov
uv run ruff check . && uv run pyright
```
5. **Docs & close-out:** confirm `README.md`'s edit-the-last-question note (if any names the "Save" label or the hidden-bubble behavior — phase 159's README section, grep `edit` there) is aligned to the in-bubble editor + "Send"; move the phase directory:
```bash
git mv .agents/phases/todo/170_edit_bubble_sizing .agents/phases/complete/170_edit_bubble_sizing
```
and commit (AGENTS rule 8, `--no-gpg-sign`):
```bash
git add frontend/ tests/ README.md .agents/phases/ && git commit --no-gpg-sign -m "feat(chat): edit the last question inside its bubble and re-send it with Send"
```
## Testing & Quality
- The suite passes **in isolation** (`-v --no-cov`).
- Unit pins already exist from tasks 01/02 — this file adds the browser contract only; if it needs a behavior the UI lacks, fix the UI in this task and say so in the commit body.
- Coverage: **>90%** on `app/` after everything (the `--cov` runs exclude E2E by the repo's config; the gate is the unit+integration number).
## Completion Criteria
- [ ] `test_editor_box_keeps_the_bubble_dimensions` is green: the open editor's bubble keeps the pre-edit width (≥ 95 %) and never collapses.
- [ ] The "Send" label, the in-bubble housing, the cancel restore, and the failed-turn driver case are all green in isolation.
- [ ] `uv run pytest` green, `app/` coverage >90%, ruff + pyright clean.
- [ ] Phase directory moved to `.agents/phases/complete/170_edit_bubble_sizing/` and one atomic `--no-gpg-sign` Conventional Commit exists (verify with `git log -1 --stat`).