324 lines
10 KiB
Svelte
324 lines
10 KiB
Svelte
<script lang="ts">
|
|
import PanelLeftCloseIcon from "@lucide/svelte/icons/panel-left-close";
|
|
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 { ui } from "$lib/ui.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>
|
|
<Button
|
|
aria-label="Hide conversations"
|
|
class="hidden lg:inline-flex"
|
|
onclick={() => ui.toggleRail()}
|
|
size="icon-sm"
|
|
title="Hide conversations"
|
|
variant="ghost"
|
|
>
|
|
<PanelLeftCloseIcon 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>
|