AI Chat Fancy Stable
ThreadList
Conversation history for a chat sidebar: one row per thread with an unread dot, its last message, and a relative timestamp, plus selection and per-row delete
Preview
svelte
<script lang="ts">
import { ThreadList } from "fancy-ui-svelte";
import type { ThreadData } from "fancy-ui-svelte";
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
// One reading of the clock for the whole fixture, so the rows keep their
// order relative to each other. Every offset is minutes or more, which is
// what keeps the relative text identical on the server and after hydration.
const now = Date.now();
let threads = $state<ThreadData[]>([
{
id: "t1",
title: "Retry policy for billing webhooks",
preview: "So a 429 should back off exponentially, capped at five minutes.",
updatedAt: now - 4 * MINUTE,
unread: true,
},
{
id: "t2",
title: "Migration plan for the events table",
preview: "Split it before the backfill, not after.",
updatedAt: now - 3 * HOUR,
},
{
id: "t3",
title: "Naming the new endpoint",
preview: "/v2/runs reads better than /v2/executions.",
updatedAt: now - 26 * HOUR,
},
{
id: "t4",
title: "Why the dashboard is slow on Mondays",
preview: "Every cron lands at 00:00 UTC and they all query the same index.",
updatedAt: now - 3 * DAY,
},
{
id: "t5",
title: "Copy pass on the onboarding emails",
preview: "Three of the four say “simply”.",
updatedAt: now - 9 * DAY,
},
]);
let activeId = $state("t2");
const selected = $derived(threads.find((thread) => thread.id === activeId));
function open(thread: ThreadData) {
// Opening a conversation is what marks it read — the list itself never
// mutates the data it was handed.
threads = threads.map((entry) =>
entry.id === thread.id ? { ...entry, unread: false } : entry
);
}
function remove(thread: ThreadData) {
threads = threads.filter((entry) => entry.id !== thread.id);
if (activeId === thread.id) activeId = threads[0]?.id ?? "";
}
</script>
<div class="bg-card grid w-full max-w-2xl gap-4 rounded-lg border p-4 sm:grid-cols-[20rem_1fr]">
<ThreadList {threads} bind:activeId onSelect={open} onDelete={remove}>
{#snippet empty()}
<p class="text-muted-foreground px-2 py-6 text-center text-xs">
Every conversation deleted. Refresh to bring them back.
</p>
{/snippet}
</ThreadList>
<div class="bg-muted/40 flex min-h-40 flex-col justify-center rounded-md p-4">
{#if selected}
<p class="text-foreground/70 text-xs font-medium tracking-wide uppercase">Conversation</p>
<p class="mt-1 font-medium">{selected.title}</p>
<p class="text-foreground/70 mt-1 text-xs">{selected.preview}</p>
{:else}
<p class="text-foreground/70 text-xs">Pick a conversation on the left.</p>
{/if}
</div>
</div>
<p class="text-muted-foreground mt-3 max-w-2xl text-xs">
The unread dot clears the moment the thread is opened, and each row reveals its delete button on
hover — or on focus, if you tab through instead.
</p>Installation
pnpm add fancy-ui-svelte
import { ThreadList } from 'fancy-ui-svelte'
Usage
svelte
<script lang="ts">
import { ThreadList } from 'fancy-ui-svelte';
</script>
<ThreadList />Examples
Basic Usage
svelte
<script lang="ts">
import { ThreadList } from "$lib/fancy-ui/thread-list";
import type { ThreadData } from "$lib/fancy-ui";
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
// One reading of the clock for the whole fixture, so the rows keep their
// order relative to each other. Every offset is minutes or more, which is
// what keeps the relative text identical on the server and after hydration.
const now = Date.now();
let threads = $state<ThreadData[]>([
{
id: "t1",
title: "Retry policy for billing webhooks",
preview: "So a 429 should back off exponentially, capped at five minutes.",
updatedAt: now - 4 * MINUTE,
unread: true,
},
{
id: "t2",
title: "Migration plan for the events table",
preview: "Split it before the backfill, not after.",
updatedAt: now - 3 * HOUR,
},
{
id: "t3",
title: "Naming the new endpoint",
preview: "/v2/runs reads better than /v2/executions.",
updatedAt: now - 26 * HOUR,
},
{
id: "t4",
title: "Why the dashboard is slow on Mondays",
preview: "Every cron lands at 00:00 UTC and they all query the same index.",
updatedAt: now - 3 * DAY,
},
{
id: "t5",
title: "Copy pass on the onboarding emails",
preview: "Three of the four say “simply”.",
updatedAt: now - 9 * DAY,
},
]);
let activeId = $state("t2");
const selected = $derived(threads.find((thread) => thread.id === activeId));
function open(thread: ThreadData) {
// Opening a conversation is what marks it read — the list itself never
// mutates the data it was handed.
threads = threads.map((entry) =>
entry.id === thread.id ? { ...entry, unread: false } : entry
);
}
function remove(thread: ThreadData) {
threads = threads.filter((entry) => entry.id !== thread.id);
if (activeId === thread.id) activeId = threads[0]?.id ?? "";
}
</script>
<div class="bg-card grid w-full max-w-2xl gap-4 rounded-lg border p-4 sm:grid-cols-[20rem_1fr]">
<ThreadList {threads} bind:activeId onSelect={open} onDelete={remove}>
{#snippet empty()}
<p class="text-muted-foreground px-2 py-6 text-center text-xs">
Every conversation deleted. Refresh to bring them back.
</p>
{/snippet}
</ThreadList>
<div class="bg-muted/40 flex min-h-40 flex-col justify-center rounded-md p-4">
{#if selected}
<p class="text-foreground/70 text-xs font-medium tracking-wide uppercase">Conversation</p>
<p class="mt-1 font-medium">{selected.title}</p>
<p class="text-foreground/70 mt-1 text-xs">{selected.preview}</p>
{:else}
<p class="text-foreground/70 text-xs">Pick a conversation on the left.</p>
{/if}
</div>
</div>
<p class="text-muted-foreground mt-3 max-w-2xl text-xs">
The unread dot clears the moment the thread is opened, and each row reveals its delete button on
hover — or on focus, if you tab through instead.
</p>Props
| Prop | Type | Default | Description |
|---|---|---|---|
threads * | ThreadData[] | - | The conversations to list, in the order they should appear |
activeId | string | - | Id of the selected conversation (bindable). A click writes the new id back whether or not onSelect is supplied |
onSelect | (thread: ThreadData) => void | - | Called with the conversation the reader picked |
onDelete | (thread: ThreadData) => void | - | Supplying it puts a delete button on every row, revealed on hover or focus |
label | string | "Conversations" | Accessible name for the list |
Slots
| Slot | Description |
|---|---|
item | Replaces the built-in row body; receives the thread and whether it is the active one |
empty | Replaces the built-in "No conversations yet" line |
Links
Related components
Ch
ChatError
Quiet inline failure banner for a chat turn: the error, an optional detail line, and a retry button that disables itself while the retry is in flight
AI Chat Stable
Ch
ChatMessage
One conversation turn, aligned and dressed by its role, streaming its body while the answer arrives, with an action rail that fades in on hover and a version navigator underneath
AI Chat Stable
Ch
ChatPanel
The shell a conversation lives in: a sticky header, a transcript that pins itself to the bottom while an answer arrives and offers the way back once you scroll up, and a sticky composer row
AI Chat Stable