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

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