From d41f18504c37d7d6dd980f8224cf9bed3d35beb8 Mon Sep 17 00:00:00 2001 From: h Date: Wed, 2 Sep 2026 04:24:15 +0200 Subject: [PATCH] feat(ui): interactive graph, strip context menus, days by activity, message times, limit status words --- src/beaver_gateway/backends/transcript.py | 18 +- src/beaver_gateway/conversations/context.py | 63 ++- src/beaver_gateway/conversations/rows.py | 4 +- src/beaver_gateway/frontends/api/frontend.py | 20 +- tests/test_context.py | 40 +- ui/src/lib/api/types.ts | 4 + ui/src/lib/gateway.svelte.ts | 25 +- ui/src/lib/limits.ts | 11 + ui/src/lib/nav.ts | 15 +- ui/src/lib/panel/chat-view.svelte | 87 ++- ui/src/lib/panel/context-view.svelte | 2 +- ui/src/lib/panel/conversation-menu.svelte | 18 +- ui/src/lib/rail/days.ts | 67 ++- ui/src/lib/rail/rail.svelte | 80 ++- ui/src/lib/shell/board.svelte | 357 +++++++++--- ui/src/lib/shell/float-menu.svelte | 130 +++++ ui/src/lib/shell/force.ts | 205 +++++++ ui/src/lib/shell/graph.svelte | 538 +++++++++++++------ ui/src/lib/shell/instruments.svelte | 35 +- ui/src/lib/shell/kind-mark.svelte | 29 +- ui/src/lib/shell/strip.svelte | 113 ++-- ui/src/routes/conversations/+page.svelte | 35 +- ui/src/routes/layout.css | 1 - ui/src/routes/memory/+page.svelte | 29 + ui/src/routes/system/+layout.svelte | 2 +- ui/src/routes/system/jobs/+page.svelte | 11 +- ui/src/routes/usage/+page.svelte | 61 ++- 27 files changed, 1619 insertions(+), 381 deletions(-) create mode 100644 ui/src/lib/shell/float-menu.svelte create mode 100644 ui/src/lib/shell/force.ts diff --git a/src/beaver_gateway/backends/transcript.py b/src/beaver_gateway/backends/transcript.py index 125bda6..ee51117 100644 --- a/src/beaver_gateway/backends/transcript.py +++ b/src/beaver_gateway/backends/transcript.py @@ -161,7 +161,10 @@ def build_entries( return entries -def messages_from_entries(entries: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]: +def messages_from_entries( + entries: Iterable[Mapping[str, Any]], *, with_ts: bool = False +) -> list[dict[str, Any]]: + """Messages as the model saw them; ``with_ts`` adds when each one was written.""" out: list[dict[str, Any]] = [] last_message_id: str | None = None for entry in entries: @@ -180,7 +183,13 @@ def messages_from_entries(entries: Iterable[Mapping[str, Any]]) -> list[dict[str ): out[-1]["content"].extend(results) else: - out.append({"role": "user", "content": _user_content(content)}) + message_out: dict[str, Any] = { + "role": "user", + "content": _user_content(content), + } + if with_ts: + message_out["ts"] = entry.get("timestamp") + out.append(message_out) last_message_id = None continue blocks = _assistant_blocks(content) @@ -193,7 +202,10 @@ def messages_from_entries(entries: Iterable[Mapping[str, Any]]) -> list[dict[str ): out[-1]["content"].extend(blocks) else: - out.append({"role": "assistant", "content": blocks}) + message_out: dict[str, Any] = {"role": "assistant", "content": blocks} + if with_ts: + message_out["ts"] = entry.get("timestamp") + out.append(message_out) last_message_id = message_id return out diff --git a/src/beaver_gateway/conversations/context.py b/src/beaver_gateway/conversations/context.py index aa2458f..be782f6 100644 --- a/src/beaver_gateway/conversations/context.py +++ b/src/beaver_gateway/conversations/context.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from beaver_gateway.agents.claude import ClaudeAgent from beaver_gateway.conversations.kinds import Kind -__all__ = ["compose", "files_touched", "granules", "skill_sets"] +__all__ = ["bash_paths", "compose", "files_touched", "granules", "skill_sets"] CHARS_PER_TOKEN = 3.2 READ_TOOLS = frozenset({"Read", "NotebookRead"}) @@ -21,6 +21,40 @@ WRITE_TOOLS = frozenset({"Write", "Edit", "MultiEdit", "NotebookEdit"}) SEARCH_TOOLS = frozenset({"Glob", "Grep"}) PATH_KEYS = ("file_path", "notebook_path", "path", "file", "filename") _FRONTMATTER = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL) +# A shell command names files as quoted strings or bare words; only notes +# and plain text files count, code and binaries are the agent's own business. +_SHELL_TOKEN = re.compile(r'"([^"\n]+)"|\'([^\'\n]+)\'|([^\s"\'`;|&()<>]+)') +_NOTE_SUFFIXES = (".md", ".canvas", ".txt", ".csv", ".json", ".yaml", ".yml") +_SHELL_CD = re.compile(r'(?:^|&&|;|\|\|)\s*cd\s+("([^"]+)"|\'([^\']+)\'|(\S+))') +_SHELL_WRITES = re.compile( + r"write_text|write_bytes|>>?\s*[\"\']?[^\s&|]|\btee\b|\bsed\s+-i|\bmv\b|\brm\b" + r"|\btouch\b|\bcp\b|open\([^)]*[\"\'][wa]" +) + + +def bash_paths(command: str, cwd: Path | None) -> tuple[list[str], Path | None, bool]: + """Files a shell command names, where it leaves the shell, whether it writes. + + Relative names resolve against ``cwd``, or against the directory of a + leading ``cd`` in the same command. Absolute names stay as they are. + """ + here = cwd + for match in _SHELL_CD.finditer(command): + target = match.group(2) or match.group(3) or match.group(4) or "" + target = target.strip() + if not target: + continue + here = Path(target) if target.startswith("/") else (here or Path()) / target + found: list[str] = [] + for match in _SHELL_TOKEN.finditer(command): + raw = (match.group(1) or match.group(2) or match.group(3) or "").strip() + if not raw.lower().endswith(_NOTE_SUFFIXES) or "*" in raw: + continue + path = Path(raw) if raw.startswith("/") else (here or Path()) / raw + text = str(path) + if text not in found: + found.append(text) + return found, here, bool(_SHELL_WRITES.search(command)) def _tokens(chars: int) -> int: @@ -56,27 +90,38 @@ def files_touched( """Files named in tool inputs, with how they were touched, plus tool counts.""" files: dict[str, dict[str, Any]] = {} counts: Counter[str] = Counter() + # The shell keeps its directory between calls; follow it. + shell_cwd = cwd + + def touch(raw: str, how: str, stamp: str) -> None: + key = _relative(raw, cwd) + row = files.setdefault( + key, {"path": key, "reads": 0, "writes": 0, "other": 0, "last_at": stamp} + ) + row[how] += 1 + row["last_at"] = max(row["last_at"], stamp) + for name, tool_input, stamp in _tool_uses(entries): counts[name] += 1 if not isinstance(tool_input, dict): continue + if name == "Bash" and isinstance(tool_input.get("command"), str): + paths, shell_cwd, writes = bash_paths(tool_input["command"], shell_cwd) + for raw in paths: + touch(raw, "writes" if writes else "reads", stamp) + continue raw = next( (v for k in PATH_KEYS if isinstance(v := tool_input.get(k), str) and v), None, ) if raw is None or (name in SEARCH_TOOLS and "file_path" not in tool_input): continue - key = _relative(raw, cwd) - row = files.setdefault( - key, {"path": key, "reads": 0, "writes": 0, "other": 0, "last_at": stamp} - ) if name in READ_TOOLS: - row["reads"] += 1 + touch(raw, "reads", stamp) elif name in WRITE_TOOLS: - row["writes"] += 1 + touch(raw, "writes", stamp) else: - row["other"] += 1 - row["last_at"] = max(row["last_at"], stamp) + touch(raw, "other", stamp) ordered = sorted(files.values(), key=lambda r: r["last_at"], reverse=True) return ordered, dict(counts) diff --git a/src/beaver_gateway/conversations/rows.py b/src/beaver_gateway/conversations/rows.py index 7f3e845..109e5b3 100644 --- a/src/beaver_gateway/conversations/rows.py +++ b/src/beaver_gateway/conversations/rows.py @@ -474,7 +474,9 @@ class Rows(State): return await load_messages( session, conversation_id=cast("int", conv.id) ) - return messages_from_entries(cast("Any", await self.entries(conv))) + return messages_from_entries( + cast("Any", await self.entries(conv)), with_ts=True + ) async def entries(self, conv: Conversation, *, subpath: str = "") -> list[Any]: if conv.session_id is None: diff --git a/src/beaver_gateway/frontends/api/frontend.py b/src/beaver_gateway/frontends/api/frontend.py index c271f52..f7755b5 100644 --- a/src/beaver_gateway/frontends/api/frontend.py +++ b/src/beaver_gateway/frontends/api/frontend.py @@ -750,7 +750,25 @@ def build_app( # noqa: PLR0915 ) gateway = _sum_usage(await _usage_rows(runtime, since, now)) gateway["since"] = since.isoformat(timespec="seconds") - windows.append({**_limit_public(row), "gateway": gateway}) + # The API sends a figure only when a window nears its limit; + # the last one inside this window is the best lower bound. + known = next( + ( + r + for r in rows + if r.window == window + and r.utilization is not None + and _aware(r.ts) >= since + ), + None, + ) + windows.append( + { + **_limit_public(row), + "gateway": gateway, + "last_known": _limit_public(known) if known else None, + } + ) windows.sort( key=lambda w: (WINDOWS.get(w["window"], timedelta.max), w["window"]) ) diff --git a/tests/test_context.py b/tests/test_context.py index fc3684c..dcaaf9c 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -2,7 +2,12 @@ import tempfile from pathlib import Path from beaver_gateway.agents.claude import ClaudeAgent, Prompts -from beaver_gateway.conversations.context import compose, files_touched, granules +from beaver_gateway.conversations.context import ( + bash_paths, + compose, + files_touched, + granules, +) from beaver_gateway.vault.links import LinkIndex @@ -39,6 +44,39 @@ def test_files_touched_groups_by_path_relative_to_cwd() -> None: assert counts == {"Read": 2, "Edit": 1, "Grep": 1, "Bash": 1} +def test_bash_paths_follow_cd_and_quotes() -> None: + paths, here, writes = bash_paths( + "cd /vault/мета && cat 'заметка.md' | head; sed -n 1,5p \"📆 доски/работа.md\"", + Path("/elsewhere"), + ) + assert paths == ["/vault/мета/заметка.md", "/vault/мета/📆 доски/работа.md"] + assert here == Path("/vault/мета") + assert writes is False + _, _, writes = bash_paths( + 'python3 - < None: + cwd = Path("/vault") + files, counts = files_touched( + [ + entry("Bash", {"command": "cd /vault/мета && cat a.md"}, "1"), + entry("Bash", {"command": "python3 - < None: root = Path(tempfile.mkdtemp(prefix="beaver-ctx-")) (root / "voice.md").write_text("be brief " * 40) diff --git a/ui/src/lib/api/types.ts b/ui/src/lib/api/types.ts index aaa7a90..253006f 100644 --- a/ui/src/lib/api/types.ts +++ b/ui/src/lib/api/types.ts @@ -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 { diff --git a/ui/src/lib/gateway.svelte.ts b/ui/src/lib/gateway.svelte.ts index d003e77..8c65902 100644 --- a/ui/src/lib/gateway.svelte.ts +++ b/ui/src/lib/gateway.svelte.ts @@ -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([]); limits = $state(null); + // The scheduler snapshot: what was last seen, shown at once, then refreshed. + jobs = $state(cache.get(JOBS_KEY) ?? null); constructor() { super(() => session.client); } + async refreshJobs(): Promise { + const client = this.client(); + if (!client) { + return; + } + this.jobs = await client.jobs(); + cache.set(JOBS_KEY, this.jobs); + } + override async load(): Promise { 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 { @@ -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); } diff --git a/ui/src/lib/limits.ts b/ui/src/lib/limits.ts index f640ad4..926f3e6 100644 --- a/ui/src/lib/limits.ts +++ b/ui/src/lib/limits.ts @@ -9,3 +9,14 @@ export const LIMIT_LABELS: Record = { 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 = { + allowed: "clear", + allowed_warning: "near the limit", + rejected: "rejected", +}; + +export function limitWord(status: string): string { + return LIMIT_WORDS[status] ?? status.replace("_", " "); +} diff --git a/ui/src/lib/nav.ts b/ui/src/lib/nav.ts index c0ed949..e488b02 100644 --- a/ui/src/lib/nav.ts +++ b/ui/src/lib/nav.ts @@ -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}/`); } diff --git a/ui/src/lib/panel/chat-view.svelte b/ui/src/lib/panel/chat-view.svelte index 707c16d..2e12074 100644 --- a/ui/src/lib/panel/chat-view.svelte +++ b/ui/src/lib/panel/chat-view.svelte @@ -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(null); let body = $state(null); + let end = $state(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>(new Set()); function systemText(message: HistoryMessage): string | null { @@ -175,13 +211,15 @@
{ - pinned = nearBottom(); + if (Date.now() > settleUntil) { + pinned = nearBottom(); + } }} bind:this={scroller} > -
+
{:else if !(isResultOnly && !showResults)}
+ {#if message.ts && newMinute && !isResultOnly} + + {minuteOf(message.ts)} + + {/if} {#each parts as block, blockIndex (blockIndex)} {#if block.type === "text" && block.text} {:else if block.type === "tool_use"} -

- + {toolLabel(block.name ?? "?")} - + {clip(summarizeInput(block.name ?? "", block.input), 160)}

@@ -271,5 +331,6 @@ {#each tail as turn (turn.id)} {/each} +
diff --git a/ui/src/lib/panel/context-view.svelte b/ui/src/lib/panel/context-view.svelte index 93c3903..0014013 100644 --- a/ui/src/lib/panel/context-view.svelte +++ b/ui/src/lib/panel/context-view.svelte @@ -338,7 +338,7 @@ >
{#if graph && graph.nodes.length > 0} -
+

diff --git a/ui/src/lib/panel/conversation-menu.svelte b/ui/src/lib/panel/conversation-menu.svelte index 6102613..c8c343b 100644 --- a/ui/src/lib/panel/conversation-menu.svelte +++ b/ui/src/lib/panel/conversation-menu.svelte @@ -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]>; } = $props(); const host = usePanelHost(); @@ -93,9 +97,17 @@ {#if mode === "context"} - - {@render children?.()} - + {#if trigger} + + {#snippet child({ props })} + {@render trigger(props)} + {/snippet} + + {:else} + + {@render children?.()} + + {/if} {@render body(ContextMenu)} diff --git a/ui/src/lib/rail/days.ts b/ui/src/lib/rail/days.ts index a43a829..d6fc534 100644 --- a/ui/src/lib/rail/days.ts +++ b/ui/src/lib/rail/days.ts @@ -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(); - const dayOfMaster = new Map(); 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(" · "); +} diff --git a/ui/src/lib/rail/rail.svelte b/ui/src/lib/rail/rail.svelte index ed2dbd7..3d251f0 100644 --- a/ui/src/lib/rail/rail.svelte +++ b/ui/src/lib/rail/rail.svelte @@ -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>(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); @@ -188,10 +185,12 @@

{#each found as row (row.id)} @@ -254,17 +253,19 @@ > {/if} - {summary(group)} + {daySummary(group, position === 0)} {#if shown}
{#if group.master} @@ -272,34 +273,73 @@ {#each group.branches as row (row.id)} {/each} {#each group.deep as row (row.id)} {/each} - {#each group.others as row (row.id)} + {#each [...group.forks, ...group.others] as row (row.id)} {/each} + {#if group.jobs.length > 0} + {@const shownJobs = jobsOpen.has(group.day)} + + {#if shownJobs} + {#each group.jobs as row (row.id)} + + {/each} + {/if} + {/if}
{/if} diff --git a/ui/src/lib/shell/board.svelte b/ui/src/lib/shell/board.svelte index 4b4c040..1a17939 100644 --- a/ui/src/lib/shell/board.svelte +++ b/ui/src/lib/shell/board.svelte @@ -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(null); let vault = $state(null); let vaultFor = $state(null); let quietOpen = $state(false); - let tapeOpen = $state(false); + let menuOpen = $state(false); + let menuAt = $state({ x: 0, y: 0 }); + let menuItems = $state([]); 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); + +
@@ -235,9 +408,12 @@ {#each board.waiting as row (row.id)} {@const info = board.snapshots[row.id]} @@ -277,6 +456,49 @@ {/if} + {#if coming.length > 0} + + {#snippet aside()} + scheduler + {/snippet} + {#each coming as item (item.id)} + comingMenu(event, item)} + title="right-click for actions" + > + {#if item.kind === "job"} + + {:else} + + ↳ + + {/if} + + {item.label} + + {clip(item.detail, PAYLOAD_MAX)} + + + + {fmtCountdown(item.at, now).replace("resets ", "")} + + + {/each} + + {/if} + {/each} {/if} - - {#if !compact} -
- - {#if tapeOpen} -
    - {#each tape as item (item.seq)} -
  • - - {fmtTime(item.ts)} - - {item.type} - - {#if item.conversation_id} - - {shortId(item.conversation_id)} - - {/if} - {#if typeof item.name === "string"} - {item.name} - {/if} - {#if typeof item.text === "string"} - {item.text.slice(0, 80)} - {/if} - -
  • - {/each} -
- {/if} -
- {/if}
{#if !compact} -
diff --git a/ui/src/lib/shell/float-menu.svelte b/ui/src/lib/shell/float-menu.svelte new file mode 100644 index 0000000..ee4eb27 --- /dev/null +++ b/ui/src/lib/shell/float-menu.svelte @@ -0,0 +1,130 @@ + + + + +{#if open} + +{/if} diff --git a/ui/src/lib/shell/force.ts b/ui/src/lib/shell/force.ts new file mode 100644 index 0000000..945a010 --- /dev/null +++ b/ui/src/lib/shell/force.ts @@ -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(); + 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(); + 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 }; + } +} diff --git a/ui/src/lib/shell/graph.svelte b/ui/src/lib/shell/graph.svelte index cc0fa5e..4443e9c 100644 --- a/ui/src/lib/shell/graph.svelte +++ b/ui/src/lib/shell/graph.svelte @@ -1,8 +1,4 @@ - + + -{#snippet dot(node: Placed)} - {@const r = radius(node)} - {#if node.state === "running"} - - {/if} - - - {clip(node.label, LABEL_MAX)} - -{/snippet} + - - - - - - - - {#each edges as edge (edge.from + edge.to)} - {@const a = byId.get(edge.from)} - {@const b = byId.get(edge.to)} - {#if a && b} - - {/if} - {/each} - {#each placed as node (node.id)} - {#if node.href} - { - event.preventDefault(); - onOpen?.(node.id); - }} - > - {@render dot(node)} - - {:else} - {@render dot(node)} - {/if} - {/each} - + + onDown(event, null)} + onpointermove={onMove} + onpointerup={onUp} + onwheel={onWheel} + role="img" + bind:this={svg} + > + + + + + + + + {#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} + + {/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)} + { + 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"} + + {/if} + + + {#if labelled(node)} + + {clip(node.label, near(node.id) ? LABEL_MAX_NEAR : LABEL_MAX)} + + {/if} + + {/if} + {/each} + + +
+ + +
+ {#if nodes.length === 0} +

+ Nothing reached yet. +

+ {/if} +
diff --git a/ui/src/lib/shell/instruments.svelte b/ui/src/lib/shell/instruments.svelte index fbfba36..6b79e37 100644 --- a/ui/src/lib/shell/instruments.svelte +++ b/ui/src/lib/shell/instruments.svelte @@ -1,7 +1,7 @@
{#each windows as w (w.window)} + {@const known = figure(w) !== null && figure(w) !== undefined}
{limitLabel(w.window)} - {#if known(w)} + {#if known} - {pct(w)} + {bound(w)}{pct(w)} % {fmtCountdown(w.resets_at, now).replace("resets ", "")} @@ -97,11 +100,23 @@ > {:else} - - no report + + {limitWord(w.status)} + + {fmtCountdown(w.resets_at, now).replace("resets ", "")} + - - last seen {fmtRelative(w.ts, now)} + + {fmtTokens( + w.gateway.input + w.gateway.output + w.gateway.cache_creation + )} + through here · {fmtMoney(w.gateway.cost_usd)} · seen + {fmtRelative(w.ts, now)} {/if}
diff --git a/ui/src/lib/shell/kind-mark.svelte b/ui/src/lib/shell/kind-mark.svelte index ae96bb9..8b47724 100644 --- a/ui/src/lib/shell/kind-mark.svelte +++ b/ui/src/lib/shell/kind-mark.svelte @@ -1,3 +1,21 @@ + + - onclick?.(row.id) : undefined} - role={href ? undefined : "button"} - this={tag} - type={href ? undefined : "button"} -> - - - {title} - {#if detail} - - {clip(detail, DETAIL_MAX)} - - {/if} - - )} + - {#if row.context_tokens} - {fmtTokens(row.context_tokens)} - {/if} - {fmtRelative(when, now)} - - + + + {title} + {#if detail} + + {clip(detail, DETAIL_MAX)} + + {/if} + + + {#if row.context_tokens} + {fmtTokens(row.context_tokens)} + {/if} + {fmtRelative(when, now)} + + +{/snippet} + +{#if client} + onChanged?.()} + onOpen={follow} + > + {#snippet trigger(props)} + {@render body(props)} + {/snippet} + +{:else} + {@render body({})} +{/if} {#if children && open}
{@render children()}
{/if} diff --git a/ui/src/routes/conversations/+page.svelte b/ui/src/routes/conversations/+page.svelte index f5b73b7..bbcf009 100644 --- a/ui/src/routes/conversations/+page.svelte +++ b/ui/src/routes/conversations/+page.svelte @@ -1,14 +1,35 @@ -
-

Pick a strip on the left.

- {#if running > 0} -

{running} in motion right now.

- {/if} +
+

+ Pick a strip on the left. + {#if running > 0} + {running} in motion right now. + {/if} +

+
+ {#each KINDS as kind (kind)} +
+ + {kind} +
+
{meaning(kind)}
+ {/each} +
+

+ 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. +

diff --git a/ui/src/routes/layout.css b/ui/src/routes/layout.css index 0e38f8e..9c604bc 100644 --- a/ui/src/routes/layout.css +++ b/ui/src/routes/layout.css @@ -4,7 +4,6 @@ @import "@fontsource-variable/inter"; @import "../lib/panel/panel.css"; -@plugin "@tailwindcss/forms"; @plugin "@tailwindcss/typography"; @custom-variant dark (&:is(.dark *)); diff --git a/ui/src/routes/memory/+page.svelte b/ui/src/routes/memory/+page.svelte index c66b670..5a7c5df 100644 --- a/ui/src/routes/memory/+page.svelte +++ b/ui/src/routes/memory/+page.svelte @@ -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(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([]); + + 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; + } + + Memory · Beaver
@@ -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" > diff --git a/ui/src/routes/system/+layout.svelte b/ui/src/routes/system/+layout.svelte index f1d41e1..0667ac0 100644 --- a/ui/src/routes/system/+layout.svelte +++ b/ui/src/routes/system/+layout.svelte @@ -17,7 +17,7 @@ >

System

{#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)} (null); let failure = $state(null); let busy = $state(null); let timer: ReturnType | undefined; + const data = $derived(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 @@
{#each runs as row (row.id)} gateway.load().catch(() => undefined)} {row} /> {/each} diff --git a/ui/src/routes/usage/+page.svelte b/ui/src/routes/usage/+page.svelte index 2bd6f7d..76754e3 100644 --- a/ui/src/routes/usage/+page.svelte +++ b/ui/src/routes/usage/+page.svelte @@ -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 = { allowed: "bg-primary", @@ -104,13 +106,14 @@

Subscription

{#if windows.length === 0}

- 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.

{:else}
{#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}
{limitLabel(w.window)} {#if known} @@ -120,7 +123,7 @@ w.status === "rejected" && "text-destructive" )} > - {pct(w.utilization)} + {bound ? "≥ " : ""}{pct(figure)} % @@ -128,38 +131,52 @@ - - {fmtCountdown(w.resets_at, now)} - · gateway share - {fmtTokens( - w.gateway.input + w.gateway.output + w.gateway.cache_creation - )} - tokens · {fmtMoney(w.gateway.cost_usd)} - + {#if bound && w.last_known} + + {limitWord(w.status)} + now · the figure is from + {fmtRelative(w.last_known.ts, now)} + + {/if} {:else} - - no report + + {limitWord(w.status)} - last seen {fmtRelative(w.ts, now)} · - {fmtCountdown( - w.resets_at, - now - )} + no figure · seen {fmtRelative(w.ts, now)} {/if} + + {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)} +
{/each}
+

+ 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. +

{/if}