feat(ui): interactive graph, strip context menus, days by activity, message times, limit status words

This commit is contained in:
hh committed 2026-09-02 04:24:15 +02:00
1 parent 025f2dbc3f
commit c6401f1114
27 files changed
+1619 -381

No files matched your search

+28 -7
View File
@@ -1,14 +1,35 @@
<script lang="ts">
import { gateway } from "$lib/gateway.svelte";
import KindMark, { KIND_MEANING } from "$lib/shell/kind-mark.svelte";
const KINDS = ["master", "branch", "deep", "fork", "job"];
const running = $derived(gateway.running.length);
function meaning(kind: string): string {
return KIND_MEANING[kind]?.split(" — ")[1] ?? "";
}
</script>
<div
class="flex flex-1 flex-col items-center justify-center gap-1 p-6 text-center text-muted-foreground text-sm"
>
<p>Pick a strip on the left.</p>
{#if running > 0}
<p class="text-signal">{running} in motion right now.</p>
{/if}
<div class="flex flex-1 flex-col items-center justify-center gap-6 p-6">
<p class="text-center text-muted-foreground text-sm">
Pick a strip on the left.
{#if running > 0}
<span class="text-signal">{running} in motion right now.</span>
{/if}
</p>
<dl
class="grid max-w-md grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-2 text-sm"
>
{#each KINDS as kind (kind)}
<dt class="flex items-center gap-2">
<KindMark {kind} />
<span class="font-medium">{kind}</span>
</dt>
<dd class="text-muted-foreground">{meaning(kind)}</dd>
{/each}
</dl>
<p class="max-w-md text-center text-muted-foreground text-xs">
Days go by the last word, not by the parent: an open branch follows the
master through the morning rotation. Right-click a strip for its actions.
</p>
</div>
-1
View File
@@ -4,7 +4,6 @@
@import "@fontsource-variable/inter";
@import "../lib/panel/panel.css";
@plugin "@tailwindcss/forms";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
+29
View File
@@ -7,6 +7,7 @@
import { fmtBytes, fmtDateTime, fmtRelative } from "$lib/format";
import Markdown from "$lib/panel/markdown.svelte";
import { session } from "$lib/session.svelte";
import FloatMenu, { type FloatItem } from "$lib/shell/float-menu.svelte";
import { cn } from "$lib/utils";
let tree = $state<MemoryTree | null>(null);
@@ -93,8 +94,35 @@
});
const isMarkdown = $derived(file?.path.endsWith(".md") ?? false);
let menuOpen = $state(false);
let menuAt = $state({ x: 0, y: 0 });
let menuItems = $state<FloatItem[]>([]);
function fileMenu(event: MouseEvent, path: string) {
event.preventDefault();
menuAt = { x: event.clientX, y: event.clientY };
menuItems = [
{ label: "Open", run: () => open(path) },
{
label: "Open in Obsidian",
run: () =>
window.open(
`obsidian://open?file=${encodeURIComponent(path.replace(MD_SUFFIX, ""))}`
),
},
{
gap: true,
label: "Copy path",
run: () => navigator.clipboard.writeText(path).catch(() => undefined),
},
];
menuOpen = true;
}
</script>
<FloatMenu items={menuItems} x={menuAt.x} y={menuAt.y} bind:open={menuOpen} />
<svelte:head><title>Memory · Beaver</title></svelte:head>
<div class="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[20rem_minmax(0,1fr)]">
@@ -220,6 +248,7 @@
selected === node.path && "bg-accent font-medium"
)}
onclick={() => open(node.path)}
oncontextmenu={(event) => fileMenu(event, node.path)}
style="padding-left: {depth * 0.875 + 1.5}rem"
type="button"
>
+1 -1
View File
@@ -17,7 +17,7 @@
>
<h1 class="font-semibold text-lg tracking-tight">System</h1>
{#each SYSTEM_PAGES as item (item.href)}
{@const active = isActive(page.url.pathname, base, item.href)}
{@const active = isActive(page.url.pathname, base, item.href, item.exact)}
<a
aria-current={active ? "page" : undefined}
class={cn("rule-word", active && "text-foreground")}
+5 -6
View File
@@ -15,19 +15,16 @@
const TICK_MS = 15_000;
const PAYLOAD_MAX = 120;
let data = $state<JobsResponse | null>(null);
let failure = $state<string | null>(null);
let busy = $state<string | null>(null);
let timer: ReturnType<typeof setInterval> | undefined;
const data = $derived<JobsResponse | null>(gateway.jobs);
async function load() {
const { client } = session;
if (!client) {
return;
}
failure = null;
try {
data = await client.jobs();
await gateway.refreshJobs();
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
@@ -258,8 +255,10 @@
<div class="bay-rack">
{#each runs as row (row.id)}
<Strip
client={session.client}
detail={row.last_item?.text ?? ""}
href="{base}/conversations/{row.id}"
onChanged={() => gateway.load().catch(() => undefined)}
{row}
/>
{/each}
+39 -22
View File
@@ -10,11 +10,12 @@
fmtMoney,
fmtPct,
fmtRelative,
fmtTime,
fmtTokens,
shortId,
} from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { limitLabel } from "$lib/limits";
import { limitLabel, limitWord } from "$lib/limits";
import { session } from "$lib/session.svelte";
import KindMark from "$lib/shell/kind-mark.svelte";
import { cn } from "$lib/utils";
@@ -31,6 +32,7 @@
{ label: "by day", value: "day" },
];
const TICK_MS = 30_000;
const SECONDS = /:\d{2}$/;
const LOW_CACHE = 0.5;
const BAR: Record<string, string> = {
allowed: "bg-primary",
@@ -104,13 +106,14 @@
<h1 class="font-semibold text-lg tracking-tight">Subscription</h1>
{#if windows.length === 0}
<p class="doc text-muted-foreground text-sm">
No rate-limit report yet. The SDK sends one the first time a window
changes state; until then there is nothing to show.
No rate-limit report yet. The API sends one with the first turn.
</p>
{:else}
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each windows as w (w.window)}
{@const known = w.utilization !== null && w.utilization !== undefined}
{@const figure = w.utilization ?? w.last_known?.utilization}
{@const known = figure !== null && figure !== undefined}
{@const bound = w.utilization === null || w.utilization === undefined}
<div class="flex flex-col gap-2">
<span class="label-quiet">{limitLabel(w.window)}</span>
{#if known}
@@ -120,7 +123,7 @@
w.status === "rejected" && "text-destructive"
)}
>
{pct(w.utilization)}
{bound ? "≥ " : ""}{pct(figure)}
<span class="font-normal text-lg text-muted-foreground"
>%</span
>
@@ -128,38 +131,52 @@
<span
aria-valuemax="100"
aria-valuemin="0"
aria-valuenow={pct(w.utilization)}
aria-valuenow={pct(figure)}
class="h-1 w-full overflow-hidden rounded-full bg-muted"
role="progressbar"
>
<span
class={cn("block h-full rounded-full", BAR[w.status])}
style="width: {pct(w.utilization)}%"
style="width: {pct(figure)}%"
></span>
</span>
<span class="tabular text-muted-foreground text-xs">
{fmtCountdown(w.resets_at, now)}
· gateway share
{fmtTokens(
w.gateway.input + w.gateway.output + w.gateway.cache_creation
)}
tokens · {fmtMoney(w.gateway.cost_usd)}
</span>
{#if bound && w.last_known}
<span class="text-muted-foreground text-xs">
{limitWord(w.status)}
now · the figure is from
{fmtRelative(w.last_known.ts, now)}
</span>
{/if}
{:else}
<span class="text-2xl text-muted-foreground leading-none">
no report
<span
class={cn(
"font-semibold text-3xl leading-none tracking-tight",
w.status === "rejected" && "text-destructive"
)}
>
{limitWord(w.status)}
</span>
<span class="text-muted-foreground text-xs">
last seen {fmtRelative(w.ts, now)} ·
{fmtCountdown(
w.resets_at,
now
)}
no figure · seen {fmtRelative(w.ts, now)}
</span>
{/if}
<span class="tabular text-muted-foreground text-xs">
{fmtCountdown(w.resets_at, now)}
· through here since
{fmtTime(w.gateway.since).replace(SECONDS, "")}:
{fmtTokens(
w.gateway.input + w.gateway.output + w.gateway.cache_creation
)}
tokens · {fmtMoney(w.gateway.cost_usd)}
</span>
</div>
{/each}
</div>
<p class="doc text-muted-foreground text-xs">
The API puts a number on a window only when it nears the limit; the
rest of the time the status word is all it says. Between reports the
gateway's own count is the lower bound.
</p>
{/if}
</section>