feat(chat): invalidate saved chats on sources sync — versioned stamps, stale marker, Regenerate against the new index

This commit is contained in:
2026-08-30 23:39:15 -04:00
parent ea8e041189
commit 32b7bfd4b3
26 changed files with 2145 additions and 63 deletions
+98 -17
View File
@@ -17,7 +17,13 @@ regression is caught without a browser:
* ``#nav-history`` on ALL SEVEN pages (the phase-34 one-bar contract)
+ ``header.js``'s reveal-for-admin block;
* the full-width table CSS (AGENTS.md rule 5) + the confirm pair +
the empty-state row.
the empty-state row;
* the Stale column (phase 53, task 04): the READ-ONLY marker cell in
``makeRow`` (the rose ``.stale-pill`` from the row's ``stale`` flag
+ the em-dash fallback, the ``<td>`` aria-label in BOTH states —
WCAG 2.1 AA, conveyed without the visual), the ``Stale`` ``<th>``
between Updated and Share in ``history.html``, and the ``.stale-pill``
rose-family CSS in ``styles.css``.
The Containerfile stage-1 coverage (history.html copied, history.js
bundled) is pinned dynamically by
@@ -177,35 +183,39 @@ def test_history_page_scaffold_and_landmarks() -> None:
def test_history_table_skeleton() -> None:
"""The table skeleton: ``.history-table`` with the five columns —
Title | Messages | Updated | Share (phase 51) | Actions (the
Actions header text is visually-hidden — the row buttons carry
their own aria-labels) — and the empty-state row (ship-hidden, the
exact copy)."""
"""The table skeleton: ``.history-table`` with the six columns —
Title | Messages | Updated | Stale (phase 53) | Share (phase 51) |
Actions (the Actions header text is visually-hidden — the row
buttons carry their own aria-labels) — and the empty-state row
(ship-hidden, the exact copy)."""
html = _text(HISTORY_HTML)
assert '<table class="history-table">' in html
for col in ('<th scope="col">Title</th>', '<th scope="col">Messages</th>',
'<th scope="col">Updated</th>', '<th scope="col">Share</th>'):
'<th scope="col">Updated</th>', '<th scope="col">Stale</th>',
'<th scope="col">Share</th>'):
assert col in html
# The Share column sits BETWEEN Updated and Actions.
# The Stale column (phase 53) sits BETWEEN Updated and Share —
# i.e. between Updated and Actions — so the phase-51 contract
# (Share between Updated and Actions) still holds.
assert (
html.find('<th scope="col">Updated</th>')
< html.find('<th scope="col">Stale</th>')
< html.find('<th scope="col">Share</th>')
< html.find('visually-hidden">Actions')
), "the Share column must sit between Updated and Actions"
), "the Stale column must sit between Updated and Share"
actions_th = re.search(
r'<th scope="col">([^<]*)<span class="visually-hidden">Actions</span></th>',
html,
)
assert actions_th, "the Actions column header must be visually-hidden text"
assert actions_th.group(1) == "", "no visible text beside the hidden header"
# The empty-state row: ship-hidden, colspan 5 (the Share column
# joined the table in phase 51), the exact copy.
# The empty-state row: ship-hidden, colspan 6 (phase 51 added
# Share, phase 53 added Stale), the exact copy.
row = re.search(r'<tr[^>]*class="history-empty-row"[^>]*>', html)
assert row, "the empty-state row must ship in the skeleton"
assert "hidden" in row.group(0)
assert 'id="history-empty-row"' in row.group(0)
assert "<td colspan=\"5\">" in html
assert "<td colspan=\"6\">" in html
assert (
"No saved chats yet — finish a conversation and press"
" <strong>Save</strong> in the chat."
@@ -446,20 +456,91 @@ def test_history_table_mobile_behavior() -> None:
def test_make_row_inserts_share_cell_between_updated_and_actions() -> None:
"""makeRow: the Share <td> (with the share control) lands BETWEEN
the Updated cell and the Actions cell — the column order in
history.html is Title | Messages | Updated | Share | Actions."""
history.html is Title | Messages | Updated | Stale (phase 53) |
Share | Actions."""
js = _js()
row = _fn(js, "makeRow")
updated_i = row.find('updatedTd.className = "history-updated-cell"')
stale_i = row.find('staleTd.className = "history-stale-cell"')
share_i = row.find('shareTd.className = "history-share-cell"')
actions_i = row.find('actionsTd.className = "history-actions-cell"')
assert -1 < updated_i < share_i < actions_i, (
"the share cell must sit between Updated and Actions"
assert -1 < updated_i < stale_i < share_i < actions_i, (
"the stale cell (phase 53) must sit between Updated and Share —"
" i.e. the share cell must still sit between Updated and Actions"
)
assert "makeShareControl(chat)" in row
seq = re.findall(r"tr\.appendChild\((\w+)\)", row)
assert seq == ["titleTd", "countTd", "updatedTd", "shareTd", "actionsTd"], (
f"row cell order must be title/count/updated/share/actions, got {seq}"
assert seq == [
"titleTd", "countTd", "updatedTd", "staleTd", "shareTd", "actionsTd",
], f"row cell order must be title/count/updated/stale/share/actions, got {seq}"
# ---------- the Stale column (phase 53, task 04) ----------
def test_stale_cell_branches_on_row_flag_with_aria_label() -> None:
"""makeRow (phase 53 task 04): the Stale cell renders from the
row's ``stale`` flag — the SERVER computes staleness (task 03),
the client never does version math. Stale rows: the rose
``.stale-pill`` (the ``Stale`` text + the EXACT hover copy pointing
at the Regenerate action on the chat page, task 05). Fresh rows:
a plain em-dash (no pill). The <td> carries its own aria-label in
BOTH states (WCAG 2.1 AA — the marker must be conveyed without the
visual). READ-ONLY badge: the cell binds no events and creates no
controls (the Regenerate button lives on the chat-page banner);
textContent only (XSS contract)."""
js = _js()
row = _fn(js, "makeRow")
assert 'staleTd.className = "history-stale-cell"' in row
assert "tr.appendChild(staleTd)" in row
assert "if (chat.stale)" in row, "the cell branches on the row's stale flag"
branch = row[row.find("if (chat.stale)") : row.find("tr.appendChild(staleTd)")]
# The stale branch: the rose pill with the exact hover copy.
assert 'pill.className = "stale-pill"' in branch
assert 'pill.textContent = "Stale"' in branch
assert (
'pill.title = "Sources have changed since this chat was saved'
" — open the chat to Regenerate\";"
) in branch, "the pill's hover copy points at the Regenerate action (task 05)"
# The fresh branch: the plain em-dash, never the pill.
else_i = branch.find("} else {")
assert else_i != -1, "the fresh branch must exist"
fresh = branch[else_i:]
assert 'staleTd.textContent = "—"' in fresh, "fresh rows render the em-dash"
assert "stale-pill" not in fresh, "fresh rows render the em-dash, not the pill"
# The <td> aria-label ships in BOTH states (conveyed without the
# visual — WCAG 2.1 AA).
assert branch.count('staleTd.setAttribute("aria-label"') == 2, (
"the cell's aria-label must exist in the stale AND the fresh branch"
)
# READ-ONLY: no events, no controls, no innerHTML anywhere in the
# cell's construction.
assert "addEventListener" not in branch
assert "createElement(\"button\")" not in branch
assert "innerHTML" not in branch
def test_stale_column_css_rose_family() -> None:
"""styles.css (phase 53 task 04): ``.stale-pill`` is the rose
family — the Stop-treatment tokens (err-ink on err-bg ≈9.3:1, the
err-line border), theme-token based so it stays AA with the
palette; a compact rounded pill (border-radius 999px, nowrap). The
``.history-stale-cell`` keeps the marker on one line and rides
ink-soft (5.1:1 on --surface) for the fresh rows' em-dash."""
css = _css()
pill = re.search(r"\.stale-pill \{([\s\S]*?)\n\}", css)
assert pill, "styles.css must style .stale-pill"
body = pill.group(1)
assert "background: var(--err-bg)" in body, "the Stop-treatment tokens"
assert "color: var(--err-ink)" in body
assert "border: 1px solid var(--err-line)" in body
assert "border-radius: 999px" in body, "the pill shape"
assert "white-space: nowrap" in body
cell = re.search(r"\.history-stale-cell \{([^}]*)\}", css)
assert cell, "the stale cell must be styled"
cbody = cell.group(1)
assert "white-space: nowrap" in cbody
assert "var(--ink-soft)" in cbody, "the em-dash rides ink-soft (AA on --surface)"
def test_share_control_three_states_and_two_step_unshare() -> None: