feat(ui,api): chat view with live tail and autoscroll, conversation rail with last message previews
This commit is contained in:
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING, Literal
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlmodel import col, select
|
from sqlmodel import col, select
|
||||||
|
|
||||||
from beaver_gateway.storage.models import InjectQueueItem
|
from beaver_gateway.storage.models import InjectQueueItem
|
||||||
@@ -105,6 +106,23 @@ class InjectQueue:
|
|||||||
)
|
)
|
||||||
return list(result.all())
|
return list(result.all())
|
||||||
|
|
||||||
|
async def latest(
|
||||||
|
self, conversation_ids: Iterable[int]
|
||||||
|
) -> dict[int, InjectQueueItem]:
|
||||||
|
ids = list(conversation_ids)
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
newest = (
|
||||||
|
select(func.max(InjectQueueItem.id))
|
||||||
|
.where(col(InjectQueueItem.conversation_id).in_(ids))
|
||||||
|
.group_by(col(InjectQueueItem.conversation_id))
|
||||||
|
)
|
||||||
|
async with self._db.session() as session:
|
||||||
|
result = await session.exec(
|
||||||
|
select(InjectQueueItem).where(col(InjectQueueItem.id).in_(newest))
|
||||||
|
)
|
||||||
|
return {row.conversation_id: row for row in result.all()}
|
||||||
|
|
||||||
async def _mark(
|
async def _mark(
|
||||||
self,
|
self,
|
||||||
items: Iterable[InjectQueueItem],
|
items: Iterable[InjectQueueItem],
|
||||||
|
|||||||
@@ -46,7 +46,13 @@ from beaver_gateway.storage import (
|
|||||||
list_tokens,
|
list_tokens,
|
||||||
revoke_token,
|
revoke_token,
|
||||||
)
|
)
|
||||||
from beaver_gateway.storage.models import Conversation, RateLimit, Token, Usage
|
from beaver_gateway.storage.models import (
|
||||||
|
Conversation,
|
||||||
|
InjectQueueItem,
|
||||||
|
RateLimit,
|
||||||
|
Token,
|
||||||
|
Usage,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator, Iterable, Sequence
|
from collections.abc import AsyncIterator, Iterable, Sequence
|
||||||
@@ -220,7 +226,16 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
|||||||
kind=q.get("kind"),
|
kind=q.get("kind"),
|
||||||
limit=query_int(request, "limit", 200),
|
limit=query_int(request, "limit", 200),
|
||||||
)
|
)
|
||||||
return {"conversations": [conversations.public(r) for r in rows]}
|
latest = await conversations.queue.latest(cast("int", r.id) for r in rows)
|
||||||
|
return {
|
||||||
|
"conversations": [
|
||||||
|
{
|
||||||
|
**conversations.public(r),
|
||||||
|
"last_item": _queue_item(latest.get(cast("int", r.id))),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@app.post("/api/conversations", status_code=status.HTTP_201_CREATED)
|
@app.post("/api/conversations", status_code=status.HTTP_201_CREATED)
|
||||||
async def create_conversation(request: Request) -> dict[str, Any]:
|
async def create_conversation(request: Request) -> dict[str, Any]:
|
||||||
@@ -272,14 +287,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
|||||||
conv = await conv_of(public_id)
|
conv = await conv_of(public_id)
|
||||||
out = await conversations.describe(conv)
|
out = await conversations.describe(conv)
|
||||||
out["queue"] = [
|
out["queue"] = [
|
||||||
{
|
_queue_item(i)
|
||||||
"id": i.id,
|
|
||||||
"priority": i.priority,
|
|
||||||
"origin": i.origin,
|
|
||||||
"status": i.status,
|
|
||||||
"created_at": _iso(i.created_at),
|
|
||||||
"text": i.text[:200],
|
|
||||||
}
|
|
||||||
for i in await conversations.queue.recent(cast("int", conv.id), limit=20)
|
for i in await conversations.queue.recent(cast("int", conv.id), limit=20)
|
||||||
]
|
]
|
||||||
return out
|
return out
|
||||||
@@ -859,6 +867,19 @@ async def _conversation_titles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_item(item: InjectQueueItem | None) -> dict[str, Any] | None:
|
||||||
|
if item is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": item.id,
|
||||||
|
"priority": item.priority,
|
||||||
|
"origin": item.origin,
|
||||||
|
"status": item.status,
|
||||||
|
"created_at": _iso(item.created_at),
|
||||||
|
"text": item.text[:200],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _limit_public(row: RateLimit) -> dict[str, Any]:
|
def _limit_public(row: RateLimit) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"id": row.id,
|
"id": row.id,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export interface ConversationSummary {
|
|||||||
id: string;
|
id: string;
|
||||||
kind: Kind;
|
kind: Kind;
|
||||||
last_activity_at: string | null;
|
last_activity_at: string | null;
|
||||||
|
last_item?: QueueItem | null;
|
||||||
last_user_activity_at: string | null;
|
last_user_activity_at: string | null;
|
||||||
origin: string;
|
origin: string;
|
||||||
parent_row: number | null;
|
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>
|
||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
import { session } from "./session.svelte";
|
import { session } from "./session.svelte";
|
||||||
|
|
||||||
const TAPE_SIZE = 120;
|
const TAPE_SIZE = 120;
|
||||||
|
const RELOAD_DEBOUNCE_MS = 1500;
|
||||||
const QUIET = new Set(["stream", "hello"]);
|
const QUIET = new Set(["stream", "hello"]);
|
||||||
|
|
||||||
// Gateway-wide live state: the conversation index kept fresh by
|
// Gateway-wide live state: the conversation index kept fresh by
|
||||||
@@ -98,10 +99,28 @@ class Gateway {
|
|||||||
this.applyLimit(event);
|
this.applyLimit(event);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case "message.queued":
|
||||||
|
case "inject.queued":
|
||||||
|
case "reply": {
|
||||||
|
this.reloadSoon();
|
||||||
|
return;
|
||||||
|
}
|
||||||
default:
|
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 {
|
private applyLimit(event: BusEvent): void {
|
||||||
if (!this.limits || typeof event.window !== "string") {
|
if (!this.limits || typeof event.window !== "string") {
|
||||||
this.refreshLimits().catch(() => undefined);
|
this.refreshLimits().catch(() => undefined);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { tick } from "svelte";
|
||||||
import type { ApiClient } from "$lib/api/client";
|
import type { ApiClient } from "$lib/api/client";
|
||||||
import type { ContentBlock, HistoryMessage } from "$lib/api/types";
|
import type { ContentBlock, HistoryMessage } from "$lib/api/types";
|
||||||
import EmptyState from "$lib/components/empty-state.svelte";
|
import EmptyState from "$lib/components/empty-state.svelte";
|
||||||
@@ -7,37 +8,96 @@
|
|||||||
import { Switch } from "$lib/components/ui/switch";
|
import { Switch } from "$lib/components/ui/switch";
|
||||||
import { clip } from "$lib/format";
|
import { clip } from "$lib/format";
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
|
import type { ActivityModel } from "./activity.svelte";
|
||||||
import { summarizeInput, toolLabel } from "./activity.svelte";
|
import { summarizeInput, toolLabel } from "./activity.svelte";
|
||||||
|
import QuestionCard from "./question-card.svelte";
|
||||||
|
import TurnCard from "./turn-card.svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
client,
|
client,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
model,
|
||||||
refreshKey = 0,
|
refreshKey = 0,
|
||||||
}: {
|
}: {
|
||||||
client: ApiClient;
|
client: ApiClient;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
model: ActivityModel;
|
||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const RESULT_CLIP = 600;
|
||||||
|
const NEAR_BOTTOM_PX = 120;
|
||||||
|
const TICK_MS = 1000;
|
||||||
|
|
||||||
let messages = $state<HistoryMessage[] | null>(null);
|
let messages = $state<HistoryMessage[] | null>(null);
|
||||||
let failure = $state<string | null>(null);
|
let failure = $state<string | null>(null);
|
||||||
let showResults = $state(false);
|
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() {
|
async function load() {
|
||||||
failure = null;
|
failure = null;
|
||||||
try {
|
try {
|
||||||
({ messages } = await client.history(conversationId));
|
({ messages } = await client.history(conversationId));
|
||||||
|
loadedAt = new Date().toISOString();
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
failure = cause instanceof Error ? cause.message : String(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(() => {
|
$effect(() => {
|
||||||
if (refreshKey >= 0) {
|
if (refreshKey >= 0) {
|
||||||
|
pinned = true;
|
||||||
load();
|
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[] {
|
function blocks(message: HistoryMessage): ContentBlock[] {
|
||||||
return typeof message.content === "string"
|
return typeof message.content === "string"
|
||||||
? [{ text: message.content, type: "text" }]
|
? [{ text: message.content, type: "text" }]
|
||||||
@@ -60,11 +120,15 @@
|
|||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESULT_CLIP = 600;
|
|
||||||
</script>
|
</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">
|
<span class="flex items-center gap-2 self-end text-muted-foreground text-xs">
|
||||||
<Switch aria-label="Show tool results" bind:checked={showResults} />
|
<Switch aria-label="Show tool results" bind:checked={showResults} />
|
||||||
show tool results
|
show tool results
|
||||||
@@ -77,10 +141,10 @@
|
|||||||
<Skeleton class="h-16 w-full" />
|
<Skeleton class="h-16 w-full" />
|
||||||
<Skeleton class="h-10 w-1/2" />
|
<Skeleton class="h-10 w-1/2" />
|
||||||
</div>
|
</div>
|
||||||
{:else if messages.length === 0}
|
{:else if messages.length === 0 && tail.length === 0}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
hint="The transcript mirror is empty - nothing has been said in this conversation yet."
|
hint="Nothing has been said here yet. Write below, or wait for the first inject."
|
||||||
title="No history"
|
title="Empty conversation"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
{#each messages as message, index (index)}
|
{#each messages as message, index (index)}
|
||||||
@@ -98,9 +162,7 @@
|
|||||||
<p
|
<p
|
||||||
class={cn(
|
class={cn(
|
||||||
"max-w-[75ch] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm",
|
"max-w-[75ch] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm",
|
||||||
message.role === "user"
|
message.role === "user" ? "bg-primary/10" : "bg-muted/50"
|
||||||
? "bg-primary/10"
|
|
||||||
: "bg-muted/50"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{block.text}
|
{block.text}
|
||||||
@@ -116,7 +178,9 @@
|
|||||||
<pre
|
<pre
|
||||||
class={cn(
|
class={cn(
|
||||||
"max-h-48 max-w-full overflow-auto whitespace-pre-wrap break-all rounded-md p-2 text-xs",
|
"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>
|
>{clip(resultText(block), RESULT_CLIP)}</pre>
|
||||||
{:else if block.type === "thinking"}
|
{:else if block.type === "thinking"}
|
||||||
@@ -127,4 +191,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if model.question}
|
||||||
|
<QuestionCard {client} {conversationId} question={model.question} />
|
||||||
|
{/if}
|
||||||
|
{#each tail as turn (turn.id)}
|
||||||
|
<TurnCard {now} {turn} />
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -6,10 +6,10 @@
|
|||||||
import * as Tabs from "$lib/components/ui/tabs";
|
import * as Tabs from "$lib/components/ui/tabs";
|
||||||
import ActivityFeed from "./activity-feed.svelte";
|
import ActivityFeed from "./activity-feed.svelte";
|
||||||
import BindingsList from "./bindings-list.svelte";
|
import BindingsList from "./bindings-list.svelte";
|
||||||
|
import ChatView from "./chat-view.svelte";
|
||||||
import Composer from "./composer.svelte";
|
import Composer from "./composer.svelte";
|
||||||
import { ConversationFeed } from "./conversation.svelte";
|
import { ConversationFeed } from "./conversation.svelte";
|
||||||
import ConversationHeader from "./conversation-header.svelte";
|
import ConversationHeader from "./conversation-header.svelte";
|
||||||
import HistoryView from "./history-view.svelte";
|
|
||||||
import QueueList from "./queue-list.svelte";
|
import QueueList from "./queue-list.svelte";
|
||||||
import RawEntries from "./raw-entries.svelte";
|
import RawEntries from "./raw-entries.svelte";
|
||||||
|
|
||||||
@@ -31,9 +31,8 @@
|
|||||||
() => client,
|
() => client,
|
||||||
untrack(() => id)
|
untrack(() => id)
|
||||||
);
|
);
|
||||||
let tab = $state("activity");
|
let tab = $state("chat");
|
||||||
let historyKey = $state(0);
|
let historyKey = $state(0);
|
||||||
let landed = $state(false);
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
feed.start();
|
feed.start();
|
||||||
@@ -55,13 +54,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (feed.loaded && !landed) {
|
|
||||||
landed = true;
|
|
||||||
tab = feed.model.running ? "activity" : "history";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full min-h-0 flex-col">
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
@@ -90,15 +82,25 @@
|
|||||||
<div class="flex min-h-0 flex-col">
|
<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.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.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="activity">Activity</Tabs.Trigger>
|
||||||
<Tabs.Trigger value="history">History</Tabs.Trigger>
|
|
||||||
<Tabs.Trigger value="raw">Raw</Tabs.Trigger>
|
<Tabs.Trigger value="raw">Raw</Tabs.Trigger>
|
||||||
<Tabs.Trigger class={compact ? "" : "lg:hidden"} value="meta"
|
<Tabs.Trigger class={compact ? "" : "lg:hidden"} value="meta">
|
||||||
>Meta</Tabs.Trigger
|
Meta
|
||||||
>
|
</Tabs.Trigger>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6">
|
<Tabs.Content class="flex min-h-0 flex-1 flex-col" value="chat">
|
||||||
<Tabs.Content value="activity">
|
<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
|
<ActivityFeed
|
||||||
{client}
|
{client}
|
||||||
connected={feed.live.state === "open"}
|
connected={feed.live.state === "open"}
|
||||||
@@ -106,18 +108,18 @@
|
|||||||
model={feed.model}
|
model={feed.model}
|
||||||
/>
|
/>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="history">
|
<Tabs.Content
|
||||||
<HistoryView
|
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
|
||||||
{client}
|
value="raw"
|
||||||
conversationId={id}
|
>
|
||||||
refreshKey={historyKey}
|
|
||||||
/>
|
|
||||||
</Tabs.Content>
|
|
||||||
<Tabs.Content value="raw">
|
|
||||||
<RawEntries {client} conversationId={id} />
|
<RawEntries {client} conversationId={id} />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="meta">{@render rail()}</Tabs.Content>
|
<Tabs.Content
|
||||||
</div>
|
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
|
||||||
|
value="meta"
|
||||||
|
>
|
||||||
|
{@render rail()}
|
||||||
|
</Tabs.Content>
|
||||||
</Tabs.Root>
|
</Tabs.Root>
|
||||||
<Composer
|
<Composer
|
||||||
{client}
|
{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>
|
||||||
@@ -1,287 +1,16 @@
|
|||||||
<script lang="ts">
|
<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 { gateway } from "$lib/gateway.svelte";
|
||||||
import { session } from "$lib/session.svelte";
|
|
||||||
|
|
||||||
const KINDS = ["all", "master", "branch", "deep", "job", "fork"];
|
const running = $derived(gateway.running.length);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Conversations · Beaver</title></svelte:head>
|
<div
|
||||||
|
class="flex flex-1 items-center justify-center p-6 text-muted-foreground text-sm"
|
||||||
<PageHeader subtitle={`${rows.length} shown`} title="Conversations">
|
>
|
||||||
<Select.Root
|
{#if running > 0}
|
||||||
onValueChange={(value) => {
|
{running}
|
||||||
kind = value;
|
running - pick one on the left.
|
||||||
}}
|
|
||||||
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>
|
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="flex flex-col">
|
Pick a conversation on the left.
|
||||||
{#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>
|
|
||||||
{/if}
|
{/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