Converts the 9 TODO items into an executable phase roadmap (Protocol B, appended after phase 39): - 40 tuning toggle anonymous flash (TODO L3) - 41 sync fail-fast + modal when a model is down (TODO L4) - 42 no reply autoscroll (TODO L5) - 43 thinking scroll back — user scroll + gated autoscroll (TODO L7) - 44 markdown tables (TODO L6) - 45 agent unlimited tool calls behind BOR_AGENT_MAX_ROUNDS (TODO L8) - 46 mobile hamburger nav (TODO L9) - 47 quadlet + jinja import formats, A9 revision (TODO L10–L11) Each phase carries a user story, a dedicated Playwright E2E suite plan, and owner-locked decisions (R1 A9 format extension, R2 phase-37 budget revision, A1–A5 scope decisions) confirmed 2026-08-27. Also records the completed phases 30–39 todo/ -> complete/ moves that were pending in the working tree. TODO.md is cleared (items now live in .agent/phases/todo/).
5.1 KiB
5.1 KiB
Story: Markdown tables in chat (and everywhere the renderer runs)
Phase: 44_markdown_tables · Source: TODO.md L6 ·
E2E: tests/e2e/test_markdown_tables.py
Bug report (verbatim, TODO.md L6)
"Certain markdown formatting isn't working - tables for example don't get rendered as tables in the chat response."
Narrative
As a user, when Brain answers with a markdown table (services and
ports, versions, schedules — the concrete specifics the persona is built
around), I expect a real table: aligned columns, borders, readable.
Today the shared renderer (frontend/assets/markdown.js, ~60 lines,
no-CDN by A11) has no table support — a pipe table renders as one
paragraph of raw | text.
- Given Brain's answer (or a document / thinking block) contains a GFM pipe table
- When the renderer runs
- Then it renders a semantic
<table>with a<thead>header row and<tbody>body rows, XSS-safe (escape-first, as the rest of the renderer).
Acceptance criteria
- Pipe tables render: header row +
|---|separator row + body rows →<table class="md-table">with<th scope="col">header cells; leading/trailing pipes and in-cell whitespace are handled; cells keep their inline markdown (bold/em/code). - XSS-safe: cell content is escaped before any transform — a cell
containing
<img onerror=…>renders inert (the escape-first guarantee, same as all other content). - Code fences win: a
|-heavy block inside a ``` fence is never parsed as a table (fence protection runs first, as today). - Non-tables stay put: a single
|in prose, a lone separator without a header, or a 1-line "table" is left as text. - Wide tables: the table sits in an
overflow-x: autowrapper so a wide table scrolls horizontally instead of breaking the 46rem chat column (PLAN §7.1). - Shared everywhere: the same renderer serves the chat answer, the document viewer, and the thinking block — all three render tables.
- Style:
.md-tableuses the existing dark-tech palette (PLAN §7.2 tokens, contrast ≥4.5:1); alignment colons in the separator are parsed but all cells render left-aligned (owner decision).
Owner-confirmed (2026-08-27, roadmap A3)
- Scope = GFM pipe tables (header + separator + body rows). Links, blockquotes, and hr are not in scope for this story.
- Alignment colons parsed, rendered left.
- Wide tables get a horizontal scroll wrapper inside the bubble.
UI Visualization & Structure
- Renderer (
frontend/assets/markdown.js): a table-protection pass between the existing fence pass and the escape pass — consecutive lines forming a table (every line contains|; line 2 matches the separator^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$-style rule with ≥1 cell) are pulled out, each cell is escaped + inline- transformed, and the block is reinserted as protected HTML (same\u0000CODE…\u0000placeholder mechanism as code fences, or a sibling placeholder — the existing restore step is the only place placeholders are re-expanded). Output shape: ``.…` / ` - CSS (
frontend/assets/styles.css):.md-table-wrap { overflow-x: auto; }(the wrapper is the scroller — the table keeps natural width);.md-tableborder-collapse,th/tdborders from--line, padding ≈0.4rem 0.6rem,theadtinted from the surface palette; fits the 46rem column without a new container. - Mock (
tests/e2e/mock_llm.py): newTABLE_TRIGGER(a substring like"show me a table") →compose_answerreturns a fixed deterministic GFM table answer (checked before the default tail-echo branch), including one deliberately wide table for the overflow assertion. - Non-goals: no new library (A11), no CDN, no change to the escape- first architecture, no table editing in the steering/tuning UI.
Playwright Mapping Rule
Test Scenario → tests/e2e/test_markdown_tables.py (mock LLM; DB
up):
test_chat_table_renders— ask a question containingTABLE_TRIGGER; the brain bubble contains<table class="md-table">with a<thead>(header cells as<th scope="col">) and the expected body-cell texts; no raw|---|separator text in the bubble.test_wide_table_scrolls— the mock's wide table: the bubble's.md-table-wraphasscrollWidth > clientWidthand horizontal wheel/scroll moves it; the 46rem column itself does not overflow the page.test_table_xss_safe— a table whose cell contains an HTML tag (mock variant or document content) renders the tag as text (no injected element).test_viewer_table_renders(shared renderer) — an indexed fixture document containing a pipe table opens in the document modal and renders the same<table class="md-table">.test_fence_not_a_table(regression) — a fenced code block full of|pipes renders as<pre><code>, no<table>.test_plain_pipe_stays_text(regression) — a prose answer with a single|renders as text, no<table>.