Release v0.51.322 — Release KL (sortable + filterable markdown tables, #3728) (#3803)
Some checks failed
Release & Docker / release (push) Has been cancelled
Some checks failed
Release & Docker / release (push) Has been cancelled
#3728 (@rodboev): markdown tables sortable+filterable on desktop, suppressed <=640px so mobile is unchanged. UX-approved by Nathan via Telegram. Full suite 8234, CI 11/11, live-driven. Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
@@ -3,6 +3,11 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.322] — 2026-06-07 — Release KL (sortable + filterable markdown tables)
|
||||
|
||||
### Added
|
||||
- **Markdown tables in chat are now sortable and filterable on desktop.** Click any column header to sort (numeric-aware), and tables with 4+ rows get a filter box. Column headers show a faint sort indicator that becomes a solid arrow on the active column. Progressive enhancement only — it doesn't change how tables look until you interact, skips CSV tables, and is suppressed below the 640px mobile breakpoint so phone tables render exactly as before (no added wrapping). (#3728, @rodboev)
|
||||
|
||||
## [v0.51.321] — 2026-06-07 — Release KK (Phase 3 light — load renderable transcript tails)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -744,6 +744,8 @@ const LOCALES = {
|
||||
tab_settings: 'Settings',
|
||||
new_conversation: 'New conversation',
|
||||
filter_conversations: 'Filter conversations...',
|
||||
markdown_table_filter: 'Filter table',
|
||||
markdown_table_sort_column: 'Sort column',
|
||||
session_time_unknown: 'Unknown',
|
||||
session_time_minutes_ago: (n) => `${n}m`,
|
||||
session_time_hours_ago: (n) => `${n}h`,
|
||||
@@ -2085,6 +2087,8 @@ const LOCALES = {
|
||||
tab_settings: 'Impostazioni',
|
||||
new_conversation: 'Nuova conversazione',
|
||||
filter_conversations: 'Filtra conversazioni...',
|
||||
markdown_table_filter: 'Filtra tabella',
|
||||
markdown_table_sort_column: 'Ordina colonna',
|
||||
session_time_unknown: 'Sconosciuto',
|
||||
session_time_minutes_ago: (n) => `${n}m`,
|
||||
session_time_hours_ago: (n) => `${n}h`,
|
||||
@@ -3418,6 +3422,8 @@ const LOCALES = {
|
||||
tab_settings: '設定',
|
||||
new_conversation: '新しい会話',
|
||||
filter_conversations: '会話を絞り込み...',
|
||||
markdown_table_filter: 'テーブルをフィルター',
|
||||
markdown_table_sort_column: '列を並べ替え',
|
||||
session_time_unknown: '不明',
|
||||
session_time_minutes_ago: (n) => `${n}分前`,
|
||||
session_time_hours_ago: (n) => `${n}時間前`,
|
||||
@@ -4562,6 +4568,8 @@ const LOCALES = {
|
||||
logs_filter_active: 'показано (фильтр активен)',
|
||||
new_conversation: 'Новая беседа',
|
||||
filter_conversations: 'Фильтр бесед...',
|
||||
markdown_table_filter: 'Фильтр таблицы',
|
||||
markdown_table_sort_column: 'Сортировать столбец',
|
||||
session_time_unknown: 'Неизвестно',
|
||||
session_time_minutes_ago: (n) => `${n}м`,
|
||||
session_time_hours_ago: (n) => `${n}ч`,
|
||||
@@ -5832,6 +5840,8 @@ const LOCALES = {
|
||||
logs_filter_active: 'mostrados (filtro activo)',
|
||||
new_conversation: 'Nueva conversación',
|
||||
filter_conversations: 'Filtrar conversaciones...',
|
||||
markdown_table_filter: 'Filtrar tabla',
|
||||
markdown_table_sort_column: 'Ordenar columna',
|
||||
session_time_unknown: 'Desconocido',
|
||||
session_time_minutes_ago: (n) => `${n}m`,
|
||||
session_time_hours_ago: (n) => `${n}h`,
|
||||
@@ -7086,6 +7096,8 @@ const LOCALES = {
|
||||
logs_filter_active: 'angezeigt (Filter aktiv)',
|
||||
new_conversation: 'Neuer Chat',
|
||||
filter_conversations: 'Chats filtern...',
|
||||
markdown_table_filter: 'Tabelle filtern',
|
||||
markdown_table_sort_column: 'Spalte sortieren',
|
||||
scheduled_jobs: 'Geplante Aufgaben',
|
||||
new_job: 'Neuer Job',
|
||||
loading: 'Lädt...',
|
||||
@@ -8385,6 +8397,8 @@ const LOCALES = {
|
||||
logs_filter_active: '已显示(筛选器已启用)',
|
||||
new_conversation: '新建对话',
|
||||
filter_conversations: '筛选对话…',
|
||||
markdown_table_filter: '筛选表格',
|
||||
markdown_table_sort_column: '列排序',
|
||||
session_time_unknown: '未知',
|
||||
session_time_minutes_ago: (n) => `${n}分钟前`,
|
||||
session_time_hours_ago: (n) => `${n}小时前`,
|
||||
@@ -9832,6 +9846,8 @@ const LOCALES = {
|
||||
tab_settings: '設定',
|
||||
new_conversation: '新增對話',
|
||||
filter_conversations: '篩選對話……',
|
||||
markdown_table_filter: '篩選表格',
|
||||
markdown_table_sort_column: '欄排序',
|
||||
session_time_unknown: '未知',
|
||||
session_time_minutes_ago: (m) => `${m}分`,
|
||||
session_time_hours_ago: (h) => `${h}小時`,
|
||||
@@ -11067,6 +11083,8 @@ const LOCALES = {
|
||||
logs_filter_active: 'exibidos (filtro ativo)',
|
||||
new_conversation: 'Nova conversa',
|
||||
filter_conversations: 'Filtrar conversas...',
|
||||
markdown_table_filter: 'Filtrar tabela',
|
||||
markdown_table_sort_column: 'Ordenar coluna',
|
||||
session_time_unknown: 'Desconhecido',
|
||||
session_time_minutes_ago: (n) => `${n}m`,
|
||||
session_time_hours_ago: (n) => `${n}h`,
|
||||
@@ -12300,6 +12318,8 @@ const LOCALES = {
|
||||
logs_filter_active: '표시됨(필터 활성)',
|
||||
new_conversation: '새 대화',
|
||||
filter_conversations: '대화 필터…',
|
||||
markdown_table_filter: '표 필터',
|
||||
markdown_table_sort_column: '열 정렬',
|
||||
session_time_unknown: 'Unknown',
|
||||
session_time_minutes_ago: (n) => `${n}m`,
|
||||
session_time_hours_ago: (n) => `${n}h`,
|
||||
@@ -13514,6 +13534,8 @@ const LOCALES = {
|
||||
tab_settings: 'Paramètres',
|
||||
new_conversation: 'Nouvelle conversation',
|
||||
filter_conversations: 'Filtrer les conversations...',
|
||||
markdown_table_filter: 'Filtrer le tableau',
|
||||
markdown_table_sort_column: 'Trier la colonne',
|
||||
session_time_unknown: 'Inconnu',
|
||||
session_time_last_week: '1w',
|
||||
session_time_bucket_today: 'Aujourd\'hui',
|
||||
@@ -14902,6 +14924,8 @@ const LOCALES = {
|
||||
logs_filter_active: 'gösteriliyor (filtre etkin)',
|
||||
new_conversation: 'Yeni görüşme',
|
||||
filter_conversations: 'Konuşmaları filtrele...',
|
||||
markdown_table_filter: 'Tabloyu filtrele',
|
||||
markdown_table_sort_column: 'Sütunu sırala',
|
||||
session_time_unknown: 'Bilinmiyor',
|
||||
session_time_minutes_ago: (n) => `${n} dk önce`,
|
||||
session_time_hours_ago: (n) => `${n} sa önce`,
|
||||
@@ -16228,6 +16252,8 @@ const LOCALES = {
|
||||
tab_settings: 'Ustawienia',
|
||||
new_conversation: 'Nowa konwersacja',
|
||||
filter_conversations: 'Filtruj konwersacje...',
|
||||
markdown_table_filter: 'Filtruj tabelę',
|
||||
markdown_table_sort_column: 'Sortuj kolumnę',
|
||||
session_time_unknown: 'Nieznany',
|
||||
session_time_minutes_ago: (n) => `${n} min temu`,
|
||||
session_time_hours_ago: (n) => `${n} godz. temu`,
|
||||
|
||||
@@ -76,6 +76,103 @@ let _selectedTextReplyText='';
|
||||
let _selectedTextReplyRaf=0;
|
||||
const _persistentStateToastSeen=new Set();
|
||||
|
||||
function enhanceMarkdownTables(root){
|
||||
if(!root||!root.querySelectorAll) return;
|
||||
const scope=root;
|
||||
const tables=scope.querySelectorAll('.msg-body table:not([data-markdown-table-enhanced])');
|
||||
const sortLabel=typeof t==='function'?t('markdown_table_sort_column'):'Sort column';
|
||||
const filterLabel=typeof t==='function'?t('markdown_table_filter'):'Filter table';
|
||||
tables.forEach((table)=>{
|
||||
if(table.closest('.csv-table-wrap')) return;
|
||||
const headRows=table.tHead?Array.from(table.tHead.rows):[];
|
||||
const body=table.tBodies&&table.tBodies.length?table.tBodies[0]:table;
|
||||
const bodyRows=Array.from(body.rows||[]).filter((row)=>row.parentElement===body);
|
||||
const headerRow=headRows[0]||table.querySelector('tr');
|
||||
if(!headerRow||!bodyRows.length) return;
|
||||
table.setAttribute('data-markdown-table-enhanced','1');
|
||||
bodyRows.forEach((row,idx)=>{ row.dataset.markdownTableOriginalIndex=String(idx); });
|
||||
|
||||
if(bodyRows.length>=4&&table.parentElement){
|
||||
const filter=document.createElement('input');
|
||||
filter.type='search';
|
||||
filter.className='markdown-table-filter';
|
||||
filter.placeholder=filterLabel;
|
||||
filter.setAttribute('aria-label',filterLabel);
|
||||
filter.autocomplete='off';
|
||||
filter.spellcheck=false;
|
||||
filter.addEventListener('input',()=>{
|
||||
const query=_markdownTableText(filter.value).toLowerCase();
|
||||
bodyRows.forEach((row)=>{
|
||||
row.hidden=!!query&&!_markdownTableText(row.textContent).toLowerCase().includes(query);
|
||||
});
|
||||
});
|
||||
table.parentElement.insertBefore(filter,table);
|
||||
}
|
||||
|
||||
Array.from(headerRow.cells||[]).forEach((cell,colIdx)=>{
|
||||
const button=document.createElement('button');
|
||||
button.type='button';
|
||||
button.className='markdown-table-sort';
|
||||
const columnName=_markdownTableText(cell.textContent)||String(colIdx+1);
|
||||
const columnSortLabel=`${sortLabel}: ${columnName}`;
|
||||
button.setAttribute('aria-label',columnSortLabel);
|
||||
button.title=columnSortLabel;
|
||||
cell.setAttribute('aria-sort','none');
|
||||
const label=document.createElement('span');
|
||||
label.className='markdown-table-sort-label';
|
||||
while(cell.firstChild) label.appendChild(cell.firstChild);
|
||||
const indicator=document.createElement('span');
|
||||
indicator.className='markdown-table-sort-indicator';
|
||||
indicator.setAttribute('aria-hidden','true');
|
||||
button.appendChild(label);
|
||||
button.appendChild(indicator);
|
||||
button.addEventListener('click',()=>{
|
||||
const nextDir=table.dataset.markdownTableSortCol===String(colIdx)&&table.dataset.markdownTableSortDir==='asc'?'desc':'asc';
|
||||
table.dataset.markdownTableSortCol=String(colIdx);
|
||||
table.dataset.markdownTableSortDir=nextDir;
|
||||
Array.from(headerRow.cells||[]).forEach((other)=>{
|
||||
other.setAttribute('aria-sort','none');
|
||||
});
|
||||
cell.setAttribute('aria-sort',nextDir==='asc'?'ascending':'descending');
|
||||
const rows=Array.from(body.rows||[]).filter((row)=>row.parentElement===body);
|
||||
rows.sort((a,b)=>{
|
||||
const av=_markdownTableCellText(a.cells[colIdx]);
|
||||
const bv=_markdownTableCellText(b.cells[colIdx]);
|
||||
const cmp=av.localeCompare(bv,undefined,{numeric:true,sensitivity:'base'});
|
||||
if(cmp!==0) return nextDir==='asc'?cmp:-cmp;
|
||||
const ai=Number(a.dataset.markdownTableOriginalIndex||0);
|
||||
const bi=Number(b.dataset.markdownTableOriginalIndex||0);
|
||||
return ai-bi;
|
||||
});
|
||||
rows.forEach((row)=>body.appendChild(row));
|
||||
});
|
||||
cell.appendChild(button);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _markdownTableText(value){
|
||||
return String(value||'').replace(/\s+/g,' ').trim();
|
||||
}
|
||||
|
||||
function _markdownTableCellText(cell){
|
||||
return _markdownTableText(cell?cell.textContent:'');
|
||||
}
|
||||
|
||||
window.enhanceMarkdownTables=enhanceMarkdownTables;
|
||||
|
||||
(function _wireMarkdownTableEnhancer(){
|
||||
if(typeof window==='undefined'||typeof window.renderMessages!=='function'||window.renderMessages._markdownTablesEnhanced) return;
|
||||
const baseRenderMessages=window.renderMessages;
|
||||
window.renderMessages=function(...args){
|
||||
const result=baseRenderMessages.apply(this,args);
|
||||
const inner=typeof $==='function'?$('msgInner'):document.getElementById('msgInner');
|
||||
enhanceMarkdownTables(inner);
|
||||
return result;
|
||||
};
|
||||
window.renderMessages._markdownTablesEnhanced=true;
|
||||
})();
|
||||
|
||||
function _persistentToastText(value){
|
||||
if(value===null||value===undefined)return '';
|
||||
if(typeof value==='string')return value;
|
||||
@@ -1521,7 +1618,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
try{window.smd.parser_end(_smdParser);}catch(_){}
|
||||
// parser_end may flush remaining markdown that creates new links/images —
|
||||
// re-sanitize the body before the DOM is handed off to highlightCode / renderMessages.
|
||||
if(assistantBody){_sanitizeSmdLinks(assistantBody);}
|
||||
if(assistantBody){_sanitizeSmdLinks(assistantBody);enhanceMarkdownTables(assistantBody);}
|
||||
}
|
||||
_smdParser=null;
|
||||
_smdWrittenLen=0;
|
||||
|
||||
@@ -1667,6 +1667,30 @@
|
||||
.msg-body th{background:rgba(255,255,255,.07);padding:6px 10px;text-align:left;font-weight:600;border:1px solid var(--border2);}
|
||||
.msg-body td{padding:5px 10px;border:1px solid rgba(255,255,255,.06);}
|
||||
.msg-body tr:nth-child(even){background:rgba(255,255,255,.03);}
|
||||
.markdown-table-filter{display:block;width:min(260px,100%);margin:8px 0 4px;padding:5px 8px;border:1px solid var(--border2);border-radius:6px;background:var(--input-bg);color:var(--text);font:inherit;font-size:12px;}
|
||||
.markdown-table-filter:focus{outline:1px solid var(--accent);outline-offset:1px;}
|
||||
.markdown-table-sort{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;min-height:20px;padding:0;border:0;background:transparent;color:inherit;font:inherit;font-weight:inherit;text-align:left;cursor:pointer;}
|
||||
.markdown-table-sort:focus-visible{outline:1px solid var(--accent);outline-offset:2px;}
|
||||
.markdown-table-sort-label{min-width:0;}
|
||||
.markdown-table-sort-indicator{flex:0 0 10px;color:var(--muted);font-size:10px;line-height:1;text-align:center;}
|
||||
/* Neutral (unsorted) affordance: a faint up/down glyph so it's discoverable
|
||||
that the header is clickable/sortable. Becomes a solid ▲/▼ when active. */
|
||||
.markdown-table-sort-indicator::before{content:"\2195";opacity:0.45;}
|
||||
.markdown-table-sort:hover .markdown-table-sort-indicator::before{opacity:0.8;}
|
||||
.msg-body th[aria-sort="ascending"] .markdown-table-sort-indicator::before{content:"\25B2";opacity:1;}
|
||||
.msg-body th[aria-sort="descending"] .markdown-table-sort-indicator::before{content:"\25BC";opacity:1;}
|
||||
/* Mobile: tables are already width-constrained at <=640px and tapping a tiny
|
||||
squished header to sort is impractical on a phone. Reserving width for the
|
||||
sort glyph + the filter row made narrow headers wrap MORE than the plain
|
||||
table did (regression). So below the mobile breakpoint, render headers as
|
||||
plain text — no reserved glyph width, no filter row — matching the
|
||||
pre-feature mobile layout exactly. Sortable/filter stays a desktop/tablet
|
||||
affordance where it has room and a pointer. */
|
||||
@media (max-width: 640px){
|
||||
.markdown-table-sort{display:inline;width:auto;gap:0;min-height:0;}
|
||||
.markdown-table-sort-indicator{display:none;}
|
||||
.markdown-table-filter{display:none;}
|
||||
}
|
||||
/* #486: inline code inside table cells needs scaled sizing to avoid overflow/clipping */
|
||||
.msg-body td code,.msg-body th code{font-size:0.85em;padding:1px 4px;vertical-align:baseline;}
|
||||
/* KaTeX math rendering */
|
||||
|
||||
92
tests/test_issue2966_markdown_table_enhancer.py
Normal file
92
tests/test_issue2966_markdown_table_enhancer.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _read_static(name):
|
||||
return (ROOT / "static" / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _locale_blocks():
|
||||
text = _read_static("i18n.js")
|
||||
matches = list(re.finditer(r"^ '?([A-Za-z]{2}(?:-[A-Za-z]+)?)'?: \{", text, re.M))
|
||||
assert matches, "could not find locale blocks"
|
||||
blocks = {}
|
||||
for idx, match in enumerate(matches):
|
||||
end = matches[idx + 1].start() if idx + 1 < len(matches) else text.index("\n};", match.end())
|
||||
blocks[match.group(1)] = text[match.end():end]
|
||||
return blocks
|
||||
|
||||
|
||||
def test_markdown_table_enhancer_is_registered_and_invoked_after_render_paths():
|
||||
messages = _read_static("messages.js")
|
||||
|
||||
assert "function enhanceMarkdownTables(root)" in messages
|
||||
assert "window.enhanceMarkdownTables=enhanceMarkdownTables" in messages
|
||||
assert "function _wireMarkdownTableEnhancer()" in messages
|
||||
assert "window.renderMessages=function(...args)" in messages
|
||||
assert "enhanceMarkdownTables(inner)" in messages
|
||||
|
||||
smd_end = messages[messages.index("function _smdEndParser()"):messages.index("function _scheduleStreamingKatex()")]
|
||||
assert "_sanitizeSmdLinks(assistantBody);enhanceMarkdownTables(assistantBody);" in smd_end
|
||||
|
||||
|
||||
def test_markdown_table_enhancement_is_idempotent_and_message_scoped():
|
||||
messages = _read_static("messages.js")
|
||||
helper = messages[messages.index("function enhanceMarkdownTables(root)"):messages.index("function _markdownTableText")]
|
||||
|
||||
assert ".msg-body table:not([data-markdown-table-enhanced])" in helper
|
||||
assert "data-markdown-table-enhanced" in helper
|
||||
assert "table.setAttribute('data-markdown-table-enhanced','1')" in helper
|
||||
assert ".csv-table-wrap" in helper
|
||||
|
||||
|
||||
def test_markdown_table_sorting_uses_accessible_buttons_and_stable_rows():
|
||||
messages = _read_static("messages.js")
|
||||
helper = messages[messages.index("function enhanceMarkdownTables(root)"):messages.index("function _markdownTableText")]
|
||||
|
||||
assert "document.createElement('button')" in helper
|
||||
assert "button.type='button'" in helper
|
||||
assert "const columnName=_markdownTableText(cell.textContent)||String(colIdx+1)" in helper
|
||||
assert "const columnSortLabel=`${sortLabel}: ${columnName}`" in helper
|
||||
assert "button.setAttribute('aria-label',columnSortLabel)" in helper
|
||||
assert "button.title=columnSortLabel" in helper
|
||||
assert "cell.setAttribute('aria-sort','none')" in helper
|
||||
assert "other.setAttribute('aria-sort','none')" in helper
|
||||
assert "cell.setAttribute('aria-sort',nextDir==='asc'?'ascending':'descending')" in helper
|
||||
assert "row.dataset.markdownTableOriginalIndex=String(idx)" in helper
|
||||
assert "localeCompare(bv,undefined,{numeric:true,sensitivity:'base'})" in helper
|
||||
assert "return ai-bi" in helper
|
||||
|
||||
|
||||
def test_markdown_table_filter_is_gated_to_multi_row_tables_and_preserves_rows():
|
||||
messages = _read_static("messages.js")
|
||||
helper = messages[messages.index("function enhanceMarkdownTables(root)"):messages.index("function _markdownTableText")]
|
||||
|
||||
assert "if(bodyRows.length>=4&&table.parentElement)" in helper
|
||||
assert "filter.type='search'" in helper
|
||||
assert "filter.placeholder=filterLabel" in helper
|
||||
assert "filter.setAttribute('aria-label',filterLabel)" in helper
|
||||
assert "row.hidden=!!query" in helper
|
||||
assert "body.appendChild(row)" in helper
|
||||
|
||||
|
||||
def test_markdown_table_styles_keep_controls_compact():
|
||||
style = _read_static("style.css")
|
||||
|
||||
assert ".markdown-table-filter" in style
|
||||
assert "width:min(260px,100%)" in style
|
||||
assert ".markdown-table-sort{display:flex" in style
|
||||
assert "min-height:20px" in style
|
||||
assert ".msg-body th[aria-sort=\"ascending\"]" in style
|
||||
assert ".msg-body th[aria-sort=\"descending\"]" in style
|
||||
|
||||
|
||||
def test_markdown_table_i18n_keys_are_present_in_every_locale():
|
||||
assert "zh-Hant" in _locale_blocks()
|
||||
for locale, block in _locale_blocks().items():
|
||||
keys = set(re.findall(r"^\s*([A-Za-z0-9_]+):", block, re.M))
|
||||
assert "markdown_table_filter" in keys, f"{locale} missing markdown_table_filter"
|
||||
assert "markdown_table_sort_column" in keys, f"{locale} missing markdown_table_sort_column"
|
||||
Reference in New Issue
Block a user