feat(ui,api): strip board redesign - island, rail by day, context view, server search, vault graph

This commit is contained in:
hh
2026-09-02 03:48:26 +02:00
parent 85b14e2c2f
commit 025f2dbc3f
60 changed files with 5071 additions and 2347 deletions
+24
View File
@@ -3,6 +3,7 @@ import type {
AgentsResponse,
AuditPage,
BusEvent,
ContextResponse,
ConversationInfo,
ConversationSummary,
EntriesPage,
@@ -11,10 +12,12 @@ import type {
LimitsResponse,
MemoryFile,
MemoryTree,
SearchResponse,
SessionsResponse,
TokenRow,
UsageGroup,
UsageResponse,
VaultGraph,
} from "./types";
const TRAILING_SLASHES = /\/+$/;
@@ -182,6 +185,23 @@ export class ApiClient {
return this.get(`/api/conversations/${id}/history`);
}
context(id: string, prompt = false): Promise<ContextResponse> {
return this.get(`/api/conversations/${id}/context`, {
prompt: prompt ? 1 : undefined,
});
}
vaultGraph(paths: string[], limit?: number): Promise<VaultGraph> {
const search = new URLSearchParams();
for (const path of paths) {
search.append("path", path);
}
if (limit) {
search.set("limit", String(limit));
}
return this.get(`/api/vault/graph?${search.toString()}`);
}
entries(
id: string,
params?: { subpath?: string; offset?: number; limit?: number }
@@ -283,6 +303,10 @@ export class ApiClient {
return this.post("/api/conversations", body);
}
search(q: string, limit?: number): Promise<SearchResponse> {
return this.get("/api/search", { limit, q });
}
sessions(): Promise<SessionsResponse> {
return this.get("/api/sessions");
}
+75
View File
@@ -54,6 +54,7 @@ export interface QueueItem {
export interface ConversationSummary {
agent: string;
context_tokens?: number;
created_at: string | null;
flags: Record<string, unknown>;
id: string;
@@ -62,6 +63,7 @@ export interface ConversationSummary {
last_item?: QueueItem | null;
last_user_activity_at: string | null;
origin: string;
parent?: string | null;
parent_row: number | null;
pending_question: boolean;
running_turn: string | null;
@@ -310,3 +312,76 @@ export interface TurnUsage {
input?: number;
output?: number;
}
export interface SearchFile {
line: number;
path: string;
snippet: string;
}
export interface SearchResponse {
conversations: ConversationSummary[];
files: SearchFile[];
query: string;
}
export interface Granule {
bytes: number;
path: string | null;
tag: string | null;
tokens_est: number;
}
export interface SkillInfo {
description: string;
name: string;
path: string;
}
export interface SkillSetInfo {
path: string;
set: string;
skills: SkillInfo[];
}
export interface TouchedFile {
last_at: string;
other: number;
path: string;
reads: number;
writes: number;
}
export interface ContextResponse {
agent: { effort: string | null; model: string; name: string };
context_tokens: number;
files: TouchedFile[];
history_tokens_est: number;
id: string;
kind: Kind;
prompt: { bytes: number; granules: Granule[]; tokens_est: number };
prompt_text?: string;
skills: SkillSetInfo[];
skills_tokens_est: number;
tool_counts: Record<string, number>;
tools: {
allowed: string[] | null;
disallowed: string[];
gateway: string[];
mcps: string[];
};
turns: number;
}
export interface VaultNode {
exists: boolean;
path: string;
title: string;
touched: boolean;
}
export interface VaultGraph {
edges: { from: string; to: string }[];
nodes: VaultNode[];
root: string;
}
-121
View File
@@ -1,121 +0,0 @@
<script lang="ts">
import LogOutIcon from "@lucide/svelte/icons/log-out";
import PanelLeftCloseIcon from "@lucide/svelte/icons/panel-left-close";
import PanelLeftOpenIcon from "@lucide/svelte/icons/panel-left-open";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import { page } from "$app/state";
import LiveDot from "$lib/components/live-dot.svelte";
import ThemeToggle from "$lib/components/theme-toggle.svelte";
import { Button } from "$lib/components/ui/button";
import { gateway } from "$lib/gateway.svelte";
import { isActive, NAV } from "$lib/nav";
import { session } from "$lib/session.svelte";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils";
const running = $derived(gateway.running.length);
const open = $derived(ui.nav);
async function signOut() {
await session.logout();
await goto(`${base}/login`);
}
</script>
<aside
class={cn(
"hidden shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground transition-[width] duration-150 sm:flex",
open ? "w-52" : "w-12"
)}
>
<a
class={cn(
"flex h-12 items-center gap-2 border-b font-semibold tracking-tight",
open ? "px-4" : "justify-center"
)}
href="{base}/"
title="Beaver"
>
<span class="size-2.5 shrink-0 rounded-sm bg-primary"></span>
{#if open}
Beaver
{/if}
</a>
<nav aria-label="Sections" class="flex flex-1 flex-col gap-0.5 p-2">
{#each NAV as item (item.href)}
{@const active = isActive(page.url.pathname, base, item.href)}
<a
aria-current={active ? "page" : undefined}
aria-label={item.label}
class={cn(
"relative flex h-8 items-center gap-2.5 rounded-md text-sm transition-colors",
open ? "px-2.5" : "justify-center",
active
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"
)}
href="{base}{item.href}"
title={open ? undefined : item.label}
>
<item.icon class="size-4 shrink-0 text-icon" />
{#if open}
<span class="flex-1">{item.label}</span>
{/if}
{#if item.href === "/" && running > 0}
{#if open}
<span
class="tabular rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
>
{running}
</span>
{:else}
<span
class="absolute top-1 right-1 size-1.5 rounded-full bg-signal"
></span>
{/if}
{/if}
</a>
{/each}
</nav>
<div
class={cn(
"flex items-center gap-1 border-t",
open ? "px-3 py-2" : "flex-col px-1 py-2"
)}
>
<span
class={cn(open && "mr-auto")}
title="event stream from the gateway (SSE)"
>
<LiveDot
detail={gateway.live.detail}
label={open}
state={gateway.live.state}
/>
</span>
<ThemeToggle />
<Button
aria-label="Sign out"
onclick={signOut}
size="icon-sm"
title="Sign out"
variant="ghost"
>
<LogOutIcon class="size-4" />
</Button>
<Button
aria-label={open ? "Collapse sidebar" : "Expand sidebar"}
onclick={() => ui.toggleNav()}
size="icon-sm"
title="{open ? 'Collapse' : 'Expand'} sidebar (⌘B)"
variant="ghost"
>
{#if open}
<PanelLeftCloseIcon class="size-4" />
{:else}
<PanelLeftOpenIcon class="size-4" />
{/if}
</Button>
</div>
</aside>
+23 -60
View File
@@ -1,79 +1,42 @@
<script lang="ts">
import MoreHorizontalIcon from "@lucide/svelte/icons/more-horizontal";
import { goto } from "$app/navigation";
import ActivityIcon from "@lucide/svelte/icons/activity";
import BrainIcon from "@lucide/svelte/icons/brain";
import GaugeIcon from "@lucide/svelte/icons/gauge";
import MessagesSquareIcon from "@lucide/svelte/icons/messages-square";
import SlidersHorizontalIcon from "@lucide/svelte/icons/sliders-horizontal";
import type { Component } from "svelte";
import { base } from "$app/paths";
import { page } from "$app/state";
import LiveDot from "$lib/components/live-dot.svelte";
import ThemeToggle from "$lib/components/theme-toggle.svelte";
import { Button } from "$lib/components/ui/button";
import * as Sheet from "$lib/components/ui/sheet";
import { gateway } from "$lib/gateway.svelte";
import { isActive, NAV } from "$lib/nav";
import { session } from "$lib/session.svelte";
import { isSectionActive, SECTIONS, SYSTEM } from "$lib/nav";
import { cn } from "$lib/utils";
let moreOpen = $state(false);
const primary = NAV.filter((item) => item.mobile);
const secondary = NAV.filter((item) => !item.mobile);
async function signOut() {
moreOpen = false;
await session.logout();
await goto(`${base}/login`);
}
const ICONS: Record<string, Component<{ class?: string }>> = {
"/": ActivityIcon,
"/conversations": MessagesSquareIcon,
"/memory": BrainIcon,
"/system": SlidersHorizontalIcon,
"/usage": GaugeIcon,
};
const items = [...SECTIONS, SYSTEM];
</script>
<nav
aria-label="Sections"
class="flex shrink-0 items-stretch border-t bg-sidebar pb-[env(safe-area-inset-bottom)] sm:hidden"
class="flex shrink-0 items-stretch border-t bg-rack/90 pb-[env(safe-area-inset-bottom)] backdrop-blur-md sm:hidden"
>
{#each primary as item (item.href)}
{@const active = isActive(page.url.pathname, base, item.href)}
{#each items as item (item.href)}
{@const active = isSectionActive(page.url.pathname, base, item.href)}
{@const Icon = ICONS[item.href]}
<a
aria-current={active ? "page" : undefined}
class={cn(
"flex h-14 flex-1 flex-col items-center justify-center gap-1 text-[11px]",
active ? "text-link" : "text-muted-foreground"
"flex h-13 flex-1 flex-col items-center justify-center gap-0.5 text-[10px] transition-colors",
active ? "text-primary" : "text-muted-foreground"
)}
href="{base}{item.href}"
>
<item.icon class="size-5" />
{item.label}
<Icon class="size-5" />
{item.label.split(" ")[0]}
</a>
{/each}
<button
class="flex h-14 flex-1 flex-col items-center justify-center gap-1 text-[11px] text-muted-foreground"
onclick={() => {
moreOpen = true;
}}
type="button"
>
<MoreHorizontalIcon class="size-5" />
More
</button>
</nav>
<Sheet.Root bind:open={moreOpen}>
<Sheet.Content class="flex flex-col gap-1 pt-10" side="bottom">
<Sheet.Title class="px-2 pb-2">More</Sheet.Title>
{#each secondary as item (item.href)}
<a
class="flex h-11 items-center gap-3 rounded-md px-3 text-sm hover:bg-muted"
href="{base}{item.href}"
onclick={() => {
moreOpen = false;
}}
>
<item.icon class="size-4 text-icon" />
{item.label}
</a>
{/each}
<div class="mt-2 flex items-center justify-between border-t px-3 pt-3">
<LiveDot detail={gateway.live.detail} state={gateway.live.state} />
<div class="flex items-center gap-1">
<ThemeToggle />
<Button onclick={signOut} size="sm" variant="ghost">Sign out</Button>
</div>
</div>
</Sheet.Content>
</Sheet.Root>
@@ -1,323 +0,0 @@
<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>
+5 -13
View File
@@ -1,7 +1,5 @@
<script lang="ts">
import type { Snippet } from "svelte";
import LiveDot from "$lib/components/live-dot.svelte";
import { gateway } from "$lib/gateway.svelte";
let {
title,
@@ -17,7 +15,7 @@
</script>
<header
class="flex min-h-12 flex-wrap items-center gap-x-4 gap-y-2 border-b px-4 py-2 sm:px-6"
class="flex min-h-12 flex-wrap items-center gap-x-4 gap-y-2 px-4 pt-4 pb-1 sm:px-6"
>
<div class="flex min-w-0 items-baseline gap-2">
<h1 class="truncate font-semibold text-lg tracking-tight">{title}</h1>
@@ -30,15 +28,9 @@
{@render children()}
</div>
{/if}
<div class="ml-auto flex items-center gap-2">
{#if actions}
{#if actions}
<div class="ml-auto flex items-center gap-2">
{@render actions()}
{/if}
<LiveDot
class="sm:hidden"
detail={gateway.live.detail}
label={false}
state={gateway.live.state}
/>
</div>
</div>
{/if}
</header>
+8 -10
View File
@@ -123,19 +123,17 @@ export function fmtRelative(
if (!date) {
return "";
}
const diff = now - date.getTime();
const abs = Math.abs(diff);
const suffix = diff >= 0 ? "ago" : "from now";
if (abs < MINUTE_MS) {
return diff >= 0 ? "just now" : "in <1m";
const diff = Math.max(0, now - date.getTime());
if (diff < MINUTE_MS) {
return "just now";
}
if (abs < HOUR_MS) {
return `${Math.round(abs / MINUTE_MS)}m ${suffix}`;
if (diff < HOUR_MS) {
return `${Math.round(diff / MINUTE_MS)}m ago`;
}
if (abs < DAY_MS) {
return `${Math.round(abs / HOUR_MS)}h ${suffix}`;
if (diff < DAY_MS) {
return `${Math.round(diff / HOUR_MS)}h ago`;
}
return `${Math.round(abs / DAY_MS)}d ${suffix}`;
return `${Math.round(diff / DAY_MS)}d ago`;
}
export function fmtCountdown(iso: string | null | undefined, now = Date.now()) {
+27 -24
View File
@@ -1,35 +1,38 @@
import ActivityIcon from "@lucide/svelte/icons/activity";
import BrainIcon from "@lucide/svelte/icons/brain";
import ClockIcon from "@lucide/svelte/icons/clock";
import GaugeIcon from "@lucide/svelte/icons/gauge";
import KeyRoundIcon from "@lucide/svelte/icons/key-round";
import MessagesSquareIcon from "@lucide/svelte/icons/messages-square";
import ScrollTextIcon from "@lucide/svelte/icons/scroll-text";
import type { Component } from "svelte";
export interface NavItem {
href: string;
icon: Component<{ class?: string }>;
key: string;
label: string;
mobile: boolean;
}
export const NAV: NavItem[] = [
{ href: "/", icon: ActivityIcon, label: "Now", mobile: true },
{
href: "/conversations",
icon: MessagesSquareIcon,
label: "Conversations",
mobile: true,
},
{ href: "/usage", icon: GaugeIcon, label: "Usage", mobile: true },
{ href: "/memory", icon: BrainIcon, label: "Memory", mobile: true },
{ href: "/tokens", icon: KeyRoundIcon, label: "Tokens", mobile: false },
{ href: "/jobs", icon: ClockIcon, label: "Jobs", mobile: false },
{ href: "/audit", icon: ScrollTextIcon, label: "Audit", mobile: false },
export const SECTIONS: NavItem[] = [
{ href: "/", key: "1", label: "Now" },
{ href: "/conversations", key: "2", label: "Conversations" },
{ href: "/memory", key: "3", label: "Memory" },
{ href: "/usage", key: "4", label: "Usage" },
];
export const SYSTEM: NavItem = { href: "/system", key: "5", label: "System" };
export const SYSTEM_PAGES: NavItem[] = [
{ href: "/system", key: "", label: "Sessions" },
{ href: "/system/agents", key: "", label: "Agents & endpoints" },
{ href: "/system/jobs", key: "", label: "Jobs" },
{ href: "/system/tokens", key: "", label: "Tokens" },
{ href: "/system/audit", key: "", label: "Audit" },
];
export function isActive(pathname: string, base: string, href: string) {
const path = pathname.startsWith(base)
? pathname.slice(base.length)
: pathname;
const current = path || "/";
if (href === "/") {
return current === "/";
}
return current === href || current.startsWith(`${href}/`);
}
export function isSectionActive(pathname: string, base: string, href: string) {
const path = pathname.startsWith(base)
? pathname.slice(base.length)
: pathname;
+49
View File
@@ -0,0 +1,49 @@
// A small keyed cache behind the panel: the conversation index and the
// last threads land here so the next open paints before the network
// answers. localStorage when it exists, memory otherwise.
export interface PanelCache {
get: <T>(key: string) => T | null;
set: (key: string, value: unknown) => void;
}
const memory = new Map<string, string>();
function storage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
try {
if (typeof localStorage !== "undefined") {
return localStorage;
}
} catch {
/* sandboxed */
}
return {
getItem: (key) => memory.get(key) ?? null,
removeItem: (key) => {
memory.delete(key);
},
setItem: (key, value) => {
memory.set(key, value);
},
};
}
export function panelCache(prefix: string): PanelCache {
const store = storage();
return {
get<T>(key: string): T | null {
try {
const raw = store.getItem(`${prefix}:${key}`);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
},
set(key: string, value: unknown): void {
try {
store.setItem(`${prefix}:${key}`, JSON.stringify(value));
} catch {
store.removeItem(`${prefix}:${key}`);
}
},
};
}
+71 -18
View File
@@ -1,15 +1,14 @@
<script lang="ts">
import { tick } from "svelte";
import { tick, untrack } from "svelte";
import type { ApiClient } from "$lib/api/client";
import type { ContentBlock, HistoryMessage } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
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 type { ActivityModel } from "./activity.svelte";
import { summarizeInput, toolLabel } from "./activity.svelte";
import { usePanelHost } from "./host";
import Markdown from "./markdown.svelte";
import QuestionCard from "./question-card.svelte";
import TurnCard from "./turn-card.svelte";
@@ -27,6 +26,8 @@
} = $props();
const RESULT_CLIP = 600;
const CACHED_MESSAGES = 80;
const host = usePanelHost();
const NEAR_BOTTOM_PX = 120;
const TICK_MS = 1000;
@@ -53,9 +54,21 @@
async function load() {
failure = null;
if (messages === null) {
const cached = host.cache?.get<HistoryMessage[]>(
`history:${conversationId}`
);
if (cached) {
messages = cached;
}
}
try {
({ messages } = await client.history(conversationId));
loadedAt = new Date().toISOString();
host.cache?.set(
`history:${conversationId}`,
messages.slice(-CACHED_MESSAGES)
);
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
@@ -80,7 +93,7 @@
$effect(() => {
if (refreshKey >= 0) {
pinned = true;
load();
untrack(() => load());
}
});
@@ -115,6 +128,28 @@
return () => clearInterval(timer);
});
const SYSTEM_HEAD = /^\[[^\]\n]+\]/;
let openSystem = $state<Set<number>>(new Set());
function systemText(message: HistoryMessage): string | null {
if (message.role !== "user" || typeof message.content !== "string") {
return null;
}
return SYSTEM_HEAD.test(message.content.trimStart())
? message.content
: null;
}
function toggleSystem(index: number) {
const next = new Set(openSystem);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
openSystem = next;
}
function blocks(message: HistoryMessage): ContentBlock[] {
return typeof message.content === "string"
? [{ text: message.content, type: "text" }]
@@ -147,20 +182,22 @@
bind:this={scroller}
>
<div class="flex flex-col gap-3" bind:this={body}>
<span
class="flex items-center gap-2 self-end text-muted-foreground text-xs"
<button
class="rule-word self-end text-xs"
data-active={showResults}
onclick={() => {
showResults = !showResults;
}}
type="button"
>
<Switch aria-label="Show tool results" bind:checked={showResults} />
show tool results
</span>
{showResults ? "hide tool results" : "show tool results"}
</button>
{#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>
<p class="py-6 text-center text-muted-foreground text-xs">
Loading the thread…
</p>
{:else if messages.length === 0 && tail.length === 0}
<EmptyState
hint="Nothing has been said here yet. Write below, or wait for the first inject."
@@ -170,7 +207,21 @@
{#each messages as message, index (index)}
{@const parts = blocks(message)}
{@const isResultOnly = parts.every((b) => b.type === "tool_result")}
{#if !(isResultOnly && !showResults)}
{@const system = systemText(message)}
{#if system}
<button
aria-expanded={openSystem.has(index)}
class="self-center max-w-[75ch] rounded-md px-2 py-1 text-left font-mono text-muted-foreground text-xs hover:bg-accent"
onclick={() => toggleSystem(index)}
type="button"
>
{#if openSystem.has(index)}
<span class="whitespace-pre-wrap break-words">{system}</span>
{:else}
<span class="truncate">{clip(system.split("\n")[0], 120)}</span>
{/if}
</button>
{:else if !(isResultOnly && !showResults)}
<div
class={cn(
"flex flex-col gap-1.5",
@@ -181,9 +232,11 @@
{#if block.type === "text" && block.text}
<Markdown
class={cn(
"max-w-[75ch] rounded-lg px-3 py-2",
message.role === "user" ? "bg-primary/10" : "bg-muted/50"
)}
"max-w-[75ch]",
message.role === "user"
? "rounded-2xl rounded-br-md bg-primary/10 px-3.5 py-2"
: "px-1 py-1"
)}
text={block.text}
/>
{:else if block.type === "tool_use"}
+64 -58
View File
@@ -1,9 +1,8 @@
<script lang="ts">
import SendHorizontalIcon from "@lucide/svelte/icons/send-horizontal";
import ArrowUpIcon from "@lucide/svelte/icons/arrow-up";
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";
import { cn } from "$lib/utils";
let {
client,
@@ -17,28 +16,31 @@
type Mode = "message" | "inject" | "urgent";
const MODES: { value: Mode; label: string; hint: string }[] = [
{ hint: "as you, mirrored to the window", label: "Say", value: "message" },
{
hint: "as the user, mirrored to Telegram",
label: "Message",
value: "message",
},
{
hint: "system note, rides with the next turn",
hint: "a system note for the next turn",
label: "Inject",
value: "inject",
},
{
hint: "interrupts the running turn",
label: "Urgent inject",
value: "urgent",
},
{ hint: "interrupts the running turn", label: "Urgent", value: "urgent" },
];
const MAX_ROWS = 10;
const LINE_PX = 22;
let mode = $state<Mode>("message");
let text = $state("");
let busy = $state(false);
let area = $state<HTMLTextAreaElement | null>(null);
const current = $derived(MODES.find((m) => m.value === mode) ?? MODES[0]);
function grow() {
if (!area) {
return;
}
area.style.height = "auto";
area.style.height = `${Math.min(area.scrollHeight, LINE_PX * MAX_ROWS)}px`;
}
async function send() {
const body = text.trim();
if (!body || busy) {
@@ -56,6 +58,7 @@
);
}
text = "";
queueMicrotask(grow);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
@@ -72,57 +75,60 @@
</script>
<form
class="flex flex-col gap-2 border-t bg-background px-3 py-2"
class="flex flex-col gap-1.5 px-3 pt-2 pb-[max(0.5rem,var(--beaver-inset-bottom,0px))] @md:px-6"
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 @md:inline"
>{current.hint}</span
>
<Button
class="ml-auto"
<div
class={cn(
"flex items-end gap-2 rounded-xl bg-strip px-3 py-2 shadow-lift ring-1 ring-border focus-within:ring-ring",
disabled && "opacity-60"
)}
>
<textarea
aria-label="Message"
class="max-h-56 min-h-6 w-full resize-none bg-transparent py-0.5 text-sm leading-[22px] outline-none placeholder:text-muted-foreground"
{disabled}
oninput={grow}
onkeydown={onKeydown}
placeholder={disabled
? "This conversation is closed."
: `${current.label} something (⌘↩)`}
rows="1"
bind:this={area}
bind:value={text}
></textarea>
<button
aria-label="Send"
class="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity disabled:opacity-30"
disabled={disabled || busy || !text.trim()}
size="sm"
type="submit"
>
<SendHorizontalIcon class="size-4" />
Send
<kbd class="hidden text-[10px] opacity-70 @md:inline">⌘↩</kbd>
</Button>
<ArrowUpIcon class="size-4" />
</button>
</div>
<div class="flex items-center gap-3 px-1">
{#each MODES as option (option.value)}
<button
class={cn(
"rule-word text-xs",
mode === option.value && "text-foreground",
option.value === "urgent" && mode === option.value && "text-destructive"
)}
data-active={mode === option.value}
onclick={() => {
mode = option.value;
}}
title={option.hint}
type="button"
>
{option.label}
</button>
{/each}
<span class="hidden truncate text-muted-foreground text-xs @md:inline">
{current.hint}
</span>
</div>
</form>
+395
View File
@@ -0,0 +1,395 @@
<script lang="ts">
import { onMount } from "svelte";
import type { ApiClient } from "$lib/api/client";
import type { ContextResponse, VaultGraph } from "$lib/api/types";
import ErrorNote from "$lib/components/error-note.svelte";
import { fmtRelative, fmtTokens } from "$lib/format";
import Graph, {
type GraphEdge,
type GraphNode,
} from "$lib/shell/graph.svelte";
import { cn } from "$lib/utils";
import { usePanelHost } from "./host";
let {
client,
conversationId,
window = 1_000_000,
}: {
client: ApiClient;
conversationId: string;
window?: number;
} = $props();
const HARNESS_EST = 15_000;
const FILES_SHOWN = 12;
const MD_SUFFIX = /\.md$/;
const host = usePanelHost();
let data = $state<ContextResponse | null>(null);
let graph = $state<VaultGraph | null>(null);
let graphError = $state<string | null>(null);
let failure = $state<string | null>(null);
let promptOpen = $state(false);
let promptText = $state<string | null>(null);
let filesOpen = $state(false);
async function load() {
failure = null;
try {
data = await client.context(conversationId);
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
return;
}
const paths = data.files.map((f) => f.path);
if (paths.length === 0) {
graph = null;
return;
}
try {
graph = await client.vaultGraph(paths.slice(0, 40));
graphError = null;
} catch (cause) {
graph = null;
graphError = cause instanceof Error ? cause.message : String(cause);
}
}
async function showPrompt() {
promptOpen = !promptOpen;
if (promptOpen && promptText === null) {
try {
const full = await client.context(conversationId, true);
promptText = full.prompt_text ?? "";
} catch (cause) {
promptText = cause instanceof Error ? cause.message : String(cause);
}
}
}
onMount(load);
interface Layer {
label: string;
tokens: number;
tone: string;
}
const layers = $derived.by((): Layer[] => {
if (!data) {
return [];
}
const prompt = data.prompt.tokens_est;
const skills = data.skills_tokens_est;
const harness = Math.min(
HARNESS_EST,
Math.max(0, data.context_tokens - prompt - skills)
);
const history = Math.max(
0,
data.context_tokens - prompt - skills - harness
);
return [
{ label: "harness", tokens: harness, tone: "bg-muted-foreground/40" },
{ label: "prompt", tokens: prompt, tone: "bg-primary" },
{ label: "skills", tokens: skills, tone: "bg-kind-deep" },
{ label: "history & tools", tokens: history, tone: "bg-kind-branch" },
];
});
const total = $derived(data?.context_tokens ?? 0);
const share = (tokens: number) =>
total > 0 ? `${Math.max(0.5, (tokens / total) * 100)}%` : "0%";
const graphNodes = $derived.by((): GraphNode[] => {
if (!graph) {
return [];
}
const single = graph.nodes.filter((n) => n.touched).length === 1;
return graph.nodes.map((n) => ({
href: n.exists ? n.path : undefined,
id: n.path,
kind: "file",
label: n.title,
ring: ringOf(n.touched, single),
state: stateOf(n.touched, n.exists),
}));
});
const graphEdges = $derived.by((): GraphEdge[] =>
graph ? graph.edges.map((e) => ({ from: e.from, to: e.to })) : []
);
const files = $derived(
filesOpen ? (data?.files ?? []) : (data?.files ?? []).slice(0, FILES_SHOWN)
);
function ringOf(touched: boolean, single: boolean): number {
if (!touched) {
return 2;
}
return single ? 0 : 1;
}
function stateOf(touched: boolean, exists: boolean): GraphNode["state"] {
if (!touched) {
return "ghost";
}
return exists ? "quiet" : "closed";
}
function openNote(path: string) {
host.openLink(path.replace(MD_SUFFIX, ""), "internal");
}
function touch(file: {
reads: number;
writes: number;
other: number;
}): string {
const parts: string[] = [];
if (file.reads) {
parts.push(`read ×${file.reads}`);
}
if (file.writes) {
parts.push(`wrote ×${file.writes}`);
}
if (file.other) {
parts.push(`touched ×${file.other}`);
}
return parts.join(" · ");
}
</script>
<div class="@container flex max-w-5xl flex-col gap-8">
{#if failure}
<ErrorNote message={failure} retry={load} />
{:else if !data}
<p class="text-muted-foreground text-sm">Reading the context…</p>
{:else}
<section class="flex flex-col gap-3">
<div class="flex items-baseline gap-3">
<span
class="tabular font-semibold text-3xl leading-none tracking-tight"
>
{fmtTokens(total)}
</span>
<span class="text-muted-foreground text-sm">
of {fmtTokens(window)} · {data.turns}
{data.turns === 1 ? "turn" : "turns"}
· {data.agent.model}
{data.agent.effort
? ` · ${data.agent.effort}`
: ""}
</span>
</div>
<div class="flex h-2 w-full overflow-hidden rounded-full bg-muted">
{#each layers as layer (layer.label)}
{#if layer.tokens > 0}
<span
class={cn("h-full", layer.tone)}
style="width: {share(layer.tokens)}"
title="{layer.label}: {fmtTokens(layer.tokens)}"
></span>
{/if}
{/each}
</div>
<div class="flex flex-wrap gap-x-5 gap-y-1 text-xs">
{#each layers as layer (layer.label)}
<span class="flex items-center gap-1.5">
<span class={cn("size-2 rounded-full", layer.tone)}></span>
<span class="text-muted-foreground">{layer.label}</span>
<span class="tabular">{fmtTokens(layer.tokens)}</span>
</span>
{/each}
<span class="text-muted-foreground/70"
>estimates, except the total</span
>
</div>
</section>
<div class="grid gap-8 @3xl:grid-cols-[minmax(0,1fr)_22rem]">
<div class="flex min-w-0 flex-col gap-8">
<section class="flex flex-col gap-2">
<div class="flex items-baseline gap-3">
<h2 class="font-medium text-sm">System prompt</h2>
<span class="tabular text-muted-foreground text-xs">
{fmtTokens(data.prompt.tokens_est)}
· {data.prompt.granules.length} granules
</span>
<button
class="rule-word ml-auto text-xs"
data-active={promptOpen}
onclick={showPrompt}
type="button"
>
{promptOpen ? "hide text" : "read it"}
</button>
</div>
<div class="bay-rack">
{#each data.prompt.granules as g, index (index)}
<div class="strip text-sm">
<span
class="size-2 justify-self-center rounded-full bg-primary"
></span>
<span class="flex min-w-0 flex-col leading-tight">
<button
class="truncate text-left font-medium hover:underline"
onclick={() => g.path && openNote(g.path)}
type="button"
>
{g.path ?? "verbatim prompt"}
</button>
{#if g.tag}
<span
class="truncate font-mono text-[10px] text-muted-foreground"
>
&lt;{g.tag}&gt;
</span>
{/if}
</span>
<span class="tabular text-muted-foreground text-xs">
{fmtTokens(g.tokens_est)}
</span>
</div>
{/each}
</div>
{#if promptOpen}
<pre
class="max-h-[32rem] overflow-auto rounded-lg border bg-strip p-3 text-xs whitespace-pre-wrap break-words"
>{promptText ?? "…"}</pre>
{/if}
</section>
<section class="flex flex-col gap-2">
<div class="flex items-baseline gap-3">
<h2 class="font-medium text-sm">Skills</h2>
<span class="tabular text-muted-foreground text-xs">
{data.skills.reduce((n, s) => n + s.skills.length, 0)}
loaded
</span>
</div>
{#if data.skills.length === 0}
<p class="text-muted-foreground text-sm">None for this kind.</p>
{:else}
<div class="bay-rack">
{#each data.skills as set (set.path)}
{#each set.skills as skill (skill.path)}
<div class="strip text-sm">
<span
class="size-2 justify-self-center rounded-full bg-kind-deep"
></span>
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate font-medium">{skill.name}</span>
{#if skill.description}
<span class="truncate text-muted-foreground text-xs">
{skill.description}
</span>
{/if}
</span>
<span class="text-muted-foreground text-xs">{set.set}</span>
</div>
{/each}
{/each}
</div>
{/if}
</section>
<section class="flex flex-col gap-2">
<h2 class="font-medium text-sm">Tools</h2>
<dl
class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs"
>
{#if Object.keys(data.tool_counts).length > 0}
<dt class="text-muted-foreground">used</dt>
<dd class="tabular">
{Object.entries(data.tool_counts)
.sort((a, b) => b[1] - a[1])
.map(([name, n]) => `${name.replace("mcp__", "")} ×${n}`)
.join(" ")}
</dd>
{/if}
{#if data.tools.gateway.length}
<dt class="text-muted-foreground">gateway</dt>
<dd>{data.tools.gateway.join(", ")}</dd>
{/if}
{#if data.tools.mcps.length}
<dt class="text-muted-foreground">mcp</dt>
<dd>{data.tools.mcps.join(", ")}</dd>
{/if}
{#if data.tools.allowed}
<dt class="text-muted-foreground">allowed</dt>
<dd>{data.tools.allowed.join(", ")}</dd>
{/if}
{#if data.tools.disallowed.length}
<dt class="text-muted-foreground">off</dt>
<dd class="text-muted-foreground">
{data.tools.disallowed.join(", ")}
</dd>
{/if}
</dl>
</section>
</div>
<div class="flex min-w-0 flex-col gap-8">
<section class="flex flex-col gap-2">
<div class="flex items-baseline gap-3">
<h2 class="font-medium text-sm">Notes it reached</h2>
<span class="tabular text-muted-foreground text-xs"
>{data.files.length}</span
>
</div>
{#if graph && graph.nodes.length > 0}
<div class="rounded-lg border bg-strip/60 p-2">
<Graph edges={graphEdges} nodes={graphNodes} onOpen={openNote} />
</div>
<p class="text-muted-foreground text-xs">
Solid: read or written by this thread. Hollow: one link away, not
reached.
</p>
{:else if graphError}
<p class="text-muted-foreground text-xs">{graphError}</p>
{/if}
{#if data.files.length === 0}
<p class="text-muted-foreground text-sm">No files touched yet.</p>
{:else}
<div class="bay-rack">
{#each files as file (file.path)}
<button
class="strip w-full text-left text-sm"
onclick={() => openNote(file.path)}
type="button"
>
<span
class={cn(
"size-2 justify-self-center rounded-full",
file.writes ? "bg-primary" : "bg-kind-branch"
)}
></span>
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate font-medium">{file.path}</span>
<span class="truncate text-muted-foreground text-xs"
>{touch(file)}</span
>
</span>
<span class="tabular text-muted-foreground text-xs">
{fmtRelative(file.last_at)}
</span>
</button>
{/each}
</div>
{#if data.files.length > FILES_SHOWN}
<button
class="rule-word self-start text-xs"
onclick={() => {
filesOpen = !filesOpen;
}}
type="button"
>
{filesOpen ? "fewer" : `all ${data.files.length}`}
</button>
{/if}
{/if}
</section>
</div>
</div>
{/if}
</div>
+176 -79
View File
@@ -1,15 +1,16 @@
<script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import GitBranchIcon from "@lucide/svelte/icons/git-branch";
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 { clip, fmtRelative, shortId } from "$lib/format";
import { fmtRelative, fmtTokens, shortId } from "$lib/format";
import KindMark from "$lib/shell/kind-mark.svelte";
import { cn } from "$lib/utils";
import BindingsList from "./bindings-list.svelte";
import BranchDialog from "./branch-dialog.svelte";
import ConversationMenu from "./conversation-menu.svelte";
import QueueList from "./queue-list.svelte";
let {
client,
@@ -20,63 +21,119 @@
onChanged,
onOpen,
showTitle = true,
view = $bindable("chat"),
onContext,
}: {
client: ApiClient;
info: ConversationInfo;
liveState: LiveState;
liveDetail?: string | null;
// Where another conversation lives as a link; without it, parents open in place.
href?: ((id: string) => string) | null;
onChanged: () => void;
onOpen: (id: string) => void;
showTitle?: boolean;
view?: string;
onContext?: () => void;
} = $props();
let branchOpen = $state(false);
const VIEWS = [
{ label: "Thread", value: "chat" },
{ label: "Activity", value: "activity" },
{ label: "Raw", value: "raw" },
{ label: "Context", value: "context" },
];
const WINDOW_MAX = 56;
const windows = $derived(info.bindings.filter((b) => b.visible));
let branchOpen = $state(false);
let detailsOpen = $state(false);
const mood = $derived.by(() => {
if (info.running_turn) {
return {
dot: "bg-signal animate-pulse-dot",
text: "in motion",
tone: "text-signal",
};
}
if (info.pending_question) {
return {
dot: "bg-attention",
text: "waiting for you",
tone: "text-attention-foreground",
};
}
if (info.status !== "open") {
return {
dot: "bg-muted-foreground/50",
text: info.status,
tone: "text-muted-foreground",
};
}
return {
dot: "bg-muted-foreground/50",
text: `quiet · ${fmtRelative(info.last_activity_at)}`,
tone: "text-muted-foreground",
};
});
const queued = $derived(
info.queue.filter((item) => item.status === "queued").length
);
const connection = $derived(
liveState === "open" ? null : (liveDetail ?? liveState)
);
</script>
<header class="flex flex-col gap-1.5 border-b px-3 py-2.5 @md:px-6">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<header class="flex flex-col border-b bg-background/80 backdrop-blur-md">
<div class="flex items-center gap-2 px-3 py-2 @md:px-6">
{#if showTitle}
<KindBadge kind={info.kind} />
<KindMark kind={info.kind} />
<h1 class="min-w-0 truncate font-semibold text-base tracking-tight">
{info.title || `${info.kind} ${shortId(info.id)}`}
</h1>
{/if}
<StatusPill status={info.running_turn ? "running" : info.status} />
{#if info.pending_question}
<span class="font-medium text-link text-xs">question pending</span>
<span class={cn("flex shrink-0 items-center gap-1.5 text-xs", mood.tone)}>
<span class={cn("size-1.5 rounded-full", mood.dot)}></span>
<span class="hidden @sm:inline">{mood.text}</span>
</span>
{#if queued > 0}
<span
class="tabular text-note text-xs"
title="messages waiting for the next turn"
>
+{queued}
</span>
{/if}
<div class="ml-auto flex items-center gap-1">
<LiveDot
class="hidden @md:inline-flex"
detail={liveDetail}
state={liveState}
/>
<LiveDot
class="@md:hidden"
detail={liveDetail}
label={false}
state={liveState}
/>
<Button
{#if connection}
<span class="truncate text-warn text-xs" title={liveDetail ?? undefined}>
{connection}
</span>
{/if}
<div class="ml-auto flex shrink-0 items-center gap-1">
{#if info.context_tokens}
<button
class="rule-word tabular inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs hover:bg-accent"
onclick={() => {
view = "context";
onContext?.();
}}
title="context of the last turn - what the model is holding"
type="button"
>
{fmtTokens(info.context_tokens)}
<span class="text-muted-foreground/70">/ 1M</span>
</button>
{/if}
<button
aria-label="Branch off this conversation"
class="rule-word inline-flex size-7 items-center justify-center rounded-md hover:bg-accent disabled:opacity-40"
disabled={info.status !== "open"}
onclick={() => {
branchOpen = true;
}}
size="sm"
variant="outline"
title="Branch off"
type="button"
>
<GitBranchIcon class="size-4" />
<span class="hidden @md:inline">Branch</span>
</Button>
</button>
<ConversationMenu
{client}
current
@@ -86,59 +143,99 @@
{onChanged}
{onOpen}
/>
<button
aria-expanded={detailsOpen}
aria-label="Details"
class={cn(
"rule-word inline-flex size-7 items-center justify-center rounded-md hover:bg-accent",
detailsOpen && "bg-accent text-foreground"
)}
onclick={() => {
detailsOpen = !detailsOpen;
}}
title="Details"
type="button"
>
<ChevronDownIcon
class={cn("size-4 transition-transform", detailsOpen && "rotate-180")}
/>
</button>
</div>
</div>
<!-- Below @md the column is the live thread's; the rest of this lives in Meta. -->
<div
class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs"
>
<span class="hidden @md:inline">{info.agent}</span>
<span class="hidden @md:inline">via {info.origin}</span>
{#if info.parent}
{#if href}
<a class="text-link hover:underline" href={href(info.parent)}>
parent {shortId(info.parent)}
</a>
{:else}
<button
class="text-link hover:underline"
onclick={() => info.parent && onOpen(info.parent)}
type="button"
>
parent {shortId(info.parent)}
</button>
{/if}
{/if}
<span class="tabular hidden @md:inline" title={info.id}>
{shortId(info.id)}
</span>
<span class="tabular hidden @md:inline">
{info.live ? "session live" : "no live session"}
{info.busy ? " · busy" : ""}
</span>
<span class="tabular">
last activity {fmtRelative(info.last_activity_at)}
</span>
{#if queued > 0}
<span
class="tabular font-medium text-note"
title="messages waiting for the next turn"
<div class="flex items-center gap-4 px-3 pb-1.5 @md:px-6">
{#each VIEWS as item (item.value)}
<button
class="rule-word text-xs"
data-active={view === item.value}
onclick={() => {
view = item.value;
}}
type="button"
>
{queued}
queued
</span>
{/if}
{item.label}
</button>
{/each}
</div>
{#if windows.length > 0}
{#if detailsOpen}
<div
class="hidden flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs @md:flex"
class="animate-fade-in grid gap-x-6 gap-y-3 border-t px-3 py-3 text-xs @md:grid-cols-2 @md:px-6"
>
{#each windows as window (window.frontend + window.external_id)}
<span class="truncate" title="{window.frontend}: {window.external_id}">
<span class="text-foreground/70">{window.frontend}</span>
{clip(window.external_id, WINDOW_MAX)}
</span>
{/each}
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1">
<dt class="text-muted-foreground">agent</dt>
<dd>{info.agent}</dd>
<dt class="text-muted-foreground">opened via</dt>
<dd>{info.origin}</dd>
{#if info.parent}
<dt class="text-muted-foreground">parent</dt>
<dd>
{#if href}
<a class="text-link hover:underline" href={href(info.parent)}>
{shortId(info.parent)}
</a>
{:else}
<button
class="text-link hover:underline"
onclick={() => info.parent && onOpen(info.parent)}
type="button"
>
{shortId(info.parent)}
</button>
{/if}
</dd>
{/if}
<dt class="text-muted-foreground">id</dt>
<dd class="tabular break-all">{info.id}</dd>
<dt class="text-muted-foreground">session</dt>
<dd class="tabular break-all">
{info.session_id ?? "none yet"}
{info.live ? " · process alive" : ""}
</dd>
{#if Object.keys(info.flags).length > 0}
<dt class="text-muted-foreground">flags</dt>
<dd class="tabular">
{Object.entries(info.flags)
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.join(" ")}
</dd>
{/if}
</dl>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1">
<span class="text-muted-foreground">windows</span>
<BindingsList
bindings={info.bindings}
{client}
conversationId={info.id}
{onChanged}
/>
</div>
{#if info.queue.length > 0}
<div class="flex flex-col gap-1">
<span class="text-muted-foreground">queue</span>
<QueueList items={info.queue} />
</div>
{/if}
</div>
</div>
{/if}
</header>
-219
View File
@@ -1,219 +0,0 @@
<script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check";
import Link2Icon from "@lucide/svelte/icons/link-2";
import Link2OffIcon from "@lucide/svelte/icons/link-2-off";
import type { ApiClient } from "$lib/api/client";
import type { ConversationSummary, Kind } 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 { Input } from "$lib/components/ui/input";
import { Skeleton } from "$lib/components/ui/skeleton";
import ConversationRow from "./conversation-row.svelte";
import type { ConversationIndex } from "./index.svelte";
let {
client,
index,
selected = null,
onOpen,
follow = $bindable(false),
showFollow = false,
autofocus = false,
}: {
client: ApiClient;
index: ConversationIndex;
selected?: string | null;
onOpen: (id: string) => void;
follow?: boolean;
showFollow?: boolean;
autofocus?: boolean;
} = $props();
const GROUPS: { kind: Kind; label: string }[] = [
{ kind: "master", label: "Master" },
{ kind: "branch", label: "Branches" },
{ kind: "deep", label: "Deep chats" },
{ kind: "job", label: "Jobs" },
{ kind: "fork", label: "Forks" },
];
let search = $state("");
let withClosed = $state(false);
let list = $state<HTMLDivElement | null>(null);
let searchInput = $state<HTMLInputElement | null>(null);
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));
}
const rows = $derived.by(() => {
const needle = search.trim().toLowerCase();
return index.conversations
.filter(
(row) => withClosed || row.status === "open" || row.id === selected
)
.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);
});
const groups = $derived(
GROUPS.map((group) => ({
...group,
rows: rows.filter((row) => row.kind === group.kind),
})).filter((group) => group.rows.length > 0)
);
function refresh() {
index.load().catch(() => undefined);
}
// ↑/↓ walk the rows, / jumps to the search box, Enter is the button's own.
function onKeydown(event: KeyboardEvent) {
if (event.key === "/" && event.target !== searchInput) {
event.preventDefault();
searchInput?.focus();
return;
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
return;
}
const buttons = [
...(list?.querySelectorAll<HTMLButtonElement>("[data-row]") ?? []),
];
if (buttons.length === 0) {
return;
}
event.preventDefault();
const at = buttons.indexOf(document.activeElement as HTMLButtonElement);
const step = event.key === "ArrowDown" ? 1 : -1;
const next = at < 0 ? 0 : (at + step + buttons.length) % buttons.length;
buttons[next].focus();
}
$effect(() => {
if (autofocus) {
searchInput?.focus();
}
});
</script>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
aria-label="Conversations"
class="flex h-full min-h-0 flex-col"
onkeydown={onKeydown}
role="listbox"
tabindex="-1"
>
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
<Input
aria-label="Search conversations"
class="h-8 min-w-0 flex-1 text-xs"
placeholder="search"
bind:ref={searchInput}
bind:value={search}
/>
<Button
aria-pressed={withClosed}
class="text-xs"
onclick={() => {
withClosed = !withClosed;
}}
size="xs"
title={withClosed
? "Hide closed conversations"
: "Show closed conversations"}
variant={withClosed ? "secondary" : "ghost"}
>
{#if withClosed}
<CheckIcon class="size-3" />
{/if}
closed
</Button>
{#if showFollow}
<Button
aria-label="Follow the active note"
aria-pressed={follow}
onclick={() => {
follow = !follow;
}}
size="icon-sm"
title={follow
? "Following the active note - click to pin this conversation"
: "Pinned - click to follow the active note"}
variant={follow ? "secondary" : "ghost"}
>
{#if follow}
<Link2Icon class="size-4" />
{:else}
<Link2OffIcon class="size-4" />
{/if}
</Button>
{/if}
</div>
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={list}>
{#if index.live.state === "failed"}
<div class="p-3">
<ErrorNote
message={index.live.detail ?? "event stream failed"}
retry={() => index.start()}
/>
</div>
{:else if !index.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={search
? "Nothing matches. Try fewer letters, or include closed ones."
: "Nothing is open. Say something to the master in Telegram, or send a note from Obsidian."}
title="No conversations"
/>
</div>
{:else}
{#each groups as group (group.kind)}
<section class="flex flex-col">
<h2
class="sticky top-0 z-10 bg-background/95 px-3 pt-3 pb-1 font-medium text-muted-foreground text-xs uppercase tracking-wide backdrop-blur-sm"
>
{group.label}
<span class="tabular ml-1 normal-case tracking-normal">
{group.rows.length}
</span>
</h2>
<ul class="flex flex-col">
{#each group.rows as row (row.id)}
<li class="border-b last:border-b-0">
<ConversationRow
{client}
onChanged={refresh}
{onOpen}
{row}
selected={selected === row.id}
/>
</li>
{/each}
</ul>
</section>
{/each}
{/if}
</div>
</div>
-91
View File
@@ -1,91 +0,0 @@
<script lang="ts">
import type { ApiClient } from "$lib/api/client";
import type { ConversationSummary } from "$lib/api/types";
import KindBadge from "$lib/components/kind-badge.svelte";
import StatusPill from "$lib/components/status-pill.svelte";
import { clip, fmtRelative, shortId } from "$lib/format";
import { cn } from "$lib/utils";
import ConversationMenu from "./conversation-menu.svelte";
import { usePanelHost } from "./host";
let {
client,
row,
selected = false,
onOpen,
onChanged,
}: {
client: ApiClient;
row: ConversationSummary;
selected?: boolean;
onOpen: (id: string) => void;
onChanged: () => void;
} = $props();
const host = usePanelHost();
const TITLE_MAX = 60;
const PREVIEW_MAX = 90;
const title = $derived(
row.title
? clip(row.title, TITLE_MAX)
: `${row.kind === "master" ? "Master" : row.kind} · ${shortId(row.id)}`
);
const when = $derived(row.last_activity_at ?? row.created_at ?? null);
</script>
<ConversationMenu
class={cn("group/row block", selected && "bg-sidebar-accent")}
{client}
current={selected}
id={row.id}
mode="context"
{onChanged}
{onOpen}
>
<div class="relative">
<button
aria-current={selected ? "true" : undefined}
class="row-hover flex w-full flex-col items-stretch justify-start gap-1 px-3 py-2 text-left text-sm"
data-row={row.id}
onclick={() => onOpen(row.id)}
type="button"
>
<span class="flex w-full items-center gap-2">
<KindBadge kind={row.kind} />
<span class="min-w-0 flex-1 truncate font-medium">{title}</span>
<span class="tabular shrink-0 text-muted-foreground text-xs">
{fmtRelative(when)}
</span>
</span>
<span class="flex w-full items-center gap-2 pr-7 text-xs">
<StatusPill status={row.running_turn ? "running" : row.status} />
<span class="truncate text-muted-foreground">{row.agent}</span>
{#if row.pending_question}
<span class="shrink-0 font-medium text-link">question</span>
{/if}
</span>
{#if row.last_item}
<span
class="block w-full min-w-0 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}
</button>
<ConversationMenu
class={cn(
"absolute right-1.5 bottom-1 size-7 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/row:opacity-100 aria-expanded:opacity-100",
host.touch && "opacity-100",
row.last_item && "bottom-6"
)}
{client}
current={selected}
id={row.id}
mode="button"
{onChanged}
{onOpen}
/>
</div>
</ConversationMenu>
+30 -99
View File
@@ -2,15 +2,12 @@
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 ChatView from "./chat-view.svelte";
import Composer from "./composer.svelte";
import ContextView from "./context-view.svelte";
import { ConversationFeed } from "./conversation.svelte";
import ConversationHeader from "./conversation-header.svelte";
import QueueList from "./queue-list.svelte";
import RawEntries from "./raw-entries.svelte";
let {
@@ -19,6 +16,7 @@
href = null,
onOpen,
showTitle = true,
initialView = "chat",
}: {
client: ApiClient;
id: string;
@@ -26,13 +24,15 @@
onOpen: (id: string) => void;
// Off when a switcher above the thread already names it.
showTitle?: boolean;
// "activity" beside a deep chat's own note: the file is the thread.
initialView?: string;
} = $props();
const feed = new ConversationFeed(
() => client,
untrack(() => id)
);
let tab = $state("chat");
let view = $state(untrack(() => initialView));
let historyKey = $state(0);
onMount(() => {
@@ -61,9 +61,10 @@
{#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 class="flex flex-1 items-center justify-center p-4">
<span
class="size-2 animate-pulse-dot rounded-full bg-muted-foreground/40"
></span>
</div>
{:else}
<ConversationHeader
@@ -75,49 +76,36 @@
onChanged={refresh}
{onOpen}
{showTitle}
bind:view
/>
<div class="flex min-h-0 flex-1 flex-col">
<Tabs.Root class="flex min-h-0 flex-1 flex-col gap-0" bind:value={tab}>
<div class="border-b px-3 py-2 @md:px-6">
<Tabs.List class="w-fit max-w-full">
<Tabs.Trigger value="chat">Chat</Tabs.Trigger>
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
<Tabs.Trigger value="raw">Raw</Tabs.Trigger>
<Tabs.Trigger value="meta">Meta</Tabs.Trigger>
</Tabs.List>
</div>
<Tabs.Content class="flex min-h-0 flex-1 flex-col" value="chat">
<ChatView
{client}
conversationId={id}
model={feed.model}
refreshKey={historyKey}
/>
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
value="activity"
>
{#if view === "chat"}
<ChatView
{client}
conversationId={id}
model={feed.model}
refreshKey={historyKey}
/>
{:else if view === "activity"}
<div class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6">
<ActivityFeed
{client}
connected={feed.live.state === "open"}
conversationId={id}
model={feed.model}
/>
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
value="raw"
>
</div>
{:else if view === "context"}
<div class="min-h-0 flex-1 overflow-y-auto px-3 py-4 @md:px-6">
{#key historyKey}
<ContextView {client} conversationId={id} />
{/key}
</div>
{:else}
<div class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6">
<RawEntries {client} conversationId={id} />
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
value="meta"
>
{@render meta()}
</Tabs.Content>
</Tabs.Root>
</div>
{/if}
<Composer
{client}
conversationId={id}
@@ -126,60 +114,3 @@
</div>
{/if}
</div>
{#snippet meta()}
{#if info}
<div class="flex max-w-3xl 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}
+4
View File
@@ -1,4 +1,5 @@
import { getContext, setContext } from "svelte";
import { type PanelCache, panelCache } from "./cache";
export type LinkKind = "internal" | "external";
@@ -7,6 +8,8 @@ export type LinkKind = "internal" | "external";
// Obsidian plugin renders through MarkdownRenderer and opens notes in
// place. Anything the host leaves undefined falls back to the browser way.
export interface PanelHost {
// Where the index and the last threads persist between opens.
cache?: PanelCache;
// Renders ``text`` into ``node``; returns the cleanup. Absent: marked + DOMPurify.
markdown?: (node: HTMLElement, text: string) => (() => void) | undefined;
name: "browser" | "obsidian";
@@ -35,6 +38,7 @@ export function browserHost(): PanelHost {
return browser;
}
browser = {
cache: panelCache("beaver.panel"),
name: "browser",
openLink(target, kind) {
if (kind === "internal") {
+12 -1
View File
@@ -1,9 +1,12 @@
import type { ApiClient } from "$lib/api/client";
import { LiveStream } from "$lib/api/live.svelte";
import type { BusEvent, ConversationSummary } from "$lib/api/types";
import type { PanelCache } from "./cache";
const RELOAD_DEBOUNCE_MS = 1500;
const PAGE = 500;
const CACHED_ROWS = 200;
const INDEX_KEY = "index";
// The conversation index kept fresh by ``/api/events``: rows land as
// ``conversation.*`` events arrive, turn markers flip ``running_turn``
@@ -14,10 +17,17 @@ export class ConversationIndex {
loaded = $state(false);
readonly live: LiveStream;
protected readonly client: () => ApiClient | null;
private readonly cache: PanelCache | null;
private reloadTimer: ReturnType<typeof setTimeout> | null = null;
constructor(client: () => ApiClient | null) {
constructor(client: () => ApiClient | null, cache: PanelCache | null = null) {
this.client = client;
this.cache = cache;
const cached = cache?.get<ConversationSummary[]>(INDEX_KEY);
if (cached && cached.length > 0) {
this.conversations = cached;
this.loaded = true;
}
this.live = new LiveStream(client, "/api/events", {
onEvent: (event) => this.apply(event),
prepare: () => this.load(),
@@ -44,6 +54,7 @@ export class ConversationIndex {
const list = await client.conversations({ limit: PAGE });
this.conversations = list.conversations;
this.loaded = true;
this.cache?.set(INDEX_KEY, list.conversations.slice(0, CACHED_ROWS));
}
apply(event: BusEvent): void {
+22 -28
View File
@@ -3,19 +3,20 @@
import Link2Icon from "@lucide/svelte/icons/link-2";
import Link2OffIcon from "@lucide/svelte/icons/link-2-off";
import type { ApiClient } from "$lib/api/client";
import KindBadge from "$lib/components/kind-badge.svelte";
import LiveDot from "$lib/components/live-dot.svelte";
import { Button } from "$lib/components/ui/button";
import * as Popover from "$lib/components/ui/popover";
import { clip, shortId } from "$lib/format";
import ConversationPicker from "./conversation-picker.svelte";
import Rail from "$lib/rail/rail.svelte";
import KindMark from "$lib/shell/kind-mark.svelte";
import { cn } from "$lib/utils";
import type { ConversationIndex } from "./index.svelte";
import PanelIsland from "./panel-island.svelte";
let {
client,
index,
selected = null,
onOpen,
onOpenFile,
follow = $bindable(false),
showFollow = false,
}: {
@@ -23,6 +24,7 @@
index: ConversationIndex;
selected?: string | null;
onOpen: (id: string) => void;
onOpenFile?: (path: string) => void;
follow?: boolean;
showFollow?: boolean;
} = $props();
@@ -30,10 +32,10 @@
const TITLE_MAX = 48;
let switcherOpen = $state(false);
const current = $derived(selected ? index.byId(selected) : undefined);
const running = $derived(index.running.length);
</script>
<div class="flex items-center gap-1 border-b px-2 py-1.5">
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
<PanelIsland compact {index} {onOpen} />
<Popover.Root bind:open={switcherOpen}>
<Popover.Trigger>
{#snippet child({ props })}
@@ -44,7 +46,7 @@
type="button"
>
{#if current}
<KindBadge kind={current.kind} />
<KindMark kind={current.kind} />
<span class="min-w-0 flex-1 truncate font-medium">
{current.title
? clip(current.title, TITLE_MAX)
@@ -59,14 +61,6 @@
Pick a conversation
</span>
{/if}
{#if running > 0 && !current?.running_turn}
<span
class="tabular shrink-0 rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
title="{running} running elsewhere"
>
{running}
</span>
{/if}
<ChevronDownIcon class="size-3.5 shrink-0 text-icon" />
</button>
{/snippet}
@@ -76,44 +70,44 @@
class="w-[min(24rem,calc(100vw-1rem))] gap-0 overflow-hidden p-0"
sideOffset={6}
>
<div class="flex max-h-[min(70vh,32rem)] flex-col">
<ConversationPicker
autofocus
<div class="flex h-[min(70vh,32rem)] flex-col">
<Rail
{client}
{index}
onOpen={(id) => {
switcherOpen = false;
onOpen(id);
}}
onOpenFile={(path) => {
switcherOpen = false;
onOpenFile?.(path);
}}
{selected}
/>
</div>
</Popover.Content>
</Popover.Root>
<LiveDot
class="px-1"
detail={index.live.detail}
label={false}
state={index.live.state}
/>
{#if showFollow}
<Button
<button
aria-label="Follow the active note"
aria-pressed={follow}
class={cn(
"rule-word inline-flex size-7 shrink-0 items-center justify-center rounded-md",
follow && "bg-accent text-foreground"
)}
onclick={() => {
follow = !follow;
}}
size="icon-sm"
title={follow
? "Following the active note - click to pin this conversation"
: "Pinned - click to follow the active note"}
variant={follow ? "secondary" : "ghost"}
type="button"
>
{#if follow}
<Link2Icon class="size-4" />
{:else}
<Link2OffIcon class="size-4" />
{/if}
</Button>
</button>
{/if}
</div>
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import { cn } from "$lib/utils";
import type { ConversationIndex } from "./index.svelte";
let {
index,
onOpen,
compact = false,
class: className = "",
}: {
index: ConversationIndex;
onOpen: (id: string) => void;
compact?: boolean;
class?: string;
} = $props();
const waiting = $derived(index.open.filter((row) => row.pending_question));
const running = $derived(
index.open.filter((row) => row.running_turn && !row.pending_question)
);
const live = $derived(index.live.state);
const dot = $derived.by(() => {
if (live === "failed") {
return "bg-destructive";
}
if (live !== "open") {
return "bg-warn";
}
return running.length > 0 ? "bg-signal animate-pulse-dot" : "bg-ok";
});
const headline = $derived.by(() => {
if (live === "failed") {
return "offline";
}
if (live !== "open") {
return "connecting";
}
if (running.length === 0) {
return "idle";
}
return running.length === 1 ? "1 in motion" : `${running.length} in motion`;
});
const target = $derived(waiting[0] ?? running[0] ?? null);
const questions = $derived.by(() => {
if (compact) {
return String(waiting.length);
}
return waiting.length === 1 ? "1 question" : `${waiting.length} questions`;
});
</script>
<button
aria-label={target ? `Open ${target.title ?? target.kind}` : headline}
class={cn("island", compact && "gap-1.5 px-2.5", className)}
disabled={!target}
onclick={() => target && onOpen(target.id)}
title={index.live.detail ?? headline}
type="button"
>
<span class={cn("size-2 shrink-0 rounded-full", dot)}></span>
{#if !compact}
<span class="font-medium">{headline}</span>
{/if}
{#if waiting.length > 0}
{#if !compact}
<span class="island-sep"></span>
{/if}
<span class="font-medium text-attention-foreground">{questions}</span>
{/if}
</button>
+30 -31
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import { onMount } from "svelte";
import type { ApiClient } from "$lib/api/client";
import EmptyState from "$lib/components/empty-state.svelte";
import ConversationPicker from "./conversation-picker.svelte";
import Rail from "$lib/rail/rail.svelte";
import ConversationView from "./conversation-view.svelte";
import type { ConversationIndex } from "./index.svelte";
import PanelBar from "./panel-bar.svelte";
import PanelIsland from "./panel-island.svelte";
let {
client,
@@ -13,12 +13,17 @@
selected = $bindable(null),
follow = $bindable(false),
showFollow = false,
companion = false,
onOpenFile,
}: {
client: ApiClient;
index: ConversationIndex;
selected?: string | null;
follow?: boolean;
showFollow?: boolean;
// The thread lives in the note beside this panel: open on activity.
companion?: boolean;
onOpenFile?: (path: string) => void;
} = $props();
// 48rem of panel: below it the switcher names the thread, above it the rail does.
@@ -37,19 +42,17 @@
</script>
<!--
impeccable direction contract (mode operate, brief-pinned world: beaver-calendar's Obsidian skin)
THESIS: the agent's terminal, not a chat app. The thread is always on screen; picking a
conversation is a header switch, never a screen of its own. Refuses the messenger's list-first
layout and the admin's chrome.
OWN-WORLD: Obsidian's own variables through .beaver-root - its font, radii, accent, and status
colors; hairline rows, tabular numerals, uppercase 12px section labels; nothing pink.
STORY: the operator opens a note, the panel follows to its conversation, they watch tools and
subagents stream, answer a question, branch off with a chosen seed, send the branch back to
Telegram, and close a deep chat with memory on or off - all from the sidedock.
FIRST VIEWPORT: narrow column: the switcher (kind badge, title, live dot, follow toggle) over
the thread header, tabs, the live thread and the composer pinned at the bottom. Wide tab:
the same with the grouped conversation rail on the left. Primary action: write to the agent.
FORM: two-pane operator console; pinned by the brief, no roll.
impeccable direction contract (seed 071a8c77, mode operate, strip board × live activity, in Obsidian's skin)
THESIS: the agent's front inside the vault, not a website in a pane. The island carries what is
alive, the rail is the day's strips, the thread owns its column.
OWN-WORLD: Obsidian's own variables through .beaver-root; strips with a state edge, hairline
rules, tabular numerals; amber only for a question waiting; nothing decorative.
STORY: the operator opens the tab and reads the island, picks today's strip, answers a question,
watches tools stream; beside a deep chat the sidedock shows the agent's activity, never a
second copy of the note.
FIRST VIEWPORT: narrow: island + switcher over the thread and the composer pinned low. Wide:
the rail with the island on top, the thread to the right.
FORM: strip board × live activity; user-steered; seed key 071a8c77.
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review,
the verdict, and DESIGN.md
-->
@@ -59,15 +62,11 @@ the verdict, and DESIGN.md
>
<div class="flex min-h-0 flex-1">
{#if wide}
<aside class="flex w-72 shrink-0 flex-col border-r bg-sidebar/40">
<ConversationPicker
{client}
{index}
onOpen={open}
{selected}
{showFollow}
bind:follow
/>
<aside class="flex w-80 shrink-0 flex-col border-r">
<div class="flex items-center gap-2 px-3 pt-3 pb-1">
<PanelIsland {index} onOpen={open} />
</div>
<Rail {client} {index} onOpen={open} {onOpenFile} {selected} />
</aside>
{/if}
<section class="flex min-w-0 flex-1 flex-col">
@@ -76,6 +75,7 @@ the verdict, and DESIGN.md
{client}
{index}
onOpen={open}
{onOpenFile}
{selected}
{showFollow}
bind:follow
@@ -86,19 +86,18 @@ the verdict, and DESIGN.md
<ConversationView
{client}
id={selected}
initialView={companion ? "activity" : "chat"}
onOpen={open}
showTitle={wide}
/>
{/key}
{:else}
<div class="flex flex-1 items-start justify-center p-4 @md:p-6">
<EmptyState
class="w-full max-w-md"
hint={showFollow
? "Open a note with conversation_id in its frontmatter, or pick one from the list."
: "Pick one from the list: the master, a branch, a deep chat."}
title="No conversation on screen"
/>
<p class="max-w-md text-muted-foreground text-sm">
{showFollow
? "Open a note with a conversation in its frontmatter, or pick a strip."
: "Pick a strip: the master, a branch, a deep chat."}
</p>
</div>
{/if}
</section>
+143
View File
@@ -68,3 +68,146 @@
column-gap: 0.75rem;
align-items: center;
}
/* Strips: the one row vocabulary of the console. A strip is a physical
object in a bay: fixed columns, a state edge, lifts on hover, cocked
(pushed out) while it waits for the operator. */
@layer components {
.strip {
position: relative;
display: grid;
grid-template-columns: 1.5rem minmax(0, 1fr) auto;
column-gap: 0.75rem;
align-items: center;
min-height: 2.75rem;
padding: 0.375rem 0.875rem 0.375rem 0.625rem;
color: var(--foreground);
text-decoration: none;
background: var(--strip);
border-bottom: 1px solid var(--border);
transition:
transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1),
box-shadow 200ms cubic-bezier(0.2, 0.8, 0.2, 1),
background-color 150ms ease-out;
}
.strip::before {
position: absolute;
top: 0.375rem;
bottom: 0.375rem;
left: 0;
width: 3px;
content: "";
background: transparent;
border-radius: 0 2px 2px 0;
transition: background-color 200ms ease-out;
}
.strip:first-child {
border-top-left-radius: var(--radius-md);
border-top-right-radius: var(--radius-md);
}
.strip:last-child {
border-bottom: 0;
border-bottom-right-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.strip:hover,
.strip:focus-visible {
z-index: 1;
box-shadow: var(--shadow-lift);
transform: translateY(-1px);
}
.strip[data-state="running"]::before {
background: var(--signal);
animation: pulse-edge 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.strip[data-state="waiting"] {
z-index: 1;
box-shadow: var(--shadow-lift);
transform: translateX(0.5rem);
}
.strip[data-state="waiting"]::before {
background: var(--attention);
}
.strip[data-state="waiting"]:hover {
transform: translateX(0.5rem) translateY(-1px);
}
.strip[aria-current="true"] {
background: color-mix(in oklab, var(--strip) 85%, var(--primary) 15%);
}
.strip[data-state="closed"] {
color: var(--muted-foreground);
}
.strip-open {
display: block;
padding: 0.25rem 0.875rem 0.875rem 3rem;
background: var(--strip);
border-bottom: 1px solid var(--border);
}
.strip-open:last-child {
border-bottom: 0;
border-bottom-right-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.bay {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.bay-rack {
background: color-mix(in oklab, var(--rack) 70%, var(--strip) 30%);
border: 1px solid var(--border);
border-radius: calc(var(--radius-md) + 1px);
box-shadow: inset 0 1px 0
color-mix(in oklab, var(--foreground) 4%, transparent);
}
.island {
display: inline-flex;
gap: 0.625rem;
align-items: center;
height: 2.125rem;
padding: 0 0.875rem 0 0.75rem;
font-size: var(--text-sm);
color: var(--foreground);
white-space: nowrap;
background: var(--strip);
border: 1px solid var(--border);
border-radius: 999px;
box-shadow: 0 1px 2px oklch(0.2 0.06 340 / 0.06);
transition:
transform 220ms cubic-bezier(0.2, 0.9, 0.25, 1.1),
box-shadow 220ms ease-out,
background-color 150ms ease-out;
}
.island:hover,
.island[aria-expanded="true"] {
box-shadow: var(--shadow-lift);
transform: scale(1.02);
}
.island-sep {
width: 1px;
height: 1rem;
background: var(--border);
}
.doc {
max-width: 78ch;
}
}
@keyframes pulse-edge {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
@layer components {
.strip-child {
padding-left: 1.75rem;
}
.strip-child::before {
left: 1.125rem;
}
}
+1 -1
View File
@@ -28,7 +28,7 @@
<article
class={cn(
"flex animate-rise flex-col gap-2 border-b py-3",
"flex flex-col gap-2 border-b py-3",
turn.status === "running" && "bg-signal/[0.03]"
)}
>
+109
View File
@@ -0,0 +1,109 @@
import type { ConversationSummary } from "$lib/api/types";
import { parseDate } from "$lib/format";
import { byActivity } from "$lib/shell/state";
export interface DayGroup {
branches: ConversationSummary[];
day: string;
deep: ConversationSummary[];
label: string;
live: boolean;
master: ConversationSummary | null;
others: ConversationSummary[];
}
const DAY_MS = 86_400_000;
function localDay(iso: string | null | undefined): string {
const date = parseDate(iso) ?? new Date();
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
export function dayLabel(day: string, now = new Date()): string {
const today = localDay(now.toISOString());
const yesterday = localDay(new Date(now.getTime() - DAY_MS).toISOString());
if (day === today) {
return "Today";
}
if (day === yesterday) {
return "Yesterday";
}
const date = new Date(`${day}T12:00:00`);
return date.toLocaleDateString(undefined, {
day: "numeric",
month: "short",
weekday: "short",
});
}
function activityOf(row: ConversationSummary): string {
return row.last_activity_at ?? row.created_at ?? "";
}
// Days as the operator remembers them: a master per day, its branches
// under it, deep chats by the day they last moved, forks with their
// parent's day. Jobs never enter the rail.
export function groupByDay(
rows: ConversationSummary[],
now = new Date()
): DayGroup[] {
const groups = new Map<string, DayGroup>();
const dayOfMaster = new Map<string, string>();
const group = (day: string): DayGroup => {
let found = groups.get(day);
if (!found) {
found = {
branches: [],
day,
deep: [],
label: dayLabel(day, now),
live: false,
master: null,
others: [],
};
groups.set(day, found);
}
return found;
};
const masters = rows
.filter((row) => row.kind === "master")
.sort((a, b) => (b.created_at ?? "").localeCompare(a.created_at ?? ""));
for (const master of masters) {
const day = localDay(master.created_at);
dayOfMaster.set(master.id, day);
const g = group(day);
if (!g.master || master.status === "open") {
g.master = master;
} else {
g.others.push(master);
}
}
for (const row of rows) {
if (row.kind === "master" || row.kind === "job") {
continue;
}
const parentDay = row.parent ? dayOfMaster.get(row.parent) : undefined;
const day = parentDay ?? localDay(activityOf(row));
const g = group(day);
if (row.kind === "branch") {
g.branches.push(row);
} else if (row.kind === "deep") {
g.deep.push(row);
} else {
g.others.push(row);
}
}
const out = [...groups.values()];
for (const g of out) {
g.branches.sort(byActivity);
g.deep.sort(byActivity);
g.others.sort(byActivity);
g.live = [g.master, ...g.branches, ...g.deep, ...g.others].some(
(row) => row && (row.running_turn || row.pending_question)
);
}
return out.sort((a, b) => b.day.localeCompare(a.day));
}
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client";
import type { AgentInfo } from "$lib/api/types";
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";
let {
client,
open = $bindable(false),
onCreated,
}: {
client: ApiClient;
open?: boolean;
onCreated: (id: string) => void;
} = $props();
const KINDS = ["deep", "branch", "master", "job"];
let agents = $state<AgentInfo[]>([]);
let form = $state({ agent: "", kind: "deep", text: "", title: "" });
let busy = $state(false);
$effect(() => {
if (open && agents.length === 0) {
client
.agents()
.then((result) => {
({ agents } = result);
})
.catch(() => undefined);
}
});
const agentsForKind = $derived(
agents.filter((a) =>
a.kinds.includes(form.kind as AgentInfo["kinds"][number])
)
);
async function create() {
busy = true;
try {
const created = await client.spawn({
agent: form.agent || undefined,
kind: form.kind,
seed: "clean",
text: form.text || undefined,
title: form.title || undefined,
});
open = false;
form = { agent: "", kind: "deep", text: "", title: "" };
onCreated(created.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
</script>
<Dialog.Root bind:open>
<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 KINDS 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={() => {
// biome-ignore lint/suspicious/noGlobalAssign: bindable prop, not window.open
open = false;
}}
variant="ghost"
>
Cancel
</Button>
<Button disabled={busy} onclick={create}>Create</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+320
View File
@@ -0,0 +1,320 @@
<script lang="ts">
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import PlusIcon from "@lucide/svelte/icons/plus";
import SearchIcon from "@lucide/svelte/icons/search";
import { onMount } from "svelte";
import type { ApiClient } from "$lib/api/client";
import type {
ConversationSummary,
SearchFile,
SearchResponse,
} from "$lib/api/types";
import ErrorNote from "$lib/components/error-note.svelte";
import { clip } from "$lib/format";
import type { ConversationIndex } from "$lib/panel/index.svelte";
import Strip from "$lib/shell/strip.svelte";
import { cn } from "$lib/utils";
import { type DayGroup, groupByDay } from "./days";
import NewConversation from "./new-conversation.svelte";
let {
client,
index,
selected = null,
href,
onOpen,
onOpenFile,
class: className = "",
}: {
client: ApiClient;
index: ConversationIndex;
selected?: string | null;
href?: (id: string) => string;
onOpen?: (id: string) => void;
onOpenFile?: (path: string) => void;
class?: string;
} = $props();
const SEARCH_MIN = 2;
const SEARCH_DEBOUNCE_MS = 250;
const DAYS_SHOWN = 14;
const TICK_MS = 30_000;
let query = $state("");
let expanded = $state<Set<string>>(new Set());
let remote = $state<SearchResponse | null>(null);
let searching = $state(false);
let createOpen = $state(false);
let now = $state(Date.now());
let timer: ReturnType<typeof setTimeout> | undefined;
onMount(() => {
const tick = setInterval(() => {
now = Date.now();
}, TICK_MS);
return () => clearInterval(tick);
});
const needle = $derived(query.trim().toLowerCase());
const groups = $derived(groupByDay(index.conversations, new Date(now)));
const local = $derived.by((): ConversationSummary[] => {
if (!needle) {
return [];
}
return index.conversations.filter(
(row) =>
row.kind !== "job" &&
((row.title ?? "").toLowerCase().includes(needle) ||
row.id.startsWith(needle) ||
(row.last_item?.text ?? "").toLowerCase().includes(needle))
);
});
const found = $derived.by((): ConversationSummary[] => {
const seen = new Set(local.map((row) => row.id));
const extra = (remote?.query === needle ? remote.conversations : []).filter(
(row) => !seen.has(row.id)
);
return [...local, ...extra];
});
const files = $derived<SearchFile[]>(
remote?.query === needle ? remote.files : []
);
$effect(() => {
const q = needle;
clearTimeout(timer);
if (q.length < SEARCH_MIN) {
remote = null;
searching = false;
return;
}
searching = true;
timer = setTimeout(async () => {
try {
const result = await client.search(q);
if (result.query === query.trim().toLowerCase()) {
remote = result;
}
} catch {
remote = null;
} finally {
searching = false;
}
}, SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
});
function isOpen(group: DayGroup, position: number): boolean {
return position === 0 || group.live || expanded.has(group.day);
}
function toggle(day: string) {
const next = new Set(expanded);
if (next.has(day)) {
next.delete(day);
} else {
next.add(day);
}
expanded = next;
}
function summary(group: DayGroup): string {
const parts: string[] = [];
if (group.branches.length) {
parts.push(
`${group.branches.length} ${group.branches.length === 1 ? "branch" : "branches"}`
);
}
if (group.deep.length) {
parts.push(`${group.deep.length} deep`);
}
if (group.others.length) {
parts.push(`${group.others.length} more`);
}
return parts.join(" · ");
}
const linkOf = (id: string) => href?.(id);
</script>
<div class={cn("flex h-full min-h-0 flex-col", className)}>
<div class="flex items-center gap-2 px-3 py-2">
<label
class="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md bg-strip px-2 ring-1 ring-border focus-within:ring-ring"
>
<SearchIcon class="size-3.5 shrink-0 text-icon" />
<input
aria-label="Search conversations and memory"
class="h-full w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
placeholder="Search"
spellcheck="false"
type="search"
bind:value={query}
>
{#if searching}
<span
class="size-1.5 shrink-0 animate-pulse-dot rounded-full bg-signal"
></span>
{/if}
</label>
<button
aria-label="New conversation"
class="rule-word inline-flex size-8 items-center justify-center rounded-md hover:bg-accent"
onclick={() => {
createOpen = true;
}}
type="button"
>
<PlusIcon class="size-4" />
</button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-3 pb-6">
{#if index.live.state === "failed"}
<ErrorNote
message={index.live.detail ?? "event stream failed"}
retry={() => index.start()}
/>
{:else if !index.loaded}
<p class="px-1 py-3 text-muted-foreground text-sm">Loading…</p>
{:else if needle}
<section class="flex flex-col gap-2 pt-1">
<h2 class="label-quiet px-1">
{found.length === 0 && !searching ? "Nothing matches" : "Conversations"}
</h2>
{#if found.length > 0}
<div class="bay-rack">
{#each found as row (row.id)}
<Strip
current={row.id === selected}
detail={row.last_item?.text ?? ""}
href={linkOf(row.id)}
{now}
onclick={onOpen}
{row}
/>
{/each}
</div>
{/if}
{#if files.length > 0}
<h2 class="label-quiet px-1 pt-2">Memory</h2>
<div class="bay-rack">
{#each files as file (file.path)}
<button
class="strip w-full text-left text-sm"
onclick={() => onOpenFile?.(file.path)}
type="button"
>
<span class="size-5"></span>
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate font-medium">{file.path}</span>
{#if file.snippet}
<span class="truncate text-muted-foreground text-xs">
{clip(file.snippet, 96)}
</span>
{/if}
</span>
<span class="tabular text-muted-foreground text-xs">
{file.line ? `:${file.line}` : ""}
</span>
</button>
{/each}
</div>
{/if}
</section>
{:else}
{#each groups.slice(0, DAYS_SHOWN) as group, position (group.day)}
{@const shown = isOpen(group, position)}
<section class="flex flex-col gap-1.5 pt-2">
<button
aria-expanded={shown}
class="flex items-center gap-1.5 px-1 text-left"
onclick={() => toggle(group.day)}
type="button"
>
<ChevronRightIcon
class={cn(
"size-3.5 text-icon transition-transform duration-150",
shown && "rotate-90"
)}
/>
<span
class={cn(
"font-medium text-sm",
position === 0 ? "text-foreground" : "text-muted-foreground"
)}
>
{group.label}
</span>
{#if group.live}
<span
class="size-1.5 rounded-full bg-signal animate-pulse-dot"
></span>
{/if}
<span class="truncate text-muted-foreground text-xs">
{summary(group)}
</span>
</button>
{#if shown}
<div class="bay-rack">
{#if group.master}
<Strip
current={group.master.id === selected}
detail={group.master.last_item?.text ?? ""}
href={linkOf(group.master.id)}
{now}
onclick={onOpen}
row={group.master}
/>
{/if}
{#each group.branches as row (row.id)}
<Strip
class="strip-child"
current={row.id === selected}
detail={row.last_item?.text ?? ""}
href={linkOf(row.id)}
{now}
onclick={onOpen}
{row}
/>
{/each}
{#each group.deep as row (row.id)}
<Strip
current={row.id === selected}
detail={row.last_item?.text ?? ""}
href={linkOf(row.id)}
{now}
onclick={onOpen}
{row}
/>
{/each}
{#each group.others as row (row.id)}
<Strip
class="strip-child"
current={row.id === selected}
href={linkOf(row.id)}
{now}
onclick={onOpen}
{row}
/>
{/each}
</div>
{/if}
</section>
{/each}
{#if groups.length === 0}
<p class="px-1 py-3 text-muted-foreground text-sm">
No conversations yet. Start one, or wait for the morning master.
</p>
{/if}
{/if}
</div>
</div>
<NewConversation
{client}
onCreated={(id) => onOpen?.(id)}
bind:open={createOpen}
/>
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { cn } from "$lib/utils";
let {
label,
count = null,
tone = "default",
hint = "",
class: className = "",
children,
aside,
}: {
label: string;
count?: number | null;
tone?: "default" | "attention" | "signal";
hint?: string;
class?: string;
children?: Snippet;
aside?: Snippet;
} = $props();
const TONE: Record<string, string> = {
attention: "text-attention-foreground",
default: "text-muted-foreground",
signal: "text-signal",
};
</script>
<section class={cn("bay", className)}>
<header class="flex items-baseline gap-2 px-1">
<h2 class={cn("font-medium text-sm", TONE[tone])}>{label}</h2>
{#if count !== null}
<span class={cn("tabular text-xs", TONE[tone])}>{count}</span>
{/if}
{#if hint}
<span class="truncate text-muted-foreground text-xs">{hint}</span>
{/if}
{#if aside}
<span class="ml-auto">{@render aside()}</span>
{/if}
</header>
<div class="bay-rack">
{@render children?.()}
</div>
</section>
+376
View File
@@ -0,0 +1,376 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import type { ConversationSummary, VaultGraph } from "$lib/api/types";
import { clip, fmtTime, shortId } from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { summarizeInput, toolLabel } from "$lib/panel/activity.svelte";
import QuestionCard from "$lib/panel/question-card.svelte";
import { session } from "$lib/session.svelte";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils";
import Bay from "./bay.svelte";
import Graph, { type GraphEdge, type GraphNode } from "./graph.svelte";
import Instruments from "./instruments.svelte";
import { now as board } from "./now.svelte";
import { stateOf } from "./state";
import Strip from "./strip.svelte";
let {
compact = false,
}: {
compact?: boolean;
} = $props();
const TICK_MS = 1000;
const QUIET_SHOWN = 6;
const TAPE_SHOWN = 24;
const HOURS_DAY = 24;
let now = $state(Date.now());
let spend = $state<number | null>(null);
let vault = $state<VaultGraph | null>(null);
let vaultFor = $state<string | null>(null);
let quietOpen = $state(false);
let tapeOpen = $state(false);
onMount(() => {
const tick = setInterval(() => {
now = Date.now();
}, TICK_MS);
const { client } = session;
if (client) {
client
.usage({ group_by: "agent", hours: HOURS_DAY })
.then((usage) => {
spend = usage.total.cost_usd;
})
.catch(() => undefined);
}
return () => clearInterval(tick);
});
$effect(() => {
const key = board.liveIds.join("|");
if (key.length >= 0) {
untrack(() => board.refreshSnapshots().catch(() => undefined));
}
});
const href = (id: string) => `${base}/conversations/${id}`;
const GRAPH_FILES = 24;
async function loadVault(masterId: string) {
const { client } = session;
if (!client) {
return;
}
try {
const context = await client.context(masterId);
const paths = context.files.slice(0, GRAPH_FILES).map((f) => f.path);
vault = paths.length > 0 ? await client.vaultGraph(paths, 60) : null;
} catch {
vault = null;
}
vaultFor = masterId;
}
$effect(() => {
const { master } = board;
if (!master || master.running_turn) {
return;
}
const key = `${master.id}:${master.last_activity_at ?? ""}`;
if (vaultFor !== key) {
vaultFor = key;
untrack(() => loadVault(master.id));
}
});
function open(id: string) {
ui.closeAll();
goto(href(id));
}
function lastTool(row: ConversationSummary): string {
const info = board.snapshots[row.id];
const tools = info?.turn?.tools ?? [];
const live = tools.filter((t) => !t.ended_at);
const tool = live.at(-1) ?? tools.at(-1);
if (!tool) {
return info?.turn?.text ? clip(info.turn.text, 80) : "thinking";
}
const what = summarizeInput(tool.name, tool.input);
const short = what.startsWith("/")
? what.split("/").slice(-2).join("/")
: what;
return `${toolLabel(tool.name)} · ${clip(short, 72)}`;
}
const quiet = $derived(
quietOpen ? board.quiet : board.quiet.slice(0, QUIET_SHOWN)
);
const windows = $derived(gateway.limits?.windows ?? []);
const tape = $derived(gateway.tape.slice(0, TAPE_SHOWN));
const MD_SUFFIX = /\.md$/;
function conversationNodes(master: ConversationSummary | null): {
nodes: GraphNode[];
edges: GraphEdge[];
} {
const nodes: GraphNode[] = [];
const edges: GraphEdge[] = [];
const rows = gateway.conversations.filter(
(row) => row.status === "open" || row.running_turn
);
if (master) {
nodes.push({
href: href(master.id),
id: master.id,
kind: "master",
label: master.title ?? "master",
ring: 0,
state: stateOf(master),
});
}
for (const row of rows) {
if (row.id === master?.id || row.kind === "master") {
continue;
}
const child = row.kind === "branch" || row.kind === "fork";
nodes.push({
href: href(row.id),
id: row.id,
kind: row.kind,
label: row.title ?? `${row.kind} ${shortId(row.id)}`,
ring: child ? 1 : 2,
state: stateOf(row),
});
if (master && child) {
edges.push({ from: master.id, to: row.id });
}
}
return { edges, nodes };
}
function noteState(touched: boolean, exists: boolean): GraphNode["state"] {
if (!touched) {
return "ghost";
}
return exists ? "quiet" : "closed";
}
function vaultNodes(
master: ConversationSummary,
graphData: VaultGraph
): { nodes: GraphNode[]; edges: GraphEdge[] } {
const nodes: GraphNode[] = graphData.nodes.map((note) => ({
href: note.exists ? note.path : undefined,
id: note.path,
kind: "file",
label: note.title,
ring: note.touched ? 1 : 2,
state: noteState(note.touched, note.exists),
}));
const edges: GraphEdge[] = graphData.nodes
.filter((note) => note.touched)
.map((note) => ({ from: master.id, to: note.path }));
for (const edge of graphData.edges) {
edges.push({ from: edge.from, to: edge.to });
}
return { edges, nodes };
}
const graph = $derived.by((): { nodes: GraphNode[]; edges: GraphEdge[] } => {
const { master } = board;
const own = conversationNodes(master);
if (!(vault && master)) {
return own;
}
const extra = vaultNodes(master, vault);
return {
edges: [...own.edges, ...extra.edges],
nodes: [...own.nodes, ...extra.nodes],
};
});
function openNode(id: string) {
if (id.endsWith(".md")) {
window.open(
`obsidian://open?file=${encodeURIComponent(id.replace(MD_SUFFIX, ""))}`
);
return;
}
open(id);
}
</script>
<div
class={cn(
"@container grid gap-8",
compact
? "grid-cols-1"
: "grid-cols-1 lg:grid-cols-[minmax(0,1fr)_20rem] xl:grid-cols-[minmax(0,1fr)_24rem]"
)}
>
<div class="flex min-w-0 flex-col gap-8">
<Instruments
{compact}
contextTokens={board.contextTokens}
contextWindow={board.contextWindow}
{now}
{spend}
{windows}
/>
{#if board.waiting.length > 0}
<Bay
count={board.waiting.length}
hint="the agent asked and stopped"
label="Waiting for you"
tone="attention"
>
{#each board.waiting as row (row.id)}
{@const info = board.snapshots[row.id]}
<Strip
detail={info?.question?.questions[0]?.question ?? "question pending"}
href={href(row.id)}
{now}
open={Boolean(info?.question)}
{row}
state="waiting"
>
{#if info?.question && session.client}
<QuestionCard
client={session.client}
conversationId={row.id}
question={info.question}
/>
{/if}
</Strip>
{/each}
</Bay>
{/if}
<Bay
count={board.running.length}
hint={board.running.length === 0 ? "nothing in a turn" : ""}
label="In motion"
tone="signal"
>
{#if board.running.length === 0}
<p class="px-4 py-3 text-muted-foreground text-sm">
Idle. The next message or inject lights a strip here.
</p>
{:else}
{#each board.running as row (row.id)}
<Strip
detail={lastTool(row)}
href={href(row.id)}
{now}
{row}
state="running"
/>
{/each}
{/if}
</Bay>
<Bay
count={board.quiet.length}
hint="open, waiting for the next word"
label="Quiet"
>
{#snippet aside()}
{#if board.quiet.length > QUIET_SHOWN}
<button
class="rule-word text-xs"
onclick={() => {
quietOpen = !quietOpen;
}}
type="button"
>
{quietOpen ? "fewer" : `all ${board.quiet.length}`}
</button>
{/if}
{/snippet}
{#if !gateway.loaded}
<p class="px-4 py-3 text-muted-foreground text-sm">Loading…</p>
{:else if quiet.length === 0}
<p class="px-4 py-3 text-muted-foreground text-sm">
No open conversations.
</p>
{:else}
{#each quiet as row (row.id)}
<Strip
detail={row.last_item?.text ?? ""}
href={href(row.id)}
{now}
{row}
state="quiet"
/>
{/each}
{/if}
</Bay>
{#if !compact}
<section class="flex flex-col gap-2">
<button
aria-expanded={tapeOpen}
class="rule-word flex items-center gap-2 self-start px-1 text-sm"
onclick={() => {
tapeOpen = !tapeOpen;
}}
type="button"
>
Recent events
<span class="tabular text-xs">{gateway.tape.length}</span>
</button>
{#if tapeOpen}
<ul class="flex flex-col px-1 text-xs">
{#each tape as item (item.seq)}
<li
class="ledger-grid grid-cols-[5rem_8rem_minmax(0,1fr)] py-0.5"
>
<span class="tabular text-muted-foreground">
{fmtTime(item.ts)}
</span>
<span class="truncate">{item.type}</span>
<span class="truncate text-muted-foreground">
{#if item.conversation_id}
<a
class="hover:underline"
href={href(item.conversation_id)}
>
{shortId(item.conversation_id)}
</a>
{/if}
{#if typeof item.name === "string"}
{item.name}
{/if}
{#if typeof item.text === "string"}
{item.text.slice(0, 80)}
{/if}
</span>
</li>
{/each}
</ul>
{/if}
</section>
{/if}
</div>
{#if !compact}
<aside class="hidden flex-col gap-2 lg:flex">
<h2 class="label-quiet px-1">Around the master</h2>
<div class="rounded-lg border bg-strip/60 p-2">
<Graph edges={graph.edges} nodes={graph.nodes} onOpen={openNode} />
</div>
<p class="px-1 text-muted-foreground text-xs">
Inner ring: branches and the notes the master touched today. Hollow dots
are one link away, not reached. Click a note to open it in Obsidian.
</p>
</aside>
{/if}
</div>
+235
View File
@@ -0,0 +1,235 @@
<script lang="ts">
import { onMount } from "svelte";
import { clip } from "$lib/format";
import { cn } from "$lib/utils";
export interface GraphNode {
href?: string;
id: string;
kind: string;
label: string;
// 0 = hub, 1 = first ring, 2 = second ring, 3 = ghost
ring: number;
state: "running" | "waiting" | "quiet" | "closed" | "ghost";
}
export interface GraphEdge {
from: string;
to: string;
}
let {
nodes,
edges,
onOpen,
class: className = "",
}: {
nodes: GraphNode[];
edges: GraphEdge[];
onOpen?: (id: string) => void;
class?: string;
} = $props();
const SIZE = 320;
const CENTER = SIZE / 2;
const RING = [0, 74, 118, 148];
const DRIFT = 3.5;
const LABEL_MAX = 18;
const TAU = Math.PI * 2;
const HASH_MOD = 1_000_003;
const HASH_BASE = 31;
let time = $state(0);
let reduced = false;
onMount(() => {
reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced) {
return;
}
let frame = 0;
const start = performance.now();
const tick = (ts: number) => {
time = (ts - start) / 1000;
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
});
function hash(id: string): number {
let h = 0;
for (const ch of id) {
h = (h * HASH_BASE + ch.charCodeAt(0)) % HASH_MOD;
}
return h;
}
interface Placed extends GraphNode {
x: number;
y: number;
}
function place(
node: GraphNode,
slot: number,
total: number,
t: number
): Placed {
const ring = Math.min(node.ring, RING.length - 1);
const dist = RING[ring];
const seed = hash(node.id);
const angle = (slot / total) * TAU + (seed % 100) / 100 + node.ring * 0.7;
const wobble = reduced ? 0 : Math.sin(t * 0.35 + (seed % 7)) * DRIFT;
const wobble2 = reduced ? 0 : Math.cos(t * 0.27 + (seed % 5)) * DRIFT;
if (node.ring === 0) {
return { ...node, x: CENTER + wobble * 0.3, y: CENTER + wobble2 * 0.3 };
}
return {
...node,
x: CENTER + Math.cos(angle) * dist + wobble,
y: CENTER + Math.sin(angle) * dist + wobble2,
};
}
const placed = $derived.by((): Placed[] => {
const byRing = new Map<number, GraphNode[]>();
for (const node of nodes) {
const list = byRing.get(node.ring) ?? [];
list.push(node);
byRing.set(node.ring, list);
}
const out: Placed[] = [];
for (const list of byRing.values()) {
let slot = 0;
for (const node of list) {
out.push(place(node, slot, list.length, time));
slot += 1;
}
}
return out;
});
const byId = $derived(new Map(placed.map((node) => [node.id, node])));
const RADIUS: Record<string, number> = {
closed: 3.5,
ghost: 2.5,
quiet: 4.5,
running: 6.5,
waiting: 6,
};
const FILL: Record<string, string> = {
branch: "var(--color-kind-branch)",
deep: "var(--color-kind-deep)",
file: "var(--foreground)",
fork: "var(--color-kind-fork)",
job: "var(--color-kind-job)",
master: "var(--color-kind-master)",
};
function radius(node: Placed): number {
const base = RADIUS[node.state] ?? 4;
return node.ring === 0 ? base + 4 : base;
}
function strokeOf(node: Placed): string {
if (node.state === "ghost") {
return "var(--muted-foreground)";
}
return node.state === "waiting" ? "var(--attention)" : "none";
}
function fillOf(node: Placed): string {
return node.state === "ghost"
? "none"
: (FILL[node.kind] ?? "var(--foreground)");
}
function textOf(node: Placed): string {
return node.state === "ghost" || node.state === "closed"
? "var(--muted-foreground)"
: "var(--foreground)";
}
function ghostEdge(edge: GraphEdge): boolean {
return (
byId.get(edge.from)?.state === "ghost" ||
byId.get(edge.to)?.state === "ghost"
);
}
</script>
{#snippet dot(node: Placed)}
{@const r = radius(node)}
{#if node.state === "running"}
<circle cx={node.x} cy={node.y} fill="url(#graph-glow)" r={r * 4} />
{/if}
<circle
cx={node.x}
cy={node.y}
fill={fillOf(node)}
fill-opacity={node.state === "closed" ? 0.35 : 1}
{r}
stroke={strokeOf(node)}
stroke-opacity={node.state === "ghost" ? 0.6 : 1}
stroke-width={node.state === "waiting" ? 2.5 : 1}
/>
<text
dominant-baseline="hanging"
fill={textOf(node)}
font-size="10"
font-weight={node.ring === 0 ? 600 : 400}
text-anchor="middle"
x={node.x}
y={node.y + r + 4}
>
{clip(node.label, LABEL_MAX)}
</text>
{/snippet}
<svg
aria-label="what the agent is touching"
class={cn("h-auto w-full select-none", className)}
role="img"
viewBox="0 0 {SIZE} {SIZE}"
>
<defs>
<radialGradient id="graph-glow">
<stop offset="0%" stop-color="var(--signal)" stop-opacity="0.35" />
<stop offset="100%" stop-color="var(--signal)" stop-opacity="0" />
</radialGradient>
</defs>
{#each edges as edge (edge.from + edge.to)}
{@const a = byId.get(edge.from)}
{@const b = byId.get(edge.to)}
{#if a && b}
<line
stroke="var(--foreground)"
stroke-dasharray={ghostEdge(edge) ? "2 4" : undefined}
stroke-opacity={ghostEdge(edge) ? 0.18 : 0.14}
stroke-width="1"
x1={a.x}
x2={b.x}
y1={a.y}
y2={b.y}
/>
{/if}
{/each}
{#each placed as node (node.id)}
{#if node.href}
<a
class="cursor-pointer"
href={node.href}
onclick={(event) => {
event.preventDefault();
onOpen?.(node.id);
}}
>
{@render dot(node)}
</a>
{:else}
<g>{@render dot(node)}</g>
{/if}
{/each}
</svg>
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
import type { LimitWindow } from "$lib/api/types";
import { fmtCountdown, fmtMoney, fmtRelative, fmtTokens } from "$lib/format";
import { limitLabel } from "$lib/limits";
import { cn } from "$lib/utils";
let {
contextTokens,
contextWindow,
windows,
spend = null,
now,
compact = false,
}: {
contextTokens: number;
contextWindow: number;
windows: LimitWindow[];
spend?: number | null;
now: number;
compact?: boolean;
} = $props();
const TONE: Record<string, string> = {
allowed: "bg-primary",
allowed_warning: "bg-primary",
rejected: "bg-destructive",
};
const contextPct = $derived(
Math.min(100, Math.round((contextTokens / contextWindow) * 100))
);
const known = (w: LimitWindow) =>
w.utilization !== null && w.utilization !== undefined;
const pct = (w: LimitWindow) =>
Math.min(100, Math.round((w.utilization ?? 0) * 100));
</script>
<div
class={cn(
"grid gap-x-6 gap-y-4",
compact
? "@lg:grid-cols-5 grid-cols-2"
: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
)}
>
<div class="flex flex-col gap-1.5">
<span class="label-quiet">Context</span>
<span class="tabular font-semibold text-2xl leading-none tracking-tight">
{fmtTokens(contextTokens)}
<span class="font-normal text-muted-foreground text-sm">
/ {fmtTokens(contextWindow)}
</span>
</span>
<span
aria-label="context share"
aria-valuemax="100"
aria-valuemin="0"
aria-valuenow={contextPct}
class="h-0.5 w-full overflow-hidden rounded-full bg-muted"
role="progressbar"
>
<span
class="block h-full rounded-full bg-primary transition-[width] duration-500"
style="width: {contextPct}%"
></span>
</span>
</div>
{#each windows as w (w.window)}
<div class="flex flex-col gap-1.5">
<span class="label-quiet">{limitLabel(w.window)}</span>
{#if known(w)}
<span
class={cn(
"tabular font-semibold text-2xl leading-none tracking-tight",
w.status === "rejected" && "text-destructive"
)}
>
{pct(w)}
<span class="font-normal text-muted-foreground text-sm">%</span>
<span class="font-normal text-muted-foreground text-xs">
{fmtCountdown(w.resets_at, now).replace("resets ", "")}
</span>
</span>
<span
aria-label="{limitLabel(w.window)} utilization"
aria-valuemax="100"
aria-valuemin="0"
aria-valuenow={pct(w)}
class="h-0.5 w-full overflow-hidden rounded-full bg-muted"
role="progressbar"
>
<span
class={cn(
"block h-full rounded-full transition-[width] duration-500",
TONE[w.status]
)}
style="width: {pct(w)}%"
></span>
</span>
{:else}
<span class="text-muted-foreground text-sm leading-none">
no report
</span>
<span class="text-muted-foreground text-xs">
last seen {fmtRelative(w.ts, now)}
</span>
{/if}
</div>
{/each}
{#if spend !== null}
<div class="flex flex-col gap-1.5">
<span class="label-quiet">Spend · 24 h</span>
<span class="tabular font-semibold text-2xl leading-none tracking-tight">
{fmtMoney(spend)}
</span>
<span class="text-muted-foreground text-xs">API-price equivalent</span>
</div>
{/if}
</div>
+86
View File
@@ -0,0 +1,86 @@
<script lang="ts">
import { fmtTokens } from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils";
import { now } from "./now.svelte";
let { class: className = "" }: { class?: string } = $props();
const running = $derived(now.running.length);
const waiting = $derived(now.waiting.length);
const worst = $derived.by(() => {
const windows = gateway.limits?.windows ?? [];
let top: number | null = null;
for (const w of windows) {
if (w.utilization !== null && w.utilization !== undefined) {
top = Math.max(top ?? 0, w.utilization);
}
}
return top;
});
const live = $derived(gateway.live.state);
const dot = $derived.by(() => {
if (live === "failed") {
return "bg-destructive";
}
if (live !== "open") {
return "bg-warn";
}
return running > 0 ? "bg-signal animate-pulse-dot" : "bg-ok";
});
const headline = $derived.by(() => {
if (live === "failed") {
return "offline";
}
if (live !== "open") {
return "connecting";
}
if (running === 0) {
return "idle";
}
return running === 1 ? "1 in motion" : `${running} in motion`;
});
</script>
<button
aria-expanded={ui.island}
aria-haspopup="dialog"
aria-label="What is happening now"
class={cn("island", className)}
onclick={() => ui.toggleIsland()}
type="button"
>
<span class={cn("size-2 shrink-0 rounded-full", dot)}></span>
<span class="font-medium">{headline}</span>
{#if waiting > 0}
<span class="island-sep"></span>
<span class="font-medium text-attention-foreground">
{waiting === 1 ? "1 question" : `${waiting} questions`}
</span>
{/if}
{#if now.contextTokens > 0}
<span class="island-sep hidden sm:block"></span>
<span
class="tabular hidden text-muted-foreground sm:inline"
title="master context"
>
{fmtTokens(now.contextTokens)}
</span>
{/if}
{#if worst !== null}
<span class="island-sep hidden sm:block"></span>
<span
class="tabular hidden text-muted-foreground sm:inline"
title="highest quota window"
>
{Math.round(worst * 100)}%
</span>
{/if}
<kbd
class="ml-1 hidden rounded border px-1 font-sans text-[10px] text-muted-foreground md:inline"
title="open the board"
>
⌘K
</kbd>
</button>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import type { Kind } from "$lib/api/types";
import { cn } from "$lib/utils";
let { kind, class: className = "" }: { kind: Kind | string; class?: string } =
$props();
const LETTER: Record<string, string> = {
branch: "B",
deep: "D",
fork: "F",
job: "J",
master: "M",
};
const TONE: Record<string, string> = {
branch: "text-kind-branch bg-kind-branch/12",
deep: "text-kind-deep bg-kind-deep/12",
fork: "text-kind-fork bg-kind-fork/12",
job: "text-kind-job bg-kind-job/12",
master: "text-kind-master bg-kind-master/12",
};
</script>
<span
aria-label={kind}
class={cn(
"inline-flex size-5 shrink-0 items-center justify-center rounded-full font-semibold text-[10px] leading-none",
TONE[kind] ?? "bg-muted text-muted-foreground",
className
)}
role="img"
title={kind}
>
{LETTER[kind] ?? "?"}
</span>
+97
View File
@@ -0,0 +1,97 @@
import type { ConversationInfo, ConversationSummary } from "$lib/api/types";
import { gateway } from "$lib/gateway.svelte";
import { session } from "$lib/session.svelte";
import { byActivity } from "./state";
const CONTEXT_WINDOW = 1_000_000;
const SNAPSHOT_MIN_GAP_MS = 400;
// What the board shows: the open conversations sorted into bays, the
// open master's context, and a snapshot (tools in flight, the pending
// question) for every strip that is waiting or running.
class Now {
snapshots = $state<Record<string, ConversationInfo>>({});
private snapshotAt = 0;
private inFlight: Promise<void> | null = null;
get master(): ConversationSummary | null {
return (
gateway.conversations.find(
(row) => row.kind === "master" && row.status === "open"
) ?? null
);
}
get waiting(): ConversationSummary[] {
return gateway.open.filter((row) => row.pending_question).sort(byActivity);
}
get running(): ConversationSummary[] {
return gateway.open
.filter((row) => row.running_turn && !row.pending_question)
.sort(byActivity);
}
get quiet(): ConversationSummary[] {
return gateway.open
.filter(
(row) =>
!(row.running_turn || row.pending_question) && row.kind !== "job"
)
.sort(byActivity);
}
get contextTokens(): number {
return this.master?.context_tokens ?? 0;
}
get contextShare(): number {
return Math.min(1, this.contextTokens / CONTEXT_WINDOW);
}
get contextWindow(): number {
return CONTEXT_WINDOW;
}
get liveIds(): string[] {
return [...this.waiting, ...this.running].map((row) => row.id);
}
// One round of ``GET /api/conversations/{id}`` for every live strip,
// coalesced so a burst of turn events costs one fetch.
refreshSnapshots(): Promise<void> {
if (this.inFlight) {
return this.inFlight;
}
const gap = Date.now() - this.snapshotAt;
this.inFlight = (async () => {
if (gap < SNAPSHOT_MIN_GAP_MS) {
await new Promise((resolve) =>
setTimeout(resolve, SNAPSHOT_MIN_GAP_MS - gap)
);
}
const { client } = session;
const ids = this.liveIds;
if (!client || ids.length === 0) {
this.snapshots = {};
return;
}
const results = await Promise.all(
ids.map((id) => client.conversation(id).catch(() => null))
);
const next: Record<string, ConversationInfo> = {};
for (const info of results) {
if (info) {
next[info.id] = info;
}
}
this.snapshots = next;
this.snapshotAt = Date.now();
})().finally(() => {
this.inFlight = null;
});
return this.inFlight;
}
}
export const now = new Now();
+164
View File
@@ -0,0 +1,164 @@
<script lang="ts">
import SearchIcon from "@lucide/svelte/icons/search";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import type { ConversationSummary } from "$lib/api/types";
import { clip, fmtRelative, shortId } from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { SECTIONS, SYSTEM_PAGES } from "$lib/nav";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils";
import KindMark from "./kind-mark.svelte";
import { byActivity } from "./state";
interface Item {
hint: string;
href: string;
id: string;
kind: "section" | "conversation";
label: string;
row?: ConversationSummary;
}
const LIMIT = 10;
let query = $state("");
let cursor = $state(0);
let input = $state<HTMLInputElement | null>(null);
const items = $derived.by((): Item[] => {
const needle = query.trim().toLowerCase();
const sections: Item[] = [...SECTIONS, ...SYSTEM_PAGES]
.filter((s) => !needle || s.label.toLowerCase().includes(needle))
.map((s) => ({
hint: "section",
href: `${base}${s.href}`,
id: `s:${s.href}`,
kind: "section",
label: s.label,
}));
const conversations: Item[] = gateway.conversations
.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)
.slice(0, needle ? LIMIT : 5)
.map((row) => ({
hint: `${row.status === "open" ? "" : `${row.status} · `}${fmtRelative(row.last_activity_at ?? row.created_at)}`,
href: `${base}/conversations/${row.id}`,
id: row.id,
kind: "conversation",
label: row.title ?? `${row.kind} · ${shortId(row.id)}`,
row,
}));
return needle
? [...conversations, ...sections]
: [...sections.slice(0, 4), ...conversations];
});
$effect(() => {
if (ui.palette) {
query = "";
cursor = 0;
queueMicrotask(() => input?.focus());
}
});
$effect(() => {
if (cursor >= items.length) {
cursor = Math.max(0, items.length - 1);
}
});
function go(item: Item) {
ui.closeAll();
goto(item.href);
}
function onKeydown(event: KeyboardEvent) {
if (event.key === "ArrowDown") {
event.preventDefault();
cursor = Math.min(items.length - 1, cursor + 1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
cursor = Math.max(0, cursor - 1);
} else if (event.key === "Enter" && items[cursor]) {
event.preventDefault();
go(items[cursor]);
}
}
</script>
{#if ui.palette}
<div
aria-label="Go to"
aria-modal="true"
class="fixed inset-0 z-40 flex items-start justify-center px-4 pt-[12vh]"
role="dialog"
>
<button
aria-label="Close"
class="absolute inset-0 animate-fade-in bg-foreground/15 backdrop-blur-[2px]"
onclick={() => ui.closePalette()}
type="button"
></button>
<div
class="relative flex w-full max-w-lg animate-island-in flex-col overflow-hidden rounded-xl border bg-popover shadow-float"
>
<label class="flex items-center gap-2 border-b px-3">
<SearchIcon class="size-4 shrink-0 text-icon" />
<input
autocomplete="off"
class="h-11 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
onkeydown={onKeydown}
placeholder="Conversation, section…"
spellcheck="false"
type="text"
bind:this={input}
bind:value={query}
>
<kbd
class="rounded border px-1 font-sans text-[10px] text-muted-foreground"
>
esc
</kbd>
</label>
<div class="max-h-[50vh] overflow-y-auto py-1" role="listbox">
{#each items as item, index (item.id)}
<div
aria-selected={index === cursor}
class={cn(
"flex cursor-pointer items-center gap-2.5 px-3 py-2 text-sm",
index === cursor && "bg-accent"
)}
onclick={() => go(item)}
onkeydown={(event) => event.key === "Enter" && go(item)}
onmousemove={() => {
cursor = index;
}}
role="option"
tabindex="-1"
>
{#if item.row}
<KindMark kind={item.row.kind} />
{:else}
<span class="size-5 shrink-0"></span>
{/if}
<span class="min-w-0 flex-1 truncate">{clip(item.label, 80)}</span>
<span class="tabular shrink-0 text-muted-foreground text-xs">
{item.hint}
</span>
</div>
{:else}
<p class="px-3 py-3 text-muted-foreground text-sm">
Nothing matches.
</p>
{/each}
</div>
</div>
</div>
{/if}
+21
View File
@@ -0,0 +1,21 @@
import type { ConversationSummary } from "$lib/api/types";
export type StripState = "waiting" | "running" | "quiet" | "closed";
function activityOf(row: ConversationSummary): string {
return row.last_activity_at ?? row.created_at ?? "";
}
export function byActivity(a: ConversationSummary, b: ConversationSummary) {
return activityOf(b).localeCompare(activityOf(a));
}
export function stateOf(row: ConversationSummary): StripState {
if (row.status !== "open") {
return "closed";
}
if (row.pending_question) {
return "waiting";
}
return row.running_turn ? "running" : "quiet";
}
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ConversationSummary } from "$lib/api/types";
import { clip, fmtRelative, fmtTokens, shortId } from "$lib/format";
import { cn } from "$lib/utils";
import KindMark from "./kind-mark.svelte";
import { type StripState, stateOf } from "./state";
let {
row,
href,
state = stateOf(row),
current = false,
open = false,
detail = "",
now = Date.now(),
class: className = "",
children,
onclick,
}: {
row: ConversationSummary;
href?: string;
state?: StripState;
current?: boolean;
open?: boolean;
detail?: string;
now?: number;
class?: string;
children?: Snippet;
onclick?: (id: string) => void;
} = $props();
const TITLE_MAX = 72;
const DETAIL_MAX = 96;
const title = $derived(
row.title
? clip(row.title, TITLE_MAX)
: `${row.kind === "master" ? "Master" : row.kind} · ${shortId(row.id)}`
);
const when = $derived(row.last_activity_at ?? row.created_at ?? null);
const tag = $derived(href ? "a" : "button");
</script>
<svelte:element
aria-current={current ? "true" : undefined}
class={cn("strip text-left text-sm", className)}
data-row={row.id}
data-state={state}
href={href ?? undefined}
onclick={onclick ? () => onclick?.(row.id) : undefined}
role={href ? undefined : "button"}
this={tag}
type={href ? undefined : "button"}
>
<KindMark kind={row.kind} />
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate font-medium">{title}</span>
{#if detail}
<span class="truncate text-muted-foreground text-xs">
{clip(detail, DETAIL_MAX)}
</span>
{/if}
</span>
<span
class="tabular flex shrink-0 items-center gap-3 text-muted-foreground text-xs"
>
{#if row.context_tokens}
<span title="context of the last turn"
>{fmtTokens(row.context_tokens)}</span
>
{/if}
<span class="w-14 text-right">{fmtRelative(when, now)}</span>
</span>
</svelte:element>
{#if children && open}
<div class="strip-open">{@render children()}</div>
{/if}
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
import LogOutIcon from "@lucide/svelte/icons/log-out";
import MoonIcon from "@lucide/svelte/icons/moon";
import SunIcon from "@lucide/svelte/icons/sun";
import { mode, toggleMode } from "mode-watcher";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import { page } from "$app/state";
import { isSectionActive, SECTIONS, SYSTEM } from "$lib/nav";
import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils";
import Island from "./island.svelte";
async function signOut() {
await session.logout();
await goto(`${base}/login`);
}
</script>
<header
class="relative z-30 grid h-12 shrink-0 grid-cols-[1fr_auto_1fr] items-center gap-3 border-b bg-rack/85 px-3 backdrop-blur-md sm:px-5"
>
<nav aria-label="Sections" class="flex min-w-0 items-center gap-4">
<a
aria-label="Beaver"
class="flex size-6 shrink-0 items-center justify-center"
href="{base}/"
>
<span class="size-2.5 rounded-[3px] bg-primary"></span>
</a>
<div class="hidden items-center gap-4 sm:flex">
{#each SECTIONS as item (item.href)}
{@const active = isSectionActive(page.url.pathname, base, item.href)}
<a
aria-current={active ? "page" : undefined}
class={cn("rule-word", active && "text-foreground")}
href="{base}{item.href}"
title="{item.label} ({item.key})"
>
{item.label}
</a>
{/each}
</div>
</nav>
<Island />
<div class="flex min-w-0 items-center justify-end gap-3">
<a
aria-current={isSectionActive(page.url.pathname, base, SYSTEM.href)
? "page"
: undefined}
class="rule-word hidden sm:inline"
href="{base}{SYSTEM.href}"
title="{SYSTEM.label} ({SYSTEM.key})"
>
{SYSTEM.label}
</a>
<button
aria-label="Toggle theme"
class="rule-word inline-flex size-7 items-center justify-center rounded-md"
onclick={toggleMode}
title="Toggle theme"
type="button"
>
{#if mode.current === "dark"}
<SunIcon class="size-4" />
{:else}
<MoonIcon class="size-4" />
{/if}
</button>
<button
aria-label="Sign out"
class="rule-word hidden size-7 items-center justify-center rounded-md sm:inline-flex"
onclick={signOut}
title="Sign out"
type="button"
>
<LogOutIcon class="size-4" />
</button>
</div>
</header>
+35 -16
View File
@@ -1,6 +1,5 @@
import { browser } from "$app/environment";
const NAV_KEY = "beaver.ui.nav";
const RAIL_KEY = "beaver.ui.rail";
function stored(key: string, fallback: boolean): boolean {
@@ -17,28 +16,48 @@ function store(key: string, value: boolean): void {
}
}
// Chrome state: the section sidebar and the conversation rail. Explicit
// toggles persist; the automatic collapse on the conversations pages does
// not, so leaving them restores what the user had.
// Chrome state: the island's board, the command palette, the conversation
// rail. The rail toggle persists; the overlays never do.
class Ui {
nav = $state(stored(NAV_KEY, true));
rail = $state(stored(RAIL_KEY, true));
setNav(open: boolean, persist = false): void {
this.nav = open;
if (persist) {
store(NAV_KEY, open);
}
}
toggleNav(): void {
this.setNav(!this.nav, true);
}
island = $state(false);
palette = $state(false);
toggleRail(): void {
this.rail = !this.rail;
store(RAIL_KEY, this.rail);
}
openIsland(): void {
this.palette = false;
this.island = true;
}
closeIsland(): void {
this.island = false;
}
toggleIsland(): void {
if (this.island) {
this.closeIsland();
} else {
this.openIsland();
}
}
openPalette(): void {
this.island = false;
this.palette = true;
}
closePalette(): void {
this.palette = false;
}
closeAll(): void {
this.island = false;
this.palette = false;
}
}
export const ui = new Ui();