feat(ui): interactive graph, strip context menus, days by activity, message times, limit status words
This commit is contained in:
1 parent
025f2dbc3f
commit
c6401f1114
27 files changed
+1619
-381
No files matched your search
@@ -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>
|
||||
@@ -4,7 +4,6 @@
|
||||
@import "@fontsource-variable/inter";
|
||||
@import "../lib/panel/panel.css";
|
||||
|
||||
@plugin "@tailwindcss/forms";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user