feat(ui,api,admin): sveltekit admin spa, usage by day/agent/conversation/model, rate limits, memory tree, turn snapshot

This commit is contained in:
hh
2026-08-28 22:14:15 +02:00
parent 61562e947d
commit 98796a82c6
158 changed files with 8459 additions and 2531 deletions
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import { onMount } from "svelte";
import type { ApiClient } from "$lib/api/client";
import EmptyState from "$lib/components/empty-state.svelte";
import type { ActivityModel } from "./activity.svelte";
import QuestionCard from "./question-card.svelte";
import TurnCard from "./turn-card.svelte";
let {
model,
client,
conversationId,
connected,
}: {
model: ActivityModel;
client: ApiClient;
conversationId: string;
connected: boolean;
} = $props();
let now = $state(Date.now());
const TICK_MS = 1000;
onMount(() => {
const timer = setInterval(() => {
if (model.running) {
now = Date.now();
}
}, TICK_MS);
return () => clearInterval(timer);
});
</script>
<div class="flex flex-col gap-3">
{#if model.question}
<QuestionCard {client} {conversationId} question={model.question} />
{/if}
{#if model.turns.length === 0}
<EmptyState
hint={connected
? "Live turns, tool calls and subagents land here the moment the agent moves; earlier turns are under History."
: "Connecting to the event stream…"}
title="Nothing live right now"
/>
{:else}
<div class="flex flex-col">
{#each model.turns as turn (turn.id)}
<TurnCard {now} {turn} />
{/each}
</div>
{/if}
</div>
+396
View File
@@ -0,0 +1,396 @@
import type {
BusEvent,
ConversationInfo,
PendingQuestion,
ToolSnapshot,
TurnUsage,
} from "$lib/api/types";
export type ToolStatus = "running" | "done" | "error" | "aborted";
export type TurnStatus = "running" | "done" | "interrupted" | "error";
export interface ToolNode {
children: string[];
endedAt: string | null;
id: string;
input: unknown;
name: string;
parent: string | null;
result: string;
startedAt: string;
status: ToolStatus;
}
export interface Turn {
endedAt: string | null;
id: string;
itemOrigin: string | null;
nodes: Record<string, ToolNode>;
origin: string;
resultSubtype: string | null;
roots: string[];
says: string[];
startedAt: string;
status: TurnStatus;
text: string;
thinking: number;
usage: TurnUsage | null;
userText: string | null;
}
interface Cursor {
event: BusEvent;
parent: string | null;
ts: string;
turnId: string | null;
}
const MAX_TURNS = 30;
function str(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function stopStatus(turn: Turn, stop: string | null): TurnStatus {
if (stop === "end_turn") {
return turn.status === "error" ? "error" : "done";
}
return stop === "interrupted" ? "interrupted" : "error";
}
// Bus events in, turns → tool calls → subagent tool calls out. Reactive
// through $state so the tree re-renders per node, not per event.
export class ActivityModel {
conversation = $state<ConversationInfo | null>(null);
turns = $state<Turn[]>([]);
question = $state<PendingQuestion | null>(null);
private readonly handlers: Record<string, (c: Cursor) => void> = {
"conversation.created": (c) => this.onConversation(c),
"conversation.updated": (c) => this.onConversation(c),
question: (c) => this.onQuestion(c),
"question.answered": () => {
this.question = null;
},
"question.timeout": () => {
this.question = null;
},
reply: (c) => this.onReply(c),
result: (c) => this.onResult(c),
say: (c) => this.onSay(c),
stream: (c) => this.onStream(c),
tool: (c) => this.onTool(c),
"tool.result": (c) => this.onToolResult(c),
"turn.end": (c) => this.onTurnEnd(c),
"turn.start": (c) => this.onTurnStart(c),
};
get running(): Turn | null {
return this.turns.find((turn) => turn.status === "running") ?? null;
}
setConversation(info: ConversationInfo): void {
this.conversation = info;
this.question = info.question;
const now = new Date().toISOString();
for (const turn of this.turns) {
if (turn.status === "running" && turn.id !== info.running_turn) {
this.closeTurn(turn, "interrupted", now);
}
}
if (info.turn) {
const turn = this.ensureTurn(
info.turn.id,
info.turn.started_at ?? now,
info.turn.origin
);
turn.userText = info.turn.text ?? turn.userText;
for (const tool of info.turn.tools) {
this.applySnapshot(turn, tool);
}
} else if (info.running_turn) {
this.ensureTurn(info.running_turn, now, null);
}
}
apply(event: BusEvent): void {
const handler = this.handlers[event.type];
if (!handler) {
return;
}
handler({
event,
parent: str(event.parent_tool_use_id),
ts: event.ts ?? new Date().toISOString(),
turnId: str(event.turn_id),
});
}
private applySnapshot(turn: Turn, tool: ToolSnapshot): void {
const node = this.ensureNode(
turn,
tool.tool_use_id,
tool.name,
tool.parent_tool_use_id,
tool.started_at
);
node.input = tool.input;
if (tool.ended_at) {
node.status = tool.is_error ? "error" : "done";
node.endedAt = tool.ended_at;
node.result = tool.content ?? "";
}
}
private onTurnStart({ event, ts, turnId }: Cursor): void {
if (!turnId) {
return;
}
const turn = this.ensureTurn(turnId, ts, str(event.origin));
turn.status = "running";
turn.startedAt = ts;
turn.itemOrigin = str(event.item_origin);
turn.userText = str(event.text) ?? turn.userText;
}
private onStream({ event, ts, turnId, parent }: Cursor): void {
const raw = event.event;
if (!(turnId && raw && typeof raw === "object")) {
return;
}
const turn = this.ensureTurn(turnId, ts, null);
const sdk = raw as Record<string, unknown>;
if (sdk.type === "content_block_start") {
const block = sdk.content_block as Record<string, unknown> | undefined;
if (block?.type === "tool_use" && typeof block.id === "string") {
this.ensureNode(turn, block.id, str(block.name) ?? "?", parent, ts);
} else if (block?.type === "thinking" && parent === null) {
turn.thinking += 1;
}
return;
}
if (sdk.type === "content_block_delta" && parent === null) {
const delta = sdk.delta as Record<string, unknown> | undefined;
if (delta?.type === "text_delta" && typeof delta.text === "string") {
turn.text += delta.text;
}
}
}
private onTool({ event, ts, turnId, parent }: Cursor): void {
if (!(turnId && typeof event.tool_use_id === "string")) {
return;
}
const turn = this.ensureTurn(turnId, ts, null);
const node = this.ensureNode(
turn,
event.tool_use_id,
str(event.name) ?? "?",
parent,
ts
);
node.input = event.input ?? null;
}
private onToolResult({ event, ts, turnId, parent }: Cursor): void {
if (!(turnId && typeof event.tool_use_id === "string")) {
return;
}
const turn = this.ensureTurn(turnId, ts, null);
const node = this.ensureNode(turn, event.tool_use_id, "?", parent, ts);
node.status = event.is_error ? "error" : "done";
node.endedAt = ts;
node.result = str(event.content) ?? "";
}
private onResult({ event, ts, turnId }: Cursor): void {
if (!turnId) {
return;
}
const turn = this.ensureTurn(turnId, ts, null);
turn.resultSubtype = str(event.subtype);
if (event.is_error) {
turn.status = "error";
}
}
private onTurnEnd({ event, ts, turnId }: Cursor): void {
if (!turnId) {
return;
}
const turn = this.ensureTurn(turnId, ts, str(event.origin));
this.closeTurn(turn, stopStatus(turn, str(event.stop)), ts);
turn.usage =
event.usage && typeof event.usage === "object"
? (event.usage as TurnUsage)
: null;
}
private onReply({ event, ts, turnId }: Cursor): void {
if (!turnId) {
return;
}
const turn = this.ensureTurn(turnId, ts, null);
turn.userText = str(event.user_text);
const text = str(event.text);
if (text) {
turn.text = text;
}
}
private onSay({ event, ts, turnId }: Cursor): void {
const text = str(event.text);
if (!text) {
return;
}
const turn = turnId ? this.ensureTurn(turnId, ts, null) : this.turns[0];
turn?.says.push(text);
}
private onQuestion({ event }: Cursor): void {
if (typeof event.question_id === "string") {
this.question = {
id: event.question_id,
questions: Array.isArray(event.questions) ? event.questions : [],
};
}
}
private onConversation({ event }: Cursor): void {
if (!this.conversation || event.id !== this.conversation.id) {
return;
}
this.conversation = {
...this.conversation,
...(event as unknown as Partial<ConversationInfo>),
};
}
private closeTurn(turn: Turn, status: TurnStatus, ts: string): void {
turn.status = status;
turn.endedAt = ts;
for (const node of Object.values(turn.nodes)) {
if (node.status === "running") {
node.status = "aborted";
node.endedAt = ts;
}
}
}
private ensureTurn(id: string, ts: string, origin: string | null): Turn {
const existing = this.turns.find((t) => t.id === id);
if (existing) {
if (origin && existing.origin === "?") {
existing.origin = origin;
}
return existing;
}
const turn: Turn = {
endedAt: null,
id,
itemOrigin: null,
nodes: {},
origin: origin ?? "?",
resultSubtype: null,
roots: [],
says: [],
startedAt: ts,
status: "running",
text: "",
thinking: 0,
usage: null,
userText: null,
};
this.turns.unshift(turn);
if (this.turns.length > MAX_TURNS) {
this.turns.length = MAX_TURNS;
}
return this.turns[0];
}
private ensureNode(
turn: Turn,
id: string,
name: string,
parent: string | null,
ts: string
): ToolNode {
const existing = turn.nodes[id];
if (existing) {
if (existing.name === "?" && name !== "?") {
existing.name = name;
}
return existing;
}
turn.nodes[id] = {
children: [],
endedAt: null,
id,
input: null,
name,
parent,
result: "",
startedAt: ts,
status: "running",
};
if (parent) {
this.ensureNode(turn, parent, "?", null, ts).children.push(id);
} else {
turn.roots.push(id);
}
return turn.nodes[id];
}
}
function pick(input: unknown, ...keys: string[]): string | null {
if (!input || typeof input !== "object") {
return null;
}
const record = input as Record<string, unknown>;
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) {
return value;
}
}
return null;
}
const WHITESPACE = /\s+/g;
const KEYS_BY_TOOL: Record<string, string[]> = {
Agent: ["description", "prompt"],
Bash: ["command", "description"],
Edit: ["file_path"],
Glob: ["pattern"],
Grep: ["pattern"],
MultiEdit: ["file_path"],
NotebookEdit: ["notebook_path"],
Read: ["file_path", "notebook_path"],
Skill: ["skill"],
Task: ["description", "prompt"],
WebFetch: ["url"],
WebSearch: ["query"],
Write: ["file_path"],
};
export const SUBAGENT_TOOLS = new Set(["Task", "Agent"]);
export function summarizeInput(name: string, input: unknown): string {
if (input === null || input === undefined) {
return "";
}
const keys =
KEYS_BY_TOOL[name] ??
(name.startsWith("mcp__gateway__") ? ["text", "title", "id"] : []);
let summary = keys.length > 0 ? pick(input, ...keys) : null;
if (summary === null && typeof input === "object") {
const first = Object.values(input as Record<string, unknown>).find(
(value) => typeof value === "string" && value.trim()
);
summary = typeof first === "string" ? first : JSON.stringify(input);
}
return (summary ?? String(input)).replace(WHITESPACE, " ").trim();
}
export function toolLabel(name: string): string {
return name.startsWith("mcp__") ? name.slice(5).replace("__", " · ") : name;
}
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client";
import type { Binding } from "$lib/api/types";
import { Switch } from "$lib/components/ui/switch";
let {
client,
conversationId,
bindings,
onChanged,
}: {
client: ApiClient;
conversationId: string;
bindings: Binding[];
onChanged: () => void;
} = $props();
async function toggle(binding: Binding, visible: boolean) {
try {
await client.bind(
conversationId,
binding.frontend,
binding.external_id,
visible
);
onChanged();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
}
}
</script>
{#if bindings.length === 0}
<p class="text-muted-foreground text-xs">
Not shown in any window. Bind it from a frontend, or it stays admin-only.
</p>
{:else}
<ul class="flex flex-col">
{#each bindings as binding (`${binding.frontend}:${binding.external_id}`)}
<li
class="flex items-center gap-2 border-b py-1.5 text-xs last:border-b-0"
>
<span class="font-medium">{binding.frontend}</span>
<span
class="truncate text-muted-foreground"
title={binding.external_id}
>
{binding.external_id}
</span>
<Switch
aria-label="Visible in {binding.frontend}"
checked={binding.visible}
class="ml-auto"
onCheckedChange={(value) => toggle(binding, value)}
/>
</li>
{/each}
</ul>
{/if}
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
import SendHorizontalIcon from "@lucide/svelte/icons/send-horizontal";
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client";
import { Button } from "$lib/components/ui/button";
import * as Select from "$lib/components/ui/select";
let {
client,
conversationId,
disabled = false,
}: {
client: ApiClient;
conversationId: string;
disabled?: boolean;
} = $props();
type Mode = "message" | "inject" | "urgent";
const MODES: { value: Mode; label: string; hint: string }[] = [
{
hint: "as the user, mirrored to Telegram",
label: "Message",
value: "message",
},
{
hint: "system note, rides with the next turn",
label: "Inject",
value: "inject",
},
{
hint: "interrupts the running turn",
label: "Urgent inject",
value: "urgent",
},
];
let mode = $state<Mode>("message");
let text = $state("");
let busy = $state(false);
const current = $derived(MODES.find((m) => m.value === mode) ?? MODES[0]);
async function send() {
const body = text.trim();
if (!body || busy) {
return;
}
busy = true;
try {
if (mode === "message") {
await client.postMessage(conversationId, body);
} else {
await client.inject(
conversationId,
body,
mode === "urgent" ? "urgent" : "normal"
);
}
text = "";
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
function onKeydown(event: KeyboardEvent) {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
send();
}
}
</script>
<form
class="flex flex-col gap-2 border-t bg-background px-3 py-2"
onsubmit={(event) => {
event.preventDefault();
send();
}}
>
<textarea
aria-label="Message"
class="min-h-16 w-full resize-y 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"
{disabled}
onkeydown={onKeydown}
placeholder={mode === "message"
? "Say something to the agent…"
: "Text of the inject…"}
rows="2"
bind:value={text}
></textarea>
<div class="flex items-center gap-2">
<Select.Root
onValueChange={(value) => {
mode = value as Mode;
}}
type="single"
value={mode}
>
<Select.Trigger class="h-8 text-xs" size="sm"
>{current.label}</Select.Trigger
>
<Select.Content>
{#each MODES as option (option.value)}
<Select.Item label={option.label} value={option.value}>
<span class="flex flex-col">
<span>{option.label}</span>
<span class="text-muted-foreground text-xs">{option.hint}</span>
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<span class="hidden text-muted-foreground text-xs sm:inline"
>{current.hint}</span
>
<Button
class="ml-auto"
disabled={disabled || busy || !text.trim()}
size="sm"
type="submit"
>
<SendHorizontalIcon class="size-4" />
Send
<kbd class="hidden text-[10px] opacity-70 sm:inline">⌘↩</kbd>
</Button>
</div>
</form>
+292
View File
@@ -0,0 +1,292 @@
<script lang="ts">
import CopyIcon from "@lucide/svelte/icons/copy";
import GitBranchIcon from "@lucide/svelte/icons/git-branch";
import MoreHorizontalIcon from "@lucide/svelte/icons/more-horizontal";
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client";
import type { LiveState } from "$lib/api/live.svelte";
import type { ConversationInfo } from "$lib/api/types";
import KindBadge from "$lib/components/kind-badge.svelte";
import LiveDot from "$lib/components/live-dot.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 * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select";
import { fmtRelative, shortId } from "$lib/format";
let {
client,
info,
liveState,
liveDetail = null,
href,
onChanged,
onOpen,
}: {
client: ApiClient;
info: ConversationInfo;
liveState: LiveState;
liveDetail?: string | null;
href: (id: string) => string;
onChanged: () => void;
onOpen: (id: string) => void;
} = $props();
let branchOpen = $state(false);
let renameOpen = $state(false);
let busy = $state(false);
let seed = $state("clean");
let branchTitle = $state("");
let branchText = $state("");
let newTitle = $state("");
const SEEDS = [
{ label: "clean - empty context", value: "clean" },
{ label: "copy - copy of this history", value: "copy" },
{ label: "brief - your text as the seed", value: "brief" },
{ label: "morning - the handout", value: "morning" },
];
async function run(action: () => Promise<unknown>, done: string) {
busy = true;
try {
await action();
toast.success(done);
onChanged();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
async function branch() {
busy = true;
try {
const child = await client.branch(info.id, {
seed,
text: branchText || undefined,
title: branchTitle || undefined,
});
branchOpen = false;
toast.success("Branch opened");
onOpen(child.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
function copyId() {
navigator.clipboard
.writeText(info.id)
.then(() => toast.success("Id copied"))
.catch(() => toast.error("Clipboard is not available"));
}
</script>
<header class="flex flex-col gap-1.5 border-b px-4 py-2.5 sm:px-6">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<KindBadge kind={info.kind} />
<h1 class="min-w-0 truncate font-semibold text-base tracking-tight">
{info.title || `${info.kind} ${shortId(info.id)}`}
</h1>
<StatusPill status={info.running_turn ? "running" : info.status} />
{#if info.pending_question}
<span class="font-medium text-link text-xs">question pending</span>
{/if}
<div class="ml-auto flex items-center gap-1">
<LiveDot detail={liveDetail} state={liveState} />
<Button
disabled={busy || info.status !== "open"}
onclick={() => {
branchOpen = true;
}}
size="sm"
variant="outline"
>
<GitBranchIcon class="size-4" />
Branch
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
aria-label="More actions"
size="icon-sm"
variant="ghost"
>
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={copyId}>
<CopyIcon class="size-4" />
Copy id
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => {
newTitle = info.title ?? "";
renameOpen = true;
}}
>
Rename
</DropdownMenu.Item>
<DropdownMenu.Separator />
{#if info.parent && info.status === "open"}
<DropdownMenu.Item
onclick={() =>
run(() => client.merge(info.id), "Merged into the parent")}
>
Merge into parent
</DropdownMenu.Item>
{/if}
{#if info.status === "open"}
<DropdownMenu.Item
onclick={() =>
run(() => client.update(info.id, { status: "closed" }), "Closed")}
>
Close
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() =>
run(
() => client.update(info.id, { status: "archived" }),
"Archived"
)}
>
Archive
</DropdownMenu.Item>
{:else}
<DropdownMenu.Item
onclick={() =>
run(() => client.update(info.id, { status: "open" }), "Reopened")}
>
Reopen
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div>
<div
class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs"
>
<span>{info.agent}</span>
<span>via {info.origin}</span>
{#if info.parent}
<a class="text-link hover:underline" href={href(info.parent)}>
parent {shortId(info.parent)}
</a>
{/if}
<span class="tabular" title={info.id}>{shortId(info.id)}</span>
<span class="tabular">
{info.live ? "session live" : "no live session"}
{info.busy ? " · busy" : ""}
</span>
<span class="tabular">
last activity {fmtRelative(info.last_activity_at)}
</span>
</div>
</header>
<Dialog.Root bind:open={branchOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Branch off this conversation</Dialog.Title>
<Dialog.Description>
A branch gets its own window and session and ends with a merge back into
this thread.
</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<Label>Seed</Label>
<Select.Root
onValueChange={(value) => {
seed = value;
}}
type="single"
value={seed}
>
<Select.Trigger class="w-full">
{SEEDS.find((s) => s.value === seed)?.label}
</Select.Trigger>
<Select.Content>
{#each SEEDS as option (option.value)}
<Select.Item label={option.label} value={option.value} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col gap-1.5">
<Label for="branch-title">Title</Label>
<Input
id="branch-title"
placeholder="optional"
bind:value={branchTitle}
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="branch-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="branch-text"
placeholder={seed === "brief" ? "required for a brief seed" : "optional"}
bind:value={branchText}
></textarea>
</div>
</div>
<Dialog.Footer>
<Button
onclick={() => {
branchOpen = false;
}}
variant="ghost"
>
Cancel
</Button>
<Button
disabled={busy || (seed === "brief" && !branchText.trim())}
onclick={branch}
>
Open branch
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={renameOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Rename</Dialog.Title>
</Dialog.Header>
<Input placeholder="Title" bind:value={newTitle} />
<Dialog.Footer>
<Button
onclick={() => {
renameOpen = false;
}}
variant="ghost"
>
Cancel
</Button>
<Button
disabled={busy}
onclick={() =>
run(async () => {
await client.update(info.id, { title: newTitle });
renameOpen = false;
}, "Renamed")}
>
Save
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+194
View File
@@ -0,0 +1,194 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import type { ApiClient } from "$lib/api/client";
import ErrorNote from "$lib/components/error-note.svelte";
import { Skeleton } from "$lib/components/ui/skeleton";
import * as Tabs from "$lib/components/ui/tabs";
import ActivityFeed from "./activity-feed.svelte";
import BindingsList from "./bindings-list.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";
let {
client,
id,
href,
onOpen,
compact = false,
}: {
client: ApiClient;
id: string;
href: (id: string) => string;
onOpen: (id: string) => void;
compact?: boolean;
} = $props();
const feed = new ConversationFeed(
() => client,
untrack(() => id)
);
let tab = $state("activity");
let historyKey = $state(0);
let landed = $state(false);
onMount(() => {
feed.start();
return () => feed.stop();
});
const info = $derived(feed.model.conversation);
function refresh() {
feed.refresh().catch(() => undefined);
historyKey += 1;
}
$effect(() => {
const { running } = feed.model;
if (!running) {
untrack(() => {
historyKey += 1;
});
}
});
$effect(() => {
if (feed.loaded && !landed) {
landed = true;
tab = feed.model.running ? "activity" : "history";
}
});
</script>
<div class="flex h-full min-h-0 flex-col">
{#if feed.error && !info}
<div class="p-4"><ErrorNote message={feed.error} retry={refresh} /></div>
{:else if !info}
<div class="flex flex-col gap-3 p-4">
<Skeleton class="h-8 w-1/2" />
<Skeleton class="h-24 w-full" />
</div>
{:else}
<ConversationHeader
{client}
{href}
{info}
liveDetail={feed.live.detail}
liveState={feed.live.state}
onChanged={refresh}
{onOpen}
/>
<div
class="grid min-h-0 flex-1 grid-cols-1 {compact
? ''
: 'lg:grid-cols-[minmax(0,1fr)_18rem]'}"
>
<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="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.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.Root>
<Composer
{client}
conversationId={id}
disabled={info.status !== "open"}
/>
</div>
{#if !compact}
<aside
class="hidden min-h-0 overflow-y-auto border-l bg-sidebar/50 p-4 lg:block"
>
{@render rail()}
</aside>
{/if}
</div>
{/if}
</div>
{#snippet rail()}
{#if info}
<div class="flex flex-col gap-5">
<section class="flex flex-col gap-1.5">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Queue
</h2>
<QueueList items={info.queue} />
</section>
<section class="flex flex-col gap-1.5">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Windows
</h2>
<BindingsList
bindings={info.bindings}
{client}
conversationId={id}
onChanged={refresh}
/>
</section>
<section class="flex flex-col gap-1.5">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Flags
</h2>
{#if Object.keys(info.flags).length === 0}
<p class="text-muted-foreground text-xs">No flags set.</p>
{:else}
<dl
class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs"
>
{#each Object.entries(info.flags) as [key, value] (key)}
<dt class="text-muted-foreground">{key}</dt>
<dd class="truncate">{JSON.stringify(value)}</dd>
{/each}
</dl>
{/if}
</section>
<section class="flex flex-col gap-1.5">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Session
</h2>
<p class="tabular break-all text-muted-foreground text-xs">
{info.session_id ?? "no session yet"}
</p>
</section>
</div>
{/if}
{/snippet}
+48
View File
@@ -0,0 +1,48 @@
import type { ApiClient } from "$lib/api/client";
import { LiveStream } from "$lib/api/live.svelte";
import { ActivityModel } from "./activity.svelte";
// One conversation, live: the snapshot from ``GET /api/conversations/{id}``
// (bindings, queue, the turn in flight) plus its SSE stream folded into an
// ``ActivityModel``. Shared by the admin thread page and the Obsidian panel.
export class ConversationFeed {
model = new ActivityModel();
error = $state<string | null>(null);
loaded = $state(false);
readonly id: string;
readonly live: LiveStream;
private readonly client: () => ApiClient | null;
constructor(client: () => ApiClient | null, id: string) {
this.client = client;
this.id = id;
this.live = new LiveStream(client, `/api/conversations/${id}/events`, {
onEvent: (event) => this.model.apply(event),
prepare: () => this.refresh(),
});
}
async refresh(): Promise<void> {
const client = this.client();
if (!client) {
return;
}
try {
this.model.setConversation(await client.conversation(this.id));
this.error = null;
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
throw error;
} finally {
this.loaded = true;
}
}
start(): void {
this.live.start();
}
stop(): void {
this.live.stop();
}
}
+130
View File
@@ -0,0 +1,130 @@
<script lang="ts">
import type { ApiClient } from "$lib/api/client";
import type { ContentBlock, HistoryMessage } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import { Skeleton } from "$lib/components/ui/skeleton";
import { Switch } from "$lib/components/ui/switch";
import { clip } from "$lib/format";
import { cn } from "$lib/utils";
import { summarizeInput, toolLabel } from "./activity.svelte";
let {
client,
conversationId,
refreshKey = 0,
}: {
client: ApiClient;
conversationId: string;
refreshKey?: number;
} = $props();
let messages = $state<HistoryMessage[] | null>(null);
let failure = $state<string | null>(null);
let showResults = $state(false);
async function load() {
failure = null;
try {
({ messages } = await client.history(conversationId));
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
}
$effect(() => {
if (refreshKey >= 0) {
load();
}
});
function blocks(message: HistoryMessage): ContentBlock[] {
return typeof message.content === "string"
? [{ text: message.content, type: "text" }]
: message.content;
}
function resultText(block: ContentBlock): string {
const { content } = block;
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
return content
.map((part) =>
part && typeof part === "object" && "text" in part
? String((part as { text: unknown }).text)
: ""
)
.join("\n");
}
return "";
}
const RESULT_CLIP = 600;
</script>
<div class="flex flex-col gap-3">
<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
</span>
{#if failure}
<ErrorNote message={failure} retry={load} />
{:else if messages === null}
<div class="flex flex-col gap-2">
<Skeleton class="h-10 w-2/3" />
<Skeleton class="h-16 w-full" />
<Skeleton class="h-10 w-1/2" />
</div>
{:else if messages.length === 0}
<EmptyState
hint="The transcript mirror is empty - nothing has been said in this conversation yet."
title="No history"
/>
{:else}
{#each messages as message, index (index)}
{@const parts = blocks(message)}
{@const isResultOnly = parts.every((b) => b.type === "tool_result")}
{#if !(isResultOnly && !showResults)}
<div
class={cn(
"flex flex-col gap-1.5",
message.role === "user" && !isResultOnly && "items-end"
)}
>
{#each parts as block, blockIndex (blockIndex)}
{#if block.type === "text" && block.text}
<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"
)}
>
{block.text}
</p>
{:else if block.type === "tool_use"}
<p class="flex items-baseline gap-2 px-1 text-xs">
<span class="font-medium">{toolLabel(block.name ?? "?")}</span>
<span class="truncate text-muted-foreground">
{clip(summarizeInput(block.name ?? "", block.input), 160)}
</span>
</p>
{:else if block.type === "tool_result" && showResults}
<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"
)}
>{clip(resultText(block), RESULT_CLIP)}</pre>
{:else if block.type === "thinking"}
<p class="px-1 text-kind-deep text-xs">thinking</p>
{/if}
{/each}
</div>
{/if}
{/each}
{/if}
</div>
+75
View File
@@ -0,0 +1,75 @@
<script lang="ts">
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client";
import type { PendingQuestion } from "$lib/api/types";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
let {
client,
conversationId,
question,
}: {
client: ApiClient;
conversationId: string;
question: PendingQuestion;
} = $props();
let free = $state("");
let busy = $state(false);
async function answer(text: string) {
if (!text.trim()) {
return;
}
busy = true;
try {
await client.answer(conversationId, question.id, text);
free = "";
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
</script>
<section
class="flex flex-col gap-3 rounded-lg border border-primary/40 bg-primary/5 p-3"
>
<p class="font-medium text-link text-xs uppercase tracking-wide">
The agent is asking
</p>
{#each question.questions as q, index (index)}
<div class="flex flex-col gap-2">
<p class="text-sm">{q.question}</p>
{#if q.options?.length}
<div class="flex flex-wrap gap-1.5">
{#each q.options as option (option.label)}
<Button
disabled={busy}
onclick={() => answer(option.label)}
size="sm"
title={option.description}
variant="outline"
>
{option.label}
</Button>
{/each}
</div>
{/if}
</div>
{/each}
<form
class="flex gap-2"
onsubmit={(event) => {
event.preventDefault();
answer(free);
}}
>
<Input placeholder="Answer in your own words" bind:value={free} />
<Button disabled={busy || !free.trim()} size="sm" type="submit"
>Send</Button
>
</form>
</section>
+36
View File
@@ -0,0 +1,36 @@
<script lang="ts">
import type { QueueItem } from "$lib/api/types";
import StatusPill from "$lib/components/status-pill.svelte";
import { clip, fmtRelative } from "$lib/format";
import { cn } from "$lib/utils";
let { items }: { items: QueueItem[] } = $props();
const PRIORITY: Record<string, string> = {
normal: "text-note",
urgent: "text-destructive",
user: "text-foreground",
};
</script>
{#if items.length === 0}
<p class="text-muted-foreground text-xs">Queue is empty.</p>
{:else}
<ul class="flex flex-col">
{#each items as item (item.id)}
<li class="flex flex-col gap-0.5 border-b py-1.5 text-xs last:border-b-0">
<div class="flex items-center gap-2">
<span class={cn("font-medium", PRIORITY[item.priority])}>
{item.priority}
</span>
<span class="text-muted-foreground">{item.origin}</span>
<StatusPill class="ml-auto" status={item.status} />
</div>
<p class="text-foreground/90">{clip(item.text, 140)}</p>
<span class="tabular text-muted-foreground">
{fmtRelative(item.created_at)}
</span>
</li>
{/each}
</ul>
{/if}
+182
View File
@@ -0,0 +1,182 @@
<script lang="ts">
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import type { ApiClient } from "$lib/api/client";
import type { EntriesPage } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import { Button } from "$lib/components/ui/button";
import * as Select from "$lib/components/ui/select";
import { Skeleton } from "$lib/components/ui/skeleton";
import { fmtTime } from "$lib/format";
import { cn } from "$lib/utils";
let {
client,
conversationId,
}: { client: ApiClient; conversationId: string } = $props();
const PAGE = 50;
let page = $state<EntriesPage | null>(null);
let failure = $state<string | null>(null);
let subpath = $state("");
let offset = $state<number | null>(null);
let open = $state<Set<number>>(new Set());
async function load(sub: string, from: number | null) {
failure = null;
try {
page = await client.entries(conversationId, {
limit: PAGE,
offset: from ?? undefined,
subpath: sub,
});
({ offset } = page);
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
}
$effect(() => {
load(subpath, offset);
});
function toggle(index: number) {
const next = new Set(open);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
open = next;
}
function headline(entry: Record<string, unknown>): string {
const message = entry.message as Record<string, unknown> | undefined;
const content = message?.content;
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
return content
.map((block) => {
const b = block as Record<string, unknown>;
if (b.type === "text") {
return String(b.text ?? "");
}
if (b.type === "tool_use") {
return `tool_use ${String(b.name ?? "")}`;
}
if (b.type === "tool_result") {
return "tool_result";
}
return String(b.type ?? "");
})
.join(" · ");
}
return "";
}
const HEADLINE_MAX = 140;
</script>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-2 text-xs">
{#if page && page.subpaths.length > 0}
<Select.Root
onValueChange={(value) => {
subpath = value === "main" ? "" : value;
offset = null;
}}
type="single"
value={subpath || "main"}
>
<Select.Trigger class="h-7 text-xs" size="sm">
{subpath || "main transcript"}
</Select.Trigger>
<Select.Content>
<Select.Item label="main transcript" value="main" />
{#each page.subpaths as sub (sub)}
<Select.Item label={sub} value={sub} />
{/each}
</Select.Content>
</Select.Root>
{/if}
{#if page}
<span class="tabular text-muted-foreground">
{page.total === 0 ? "0" : `${page.offset + 1}${page.offset + page.entries.length}`}
of {page.total}
</span>
<div class="ml-auto flex gap-1">
<Button
disabled={page.offset === 0}
onclick={() => {
offset = Math.max(0, (page?.offset ?? 0) - PAGE);
}}
size="sm"
variant="ghost"
>
Newer… older
</Button>
<Button
disabled={page.offset + page.entries.length >= page.total}
onclick={() => {
offset = (page?.offset ?? 0) + PAGE;
}}
size="sm"
variant="ghost"
>
Later
</Button>
</div>
{/if}
</div>
{#if failure}
<ErrorNote message={failure} retry={() => load(subpath, offset)} />
{:else if page === null}
<Skeleton class="h-24 w-full" />
{:else if page.entries.length === 0}
<EmptyState
hint="Entries appear once the session store has mirrored the first turn."
title="No transcript entries"
/>
{:else}
<div class="flex flex-col">
{#each page.entries as entry, index (index)}
{@const seq = page.offset + index}
{@const isOpen = open.has(seq)}
<div class="border-b">
<button
aria-expanded={isOpen}
class="row-hover ledger-grid w-full grid-cols-[auto_auto_auto_minmax(0,1fr)] px-1.5 py-1.5 text-left text-xs"
onclick={() => toggle(seq)}
type="button"
>
<ChevronRightIcon
class={cn(
"size-3.5 text-icon transition-transform duration-150",
isOpen && "rotate-90"
)}
/>
<span class="tabular w-8 text-muted-foreground">{seq}</span>
<span class="w-16 font-medium">{String(entry.type ?? "?")}</span>
<span class="truncate text-muted-foreground">
{headline(entry).slice(0, HEADLINE_MAX)}
<span class="tabular ml-2">
{fmtTime(typeof entry.timestamp === "string" ? entry.timestamp : null)}
</span>
</span>
</button>
{#if isOpen}
<pre
class="mb-2 ml-7 max-h-96 overflow-auto rounded-md bg-muted/50 p-2 text-xs whitespace-pre-wrap break-all"
>{JSON.stringify(
entry,
null,
2
)}</pre>
{/if}
</div>
{/each}
</div>
{/if}
</div>
+100
View File
@@ -0,0 +1,100 @@
<script lang="ts">
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { elapsedMs, fmtDuration } from "$lib/format";
import { cn } from "$lib/utils";
import {
SUBAGENT_TOOLS,
summarizeInput,
type ToolNode,
toolLabel,
} from "./activity.svelte";
import ToolNodeView from "./tool-node.svelte";
let {
node,
nodes,
now,
depth = 0,
}: {
node: ToolNode;
nodes: Record<string, ToolNode>;
now: number;
depth?: number;
} = $props();
let expanded = $state(false);
const duration = $derived(elapsedMs(node.startedAt, node.endedAt, now));
const summary = $derived(summarizeInput(node.name, node.input));
const subagent = $derived(SUBAGENT_TOOLS.has(node.name));
const DOT: Record<string, string> = {
aborted: "bg-muted-foreground/50",
done: "bg-ok",
error: "bg-destructive",
running: "bg-signal animate-pulse-dot",
};
const SHOW_DURATION_AFTER_MS = 2000;
</script>
<div class={cn("flex flex-col", depth > 0 && "ml-3 border-l pl-2")}>
<button
aria-expanded={expanded}
class="row-hover ledger-grid grid-cols-[auto_auto_minmax(0,1fr)_auto] rounded-md px-1.5 py-1 text-left text-sm"
onclick={() => {
expanded = !expanded;
}}
type="button"
>
<ChevronRightIcon
class={cn(
"size-3.5 text-icon transition-transform duration-150",
expanded && "rotate-90"
)}
/>
<span class={cn("size-2 rounded-full", DOT[node.status])}></span>
<span class="flex min-w-0 items-baseline gap-2">
<span class={cn("shrink-0 font-medium", subagent && "text-kind-deep")}>
{toolLabel(node.name)}
</span>
{#if summary}
<span class="truncate text-muted-foreground">{summary}</span>
{/if}
</span>
{#if node.status !== "running" || duration >= SHOW_DURATION_AFTER_MS}
<span class="tabular text-muted-foreground text-xs">
{fmtDuration(duration)}
</span>
{/if}
</button>
{#if expanded}
<div class="ml-7 mb-1 flex flex-col gap-1.5">
{#if node.input !== null && node.input !== undefined}
<pre
class="max-h-64 overflow-auto rounded-md bg-muted/60 p-2 text-xs whitespace-pre-wrap break-all"
>{JSON.stringify(
node.input,
null,
2
)}</pre>
{/if}
{#if node.result}
<pre
class={cn(
"max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-md p-2 text-xs",
node.status === "error"
? "bg-destructive/8 text-destructive"
: "bg-muted/40"
)}
>{node.result}</pre>
{/if}
</div>
{/if}
{#if node.children.length > 0}
<div class="flex flex-col">
{#each node.children as childId (childId)}
{#if nodes[childId]}
<ToolNodeView depth={depth + 1} node={nodes[childId]} {nodes} {now} />
{/if}
{/each}
</div>
{/if}
</div>
+96
View File
@@ -0,0 +1,96 @@
<script lang="ts">
import MessageSquareIcon from "@lucide/svelte/icons/message-square";
import StatusPill from "$lib/components/status-pill.svelte";
import {
elapsedMs,
fmtDuration,
fmtMoney,
fmtTime,
fmtTokens,
} from "$lib/format";
import { cn } from "$lib/utils";
import type { Turn } from "./activity.svelte";
import ToolNodeView from "./tool-node.svelte";
let { turn, now }: { turn: Turn; now: number } = $props();
const duration = $derived(
turn.usage?.duration_ms ?? elapsedMs(turn.startedAt, turn.endedAt, now)
);
const originLabel = $derived(
turn.itemOrigin && turn.itemOrigin !== turn.origin
? `${turn.origin} · ${turn.itemOrigin}`
: turn.origin
);
const toolCount = $derived(Object.keys(turn.nodes).length);
</script>
<article
class={cn(
"flex animate-rise flex-col gap-2 border-b py-3",
turn.status === "running" && "bg-signal/[0.03]"
)}
>
<header class="flex flex-wrap items-center gap-x-3 gap-y-1 px-1 text-xs">
<StatusPill status={turn.status} />
<span
class={cn(
"font-medium",
turn.origin === "inject" ? "text-note" : "text-foreground"
)}
>
{originLabel}
</span>
<span class="tabular text-muted-foreground">{fmtTime(turn.startedAt)}</span>
<span class="tabular text-muted-foreground">{fmtDuration(duration)}</span>
{#if toolCount > 0}
<span class="tabular text-muted-foreground">
{toolCount}
tool {toolCount === 1 ? "call" : "calls"}
</span>
{/if}
{#if turn.thinking > 0 && turn.status === "running"}
<span class="text-kind-deep">thinking…</span>
{/if}
{#if turn.usage}
<span class="tabular ml-auto text-muted-foreground">
in {fmtTokens(turn.usage.input)} · out {fmtTokens(turn.usage.output)} ·
cache {fmtTokens(turn.usage.cache_read)}/{fmtTokens(
turn.usage.cache_creation
)}
{#if typeof turn.usage.cost_usd === "number"}
· {fmtMoney(turn.usage.cost_usd)}
{/if}
</span>
{/if}
</header>
{#if turn.userText}
<p
class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5 text-sm whitespace-pre-wrap"
>
{turn.userText}
</p>
{/if}
{#if turn.roots.length > 0}
<div class="flex flex-col">
{#each turn.roots as rootId (rootId)}
{#if turn.nodes[rootId]}
<ToolNodeView node={turn.nodes[rootId]} nodes={turn.nodes} {now} />
{/if}
{/each}
</div>
{/if}
{#each turn.says as text, index (index)}
<p class="mx-1 flex gap-2 rounded-md bg-primary/8 px-2.5 py-1.5 text-sm">
<MessageSquareIcon class="mt-0.5 size-3.5 shrink-0 text-link" />
<span class="whitespace-pre-wrap">{text}</span>
</p>
{/each}
{#if turn.text}
<p class="mx-1 px-1 text-sm whitespace-pre-wrap">{turn.text}</p>
{:else if turn.status === "running" && turn.roots.length === 0 && turn.thinking === 0}
<p class="mx-1 px-1 text-muted-foreground text-sm">
Waiting for the model…
</p>
{/if}
</article>