feat(renderer): interactive table with sort, filter & CSV export (M10.9)

- InteractiveTable.vue: sortable columns, row filter, CSV export
- Extracts markdown tables from AI responses into structured data
- Click column header to sort (asc/desc), numeric-aware
- Filter input for live row filtering
- Renders alongside regular chat markdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-04 00:00:08 +00:00
co-authored by Claude Opus 4.6
parent 9404085668
commit 04a175171b
3 changed files with 199 additions and 0 deletions
@@ -200,6 +200,15 @@
/>
</div>
<div v-if="inlineTables.length > 0" class="mt-3 space-y-2" @click.stop>
<InteractiveTable
v-for="(table, i) in inlineTables"
:key="`table-${i}`"
:headers="table.headers"
:rows="table.rows"
/>
</div>
<div v-if="inlineNewsLinks.length > 0" class="mt-3 space-y-1" @click.stop>
<NewsCard
v-for="(link, i) in inlineNewsLinks"
@@ -346,6 +355,8 @@ import { extractCashuTokens } from '@/utils/cashu'
import { extractRecipes, extractEvents } from '@/composables/contentExtraction'
import RecipeCard from '@/components/renderers/RecipeCard.vue'
import EventCard from '@/components/renderers/EventCard.vue'
import InteractiveTable from '@/components/renderers/InteractiveTable.vue'
import { extractTables } from '@/composables/useTableExtractor'
const props = withDefaults(
defineProps<{
@@ -443,6 +454,11 @@ const inlineRecipes = computed(() => {
return extractRecipes(props.message.content)
})
const inlineTables = computed(() => {
if (isUser.value) return []
return extractTables(props.message.content)
})
const inlineEvents = computed(() => {
if (isUser.value) return []
return extractEvents(props.message.content)
@@ -0,0 +1,134 @@
<template>
<div class="interactive-table my-4 rounded-lg bg-white/5 border border-white/5 overflow-hidden">
<!-- Toolbar -->
<div class="flex items-center gap-2 px-3 py-2 border-b border-white/5">
<input
v-model="filterQuery"
type="text"
class="flex-1 bg-white/5 border border-white/10 rounded px-2 py-1 text-xs text-white/80 outline-none focus:border-accent/40 placeholder:text-white/25"
placeholder="Filter rows..."
/>
<button
class="text-[10px] px-2 py-1 rounded bg-white/5 border border-white/10 text-white/50 hover:text-white/80 hover:bg-white/10 transition-colors"
title="Export CSV"
@click="exportCsv"
>
CSV
</button>
</div>
<!-- Table -->
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr class="border-b border-white/10">
<th
v-for="(header, col) in headers"
:key="col"
class="px-3 py-2 text-left text-white/50 font-medium cursor-pointer hover:text-white/70 select-none whitespace-nowrap"
@click="toggleSort(col)"
>
<span class="inline-flex items-center gap-1">
{{ header }}
<span v-if="sortCol === col" class="text-accent">
{{ sortDir === 'asc' ? '' : '' }}
</span>
</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, i) in displayRows"
:key="i"
class="border-b border-white/5 hover:bg-white/5 transition-colors"
>
<td
v-for="(cell, j) in row"
:key="j"
class="px-3 py-1.5 text-white/70 whitespace-nowrap"
>
{{ cell }}
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="filteredRows.length === 0" class="px-3 py-4 text-center text-xs text-white/30">
No matching rows
</div>
<div v-if="filteredRows.length > 0" class="px-3 py-1.5 text-[10px] text-white/25 border-t border-white/5">
{{ filteredRows.length }} row{{ filteredRows.length === 1 ? '' : 's' }}
<span v-if="filterQuery"> (filtered from {{ rows.length }})</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{
headers: string[]
rows: string[][]
}>()
const filterQuery = ref('')
const sortCol = ref(-1)
const sortDir = ref<'asc' | 'desc'>('asc')
function toggleSort(col: number) {
if (sortCol.value === col) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
} else {
sortCol.value = col
sortDir.value = 'asc'
}
}
const filteredRows = computed(() => {
const q = filterQuery.value.toLowerCase()
if (!q) return props.rows
return props.rows.filter((row) =>
row.some((cell) => cell.toLowerCase().includes(q))
)
})
const sortedRows = computed(() => {
if (sortCol.value < 0) return filteredRows.value
const col = sortCol.value
const dir = sortDir.value === 'asc' ? 1 : -1
return [...filteredRows.value].sort((a, b) => {
const aVal = a[col] || ''
const bVal = b[col] || ''
// Try numeric sort
const aNum = parseFloat(aVal)
const bNum = parseFloat(bVal)
if (!isNaN(aNum) && !isNaN(bNum)) return (aNum - bNum) * dir
return aVal.localeCompare(bVal) * dir
})
})
const displayRows = computed(() => sortedRows.value)
function exportCsv() {
const header = props.headers.map(escapeCsvCell).join(',')
const body = props.rows.map((row) => row.map(escapeCsvCell).join(',')).join('\n')
const csv = header + '\n' + body
const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'table.csv'
a.click()
URL.revokeObjectURL(url)
}
function escapeCsvCell(cell: string): string {
if (cell.includes(',') || cell.includes('"') || cell.includes('\n')) {
return `"${cell.replace(/"/g, '""')}"`
}
return cell
}
</script>
@@ -0,0 +1,49 @@
/**
* Extract markdown tables from text into structured data
* for interactive table rendering.
*/
export interface TableData {
headers: string[]
rows: string[][]
raw: string
}
// Matches a markdown table: header row, separator row, data rows
const TABLE_RE = /^(\|[^\n]+\|)\n(\|[\s:|-]+\|)\n((?:\|[^\n]+\|\n?)+)/gm
export function extractTables(text: string): TableData[] {
const results: TableData[] = []
let m: RegExpExecArray | null
const re = new RegExp(TABLE_RE.source, TABLE_RE.flags)
while ((m = re.exec(text)) !== null) {
const headerLine = m[1]
const dataBlock = m[3]
const headers = parseRow(headerLine)
const rows = dataBlock
.trim()
.split('\n')
.map(parseRow)
.filter((r) => r.length > 0)
if (headers.length > 0 && rows.length > 0) {
results.push({ headers, rows, raw: m[0] })
}
}
return results
}
function parseRow(line: string): string[] {
return line
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim())
}
export function hasTables(text: string): boolean {
return TABLE_RE.test(text)
}