feat(ui): interactive graph, strip context menus, days by activity, message times, limit status words
This commit is contained in:
@@ -106,6 +106,8 @@ export interface ContentBlock {
|
||||
export interface HistoryMessage {
|
||||
content: string | ContentBlock[];
|
||||
role: "user" | "assistant";
|
||||
// When the first block of this message was written; absent for old rows.
|
||||
ts?: string | null;
|
||||
}
|
||||
|
||||
export interface EntriesPage {
|
||||
@@ -211,6 +213,8 @@ export interface LimitRecord {
|
||||
|
||||
export interface LimitWindow extends LimitRecord {
|
||||
gateway: UsageTotals & { since: string };
|
||||
// The last figure reported inside this window, if any: a lower bound.
|
||||
last_known: LimitRecord | null;
|
||||
}
|
||||
|
||||
export interface LimitsResponse {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { BusEvent, LimitsResponse, LimitWindow } from "./api/types";
|
||||
import type {
|
||||
BusEvent,
|
||||
JobsResponse,
|
||||
LimitsResponse,
|
||||
LimitWindow,
|
||||
} from "./api/types";
|
||||
import { panelCache } from "./panel/cache";
|
||||
import { ConversationIndex } from "./panel/index.svelte";
|
||||
import { session } from "./session.svelte";
|
||||
|
||||
const TAPE_SIZE = 120;
|
||||
const QUIET = new Set(["stream", "hello"]);
|
||||
const JOBS_KEY = "jobs";
|
||||
const cache = panelCache("beaver.admin");
|
||||
|
||||
// Gateway-wide live state for the admin: the conversation index, the last
|
||||
// non-chatty events as a tape, and the quota windows updated the moment
|
||||
@@ -11,11 +19,22 @@ const QUIET = new Set(["stream", "hello"]);
|
||||
class Gateway extends ConversationIndex {
|
||||
tape = $state<BusEvent[]>([]);
|
||||
limits = $state<LimitsResponse | null>(null);
|
||||
// The scheduler snapshot: what was last seen, shown at once, then refreshed.
|
||||
jobs = $state<JobsResponse | null>(cache.get<JobsResponse>(JOBS_KEY) ?? null);
|
||||
|
||||
constructor() {
|
||||
super(() => session.client);
|
||||
}
|
||||
|
||||
async refreshJobs(): Promise<void> {
|
||||
const client = this.client();
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
this.jobs = await client.jobs();
|
||||
cache.set(JOBS_KEY, this.jobs);
|
||||
}
|
||||
|
||||
override async load(): Promise<void> {
|
||||
const client = this.client();
|
||||
if (!client) {
|
||||
@@ -28,6 +47,7 @@ class Gateway extends ConversationIndex {
|
||||
if (limits) {
|
||||
this.limits = limits;
|
||||
}
|
||||
this.refreshJobs().catch(() => undefined);
|
||||
}
|
||||
|
||||
async refreshLimits(): Promise<void> {
|
||||
@@ -48,6 +68,9 @@ class Gateway extends ConversationIndex {
|
||||
this.applyLimit(event);
|
||||
return;
|
||||
}
|
||||
if (event.type.startsWith("job.") || event.type.startsWith("schedule.")) {
|
||||
this.refreshJobs().catch(() => undefined);
|
||||
}
|
||||
super.apply(event);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,3 +9,14 @@ export const LIMIT_LABELS: Record<string, string> = {
|
||||
export function limitLabel(window: string): string {
|
||||
return LIMIT_LABELS[window] ?? window;
|
||||
}
|
||||
|
||||
// The status as a word, for the windows the API never puts a figure on.
|
||||
export const LIMIT_WORDS: Record<string, string> = {
|
||||
allowed: "clear",
|
||||
allowed_warning: "near the limit",
|
||||
rejected: "rejected",
|
||||
};
|
||||
|
||||
export function limitWord(status: string): string {
|
||||
return LIMIT_WORDS[status] ?? status.replace("_", " ");
|
||||
}
|
||||
|
||||
+11
-4
@@ -1,4 +1,6 @@
|
||||
export interface NavItem {
|
||||
// Lit only on its own path, not on its children (an index page).
|
||||
exact?: boolean;
|
||||
href: string;
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -14,20 +16,25 @@ export const SECTIONS: NavItem[] = [
|
||||
export const SYSTEM: NavItem = { href: "/system", key: "5", label: "System" };
|
||||
|
||||
export const SYSTEM_PAGES: NavItem[] = [
|
||||
{ href: "/system", key: "", label: "Sessions" },
|
||||
{ exact: true, 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) {
|
||||
export function isActive(
|
||||
pathname: string,
|
||||
base: string,
|
||||
href: string,
|
||||
exact = false
|
||||
) {
|
||||
const path = pathname.startsWith(base)
|
||||
? pathname.slice(base.length)
|
||||
: pathname;
|
||||
const current = path || "/";
|
||||
if (href === "/") {
|
||||
return current === "/";
|
||||
if (href === "/" || exact) {
|
||||
return current === href;
|
||||
}
|
||||
return current === href || current.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
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 { clip } from "$lib/format";
|
||||
import { clip, fmtDateTime, fmtTime } from "$lib/format";
|
||||
import { cn } from "$lib/utils";
|
||||
import type { ActivityModel } from "./activity.svelte";
|
||||
import { summarizeInput, toolLabel } from "./activity.svelte";
|
||||
@@ -37,6 +37,10 @@
|
||||
let loadedAt = $state(new Date(0).toISOString());
|
||||
let scroller = $state<HTMLDivElement | null>(null);
|
||||
let body = $state<HTMLDivElement | null>(null);
|
||||
let end = $state<HTMLDivElement | null>(null);
|
||||
const SETTLE_MS = 2500;
|
||||
const SETTLE_STEP_MS = 120;
|
||||
let settleUntil = 0;
|
||||
let now = $state(Date.now());
|
||||
let pinned = true;
|
||||
|
||||
@@ -65,6 +69,7 @@
|
||||
try {
|
||||
({ messages } = await client.history(conversationId));
|
||||
loadedAt = new Date().toISOString();
|
||||
settle();
|
||||
host.cache?.set(
|
||||
`history:${conversationId}`,
|
||||
messages.slice(-CACHED_MESSAGES)
|
||||
@@ -83,11 +88,40 @@
|
||||
return left < NEAR_BOTTOM_PX;
|
||||
}
|
||||
|
||||
async function scrollToBottom() {
|
||||
await tick();
|
||||
function toBottom() {
|
||||
if (scroller) {
|
||||
scroller.scrollTop = scroller.scrollHeight;
|
||||
}
|
||||
// Whatever actually scrolls - this box, or a host's pane around it.
|
||||
end?.scrollIntoView({ block: "end" });
|
||||
}
|
||||
|
||||
async function scrollToBottom() {
|
||||
await tick();
|
||||
toBottom();
|
||||
}
|
||||
|
||||
// A fresh thread keeps growing after it is on screen: markdown renders
|
||||
// late, embeds load, fonts land. Hold the bottom for a while after a load.
|
||||
function settle() {
|
||||
settleUntil = Date.now() + SETTLE_MS;
|
||||
const step = () => {
|
||||
if (!pinned || Date.now() > settleUntil) {
|
||||
return;
|
||||
}
|
||||
toBottom();
|
||||
setTimeout(step, SETTLE_STEP_MS);
|
||||
};
|
||||
step();
|
||||
}
|
||||
|
||||
// The date changes rarely; say it once, then only the time.
|
||||
function dayOf(iso: string | null | undefined): string {
|
||||
return iso ? iso.slice(0, 10) : "";
|
||||
}
|
||||
|
||||
function minuteOf(iso: string | null | undefined): string {
|
||||
return iso ? fmtTime(iso).replace(SECONDS, "") : "";
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -129,6 +163,8 @@
|
||||
});
|
||||
|
||||
const SYSTEM_HEAD = /^\[[^\]\n]+\]/;
|
||||
const SECONDS = /:\d{2}$/;
|
||||
const DAY_TIME = /,?\s*\d{2}:\d{2}$/;
|
||||
let openSystem = $state<Set<number>>(new Set());
|
||||
|
||||
function systemText(message: HistoryMessage): string | null {
|
||||
@@ -175,13 +211,15 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
|
||||
class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 @md:px-6"
|
||||
onscroll={() => {
|
||||
pinned = nearBottom();
|
||||
if (Date.now() > settleUntil) {
|
||||
pinned = nearBottom();
|
||||
}
|
||||
}}
|
||||
bind:this={scroller}
|
||||
>
|
||||
<div class="flex flex-col gap-3" bind:this={body}>
|
||||
<div class="flex min-w-0 max-w-full flex-col gap-3" bind:this={body}>
|
||||
<button
|
||||
class="rule-word self-end text-xs"
|
||||
data-active={showResults}
|
||||
@@ -208,31 +246,51 @@
|
||||
{@const parts = blocks(message)}
|
||||
{@const isResultOnly = parts.every((b) => b.type === "tool_result")}
|
||||
{@const system = systemText(message)}
|
||||
{@const newDay =
|
||||
message.ts && dayOf(message.ts) !== dayOf(messages[index - 1]?.ts)}
|
||||
{@const newMinute =
|
||||
message.ts &&
|
||||
minuteOf(message.ts) !== minuteOf(messages[index - 1]?.ts)}
|
||||
{#if newDay}
|
||||
<p class="label-quiet self-center pt-2">
|
||||
{fmtDateTime(message.ts).replace(DAY_TIME, "")}
|
||||
</p>
|
||||
{/if}
|
||||
{#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"
|
||||
class="@md:max-w-[75ch] max-w-full self-center 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>
|
||||
<span class="block truncate"
|
||||
>{clip(system.split("\n")[0], 120)}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{:else if !(isResultOnly && !showResults)}
|
||||
<div
|
||||
class={cn(
|
||||
"flex flex-col gap-1.5",
|
||||
"group flex min-w-0 max-w-full flex-col gap-1.5",
|
||||
message.role === "user" && !isResultOnly && "items-end"
|
||||
)}
|
||||
>
|
||||
{#if message.ts && newMinute && !isResultOnly}
|
||||
<span
|
||||
class="tabular px-1 text-[11px] text-muted-foreground opacity-60 transition-opacity group-hover:opacity-100"
|
||||
title={fmtDateTime(message.ts)}
|
||||
>
|
||||
{minuteOf(message.ts)}
|
||||
</span>
|
||||
{/if}
|
||||
{#each parts as block, blockIndex (blockIndex)}
|
||||
{#if block.type === "text" && block.text}
|
||||
<Markdown
|
||||
class={cn(
|
||||
"max-w-[75ch]",
|
||||
"min-w-0 @md:max-w-[75ch] max-w-full",
|
||||
message.role === "user"
|
||||
? "rounded-2xl rounded-br-md bg-primary/10 px-3.5 py-2"
|
||||
: "px-1 py-1"
|
||||
@@ -240,11 +298,13 @@
|
||||
text={block.text}
|
||||
/>
|
||||
{:else if block.type === "tool_use"}
|
||||
<p class="flex items-baseline gap-2 px-1 text-xs">
|
||||
<span class="font-medium"
|
||||
<p
|
||||
class="flex min-w-0 max-w-full items-baseline gap-2 px-1 text-xs"
|
||||
>
|
||||
<span class="shrink-0 font-medium"
|
||||
>{toolLabel(block.name ?? "?")}</span
|
||||
>
|
||||
<span class="truncate text-muted-foreground">
|
||||
<span class="min-w-0 truncate text-muted-foreground">
|
||||
{clip(summarizeInput(block.name ?? "", block.input), 160)}
|
||||
</span>
|
||||
</p>
|
||||
@@ -271,5 +331,6 @@
|
||||
{#each tail as turn (turn.id)}
|
||||
<TurnCard {now} {turn} />
|
||||
{/each}
|
||||
<div aria-hidden="true" class="h-px shrink-0" bind:this={end}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -338,7 +338,7 @@
|
||||
>
|
||||
</div>
|
||||
{#if graph && graph.nodes.length > 0}
|
||||
<div class="rounded-lg border bg-strip/60 p-2">
|
||||
<div class="h-72 overflow-hidden rounded-lg border bg-strip/60">
|
||||
<Graph edges={graphEdges} nodes={graphNodes} onOpen={openNote} />
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
onChanged,
|
||||
class: className = "",
|
||||
children,
|
||||
trigger,
|
||||
}: {
|
||||
client: ApiClient;
|
||||
id: string;
|
||||
@@ -35,6 +36,9 @@
|
||||
onChanged: () => void;
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
// Render the trigger yourself with these props spread on it - the strip
|
||||
// stays a strip instead of sitting inside a wrapper div.
|
||||
trigger?: Snippet<[Record<string, unknown>]>;
|
||||
} = $props();
|
||||
|
||||
const host = usePanelHost();
|
||||
@@ -93,9 +97,17 @@
|
||||
|
||||
{#if mode === "context"}
|
||||
<ContextMenu.Root bind:open>
|
||||
<ContextMenu.Trigger class={className}>
|
||||
{@render children?.()}
|
||||
</ContextMenu.Trigger>
|
||||
{#if trigger}
|
||||
<ContextMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
{@render trigger(props)}
|
||||
{/snippet}
|
||||
</ContextMenu.Trigger>
|
||||
{:else}
|
||||
<ContextMenu.Trigger class={className}>
|
||||
{@render children?.()}
|
||||
</ContextMenu.Trigger>
|
||||
{/if}
|
||||
<ContextMenu.Content class="min-w-52">
|
||||
{@render body(ContextMenu)}
|
||||
</ContextMenu.Content>
|
||||
|
||||
+53
-14
@@ -6,6 +6,8 @@ export interface DayGroup {
|
||||
branches: ConversationSummary[];
|
||||
day: string;
|
||||
deep: ConversationSummary[];
|
||||
forks: ConversationSummary[];
|
||||
jobs: ConversationSummary[];
|
||||
label: string;
|
||||
live: boolean;
|
||||
master: ConversationSummary | null;
|
||||
@@ -43,15 +45,16 @@ 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.
|
||||
// Days as the operator remembers them: the master that began that day,
|
||||
// and under it everything that last moved that day - branches, deep
|
||||
// chats, forks, job runs. Open branches follow the master through the
|
||||
// rotation, so their parent says nothing about when they were alive;
|
||||
// the day of their last word does.
|
||||
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) {
|
||||
@@ -59,6 +62,8 @@ export function groupByDay(
|
||||
branches: [],
|
||||
day,
|
||||
deep: [],
|
||||
forks: [],
|
||||
jobs: [],
|
||||
label: dayLabel(day, now),
|
||||
live: false,
|
||||
master: null,
|
||||
@@ -72,9 +77,7 @@ export function groupByDay(
|
||||
.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);
|
||||
const g = group(localDay(master.created_at));
|
||||
if (!g.master || master.status === "open") {
|
||||
g.master = master;
|
||||
} else {
|
||||
@@ -82,16 +85,18 @@ export function groupByDay(
|
||||
}
|
||||
}
|
||||
for (const row of rows) {
|
||||
if (row.kind === "master" || row.kind === "job") {
|
||||
if (row.kind === "master") {
|
||||
continue;
|
||||
}
|
||||
const parentDay = row.parent ? dayOfMaster.get(row.parent) : undefined;
|
||||
const day = parentDay ?? localDay(activityOf(row));
|
||||
const g = group(day);
|
||||
const g = group(localDay(activityOf(row)));
|
||||
if (row.kind === "branch") {
|
||||
g.branches.push(row);
|
||||
} else if (row.kind === "deep") {
|
||||
g.deep.push(row);
|
||||
} else if (row.kind === "fork") {
|
||||
g.forks.push(row);
|
||||
} else if (row.kind === "job") {
|
||||
g.jobs.push(row);
|
||||
} else {
|
||||
g.others.push(row);
|
||||
}
|
||||
@@ -100,10 +105,44 @@ export function groupByDay(
|
||||
for (const g of out) {
|
||||
g.branches.sort(byActivity);
|
||||
g.deep.sort(byActivity);
|
||||
g.forks.sort(byActivity);
|
||||
g.jobs.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)
|
||||
);
|
||||
g.live = [
|
||||
g.master,
|
||||
...g.branches,
|
||||
...g.deep,
|
||||
...g.forks,
|
||||
...g.jobs,
|
||||
...g.others,
|
||||
].some((row) => row && (row.running_turn || row.pending_question));
|
||||
}
|
||||
return out.sort((a, b) => b.day.localeCompare(a.day));
|
||||
}
|
||||
|
||||
// One phrase for the day header: what the day held, and whether its
|
||||
// master is still the live one.
|
||||
export function daySummary(group: DayGroup, today: boolean): string {
|
||||
const parts: string[] = [];
|
||||
if (group.master?.status === "open" && !today) {
|
||||
parts.push("master still open");
|
||||
}
|
||||
const count = (n: number, one: string, many = `${one}s`) =>
|
||||
`${n} ${n === 1 ? one : many}`;
|
||||
if (group.branches.length) {
|
||||
parts.push(count(group.branches.length, "branch", "branches"));
|
||||
}
|
||||
if (group.deep.length) {
|
||||
parts.push(count(group.deep.length, "deep", "deep"));
|
||||
}
|
||||
if (group.forks.length) {
|
||||
parts.push(count(group.forks.length, "fork"));
|
||||
}
|
||||
if (group.jobs.length) {
|
||||
parts.push(count(group.jobs.length, "job run"));
|
||||
}
|
||||
if (group.others.length) {
|
||||
parts.push(count(group.others.length, "more", "more"));
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
+60
-20
@@ -14,7 +14,7 @@
|
||||
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 { type DayGroup, daySummary, groupByDay } from "./days";
|
||||
import NewConversation from "./new-conversation.svelte";
|
||||
|
||||
let {
|
||||
@@ -64,10 +64,9 @@
|
||||
}
|
||||
return index.conversations.filter(
|
||||
(row) =>
|
||||
row.kind !== "job" &&
|
||||
((row.title ?? "").toLowerCase().includes(needle) ||
|
||||
row.id.startsWith(needle) ||
|
||||
(row.last_item?.text ?? "").toLowerCase().includes(needle))
|
||||
(row.title ?? "").toLowerCase().includes(needle) ||
|
||||
row.id.startsWith(needle) ||
|
||||
(row.last_item?.text ?? "").toLowerCase().includes(needle)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -120,22 +119,20 @@
|
||||
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"}`
|
||||
);
|
||||
let jobsOpen = $state<Set<string>>(new Set());
|
||||
|
||||
function toggleJobs(day: string) {
|
||||
const next = new Set(jobsOpen);
|
||||
if (next.has(day)) {
|
||||
next.delete(day);
|
||||
} else {
|
||||
next.add(day);
|
||||
}
|
||||
if (group.deep.length) {
|
||||
parts.push(`${group.deep.length} deep`);
|
||||
}
|
||||
if (group.others.length) {
|
||||
parts.push(`${group.others.length} more`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
jobsOpen = next;
|
||||
}
|
||||
|
||||
const refresh = () => index.load().catch(() => undefined);
|
||||
|
||||
const linkOf = (id: string) => href?.(id);
|
||||
</script>
|
||||
|
||||
@@ -188,10 +185,12 @@
|
||||
<div class="bay-rack">
|
||||
{#each found as row (row.id)}
|
||||
<Strip
|
||||
{client}
|
||||
current={row.id === selected}
|
||||
detail={row.last_item?.text ?? ""}
|
||||
href={linkOf(row.id)}
|
||||
{now}
|
||||
onChanged={refresh}
|
||||
onclick={onOpen}
|
||||
{row}
|
||||
/>
|
||||
@@ -254,17 +253,19 @@
|
||||
></span>
|
||||
{/if}
|
||||
<span class="truncate text-muted-foreground text-xs">
|
||||
{summary(group)}
|
||||
{daySummary(group, position === 0)}
|
||||
</span>
|
||||
</button>
|
||||
{#if shown}
|
||||
<div class="bay-rack">
|
||||
{#if group.master}
|
||||
<Strip
|
||||
{client}
|
||||
current={group.master.id === selected}
|
||||
detail={group.master.last_item?.text ?? ""}
|
||||
href={linkOf(group.master.id)}
|
||||
{now}
|
||||
onChanged={refresh}
|
||||
onclick={onOpen}
|
||||
row={group.master}
|
||||
/>
|
||||
@@ -272,34 +273,73 @@
|
||||
{#each group.branches as row (row.id)}
|
||||
<Strip
|
||||
class="strip-child"
|
||||
{client}
|
||||
current={row.id === selected}
|
||||
detail={row.last_item?.text ?? ""}
|
||||
href={linkOf(row.id)}
|
||||
{now}
|
||||
onChanged={refresh}
|
||||
onclick={onOpen}
|
||||
{row}
|
||||
/>
|
||||
{/each}
|
||||
{#each group.deep as row (row.id)}
|
||||
<Strip
|
||||
{client}
|
||||
current={row.id === selected}
|
||||
detail={row.last_item?.text ?? ""}
|
||||
href={linkOf(row.id)}
|
||||
{now}
|
||||
onChanged={refresh}
|
||||
onclick={onOpen}
|
||||
{row}
|
||||
/>
|
||||
{/each}
|
||||
{#each group.others as row (row.id)}
|
||||
{#each [...group.forks, ...group.others] as row (row.id)}
|
||||
<Strip
|
||||
class="strip-child"
|
||||
{client}
|
||||
current={row.id === selected}
|
||||
detail="{row.kind} · {row.status}"
|
||||
href={linkOf(row.id)}
|
||||
{now}
|
||||
onChanged={refresh}
|
||||
onclick={onOpen}
|
||||
{row}
|
||||
/>
|
||||
{/each}
|
||||
{#if group.jobs.length > 0}
|
||||
{@const shownJobs = jobsOpen.has(group.day)}
|
||||
<button
|
||||
aria-expanded={shownJobs}
|
||||
class="strip w-full text-left text-muted-foreground text-xs"
|
||||
onclick={() => toggleJobs(group.day)}
|
||||
type="button"
|
||||
>
|
||||
<span class="size-5"></span>
|
||||
<span>
|
||||
{group.jobs.length}
|
||||
{group.jobs.length === 1 ? "job run" : "job runs"}
|
||||
· {shownJobs ? "hide" : "show"}
|
||||
</span>
|
||||
<span></span>
|
||||
</button>
|
||||
{#if shownJobs}
|
||||
{#each group.jobs as row (row.id)}
|
||||
<Strip
|
||||
class="strip-child"
|
||||
{client}
|
||||
current={row.id === selected}
|
||||
detail={row.last_item?.text ?? ""}
|
||||
href={linkOf(row.id)}
|
||||
{now}
|
||||
onChanged={refresh}
|
||||
onclick={onOpen}
|
||||
{row}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
+287
-70
@@ -2,8 +2,19 @@
|
||||
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 type {
|
||||
ConversationSummary,
|
||||
JobInfo,
|
||||
QueuedJob,
|
||||
VaultGraph,
|
||||
} from "$lib/api/types";
|
||||
import {
|
||||
clip,
|
||||
fmtCountdown,
|
||||
fmtRelative,
|
||||
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";
|
||||
@@ -11,8 +22,10 @@
|
||||
import { ui } from "$lib/ui.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
import Bay from "./bay.svelte";
|
||||
import FloatMenu, { type FloatItem } from "./float-menu.svelte";
|
||||
import Graph, { type GraphEdge, type GraphNode } from "./graph.svelte";
|
||||
import Instruments from "./instruments.svelte";
|
||||
import KindMark from "./kind-mark.svelte";
|
||||
import { now as board } from "./now.svelte";
|
||||
import { stateOf } from "./state";
|
||||
import Strip from "./strip.svelte";
|
||||
@@ -25,15 +38,20 @@
|
||||
|
||||
const TICK_MS = 1000;
|
||||
const QUIET_SHOWN = 6;
|
||||
const TAPE_SHOWN = 24;
|
||||
const TAPE_SHOWN = 14;
|
||||
const HOURS_DAY = 24;
|
||||
const DEEP_RECENT_MS = 36 * 3_600_000;
|
||||
const SCHEDULED_SHOWN = 8;
|
||||
const PAYLOAD_MAX = 90;
|
||||
|
||||
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);
|
||||
let menuOpen = $state(false);
|
||||
let menuAt = $state({ x: 0, y: 0 });
|
||||
let menuItems = $state<FloatItem[]>([]);
|
||||
|
||||
onMount(() => {
|
||||
const tick = setInterval(() => {
|
||||
@@ -60,6 +78,7 @@
|
||||
|
||||
const href = (id: string) => `${base}/conversations/${id}`;
|
||||
const GRAPH_FILES = 24;
|
||||
const refresh = () => gateway.load().catch(() => undefined);
|
||||
|
||||
async function loadVault(masterId: string) {
|
||||
const { client } = session;
|
||||
@@ -114,17 +133,78 @@
|
||||
const windows = $derived(gateway.limits?.windows ?? []);
|
||||
const tape = $derived(gateway.tape.slice(0, TAPE_SHOWN));
|
||||
|
||||
const MD_SUFFIX = /\.md$/;
|
||||
// What is coming: queued injects and the next cron runs, soonest first.
|
||||
interface Coming {
|
||||
at: string;
|
||||
detail: string;
|
||||
id: string;
|
||||
job: JobInfo | null;
|
||||
kind: "queue" | "job";
|
||||
label: string;
|
||||
queued: QueuedJob | null;
|
||||
}
|
||||
const coming = $derived.by((): Coming[] => {
|
||||
const snapshot = gateway.jobs;
|
||||
if (!snapshot) {
|
||||
return [];
|
||||
}
|
||||
const out: Coming[] = [];
|
||||
for (const q of snapshot.queue) {
|
||||
if (q.status === "queued" && q.execute_after) {
|
||||
const { text } = q.payload;
|
||||
out.push({
|
||||
at: q.execute_after,
|
||||
detail: typeof text === "string" ? text : q.entrypoint,
|
||||
id: `q:${q.id}`,
|
||||
job: null,
|
||||
kind: "queue",
|
||||
label: q.entrypoint.replace(INJECT_PREFIX, "inject "),
|
||||
queued: q,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const job of snapshot.jobs) {
|
||||
if (job.next_run) {
|
||||
out.push({
|
||||
at: job.next_run,
|
||||
detail: `${job.cron ?? "manual"} · ${lastRun(job)}`,
|
||||
id: `j:${job.name}`,
|
||||
job,
|
||||
kind: "job",
|
||||
label: job.name,
|
||||
queued: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out
|
||||
.sort((a, b) => a.at.localeCompare(b.at))
|
||||
.slice(0, SCHEDULED_SHOWN);
|
||||
});
|
||||
|
||||
function lastRun(job: JobInfo): string {
|
||||
if (job.run) {
|
||||
return `${job.run.status} ${fmtRelative(job.run.started_at, now)}`;
|
||||
}
|
||||
return job.last_run ? `ran ${fmtRelative(job.last_run, now)}` : "never ran";
|
||||
}
|
||||
|
||||
const MD_SUFFIX = /\.md$/;
|
||||
const INJECT_PREFIX = /^inject[._-]?/;
|
||||
const SECONDS = /:\d{2}$/;
|
||||
|
||||
function recent(row: ConversationSummary): boolean {
|
||||
const at = Date.parse(row.last_activity_at ?? row.created_at ?? "");
|
||||
return Number.isFinite(at) && now - at < DEEP_RECENT_MS;
|
||||
}
|
||||
|
||||
// The master, what hangs off it, what moves, and what spoke lately:
|
||||
// not every open thread from the past week.
|
||||
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),
|
||||
@@ -135,17 +215,22 @@
|
||||
state: stateOf(master),
|
||||
});
|
||||
}
|
||||
for (const row of rows) {
|
||||
for (const row of gateway.conversations) {
|
||||
if (row.id === master?.id || row.kind === "master") {
|
||||
continue;
|
||||
}
|
||||
const child = row.kind === "branch" || row.kind === "fork";
|
||||
const child = Boolean(master) && row.parent === master?.id;
|
||||
const live = Boolean(row.running_turn) || row.pending_question;
|
||||
const shown = live || (row.status === "open" && (child || recent(row)));
|
||||
if (!shown) {
|
||||
continue;
|
||||
}
|
||||
nodes.push({
|
||||
href: href(row.id),
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
label: row.title ?? `${row.kind} ${shortId(row.id)}`,
|
||||
ring: child ? 1 : 2,
|
||||
ring: child || live ? 1 : 2,
|
||||
state: stateOf(row),
|
||||
});
|
||||
if (master && child) {
|
||||
@@ -196,23 +281,111 @@
|
||||
};
|
||||
});
|
||||
|
||||
function obsidianUrl(path: string): string {
|
||||
return `obsidian://open?file=${encodeURIComponent(path.replace(MD_SUFFIX, ""))}`;
|
||||
}
|
||||
|
||||
function openNode(id: string) {
|
||||
if (id.endsWith(".md")) {
|
||||
window.open(
|
||||
`obsidian://open?file=${encodeURIComponent(id.replace(MD_SUFFIX, ""))}`
|
||||
);
|
||||
window.open(obsidianUrl(id));
|
||||
return;
|
||||
}
|
||||
open(id);
|
||||
}
|
||||
|
||||
function nodeMenu(node: GraphNode, x: number, y: number) {
|
||||
menuAt = { x, y };
|
||||
if (node.kind === "file") {
|
||||
menuItems = [
|
||||
{
|
||||
label: "Open in Obsidian",
|
||||
run: () => window.open(obsidianUrl(node.id)),
|
||||
},
|
||||
{
|
||||
label: "Open in Memory",
|
||||
run: () => {
|
||||
ui.closeAll();
|
||||
goto(`${base}/memory?path=${encodeURIComponent(node.id)}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
gap: true,
|
||||
label: "Copy path",
|
||||
run: () =>
|
||||
navigator.clipboard.writeText(node.id).catch(() => undefined),
|
||||
},
|
||||
];
|
||||
} else {
|
||||
menuItems = [
|
||||
{ label: "Open", run: () => open(node.id) },
|
||||
{
|
||||
label: "Open in a new tab",
|
||||
run: () => window.open(href(node.id), "_blank", "noopener"),
|
||||
},
|
||||
{
|
||||
gap: true,
|
||||
label: "Copy id",
|
||||
run: () =>
|
||||
navigator.clipboard.writeText(node.id).catch(() => undefined),
|
||||
},
|
||||
];
|
||||
}
|
||||
menuOpen = true;
|
||||
}
|
||||
|
||||
function comingMenu(event: MouseEvent, item: Coming) {
|
||||
event.preventDefault();
|
||||
menuAt = { x: event.clientX, y: event.clientY };
|
||||
const items: FloatItem[] = [
|
||||
{
|
||||
label: "Open the scheduler",
|
||||
run: () => {
|
||||
ui.closeAll();
|
||||
goto(`${base}/system/jobs`);
|
||||
},
|
||||
},
|
||||
];
|
||||
if (item.job && session.client) {
|
||||
const { client } = session;
|
||||
const { name } = item.job;
|
||||
items.push({
|
||||
label: "Run now",
|
||||
run: () =>
|
||||
client
|
||||
.runJob(name)
|
||||
.then(refreshJobs)
|
||||
.catch(() => undefined),
|
||||
});
|
||||
}
|
||||
if (item.queued && session.client) {
|
||||
const { client } = session;
|
||||
const { id } = item.queued;
|
||||
items.push({
|
||||
danger: true,
|
||||
gap: true,
|
||||
label: "Cancel",
|
||||
run: () =>
|
||||
client
|
||||
.cancelJob(id)
|
||||
.then(refreshJobs)
|
||||
.catch(() => undefined),
|
||||
});
|
||||
}
|
||||
menuItems = items;
|
||||
menuOpen = true;
|
||||
}
|
||||
|
||||
const refreshJobs = () => gateway.refreshJobs().catch(() => undefined);
|
||||
</script>
|
||||
|
||||
<FloatMenu items={menuItems} x={menuAt.x} y={menuAt.y} bind:open={menuOpen} />
|
||||
|
||||
<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]"
|
||||
: "grid-cols-1 lg:grid-cols-[minmax(0,1fr)_22rem] xl:grid-cols-[minmax(0,1fr)_26rem]"
|
||||
)}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-8">
|
||||
@@ -235,9 +408,12 @@
|
||||
{#each board.waiting as row (row.id)}
|
||||
{@const info = board.snapshots[row.id]}
|
||||
<Strip
|
||||
client={session.client}
|
||||
detail={info?.question?.questions[0]?.question ?? "question pending"}
|
||||
href={href(row.id)}
|
||||
{now}
|
||||
onChanged={refresh}
|
||||
onclick={open}
|
||||
open={Boolean(info?.question)}
|
||||
{row}
|
||||
state="waiting"
|
||||
@@ -267,9 +443,12 @@
|
||||
{:else}
|
||||
{#each board.running as row (row.id)}
|
||||
<Strip
|
||||
client={session.client}
|
||||
detail={lastTool(row)}
|
||||
href={href(row.id)}
|
||||
{now}
|
||||
onChanged={refresh}
|
||||
onclick={open}
|
||||
{row}
|
||||
state="running"
|
||||
/>
|
||||
@@ -277,6 +456,49 @@
|
||||
{/if}
|
||||
</Bay>
|
||||
|
||||
{#if coming.length > 0}
|
||||
<Bay
|
||||
count={coming.length}
|
||||
hint="queued injects and the next cron runs"
|
||||
label="Coming up"
|
||||
>
|
||||
{#snippet aside()}
|
||||
<a class="rule-word text-xs" href="{base}/system/jobs">scheduler</a>
|
||||
{/snippet}
|
||||
{#each coming as item (item.id)}
|
||||
<a
|
||||
class="strip text-left text-sm"
|
||||
href="{base}/system/jobs"
|
||||
oncontextmenu={(event) => comingMenu(event, item)}
|
||||
title="right-click for actions"
|
||||
>
|
||||
{#if item.kind === "job"}
|
||||
<KindMark kind="job" />
|
||||
{:else}
|
||||
<span
|
||||
class="inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[11px] text-muted-foreground"
|
||||
title="queued inject"
|
||||
>
|
||||
↳
|
||||
</span>
|
||||
{/if}
|
||||
<span class="flex min-w-0 flex-col leading-tight">
|
||||
<span class="truncate font-medium">{item.label}</span>
|
||||
<span class="truncate text-muted-foreground text-xs">
|
||||
{clip(item.detail, PAYLOAD_MAX)}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="tabular shrink-0 text-muted-foreground text-xs"
|
||||
title={fmtTime(item.at)}
|
||||
>
|
||||
{fmtCountdown(item.at, now).replace("resets ", "")}
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</Bay>
|
||||
{/if}
|
||||
|
||||
<Bay
|
||||
count={board.quiet.length}
|
||||
hint="open, waiting for the next word"
|
||||
@@ -304,73 +526,68 @@
|
||||
{:else}
|
||||
{#each quiet as row (row.id)}
|
||||
<Strip
|
||||
client={session.client}
|
||||
detail={row.last_item?.text ?? ""}
|
||||
href={href(row.id)}
|
||||
{now}
|
||||
onChanged={refresh}
|
||||
onclick={open}
|
||||
{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 class="hidden min-w-0 flex-col gap-6 lg:flex">
|
||||
<section class="flex flex-col gap-2">
|
||||
<h2 class="label-quiet px-1">Around the master</h2>
|
||||
<div class="h-[26rem] overflow-hidden rounded-lg border bg-strip/60">
|
||||
<Graph
|
||||
edges={graph.edges}
|
||||
nodes={graph.nodes}
|
||||
onMenu={nodeMenu}
|
||||
onOpen={openNode}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section class="flex flex-col gap-2">
|
||||
<h2 class="label-quiet px-1">
|
||||
Recent events
|
||||
<span class="tabular font-normal">{gateway.tape.length}</span>
|
||||
</h2>
|
||||
<ul class="flex flex-col px-1 text-xs">
|
||||
{#each tape as item (item.seq)}
|
||||
<li
|
||||
class="ledger-grid grid-cols-[4.5rem_7rem_minmax(0,1fr)] py-0.5"
|
||||
>
|
||||
<span class="tabular text-muted-foreground">
|
||||
{fmtTime(item.ts).replace(SECONDS, "")}
|
||||
</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>
|
||||
{:else}
|
||||
<li class="py-1 text-muted-foreground">
|
||||
Nothing yet this session.
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<script lang="ts" module>
|
||||
export interface FloatItem {
|
||||
danger?: boolean;
|
||||
// A rule sits above this item.
|
||||
gap?: boolean;
|
||||
label: string;
|
||||
run: () => void;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
// A menu that opens where the pointer is, for things that are not
|
||||
// conversations: a note in the graph, a file in the memory tree.
|
||||
let {
|
||||
items,
|
||||
x,
|
||||
y,
|
||||
open = $bindable(false),
|
||||
class: className = "",
|
||||
}: {
|
||||
items: FloatItem[];
|
||||
x: number;
|
||||
y: number;
|
||||
open?: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const MARGIN = 8;
|
||||
const WIDTH = 208;
|
||||
let panel = $state<HTMLDivElement | null>(null);
|
||||
let cursor = $state(0);
|
||||
|
||||
const left = $derived(
|
||||
typeof window === "undefined"
|
||||
? x
|
||||
: Math.min(x, window.innerWidth - WIDTH - MARGIN)
|
||||
);
|
||||
const top = $derived.by(() => {
|
||||
if (typeof window === "undefined" || !panel) {
|
||||
return y;
|
||||
}
|
||||
return Math.min(y, window.innerHeight - panel.offsetHeight - MARGIN);
|
||||
});
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
function pick(item: FloatItem) {
|
||||
close();
|
||||
item.run();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
cursor = 0;
|
||||
const away = (event: PointerEvent) => {
|
||||
if (panel && !panel.contains(event.target as Node)) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
const keys = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
cursor = (cursor + 1) % items.length;
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
cursor = (cursor - 1 + items.length) % items.length;
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const item = items[cursor];
|
||||
if (item) {
|
||||
pick(item);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointerdown", away, true);
|
||||
window.addEventListener("keydown", keys, true);
|
||||
window.addEventListener("resize", close);
|
||||
window.addEventListener("scroll", close, true);
|
||||
queueMicrotask(() => panel?.focus());
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", away, true);
|
||||
window.removeEventListener("keydown", keys, true);
|
||||
window.removeEventListener("resize", close);
|
||||
window.removeEventListener("scroll", close, true);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class={cn(
|
||||
"fixed z-50 flex animate-island-in flex-col rounded-lg border bg-popover p-1 text-popover-foreground text-sm shadow-float outline-none",
|
||||
className
|
||||
)}
|
||||
role="menu"
|
||||
style="left: {left}px; top: {top}px; width: {WIDTH}px"
|
||||
tabindex="-1"
|
||||
bind:this={panel}
|
||||
>
|
||||
{#each items as item, index (item.label)}
|
||||
{#if item.gap && index > 0}
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
{/if}
|
||||
<button
|
||||
class={cn(
|
||||
"flex w-full items-center rounded-md px-2 py-1.5 text-left hover:bg-accent",
|
||||
index === cursor && "bg-accent",
|
||||
item.danger && "text-destructive"
|
||||
)}
|
||||
onclick={() => pick(item)}
|
||||
onpointerenter={() => {
|
||||
cursor = index;
|
||||
}}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,205 @@
|
||||
// A small force layout: rings keep the structure legible, springs and
|
||||
// repulsion keep neighbours apart, a breath keeps it alive between touches.
|
||||
|
||||
export interface Body {
|
||||
fixed: boolean;
|
||||
id: string;
|
||||
ring: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface Link {
|
||||
a: Body;
|
||||
b: Body;
|
||||
}
|
||||
|
||||
const RING_RADIUS = [0, 92, 168, 224];
|
||||
const LINK_STRENGTH = 0.06;
|
||||
const LINK_REST = 70;
|
||||
const CHARGE = 1600;
|
||||
const CHARGE_REACH = 320;
|
||||
const RADIAL = 0.05;
|
||||
const HUB_HOLD = 0.4;
|
||||
const DECAY = 0.55;
|
||||
const ALPHA_EASE = 0.025;
|
||||
const BREATH = 0.35;
|
||||
const MIN_D2 = 36;
|
||||
const TAU = Math.PI * 2;
|
||||
const HASH_MOD = 1_000_003;
|
||||
const HASH_BASE = 31;
|
||||
|
||||
function hash(id: string): number {
|
||||
let h = 0;
|
||||
for (const ch of id) {
|
||||
h = (h * HASH_BASE + ch.charCodeAt(0)) % HASH_MOD;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
export function ringRadius(ring: number): number {
|
||||
return RING_RADIUS[Math.min(ring, RING_RADIUS.length - 1)] ?? 0;
|
||||
}
|
||||
|
||||
export class Force {
|
||||
bodies: Body[] = [];
|
||||
alpha = 1;
|
||||
alphaTarget = 0;
|
||||
private links: Link[] = [];
|
||||
private readonly index = new Map<string, Body>();
|
||||
private clock = 0;
|
||||
|
||||
// Keep what is already placed, seat newcomers on their ring near a
|
||||
// neighbour when they have one, forget what is gone.
|
||||
sync(
|
||||
nodes: { id: string; ring: number }[],
|
||||
edges: { from: string; to: string }[]
|
||||
): void {
|
||||
const keep = new Set<string>();
|
||||
const fresh: Body[] = [];
|
||||
for (const node of nodes) {
|
||||
keep.add(node.id);
|
||||
const known = this.index.get(node.id);
|
||||
if (known) {
|
||||
known.ring = node.ring;
|
||||
continue;
|
||||
}
|
||||
const angle = (hash(node.id) % 360) * (TAU / 360);
|
||||
const r = ringRadius(node.ring);
|
||||
const body: Body = {
|
||||
fixed: false,
|
||||
id: node.id,
|
||||
ring: node.ring,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
x: Math.cos(angle) * r,
|
||||
y: Math.sin(angle) * r,
|
||||
};
|
||||
this.index.set(node.id, body);
|
||||
fresh.push(body);
|
||||
}
|
||||
for (const id of [...this.index.keys()]) {
|
||||
if (!keep.has(id)) {
|
||||
this.index.delete(id);
|
||||
}
|
||||
}
|
||||
this.bodies = nodes
|
||||
.map((node) => this.index.get(node.id))
|
||||
.filter((body): body is Body => body !== undefined);
|
||||
this.links = [];
|
||||
for (const edge of edges) {
|
||||
const a = this.index.get(edge.from);
|
||||
const b = this.index.get(edge.to);
|
||||
if (a && b && a !== b) {
|
||||
this.links.push({ a, b });
|
||||
}
|
||||
}
|
||||
for (const body of fresh) {
|
||||
const link = this.links.find((l) => l.a === body || l.b === body);
|
||||
if (link) {
|
||||
const other = link.a === body ? link.b : link.a;
|
||||
const r = ringRadius(body.ring);
|
||||
const angle = Math.atan2(other.y, other.x) + (hash(body.id) % 7) * 0.09;
|
||||
body.x = Math.cos(angle) * r;
|
||||
body.y = Math.sin(angle) * r;
|
||||
}
|
||||
}
|
||||
if (fresh.length > 0) {
|
||||
this.reheat(0.6);
|
||||
}
|
||||
}
|
||||
|
||||
body(id: string): Body | undefined {
|
||||
return this.index.get(id);
|
||||
}
|
||||
|
||||
reheat(to = 0.4): void {
|
||||
this.alpha = Math.max(this.alpha, to);
|
||||
}
|
||||
|
||||
get settled(): boolean {
|
||||
return this.alpha < 0.004 && this.alphaTarget === 0;
|
||||
}
|
||||
|
||||
// One tick; ``breathe`` adds the slow drift of a graph nobody is touching.
|
||||
step(breathe: boolean): void {
|
||||
this.clock += 1 / 60;
|
||||
const { alpha, bodies } = this;
|
||||
for (const link of this.links) {
|
||||
const dx = link.b.x - link.a.x;
|
||||
const dy = link.b.y - link.a.y;
|
||||
const d = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
|
||||
const rest = LINK_REST + Math.abs(link.a.ring - link.b.ring) * 10;
|
||||
const f = ((d - rest) / d) * LINK_STRENGTH * alpha;
|
||||
link.a.vx += dx * f;
|
||||
link.a.vy += dy * f;
|
||||
link.b.vx -= dx * f;
|
||||
link.b.vy -= dy * f;
|
||||
}
|
||||
for (let i = 0; i < bodies.length; i += 1) {
|
||||
const a = bodies[i];
|
||||
for (let j = i + 1; j < bodies.length; j += 1) {
|
||||
const b = bodies[j];
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const d2 = Math.max(dx * dx + dy * dy, MIN_D2);
|
||||
if (d2 > CHARGE_REACH * CHARGE_REACH) {
|
||||
continue;
|
||||
}
|
||||
const f = (CHARGE / d2) * alpha;
|
||||
const d = Math.sqrt(d2);
|
||||
const fx = (dx / d) * f;
|
||||
const fy = (dy / d) * f;
|
||||
a.vx -= fx;
|
||||
a.vy -= fy;
|
||||
b.vx += fx;
|
||||
b.vy += fy;
|
||||
}
|
||||
}
|
||||
for (const body of bodies) {
|
||||
if (body.fixed) {
|
||||
body.vx = 0;
|
||||
body.vy = 0;
|
||||
continue;
|
||||
}
|
||||
if (body.ring === 0) {
|
||||
body.vx -= body.x * HUB_HOLD * alpha;
|
||||
body.vy -= body.y * HUB_HOLD * alpha;
|
||||
} else {
|
||||
const r = Math.max(Math.sqrt(body.x * body.x + body.y * body.y), 1);
|
||||
const pull = ((ringRadius(body.ring) - r) / r) * RADIAL * alpha;
|
||||
body.vx += body.x * pull;
|
||||
body.vy += body.y * pull;
|
||||
}
|
||||
if (breathe) {
|
||||
const seed = hash(body.id) % 11;
|
||||
body.vx += Math.sin(this.clock * 0.6 + seed) * BREATH * 0.02;
|
||||
body.vy += Math.cos(this.clock * 0.5 + seed * 1.3) * BREATH * 0.02;
|
||||
}
|
||||
body.vx *= DECAY;
|
||||
body.vy *= DECAY;
|
||||
body.x += body.vx;
|
||||
body.y += body.vy;
|
||||
}
|
||||
this.alpha += (this.alphaTarget - this.alpha) * ALPHA_EASE;
|
||||
}
|
||||
|
||||
bounds(): { x: number; y: number; w: number; h: number } {
|
||||
if (this.bodies.length === 0) {
|
||||
return { h: 1, w: 1, x: 0, y: 0 };
|
||||
}
|
||||
let minX = Number.POSITIVE_INFINITY;
|
||||
let minY = Number.POSITIVE_INFINITY;
|
||||
let maxX = Number.NEGATIVE_INFINITY;
|
||||
let maxY = Number.NEGATIVE_INFINITY;
|
||||
for (const body of this.bodies) {
|
||||
minX = Math.min(minX, body.x);
|
||||
minY = Math.min(minY, body.y);
|
||||
maxX = Math.max(maxX, body.x);
|
||||
maxY = Math.max(maxY, body.y);
|
||||
}
|
||||
return { h: maxY - minY || 1, w: maxX - minX || 1, x: minX, y: minY };
|
||||
}
|
||||
}
|
||||
+384
-154
@@ -1,8 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { clip } from "$lib/format";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
<script lang="ts" module>
|
||||
export interface GraphNode {
|
||||
href?: string;
|
||||
id: string;
|
||||
@@ -17,107 +13,246 @@
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Maximize2Icon from "@lucide/svelte/icons/maximize-2";
|
||||
import Minimize2Icon from "@lucide/svelte/icons/minimize-2";
|
||||
import ScanIcon from "@lucide/svelte/icons/scan";
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { clip } from "$lib/format";
|
||||
import { cn } from "$lib/utils";
|
||||
import { type Body, Force } from "./force";
|
||||
|
||||
let {
|
||||
nodes,
|
||||
edges,
|
||||
onOpen,
|
||||
onMenu,
|
||||
class: className = "",
|
||||
}: {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
onOpen?: (id: string) => void;
|
||||
// The right button on a node: what is open lands where the pointer is.
|
||||
onMenu?: (node: GraphNode, x: number, y: number) => 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;
|
||||
const LABEL_MAX = 22;
|
||||
const LABEL_MAX_NEAR = 40;
|
||||
const ZOOM_MIN = 0.25;
|
||||
const ZOOM_MAX = 4;
|
||||
const ZOOM_STEP = 0.0016;
|
||||
const FIT_PAD = 0.86;
|
||||
const FIT_MAX = 1.5;
|
||||
const CLICK_SLOP = 4;
|
||||
const LABEL_ZOOM = 1.35;
|
||||
const SETTLE_FRAMES = 90;
|
||||
const FONT = 11;
|
||||
|
||||
let time = $state(0);
|
||||
const force = new Force();
|
||||
let frame = $state(0);
|
||||
let width = $state(0);
|
||||
let height = $state(0);
|
||||
let k = $state(1);
|
||||
let tx = $state(0);
|
||||
let ty = $state(0);
|
||||
let hovered = $state<string | null>(null);
|
||||
let expanded = $state(false);
|
||||
let touched = false;
|
||||
let reduced = false;
|
||||
let settling = 0;
|
||||
let svg = $state<SVGSVGElement | null>(null);
|
||||
|
||||
interface Drag {
|
||||
body: Body | null;
|
||||
moved: boolean;
|
||||
pointer: number;
|
||||
startTx: number;
|
||||
startTy: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
}
|
||||
let drag: Drag | null = null;
|
||||
|
||||
const byId = $derived(new Map(nodes.map((node) => [node.id, node])));
|
||||
const neighbours = $derived.by(() => {
|
||||
const map = new Map<string, Set<string>>();
|
||||
for (const edge of edges) {
|
||||
map.set(edge.from, (map.get(edge.from) ?? new Set()).add(edge.to));
|
||||
map.set(edge.to, (map.get(edge.to) ?? new Set()).add(edge.from));
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
force.sync(
|
||||
nodes.map((n) => ({ id: n.id, ring: n.ring })),
|
||||
edges
|
||||
);
|
||||
settling = SETTLE_FRAMES;
|
||||
untrack(() => {
|
||||
frame += 1;
|
||||
});
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduced) {
|
||||
let handle = 0;
|
||||
const tick = () => {
|
||||
const breathe = !(reduced || drag);
|
||||
if (!(force.settled && !breathe) || settling > 0) {
|
||||
force.step(breathe && force.settled);
|
||||
frame += 1;
|
||||
}
|
||||
if (settling > 0) {
|
||||
settling -= 1;
|
||||
if (!touched) {
|
||||
fit();
|
||||
}
|
||||
}
|
||||
handle = requestAnimationFrame(tick);
|
||||
};
|
||||
handle = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(handle);
|
||||
});
|
||||
|
||||
const placed = $derived.by(() => {
|
||||
if (frame < 0) {
|
||||
return [];
|
||||
}
|
||||
return force.bodies.map((body) => ({ body, node: byId.get(body.id) }));
|
||||
});
|
||||
|
||||
function fit() {
|
||||
if (width === 0 || height === 0) {
|
||||
return;
|
||||
}
|
||||
let frame = 0;
|
||||
const start = performance.now();
|
||||
const tick = (ts: number) => {
|
||||
time = (ts - start) / 1000;
|
||||
frame = requestAnimationFrame(tick);
|
||||
const b = force.bounds();
|
||||
const pad = 48;
|
||||
const scale = Math.min(
|
||||
(width * FIT_PAD) / (b.w + pad),
|
||||
(height * FIT_PAD) / (b.h + pad),
|
||||
FIT_MAX
|
||||
);
|
||||
k = Math.max(scale, ZOOM_MIN);
|
||||
tx = -(b.x + b.w / 2) * k;
|
||||
ty = -(b.y + b.h / 2) * k;
|
||||
}
|
||||
|
||||
function refit() {
|
||||
touched = false;
|
||||
fit();
|
||||
}
|
||||
|
||||
// Pointer position in graph units.
|
||||
function toGraph(event: PointerEvent | WheelEvent): { x: number; y: number } {
|
||||
const rect = svg?.getBoundingClientRect();
|
||||
const px = event.clientX - (rect?.left ?? 0) - width / 2 - tx;
|
||||
const py = event.clientY - (rect?.top ?? 0) - height / 2 - ty;
|
||||
return { x: px / k, y: py / k };
|
||||
}
|
||||
|
||||
function onWheel(event: WheelEvent) {
|
||||
event.preventDefault();
|
||||
touched = true;
|
||||
const before = toGraph(event);
|
||||
const next = Math.min(
|
||||
ZOOM_MAX,
|
||||
Math.max(ZOOM_MIN, k * Math.exp(-event.deltaY * ZOOM_STEP))
|
||||
);
|
||||
tx -= before.x * (next - k);
|
||||
ty -= before.y * (next - k);
|
||||
k = next;
|
||||
}
|
||||
|
||||
function onDown(event: PointerEvent, body: Body | null) {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
svg?.setPointerCapture(event.pointerId);
|
||||
drag = {
|
||||
body,
|
||||
moved: false,
|
||||
pointer: event.pointerId,
|
||||
startTx: tx,
|
||||
startTy: ty,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
};
|
||||
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;
|
||||
if (body) {
|
||||
body.fixed = true;
|
||||
force.reheat(0.3);
|
||||
settling = 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
interface Placed extends GraphNode {
|
||||
x: number;
|
||||
y: number;
|
||||
function onMove(event: PointerEvent) {
|
||||
if (!drag || drag.pointer !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
const dx = event.clientX - drag.startX;
|
||||
const dy = event.clientY - drag.startY;
|
||||
if (Math.abs(dx) > CLICK_SLOP || Math.abs(dy) > CLICK_SLOP) {
|
||||
drag.moved = true;
|
||||
touched = true;
|
||||
}
|
||||
if (drag.body) {
|
||||
const at = toGraph(event);
|
||||
drag.body.x = at.x;
|
||||
drag.body.y = at.y;
|
||||
force.reheat(0.25);
|
||||
frame += 1;
|
||||
} else if (drag.moved) {
|
||||
tx = drag.startTx + dx;
|
||||
ty = drag.startTy + dy;
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
function onUp(event: PointerEvent) {
|
||||
if (!drag || drag.pointer !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
const { body, moved } = drag;
|
||||
drag = null;
|
||||
if (body) {
|
||||
body.fixed = false;
|
||||
if (!moved) {
|
||||
onOpen?.(body.id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
const byId = $derived(new Map(placed.map((node) => [node.id, node])));
|
||||
function onContext(event: MouseEvent, node: GraphNode) {
|
||||
if (!onMenu) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onMenu(node, event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape" && expanded) {
|
||||
event.stopPropagation();
|
||||
expanded = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpanded() {
|
||||
expanded = !expanded;
|
||||
touched = false;
|
||||
settling = SETTLE_FRAMES / 3;
|
||||
}
|
||||
|
||||
const RADIUS: Record<string, number> = {
|
||||
closed: 3.5,
|
||||
ghost: 2.5,
|
||||
quiet: 4.5,
|
||||
running: 6.5,
|
||||
waiting: 6,
|
||||
closed: 4,
|
||||
ghost: 3,
|
||||
quiet: 5,
|
||||
running: 7,
|
||||
waiting: 6.5,
|
||||
};
|
||||
const FILL: Record<string, string> = {
|
||||
branch: "var(--color-kind-branch)",
|
||||
@@ -128,28 +263,44 @@
|
||||
master: "var(--color-kind-master)",
|
||||
};
|
||||
|
||||
function radius(node: Placed): number {
|
||||
function radius(node: GraphNode): number {
|
||||
const base = RADIUS[node.state] ?? 4;
|
||||
return node.ring === 0 ? base + 4 : base;
|
||||
return node.ring === 0 ? base + 5 : base;
|
||||
}
|
||||
|
||||
function strokeOf(node: Placed): string {
|
||||
function strokeOf(node: GraphNode): string {
|
||||
if (node.state === "ghost") {
|
||||
return "var(--muted-foreground)";
|
||||
}
|
||||
return node.state === "waiting" ? "var(--attention)" : "none";
|
||||
}
|
||||
|
||||
function fillOf(node: Placed): string {
|
||||
function fillOf(node: GraphNode): string {
|
||||
return node.state === "ghost"
|
||||
? "none"
|
||||
? "var(--background)"
|
||||
: (FILL[node.kind] ?? "var(--foreground)");
|
||||
}
|
||||
|
||||
function textOf(node: Placed): string {
|
||||
return node.state === "ghost" || node.state === "closed"
|
||||
? "var(--muted-foreground)"
|
||||
: "var(--foreground)";
|
||||
function near(id: string): boolean {
|
||||
return (
|
||||
hovered === id ||
|
||||
(hovered !== null && Boolean(neighbours.get(hovered)?.has(id)))
|
||||
);
|
||||
}
|
||||
|
||||
function labelled(node: GraphNode): boolean {
|
||||
if (hovered) {
|
||||
return near(node.id);
|
||||
}
|
||||
return node.ring <= 1 || k >= LABEL_ZOOM;
|
||||
}
|
||||
|
||||
function dimmed(id: string): boolean {
|
||||
return hovered !== null && !near(id);
|
||||
}
|
||||
|
||||
function edgeDimmed(edge: GraphEdge): boolean {
|
||||
return hovered !== null && edge.from !== hovered && edge.to !== hovered;
|
||||
}
|
||||
|
||||
function ghostEdge(edge: GraphEdge): boolean {
|
||||
@@ -160,76 +311,155 @@
|
||||
}
|
||||
</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}
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
<svg
|
||||
aria-label="what the agent is touching"
|
||||
class={cn("h-auto w-full select-none", className)}
|
||||
role="img"
|
||||
viewBox="0 0 {SIZE} {SIZE}"
|
||||
<div
|
||||
class={cn(
|
||||
"graph-frame relative overflow-hidden",
|
||||
expanded
|
||||
? "fixed inset-3 z-50 animate-island-in rounded-2xl border bg-background shadow-float sm:inset-6"
|
||||
: cn("h-full w-full", className)
|
||||
)}
|
||||
bind:clientHeight={height}
|
||||
bind:clientWidth={width}
|
||||
>
|
||||
<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>
|
||||
<!-- biome-ignore lint/a11y/noNoninteractiveElementInteractions: the canvas pans and zooms; the nodes inside are links -->
|
||||
<svg
|
||||
aria-label="what the agent is touching"
|
||||
class="block h-full w-full cursor-grab touch-none select-none active:cursor-grabbing"
|
||||
ondblclick={refit}
|
||||
onpointercancel={onUp}
|
||||
onpointerdown={(event) => onDown(event, null)}
|
||||
onpointermove={onMove}
|
||||
onpointerup={onUp}
|
||||
onwheel={onWheel}
|
||||
role="img"
|
||||
bind:this={svg}
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="graph-glow">
|
||||
<stop offset="0%" stop-color="var(--signal)" stop-opacity="0.4" />
|
||||
<stop offset="100%" stop-color="var(--signal)" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<g transform="translate({width / 2 + tx} {height / 2 + ty}) scale({k})">
|
||||
{#each edges as edge (edge.from + edge.to)}
|
||||
{@const a = force.body(edge.from)}
|
||||
{@const b = force.body(edge.to)}
|
||||
{#if a && b && frame >= 0}
|
||||
<line
|
||||
stroke="var(--foreground)"
|
||||
stroke-dasharray={ghostEdge(edge) ? "2 4" : undefined}
|
||||
stroke-opacity={edgeDimmed(edge) ? 0.05 : 0.16}
|
||||
stroke-width={1 / Math.sqrt(k)}
|
||||
x1={a.x}
|
||||
x2={b.x}
|
||||
y1={a.y}
|
||||
y2={b.y}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each placed as item (item.body.id)}
|
||||
{#if item.node}
|
||||
{@const node = item.node}
|
||||
{@const r = radius(node)}
|
||||
{@const faded = dimmed(node.id)}
|
||||
<a
|
||||
class="cursor-pointer outline-none"
|
||||
href={node.href ?? `#${node.id}`}
|
||||
onclick={(event) => {
|
||||
event.preventDefault();
|
||||
if (event.detail === 0) {
|
||||
onOpen?.(node.id);
|
||||
}
|
||||
}}
|
||||
oncontextmenu={(event) => onContext(event, node)}
|
||||
onpointerdown={(event) => onDown(event, item.body)}
|
||||
onpointerenter={() => {
|
||||
hovered = node.id;
|
||||
}}
|
||||
onpointerleave={() => {
|
||||
hovered = null;
|
||||
}}
|
||||
style="opacity: {faded ? 0.25 : 1}; transition: opacity 150ms"
|
||||
>
|
||||
{#if node.state === "running"}
|
||||
<circle
|
||||
cx={item.body.x}
|
||||
cy={item.body.y}
|
||||
fill="url(#graph-glow)"
|
||||
r={r * 4}
|
||||
/>
|
||||
{/if}
|
||||
<circle
|
||||
cx={item.body.x}
|
||||
cy={item.body.y}
|
||||
fill="transparent"
|
||||
r={r + 8}
|
||||
/>
|
||||
<circle
|
||||
cx={item.body.x}
|
||||
cy={item.body.y}
|
||||
fill={fillOf(node)}
|
||||
fill-opacity={node.state === "closed" ? 0.4 : 1}
|
||||
{r}
|
||||
stroke={hovered === node.id ? "var(--ring)" : strokeOf(node)}
|
||||
stroke-opacity={node.state === "ghost" ? 0.6 : 1}
|
||||
stroke-width={node.state === "waiting" ? 2.5 : 1.25}
|
||||
/>
|
||||
{#if labelled(node)}
|
||||
<text
|
||||
dominant-baseline="hanging"
|
||||
fill={node.state === "ghost" || node.state === "closed"
|
||||
? "var(--muted-foreground)"
|
||||
: "var(--foreground)"}
|
||||
font-size={FONT}
|
||||
font-weight={node.ring === 0 || hovered === node.id ? 600 : 400}
|
||||
paint-order="stroke"
|
||||
stroke="var(--background)"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="3"
|
||||
text-anchor="middle"
|
||||
x={item.body.x}
|
||||
y={item.body.y + r + 3}
|
||||
>
|
||||
{clip(node.label, near(node.id) ? LABEL_MAX_NEAR : LABEL_MAX)}
|
||||
</text>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
</svg>
|
||||
<div class="absolute top-2 right-2 flex items-center gap-1">
|
||||
<button
|
||||
aria-label="Fit the graph"
|
||||
class="rule-word inline-flex size-7 items-center justify-center rounded-md bg-background/70 backdrop-blur"
|
||||
onclick={refit}
|
||||
title="Fit (double-click the canvas)"
|
||||
type="button"
|
||||
>
|
||||
<ScanIcon class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
aria-label={expanded ? "Shrink the graph" : "Expand the graph"}
|
||||
class="rule-word inline-flex size-7 items-center justify-center rounded-md bg-background/70 backdrop-blur"
|
||||
onclick={toggleExpanded}
|
||||
title={expanded ? "Shrink (Esc)" : "Expand"}
|
||||
type="button"
|
||||
>
|
||||
{#if expanded}
|
||||
<Minimize2Icon class="size-3.5" />
|
||||
{:else}
|
||||
<Maximize2Icon class="size-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if nodes.length === 0}
|
||||
<p
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center text-muted-foreground text-xs"
|
||||
>
|
||||
Nothing reached yet.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { LimitWindow } from "$lib/api/types";
|
||||
import { fmtCountdown, fmtMoney, fmtRelative, fmtTokens } from "$lib/format";
|
||||
import { limitLabel } from "$lib/limits";
|
||||
import { limitLabel, limitWord } from "$lib/limits";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
let {
|
||||
@@ -28,10 +28,12 @@
|
||||
const contextPct = $derived(
|
||||
Math.min(100, Math.round((contextTokens / contextWindow) * 100))
|
||||
);
|
||||
const known = (w: LimitWindow) =>
|
||||
w.utilization !== null && w.utilization !== undefined;
|
||||
// The figure shown: the report's own, else the last one inside the window.
|
||||
const figure = (w: LimitWindow) => w.utilization ?? w.last_known?.utilization;
|
||||
const pct = (w: LimitWindow) =>
|
||||
Math.min(100, Math.round((w.utilization ?? 0) * 100));
|
||||
Math.min(100, Math.round((figure(w) ?? 0) * 100));
|
||||
const bound = (w: LimitWindow) =>
|
||||
w.utilization === null || w.utilization === undefined ? "≥ " : "";
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -65,16 +67,17 @@
|
||||
</span>
|
||||
</div>
|
||||
{#each windows as w (w.window)}
|
||||
{@const known = figure(w) !== null && figure(w) !== undefined}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="label-quiet">{limitLabel(w.window)}</span>
|
||||
{#if known(w)}
|
||||
{#if known}
|
||||
<span
|
||||
class={cn(
|
||||
"tabular font-semibold text-2xl leading-none tracking-tight",
|
||||
w.status === "rejected" && "text-destructive"
|
||||
)}
|
||||
>
|
||||
{pct(w)}
|
||||
{bound(w)}{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 ", "")}
|
||||
@@ -97,11 +100,23 @@
|
||||
></span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-sm leading-none">
|
||||
no report
|
||||
<span
|
||||
class={cn(
|
||||
"font-semibold text-2xl leading-none tracking-tight",
|
||||
w.status === "rejected" && "text-destructive"
|
||||
)}
|
||||
>
|
||||
{limitWord(w.status)}
|
||||
<span class="font-normal text-muted-foreground text-xs">
|
||||
{fmtCountdown(w.resets_at, now).replace("resets ", "")}
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
last seen {fmtRelative(w.ts, now)}
|
||||
<span class="tabular text-muted-foreground text-xs">
|
||||
{fmtTokens(
|
||||
w.gateway.input + w.gateway.output + w.gateway.cache_creation
|
||||
)}
|
||||
through here · {fmtMoney(w.gateway.cost_usd)} · seen
|
||||
{fmtRelative(w.ts, now)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
<script lang="ts" module>
|
||||
export const KIND_LETTER: Record<string, string> = {
|
||||
branch: "B",
|
||||
deep: "D",
|
||||
fork: "F",
|
||||
job: "J",
|
||||
master: "M",
|
||||
};
|
||||
// What the letter stands for, for the tooltip and the legend.
|
||||
export const KIND_MEANING: Record<string, string> = {
|
||||
branch: "branch — a side thread off the master, merged back when done",
|
||||
deep: "deep chat — a long thread of its own, closed with a digest",
|
||||
fork: "fork — the agent's own copy of a thread, used to write a digest",
|
||||
job: "job run — a scheduled or triggered task, no human in it",
|
||||
master: "master — the day's main thread with the dispatcher",
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Kind } from "$lib/api/types";
|
||||
import { cn } from "$lib/utils";
|
||||
@@ -5,13 +23,6 @@
|
||||
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",
|
||||
@@ -29,7 +40,7 @@
|
||||
className
|
||||
)}
|
||||
role="img"
|
||||
title={kind}
|
||||
title={KIND_MEANING[kind] ?? kind}
|
||||
>
|
||||
{LETTER[kind] ?? "?"}
|
||||
{KIND_LETTER[kind] ?? "?"}
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import type { ConversationSummary } from "$lib/api/types";
|
||||
import { clip, fmtRelative, fmtTokens, shortId } from "$lib/format";
|
||||
import ConversationMenu from "$lib/panel/conversation-menu.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
import KindMark from "./kind-mark.svelte";
|
||||
import { type StripState, stateOf } from "./state";
|
||||
@@ -17,6 +19,8 @@
|
||||
class: className = "",
|
||||
children,
|
||||
onclick,
|
||||
client = null,
|
||||
onChanged,
|
||||
}: {
|
||||
row: ConversationSummary;
|
||||
href?: string;
|
||||
@@ -28,6 +32,10 @@
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
onclick?: (id: string) => void;
|
||||
// With a client the strip answers to the right button (and a long
|
||||
// press) with the conversation's actions.
|
||||
client?: ApiClient | null;
|
||||
onChanged?: () => void;
|
||||
} = $props();
|
||||
|
||||
const TITLE_MAX = 72;
|
||||
@@ -39,39 +47,84 @@
|
||||
);
|
||||
const when = $derived(row.last_activity_at ?? row.created_at ?? null);
|
||||
const tag = $derived(href ? "a" : "button");
|
||||
|
||||
function activate(event: MouseEvent) {
|
||||
if (!onclick) {
|
||||
return;
|
||||
}
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
onclick(row.id);
|
||||
}
|
||||
|
||||
function follow(id: string) {
|
||||
if (onclick) {
|
||||
onclick(id);
|
||||
} else if (href) {
|
||||
// Shared code cannot reach the router; a real anchor click can.
|
||||
const link = document.createElement("a");
|
||||
link.href = href.replace(row.id, id);
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
}
|
||||
</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"
|
||||
{#snippet body(extra: Record<string, unknown>)}
|
||||
<svelte:element
|
||||
{...extra}
|
||||
aria-current={current ? "true" : undefined}
|
||||
class={cn("strip text-left text-sm", className, extra.class as string)}
|
||||
data-row={row.id}
|
||||
data-state={state}
|
||||
href={href ?? undefined}
|
||||
onclick={activate}
|
||||
role={href ? undefined : "button"}
|
||||
tabindex="0"
|
||||
this={tag}
|
||||
type={href ? undefined : "button"}
|
||||
>
|
||||
{#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>
|
||||
<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>
|
||||
{/snippet}
|
||||
|
||||
{#if client}
|
||||
<ConversationMenu
|
||||
{client}
|
||||
{current}
|
||||
id={row.id}
|
||||
mode="context"
|
||||
onChanged={() => onChanged?.()}
|
||||
onOpen={follow}
|
||||
>
|
||||
{#snippet trigger(props)}
|
||||
{@render body(props)}
|
||||
{/snippet}
|
||||
</ConversationMenu>
|
||||
{:else}
|
||||
{@render body({})}
|
||||
{/if}
|
||||
{#if children && open}
|
||||
<div class="strip-open">{@render children()}</div>
|
||||
{/if}
|
||||
|
||||
@@ -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