feat(ui): interactive graph, strip context menus, days by activity, message times, limit status words
This commit is contained in:
@@ -161,7 +161,10 @@ def build_entries(
|
|||||||
return 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]] = []
|
out: list[dict[str, Any]] = []
|
||||||
last_message_id: str | None = None
|
last_message_id: str | None = None
|
||||||
for entry in entries:
|
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)
|
out[-1]["content"].extend(results)
|
||||||
else:
|
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
|
last_message_id = None
|
||||||
continue
|
continue
|
||||||
blocks = _assistant_blocks(content)
|
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)
|
out[-1]["content"].extend(blocks)
|
||||||
else:
|
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
|
last_message_id = message_id
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
|||||||
from beaver_gateway.agents.claude import ClaudeAgent
|
from beaver_gateway.agents.claude import ClaudeAgent
|
||||||
from beaver_gateway.conversations.kinds import Kind
|
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
|
CHARS_PER_TOKEN = 3.2
|
||||||
READ_TOOLS = frozenset({"Read", "NotebookRead"})
|
READ_TOOLS = frozenset({"Read", "NotebookRead"})
|
||||||
@@ -21,6 +21,40 @@ WRITE_TOOLS = frozenset({"Write", "Edit", "MultiEdit", "NotebookEdit"})
|
|||||||
SEARCH_TOOLS = frozenset({"Glob", "Grep"})
|
SEARCH_TOOLS = frozenset({"Glob", "Grep"})
|
||||||
PATH_KEYS = ("file_path", "notebook_path", "path", "file", "filename")
|
PATH_KEYS = ("file_path", "notebook_path", "path", "file", "filename")
|
||||||
_FRONTMATTER = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL)
|
_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:
|
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 named in tool inputs, with how they were touched, plus tool counts."""
|
||||||
files: dict[str, dict[str, Any]] = {}
|
files: dict[str, dict[str, Any]] = {}
|
||||||
counts: Counter[str] = Counter()
|
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):
|
for name, tool_input, stamp in _tool_uses(entries):
|
||||||
counts[name] += 1
|
counts[name] += 1
|
||||||
if not isinstance(tool_input, dict):
|
if not isinstance(tool_input, dict):
|
||||||
continue
|
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(
|
raw = next(
|
||||||
(v for k in PATH_KEYS if isinstance(v := tool_input.get(k), str) and v),
|
(v for k in PATH_KEYS if isinstance(v := tool_input.get(k), str) and v),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
if raw is None or (name in SEARCH_TOOLS and "file_path" not in tool_input):
|
if raw is None or (name in SEARCH_TOOLS and "file_path" not in tool_input):
|
||||||
continue
|
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:
|
if name in READ_TOOLS:
|
||||||
row["reads"] += 1
|
touch(raw, "reads", stamp)
|
||||||
elif name in WRITE_TOOLS:
|
elif name in WRITE_TOOLS:
|
||||||
row["writes"] += 1
|
touch(raw, "writes", stamp)
|
||||||
else:
|
else:
|
||||||
row["other"] += 1
|
touch(raw, "other", stamp)
|
||||||
row["last_at"] = max(row["last_at"], stamp)
|
|
||||||
ordered = sorted(files.values(), key=lambda r: r["last_at"], reverse=True)
|
ordered = sorted(files.values(), key=lambda r: r["last_at"], reverse=True)
|
||||||
return ordered, dict(counts)
|
return ordered, dict(counts)
|
||||||
|
|
||||||
|
|||||||
@@ -474,7 +474,9 @@ class Rows(State):
|
|||||||
return await load_messages(
|
return await load_messages(
|
||||||
session, conversation_id=cast("int", conv.id)
|
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]:
|
async def entries(self, conv: Conversation, *, subpath: str = "") -> list[Any]:
|
||||||
if conv.session_id is None:
|
if conv.session_id is None:
|
||||||
|
|||||||
@@ -750,7 +750,25 @@ def build_app( # noqa: PLR0915
|
|||||||
)
|
)
|
||||||
gateway = _sum_usage(await _usage_rows(runtime, since, now))
|
gateway = _sum_usage(await _usage_rows(runtime, since, now))
|
||||||
gateway["since"] = since.isoformat(timespec="seconds")
|
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(
|
windows.sort(
|
||||||
key=lambda w: (WINDOWS.get(w["window"], timedelta.max), w["window"])
|
key=lambda w: (WINDOWS.get(w["window"], timedelta.max), w["window"])
|
||||||
)
|
)
|
||||||
|
|||||||
+39
-1
@@ -2,7 +2,12 @@ import tempfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from beaver_gateway.agents.claude import ClaudeAgent, Prompts
|
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
|
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}
|
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:
|
def test_compose_reads_granules_and_skills() -> None:
|
||||||
root = Path(tempfile.mkdtemp(prefix="beaver-ctx-"))
|
root = Path(tempfile.mkdtemp(prefix="beaver-ctx-"))
|
||||||
(root / "voice.md").write_text("be brief " * 40)
|
(root / "voice.md").write_text("be brief " * 40)
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ export interface ContentBlock {
|
|||||||
export interface HistoryMessage {
|
export interface HistoryMessage {
|
||||||
content: string | ContentBlock[];
|
content: string | ContentBlock[];
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
|
// When the first block of this message was written; absent for old rows.
|
||||||
|
ts?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntriesPage {
|
export interface EntriesPage {
|
||||||
@@ -211,6 +213,8 @@ export interface LimitRecord {
|
|||||||
|
|
||||||
export interface LimitWindow extends LimitRecord {
|
export interface LimitWindow extends LimitRecord {
|
||||||
gateway: UsageTotals & { since: string };
|
gateway: UsageTotals & { since: string };
|
||||||
|
// The last figure reported inside this window, if any: a lower bound.
|
||||||
|
last_known: LimitRecord | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LimitsResponse {
|
export interface LimitsResponse {
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import type { BusEvent, LimitsResponse, LimitWindow } from "./api/types";
|
import type {
|
||||||
|
BusEvent,
|
||||||
|
JobsResponse,
|
||||||
|
LimitsResponse,
|
||||||
|
LimitWindow,
|
||||||
|
} from "./api/types";
|
||||||
|
import { panelCache } from "./panel/cache";
|
||||||
import { ConversationIndex } from "./panel/index.svelte";
|
import { ConversationIndex } from "./panel/index.svelte";
|
||||||
import { session } from "./session.svelte";
|
import { session } from "./session.svelte";
|
||||||
|
|
||||||
const TAPE_SIZE = 120;
|
const TAPE_SIZE = 120;
|
||||||
const QUIET = new Set(["stream", "hello"]);
|
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
|
// 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
|
// 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 {
|
class Gateway extends ConversationIndex {
|
||||||
tape = $state<BusEvent[]>([]);
|
tape = $state<BusEvent[]>([]);
|
||||||
limits = $state<LimitsResponse | null>(null);
|
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() {
|
constructor() {
|
||||||
super(() => session.client);
|
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> {
|
override async load(): Promise<void> {
|
||||||
const client = this.client();
|
const client = this.client();
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -28,6 +47,7 @@ class Gateway extends ConversationIndex {
|
|||||||
if (limits) {
|
if (limits) {
|
||||||
this.limits = limits;
|
this.limits = limits;
|
||||||
}
|
}
|
||||||
|
this.refreshJobs().catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshLimits(): Promise<void> {
|
async refreshLimits(): Promise<void> {
|
||||||
@@ -48,6 +68,9 @@ class Gateway extends ConversationIndex {
|
|||||||
this.applyLimit(event);
|
this.applyLimit(event);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (event.type.startsWith("job.") || event.type.startsWith("schedule.")) {
|
||||||
|
this.refreshJobs().catch(() => undefined);
|
||||||
|
}
|
||||||
super.apply(event);
|
super.apply(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,3 +9,14 @@ export const LIMIT_LABELS: Record<string, string> = {
|
|||||||
export function limitLabel(window: string): string {
|
export function limitLabel(window: string): string {
|
||||||
return LIMIT_LABELS[window] ?? window;
|
return LIMIT_LABELS[window] ?? window;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The status as a word, for the windows the API never puts a figure on.
|
||||||
|
export const LIMIT_WORDS: Record<string, string> = {
|
||||||
|
allowed: "clear",
|
||||||
|
allowed_warning: "near the limit",
|
||||||
|
rejected: "rejected",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function limitWord(status: string): string {
|
||||||
|
return LIMIT_WORDS[status] ?? status.replace("_", " ");
|
||||||
|
}
|
||||||
|
|||||||
+11
-4
@@ -1,4 +1,6 @@
|
|||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
|
// Lit only on its own path, not on its children (an index page).
|
||||||
|
exact?: boolean;
|
||||||
href: string;
|
href: string;
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -14,20 +16,25 @@ export const SECTIONS: NavItem[] = [
|
|||||||
export const SYSTEM: NavItem = { href: "/system", key: "5", label: "System" };
|
export const SYSTEM: NavItem = { href: "/system", key: "5", label: "System" };
|
||||||
|
|
||||||
export const SYSTEM_PAGES: NavItem[] = [
|
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/agents", key: "", label: "Agents & endpoints" },
|
||||||
{ href: "/system/jobs", key: "", label: "Jobs" },
|
{ href: "/system/jobs", key: "", label: "Jobs" },
|
||||||
{ href: "/system/tokens", key: "", label: "Tokens" },
|
{ href: "/system/tokens", key: "", label: "Tokens" },
|
||||||
{ href: "/system/audit", key: "", label: "Audit" },
|
{ 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)
|
const path = pathname.startsWith(base)
|
||||||
? pathname.slice(base.length)
|
? pathname.slice(base.length)
|
||||||
: pathname;
|
: pathname;
|
||||||
const current = path || "/";
|
const current = path || "/";
|
||||||
if (href === "/") {
|
if (href === "/" || exact) {
|
||||||
return current === "/";
|
return current === href;
|
||||||
}
|
}
|
||||||
return current === href || current.startsWith(`${href}/`);
|
return current === href || current.startsWith(`${href}/`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import type { ContentBlock, HistoryMessage } from "$lib/api/types";
|
import type { ContentBlock, HistoryMessage } from "$lib/api/types";
|
||||||
import EmptyState from "$lib/components/empty-state.svelte";
|
import EmptyState from "$lib/components/empty-state.svelte";
|
||||||
import ErrorNote from "$lib/components/error-note.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 { cn } from "$lib/utils";
|
||||||
import type { ActivityModel } from "./activity.svelte";
|
import type { ActivityModel } from "./activity.svelte";
|
||||||
import { summarizeInput, toolLabel } from "./activity.svelte";
|
import { summarizeInput, toolLabel } from "./activity.svelte";
|
||||||
@@ -37,6 +37,10 @@
|
|||||||
let loadedAt = $state(new Date(0).toISOString());
|
let loadedAt = $state(new Date(0).toISOString());
|
||||||
let scroller = $state<HTMLDivElement | null>(null);
|
let scroller = $state<HTMLDivElement | null>(null);
|
||||||
let body = $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 now = $state(Date.now());
|
||||||
let pinned = true;
|
let pinned = true;
|
||||||
|
|
||||||
@@ -65,6 +69,7 @@
|
|||||||
try {
|
try {
|
||||||
({ messages } = await client.history(conversationId));
|
({ messages } = await client.history(conversationId));
|
||||||
loadedAt = new Date().toISOString();
|
loadedAt = new Date().toISOString();
|
||||||
|
settle();
|
||||||
host.cache?.set(
|
host.cache?.set(
|
||||||
`history:${conversationId}`,
|
`history:${conversationId}`,
|
||||||
messages.slice(-CACHED_MESSAGES)
|
messages.slice(-CACHED_MESSAGES)
|
||||||
@@ -83,11 +88,40 @@
|
|||||||
return left < NEAR_BOTTOM_PX;
|
return left < NEAR_BOTTOM_PX;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scrollToBottom() {
|
function toBottom() {
|
||||||
await tick();
|
|
||||||
if (scroller) {
|
if (scroller) {
|
||||||
scroller.scrollTop = scroller.scrollHeight;
|
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(() => {
|
$effect(() => {
|
||||||
@@ -129,6 +163,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const SYSTEM_HEAD = /^\[[^\]\n]+\]/;
|
const SYSTEM_HEAD = /^\[[^\]\n]+\]/;
|
||||||
|
const SECONDS = /:\d{2}$/;
|
||||||
|
const DAY_TIME = /,?\s*\d{2}:\d{2}$/;
|
||||||
let openSystem = $state<Set<number>>(new Set());
|
let openSystem = $state<Set<number>>(new Set());
|
||||||
|
|
||||||
function systemText(message: HistoryMessage): string | null {
|
function systemText(message: HistoryMessage): string | null {
|
||||||
@@ -175,13 +211,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<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={() => {
|
onscroll={() => {
|
||||||
pinned = nearBottom();
|
if (Date.now() > settleUntil) {
|
||||||
|
pinned = nearBottom();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
bind:this={scroller}
|
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
|
<button
|
||||||
class="rule-word self-end text-xs"
|
class="rule-word self-end text-xs"
|
||||||
data-active={showResults}
|
data-active={showResults}
|
||||||
@@ -208,31 +246,51 @@
|
|||||||
{@const parts = blocks(message)}
|
{@const parts = blocks(message)}
|
||||||
{@const isResultOnly = parts.every((b) => b.type === "tool_result")}
|
{@const isResultOnly = parts.every((b) => b.type === "tool_result")}
|
||||||
{@const system = systemText(message)}
|
{@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}
|
{#if system}
|
||||||
<button
|
<button
|
||||||
aria-expanded={openSystem.has(index)}
|
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)}
|
onclick={() => toggleSystem(index)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{#if openSystem.has(index)}
|
{#if openSystem.has(index)}
|
||||||
<span class="whitespace-pre-wrap break-words">{system}</span>
|
<span class="whitespace-pre-wrap break-words">{system}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="truncate">{clip(system.split("\n")[0], 120)}</span>
|
<span class="block truncate"
|
||||||
|
>{clip(system.split("\n")[0], 120)}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{:else if !(isResultOnly && !showResults)}
|
{:else if !(isResultOnly && !showResults)}
|
||||||
<div
|
<div
|
||||||
class={cn(
|
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"
|
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)}
|
{#each parts as block, blockIndex (blockIndex)}
|
||||||
{#if block.type === "text" && block.text}
|
{#if block.type === "text" && block.text}
|
||||||
<Markdown
|
<Markdown
|
||||||
class={cn(
|
class={cn(
|
||||||
"max-w-[75ch]",
|
"min-w-0 @md:max-w-[75ch] max-w-full",
|
||||||
message.role === "user"
|
message.role === "user"
|
||||||
? "rounded-2xl rounded-br-md bg-primary/10 px-3.5 py-2"
|
? "rounded-2xl rounded-br-md bg-primary/10 px-3.5 py-2"
|
||||||
: "px-1 py-1"
|
: "px-1 py-1"
|
||||||
@@ -240,11 +298,13 @@
|
|||||||
text={block.text}
|
text={block.text}
|
||||||
/>
|
/>
|
||||||
{:else if block.type === "tool_use"}
|
{:else if block.type === "tool_use"}
|
||||||
<p class="flex items-baseline gap-2 px-1 text-xs">
|
<p
|
||||||
<span class="font-medium"
|
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
|
>{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)}
|
{clip(summarizeInput(block.name ?? "", block.input), 160)}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -271,5 +331,6 @@
|
|||||||
{#each tail as turn (turn.id)}
|
{#each tail as turn (turn.id)}
|
||||||
<TurnCard {now} {turn} />
|
<TurnCard {now} {turn} />
|
||||||
{/each}
|
{/each}
|
||||||
|
<div aria-hidden="true" class="h-px shrink-0" bind:this={end}></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -338,7 +338,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{#if graph && graph.nodes.length > 0}
|
{#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} />
|
<Graph edges={graphEdges} nodes={graphNodes} onOpen={openNote} />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted-foreground text-xs">
|
<p class="text-muted-foreground text-xs">
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
onChanged,
|
onChanged,
|
||||||
class: className = "",
|
class: className = "",
|
||||||
children,
|
children,
|
||||||
|
trigger,
|
||||||
}: {
|
}: {
|
||||||
client: ApiClient;
|
client: ApiClient;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -35,6 +36,9 @@
|
|||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
class?: string;
|
class?: string;
|
||||||
children?: Snippet;
|
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();
|
} = $props();
|
||||||
|
|
||||||
const host = usePanelHost();
|
const host = usePanelHost();
|
||||||
@@ -93,9 +97,17 @@
|
|||||||
|
|
||||||
{#if mode === "context"}
|
{#if mode === "context"}
|
||||||
<ContextMenu.Root bind:open>
|
<ContextMenu.Root bind:open>
|
||||||
<ContextMenu.Trigger class={className}>
|
{#if trigger}
|
||||||
{@render children?.()}
|
<ContextMenu.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">
|
<ContextMenu.Content class="min-w-52">
|
||||||
{@render body(ContextMenu)}
|
{@render body(ContextMenu)}
|
||||||
</ContextMenu.Content>
|
</ContextMenu.Content>
|
||||||
|
|||||||
+53
-14
@@ -6,6 +6,8 @@ export interface DayGroup {
|
|||||||
branches: ConversationSummary[];
|
branches: ConversationSummary[];
|
||||||
day: string;
|
day: string;
|
||||||
deep: ConversationSummary[];
|
deep: ConversationSummary[];
|
||||||
|
forks: ConversationSummary[];
|
||||||
|
jobs: ConversationSummary[];
|
||||||
label: string;
|
label: string;
|
||||||
live: boolean;
|
live: boolean;
|
||||||
master: ConversationSummary | null;
|
master: ConversationSummary | null;
|
||||||
@@ -43,15 +45,16 @@ function activityOf(row: ConversationSummary): string {
|
|||||||
return row.last_activity_at ?? row.created_at ?? "";
|
return row.last_activity_at ?? row.created_at ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Days as the operator remembers them: a master per day, its branches
|
// Days as the operator remembers them: the master that began that day,
|
||||||
// under it, deep chats by the day they last moved, forks with their
|
// and under it everything that last moved that day - branches, deep
|
||||||
// parent's day. Jobs never enter the rail.
|
// 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(
|
export function groupByDay(
|
||||||
rows: ConversationSummary[],
|
rows: ConversationSummary[],
|
||||||
now = new Date()
|
now = new Date()
|
||||||
): DayGroup[] {
|
): DayGroup[] {
|
||||||
const groups = new Map<string, DayGroup>();
|
const groups = new Map<string, DayGroup>();
|
||||||
const dayOfMaster = new Map<string, string>();
|
|
||||||
const group = (day: string): DayGroup => {
|
const group = (day: string): DayGroup => {
|
||||||
let found = groups.get(day);
|
let found = groups.get(day);
|
||||||
if (!found) {
|
if (!found) {
|
||||||
@@ -59,6 +62,8 @@ export function groupByDay(
|
|||||||
branches: [],
|
branches: [],
|
||||||
day,
|
day,
|
||||||
deep: [],
|
deep: [],
|
||||||
|
forks: [],
|
||||||
|
jobs: [],
|
||||||
label: dayLabel(day, now),
|
label: dayLabel(day, now),
|
||||||
live: false,
|
live: false,
|
||||||
master: null,
|
master: null,
|
||||||
@@ -72,9 +77,7 @@ export function groupByDay(
|
|||||||
.filter((row) => row.kind === "master")
|
.filter((row) => row.kind === "master")
|
||||||
.sort((a, b) => (b.created_at ?? "").localeCompare(a.created_at ?? ""));
|
.sort((a, b) => (b.created_at ?? "").localeCompare(a.created_at ?? ""));
|
||||||
for (const master of masters) {
|
for (const master of masters) {
|
||||||
const day = localDay(master.created_at);
|
const g = group(localDay(master.created_at));
|
||||||
dayOfMaster.set(master.id, day);
|
|
||||||
const g = group(day);
|
|
||||||
if (!g.master || master.status === "open") {
|
if (!g.master || master.status === "open") {
|
||||||
g.master = master;
|
g.master = master;
|
||||||
} else {
|
} else {
|
||||||
@@ -82,16 +85,18 @@ export function groupByDay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.kind === "master" || row.kind === "job") {
|
if (row.kind === "master") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const parentDay = row.parent ? dayOfMaster.get(row.parent) : undefined;
|
const g = group(localDay(activityOf(row)));
|
||||||
const day = parentDay ?? localDay(activityOf(row));
|
|
||||||
const g = group(day);
|
|
||||||
if (row.kind === "branch") {
|
if (row.kind === "branch") {
|
||||||
g.branches.push(row);
|
g.branches.push(row);
|
||||||
} else if (row.kind === "deep") {
|
} else if (row.kind === "deep") {
|
||||||
g.deep.push(row);
|
g.deep.push(row);
|
||||||
|
} else if (row.kind === "fork") {
|
||||||
|
g.forks.push(row);
|
||||||
|
} else if (row.kind === "job") {
|
||||||
|
g.jobs.push(row);
|
||||||
} else {
|
} else {
|
||||||
g.others.push(row);
|
g.others.push(row);
|
||||||
}
|
}
|
||||||
@@ -100,10 +105,44 @@ export function groupByDay(
|
|||||||
for (const g of out) {
|
for (const g of out) {
|
||||||
g.branches.sort(byActivity);
|
g.branches.sort(byActivity);
|
||||||
g.deep.sort(byActivity);
|
g.deep.sort(byActivity);
|
||||||
|
g.forks.sort(byActivity);
|
||||||
|
g.jobs.sort(byActivity);
|
||||||
g.others.sort(byActivity);
|
g.others.sort(byActivity);
|
||||||
g.live = [g.master, ...g.branches, ...g.deep, ...g.others].some(
|
g.live = [
|
||||||
(row) => row && (row.running_turn || row.pending_question)
|
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));
|
return out.sort((a, b) => b.day.localeCompare(a.day));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One phrase for the day header: what the day held, and whether its
|
||||||
|
// master is still the live one.
|
||||||
|
export function daySummary(group: DayGroup, today: boolean): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (group.master?.status === "open" && !today) {
|
||||||
|
parts.push("master still open");
|
||||||
|
}
|
||||||
|
const count = (n: number, one: string, many = `${one}s`) =>
|
||||||
|
`${n} ${n === 1 ? one : many}`;
|
||||||
|
if (group.branches.length) {
|
||||||
|
parts.push(count(group.branches.length, "branch", "branches"));
|
||||||
|
}
|
||||||
|
if (group.deep.length) {
|
||||||
|
parts.push(count(group.deep.length, "deep", "deep"));
|
||||||
|
}
|
||||||
|
if (group.forks.length) {
|
||||||
|
parts.push(count(group.forks.length, "fork"));
|
||||||
|
}
|
||||||
|
if (group.jobs.length) {
|
||||||
|
parts.push(count(group.jobs.length, "job run"));
|
||||||
|
}
|
||||||
|
if (group.others.length) {
|
||||||
|
parts.push(count(group.others.length, "more", "more"));
|
||||||
|
}
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|||||||
+60
-20
@@ -14,7 +14,7 @@
|
|||||||
import type { ConversationIndex } from "$lib/panel/index.svelte";
|
import type { ConversationIndex } from "$lib/panel/index.svelte";
|
||||||
import Strip from "$lib/shell/strip.svelte";
|
import Strip from "$lib/shell/strip.svelte";
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
import { type DayGroup, groupByDay } from "./days";
|
import { type DayGroup, daySummary, groupByDay } from "./days";
|
||||||
import NewConversation from "./new-conversation.svelte";
|
import NewConversation from "./new-conversation.svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -64,10 +64,9 @@
|
|||||||
}
|
}
|
||||||
return index.conversations.filter(
|
return index.conversations.filter(
|
||||||
(row) =>
|
(row) =>
|
||||||
row.kind !== "job" &&
|
(row.title ?? "").toLowerCase().includes(needle) ||
|
||||||
((row.title ?? "").toLowerCase().includes(needle) ||
|
row.id.startsWith(needle) ||
|
||||||
row.id.startsWith(needle) ||
|
(row.last_item?.text ?? "").toLowerCase().includes(needle)
|
||||||
(row.last_item?.text ?? "").toLowerCase().includes(needle))
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,22 +119,20 @@
|
|||||||
expanded = next;
|
expanded = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function summary(group: DayGroup): string {
|
let jobsOpen = $state<Set<string>>(new Set());
|
||||||
const parts: string[] = [];
|
|
||||||
if (group.branches.length) {
|
function toggleJobs(day: string) {
|
||||||
parts.push(
|
const next = new Set(jobsOpen);
|
||||||
`${group.branches.length} ${group.branches.length === 1 ? "branch" : "branches"}`
|
if (next.has(day)) {
|
||||||
);
|
next.delete(day);
|
||||||
|
} else {
|
||||||
|
next.add(day);
|
||||||
}
|
}
|
||||||
if (group.deep.length) {
|
jobsOpen = next;
|
||||||
parts.push(`${group.deep.length} deep`);
|
|
||||||
}
|
|
||||||
if (group.others.length) {
|
|
||||||
parts.push(`${group.others.length} more`);
|
|
||||||
}
|
|
||||||
return parts.join(" · ");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refresh = () => index.load().catch(() => undefined);
|
||||||
|
|
||||||
const linkOf = (id: string) => href?.(id);
|
const linkOf = (id: string) => href?.(id);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -188,10 +185,12 @@
|
|||||||
<div class="bay-rack">
|
<div class="bay-rack">
|
||||||
{#each found as row (row.id)}
|
{#each found as row (row.id)}
|
||||||
<Strip
|
<Strip
|
||||||
|
{client}
|
||||||
current={row.id === selected}
|
current={row.id === selected}
|
||||||
detail={row.last_item?.text ?? ""}
|
detail={row.last_item?.text ?? ""}
|
||||||
href={linkOf(row.id)}
|
href={linkOf(row.id)}
|
||||||
{now}
|
{now}
|
||||||
|
onChanged={refresh}
|
||||||
onclick={onOpen}
|
onclick={onOpen}
|
||||||
{row}
|
{row}
|
||||||
/>
|
/>
|
||||||
@@ -254,17 +253,19 @@
|
|||||||
></span>
|
></span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="truncate text-muted-foreground text-xs">
|
<span class="truncate text-muted-foreground text-xs">
|
||||||
{summary(group)}
|
{daySummary(group, position === 0)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{#if shown}
|
{#if shown}
|
||||||
<div class="bay-rack">
|
<div class="bay-rack">
|
||||||
{#if group.master}
|
{#if group.master}
|
||||||
<Strip
|
<Strip
|
||||||
|
{client}
|
||||||
current={group.master.id === selected}
|
current={group.master.id === selected}
|
||||||
detail={group.master.last_item?.text ?? ""}
|
detail={group.master.last_item?.text ?? ""}
|
||||||
href={linkOf(group.master.id)}
|
href={linkOf(group.master.id)}
|
||||||
{now}
|
{now}
|
||||||
|
onChanged={refresh}
|
||||||
onclick={onOpen}
|
onclick={onOpen}
|
||||||
row={group.master}
|
row={group.master}
|
||||||
/>
|
/>
|
||||||
@@ -272,34 +273,73 @@
|
|||||||
{#each group.branches as row (row.id)}
|
{#each group.branches as row (row.id)}
|
||||||
<Strip
|
<Strip
|
||||||
class="strip-child"
|
class="strip-child"
|
||||||
|
{client}
|
||||||
current={row.id === selected}
|
current={row.id === selected}
|
||||||
detail={row.last_item?.text ?? ""}
|
detail={row.last_item?.text ?? ""}
|
||||||
href={linkOf(row.id)}
|
href={linkOf(row.id)}
|
||||||
{now}
|
{now}
|
||||||
|
onChanged={refresh}
|
||||||
onclick={onOpen}
|
onclick={onOpen}
|
||||||
{row}
|
{row}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
{#each group.deep as row (row.id)}
|
{#each group.deep as row (row.id)}
|
||||||
<Strip
|
<Strip
|
||||||
|
{client}
|
||||||
current={row.id === selected}
|
current={row.id === selected}
|
||||||
detail={row.last_item?.text ?? ""}
|
detail={row.last_item?.text ?? ""}
|
||||||
href={linkOf(row.id)}
|
href={linkOf(row.id)}
|
||||||
{now}
|
{now}
|
||||||
|
onChanged={refresh}
|
||||||
onclick={onOpen}
|
onclick={onOpen}
|
||||||
{row}
|
{row}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
{#each group.others as row (row.id)}
|
{#each [...group.forks, ...group.others] as row (row.id)}
|
||||||
<Strip
|
<Strip
|
||||||
class="strip-child"
|
class="strip-child"
|
||||||
|
{client}
|
||||||
current={row.id === selected}
|
current={row.id === selected}
|
||||||
|
detail="{row.kind} · {row.status}"
|
||||||
href={linkOf(row.id)}
|
href={linkOf(row.id)}
|
||||||
{now}
|
{now}
|
||||||
|
onChanged={refresh}
|
||||||
onclick={onOpen}
|
onclick={onOpen}
|
||||||
{row}
|
{row}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+287
-70
@@ -2,8 +2,19 @@
|
|||||||
import { onMount, untrack } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { base } from "$app/paths";
|
import { base } from "$app/paths";
|
||||||
import type { ConversationSummary, VaultGraph } from "$lib/api/types";
|
import type {
|
||||||
import { clip, fmtTime, shortId } from "$lib/format";
|
ConversationSummary,
|
||||||
|
JobInfo,
|
||||||
|
QueuedJob,
|
||||||
|
VaultGraph,
|
||||||
|
} from "$lib/api/types";
|
||||||
|
import {
|
||||||
|
clip,
|
||||||
|
fmtCountdown,
|
||||||
|
fmtRelative,
|
||||||
|
fmtTime,
|
||||||
|
shortId,
|
||||||
|
} from "$lib/format";
|
||||||
import { gateway } from "$lib/gateway.svelte";
|
import { gateway } from "$lib/gateway.svelte";
|
||||||
import { summarizeInput, toolLabel } from "$lib/panel/activity.svelte";
|
import { summarizeInput, toolLabel } from "$lib/panel/activity.svelte";
|
||||||
import QuestionCard from "$lib/panel/question-card.svelte";
|
import QuestionCard from "$lib/panel/question-card.svelte";
|
||||||
@@ -11,8 +22,10 @@
|
|||||||
import { ui } from "$lib/ui.svelte";
|
import { ui } from "$lib/ui.svelte";
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
import Bay from "./bay.svelte";
|
import Bay from "./bay.svelte";
|
||||||
|
import FloatMenu, { type FloatItem } from "./float-menu.svelte";
|
||||||
import Graph, { type GraphEdge, type GraphNode } from "./graph.svelte";
|
import Graph, { type GraphEdge, type GraphNode } from "./graph.svelte";
|
||||||
import Instruments from "./instruments.svelte";
|
import Instruments from "./instruments.svelte";
|
||||||
|
import KindMark from "./kind-mark.svelte";
|
||||||
import { now as board } from "./now.svelte";
|
import { now as board } from "./now.svelte";
|
||||||
import { stateOf } from "./state";
|
import { stateOf } from "./state";
|
||||||
import Strip from "./strip.svelte";
|
import Strip from "./strip.svelte";
|
||||||
@@ -25,15 +38,20 @@
|
|||||||
|
|
||||||
const TICK_MS = 1000;
|
const TICK_MS = 1000;
|
||||||
const QUIET_SHOWN = 6;
|
const QUIET_SHOWN = 6;
|
||||||
const TAPE_SHOWN = 24;
|
const TAPE_SHOWN = 14;
|
||||||
const HOURS_DAY = 24;
|
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 now = $state(Date.now());
|
||||||
let spend = $state<number | null>(null);
|
let spend = $state<number | null>(null);
|
||||||
let vault = $state<VaultGraph | null>(null);
|
let vault = $state<VaultGraph | null>(null);
|
||||||
let vaultFor = $state<string | null>(null);
|
let vaultFor = $state<string | null>(null);
|
||||||
let quietOpen = $state(false);
|
let quietOpen = $state(false);
|
||||||
let tapeOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
|
let menuAt = $state({ x: 0, y: 0 });
|
||||||
|
let menuItems = $state<FloatItem[]>([]);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const tick = setInterval(() => {
|
const tick = setInterval(() => {
|
||||||
@@ -60,6 +78,7 @@
|
|||||||
|
|
||||||
const href = (id: string) => `${base}/conversations/${id}`;
|
const href = (id: string) => `${base}/conversations/${id}`;
|
||||||
const GRAPH_FILES = 24;
|
const GRAPH_FILES = 24;
|
||||||
|
const refresh = () => gateway.load().catch(() => undefined);
|
||||||
|
|
||||||
async function loadVault(masterId: string) {
|
async function loadVault(masterId: string) {
|
||||||
const { client } = session;
|
const { client } = session;
|
||||||
@@ -114,17 +133,78 @@
|
|||||||
const windows = $derived(gateway.limits?.windows ?? []);
|
const windows = $derived(gateway.limits?.windows ?? []);
|
||||||
const tape = $derived(gateway.tape.slice(0, TAPE_SHOWN));
|
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): {
|
function conversationNodes(master: ConversationSummary | null): {
|
||||||
nodes: GraphNode[];
|
nodes: GraphNode[];
|
||||||
edges: GraphEdge[];
|
edges: GraphEdge[];
|
||||||
} {
|
} {
|
||||||
const nodes: GraphNode[] = [];
|
const nodes: GraphNode[] = [];
|
||||||
const edges: GraphEdge[] = [];
|
const edges: GraphEdge[] = [];
|
||||||
const rows = gateway.conversations.filter(
|
|
||||||
(row) => row.status === "open" || row.running_turn
|
|
||||||
);
|
|
||||||
if (master) {
|
if (master) {
|
||||||
nodes.push({
|
nodes.push({
|
||||||
href: href(master.id),
|
href: href(master.id),
|
||||||
@@ -135,17 +215,22 @@
|
|||||||
state: stateOf(master),
|
state: stateOf(master),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const row of rows) {
|
for (const row of gateway.conversations) {
|
||||||
if (row.id === master?.id || row.kind === "master") {
|
if (row.id === master?.id || row.kind === "master") {
|
||||||
continue;
|
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({
|
nodes.push({
|
||||||
href: href(row.id),
|
href: href(row.id),
|
||||||
id: row.id,
|
id: row.id,
|
||||||
kind: row.kind,
|
kind: row.kind,
|
||||||
label: row.title ?? `${row.kind} ${shortId(row.id)}`,
|
label: row.title ?? `${row.kind} ${shortId(row.id)}`,
|
||||||
ring: child ? 1 : 2,
|
ring: child || live ? 1 : 2,
|
||||||
state: stateOf(row),
|
state: stateOf(row),
|
||||||
});
|
});
|
||||||
if (master && child) {
|
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) {
|
function openNode(id: string) {
|
||||||
if (id.endsWith(".md")) {
|
if (id.endsWith(".md")) {
|
||||||
window.open(
|
window.open(obsidianUrl(id));
|
||||||
`obsidian://open?file=${encodeURIComponent(id.replace(MD_SUFFIX, ""))}`
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
open(id);
|
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>
|
</script>
|
||||||
|
|
||||||
|
<FloatMenu items={menuItems} x={menuAt.x} y={menuAt.y} bind:open={menuOpen} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
"@container grid gap-8",
|
"@container grid gap-8",
|
||||||
compact
|
compact
|
||||||
? "grid-cols-1"
|
? "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">
|
<div class="flex min-w-0 flex-col gap-8">
|
||||||
@@ -235,9 +408,12 @@
|
|||||||
{#each board.waiting as row (row.id)}
|
{#each board.waiting as row (row.id)}
|
||||||
{@const info = board.snapshots[row.id]}
|
{@const info = board.snapshots[row.id]}
|
||||||
<Strip
|
<Strip
|
||||||
|
client={session.client}
|
||||||
detail={info?.question?.questions[0]?.question ?? "question pending"}
|
detail={info?.question?.questions[0]?.question ?? "question pending"}
|
||||||
href={href(row.id)}
|
href={href(row.id)}
|
||||||
{now}
|
{now}
|
||||||
|
onChanged={refresh}
|
||||||
|
onclick={open}
|
||||||
open={Boolean(info?.question)}
|
open={Boolean(info?.question)}
|
||||||
{row}
|
{row}
|
||||||
state="waiting"
|
state="waiting"
|
||||||
@@ -267,9 +443,12 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each board.running as row (row.id)}
|
{#each board.running as row (row.id)}
|
||||||
<Strip
|
<Strip
|
||||||
|
client={session.client}
|
||||||
detail={lastTool(row)}
|
detail={lastTool(row)}
|
||||||
href={href(row.id)}
|
href={href(row.id)}
|
||||||
{now}
|
{now}
|
||||||
|
onChanged={refresh}
|
||||||
|
onclick={open}
|
||||||
{row}
|
{row}
|
||||||
state="running"
|
state="running"
|
||||||
/>
|
/>
|
||||||
@@ -277,6 +456,49 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Bay>
|
</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
|
<Bay
|
||||||
count={board.quiet.length}
|
count={board.quiet.length}
|
||||||
hint="open, waiting for the next word"
|
hint="open, waiting for the next word"
|
||||||
@@ -304,73 +526,68 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each quiet as row (row.id)}
|
{#each quiet as row (row.id)}
|
||||||
<Strip
|
<Strip
|
||||||
|
client={session.client}
|
||||||
detail={row.last_item?.text ?? ""}
|
detail={row.last_item?.text ?? ""}
|
||||||
href={href(row.id)}
|
href={href(row.id)}
|
||||||
{now}
|
{now}
|
||||||
|
onChanged={refresh}
|
||||||
|
onclick={open}
|
||||||
{row}
|
{row}
|
||||||
state="quiet"
|
state="quiet"
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</Bay>
|
</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>
|
</div>
|
||||||
|
|
||||||
{#if !compact}
|
{#if !compact}
|
||||||
<aside class="hidden flex-col gap-2 lg:flex">
|
<aside class="hidden min-w-0 flex-col gap-6 lg:flex">
|
||||||
<h2 class="label-quiet px-1">Around the master</h2>
|
<section class="flex flex-col gap-2">
|
||||||
<div class="rounded-lg border bg-strip/60 p-2">
|
<h2 class="label-quiet px-1">Around the master</h2>
|
||||||
<Graph edges={graph.edges} nodes={graph.nodes} onOpen={openNode} />
|
<div class="h-[26rem] overflow-hidden rounded-lg border bg-strip/60">
|
||||||
</div>
|
<Graph
|
||||||
<p class="px-1 text-muted-foreground text-xs">
|
edges={graph.edges}
|
||||||
Inner ring: branches and the notes the master touched today. Hollow dots
|
nodes={graph.nodes}
|
||||||
are one link away, not reached. Click a note to open it in Obsidian.
|
onMenu={nodeMenu}
|
||||||
</p>
|
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>
|
</aside>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
export interface FloatItem {
|
||||||
|
danger?: boolean;
|
||||||
|
// A rule sits above this item.
|
||||||
|
gap?: boolean;
|
||||||
|
label: string;
|
||||||
|
run: () => void;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
|
// A menu that opens where the pointer is, for things that are not
|
||||||
|
// conversations: a note in the graph, a file in the memory tree.
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
open = $bindable(false),
|
||||||
|
class: className = "",
|
||||||
|
}: {
|
||||||
|
items: FloatItem[];
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
open?: boolean;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const MARGIN = 8;
|
||||||
|
const WIDTH = 208;
|
||||||
|
let panel = $state<HTMLDivElement | null>(null);
|
||||||
|
let cursor = $state(0);
|
||||||
|
|
||||||
|
const left = $derived(
|
||||||
|
typeof window === "undefined"
|
||||||
|
? x
|
||||||
|
: Math.min(x, window.innerWidth - WIDTH - MARGIN)
|
||||||
|
);
|
||||||
|
const top = $derived.by(() => {
|
||||||
|
if (typeof window === "undefined" || !panel) {
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
return Math.min(y, window.innerHeight - panel.offsetHeight - MARGIN);
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(item: FloatItem) {
|
||||||
|
close();
|
||||||
|
item.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cursor = 0;
|
||||||
|
const away = (event: PointerEvent) => {
|
||||||
|
if (panel && !panel.contains(event.target as Node)) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const keys = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
} else if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
cursor = (cursor + 1) % items.length;
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
cursor = (cursor - 1 + items.length) % items.length;
|
||||||
|
} else if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
const item = items[cursor];
|
||||||
|
if (item) {
|
||||||
|
pick(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("pointerdown", away, true);
|
||||||
|
window.addEventListener("keydown", keys, true);
|
||||||
|
window.addEventListener("resize", close);
|
||||||
|
window.addEventListener("scroll", close, true);
|
||||||
|
queueMicrotask(() => panel?.focus());
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointerdown", away, true);
|
||||||
|
window.removeEventListener("keydown", keys, true);
|
||||||
|
window.removeEventListener("resize", close);
|
||||||
|
window.removeEventListener("scroll", close, true);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"fixed z-50 flex animate-island-in flex-col rounded-lg border bg-popover p-1 text-popover-foreground text-sm shadow-float outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role="menu"
|
||||||
|
style="left: {left}px; top: {top}px; width: {WIDTH}px"
|
||||||
|
tabindex="-1"
|
||||||
|
bind:this={panel}
|
||||||
|
>
|
||||||
|
{#each items as item, index (item.label)}
|
||||||
|
{#if item.gap && index > 0}
|
||||||
|
<div class="my-1 h-px bg-border"></div>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class={cn(
|
||||||
|
"flex w-full items-center rounded-md px-2 py-1.5 text-left hover:bg-accent",
|
||||||
|
index === cursor && "bg-accent",
|
||||||
|
item.danger && "text-destructive"
|
||||||
|
)}
|
||||||
|
onclick={() => pick(item)}
|
||||||
|
onpointerenter={() => {
|
||||||
|
cursor = index;
|
||||||
|
}}
|
||||||
|
role="menuitem"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
// A small force layout: rings keep the structure legible, springs and
|
||||||
|
// repulsion keep neighbours apart, a breath keeps it alive between touches.
|
||||||
|
|
||||||
|
export interface Body {
|
||||||
|
fixed: boolean;
|
||||||
|
id: string;
|
||||||
|
ring: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Link {
|
||||||
|
a: Body;
|
||||||
|
b: Body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RING_RADIUS = [0, 92, 168, 224];
|
||||||
|
const LINK_STRENGTH = 0.06;
|
||||||
|
const LINK_REST = 70;
|
||||||
|
const CHARGE = 1600;
|
||||||
|
const CHARGE_REACH = 320;
|
||||||
|
const RADIAL = 0.05;
|
||||||
|
const HUB_HOLD = 0.4;
|
||||||
|
const DECAY = 0.55;
|
||||||
|
const ALPHA_EASE = 0.025;
|
||||||
|
const BREATH = 0.35;
|
||||||
|
const MIN_D2 = 36;
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
const HASH_MOD = 1_000_003;
|
||||||
|
const HASH_BASE = 31;
|
||||||
|
|
||||||
|
function hash(id: string): number {
|
||||||
|
let h = 0;
|
||||||
|
for (const ch of id) {
|
||||||
|
h = (h * HASH_BASE + ch.charCodeAt(0)) % HASH_MOD;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ringRadius(ring: number): number {
|
||||||
|
return RING_RADIUS[Math.min(ring, RING_RADIUS.length - 1)] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Force {
|
||||||
|
bodies: Body[] = [];
|
||||||
|
alpha = 1;
|
||||||
|
alphaTarget = 0;
|
||||||
|
private links: Link[] = [];
|
||||||
|
private readonly index = new Map<string, Body>();
|
||||||
|
private clock = 0;
|
||||||
|
|
||||||
|
// Keep what is already placed, seat newcomers on their ring near a
|
||||||
|
// neighbour when they have one, forget what is gone.
|
||||||
|
sync(
|
||||||
|
nodes: { id: string; ring: number }[],
|
||||||
|
edges: { from: string; to: string }[]
|
||||||
|
): void {
|
||||||
|
const keep = new Set<string>();
|
||||||
|
const fresh: Body[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
keep.add(node.id);
|
||||||
|
const known = this.index.get(node.id);
|
||||||
|
if (known) {
|
||||||
|
known.ring = node.ring;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const angle = (hash(node.id) % 360) * (TAU / 360);
|
||||||
|
const r = ringRadius(node.ring);
|
||||||
|
const body: Body = {
|
||||||
|
fixed: false,
|
||||||
|
id: node.id,
|
||||||
|
ring: node.ring,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
x: Math.cos(angle) * r,
|
||||||
|
y: Math.sin(angle) * r,
|
||||||
|
};
|
||||||
|
this.index.set(node.id, body);
|
||||||
|
fresh.push(body);
|
||||||
|
}
|
||||||
|
for (const id of [...this.index.keys()]) {
|
||||||
|
if (!keep.has(id)) {
|
||||||
|
this.index.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.bodies = nodes
|
||||||
|
.map((node) => this.index.get(node.id))
|
||||||
|
.filter((body): body is Body => body !== undefined);
|
||||||
|
this.links = [];
|
||||||
|
for (const edge of edges) {
|
||||||
|
const a = this.index.get(edge.from);
|
||||||
|
const b = this.index.get(edge.to);
|
||||||
|
if (a && b && a !== b) {
|
||||||
|
this.links.push({ a, b });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const body of fresh) {
|
||||||
|
const link = this.links.find((l) => l.a === body || l.b === body);
|
||||||
|
if (link) {
|
||||||
|
const other = link.a === body ? link.b : link.a;
|
||||||
|
const r = ringRadius(body.ring);
|
||||||
|
const angle = Math.atan2(other.y, other.x) + (hash(body.id) % 7) * 0.09;
|
||||||
|
body.x = Math.cos(angle) * r;
|
||||||
|
body.y = Math.sin(angle) * r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fresh.length > 0) {
|
||||||
|
this.reheat(0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body(id: string): Body | undefined {
|
||||||
|
return this.index.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
reheat(to = 0.4): void {
|
||||||
|
this.alpha = Math.max(this.alpha, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
get settled(): boolean {
|
||||||
|
return this.alpha < 0.004 && this.alphaTarget === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One tick; ``breathe`` adds the slow drift of a graph nobody is touching.
|
||||||
|
step(breathe: boolean): void {
|
||||||
|
this.clock += 1 / 60;
|
||||||
|
const { alpha, bodies } = this;
|
||||||
|
for (const link of this.links) {
|
||||||
|
const dx = link.b.x - link.a.x;
|
||||||
|
const dy = link.b.y - link.a.y;
|
||||||
|
const d = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
|
||||||
|
const rest = LINK_REST + Math.abs(link.a.ring - link.b.ring) * 10;
|
||||||
|
const f = ((d - rest) / d) * LINK_STRENGTH * alpha;
|
||||||
|
link.a.vx += dx * f;
|
||||||
|
link.a.vy += dy * f;
|
||||||
|
link.b.vx -= dx * f;
|
||||||
|
link.b.vy -= dy * f;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < bodies.length; i += 1) {
|
||||||
|
const a = bodies[i];
|
||||||
|
for (let j = i + 1; j < bodies.length; j += 1) {
|
||||||
|
const b = bodies[j];
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const dy = b.y - a.y;
|
||||||
|
const d2 = Math.max(dx * dx + dy * dy, MIN_D2);
|
||||||
|
if (d2 > CHARGE_REACH * CHARGE_REACH) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const f = (CHARGE / d2) * alpha;
|
||||||
|
const d = Math.sqrt(d2);
|
||||||
|
const fx = (dx / d) * f;
|
||||||
|
const fy = (dy / d) * f;
|
||||||
|
a.vx -= fx;
|
||||||
|
a.vy -= fy;
|
||||||
|
b.vx += fx;
|
||||||
|
b.vy += fy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const body of bodies) {
|
||||||
|
if (body.fixed) {
|
||||||
|
body.vx = 0;
|
||||||
|
body.vy = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (body.ring === 0) {
|
||||||
|
body.vx -= body.x * HUB_HOLD * alpha;
|
||||||
|
body.vy -= body.y * HUB_HOLD * alpha;
|
||||||
|
} else {
|
||||||
|
const r = Math.max(Math.sqrt(body.x * body.x + body.y * body.y), 1);
|
||||||
|
const pull = ((ringRadius(body.ring) - r) / r) * RADIAL * alpha;
|
||||||
|
body.vx += body.x * pull;
|
||||||
|
body.vy += body.y * pull;
|
||||||
|
}
|
||||||
|
if (breathe) {
|
||||||
|
const seed = hash(body.id) % 11;
|
||||||
|
body.vx += Math.sin(this.clock * 0.6 + seed) * BREATH * 0.02;
|
||||||
|
body.vy += Math.cos(this.clock * 0.5 + seed * 1.3) * BREATH * 0.02;
|
||||||
|
}
|
||||||
|
body.vx *= DECAY;
|
||||||
|
body.vy *= DECAY;
|
||||||
|
body.x += body.vx;
|
||||||
|
body.y += body.vy;
|
||||||
|
}
|
||||||
|
this.alpha += (this.alphaTarget - this.alpha) * ALPHA_EASE;
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds(): { x: number; y: number; w: number; h: number } {
|
||||||
|
if (this.bodies.length === 0) {
|
||||||
|
return { h: 1, w: 1, x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
let minX = Number.POSITIVE_INFINITY;
|
||||||
|
let minY = Number.POSITIVE_INFINITY;
|
||||||
|
let maxX = Number.NEGATIVE_INFINITY;
|
||||||
|
let maxY = Number.NEGATIVE_INFINITY;
|
||||||
|
for (const body of this.bodies) {
|
||||||
|
minX = Math.min(minX, body.x);
|
||||||
|
minY = Math.min(minY, body.y);
|
||||||
|
maxX = Math.max(maxX, body.x);
|
||||||
|
maxY = Math.max(maxY, body.y);
|
||||||
|
}
|
||||||
|
return { h: maxY - minY || 1, w: maxX - minX || 1, x: minX, y: minY };
|
||||||
|
}
|
||||||
|
}
|
||||||
+384
-154
@@ -1,8 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" module>
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { clip } from "$lib/format";
|
|
||||||
import { cn } from "$lib/utils";
|
|
||||||
|
|
||||||
export interface GraphNode {
|
export interface GraphNode {
|
||||||
href?: string;
|
href?: string;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,107 +13,246 @@
|
|||||||
from: string;
|
from: string;
|
||||||
to: 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 {
|
let {
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
edges,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
onMenu,
|
||||||
class: className = "",
|
class: className = "",
|
||||||
}: {
|
}: {
|
||||||
nodes: GraphNode[];
|
nodes: GraphNode[];
|
||||||
edges: GraphEdge[];
|
edges: GraphEdge[];
|
||||||
onOpen?: (id: string) => void;
|
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;
|
class?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const SIZE = 320;
|
const LABEL_MAX = 22;
|
||||||
const CENTER = SIZE / 2;
|
const LABEL_MAX_NEAR = 40;
|
||||||
const RING = [0, 74, 118, 148];
|
const ZOOM_MIN = 0.25;
|
||||||
const DRIFT = 3.5;
|
const ZOOM_MAX = 4;
|
||||||
const LABEL_MAX = 18;
|
const ZOOM_STEP = 0.0016;
|
||||||
const TAU = Math.PI * 2;
|
const FIT_PAD = 0.86;
|
||||||
const HASH_MOD = 1_000_003;
|
const FIT_MAX = 1.5;
|
||||||
const HASH_BASE = 31;
|
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 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(() => {
|
onMount(() => {
|
||||||
reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let frame = 0;
|
const b = force.bounds();
|
||||||
const start = performance.now();
|
const pad = 48;
|
||||||
const tick = (ts: number) => {
|
const scale = Math.min(
|
||||||
time = (ts - start) / 1000;
|
(width * FIT_PAD) / (b.w + pad),
|
||||||
frame = requestAnimationFrame(tick);
|
(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);
|
if (body) {
|
||||||
return () => cancelAnimationFrame(frame);
|
body.fixed = true;
|
||||||
});
|
force.reheat(0.3);
|
||||||
|
settling = 0;
|
||||||
function hash(id: string): number {
|
|
||||||
let h = 0;
|
|
||||||
for (const ch of id) {
|
|
||||||
h = (h * HASH_BASE + ch.charCodeAt(0)) % HASH_MOD;
|
|
||||||
}
|
}
|
||||||
return h;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Placed extends GraphNode {
|
function onMove(event: PointerEvent) {
|
||||||
x: number;
|
if (!drag || drag.pointer !== event.pointerId) {
|
||||||
y: number;
|
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(
|
function onUp(event: PointerEvent) {
|
||||||
node: GraphNode,
|
if (!drag || drag.pointer !== event.pointerId) {
|
||||||
slot: number,
|
return;
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
return {
|
const { body, moved } = drag;
|
||||||
...node,
|
drag = null;
|
||||||
x: CENTER + Math.cos(angle) * dist + wobble,
|
if (body) {
|
||||||
y: CENTER + Math.sin(angle) * dist + wobble2,
|
body.fixed = false;
|
||||||
};
|
if (!moved) {
|
||||||
}
|
onOpen?.(body.id);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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> = {
|
const RADIUS: Record<string, number> = {
|
||||||
closed: 3.5,
|
closed: 4,
|
||||||
ghost: 2.5,
|
ghost: 3,
|
||||||
quiet: 4.5,
|
quiet: 5,
|
||||||
running: 6.5,
|
running: 7,
|
||||||
waiting: 6,
|
waiting: 6.5,
|
||||||
};
|
};
|
||||||
const FILL: Record<string, string> = {
|
const FILL: Record<string, string> = {
|
||||||
branch: "var(--color-kind-branch)",
|
branch: "var(--color-kind-branch)",
|
||||||
@@ -128,28 +263,44 @@
|
|||||||
master: "var(--color-kind-master)",
|
master: "var(--color-kind-master)",
|
||||||
};
|
};
|
||||||
|
|
||||||
function radius(node: Placed): number {
|
function radius(node: GraphNode): number {
|
||||||
const base = RADIUS[node.state] ?? 4;
|
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") {
|
if (node.state === "ghost") {
|
||||||
return "var(--muted-foreground)";
|
return "var(--muted-foreground)";
|
||||||
}
|
}
|
||||||
return node.state === "waiting" ? "var(--attention)" : "none";
|
return node.state === "waiting" ? "var(--attention)" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillOf(node: Placed): string {
|
function fillOf(node: GraphNode): string {
|
||||||
return node.state === "ghost"
|
return node.state === "ghost"
|
||||||
? "none"
|
? "var(--background)"
|
||||||
: (FILL[node.kind] ?? "var(--foreground)");
|
: (FILL[node.kind] ?? "var(--foreground)");
|
||||||
}
|
}
|
||||||
|
|
||||||
function textOf(node: Placed): string {
|
function near(id: string): boolean {
|
||||||
return node.state === "ghost" || node.state === "closed"
|
return (
|
||||||
? "var(--muted-foreground)"
|
hovered === id ||
|
||||||
: "var(--foreground)";
|
(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 {
|
function ghostEdge(edge: GraphEdge): boolean {
|
||||||
@@ -160,76 +311,155 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet dot(node: Placed)}
|
<svelte:window onkeydown={onKey} />
|
||||||
{@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}
|
|
||||||
|
|
||||||
<svg
|
<div
|
||||||
aria-label="what the agent is touching"
|
class={cn(
|
||||||
class={cn("h-auto w-full select-none", className)}
|
"graph-frame relative overflow-hidden",
|
||||||
role="img"
|
expanded
|
||||||
viewBox="0 0 {SIZE} {SIZE}"
|
? "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>
|
<!-- biome-ignore lint/a11y/noNoninteractiveElementInteractions: the canvas pans and zooms; the nodes inside are links -->
|
||||||
<radialGradient id="graph-glow">
|
<svg
|
||||||
<stop offset="0%" stop-color="var(--signal)" stop-opacity="0.35" />
|
aria-label="what the agent is touching"
|
||||||
<stop offset="100%" stop-color="var(--signal)" stop-opacity="0" />
|
class="block h-full w-full cursor-grab touch-none select-none active:cursor-grabbing"
|
||||||
</radialGradient>
|
ondblclick={refit}
|
||||||
</defs>
|
onpointercancel={onUp}
|
||||||
{#each edges as edge (edge.from + edge.to)}
|
onpointerdown={(event) => onDown(event, null)}
|
||||||
{@const a = byId.get(edge.from)}
|
onpointermove={onMove}
|
||||||
{@const b = byId.get(edge.to)}
|
onpointerup={onUp}
|
||||||
{#if a && b}
|
onwheel={onWheel}
|
||||||
<line
|
role="img"
|
||||||
stroke="var(--foreground)"
|
bind:this={svg}
|
||||||
stroke-dasharray={ghostEdge(edge) ? "2 4" : undefined}
|
>
|
||||||
stroke-opacity={ghostEdge(edge) ? 0.18 : 0.14}
|
<defs>
|
||||||
stroke-width="1"
|
<radialGradient id="graph-glow">
|
||||||
x1={a.x}
|
<stop offset="0%" stop-color="var(--signal)" stop-opacity="0.4" />
|
||||||
x2={b.x}
|
<stop offset="100%" stop-color="var(--signal)" stop-opacity="0" />
|
||||||
y1={a.y}
|
</radialGradient>
|
||||||
y2={b.y}
|
</defs>
|
||||||
/>
|
<g transform="translate({width / 2 + tx} {height / 2 + ty}) scale({k})">
|
||||||
{/if}
|
{#each edges as edge (edge.from + edge.to)}
|
||||||
{/each}
|
{@const a = force.body(edge.from)}
|
||||||
{#each placed as node (node.id)}
|
{@const b = force.body(edge.to)}
|
||||||
{#if node.href}
|
{#if a && b && frame >= 0}
|
||||||
<a
|
<line
|
||||||
class="cursor-pointer"
|
stroke="var(--foreground)"
|
||||||
href={node.href}
|
stroke-dasharray={ghostEdge(edge) ? "2 4" : undefined}
|
||||||
onclick={(event) => {
|
stroke-opacity={edgeDimmed(edge) ? 0.05 : 0.16}
|
||||||
event.preventDefault();
|
stroke-width={1 / Math.sqrt(k)}
|
||||||
onOpen?.(node.id);
|
x1={a.x}
|
||||||
}}
|
x2={b.x}
|
||||||
>
|
y1={a.y}
|
||||||
{@render dot(node)}
|
y2={b.y}
|
||||||
</a>
|
/>
|
||||||
{:else}
|
{/if}
|
||||||
<g>{@render dot(node)}</g>
|
{/each}
|
||||||
{/if}
|
{#each placed as item (item.body.id)}
|
||||||
{/each}
|
{#if item.node}
|
||||||
</svg>
|
{@const node = item.node}
|
||||||
|
{@const r = radius(node)}
|
||||||
|
{@const faded = dimmed(node.id)}
|
||||||
|
<a
|
||||||
|
class="cursor-pointer outline-none"
|
||||||
|
href={node.href ?? `#${node.id}`}
|
||||||
|
onclick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.detail === 0) {
|
||||||
|
onOpen?.(node.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
oncontextmenu={(event) => onContext(event, node)}
|
||||||
|
onpointerdown={(event) => onDown(event, item.body)}
|
||||||
|
onpointerenter={() => {
|
||||||
|
hovered = node.id;
|
||||||
|
}}
|
||||||
|
onpointerleave={() => {
|
||||||
|
hovered = null;
|
||||||
|
}}
|
||||||
|
style="opacity: {faded ? 0.25 : 1}; transition: opacity 150ms"
|
||||||
|
>
|
||||||
|
{#if node.state === "running"}
|
||||||
|
<circle
|
||||||
|
cx={item.body.x}
|
||||||
|
cy={item.body.y}
|
||||||
|
fill="url(#graph-glow)"
|
||||||
|
r={r * 4}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<circle
|
||||||
|
cx={item.body.x}
|
||||||
|
cy={item.body.y}
|
||||||
|
fill="transparent"
|
||||||
|
r={r + 8}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={item.body.x}
|
||||||
|
cy={item.body.y}
|
||||||
|
fill={fillOf(node)}
|
||||||
|
fill-opacity={node.state === "closed" ? 0.4 : 1}
|
||||||
|
{r}
|
||||||
|
stroke={hovered === node.id ? "var(--ring)" : strokeOf(node)}
|
||||||
|
stroke-opacity={node.state === "ghost" ? 0.6 : 1}
|
||||||
|
stroke-width={node.state === "waiting" ? 2.5 : 1.25}
|
||||||
|
/>
|
||||||
|
{#if labelled(node)}
|
||||||
|
<text
|
||||||
|
dominant-baseline="hanging"
|
||||||
|
fill={node.state === "ghost" || node.state === "closed"
|
||||||
|
? "var(--muted-foreground)"
|
||||||
|
: "var(--foreground)"}
|
||||||
|
font-size={FONT}
|
||||||
|
font-weight={node.ring === 0 || hovered === node.id ? 600 : 400}
|
||||||
|
paint-order="stroke"
|
||||||
|
stroke="var(--background)"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="3"
|
||||||
|
text-anchor="middle"
|
||||||
|
x={item.body.x}
|
||||||
|
y={item.body.y + r + 3}
|
||||||
|
>
|
||||||
|
{clip(node.label, near(node.id) ? LABEL_MAX_NEAR : LABEL_MAX)}
|
||||||
|
</text>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div class="absolute top-2 right-2 flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
aria-label="Fit the graph"
|
||||||
|
class="rule-word inline-flex size-7 items-center justify-center rounded-md bg-background/70 backdrop-blur"
|
||||||
|
onclick={refit}
|
||||||
|
title="Fit (double-click the canvas)"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ScanIcon class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label={expanded ? "Shrink the graph" : "Expand the graph"}
|
||||||
|
class="rule-word inline-flex size-7 items-center justify-center rounded-md bg-background/70 backdrop-blur"
|
||||||
|
onclick={toggleExpanded}
|
||||||
|
title={expanded ? "Shrink (Esc)" : "Expand"}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{#if expanded}
|
||||||
|
<Minimize2Icon class="size-3.5" />
|
||||||
|
{:else}
|
||||||
|
<Maximize2Icon class="size-3.5" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if nodes.length === 0}
|
||||||
|
<p
|
||||||
|
class="pointer-events-none absolute inset-0 flex items-center justify-center text-muted-foreground text-xs"
|
||||||
|
>
|
||||||
|
Nothing reached yet.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { LimitWindow } from "$lib/api/types";
|
import type { LimitWindow } from "$lib/api/types";
|
||||||
import { fmtCountdown, fmtMoney, fmtRelative, fmtTokens } from "$lib/format";
|
import { fmtCountdown, fmtMoney, fmtRelative, fmtTokens } from "$lib/format";
|
||||||
import { limitLabel } from "$lib/limits";
|
import { limitLabel, limitWord } from "$lib/limits";
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -28,10 +28,12 @@
|
|||||||
const contextPct = $derived(
|
const contextPct = $derived(
|
||||||
Math.min(100, Math.round((contextTokens / contextWindow) * 100))
|
Math.min(100, Math.round((contextTokens / contextWindow) * 100))
|
||||||
);
|
);
|
||||||
const known = (w: LimitWindow) =>
|
// The figure shown: the report's own, else the last one inside the window.
|
||||||
w.utilization !== null && w.utilization !== undefined;
|
const figure = (w: LimitWindow) => w.utilization ?? w.last_known?.utilization;
|
||||||
const pct = (w: LimitWindow) =>
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -65,16 +67,17 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#each windows as w (w.window)}
|
{#each windows as w (w.window)}
|
||||||
|
{@const known = figure(w) !== null && figure(w) !== undefined}
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<span class="label-quiet">{limitLabel(w.window)}</span>
|
<span class="label-quiet">{limitLabel(w.window)}</span>
|
||||||
{#if known(w)}
|
{#if known}
|
||||||
<span
|
<span
|
||||||
class={cn(
|
class={cn(
|
||||||
"tabular font-semibold text-2xl leading-none tracking-tight",
|
"tabular font-semibold text-2xl leading-none tracking-tight",
|
||||||
w.status === "rejected" && "text-destructive"
|
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-sm">%</span>
|
||||||
<span class="font-normal text-muted-foreground text-xs">
|
<span class="font-normal text-muted-foreground text-xs">
|
||||||
{fmtCountdown(w.resets_at, now).replace("resets ", "")}
|
{fmtCountdown(w.resets_at, now).replace("resets ", "")}
|
||||||
@@ -97,11 +100,23 @@
|
|||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-muted-foreground text-sm leading-none">
|
<span
|
||||||
no report
|
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>
|
||||||
<span class="text-muted-foreground text-xs">
|
<span class="tabular text-muted-foreground text-xs">
|
||||||
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)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,21 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
export const KIND_LETTER: Record<string, string> = {
|
||||||
|
branch: "B",
|
||||||
|
deep: "D",
|
||||||
|
fork: "F",
|
||||||
|
job: "J",
|
||||||
|
master: "M",
|
||||||
|
};
|
||||||
|
// What the letter stands for, for the tooltip and the legend.
|
||||||
|
export const KIND_MEANING: Record<string, string> = {
|
||||||
|
branch: "branch — a side thread off the master, merged back when done",
|
||||||
|
deep: "deep chat — a long thread of its own, closed with a digest",
|
||||||
|
fork: "fork — the agent's own copy of a thread, used to write a digest",
|
||||||
|
job: "job run — a scheduled or triggered task, no human in it",
|
||||||
|
master: "master — the day's main thread with the dispatcher",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Kind } from "$lib/api/types";
|
import type { Kind } from "$lib/api/types";
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
@@ -5,13 +23,6 @@
|
|||||||
let { kind, class: className = "" }: { kind: Kind | string; class?: string } =
|
let { kind, class: className = "" }: { kind: Kind | string; class?: string } =
|
||||||
$props();
|
$props();
|
||||||
|
|
||||||
const LETTER: Record<string, string> = {
|
|
||||||
branch: "B",
|
|
||||||
deep: "D",
|
|
||||||
fork: "F",
|
|
||||||
job: "J",
|
|
||||||
master: "M",
|
|
||||||
};
|
|
||||||
const TONE: Record<string, string> = {
|
const TONE: Record<string, string> = {
|
||||||
branch: "text-kind-branch bg-kind-branch/12",
|
branch: "text-kind-branch bg-kind-branch/12",
|
||||||
deep: "text-kind-deep bg-kind-deep/12",
|
deep: "text-kind-deep bg-kind-deep/12",
|
||||||
@@ -29,7 +40,7 @@
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
role="img"
|
role="img"
|
||||||
title={kind}
|
title={KIND_MEANING[kind] ?? kind}
|
||||||
>
|
>
|
||||||
{LETTER[kind] ?? "?"}
|
{KIND_LETTER[kind] ?? "?"}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
import type { ApiClient } from "$lib/api/client";
|
||||||
import type { ConversationSummary } from "$lib/api/types";
|
import type { ConversationSummary } from "$lib/api/types";
|
||||||
import { clip, fmtRelative, fmtTokens, shortId } from "$lib/format";
|
import { clip, fmtRelative, fmtTokens, shortId } from "$lib/format";
|
||||||
|
import ConversationMenu from "$lib/panel/conversation-menu.svelte";
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
import KindMark from "./kind-mark.svelte";
|
import KindMark from "./kind-mark.svelte";
|
||||||
import { type StripState, stateOf } from "./state";
|
import { type StripState, stateOf } from "./state";
|
||||||
@@ -17,6 +19,8 @@
|
|||||||
class: className = "",
|
class: className = "",
|
||||||
children,
|
children,
|
||||||
onclick,
|
onclick,
|
||||||
|
client = null,
|
||||||
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
row: ConversationSummary;
|
row: ConversationSummary;
|
||||||
href?: string;
|
href?: string;
|
||||||
@@ -28,6 +32,10 @@
|
|||||||
class?: string;
|
class?: string;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
onclick?: (id: string) => void;
|
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();
|
} = $props();
|
||||||
|
|
||||||
const TITLE_MAX = 72;
|
const TITLE_MAX = 72;
|
||||||
@@ -39,39 +47,84 @@
|
|||||||
);
|
);
|
||||||
const when = $derived(row.last_activity_at ?? row.created_at ?? null);
|
const when = $derived(row.last_activity_at ?? row.created_at ?? null);
|
||||||
const tag = $derived(href ? "a" : "button");
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:element
|
{#snippet body(extra: Record<string, unknown>)}
|
||||||
aria-current={current ? "true" : undefined}
|
<svelte:element
|
||||||
class={cn("strip text-left text-sm", className)}
|
{...extra}
|
||||||
data-row={row.id}
|
aria-current={current ? "true" : undefined}
|
||||||
data-state={state}
|
class={cn("strip text-left text-sm", className, extra.class as string)}
|
||||||
href={href ?? undefined}
|
data-row={row.id}
|
||||||
onclick={onclick ? () => onclick?.(row.id) : undefined}
|
data-state={state}
|
||||||
role={href ? undefined : "button"}
|
href={href ?? undefined}
|
||||||
this={tag}
|
onclick={activate}
|
||||||
type={href ? undefined : "button"}
|
role={href ? undefined : "button"}
|
||||||
>
|
tabindex="0"
|
||||||
<KindMark kind={row.kind} />
|
this={tag}
|
||||||
<span class="flex min-w-0 flex-col leading-tight">
|
type={href ? undefined : "button"}
|
||||||
<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}
|
<KindMark kind={row.kind} />
|
||||||
<span title="context of the last turn"
|
<span class="flex min-w-0 flex-col leading-tight">
|
||||||
>{fmtTokens(row.context_tokens)}</span
|
<span class="truncate font-medium">{title}</span>
|
||||||
>
|
{#if detail}
|
||||||
{/if}
|
<span class="truncate text-muted-foreground text-xs">
|
||||||
<span class="w-14 text-right">{fmtRelative(when, now)}</span>
|
{clip(detail, DETAIL_MAX)}
|
||||||
</span>
|
</span>
|
||||||
</svelte:element>
|
{/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}
|
{#if children && open}
|
||||||
<div class="strip-open">{@render children()}</div>
|
<div class="strip-open">{@render children()}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,14 +1,35 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { gateway } from "$lib/gateway.svelte";
|
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);
|
const running = $derived(gateway.running.length);
|
||||||
|
|
||||||
|
function meaning(kind: string): string {
|
||||||
|
return KIND_MEANING[kind]?.split(" — ")[1] ?? "";
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="flex flex-1 flex-col items-center justify-center gap-6 p-6">
|
||||||
class="flex flex-1 flex-col items-center justify-center gap-1 p-6 text-center text-muted-foreground text-sm"
|
<p class="text-center text-muted-foreground text-sm">
|
||||||
>
|
Pick a strip on the left.
|
||||||
<p>Pick a strip on the left.</p>
|
{#if running > 0}
|
||||||
{#if running > 0}
|
<span class="text-signal">{running} in motion right now.</span>
|
||||||
<p class="text-signal">{running} in motion right now.</p>
|
{/if}
|
||||||
{/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>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
@import "@fontsource-variable/inter";
|
@import "@fontsource-variable/inter";
|
||||||
@import "../lib/panel/panel.css";
|
@import "../lib/panel/panel.css";
|
||||||
|
|
||||||
@plugin "@tailwindcss/forms";
|
|
||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { fmtBytes, fmtDateTime, fmtRelative } from "$lib/format";
|
import { fmtBytes, fmtDateTime, fmtRelative } from "$lib/format";
|
||||||
import Markdown from "$lib/panel/markdown.svelte";
|
import Markdown from "$lib/panel/markdown.svelte";
|
||||||
import { session } from "$lib/session.svelte";
|
import { session } from "$lib/session.svelte";
|
||||||
|
import FloatMenu, { type FloatItem } from "$lib/shell/float-menu.svelte";
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
let tree = $state<MemoryTree | null>(null);
|
let tree = $state<MemoryTree | null>(null);
|
||||||
@@ -93,8 +94,35 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isMarkdown = $derived(file?.path.endsWith(".md") ?? false);
|
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>
|
</script>
|
||||||
|
|
||||||
|
<FloatMenu items={menuItems} x={menuAt.x} y={menuAt.y} bind:open={menuOpen} />
|
||||||
|
|
||||||
<svelte:head><title>Memory · Beaver</title></svelte:head>
|
<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)]">
|
<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"
|
selected === node.path && "bg-accent font-medium"
|
||||||
)}
|
)}
|
||||||
onclick={() => open(node.path)}
|
onclick={() => open(node.path)}
|
||||||
|
oncontextmenu={(event) => fileMenu(event, node.path)}
|
||||||
style="padding-left: {depth * 0.875 + 1.5}rem"
|
style="padding-left: {depth * 0.875 + 1.5}rem"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
>
|
>
|
||||||
<h1 class="font-semibold text-lg tracking-tight">System</h1>
|
<h1 class="font-semibold text-lg tracking-tight">System</h1>
|
||||||
{#each SYSTEM_PAGES as item (item.href)}
|
{#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
|
<a
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
class={cn("rule-word", active && "text-foreground")}
|
class={cn("rule-word", active && "text-foreground")}
|
||||||
|
|||||||
@@ -15,19 +15,16 @@
|
|||||||
const TICK_MS = 15_000;
|
const TICK_MS = 15_000;
|
||||||
const PAYLOAD_MAX = 120;
|
const PAYLOAD_MAX = 120;
|
||||||
|
|
||||||
let data = $state<JobsResponse | null>(null);
|
|
||||||
let failure = $state<string | null>(null);
|
let failure = $state<string | null>(null);
|
||||||
let busy = $state<string | null>(null);
|
let busy = $state<string | null>(null);
|
||||||
let timer: ReturnType<typeof setInterval> | undefined;
|
let timer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
|
const data = $derived<JobsResponse | null>(gateway.jobs);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const { client } = session;
|
|
||||||
if (!client) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
failure = null;
|
failure = null;
|
||||||
try {
|
try {
|
||||||
data = await client.jobs();
|
await gateway.refreshJobs();
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
failure = cause instanceof Error ? cause.message : String(cause);
|
failure = cause instanceof Error ? cause.message : String(cause);
|
||||||
}
|
}
|
||||||
@@ -258,8 +255,10 @@
|
|||||||
<div class="bay-rack">
|
<div class="bay-rack">
|
||||||
{#each runs as row (row.id)}
|
{#each runs as row (row.id)}
|
||||||
<Strip
|
<Strip
|
||||||
|
client={session.client}
|
||||||
detail={row.last_item?.text ?? ""}
|
detail={row.last_item?.text ?? ""}
|
||||||
href="{base}/conversations/{row.id}"
|
href="{base}/conversations/{row.id}"
|
||||||
|
onChanged={() => gateway.load().catch(() => undefined)}
|
||||||
{row}
|
{row}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
fmtMoney,
|
fmtMoney,
|
||||||
fmtPct,
|
fmtPct,
|
||||||
fmtRelative,
|
fmtRelative,
|
||||||
|
fmtTime,
|
||||||
fmtTokens,
|
fmtTokens,
|
||||||
shortId,
|
shortId,
|
||||||
} from "$lib/format";
|
} from "$lib/format";
|
||||||
import { gateway } from "$lib/gateway.svelte";
|
import { gateway } from "$lib/gateway.svelte";
|
||||||
import { limitLabel } from "$lib/limits";
|
import { limitLabel, limitWord } from "$lib/limits";
|
||||||
import { session } from "$lib/session.svelte";
|
import { session } from "$lib/session.svelte";
|
||||||
import KindMark from "$lib/shell/kind-mark.svelte";
|
import KindMark from "$lib/shell/kind-mark.svelte";
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
{ label: "by day", value: "day" },
|
{ label: "by day", value: "day" },
|
||||||
];
|
];
|
||||||
const TICK_MS = 30_000;
|
const TICK_MS = 30_000;
|
||||||
|
const SECONDS = /:\d{2}$/;
|
||||||
const LOW_CACHE = 0.5;
|
const LOW_CACHE = 0.5;
|
||||||
const BAR: Record<string, string> = {
|
const BAR: Record<string, string> = {
|
||||||
allowed: "bg-primary",
|
allowed: "bg-primary",
|
||||||
@@ -104,13 +106,14 @@
|
|||||||
<h1 class="font-semibold text-lg tracking-tight">Subscription</h1>
|
<h1 class="font-semibold text-lg tracking-tight">Subscription</h1>
|
||||||
{#if windows.length === 0}
|
{#if windows.length === 0}
|
||||||
<p class="doc text-muted-foreground text-sm">
|
<p class="doc text-muted-foreground text-sm">
|
||||||
No rate-limit report yet. The SDK sends one the first time a window
|
No rate-limit report yet. The API sends one with the first turn.
|
||||||
changes state; until then there is nothing to show.
|
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each windows as w (w.window)}
|
{#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">
|
<div class="flex flex-col gap-2">
|
||||||
<span class="label-quiet">{limitLabel(w.window)}</span>
|
<span class="label-quiet">{limitLabel(w.window)}</span>
|
||||||
{#if known}
|
{#if known}
|
||||||
@@ -120,7 +123,7 @@
|
|||||||
w.status === "rejected" && "text-destructive"
|
w.status === "rejected" && "text-destructive"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{pct(w.utilization)}
|
{bound ? "≥ " : ""}{pct(figure)}
|
||||||
<span class="font-normal text-lg text-muted-foreground"
|
<span class="font-normal text-lg text-muted-foreground"
|
||||||
>%</span
|
>%</span
|
||||||
>
|
>
|
||||||
@@ -128,38 +131,52 @@
|
|||||||
<span
|
<span
|
||||||
aria-valuemax="100"
|
aria-valuemax="100"
|
||||||
aria-valuemin="0"
|
aria-valuemin="0"
|
||||||
aria-valuenow={pct(w.utilization)}
|
aria-valuenow={pct(figure)}
|
||||||
class="h-1 w-full overflow-hidden rounded-full bg-muted"
|
class="h-1 w-full overflow-hidden rounded-full bg-muted"
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class={cn("block h-full rounded-full", BAR[w.status])}
|
class={cn("block h-full rounded-full", BAR[w.status])}
|
||||||
style="width: {pct(w.utilization)}%"
|
style="width: {pct(figure)}%"
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="tabular text-muted-foreground text-xs">
|
{#if bound && w.last_known}
|
||||||
{fmtCountdown(w.resets_at, now)}
|
<span class="text-muted-foreground text-xs">
|
||||||
· gateway share
|
{limitWord(w.status)}
|
||||||
{fmtTokens(
|
now · the figure is from
|
||||||
w.gateway.input + w.gateway.output + w.gateway.cache_creation
|
{fmtRelative(w.last_known.ts, now)}
|
||||||
)}
|
</span>
|
||||||
tokens · {fmtMoney(w.gateway.cost_usd)}
|
{/if}
|
||||||
</span>
|
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-2xl text-muted-foreground leading-none">
|
<span
|
||||||
no report
|
class={cn(
|
||||||
|
"font-semibold text-3xl leading-none tracking-tight",
|
||||||
|
w.status === "rejected" && "text-destructive"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{limitWord(w.status)}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-muted-foreground text-xs">
|
<span class="text-muted-foreground text-xs">
|
||||||
last seen {fmtRelative(w.ts, now)} ·
|
no figure · seen {fmtRelative(w.ts, now)}
|
||||||
{fmtCountdown(
|
|
||||||
w.resets_at,
|
|
||||||
now
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user