phase: 130_chat_answer_clusters
**Phase 130 — final verification pass: 100% green, complete.** - **Verified:** chips-only row (no chip for image docs) → images-only row (`.msg-images`) → related row → `.msg-actions` last; `getActionsRow` used by all 3 buttons + stopped/failed notes; `markLastRetryable` save-re-append hack gone; in-place chip fallback on image-load failure; CSS right-align with per-button auto margins removed — implementation matches the design in every detail. - **Completion criteria:** (1) mixed citation renders chips→images→actions-last, DOM-asserted ✅ (2) all 3 buttons in one right-aligned `.msg-actions`; non-admin sees Retry-only; restore path identical ✅ (3) 404-bytes figure → plain chip, unit-pinned ✅ (4) E2E isolated ✅ (5) no behavior change (modal affordances, related tier, ARIA names) ✅ - **Tests/lint/coverage:** `uv run pytest tests/e2e/test_chat_answer_clusters.py -v --no-cov` → 4 passed; `uv run pytest --cov=app --cov-report=term-missing` → 3061 passed, **99%** app coverage; the 4 E2E files this phase modified (test_answer_images, test_image_documents, test_pdf_support, test_responsive_polish) → 22 passed in isolation; `uv run ruff check .` clean, `uv run pyright` → 0 errors. - **Docs:** README.md — verified consistent in full (page table vs routes, env-var coverage vs `.env.example`, phase-130 cluster wording already in tree); no changes needed. AGENTS.md — fixed one pre-existing inconsistency: rule 5 still cited the retired "centered 46rem chat column"; now states the 72rem container contract (phase 100). - **Decisions:** PLAN.md §7.1 still says 46rem, but it's owner-managed (phase 100 records the supersession pending owner sign-off) and out of my editable scope — AGENTS.md now cross-references the supersession. - **Next pending phase:** `131_read_line_ranges` (todo/).
This commit is contained in:
@@ -313,18 +313,22 @@ def test_stop_branch_keeps_partial_and_persists_stopped() -> None:
|
||||
|
||||
|
||||
def test_stopped_note_helper_and_restore_path() -> None:
|
||||
"""appendStoppedNote: reuses/creates the .msg-meta row exactly like
|
||||
appendTuneButton (role=list → the span joins as a listitem), one
|
||||
.stopped-note per bubble — the aria-hidden stop-glyph SVG + the
|
||||
"Stopped" text (the accessible meaning). The restore path renders it
|
||||
for records with `m.stopped` (phase-14 optional-field convention —
|
||||
no version bump)."""
|
||||
"""appendStoppedNote: joins the bubble's ACTIONS row (getActionsRow
|
||||
— phase 130 task 02, ASSUMPTION A7: the notes are answer-status
|
||||
markers, the actions row is their home; the role=list row joins the
|
||||
span as a listitem so ARIA stays valid), one .stopped-note per
|
||||
bubble — the aria-hidden stop-glyph SVG + the "Stopped" text (the
|
||||
accessible meaning). The restore path renders it for records with
|
||||
`m.stopped` (phase-14 optional-field convention — no version
|
||||
bump)."""
|
||||
js = _js()
|
||||
fn = js.find("function appendStoppedNote")
|
||||
assert fn != -1, "appendStoppedNote must exist"
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert 'querySelector(".msg-meta")' in body, "reuses the meta row when it exists"
|
||||
assert 'className = "msg-meta"' in body, "creates it otherwise"
|
||||
assert "getActionsRow(wrap)" in body, (
|
||||
"the note joins the dedicated .msg-actions row (getActionsRow — "
|
||||
"phase 130 task 02), never the first .msg-meta row"
|
||||
)
|
||||
assert 'className = "stopped-note"' in body
|
||||
assert 'querySelector(".stopped-note")' in body, "one note per bubble"
|
||||
assert 'note.setAttribute("role", "listitem")' in body
|
||||
@@ -443,19 +447,22 @@ def test_run_turn_is_the_extracted_turn_handler() -> None:
|
||||
|
||||
|
||||
def test_retry_button_is_not_admin_gated_and_one_per_bubble() -> None:
|
||||
"""appendRetryButton: the house appendTuneButton pattern — reuses the
|
||||
.msg-meta row when it exists (role=list → the button joins as a
|
||||
listitem so ARIA stays valid), creates it otherwise, one .retry-btn
|
||||
per bubble, the aria-hidden redo glyph + the "Retry" text (the
|
||||
accessible name). NOT admin-gated — unlike appendTuneButton, chat is
|
||||
public and every visitor gets the redo (owner-locked)."""
|
||||
"""appendRetryButton: the house pattern through getActionsRow
|
||||
(phase 130 task 02 — the button joins the bubble's dedicated
|
||||
.msg-actions row; the role=list row joins it as a listitem so ARIA
|
||||
stays valid), one .retry-btn per bubble, the aria-hidden redo glyph
|
||||
+ the "Retry" text (the accessible name). NOT admin-gated — unlike
|
||||
appendTuneButton, chat is public and every visitor gets the redo
|
||||
(owner-locked)."""
|
||||
js = _js()
|
||||
fn = js.find("function appendRetryButton")
|
||||
assert fn != -1, "appendRetryButton must exist"
|
||||
body = js[fn: js.find("\n}\n", fn)]
|
||||
assert "isAdmin" not in body, "Retry is NOT admin-gated (owner-locked: all visitors)"
|
||||
assert 'querySelector(".msg-meta")' in body, "reuses the meta row when it exists"
|
||||
assert 'className = "msg-meta"' in body, "creates it otherwise"
|
||||
assert "getActionsRow(wrap)" in body, (
|
||||
"the button joins the dedicated .msg-actions row (getActionsRow — "
|
||||
"phase 130 task 02), never the first .msg-meta row"
|
||||
)
|
||||
assert 'className = "retry-btn"' in body
|
||||
assert 'querySelector(".retry-btn")' in body, "one Retry button per bubble"
|
||||
assert 'btn.setAttribute("role", "listitem")' in body, ("role=list → listitem")
|
||||
@@ -584,12 +591,14 @@ def test_last_brain_wrap_tracking_and_restore() -> None:
|
||||
|
||||
def test_retry_btn_css_is_the_tune_family() -> None:
|
||||
"""Phase 49 styling: the .retry-btn pill is the exact .tune-btn
|
||||
visual family (same size/spacing/min-height, right-aligned after
|
||||
the source chips, 14px glyph) so the two meta actions read as a
|
||||
pair — with the neutral ink-soft → ink hover (Tune keeps the brand
|
||||
pair). Contrast: ink-soft on --bg ~8.6:1, ink ~15:1, hover ink on
|
||||
--brand-soft ~15.7:1 — all AA. The mobile squeeze keeps the >=44px
|
||||
floor."""
|
||||
visual family (same size/spacing/min-height, 14px glyph) so the
|
||||
meta actions read as a pair — with the neutral ink-soft → ink
|
||||
hover (Tune keeps the brand pair). The right-align moved to the
|
||||
ROW in phase 130 (the .msg-meta.msg-actions justify — the button
|
||||
is a plain flex item with NO per-button auto margin; the
|
||||
cluster-level pin lives in test_frontend_cluster_css). Contrast:
|
||||
ink-soft on --bg ~8.6:1, ink ~15:1, hover ink on --brand-soft
|
||||
~15.7:1 — all AA. The mobile squeeze keeps the >=44px floor."""
|
||||
css = _css()
|
||||
block = re.search(r"\.retry-btn \{([\s\S]*?)\n\}", css)
|
||||
assert block, "styles.css must style .retry-btn"
|
||||
@@ -597,7 +606,6 @@ def test_retry_btn_css_is_the_tune_family() -> None:
|
||||
for prop in (
|
||||
"display: inline-flex",
|
||||
"min-height: 44px",
|
||||
"margin-left: auto",
|
||||
"padding: 0.35rem 0.8rem",
|
||||
"border-radius: 999px",
|
||||
"border: 1px solid var(--line)",
|
||||
@@ -606,6 +614,10 @@ def test_retry_btn_css_is_the_tune_family() -> None:
|
||||
"cursor: pointer",
|
||||
):
|
||||
assert prop in body, f".retry-btn must keep the .tune-btn family ({prop})"
|
||||
assert "margin-left: auto" not in body and "margin-inline-start: auto" not in body, (
|
||||
"phase 130: the per-button auto margin is gone — the row's "
|
||||
".msg-actions justify right-aligns the cluster"
|
||||
)
|
||||
assert re.search(r"\.retry-btn svg \{ width: 14px; height: 14px", css), (
|
||||
"the redo glyph rides the 14px meta-row size"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user