diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6fa25bab..ae569eec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
## [Unreleased]
+### Fixed
+
+- **PR #2375** by @Michaelyklam (closes #2374) — Markdown tables rendered by chat messages now stay as block-level `
` elements instead of being wrapped in paragraph tags by the renderer's final paragraph pass. This keeps CommonMark-style pipe tables visible as tables across browsers.
+
## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n)
### Added
diff --git a/static/ui.js b/static/ui.js
index c050811c..253dd86a 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -2614,7 +2614,10 @@ function renderMd(raw){
const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`| ${inlineMd(c.trim())} | `).join('');
const header=`${parseHeader(rows[0])}
`;
const body=rows.slice(2).map(r=>`${parseRow(r)}
`).join('');
- return ``;
+ // Surround with blank lines so the final paragraph splitter treats the
+ // generated table as its own block even when the regex consumes one of the
+ // markdown block's trailing newlines.
+ return `\n\n\n\n`;
});
// #487: Outer image pass — handles  in plain paragraphs (outside tables/lists).
// Runs AFTER the table pass (images in table cells are handled by inlineMd() above).
@@ -2757,7 +2760,7 @@ function renderMd(raw){
return '\x00E'+(_pre_stash.length-1)+'\x00';
});
const parts=s.split(/\n{2,}/);
- s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|pre|hr|blockquote)|^\x00[EQ]/.test(p))return p;return `${p.replace(/\n/g,'
')}
`;}).join('\n');
+ s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|table|pre|hr|blockquote)|^\x00[EQ]/.test(p))return p;return `${p.replace(/\n/g,'
')}
`;}).join('\n');
s=s.replace(/\x00E(\d+)\x00/g,(_,i)=>_pre_stash[+i]);
// ── Restore MEDIA stash → inline images or download links ─────────────────
s=s.replace(/\x00D(\d+)\x00/g,(_,i)=>{
diff --git a/tests/test_renderer_js_behaviour.py b/tests/test_renderer_js_behaviour.py
index 22a831b7..509b1d72 100644
--- a/tests/test_renderer_js_behaviour.py
+++ b/tests/test_renderer_js_behaviour.py
@@ -187,6 +187,37 @@ class TestRendererSanitization:
class TestCommonLLMShapes:
+ def test_commonmark_table_is_not_wrapped_in_paragraph(self, driver_path):
+ src = (
+ "| 升级时段 | 人数 |\n"
+ "|---------|------|\n"
+ "| 5/15(发布当天) | ~30 人 |\n"
+ "| 5/16(今天) | ~10 人 |"
+ )
+ out = _render(driver_path, src)
+ assert "" in out
+ assert "| 升级时段 | " in out
+ assert "5/15(发布当天) | " in out
+ assert "~10 人 | " in out
+ assert "Before the table." in out
+ assert "" in out
+ assert "After the table.
" in out
+ assert "" not in out
+
def test_strikethrough_outside_quote(self, driver_path):
out = _render(driver_path, "This was ~~outdated~~ but is now fine.")
assert "outdated" in out