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 6b7de6f03d
commit 256b2a68d6
60 changed files with 5071 additions and 2347 deletions
+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>