feat(ui,api): strip board redesign - island, rail by day, context view, server search, vault graph
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user