feat(ui,api): chat view with live tail and autoscroll, conversation rail with last message previews
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user