Files
oikos/web/src/lib/components/SessionRail.svelte
dtoro fb4c76ba82 fix(ui): implement UI review findings — a11y, IA, and consistency fixes
Fixes the reviewed gaps: keyboard-inaccessible delete controls (SessionRail,
Entities row), case-sensitive entity filter, two competing entity-detail
navigation patterns (standardize on EntitySheet), non-clickable Overview KPI
cards, a bare button bypassing the shared Button component, inconsistent
blur-only vs live filtering, and an unenforced sanitization assumption on
search snippet HTML (now using the already-present dompurify dependency).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 21:52:59 +02:00

76 lines
2.9 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte'
import { sessions, currentSession, loadSessions, loadSessionMessages, newChat, deleteSession } from '$lib/stores/chat'
import { relativeTime } from '$lib/utils'
import { Button } from '$lib/components/ui/button'
import { ScrollArea } from '$lib/components/ui/scroll-area'
import PlusIcon from '@lucide/svelte/icons/plus'
import Trash2Icon from '@lucide/svelte/icons/trash-2'
onMount(() => {
loadSessions()
})
$effect(() => {
void $currentSession
loadSessions()
})
let confirmDelete = $state<string | null>(null)
function handleDelete(e: MouseEvent, id: string) {
e.stopPropagation()
if (confirmDelete === id) {
deleteSession(id)
confirmDelete = null
} else {
confirmDelete = id
// Hide confirmation after 3s
setTimeout(() => { if (confirmDelete === id) confirmDelete = null }, 3000)
}
}
function handleClick(sessionId: string) {
confirmDelete = null
loadSessionMessages(sessionId)
}
</script>
<aside class="flex h-full w-56 shrink-0 flex-col gap-2 overflow-y-auto border-r bg-card/50 p-2">
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => newChat()}>
<PlusIcon class="size-3.5" />
New chat
</Button>
<ScrollArea class="min-h-0 flex-1">
<div class="flex flex-col gap-1 pr-2">
{#each $sessions as session (session.id)}
<div class="group relative">
<button
type="button"
class="flex w-full flex-col items-start gap-0.5 rounded-md border py-1.5 pl-2 pr-7 text-left text-xs transition-colors hover:bg-muted/60 {$currentSession === session.id ? 'border-primary bg-muted/50' : 'border-transparent'}"
onclick={() => handleClick(session.id)}
>
<span class="min-w-0 max-w-full truncate font-medium">{session.title || 'Untitled'}</span>
<span class="text-[11px] text-muted-foreground">{relativeTime(session.last_active_at)}</span>
</button>
<button
type="button"
class="absolute right-1 top-1.5 shrink-0 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 hover:bg-destructive/20 hover:text-destructive"
onclick={(e) => handleDelete(e, session.id)}
aria-label={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
title={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
>
{#if confirmDelete === session.id}
<span class="text-[10px] font-semibold text-destructive">Sure?</span>
{:else}
<Trash2Icon class="size-3" />
{/if}
</button>
</div>
{:else}
<p class="px-2 py-4 text-center text-xs text-muted-foreground">No sessions yet.</p>
{/each}
</div>
</ScrollArea>
</aside>