feat(ui,api): chat view with live tail and autoscroll, conversation rail with last message previews

This commit is contained in:
hh
2026-08-29 00:18:08 +02:00
parent 8ea4cea3ef
commit 3ca5843868
9 changed files with 532 additions and 334 deletions
+1
View File
@@ -59,6 +59,7 @@ export interface ConversationSummary {
id: string;
kind: Kind;
last_activity_at: string | null;
last_item?: QueueItem | null;
last_user_activity_at: string | null;
origin: string;
parent_row: number | null;
@@ -0,0 +1,311 @@
<script lang="ts">
import PlusIcon from "@lucide/svelte/icons/plus";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import type { AgentInfo, ConversationSummary } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import KindBadge from "$lib/components/kind-badge.svelte";
import StatusPill from "$lib/components/status-pill.svelte";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select";
import { Skeleton } from "$lib/components/ui/skeleton";
import { clip, fmtRelative, shortId } from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils";
let { selected = null }: { selected?: string | null } = $props();
const KINDS = ["all", "master", "branch", "deep", "job", "fork"];
const STATUSES = ["open", "all", "merged", "closed", "archived"];
const TITLE_MAX = 60;
const PREVIEW_MAX = 90;
let kind = $state("all");
let statusFilter = $state("open");
let search = $state("");
let createOpen = $state(false);
let agents = $state<AgentInfo[]>([]);
let form = $state({
agent: "",
kind: "deep",
seed: "clean",
text: "",
title: "",
});
let busy = $state(false);
const rows = $derived.by(() => {
const needle = search.trim().toLowerCase();
return gateway.conversations
.filter((row) => kind === "all" || row.kind === kind)
.filter((row) => statusFilter === "all" || row.status === statusFilter)
.filter(
(row) =>
!needle ||
(row.title ?? "").toLowerCase().includes(needle) ||
row.id.startsWith(needle) ||
row.agent.toLowerCase().includes(needle) ||
(row.last_item?.text ?? "").toLowerCase().includes(needle)
)
.sort(byActivity);
});
function activityOf(row: ConversationSummary): string {
return row.last_activity_at ?? row.created_at ?? "";
}
function byActivity(a: ConversationSummary, b: ConversationSummary) {
if (Boolean(a.running_turn) !== Boolean(b.running_turn)) {
return a.running_turn ? -1 : 1;
}
return activityOf(b).localeCompare(activityOf(a));
}
function titleOf(row: ConversationSummary): string {
if (row.title) {
return clip(row.title, TITLE_MAX);
}
if (row.kind === "master") {
return `Master · ${shortId(row.id)}`;
}
return `${row.kind} · ${shortId(row.id)}`;
}
const agentsForKind = $derived(
agents.filter((a) =>
a.kinds.includes(form.kind as AgentInfo["kinds"][number])
)
);
async function openCreate() {
createOpen = true;
if (agents.length === 0 && session.client) {
({ agents } = await session.client.agents());
}
}
async function create() {
if (!session.client) {
return;
}
busy = true;
try {
const created = await session.client.spawn({
agent: form.agent || undefined,
kind: form.kind,
seed: form.seed,
text: form.text || undefined,
title: form.title || undefined,
});
createOpen = false;
await goto(`${base}/conversations/${created.id}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
</script>
<div class="flex h-full min-h-0 flex-col">
<div class="flex flex-wrap items-center gap-2 border-b px-3 py-2">
<Select.Root
onValueChange={(value) => {
kind = value;
}}
type="single"
value={kind}
>
<Select.Trigger class="h-8 text-xs" size="sm">
{kind === "all" ? "any kind" : kind}
</Select.Trigger>
<Select.Content>
{#each KINDS as option (option)}
<Select.Item
label={option === "all" ? "any kind" : option}
value={option}
/>
{/each}
</Select.Content>
</Select.Root>
<Select.Root
onValueChange={(value) => {
statusFilter = value;
}}
type="single"
value={statusFilter}
>
<Select.Trigger class="h-8 text-xs" size="sm">
{statusFilter === "all" ? "any status" : statusFilter}
</Select.Trigger>
<Select.Content>
{#each STATUSES as option (option)}
<Select.Item
label={option === "all" ? "any status" : option}
value={option}
/>
{/each}
</Select.Content>
</Select.Root>
<Input
aria-label="Search conversations"
class="h-8 min-w-24 flex-1 text-xs"
placeholder="search"
bind:value={search}
/>
<Button aria-label="New conversation" onclick={openCreate} size="icon-sm">
<PlusIcon class="size-4" />
</Button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
{#if gateway.live.state === "failed"}
<div class="p-3">
<ErrorNote
message={gateway.live.detail ?? "event stream failed"}
retry={() => gateway.start()}
/>
</div>
{:else if !gateway.loaded}
<div class="flex flex-col gap-2 p-3">
<Skeleton class="h-12 w-full" />
<Skeleton class="h-12 w-full" />
<Skeleton class="h-12 w-full" />
</div>
{:else if rows.length === 0}
<div class="p-3">
<EmptyState
hint="Change the filters, or start one - a deep chat, a branch off the master, a headless job."
title="No conversations match"
>
<Button onclick={openCreate} size="sm" variant="outline">
New conversation
</Button>
</EmptyState>
</div>
{:else}
<ul class="flex flex-col">
{#each rows as row (row.id)}
<li>
<a
aria-current={selected === row.id ? "page" : undefined}
class={cn(
"row-hover flex flex-col gap-1 border-b px-3 py-2 text-sm",
selected === row.id && "bg-sidebar-accent"
)}
href="{base}/conversations/{row.id}"
>
<span class="flex items-center gap-2">
<KindBadge kind={row.kind} />
<span class="min-w-0 flex-1 truncate font-medium">
{titleOf(row)}
</span>
<span class="tabular shrink-0 text-muted-foreground text-xs">
{fmtRelative(activityOf(row))}
</span>
</span>
<span class="flex items-center gap-2 text-xs">
<StatusPill
status={row.running_turn ? "running" : row.status}
/>
<span class="text-muted-foreground">{row.agent}</span>
{#if row.pending_question}
<span class="font-medium text-link">question</span>
{/if}
</span>
{#if row.last_item}
<span class="truncate text-muted-foreground text-xs">
<span class="text-foreground/70"
>{row.last_item.origin}:</span
>
{clip(row.last_item.text, PREVIEW_MAX)}
</span>
{/if}
</a>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>New conversation</Dialog.Title>
<Dialog.Description>
It opens in the home window of its kind (a vault file, a Telegram topic)
and stays silent until someone speaks.
</Dialog.Description>
</Dialog.Header>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<Label>Kind</Label>
<Select.Root
onValueChange={(value) => {
form.kind = value;
form.agent = "";
}}
type="single"
value={form.kind}
>
<Select.Trigger class="w-full">{form.kind}</Select.Trigger>
<Select.Content>
{#each ["deep", "branch", "master", "job"] as option (option)}
<Select.Item label={option} value={option} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col gap-1.5">
<Label>Agent</Label>
<Select.Root
onValueChange={(value) => {
form.agent = value === "default" ? "" : value;
}}
type="single"
value={form.agent || "default"}
>
<Select.Trigger class="w-full">
{form.agent || "frontend default"}
</Select.Trigger>
<Select.Content>
<Select.Item label="frontend default" value="default" />
{#each agentsForKind as agent (agent.name)}
<Select.Item label={agent.name} value={agent.name} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="col-span-2 flex flex-col gap-1.5">
<Label for="new-title">Title</Label>
<Input id="new-title" placeholder="optional" bind:value={form.title} />
</div>
<div class="col-span-2 flex flex-col gap-1.5">
<Label for="new-text">First message</Label>
<textarea
class="min-h-20 rounded-md border bg-input/30 px-3 py-2 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
id="new-text"
placeholder="optional - without it the window waits"
bind:value={form.text}
></textarea>
</div>
</div>
<Dialog.Footer>
<Button
onclick={() => {
createOpen = false;
}}
variant="ghost"
>
Cancel
</Button>
<Button disabled={busy} onclick={create}>Create</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+19
View File
@@ -8,6 +8,7 @@ import type {
import { session } from "./session.svelte";
const TAPE_SIZE = 120;
const RELOAD_DEBOUNCE_MS = 1500;
const QUIET = new Set(["stream", "hello"]);
// Gateway-wide live state: the conversation index kept fresh by
@@ -98,10 +99,28 @@ class Gateway {
this.applyLimit(event);
return;
}
case "message.queued":
case "inject.queued":
case "reply": {
this.reloadSoon();
return;
}
default:
}
}
private reloadTimer: ReturnType<typeof setTimeout> | null = null;
private reloadSoon(): void {
if (this.reloadTimer) {
return;
}
this.reloadTimer = setTimeout(() => {
this.reloadTimer = null;
this.load().catch(() => undefined);
}, RELOAD_DEBOUNCE_MS);
}
private applyLimit(event: BusEvent): void {
if (!this.limits || typeof event.window !== "string") {
this.refreshLimits().catch(() => undefined);
@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from "svelte";
import type { ApiClient } from "$lib/api/client";
import type { ContentBlock, HistoryMessage } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
@@ -7,37 +8,96 @@
import { Switch } from "$lib/components/ui/switch";
import { clip } from "$lib/format";
import { cn } from "$lib/utils";
import type { ActivityModel } from "./activity.svelte";
import { summarizeInput, toolLabel } from "./activity.svelte";
import QuestionCard from "./question-card.svelte";
import TurnCard from "./turn-card.svelte";
let {
client,
conversationId,
model,
refreshKey = 0,
}: {
client: ApiClient;
conversationId: string;
model: ActivityModel;
refreshKey?: number;
} = $props();
const RESULT_CLIP = 600;
const NEAR_BOTTOM_PX = 120;
const TICK_MS = 1000;
let messages = $state<HistoryMessage[] | null>(null);
let failure = $state<string | null>(null);
let showResults = $state(false);
let loadedAt = $state(new Date(0).toISOString());
let scroller = $state<HTMLDivElement | null>(null);
let now = $state(Date.now());
let pinned = true;
const tail = $derived(
model.turns
.filter((turn) => turn.status === "running" || turn.startedAt > loadedAt)
.reverse()
);
const tailSize = $derived(
tail.reduce(
(n, turn) => n + turn.text.length + Object.keys(turn.nodes).length,
0
)
);
async function load() {
failure = null;
try {
({ messages } = await client.history(conversationId));
loadedAt = new Date().toISOString();
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
}
function nearBottom(): boolean {
if (!scroller) {
return true;
}
const left =
scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
return left < NEAR_BOTTOM_PX;
}
async function scrollToBottom() {
await tick();
if (scroller) {
scroller.scrollTop = scroller.scrollHeight;
}
}
$effect(() => {
if (refreshKey >= 0) {
pinned = true;
load();
}
});
$effect(() => {
const count = (messages?.length ?? 0) + tailSize;
if (pinned && count >= 0) {
scrollToBottom();
}
});
$effect(() => {
const timer = setInterval(() => {
if (model.running) {
now = Date.now();
}
}, TICK_MS);
return () => clearInterval(timer);
});
function blocks(message: HistoryMessage): ContentBlock[] {
return typeof message.content === "string"
? [{ text: message.content, type: "text" }]
@@ -60,11 +120,15 @@
}
return "";
}
const RESULT_CLIP = 600;
</script>
<div class="flex flex-col gap-3">
<div
class="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-4 py-3 sm:px-6"
onscroll={() => {
pinned = nearBottom();
}}
bind:this={scroller}
>
<span class="flex items-center gap-2 self-end text-muted-foreground text-xs">
<Switch aria-label="Show tool results" bind:checked={showResults} />
show tool results
@@ -77,10 +141,10 @@
<Skeleton class="h-16 w-full" />
<Skeleton class="h-10 w-1/2" />
</div>
{:else if messages.length === 0}
{:else if messages.length === 0 && tail.length === 0}
<EmptyState
hint="The transcript mirror is empty - nothing has been said in this conversation yet."
title="No history"
hint="Nothing has been said here yet. Write below, or wait for the first inject."
title="Empty conversation"
/>
{:else}
{#each messages as message, index (index)}
@@ -98,9 +162,7 @@
<p
class={cn(
"max-w-[75ch] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm",
message.role === "user"
? "bg-primary/10"
: "bg-muted/50"
message.role === "user" ? "bg-primary/10" : "bg-muted/50"
)}
>
{block.text}
@@ -116,7 +178,9 @@
<pre
class={cn(
"max-h-48 max-w-full overflow-auto whitespace-pre-wrap break-all rounded-md p-2 text-xs",
block.is_error ? "bg-destructive/8 text-destructive" : "bg-muted/40"
block.is_error
? "bg-destructive/8 text-destructive"
: "bg-muted/40"
)}
>{clip(resultText(block), RESULT_CLIP)}</pre>
{:else if block.type === "thinking"}
@@ -127,4 +191,10 @@
{/if}
{/each}
{/if}
{#if model.question}
<QuestionCard {client} {conversationId} question={model.question} />
{/if}
{#each tail as turn (turn.id)}
<TurnCard {now} {turn} />
{/each}
</div>
+37 -35
View File
@@ -6,10 +6,10 @@
import * as Tabs from "$lib/components/ui/tabs";
import ActivityFeed from "./activity-feed.svelte";
import BindingsList from "./bindings-list.svelte";
import ChatView from "./chat-view.svelte";
import Composer from "./composer.svelte";
import { ConversationFeed } from "./conversation.svelte";
import ConversationHeader from "./conversation-header.svelte";
import HistoryView from "./history-view.svelte";
import QueueList from "./queue-list.svelte";
import RawEntries from "./raw-entries.svelte";
@@ -31,9 +31,8 @@
() => client,
untrack(() => id)
);
let tab = $state("activity");
let tab = $state("chat");
let historyKey = $state(0);
let landed = $state(false);
onMount(() => {
feed.start();
@@ -55,13 +54,6 @@
});
}
});
$effect(() => {
if (feed.loaded && !landed) {
landed = true;
tab = feed.model.running ? "activity" : "history";
}
});
</script>
<div class="flex h-full min-h-0 flex-col">
@@ -90,34 +82,44 @@
<div class="flex min-h-0 flex-col">
<Tabs.Root class="flex min-h-0 flex-1 flex-col gap-0" bind:value={tab}>
<Tabs.List class="mx-4 mt-2 w-fit sm:mx-6">
<Tabs.Trigger value="chat">Chat</Tabs.Trigger>
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
<Tabs.Trigger value="history">History</Tabs.Trigger>
<Tabs.Trigger value="raw">Raw</Tabs.Trigger>
<Tabs.Trigger class={compact ? "" : "lg:hidden"} value="meta"
>Meta</Tabs.Trigger
>
<Tabs.Trigger class={compact ? "" : "lg:hidden"} value="meta">
Meta
</Tabs.Trigger>
</Tabs.List>
<div class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6">
<Tabs.Content value="activity">
<ActivityFeed
{client}
connected={feed.live.state === "open"}
conversationId={id}
model={feed.model}
/>
</Tabs.Content>
<Tabs.Content value="history">
<HistoryView
{client}
conversationId={id}
refreshKey={historyKey}
/>
</Tabs.Content>
<Tabs.Content value="raw">
<RawEntries {client} conversationId={id} />
</Tabs.Content>
<Tabs.Content value="meta">{@render rail()}</Tabs.Content>
</div>
<Tabs.Content class="flex min-h-0 flex-1 flex-col" value="chat">
<ChatView
{client}
conversationId={id}
model={feed.model}
refreshKey={historyKey}
/>
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
value="activity"
>
<ActivityFeed
{client}
connected={feed.live.state === "open"}
conversationId={id}
model={feed.model}
/>
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
value="raw"
>
<RawEntries {client} conversationId={id} />
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
value="meta"
>
{@render rail()}
</Tabs.Content>
</Tabs.Root>
<Composer
{client}
@@ -0,0 +1,27 @@
<script lang="ts">
import { page } from "$app/state";
import ConversationList from "$lib/components/conversation-list.svelte";
import { cn } from "$lib/utils";
let { children } = $props();
const selected = $derived(page.params.id ?? null);
</script>
<svelte:head><title>Conversations · Beaver</title></svelte:head>
<div class="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[22rem_minmax(0,1fr)]">
<div
class={cn(
"min-h-0 border-r bg-sidebar/40",
selected ? "hidden lg:block" : "block"
)}
>
<ConversationList {selected} />
</div>
<div
class={cn("min-h-0", selected ? "flex flex-col" : "hidden lg:flex lg:flex-col")}
>
{@render children()}
</div>
</div>
+8 -279
View File
@@ -1,287 +1,16 @@
<script lang="ts">
import PlusIcon from "@lucide/svelte/icons/plus";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import type { AgentInfo, ConversationSummary } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import KindBadge from "$lib/components/kind-badge.svelte";
import PageHeader from "$lib/components/page-header.svelte";
import StatusPill from "$lib/components/status-pill.svelte";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select";
import { Skeleton } from "$lib/components/ui/skeleton";
import { fmtRelative, shortId } from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { session } from "$lib/session.svelte";
const KINDS = ["all", "master", "branch", "deep", "job", "fork"];
const STATUSES = ["open", "all", "merged", "closed", "archived"];
let kind = $state("all");
let statusFilter = $state("open");
let search = $state("");
let createOpen = $state(false);
let agents = $state<AgentInfo[]>([]);
let form = $state({
agent: "",
kind: "deep",
seed: "clean",
text: "",
title: "",
});
let busy = $state(false);
const rows = $derived.by(() => {
const needle = search.trim().toLowerCase();
return gateway.conversations
.filter((row) => kind === "all" || row.kind === kind)
.filter((row) => statusFilter === "all" || row.status === statusFilter)
.filter(
(row) =>
!needle ||
(row.title ?? "").toLowerCase().includes(needle) ||
row.id.startsWith(needle) ||
row.agent.toLowerCase().includes(needle)
)
.sort(byActivity);
});
function byActivity(a: ConversationSummary, b: ConversationSummary) {
if (Boolean(a.running_turn) !== Boolean(b.running_turn)) {
return a.running_turn ? -1 : 1;
}
return (b.last_activity_at ?? "").localeCompare(a.last_activity_at ?? "");
}
const agentsForKind = $derived(
agents.filter((a) =>
a.kinds.includes(form.kind as AgentInfo["kinds"][number])
)
);
async function openCreate() {
createOpen = true;
if (agents.length === 0 && session.client) {
({ agents } = await session.client.agents());
}
}
async function create() {
if (!session.client) {
return;
}
busy = true;
try {
const created = await session.client.spawn({
agent: form.agent || undefined,
kind: form.kind,
seed: form.seed,
text: form.text || undefined,
title: form.title || undefined,
});
createOpen = false;
await goto(`${base}/conversations/${created.id}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
const running = $derived(gateway.running.length);
</script>
<svelte:head><title>Conversations · Beaver</title></svelte:head>
<PageHeader subtitle={`${rows.length} shown`} title="Conversations">
<Select.Root
onValueChange={(value) => {
kind = value;
}}
type="single"
value={kind}
>
<Select.Trigger class="h-8 text-xs" size="sm">
{kind === "all" ? "any kind" : kind}
</Select.Trigger>
<Select.Content>
{#each KINDS as option (option)}
<Select.Item
label={option === "all" ? "any kind" : option}
value={option}
/>
{/each}
</Select.Content>
</Select.Root>
<Select.Root
onValueChange={(value) => {
statusFilter = value;
}}
type="single"
value={statusFilter}
>
<Select.Trigger class="h-8 text-xs" size="sm">
{statusFilter === "all" ? "any status" : statusFilter}
</Select.Trigger>
<Select.Content>
{#each STATUSES as option (option)}
<Select.Item
label={option === "all" ? "any status" : option}
value={option}
/>
{/each}
</Select.Content>
</Select.Root>
<Input
aria-label="Search conversations"
class="h-8 w-40 text-xs"
placeholder="title, id, agent"
bind:value={search}
/>
{#snippet actions()}
<Button onclick={openCreate} size="sm">
<PlusIcon class="size-4" />
New
</Button>
{/snippet}
</PageHeader>
<div class="min-h-0 flex-1 overflow-y-auto">
{#if gateway.live.state === "failed"}
<div class="p-4">
<ErrorNote
message={gateway.live.detail ?? "event stream failed"}
retry={() => gateway.start()}
/>
</div>
{:else if !gateway.loaded}
<div class="flex flex-col gap-2 p-4">
<Skeleton class="h-9 w-full" />
<Skeleton class="h-9 w-full" />
<Skeleton class="h-9 w-full" />
</div>
{:else if rows.length === 0}
<div class="p-4">
<EmptyState
hint="Change the filters, or start one - a deep chat, a branch off the master, a headless job."
title="No conversations match"
>
<Button onclick={openCreate} size="sm" variant="outline"
>New conversation</Button
>
</EmptyState>
</div>
<div
class="flex flex-1 items-center justify-center p-6 text-muted-foreground text-sm"
>
{#if running > 0}
{running}
running - pick one on the left.
{:else}
<ul class="flex flex-col">
{#each rows as row (row.id)}
<li>
<a
class="row-hover ledger-grid grid-cols-[auto_minmax(0,1fr)_auto] border-b px-4 py-2 text-sm sm:grid-cols-[4.5rem_minmax(0,1fr)_9rem_6rem_7rem] sm:px-6"
href="{base}/conversations/{row.id}"
>
<KindBadge kind={row.kind} />
<span class="flex min-w-0 flex-col">
<span class="truncate font-medium">
{row.title || `${row.kind} ${shortId(row.id)}`}
</span>
<span class="truncate text-muted-foreground text-xs sm:hidden">
{row.agent}
· {fmtRelative(row.last_activity_at)}
</span>
</span>
<span
class="hidden truncate text-muted-foreground text-xs sm:inline"
>
{row.agent}
</span>
<StatusPill status={row.running_turn ? "running" : row.status} />
<span
class="tabular hidden text-right text-muted-foreground text-xs sm:inline"
>
{fmtRelative(row.last_activity_at)}
</span>
</a>
</li>
{/each}
</ul>
Pick a conversation on the left.
{/if}
</div>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>New conversation</Dialog.Title>
<Dialog.Description>
It opens in the home window of its kind (a vault file, a Telegram topic)
and stays silent until someone speaks.
</Dialog.Description>
</Dialog.Header>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<Label>Kind</Label>
<Select.Root
onValueChange={(value) => {
form.kind = value;
form.agent = "";
}}
type="single"
value={form.kind}
>
<Select.Trigger class="w-full">{form.kind}</Select.Trigger>
<Select.Content>
{#each ["deep", "branch", "master", "job"] as option (option)}
<Select.Item label={option} value={option} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col gap-1.5">
<Label>Agent</Label>
<Select.Root
onValueChange={(value) => {
form.agent = value === "default" ? "" : value;
}}
type="single"
value={form.agent || "default"}
>
<Select.Trigger class="w-full"
>{form.agent || "frontend default"}</Select.Trigger
>
<Select.Content>
<Select.Item label="frontend default" value="default" />
{#each agentsForKind as agent (agent.name)}
<Select.Item label={agent.name} value={agent.name} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="col-span-2 flex flex-col gap-1.5">
<Label for="new-title">Title</Label>
<Input id="new-title" placeholder="optional" bind:value={form.title} />
</div>
<div class="col-span-2 flex flex-col gap-1.5">
<Label for="new-text">First message</Label>
<textarea
class="min-h-20 rounded-md border bg-input/30 px-3 py-2 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
id="new-text"
placeholder="optional - without it the window waits"
bind:value={form.text}
></textarea>
</div>
</div>
<Dialog.Footer>
<Button
onclick={() => {
createOpen = false;
}}
variant="ghost"
>
Cancel
</Button>
<Button disabled={busy} onclick={create}>Create</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>