feat(web): adopt @vincjo/datatables for all tables, standardize shared components

- Add DataTable.svelte: declarative columns, built-in sorting, sticky headers,
  text truncation, column alignment, configurable widths, optional pagination/search
- 12 built-in renderers: BadgeRenderer, StatusBadgeRenderer (unified risk/severity/
  execution/state/type variant mapping), HealthDotRenderer, RelativeTimeRenderer,
  DateRenderer, DurationRenderer, StatusDotRenderer, SignalActions, ApprovalActions,
  ActivityAction, ActivityCancel
- Migrate Overview (task board), Signals, Ops (3 tables) to DataTable
- Refactor EntityTable treegrid to use shared SortHeader, EmptyState, HealthDotRenderer
- Create shared components: EmptyState, StatusBadge, FilterTabs
- Clean up Knowledge.svelte: replace inline relTime() and typeVariant() with shared utils
- Add width, align, truncate column props; table-fixed layout; rounded-xl borders
- Bump version to 0.11.0
This commit is contained in:
2026-07-21 13:19:03 +02:00
parent ccbf6a8aac
commit 50aed11cc4
32 changed files with 1347 additions and 366 deletions

View File

@@ -3,9 +3,11 @@
import { sessions, loadSessions } from '$lib/stores/chat'
import { openTaskWindow, openNewTaskWindow } from '$lib/stores/windows'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import { bucket, statusStyle, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks'
import { relativeTime } from '$lib/utils'
import { bucket, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks'
import { Button } from '$lib/components/ui/button'
import DataTable from '$lib/components/data-table/DataTable.svelte'
import type { DataTableColumn } from '$lib/components/data-table/DataTable.svelte.ts'
import StatusDotRenderer from '$lib/components/data-table/renderers/StatusDotRenderer.svelte'
import PlusIcon from '@lucide/svelte/icons/plus'
import type { Session } from '$lib/api'
@@ -17,6 +19,7 @@
for (const s of $sessions) c[bucket(s)]++
return c
})
const visible = $derived(
filter === 'all' ? $sessions : $sessions.filter((s) => bucket(s) === filter)
)
@@ -29,9 +32,6 @@
loadSessions()
const unsubStream = subscribeEvents()
// Refetch the board when a task's lifecycle changes anywhere. Scan all
// events newer than the last seen (entity.touched fires constantly and
// buries task.status); debounce a burst into one refetch.
let lastSeenId = 0
let refreshTimer: ReturnType<typeof setTimeout> | null = null
const unsub = liveEvents.subscribe((evs) => {
@@ -51,6 +51,19 @@
unsubStream()
}
})
const columns: DataTableColumn<Session>[] = [
{ key: '_status', header: 'Status', render: StatusDotRenderer, width: '140px' },
{ key: '_heading', header: 'Task', sortable: true, accessor: heading, truncate: true },
{ key: 'summary', header: 'Summary', accessor: (s) => s.summary || '—', truncate: true, headerClass: 'hidden md:table-cell', class: 'hidden md:table-cell' },
{ key: 'last_active_at', header: 'Last active', render: 'relative-time', sortable: true, width: '112px', align: 'right' },
]
const emptyMessage = $derived(
filter === 'all'
? 'No tasks yet. Start one and Nomos will plan it, execute it, and report the outcome.'
: `No ${FILTERS.find((f) => f.id === filter)?.label.toLowerCase()} tasks.`
)
</script>
<div class="relative flex h-full flex-col gap-3 overflow-hidden p-4">
@@ -75,51 +88,13 @@
</Button>
</div>
<div class="relative z-10 min-h-0 flex-1 overflow-auto rounded-xl border bg-card/70 backdrop-blur">
{#if visible.length === 0}
<div class="flex flex-col items-center gap-2 px-4 py-16 text-center">
<p class="max-w-sm text-sm text-muted-foreground">
{filter === 'all'
? 'No tasks yet. Start one and Nomos will plan it, execute it, and report the outcome.'
: `No ${FILTERS.find((f) => f.id === filter)?.label.toLowerCase()} tasks.`}
</p>
</div>
{:else}
<table class="w-full text-sm">
<thead>
<tr class="border-b text-left text-xs text-muted-foreground [&>th]:sticky [&>th]:top-0 [&>th]:z-10 [&>th]:bg-card/95 [&>th]:backdrop-blur">
<th class="w-36 px-4 py-2 font-medium">Status</th>
<th class="px-4 py-2 font-medium">Task</th>
<th class="hidden px-4 py-2 font-medium md:table-cell">Summary</th>
<th class="w-28 px-4 py-2 text-right font-medium">Last active</th>
</tr>
</thead>
<tbody>
{#each visible as s (s.id)}
{@const st = statusStyle(s)}
<tr
class="cursor-pointer border-b last:border-0 transition-colors hover:bg-muted/40"
onclick={() => openTask(s)}
>
<td class="px-4 py-2.5">
<span class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span class="size-2 rounded-full {st.dot} {st.pulse ? 'animate-pulse' : ''}"></span>
{st.label}
</span>
</td>
<td class="max-w-0 px-4 py-2.5">
<span class="line-clamp-1 font-medium">{heading(s)}</span>
</td>
<td class="hidden max-w-0 px-4 py-2.5 md:table-cell">
<span class="line-clamp-1 text-xs text-muted-foreground">{s.summary || '—'}</span>
</td>
<td class="whitespace-nowrap px-4 py-2.5 text-right text-[11px] text-muted-foreground">
{relativeTime(s.last_active_at)}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<div class="relative z-10 min-h-0 flex-1 overflow-hidden rounded-xl border bg-card/70 backdrop-blur">
<DataTable
{columns}
data={visible}
{emptyMessage}
bordered={false}
onRowClick={openTask}
/>
</div>
</div>