feat(chat): render markdown tables in answers, viewer, and thinking

GFM pipe tables in the shared renderer (TODO.md L6): a table-protection
pass in frontend/assets/markdown.js (fences -> tables -> escape order)
pulls each header+separator+body block out as a placeholder, renders
cells escape-first with the same inline transforms, and reinserts a
semantic <table class="md-table"> inside a horizontal-overflow
.md-table-wrap — so a pipe table in a chat answer, the document
viewer/modal, and the thinking block all render the same semantic
table. Fences win over tables; lone pipes stay text.

- styles.css: .md-table palette rules (PLAN §7.2 tokens, no motion);
  min-width: max-content so a WIDE table keeps its natural width and
  the wrapper is the real scroller (width:100% alone wrapped the wide
  table's cells — proven by the new E2E).
- mock_llm.py: TABLE_TRIGGER ("show me a table") -> byte-stable
  TABLE_ANSWER (3-column table, <img onerror> XSS probe line, wide
  5-column table), checked before DEFLECT_MODE like SUMMARY_MODE.
- tests/fixtures/docs/homelab/tables.md: 3x3 pipe table + pipe-heavy
  fenced block (viewer/fence subject); the shared fixture set grows
  8 -> 9 docs, so every suite pinning the count (added/formats/
  stat-docs/EXPECTED_ROWS) is updated accordingly.
- tests/e2e/test_markdown_tables.py (new, story suite): chat table
  shape + non-deflection, wide-table wrapper scroll (no page
  overflow), XSS probe inert, viewer modal table, fence-not-a-table,
  lone pipe stays text.
- tests/e2e/test_agent_document_tools.py: fix a pre-existing flake —
  the "Calling tool…" label window is ~0.4 s at the mock's 0.1 s
  tool-frame pacing, and a polling expect could stride over it
  (failed 3 of 5 runs on the committed baseline). The pre-submit
  MutationObserver record is the deterministic source of truth; the
  racy to_have_text gate is gone.

uv run pytest: 738 passed, app/ coverage 99% (TOTAL unchanged);
ruff + pyright clean; story E2E 6/6 in isolation; regression E2E
suites (chat_rag, document_viewer, document_summaries, smoke) green.
This commit is contained in:
2026-08-28 03:35:50 -04:00
parent 27b7cb96d5
commit bc70ce36e0
30 changed files with 967 additions and 73 deletions
+8 -6
View File
@@ -327,14 +327,16 @@ def test_marker_question_lists_reads_and_quotes(
_install_page_hooks(page)
_submit(page, MARKER_QUESTION)
# While a tool runs the button carries the "calling tool" label: the
# first `tool` frame sets it and it holds until the FIRST answer
# delta (the agent loop completes before the answer stream) — so the
# poll issued right after the click must catch it inside that window.
expect(page.locator("#send-label")).to_have_text("Calling tool…", timeout=20_000)
# The "calling tool" label window is transient: the first `tool`
# frame sets it and it holds until the FIRST answer delta (the agent
# loop completes before the answer stream) — ~0.4 s at the mock's
# 0.1 s tool-frame pacing. A polling expect can stride straight over
# that window (observed flake, fixed in phase 44 task 03), so the
# pre-submit MutationObserver record below is the deterministic
# source of truth for the label transition.
_wait_settled(page)
# The label transition is also recorded deterministically (no race):
# The label transition, recorded deterministically (no race):
# Thinking… → Calling tool… → … → Send.
labels = page.evaluate("() => window.__labels")
assert "Calling tool…" in labels, labels