feat(ui,api): strip board redesign - island, rail by day, context view, server search, vault graph

This commit is contained in:
hh
2026-09-02 03:48:26 +02:00
parent 85b14e2c2f
commit 025f2dbc3f
60 changed files with 5071 additions and 2347 deletions
+201
View File
@@ -0,0 +1,201 @@
"""What a conversation's model holds: prompt granules, skills, tools, files touched."""
from __future__ import annotations
import re
from collections import Counter
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.conversations.kinds import Kind
__all__ = ["compose", "files_touched", "granules", "skill_sets"]
CHARS_PER_TOKEN = 3.2
READ_TOOLS = frozenset({"Read", "NotebookRead"})
WRITE_TOOLS = frozenset({"Write", "Edit", "MultiEdit", "NotebookEdit"})
SEARCH_TOOLS = frozenset({"Glob", "Grep"})
PATH_KEYS = ("file_path", "notebook_path", "path", "file", "filename")
_FRONTMATTER = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL)
def _tokens(chars: int) -> int:
return round(chars / CHARS_PER_TOKEN)
def _relative(path: str, cwd: Path | None) -> str:
if cwd is None:
return path
try:
return str(Path(path).relative_to(cwd))
except ValueError:
return path
def _tool_uses(entries: Iterable[Mapping[str, Any]]) -> Iterable[tuple[str, Any, str]]:
for entry in entries:
message = entry.get("message")
if entry.get("type") != "assistant" or not isinstance(message, dict):
continue
content = message.get("content")
if not isinstance(content, list):
continue
stamp = str(entry.get("timestamp") or "")
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_use":
yield str(block.get("name") or "?"), block.get("input"), stamp
def files_touched(
entries: Iterable[Mapping[str, Any]], *, cwd: Path | None = None
) -> tuple[list[dict[str, Any]], dict[str, int]]:
"""Files named in tool inputs, with how they were touched, plus tool counts."""
files: dict[str, dict[str, Any]] = {}
counts: Counter[str] = Counter()
for name, tool_input, stamp in _tool_uses(entries):
counts[name] += 1
if not isinstance(tool_input, dict):
continue
raw = next(
(v for k in PATH_KEYS if isinstance(v := tool_input.get(k), str) and v),
None,
)
if raw is None or (name in SEARCH_TOOLS and "file_path" not in tool_input):
continue
key = _relative(raw, cwd)
row = files.setdefault(
key, {"path": key, "reads": 0, "writes": 0, "other": 0, "last_at": stamp}
)
if name in READ_TOOLS:
row["reads"] += 1
elif name in WRITE_TOOLS:
row["writes"] += 1
else:
row["other"] += 1
row["last_at"] = max(row["last_at"], stamp)
ordered = sorted(files.values(), key=lambda r: r["last_at"], reverse=True)
return ordered, dict(counts)
def granules(agent: ClaudeAgent, kind: Kind) -> dict[str, Any]:
"""The system prompt as its sources: tag, path, size, token estimate."""
sources = agent.prompt_for(kind) if agent.serves(kind) else None
rows: list[dict[str, Any]] = []
if sources is None:
chars = len(agent.system_prompt)
if chars:
rows.append(
{
"tag": None,
"path": None,
"bytes": chars,
"tokens_est": _tokens(chars),
}
)
else:
for source in sources:
tag, raw = source if isinstance(source, tuple) else (None, source)
path = Path(raw)
try:
chars = len(path.read_text(encoding="utf-8"))
except OSError:
chars = 0
rows.append(
{
"tag": tag,
"path": _relative(str(path), agent.cwd),
"bytes": chars,
"tokens_est": _tokens(chars),
}
)
total = sum(r["bytes"] for r in rows)
return {"bytes": total, "tokens_est": _tokens(total), "granules": rows}
def _skill_meta(skill_md: Path) -> dict[str, str]:
try:
text = skill_md.read_text(encoding="utf-8")
except OSError:
return {}
match = _FRONTMATTER.match(text)
meta: dict[str, str] = {}
if match:
for line in match.group(1).splitlines():
key, sep, value = line.partition(":")
if sep:
meta[key.strip()] = value.strip()
return meta
def skill_sets(agent: ClaudeAgent, kind: Kind) -> list[dict[str, Any]]:
"""Skill-set directories the kind loads, with each skill's name and description."""
out: list[dict[str, Any]] = []
for directory in agent.skills_for(kind):
skills = []
try:
children = sorted(p for p in directory.iterdir() if p.is_dir())
except OSError:
children = []
for child in children:
skill_md = child / "SKILL.md"
if skill_md.is_file():
meta = _skill_meta(skill_md)
skills.append(
{
"name": meta.get("name") or child.name,
"description": meta.get("description", ""),
"path": _relative(str(child), agent.cwd),
}
)
out.append(
{
"set": directory.name,
"path": _relative(str(directory), agent.cwd),
"skills": skills,
}
)
return out
def compose(
agent: ClaudeAgent,
kind: Kind,
entries: Iterable[Mapping[str, Any]],
*,
context_tokens: int,
) -> dict[str, Any]:
files, counts = files_touched(entries, cwd=agent.cwd)
prompt = granules(agent, kind)
skills = skill_sets(agent, kind)
skills_chars = sum(
len(s["name"]) + len(s["description"])
for group in skills
for s in group["skills"]
)
return {
"agent": {
"name": agent.name,
"model": agent.model,
"effort": agent.options.effort,
},
"kind": kind,
"context_tokens": context_tokens,
"prompt": prompt,
"skills": skills,
"skills_tokens_est": _tokens(skills_chars),
"tools": {
"allowed": list(agent.options.tools) if agent.options.tools else None,
"disallowed": list(agent.options.disallowed_tools),
"gateway": list(agent.gateway_tools),
"mcps": [m.name for m in agent.expose_mcps],
},
"files": files,
"tool_counts": counts,
"history_tokens_est": max(
0, context_tokens - prompt["tokens_est"] - _tokens(skills_chars)
),
}
+47
View File
@@ -8,6 +8,8 @@ from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import String, func
from sqlalchemy import cast as sa_cast
from sqlmodel import col, select from sqlmodel import col, select
from beaver_gateway.backends.transcript import ( from beaver_gateway.backends.transcript import (
@@ -23,6 +25,7 @@ from beaver_gateway.storage.models import (
ConversationBinding, ConversationBinding,
ConversationMessage, ConversationMessage,
RateLimit, RateLimit,
TranscriptEntry,
Usage, Usage,
) )
@@ -132,6 +135,30 @@ class Rows(State):
async with self._db.session() as session: async with self._db.session() as session:
return list((await session.exec(stmt)).all()) return list((await session.exec(stmt)).all())
async def search(self, query: str, *, limit: int = 20) -> list[Conversation]:
needle = f"%{query}%"
by_title = select(Conversation.id).where(col(Conversation.title).ilike(needle))
by_first = select(ConversationMessage.conversation_id).where(
ConversationMessage.seq == 0,
col(ConversationMessage.content_json).ilike(needle),
)
by_entry = (
select(Conversation.id)
.join(
TranscriptEntry,
col(TranscriptEntry.session_id) == col(Conversation.session_id),
)
.where(sa_cast(col(TranscriptEntry.entry), String).ilike(needle))
)
stmt = (
select(Conversation)
.where(col(Conversation.id).in_(by_title.union(by_first, by_entry)))
.order_by(col(Conversation.id).desc())
.limit(limit)
)
async with self._db.session() as session:
return list((await session.exec(stmt)).all())
async def bindings(self, conv: Conversation) -> list[ConversationBinding]: async def bindings(self, conv: Conversation) -> list[ConversationBinding]:
async with self._db.session() as session: async with self._db.session() as session:
result = await session.exec( result = await session.exec(
@@ -317,6 +344,7 @@ class Rows(State):
async def describe(self, conv: Conversation) -> dict[str, Any]: async def describe(self, conv: Conversation) -> dict[str, Any]:
out = self.public(conv) out = self.public(conv)
out["title"] = await self.implied_title(conv) out["title"] = await self.implied_title(conv)
out["context_tokens"] = await self.context_tokens(conv)
parent = await self.get_row(conv.parent_id) if conv.parent_id else None parent = await self.get_row(conv.parent_id) if conv.parent_id else None
out["parent"] = parent.external_id if parent is not None else None out["parent"] = parent.external_id if parent is not None else None
out["bindings"] = [ out["bindings"] = [
@@ -359,6 +387,25 @@ class Rows(State):
).first() ).first()
return context_of(row) return context_of(row)
async def context_tokens_by_id(self, ids: Iterable[str]) -> dict[str, int]:
wanted = list(ids)
if not wanted:
return {}
latest = (
select(func.max(Usage.id))
.where(col(Usage.conversation_id).in_(wanted))
.group_by(Usage.conversation_id)
)
async with self._db.session() as session:
rows = (
await session.exec(select(Usage).where(col(Usage.id).in_(latest)))
).all()
return {
cast("str", row.conversation_id): context_of(row)
for row in rows
if row.conversation_id
}
async def usage_tokens(self, since: datetime) -> int: async def usage_tokens(self, since: datetime) -> int:
async with self._db.session() as session: async with self._db.session() as session:
rows = ( rows = (
+121 -2
View File
@@ -18,6 +18,10 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from sqlmodel import col, select from sqlmodel import col, select
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.agents.prompts import assemble
from beaver_gateway.backends.transcript import prompt_count
from beaver_gateway.conversations.context import compose
from beaver_gateway.conversations.injects import URGENCY from beaver_gateway.conversations.injects import URGENCY
from beaver_gateway.conversations.kinds import Kind, as_kind from beaver_gateway.conversations.kinds import Kind, as_kind
from beaver_gateway.conversations.service import SEEDS, implied_title from beaver_gateway.conversations.service import SEEDS, implied_title
@@ -45,6 +49,7 @@ from beaver_gateway.storage.models import (
Token, Token,
Usage, Usage,
) )
from beaver_gateway.vault.links import LinkIndex
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterable, Sequence from collections.abc import AsyncIterator, Iterable, Sequence
@@ -70,6 +75,10 @@ WINDOWS: dict[str, timedelta] = {
MEMORY_MAX_DEPTH = 12 MEMORY_MAX_DEPTH = 12
MEMORY_MAX_FILE = 2_000_000 MEMORY_MAX_FILE = 2_000_000
MEMORY_MAX_ENTRIES = 5000 MEMORY_MAX_ENTRIES = 5000
SEARCH_MIN = 2
GRAPH_LIMIT = 120
SEARCH_LIMIT = 20
SNIPPET_MAX = 160
class ApiFrontend(Frontend): class ApiFrontend(Frontend):
@@ -85,12 +94,14 @@ class ApiFrontend(Frontend):
deep_agent: str | None = None, deep_agent: str | None = None,
job_agent: str | None = None, job_agent: str | None = None,
memory_root: Path | None = None, memory_root: Path | None = None,
vault_root: Path | None = None,
) -> None: ) -> None:
self.master_agent = master_agent self.master_agent = master_agent
self.branch_agent = branch_agent self.branch_agent = branch_agent
self.deep_agent = deep_agent self.deep_agent = deep_agent
self.job_agent = job_agent self.job_agent = job_agent
self.memory_root = memory_root.resolve() if memory_root is not None else None self.memory_root = memory_root.resolve() if memory_root is not None else None
self.vault_root = vault_root.resolve() if vault_root is not None else None
self._app: FastAPI | None = None self._app: FastAPI | None = None
def agent_for(self, kind: Kind) -> str | None: def agent_for(self, kind: Kind) -> str | None:
@@ -105,14 +116,22 @@ class ApiFrontend(Frontend):
if runtime.conversations is None or runtime.bus is None: if runtime.conversations is None or runtime.bus is None:
msg = "ApiFrontend needs runtime.conversations and runtime.bus" msg = "ApiFrontend needs runtime.conversations and runtime.bus"
raise RuntimeError(msg) raise RuntimeError(msg)
self._app = build_app(runtime, memory_root=self.memory_root) self._app = build_app(
runtime, memory_root=self.memory_root, vault_root=self.vault_root
)
def app(self) -> FastAPI | None: def app(self) -> FastAPI | None:
return self._app return self._app
def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> FastAPI: # noqa: PLR0915 def build_app( # noqa: PLR0915
runtime: GatewayRuntime,
*,
memory_root: Path | None = None,
vault_root: Path | None = None,
) -> FastAPI:
app = FastAPI(title="beaver-gateway / API") app = FastAPI(title="beaver-gateway / API")
links = LinkIndex(vault_root) if vault_root is not None else None
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
@@ -218,17 +237,49 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
firsts = await conversations.first_user_texts( firsts = await conversations.first_user_texts(
cast("int", r.id) for r in rows if not r.title cast("int", r.id) for r in rows if not r.title
) )
contexts = await conversations.context_tokens_by_id(r.external_id for r in rows)
public_ids = {r.id: r.external_id for r in rows}
return { return {
"conversations": [ "conversations": [
{ {
**conversations.public(r), **conversations.public(r),
"title": r.title or implied_title(firsts.get(cast("int", r.id))), "title": r.title or implied_title(firsts.get(cast("int", r.id))),
"last_item": _queue_item(latest.get(cast("int", r.id))), "last_item": _queue_item(latest.get(cast("int", r.id))),
"context_tokens": contexts.get(r.external_id, 0),
"parent": public_ids.get(r.parent_id) if r.parent_id else None,
} }
for r in rows for r in rows
] ]
} }
@app.get("/search")
async def search(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
query = (request.query_params.get("q") or "").strip()
if len(query) < SEARCH_MIN:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"q must be at least {SEARCH_MIN} chars"
)
limit = min(query_int(request, "limit", SEARCH_LIMIT), SEARCH_LIMIT)
rows = await conversations.search(query, limit=limit)
firsts = await conversations.first_user_texts(
cast("int", r.id) for r in rows if not r.title
)
contexts = await conversations.context_tokens_by_id(r.external_id for r in rows)
files = _grep_memory(memory_root, query, limit=limit) if memory_root else []
return {
"query": query,
"conversations": [
{
**conversations.public(r),
"title": r.title or implied_title(firsts.get(cast("int", r.id))),
"context_tokens": contexts.get(r.external_id, 0),
}
for r in rows
],
"files": files,
}
@app.post("/conversations", status_code=status.HTTP_201_CREATED) @app.post("/conversations", status_code=status.HTTP_201_CREATED)
async def create_conversation(request: Request) -> dict[str, Any]: async def create_conversation(request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=SCOPE) token = await require_token(request, runtime, scope=SCOPE)
@@ -301,6 +352,45 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
conv = await conv_of(public_id) conv = await conv_of(public_id)
return {"id": conv.external_id, "messages": await conversations.history(conv)} return {"id": conv.external_id, "messages": await conversations.history(conv)}
@app.get("/conversations/{public_id}/context")
async def get_context(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
agent = runtime.agents.get(conv.agent_name)
if not isinstance(agent, ClaudeAgent):
raise HTTPException(
status.HTTP_404_NOT_FOUND, f"no claude agent behind {public_id}"
)
entries = list(await conversations.entries(conv))
for sub in await conversations.subpaths(conv):
entries.extend(await conversations.entries(conv, subpath=sub))
out = compose(
agent,
as_kind(conv.kind),
entries,
context_tokens=await conversations.context_tokens(conv),
)
out["id"] = conv.external_id
out["turns"] = prompt_count(entries)
if request.query_params.get("prompt"):
sources = agent.prompt_for(as_kind(conv.kind))
out["prompt_text"] = (
assemble(sources) if sources is not None else agent.system_prompt
)
return out
@app.get("/vault/graph")
async def vault_graph(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
if links is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"no vault root: set ApiFrontend(vault_root=...)",
)
paths = [p for p in request.query_params.getlist("path") if p]
limit = min(query_int(request, "limit", GRAPH_LIMIT), GRAPH_LIMIT)
return {"root": str(links.root), **links.neighbours(paths, limit=limit)}
@app.get("/conversations/{public_id}/entries") @app.get("/conversations/{public_id}/entries")
async def get_entries(public_id: str, request: Request) -> dict[str, Any]: async def get_entries(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
@@ -993,6 +1083,35 @@ def _memory_path(root: Path, raw: str) -> Path:
return target return target
def _grep_memory(root: Path, query: str, *, limit: int) -> list[dict[str, Any]]:
needle = query.casefold()
hits: list[dict[str, Any]] = []
for path in sorted(root.rglob("*")):
if len(hits) >= limit:
break
if not path.is_file() or any(part.startswith(".") for part in path.parts):
continue
if path.suffix not in (".md", ".txt", ".json", ".yaml", ".yml"):
continue
try:
if path.stat().st_size > MEMORY_MAX_FILE:
continue
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
rel = str(path.relative_to(root))
if needle in rel.casefold():
hits.append({"path": rel, "line": 0, "snippet": ""})
continue
for number, line in enumerate(text.splitlines(), 1):
if needle in line.casefold():
hits.append(
{"path": rel, "line": number, "snippet": line.strip()[:SNIPPET_MAX]}
)
break
return hits
def _tree(root: Path, directory: Path, *, depth: int) -> list[dict[str, Any]]: def _tree(root: Path, directory: Path, *, depth: int) -> list[dict[str, Any]]:
if depth > MEMORY_MAX_DEPTH: if depth > MEMORY_MAX_DEPTH:
return [] return []
+136
View File
@@ -0,0 +1,136 @@
"""Wikilink index over a vault: which note links to which, refreshed by mtime."""
from __future__ import annotations
import os
import re
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
__all__ = ["LinkIndex"]
_WIKILINK = re.compile(r"\[\[([^\]|#\n]+)(?:#[^\]|\n]*)?(?:\|[^\]\n]*)?\]\]")
_MD = ".md"
@dataclass(slots=True)
class _Note:
mtime: float
links: set[str] = field(default_factory=set)
class LinkIndex:
"""Forward and backward links between the vault's markdown notes."""
def __init__(self, root: Path, *, max_age: float = 20.0) -> None:
self.root = root.resolve()
self.max_age = max_age
self._notes: dict[str, _Note] = {}
self._raw: dict[str, set[str]] = {}
self._scanned_at = 0.0
def refresh(self, *, force: bool = False) -> None:
if not force and time.monotonic() - self._scanned_at < self.max_age:
return
seen: set[str] = set()
for dirpath, dirnames, filenames in os.walk(self.root):
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
for name in filenames:
if not name.endswith(_MD):
continue
path = Path(dirpath) / name
rel = str(path.relative_to(self.root))
seen.add(rel)
try:
mtime = path.stat().st_mtime
except OSError:
continue
note = self._notes.get(rel)
if note is None or note.mtime != mtime:
self._notes[rel] = _Note(mtime=mtime)
self._raw[rel] = self._targets(path)
for rel in list(self._notes):
if rel not in seen:
self._notes.pop(rel, None)
self._raw.pop(rel, None)
self._resolve()
self._scanned_at = time.monotonic()
@staticmethod
def _targets(path: Path) -> set[str]:
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return set()
return {m.group(1).strip() for m in _WIKILINK.finditer(text)}
def _resolve(self) -> None:
by_stem: dict[str, str] = {}
for rel in sorted(self._notes):
by_stem.setdefault(Path(rel).stem.casefold(), rel)
by_path = {rel.casefold(): rel for rel in self._notes}
for rel, targets in self._raw.items():
links: set[str] = set()
for target in targets:
candidate = target if target.endswith(_MD) else f"{target}{_MD}"
found = by_path.get(candidate.casefold()) or by_stem.get(
Path(target).stem.casefold()
)
if found and found != rel:
links.add(found)
self._notes[rel].links = links
def resolve(self, raw: str) -> str:
"""A path as a tool named it (absolute or relative) as an index key."""
path = Path(raw)
if path.is_absolute():
try:
return str(path.resolve().relative_to(self.root))
except (ValueError, OSError):
return raw
return raw
def neighbours(self, paths: list[str], *, limit: int = 80) -> dict[str, Any]:
"""Nodes for ``paths`` plus every note one link away, and their edges."""
self.refresh()
touched = [self.resolve(p) for p in paths]
backlinks: dict[str, set[str]] = {}
for rel, note in self._notes.items():
for target in note.links:
backlinks.setdefault(target, set()).add(rel)
nodes: dict[str, dict[str, Any]] = {}
for rel in touched:
nodes[rel] = {
"path": rel,
"title": Path(rel).stem,
"touched": True,
"exists": rel in self._notes,
}
for rel in touched:
note = self._notes.get(rel)
around = (note.links if note else set()) | backlinks.get(rel, set())
for other in sorted(around):
if len(nodes) >= limit:
break
nodes.setdefault(
other,
{
"path": other,
"title": Path(other).stem,
"touched": False,
"exists": True,
},
)
edges: list[dict[str, str]] = []
for rel in nodes:
note = self._notes.get(rel)
if note is None:
continue
edges.extend(
{"from": rel, "to": target}
for target in sorted(note.links)
if target in nodes
)
return {"nodes": list(nodes.values()), "edges": edges}
+89 -1
View File
@@ -29,7 +29,7 @@ from beaver_gateway.frontends.api import ApiFrontend
from beaver_gateway.frontends.api.frontend import build_app as build_api from beaver_gateway.frontends.api.frontend import build_app as build_api
from beaver_gateway.frontends.base import Frontend, GatewayRuntime from beaver_gateway.frontends.base import Frontend, GatewayRuntime
from beaver_gateway.frontends.root import build_root_app from beaver_gateway.frontends.root import build_root_app
from beaver_gateway.storage.models import RateLimit, Usage from beaver_gateway.storage.models import RateLimit, TranscriptEntry, Usage
TOKEN = "tok" TOKEN = "tok"
HEADERS = {"Authorization": f"Bearer {TOKEN}"} HEADERS = {"Authorization": f"Bearer {TOKEN}"}
@@ -525,3 +525,91 @@ async def test_bind_without_a_window_materializes_one(world: World) -> None:
headers=HEADERS, headers=HEADERS,
) )
assert unknown.status_code == 400 assert unknown.status_code == 400
async def test_search_finds_conversations_and_memory_files(world: World) -> None:
root = Path(tempfile.mkdtemp(prefix="beaver-mem-"))
(root / "notes").mkdir()
(root / "notes" / "studio.md").write_text("# studio\nthe kiln is warm\n")
api = Api(world, memory_root=root)
conv = await world.conversations.create(kind="master", agent="a", origin="test")
await world.conversations.set_title(conv, "kiln schedule")
other = await world.conversations.create(kind="deep", agent="d", origin="test")
await world.conversations.post(other, "go")
await world.settle(other, 1)
other = await world.conversations.get(other.external_id)
assert other is not None and other.session_id is not None
async with world.db.session() as db:
db.add(
TranscriptEntry(
project_key="p",
session_id=other.session_id,
seq=0,
entry={"type": "assistant", "message": {"content": "warm kiln"}},
)
)
await db.commit()
found = await api.get("/search", {"q": "kiln"})
assert {c["id"] for c in found["conversations"]} == {
conv.external_id,
other.external_id,
}
assert found["files"] == [
{"path": "notes/studio.md", "line": 2, "snippet": "the kiln is warm"}
]
short = await api.http.get("/search", params={"q": "k"}, headers=HEADERS)
assert short.status_code == 400
async def test_context_endpoint_reports_prompt_and_files(world: World) -> None:
api = Api(world)
conv = await world.conversations.create(kind="master", agent="a", origin="test")
await world.conversations.post(conv, "go")
await world.settle(conv, 1)
conv = await world.conversations.get(conv.external_id)
assert conv is not None and conv.session_id is not None
await world.store.append(
world.key(conv.session_id),
build_entries(
[
{"role": "user", "content": "go"},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "t1",
"name": "Read",
"input": {"file_path": str(world.root / "note.md")},
}
],
},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "t1", "content": "x"}
],
},
],
session_id=conv.session_id,
cwd=str(world.root),
model="m",
),
)
ctx = await api.get(f"/conversations/{conv.external_id}/context", {"prompt": 1})
assert ctx["agent"]["name"] == "a"
assert ctx["prompt"]["granules"][0]["bytes"] == len("hi")
assert ctx["prompt_text"] == "hi"
assert ctx["files"] == [
{
"path": "note.md",
"reads": 1,
"writes": 0,
"other": 0,
"last_at": ctx["files"][0]["last_at"],
}
]
assert ctx["tool_counts"] == {"Read": 1}
assert ctx["turns"] == 1
graph = await api.http.get("/vault/graph", headers=HEADERS)
assert graph.status_code == 404
+104
View File
@@ -0,0 +1,104 @@
import tempfile
from pathlib import Path
from beaver_gateway.agents.claude import ClaudeAgent, Prompts
from beaver_gateway.conversations.context import compose, files_touched, granules
from beaver_gateway.vault.links import LinkIndex
def entry(name: str, tool_input: dict, stamp: str = "2026-09-02T10:00:00Z") -> dict:
return {
"type": "assistant",
"timestamp": stamp,
"message": {
"content": [{"type": "tool_use", "name": name, "input": tool_input}]
},
}
def test_files_touched_groups_by_path_relative_to_cwd() -> None:
cwd = Path("/vault")
files, counts = files_touched(
[
entry("Read", {"file_path": "/vault/a.md"}, "2026-09-02T10:00:00Z"),
entry("Edit", {"file_path": "/vault/a.md"}, "2026-09-02T10:05:00Z"),
entry("Grep", {"pattern": "x", "path": "/vault"}),
entry("Bash", {"command": "ls"}),
entry("Read", {"file_path": "/elsewhere/b.md"}, "2026-09-02T09:00:00Z"),
],
cwd=cwd,
)
assert [f["path"] for f in files] == ["a.md", "/elsewhere/b.md"]
assert files[0] == {
"path": "a.md",
"reads": 1,
"writes": 1,
"other": 0,
"last_at": "2026-09-02T10:05:00Z",
}
assert counts == {"Read": 2, "Edit": 1, "Grep": 1, "Bash": 1}
def test_compose_reads_granules_and_skills() -> None:
root = Path(tempfile.mkdtemp(prefix="beaver-ctx-"))
(root / "voice.md").write_text("be brief " * 40)
(root / "env.md").write_text("you are in the master thread")
skills = root / "skills" / "common"
(skills / "ops").mkdir(parents=True)
(skills / "ops" / "SKILL.md").write_text(
"---\nname: ops\ndescription: servers\n---\n"
)
agent = ClaudeAgent(
name="a",
model="m",
cwd=root,
prompts=Prompts(master=(("voice", root / "voice.md"), root / "env.md")),
skill_sets=(skills,),
gateway_tools=("say",),
)
out = compose(
agent,
"master",
[entry("Read", {"file_path": str(root / "env.md")})],
context_tokens=50_000,
)
assert [g["path"] for g in out["prompt"]["granules"]] == ["voice.md", "env.md"]
assert out["prompt"]["granules"][0]["tag"] == "voice"
assert out["prompt"]["tokens_est"] > 0
assert out["skills"] == [
{
"set": "common",
"path": "skills/common",
"skills": [
{"name": "ops", "description": "servers", "path": "skills/common/ops"}
],
}
]
assert out["tools"]["gateway"] == ["say"]
assert out["files"][0]["path"] == "env.md"
assert out["history_tokens_est"] < 50_000
assert granules(agent, "deep")["granules"] == []
def test_link_index_resolves_stems_and_neighbours() -> None:
root = Path(tempfile.mkdtemp(prefix="beaver-links-"))
(root / "people").mkdir()
(root / "people" / "Marta.md").write_text("works at [[Studio]] with [[Ilya|him]]")
(root / "Studio.md").write_text("see [[people/Marta]] and [[nowhere]]")
(root / "Ilya.md").write_text("plain")
(root / ".obsidian").mkdir()
(root / ".obsidian" / "x.md").write_text("[[Studio]]")
index = LinkIndex(root)
graph = index.neighbours([str(root / "people" / "Marta.md")])
nodes = {n["path"]: n for n in graph["nodes"]}
assert nodes["people/Marta.md"]["touched"] is True
assert set(nodes) == {"people/Marta.md", "Studio.md", "Ilya.md"}
assert {(e["from"], e["to"]) for e in graph["edges"]} == {
("people/Marta.md", "Studio.md"),
("people/Marta.md", "Ilya.md"),
("Studio.md", "people/Marta.md"),
}
missing = index.neighbours(["gone.md"])
assert missing["nodes"] == [
{"path": "gone.md", "title": "gone", "touched": True, "exists": False}
]
+15 -12
View File
@@ -10,18 +10,21 @@
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
<!-- <!--
impeccable direction contract (seed 20df1617, mode operate, brief-pinned world) impeccable direction contract (seed 071a8c77, reroll 1 bolder, mode operate, user-steered fusion)
THESIS: activity first. The console opens on what the agents are doing right now, as a live THESIS: the dispatcher's strip board under a live island. What is alive is a strip in a bay
ledger of turns and tool calls, and refuses the hero-metric dashboard grid of cards. (waiting for you, in motion, quiet); the island at the top of every screen carries the live
OWN-WORLD: msos mauve/pink tokens on Inter; two neutral layers (cooler sidebar, content surface); state and opens into the board. Refuses the sidebar-and-cards admin dashboard.
one accent for selection and the live state; kind hues from the msos status palette; hairline OWN-WORLD: plum signal on a warm rack ground; strips as white/aubergine objects with a state
borders, tabular numerals, rows and rails instead of cards; no cards inside cards. edge (plum pulse = running, amber = waiting), fixed columns, hairline rules, tabular numerals,
STORY: the operator lands, sees what runs and how much quota is left, opens a thread, watches system sans; amber only for what waits for the operator; no cards inside cards, no badges as
tools and subagents stream, answers a question, checks spend, manages tokens and memory. pills, no decorative motion.
FIRST VIEWPORT: sidebar left with the gateway connection dot; main opens with the "now" ledger STORY: the operator lands on the board, answers a question on its strip, watches a running
(running turns as live rows), then quota bars and the 5 h / 7 d spend row, then live sessions. strip, opens a thread where only the thread lives, reads context as a counter, finds anything
Primary action: open a running conversation. with ⌘K, and reaches the text-heavy rooms (memory, usage, system) as wide documents.
FORM: dense operator console, brief-pinned (roll assigned index 7, superseded by the pin). FIRST VIEWPORT: top bar with four section words left, the island centered (pulse, questions,
context, quota), System right. Below: the instrument row (context / 5 h / 7 d / spend), then
the bays with strips, the living graph of the master's neighbourhood on the right.
FORM: strip board × live activity; user-steered over the dealt hand; seed key 071a8c77.
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review,
the verdict, and DESIGN.md the verdict, and DESIGN.md
--> -->
+24
View File
@@ -3,6 +3,7 @@ import type {
AgentsResponse, AgentsResponse,
AuditPage, AuditPage,
BusEvent, BusEvent,
ContextResponse,
ConversationInfo, ConversationInfo,
ConversationSummary, ConversationSummary,
EntriesPage, EntriesPage,
@@ -11,10 +12,12 @@ import type {
LimitsResponse, LimitsResponse,
MemoryFile, MemoryFile,
MemoryTree, MemoryTree,
SearchResponse,
SessionsResponse, SessionsResponse,
TokenRow, TokenRow,
UsageGroup, UsageGroup,
UsageResponse, UsageResponse,
VaultGraph,
} from "./types"; } from "./types";
const TRAILING_SLASHES = /\/+$/; const TRAILING_SLASHES = /\/+$/;
@@ -182,6 +185,23 @@ export class ApiClient {
return this.get(`/api/conversations/${id}/history`); return this.get(`/api/conversations/${id}/history`);
} }
context(id: string, prompt = false): Promise<ContextResponse> {
return this.get(`/api/conversations/${id}/context`, {
prompt: prompt ? 1 : undefined,
});
}
vaultGraph(paths: string[], limit?: number): Promise<VaultGraph> {
const search = new URLSearchParams();
for (const path of paths) {
search.append("path", path);
}
if (limit) {
search.set("limit", String(limit));
}
return this.get(`/api/vault/graph?${search.toString()}`);
}
entries( entries(
id: string, id: string,
params?: { subpath?: string; offset?: number; limit?: number } params?: { subpath?: string; offset?: number; limit?: number }
@@ -283,6 +303,10 @@ export class ApiClient {
return this.post("/api/conversations", body); return this.post("/api/conversations", body);
} }
search(q: string, limit?: number): Promise<SearchResponse> {
return this.get("/api/search", { limit, q });
}
sessions(): Promise<SessionsResponse> { sessions(): Promise<SessionsResponse> {
return this.get("/api/sessions"); return this.get("/api/sessions");
} }
+75
View File
@@ -54,6 +54,7 @@ export interface QueueItem {
export interface ConversationSummary { export interface ConversationSummary {
agent: string; agent: string;
context_tokens?: number;
created_at: string | null; created_at: string | null;
flags: Record<string, unknown>; flags: Record<string, unknown>;
id: string; id: string;
@@ -62,6 +63,7 @@ export interface ConversationSummary {
last_item?: QueueItem | null; last_item?: QueueItem | null;
last_user_activity_at: string | null; last_user_activity_at: string | null;
origin: string; origin: string;
parent?: string | null;
parent_row: number | null; parent_row: number | null;
pending_question: boolean; pending_question: boolean;
running_turn: string | null; running_turn: string | null;
@@ -310,3 +312,76 @@ export interface TurnUsage {
input?: number; input?: number;
output?: number; output?: number;
} }
export interface SearchFile {
line: number;
path: string;
snippet: string;
}
export interface SearchResponse {
conversations: ConversationSummary[];
files: SearchFile[];
query: string;
}
export interface Granule {
bytes: number;
path: string | null;
tag: string | null;
tokens_est: number;
}
export interface SkillInfo {
description: string;
name: string;
path: string;
}
export interface SkillSetInfo {
path: string;
set: string;
skills: SkillInfo[];
}
export interface TouchedFile {
last_at: string;
other: number;
path: string;
reads: number;
writes: number;
}
export interface ContextResponse {
agent: { effort: string | null; model: string; name: string };
context_tokens: number;
files: TouchedFile[];
history_tokens_est: number;
id: string;
kind: Kind;
prompt: { bytes: number; granules: Granule[]; tokens_est: number };
prompt_text?: string;
skills: SkillSetInfo[];
skills_tokens_est: number;
tool_counts: Record<string, number>;
tools: {
allowed: string[] | null;
disallowed: string[];
gateway: string[];
mcps: string[];
};
turns: number;
}
export interface VaultNode {
exists: boolean;
path: string;
title: string;
touched: boolean;
}
export interface VaultGraph {
edges: { from: string; to: string }[];
nodes: VaultNode[];
root: string;
}
-121
View File
@@ -1,121 +0,0 @@
<script lang="ts">
import LogOutIcon from "@lucide/svelte/icons/log-out";
import PanelLeftCloseIcon from "@lucide/svelte/icons/panel-left-close";
import PanelLeftOpenIcon from "@lucide/svelte/icons/panel-left-open";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import { page } from "$app/state";
import LiveDot from "$lib/components/live-dot.svelte";
import ThemeToggle from "$lib/components/theme-toggle.svelte";
import { Button } from "$lib/components/ui/button";
import { gateway } from "$lib/gateway.svelte";
import { isActive, NAV } from "$lib/nav";
import { session } from "$lib/session.svelte";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils";
const running = $derived(gateway.running.length);
const open = $derived(ui.nav);
async function signOut() {
await session.logout();
await goto(`${base}/login`);
}
</script>
<aside
class={cn(
"hidden shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground transition-[width] duration-150 sm:flex",
open ? "w-52" : "w-12"
)}
>
<a
class={cn(
"flex h-12 items-center gap-2 border-b font-semibold tracking-tight",
open ? "px-4" : "justify-center"
)}
href="{base}/"
title="Beaver"
>
<span class="size-2.5 shrink-0 rounded-sm bg-primary"></span>
{#if open}
Beaver
{/if}
</a>
<nav aria-label="Sections" class="flex flex-1 flex-col gap-0.5 p-2">
{#each NAV as item (item.href)}
{@const active = isActive(page.url.pathname, base, item.href)}
<a
aria-current={active ? "page" : undefined}
aria-label={item.label}
class={cn(
"relative flex h-8 items-center gap-2.5 rounded-md text-sm transition-colors",
open ? "px-2.5" : "justify-center",
active
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"
)}
href="{base}{item.href}"
title={open ? undefined : item.label}
>
<item.icon class="size-4 shrink-0 text-icon" />
{#if open}
<span class="flex-1">{item.label}</span>
{/if}
{#if item.href === "/" && running > 0}
{#if open}
<span
class="tabular rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
>
{running}
</span>
{:else}
<span
class="absolute top-1 right-1 size-1.5 rounded-full bg-signal"
></span>
{/if}
{/if}
</a>
{/each}
</nav>
<div
class={cn(
"flex items-center gap-1 border-t",
open ? "px-3 py-2" : "flex-col px-1 py-2"
)}
>
<span
class={cn(open && "mr-auto")}
title="event stream from the gateway (SSE)"
>
<LiveDot
detail={gateway.live.detail}
label={open}
state={gateway.live.state}
/>
</span>
<ThemeToggle />
<Button
aria-label="Sign out"
onclick={signOut}
size="icon-sm"
title="Sign out"
variant="ghost"
>
<LogOutIcon class="size-4" />
</Button>
<Button
aria-label={open ? "Collapse sidebar" : "Expand sidebar"}
onclick={() => ui.toggleNav()}
size="icon-sm"
title="{open ? 'Collapse' : 'Expand'} sidebar (⌘B)"
variant="ghost"
>
{#if open}
<PanelLeftCloseIcon class="size-4" />
{:else}
<PanelLeftOpenIcon class="size-4" />
{/if}
</Button>
</div>
</aside>
+23 -60
View File
@@ -1,79 +1,42 @@
<script lang="ts"> <script lang="ts">
import MoreHorizontalIcon from "@lucide/svelte/icons/more-horizontal"; import ActivityIcon from "@lucide/svelte/icons/activity";
import { goto } from "$app/navigation"; import BrainIcon from "@lucide/svelte/icons/brain";
import GaugeIcon from "@lucide/svelte/icons/gauge";
import MessagesSquareIcon from "@lucide/svelte/icons/messages-square";
import SlidersHorizontalIcon from "@lucide/svelte/icons/sliders-horizontal";
import type { Component } from "svelte";
import { base } from "$app/paths"; import { base } from "$app/paths";
import { page } from "$app/state"; import { page } from "$app/state";
import LiveDot from "$lib/components/live-dot.svelte"; import { isSectionActive, SECTIONS, SYSTEM } from "$lib/nav";
import ThemeToggle from "$lib/components/theme-toggle.svelte";
import { Button } from "$lib/components/ui/button";
import * as Sheet from "$lib/components/ui/sheet";
import { gateway } from "$lib/gateway.svelte";
import { isActive, NAV } from "$lib/nav";
import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
let moreOpen = $state(false); const ICONS: Record<string, Component<{ class?: string }>> = {
const primary = NAV.filter((item) => item.mobile); "/": ActivityIcon,
const secondary = NAV.filter((item) => !item.mobile); "/conversations": MessagesSquareIcon,
"/memory": BrainIcon,
async function signOut() { "/system": SlidersHorizontalIcon,
moreOpen = false; "/usage": GaugeIcon,
await session.logout(); };
await goto(`${base}/login`); const items = [...SECTIONS, SYSTEM];
}
</script> </script>
<nav <nav
aria-label="Sections" aria-label="Sections"
class="flex shrink-0 items-stretch border-t bg-sidebar pb-[env(safe-area-inset-bottom)] sm:hidden" class="flex shrink-0 items-stretch border-t bg-rack/90 pb-[env(safe-area-inset-bottom)] backdrop-blur-md sm:hidden"
> >
{#each primary as item (item.href)} {#each items as item (item.href)}
{@const active = isActive(page.url.pathname, base, item.href)} {@const active = isSectionActive(page.url.pathname, base, item.href)}
{@const Icon = ICONS[item.href]}
<a <a
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
class={cn( class={cn(
"flex h-14 flex-1 flex-col items-center justify-center gap-1 text-[11px]", "flex h-13 flex-1 flex-col items-center justify-center gap-0.5 text-[10px] transition-colors",
active ? "text-link" : "text-muted-foreground" active ? "text-primary" : "text-muted-foreground"
)} )}
href="{base}{item.href}" href="{base}{item.href}"
> >
<item.icon class="size-5" /> <Icon class="size-5" />
{item.label} {item.label.split(" ")[0]}
</a> </a>
{/each} {/each}
<button
class="flex h-14 flex-1 flex-col items-center justify-center gap-1 text-[11px] text-muted-foreground"
onclick={() => {
moreOpen = true;
}}
type="button"
>
<MoreHorizontalIcon class="size-5" />
More
</button>
</nav> </nav>
<Sheet.Root bind:open={moreOpen}>
<Sheet.Content class="flex flex-col gap-1 pt-10" side="bottom">
<Sheet.Title class="px-2 pb-2">More</Sheet.Title>
{#each secondary as item (item.href)}
<a
class="flex h-11 items-center gap-3 rounded-md px-3 text-sm hover:bg-muted"
href="{base}{item.href}"
onclick={() => {
moreOpen = false;
}}
>
<item.icon class="size-4 text-icon" />
{item.label}
</a>
{/each}
<div class="mt-2 flex items-center justify-between border-t px-3 pt-3">
<LiveDot detail={gateway.live.detail} state={gateway.live.state} />
<div class="flex items-center gap-1">
<ThemeToggle />
<Button onclick={signOut} size="sm" variant="ghost">Sign out</Button>
</div>
</div>
</Sheet.Content>
</Sheet.Root>
@@ -1,323 +0,0 @@
<script lang="ts">
import PanelLeftCloseIcon from "@lucide/svelte/icons/panel-left-close";
import PlusIcon from "@lucide/svelte/icons/plus";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import type { AgentInfo, ConversationSummary } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import KindBadge from "$lib/components/kind-badge.svelte";
import StatusPill from "$lib/components/status-pill.svelte";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select";
import { Skeleton } from "$lib/components/ui/skeleton";
import { clip, fmtRelative, shortId } from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { session } from "$lib/session.svelte";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils";
let { selected = null }: { selected?: string | null } = $props();
const KINDS = ["all", "master", "branch", "deep", "job", "fork"];
const STATUSES = ["open", "all", "merged", "closed", "archived"];
const TITLE_MAX = 60;
const PREVIEW_MAX = 90;
let kind = $state("all");
let statusFilter = $state("open");
let search = $state("");
let createOpen = $state(false);
let agents = $state<AgentInfo[]>([]);
let form = $state({
agent: "",
kind: "deep",
seed: "clean",
text: "",
title: "",
});
let busy = $state(false);
const rows = $derived.by(() => {
const needle = search.trim().toLowerCase();
return gateway.conversations
.filter((row) => kind === "all" || row.kind === kind)
.filter((row) => statusFilter === "all" || row.status === statusFilter)
.filter(
(row) =>
!needle ||
(row.title ?? "").toLowerCase().includes(needle) ||
row.id.startsWith(needle) ||
row.agent.toLowerCase().includes(needle) ||
(row.last_item?.text ?? "").toLowerCase().includes(needle)
)
.sort(byActivity);
});
function activityOf(row: ConversationSummary): string {
return row.last_activity_at ?? row.created_at ?? "";
}
function byActivity(a: ConversationSummary, b: ConversationSummary) {
if (Boolean(a.running_turn) !== Boolean(b.running_turn)) {
return a.running_turn ? -1 : 1;
}
return activityOf(b).localeCompare(activityOf(a));
}
function titleOf(row: ConversationSummary): string {
if (row.title) {
return clip(row.title, TITLE_MAX);
}
if (row.kind === "master") {
return `Master · ${shortId(row.id)}`;
}
return `${row.kind} · ${shortId(row.id)}`;
}
const agentsForKind = $derived(
agents.filter((a) =>
a.kinds.includes(form.kind as AgentInfo["kinds"][number])
)
);
async function openCreate() {
createOpen = true;
if (agents.length === 0 && session.client) {
({ agents } = await session.client.agents());
}
}
async function create() {
if (!session.client) {
return;
}
busy = true;
try {
const created = await session.client.spawn({
agent: form.agent || undefined,
kind: form.kind,
seed: form.seed,
text: form.text || undefined,
title: form.title || undefined,
});
createOpen = false;
await goto(`${base}/conversations/${created.id}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
</script>
<div class="flex h-full min-h-0 flex-col">
<div class="flex flex-wrap items-center gap-2 border-b px-3 py-2">
<Select.Root
onValueChange={(value) => {
kind = value;
}}
type="single"
value={kind}
>
<Select.Trigger class="h-8 text-xs" size="sm">
{kind === "all" ? "any kind" : kind}
</Select.Trigger>
<Select.Content>
{#each KINDS as option (option)}
<Select.Item
label={option === "all" ? "any kind" : option}
value={option}
/>
{/each}
</Select.Content>
</Select.Root>
<Select.Root
onValueChange={(value) => {
statusFilter = value;
}}
type="single"
value={statusFilter}
>
<Select.Trigger class="h-8 text-xs" size="sm">
{statusFilter === "all" ? "any status" : statusFilter}
</Select.Trigger>
<Select.Content>
{#each STATUSES as option (option)}
<Select.Item
label={option === "all" ? "any status" : option}
value={option}
/>
{/each}
</Select.Content>
</Select.Root>
<Input
aria-label="Search conversations"
class="h-8 min-w-24 flex-1 text-xs"
placeholder="search"
bind:value={search}
/>
<Button aria-label="New conversation" onclick={openCreate} size="icon-sm">
<PlusIcon class="size-4" />
</Button>
<Button
aria-label="Hide conversations"
class="hidden lg:inline-flex"
onclick={() => ui.toggleRail()}
size="icon-sm"
title="Hide conversations"
variant="ghost"
>
<PanelLeftCloseIcon class="size-4" />
</Button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
{#if gateway.live.state === "failed"}
<div class="p-3">
<ErrorNote
message={gateway.live.detail ?? "event stream failed"}
retry={() => gateway.start()}
/>
</div>
{:else if !gateway.loaded}
<div class="flex flex-col gap-2 p-3">
<Skeleton class="h-12 w-full" />
<Skeleton class="h-12 w-full" />
<Skeleton class="h-12 w-full" />
</div>
{:else if rows.length === 0}
<div class="p-3">
<EmptyState
hint="Change the filters, or start one - a deep chat, a branch off the master, a headless job."
title="No conversations match"
>
<Button onclick={openCreate} size="sm" variant="outline">
New conversation
</Button>
</EmptyState>
</div>
{:else}
<ul class="flex flex-col">
{#each rows as row (row.id)}
<li>
<a
aria-current={selected === row.id ? "page" : undefined}
class={cn(
"row-hover flex flex-col gap-1 border-b px-3 py-2 text-sm",
selected === row.id && "bg-sidebar-accent"
)}
href="{base}/conversations/{row.id}"
>
<span class="flex items-center gap-2">
<KindBadge kind={row.kind} />
<span class="min-w-0 flex-1 truncate font-medium">
{titleOf(row)}
</span>
<span class="tabular shrink-0 text-muted-foreground text-xs">
{fmtRelative(activityOf(row))}
</span>
</span>
<span class="flex items-center gap-2 text-xs">
<StatusPill
status={row.running_turn ? "running" : row.status}
/>
<span class="text-muted-foreground">{row.agent}</span>
{#if row.pending_question}
<span class="font-medium text-link">question</span>
{/if}
</span>
{#if row.last_item}
<span class="truncate text-muted-foreground text-xs">
<span class="text-foreground/70"
>{row.last_item.origin}:</span
>
{clip(row.last_item.text, PREVIEW_MAX)}
</span>
{/if}
</a>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>New conversation</Dialog.Title>
<Dialog.Description>
It opens in the home window of its kind (a vault file, a Telegram topic)
and stays silent until someone speaks.
</Dialog.Description>
</Dialog.Header>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<Label>Kind</Label>
<Select.Root
onValueChange={(value) => {
form.kind = value;
form.agent = "";
}}
type="single"
value={form.kind}
>
<Select.Trigger class="w-full">{form.kind}</Select.Trigger>
<Select.Content>
{#each ["deep", "branch", "master", "job"] as option (option)}
<Select.Item label={option} value={option} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col gap-1.5">
<Label>Agent</Label>
<Select.Root
onValueChange={(value) => {
form.agent = value === "default" ? "" : value;
}}
type="single"
value={form.agent || "default"}
>
<Select.Trigger class="w-full">
{form.agent || "frontend default"}
</Select.Trigger>
<Select.Content>
<Select.Item label="frontend default" value="default" />
{#each agentsForKind as agent (agent.name)}
<Select.Item label={agent.name} value={agent.name} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="col-span-2 flex flex-col gap-1.5">
<Label for="new-title">Title</Label>
<Input id="new-title" placeholder="optional" bind:value={form.title} />
</div>
<div class="col-span-2 flex flex-col gap-1.5">
<Label for="new-text">First message</Label>
<textarea
class="min-h-20 rounded-md border bg-input/30 px-3 py-2 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
id="new-text"
placeholder="optional - without it the window waits"
bind:value={form.text}
></textarea>
</div>
</div>
<Dialog.Footer>
<Button
onclick={() => {
createOpen = false;
}}
variant="ghost"
>
Cancel
</Button>
<Button disabled={busy} onclick={create}>Create</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+3 -11
View File
@@ -1,7 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import LiveDot from "$lib/components/live-dot.svelte";
import { gateway } from "$lib/gateway.svelte";
let { let {
title, title,
@@ -17,7 +15,7 @@
</script> </script>
<header <header
class="flex min-h-12 flex-wrap items-center gap-x-4 gap-y-2 border-b px-4 py-2 sm:px-6" class="flex min-h-12 flex-wrap items-center gap-x-4 gap-y-2 px-4 pt-4 pb-1 sm:px-6"
> >
<div class="flex min-w-0 items-baseline gap-2"> <div class="flex min-w-0 items-baseline gap-2">
<h1 class="truncate font-semibold text-lg tracking-tight">{title}</h1> <h1 class="truncate font-semibold text-lg tracking-tight">{title}</h1>
@@ -30,15 +28,9 @@
{@render children()} {@render children()}
</div> </div>
{/if} {/if}
<div class="ml-auto flex items-center gap-2">
{#if actions} {#if actions}
<div class="ml-auto flex items-center gap-2">
{@render actions()} {@render actions()}
{/if}
<LiveDot
class="sm:hidden"
detail={gateway.live.detail}
label={false}
state={gateway.live.state}
/>
</div> </div>
{/if}
</header> </header>
+8 -10
View File
@@ -123,19 +123,17 @@ export function fmtRelative(
if (!date) { if (!date) {
return ""; return "";
} }
const diff = now - date.getTime(); const diff = Math.max(0, now - date.getTime());
const abs = Math.abs(diff); if (diff < MINUTE_MS) {
const suffix = diff >= 0 ? "ago" : "from now"; return "just now";
if (abs < MINUTE_MS) {
return diff >= 0 ? "just now" : "in <1m";
} }
if (abs < HOUR_MS) { if (diff < HOUR_MS) {
return `${Math.round(abs / MINUTE_MS)}m ${suffix}`; return `${Math.round(diff / MINUTE_MS)}m ago`;
} }
if (abs < DAY_MS) { if (diff < DAY_MS) {
return `${Math.round(abs / HOUR_MS)}h ${suffix}`; return `${Math.round(diff / HOUR_MS)}h ago`;
} }
return `${Math.round(abs / DAY_MS)}d ${suffix}`; return `${Math.round(diff / DAY_MS)}d ago`;
} }
export function fmtCountdown(iso: string | null | undefined, now = Date.now()) { export function fmtCountdown(iso: string | null | undefined, now = Date.now()) {
+27 -24
View File
@@ -1,35 +1,38 @@
import ActivityIcon from "@lucide/svelte/icons/activity";
import BrainIcon from "@lucide/svelte/icons/brain";
import ClockIcon from "@lucide/svelte/icons/clock";
import GaugeIcon from "@lucide/svelte/icons/gauge";
import KeyRoundIcon from "@lucide/svelte/icons/key-round";
import MessagesSquareIcon from "@lucide/svelte/icons/messages-square";
import ScrollTextIcon from "@lucide/svelte/icons/scroll-text";
import type { Component } from "svelte";
export interface NavItem { export interface NavItem {
href: string; href: string;
icon: Component<{ class?: string }>; key: string;
label: string; label: string;
mobile: boolean;
} }
export const NAV: NavItem[] = [ export const SECTIONS: NavItem[] = [
{ href: "/", icon: ActivityIcon, label: "Now", mobile: true }, { href: "/", key: "1", label: "Now" },
{ { href: "/conversations", key: "2", label: "Conversations" },
href: "/conversations", { href: "/memory", key: "3", label: "Memory" },
icon: MessagesSquareIcon, { href: "/usage", key: "4", label: "Usage" },
label: "Conversations", ];
mobile: true,
}, export const SYSTEM: NavItem = { href: "/system", key: "5", label: "System" };
{ href: "/usage", icon: GaugeIcon, label: "Usage", mobile: true },
{ href: "/memory", icon: BrainIcon, label: "Memory", mobile: true }, export const SYSTEM_PAGES: NavItem[] = [
{ href: "/tokens", icon: KeyRoundIcon, label: "Tokens", mobile: false }, { href: "/system", key: "", label: "Sessions" },
{ href: "/jobs", icon: ClockIcon, label: "Jobs", mobile: false }, { href: "/system/agents", key: "", label: "Agents & endpoints" },
{ href: "/audit", icon: ScrollTextIcon, label: "Audit", mobile: false }, { href: "/system/jobs", key: "", label: "Jobs" },
{ href: "/system/tokens", key: "", label: "Tokens" },
{ href: "/system/audit", key: "", label: "Audit" },
]; ];
export function isActive(pathname: string, base: string, href: string) { export function isActive(pathname: string, base: string, href: string) {
const path = pathname.startsWith(base)
? pathname.slice(base.length)
: pathname;
const current = path || "/";
if (href === "/") {
return current === "/";
}
return current === href || current.startsWith(`${href}/`);
}
export function isSectionActive(pathname: string, base: string, href: string) {
const path = pathname.startsWith(base) const path = pathname.startsWith(base)
? pathname.slice(base.length) ? pathname.slice(base.length)
: pathname; : pathname;
+49
View File
@@ -0,0 +1,49 @@
// A small keyed cache behind the panel: the conversation index and the
// last threads land here so the next open paints before the network
// answers. localStorage when it exists, memory otherwise.
export interface PanelCache {
get: <T>(key: string) => T | null;
set: (key: string, value: unknown) => void;
}
const memory = new Map<string, string>();
function storage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
try {
if (typeof localStorage !== "undefined") {
return localStorage;
}
} catch {
/* sandboxed */
}
return {
getItem: (key) => memory.get(key) ?? null,
removeItem: (key) => {
memory.delete(key);
},
setItem: (key, value) => {
memory.set(key, value);
},
};
}
export function panelCache(prefix: string): PanelCache {
const store = storage();
return {
get<T>(key: string): T | null {
try {
const raw = store.getItem(`${prefix}:${key}`);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
},
set(key: string, value: unknown): void {
try {
store.setItem(`${prefix}:${key}`, JSON.stringify(value));
} catch {
store.removeItem(`${prefix}:${key}`);
}
},
};
}
+70 -17
View File
@@ -1,15 +1,14 @@
<script lang="ts"> <script lang="ts">
import { tick } from "svelte"; import { tick, untrack } from "svelte";
import type { ApiClient } from "$lib/api/client"; import type { ApiClient } from "$lib/api/client";
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 { Skeleton } from "$lib/components/ui/skeleton";
import { Switch } from "$lib/components/ui/switch";
import { clip } from "$lib/format"; import { clip } 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";
import { usePanelHost } from "./host";
import Markdown from "./markdown.svelte"; import Markdown from "./markdown.svelte";
import QuestionCard from "./question-card.svelte"; import QuestionCard from "./question-card.svelte";
import TurnCard from "./turn-card.svelte"; import TurnCard from "./turn-card.svelte";
@@ -27,6 +26,8 @@
} = $props(); } = $props();
const RESULT_CLIP = 600; const RESULT_CLIP = 600;
const CACHED_MESSAGES = 80;
const host = usePanelHost();
const NEAR_BOTTOM_PX = 120; const NEAR_BOTTOM_PX = 120;
const TICK_MS = 1000; const TICK_MS = 1000;
@@ -53,9 +54,21 @@
async function load() { async function load() {
failure = null; failure = null;
if (messages === null) {
const cached = host.cache?.get<HistoryMessage[]>(
`history:${conversationId}`
);
if (cached) {
messages = cached;
}
}
try { try {
({ messages } = await client.history(conversationId)); ({ messages } = await client.history(conversationId));
loadedAt = new Date().toISOString(); loadedAt = new Date().toISOString();
host.cache?.set(
`history:${conversationId}`,
messages.slice(-CACHED_MESSAGES)
);
} catch (cause) { } catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause); failure = cause instanceof Error ? cause.message : String(cause);
} }
@@ -80,7 +93,7 @@
$effect(() => { $effect(() => {
if (refreshKey >= 0) { if (refreshKey >= 0) {
pinned = true; pinned = true;
load(); untrack(() => load());
} }
}); });
@@ -115,6 +128,28 @@
return () => clearInterval(timer); return () => clearInterval(timer);
}); });
const SYSTEM_HEAD = /^\[[^\]\n]+\]/;
let openSystem = $state<Set<number>>(new Set());
function systemText(message: HistoryMessage): string | null {
if (message.role !== "user" || typeof message.content !== "string") {
return null;
}
return SYSTEM_HEAD.test(message.content.trimStart())
? message.content
: null;
}
function toggleSystem(index: number) {
const next = new Set(openSystem);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
openSystem = next;
}
function blocks(message: HistoryMessage): ContentBlock[] { function blocks(message: HistoryMessage): ContentBlock[] {
return typeof message.content === "string" return typeof message.content === "string"
? [{ text: message.content, type: "text" }] ? [{ text: message.content, type: "text" }]
@@ -147,20 +182,22 @@
bind:this={scroller} bind:this={scroller}
> >
<div class="flex flex-col gap-3" bind:this={body}> <div class="flex flex-col gap-3" bind:this={body}>
<span <button
class="flex items-center gap-2 self-end text-muted-foreground text-xs" class="rule-word self-end text-xs"
data-active={showResults}
onclick={() => {
showResults = !showResults;
}}
type="button"
> >
<Switch aria-label="Show tool results" bind:checked={showResults} /> {showResults ? "hide tool results" : "show tool results"}
show tool results </button>
</span>
{#if failure} {#if failure}
<ErrorNote message={failure} retry={load} /> <ErrorNote message={failure} retry={load} />
{:else if messages === null} {:else if messages === null}
<div class="flex flex-col gap-2"> <p class="py-6 text-center text-muted-foreground text-xs">
<Skeleton class="h-10 w-2/3" /> Loading the thread…
<Skeleton class="h-16 w-full" /> </p>
<Skeleton class="h-10 w-1/2" />
</div>
{:else if messages.length === 0 && tail.length === 0} {:else if messages.length === 0 && tail.length === 0}
<EmptyState <EmptyState
hint="Nothing has been said here yet. Write below, or wait for the first inject." hint="Nothing has been said here yet. Write below, or wait for the first inject."
@@ -170,7 +207,21 @@
{#each messages as message, index (index)} {#each messages as message, index (index)}
{@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")}
{#if !(isResultOnly && !showResults)} {@const system = systemText(message)}
{#if system}
<button
aria-expanded={openSystem.has(index)}
class="self-center max-w-[75ch] rounded-md px-2 py-1 text-left font-mono text-muted-foreground text-xs hover:bg-accent"
onclick={() => toggleSystem(index)}
type="button"
>
{#if openSystem.has(index)}
<span class="whitespace-pre-wrap break-words">{system}</span>
{:else}
<span class="truncate">{clip(system.split("\n")[0], 120)}</span>
{/if}
</button>
{:else if !(isResultOnly && !showResults)}
<div <div
class={cn( class={cn(
"flex flex-col gap-1.5", "flex flex-col gap-1.5",
@@ -181,8 +232,10 @@
{#if block.type === "text" && block.text} {#if block.type === "text" && block.text}
<Markdown <Markdown
class={cn( class={cn(
"max-w-[75ch] rounded-lg px-3 py-2", "max-w-[75ch]",
message.role === "user" ? "bg-primary/10" : "bg-muted/50" message.role === "user"
? "rounded-2xl rounded-br-md bg-primary/10 px-3.5 py-2"
: "px-1 py-1"
)} )}
text={block.text} text={block.text}
/> />
+58 -52
View File
@@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import SendHorizontalIcon from "@lucide/svelte/icons/send-horizontal"; import ArrowUpIcon from "@lucide/svelte/icons/arrow-up";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client"; import type { ApiClient } from "$lib/api/client";
import { Button } from "$lib/components/ui/button"; import { cn } from "$lib/utils";
import * as Select from "$lib/components/ui/select";
let { let {
client, client,
@@ -17,28 +16,31 @@
type Mode = "message" | "inject" | "urgent"; type Mode = "message" | "inject" | "urgent";
const MODES: { value: Mode; label: string; hint: string }[] = [ const MODES: { value: Mode; label: string; hint: string }[] = [
{ hint: "as you, mirrored to the window", label: "Say", value: "message" },
{ {
hint: "as the user, mirrored to Telegram", hint: "a system note for the next turn",
label: "Message",
value: "message",
},
{
hint: "system note, rides with the next turn",
label: "Inject", label: "Inject",
value: "inject", value: "inject",
}, },
{ { hint: "interrupts the running turn", label: "Urgent", value: "urgent" },
hint: "interrupts the running turn",
label: "Urgent inject",
value: "urgent",
},
]; ];
const MAX_ROWS = 10;
const LINE_PX = 22;
let mode = $state<Mode>("message"); let mode = $state<Mode>("message");
let text = $state(""); let text = $state("");
let busy = $state(false); let busy = $state(false);
let area = $state<HTMLTextAreaElement | null>(null);
const current = $derived(MODES.find((m) => m.value === mode) ?? MODES[0]); const current = $derived(MODES.find((m) => m.value === mode) ?? MODES[0]);
function grow() {
if (!area) {
return;
}
area.style.height = "auto";
area.style.height = `${Math.min(area.scrollHeight, LINE_PX * MAX_ROWS)}px`;
}
async function send() { async function send() {
const body = text.trim(); const body = text.trim();
if (!body || busy) { if (!body || busy) {
@@ -56,6 +58,7 @@
); );
} }
text = ""; text = "";
queueMicrotask(grow);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : String(error)); toast.error(error instanceof Error ? error.message : String(error));
} finally { } finally {
@@ -72,57 +75,60 @@
</script> </script>
<form <form
class="flex flex-col gap-2 border-t bg-background px-3 py-2" class="flex flex-col gap-1.5 px-3 pt-2 pb-[max(0.5rem,var(--beaver-inset-bottom,0px))] @md:px-6"
onsubmit={(event) => { onsubmit={(event) => {
event.preventDefault(); event.preventDefault();
send(); send();
}} }}
> >
<div
class={cn(
"flex items-end gap-2 rounded-xl bg-strip px-3 py-2 shadow-lift ring-1 ring-border focus-within:ring-ring",
disabled && "opacity-60"
)}
>
<textarea <textarea
aria-label="Message" aria-label="Message"
class="min-h-16 w-full resize-y rounded-md border bg-input/30 px-3 py-2 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/30" class="max-h-56 min-h-6 w-full resize-none bg-transparent py-0.5 text-sm leading-[22px] outline-none placeholder:text-muted-foreground"
{disabled} {disabled}
oninput={grow}
onkeydown={onKeydown} onkeydown={onKeydown}
placeholder={mode === "message" placeholder={disabled
? "Say something to the agent…" ? "This conversation is closed."
: "Text of the inject…"} : `${current.label} something (⌘↩)`}
rows="2" rows="1"
bind:this={area}
bind:value={text} bind:value={text}
></textarea> ></textarea>
<div class="flex items-center gap-2"> <button
<Select.Root aria-label="Send"
onValueChange={(value) => { class="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity disabled:opacity-30"
mode = value as Mode;
}}
type="single"
value={mode}
>
<Select.Trigger class="h-8 text-xs" size="sm"
>{current.label}</Select.Trigger
>
<Select.Content>
{#each MODES as option (option.value)}
<Select.Item label={option.label} value={option.value}>
<span class="flex flex-col">
<span>{option.label}</span>
<span class="text-muted-foreground text-xs">{option.hint}</span>
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<span class="hidden text-muted-foreground text-xs @md:inline"
>{current.hint}</span
>
<Button
class="ml-auto"
disabled={disabled || busy || !text.trim()} disabled={disabled || busy || !text.trim()}
size="sm"
type="submit" type="submit"
> >
<SendHorizontalIcon class="size-4" /> <ArrowUpIcon class="size-4" />
Send </button>
<kbd class="hidden text-[10px] opacity-70 @md:inline">⌘↩</kbd> </div>
</Button> <div class="flex items-center gap-3 px-1">
{#each MODES as option (option.value)}
<button
class={cn(
"rule-word text-xs",
mode === option.value && "text-foreground",
option.value === "urgent" && mode === option.value && "text-destructive"
)}
data-active={mode === option.value}
onclick={() => {
mode = option.value;
}}
title={option.hint}
type="button"
>
{option.label}
</button>
{/each}
<span class="hidden truncate text-muted-foreground text-xs @md:inline">
{current.hint}
</span>
</div> </div>
</form> </form>
+395
View File
@@ -0,0 +1,395 @@
<script lang="ts">
import { onMount } from "svelte";
import type { ApiClient } from "$lib/api/client";
import type { ContextResponse, VaultGraph } from "$lib/api/types";
import ErrorNote from "$lib/components/error-note.svelte";
import { fmtRelative, fmtTokens } from "$lib/format";
import Graph, {
type GraphEdge,
type GraphNode,
} from "$lib/shell/graph.svelte";
import { cn } from "$lib/utils";
import { usePanelHost } from "./host";
let {
client,
conversationId,
window = 1_000_000,
}: {
client: ApiClient;
conversationId: string;
window?: number;
} = $props();
const HARNESS_EST = 15_000;
const FILES_SHOWN = 12;
const MD_SUFFIX = /\.md$/;
const host = usePanelHost();
let data = $state<ContextResponse | null>(null);
let graph = $state<VaultGraph | null>(null);
let graphError = $state<string | null>(null);
let failure = $state<string | null>(null);
let promptOpen = $state(false);
let promptText = $state<string | null>(null);
let filesOpen = $state(false);
async function load() {
failure = null;
try {
data = await client.context(conversationId);
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
return;
}
const paths = data.files.map((f) => f.path);
if (paths.length === 0) {
graph = null;
return;
}
try {
graph = await client.vaultGraph(paths.slice(0, 40));
graphError = null;
} catch (cause) {
graph = null;
graphError = cause instanceof Error ? cause.message : String(cause);
}
}
async function showPrompt() {
promptOpen = !promptOpen;
if (promptOpen && promptText === null) {
try {
const full = await client.context(conversationId, true);
promptText = full.prompt_text ?? "";
} catch (cause) {
promptText = cause instanceof Error ? cause.message : String(cause);
}
}
}
onMount(load);
interface Layer {
label: string;
tokens: number;
tone: string;
}
const layers = $derived.by((): Layer[] => {
if (!data) {
return [];
}
const prompt = data.prompt.tokens_est;
const skills = data.skills_tokens_est;
const harness = Math.min(
HARNESS_EST,
Math.max(0, data.context_tokens - prompt - skills)
);
const history = Math.max(
0,
data.context_tokens - prompt - skills - harness
);
return [
{ label: "harness", tokens: harness, tone: "bg-muted-foreground/40" },
{ label: "prompt", tokens: prompt, tone: "bg-primary" },
{ label: "skills", tokens: skills, tone: "bg-kind-deep" },
{ label: "history & tools", tokens: history, tone: "bg-kind-branch" },
];
});
const total = $derived(data?.context_tokens ?? 0);
const share = (tokens: number) =>
total > 0 ? `${Math.max(0.5, (tokens / total) * 100)}%` : "0%";
const graphNodes = $derived.by((): GraphNode[] => {
if (!graph) {
return [];
}
const single = graph.nodes.filter((n) => n.touched).length === 1;
return graph.nodes.map((n) => ({
href: n.exists ? n.path : undefined,
id: n.path,
kind: "file",
label: n.title,
ring: ringOf(n.touched, single),
state: stateOf(n.touched, n.exists),
}));
});
const graphEdges = $derived.by((): GraphEdge[] =>
graph ? graph.edges.map((e) => ({ from: e.from, to: e.to })) : []
);
const files = $derived(
filesOpen ? (data?.files ?? []) : (data?.files ?? []).slice(0, FILES_SHOWN)
);
function ringOf(touched: boolean, single: boolean): number {
if (!touched) {
return 2;
}
return single ? 0 : 1;
}
function stateOf(touched: boolean, exists: boolean): GraphNode["state"] {
if (!touched) {
return "ghost";
}
return exists ? "quiet" : "closed";
}
function openNote(path: string) {
host.openLink(path.replace(MD_SUFFIX, ""), "internal");
}
function touch(file: {
reads: number;
writes: number;
other: number;
}): string {
const parts: string[] = [];
if (file.reads) {
parts.push(`read ×${file.reads}`);
}
if (file.writes) {
parts.push(`wrote ×${file.writes}`);
}
if (file.other) {
parts.push(`touched ×${file.other}`);
}
return parts.join(" · ");
}
</script>
<div class="@container flex max-w-5xl flex-col gap-8">
{#if failure}
<ErrorNote message={failure} retry={load} />
{:else if !data}
<p class="text-muted-foreground text-sm">Reading the context…</p>
{:else}
<section class="flex flex-col gap-3">
<div class="flex items-baseline gap-3">
<span
class="tabular font-semibold text-3xl leading-none tracking-tight"
>
{fmtTokens(total)}
</span>
<span class="text-muted-foreground text-sm">
of {fmtTokens(window)} · {data.turns}
{data.turns === 1 ? "turn" : "turns"}
· {data.agent.model}
{data.agent.effort
? ` · ${data.agent.effort}`
: ""}
</span>
</div>
<div class="flex h-2 w-full overflow-hidden rounded-full bg-muted">
{#each layers as layer (layer.label)}
{#if layer.tokens > 0}
<span
class={cn("h-full", layer.tone)}
style="width: {share(layer.tokens)}"
title="{layer.label}: {fmtTokens(layer.tokens)}"
></span>
{/if}
{/each}
</div>
<div class="flex flex-wrap gap-x-5 gap-y-1 text-xs">
{#each layers as layer (layer.label)}
<span class="flex items-center gap-1.5">
<span class={cn("size-2 rounded-full", layer.tone)}></span>
<span class="text-muted-foreground">{layer.label}</span>
<span class="tabular">{fmtTokens(layer.tokens)}</span>
</span>
{/each}
<span class="text-muted-foreground/70"
>estimates, except the total</span
>
</div>
</section>
<div class="grid gap-8 @3xl:grid-cols-[minmax(0,1fr)_22rem]">
<div class="flex min-w-0 flex-col gap-8">
<section class="flex flex-col gap-2">
<div class="flex items-baseline gap-3">
<h2 class="font-medium text-sm">System prompt</h2>
<span class="tabular text-muted-foreground text-xs">
{fmtTokens(data.prompt.tokens_est)}
· {data.prompt.granules.length} granules
</span>
<button
class="rule-word ml-auto text-xs"
data-active={promptOpen}
onclick={showPrompt}
type="button"
>
{promptOpen ? "hide text" : "read it"}
</button>
</div>
<div class="bay-rack">
{#each data.prompt.granules as g, index (index)}
<div class="strip text-sm">
<span
class="size-2 justify-self-center rounded-full bg-primary"
></span>
<span class="flex min-w-0 flex-col leading-tight">
<button
class="truncate text-left font-medium hover:underline"
onclick={() => g.path && openNote(g.path)}
type="button"
>
{g.path ?? "verbatim prompt"}
</button>
{#if g.tag}
<span
class="truncate font-mono text-[10px] text-muted-foreground"
>
&lt;{g.tag}&gt;
</span>
{/if}
</span>
<span class="tabular text-muted-foreground text-xs">
{fmtTokens(g.tokens_est)}
</span>
</div>
{/each}
</div>
{#if promptOpen}
<pre
class="max-h-[32rem] overflow-auto rounded-lg border bg-strip p-3 text-xs whitespace-pre-wrap break-words"
>{promptText ?? "…"}</pre>
{/if}
</section>
<section class="flex flex-col gap-2">
<div class="flex items-baseline gap-3">
<h2 class="font-medium text-sm">Skills</h2>
<span class="tabular text-muted-foreground text-xs">
{data.skills.reduce((n, s) => n + s.skills.length, 0)}
loaded
</span>
</div>
{#if data.skills.length === 0}
<p class="text-muted-foreground text-sm">None for this kind.</p>
{:else}
<div class="bay-rack">
{#each data.skills as set (set.path)}
{#each set.skills as skill (skill.path)}
<div class="strip text-sm">
<span
class="size-2 justify-self-center rounded-full bg-kind-deep"
></span>
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate font-medium">{skill.name}</span>
{#if skill.description}
<span class="truncate text-muted-foreground text-xs">
{skill.description}
</span>
{/if}
</span>
<span class="text-muted-foreground text-xs">{set.set}</span>
</div>
{/each}
{/each}
</div>
{/if}
</section>
<section class="flex flex-col gap-2">
<h2 class="font-medium text-sm">Tools</h2>
<dl
class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs"
>
{#if Object.keys(data.tool_counts).length > 0}
<dt class="text-muted-foreground">used</dt>
<dd class="tabular">
{Object.entries(data.tool_counts)
.sort((a, b) => b[1] - a[1])
.map(([name, n]) => `${name.replace("mcp__", "")} ×${n}`)
.join(" ")}
</dd>
{/if}
{#if data.tools.gateway.length}
<dt class="text-muted-foreground">gateway</dt>
<dd>{data.tools.gateway.join(", ")}</dd>
{/if}
{#if data.tools.mcps.length}
<dt class="text-muted-foreground">mcp</dt>
<dd>{data.tools.mcps.join(", ")}</dd>
{/if}
{#if data.tools.allowed}
<dt class="text-muted-foreground">allowed</dt>
<dd>{data.tools.allowed.join(", ")}</dd>
{/if}
{#if data.tools.disallowed.length}
<dt class="text-muted-foreground">off</dt>
<dd class="text-muted-foreground">
{data.tools.disallowed.join(", ")}
</dd>
{/if}
</dl>
</section>
</div>
<div class="flex min-w-0 flex-col gap-8">
<section class="flex flex-col gap-2">
<div class="flex items-baseline gap-3">
<h2 class="font-medium text-sm">Notes it reached</h2>
<span class="tabular text-muted-foreground text-xs"
>{data.files.length}</span
>
</div>
{#if graph && graph.nodes.length > 0}
<div class="rounded-lg border bg-strip/60 p-2">
<Graph edges={graphEdges} nodes={graphNodes} onOpen={openNote} />
</div>
<p class="text-muted-foreground text-xs">
Solid: read or written by this thread. Hollow: one link away, not
reached.
</p>
{:else if graphError}
<p class="text-muted-foreground text-xs">{graphError}</p>
{/if}
{#if data.files.length === 0}
<p class="text-muted-foreground text-sm">No files touched yet.</p>
{:else}
<div class="bay-rack">
{#each files as file (file.path)}
<button
class="strip w-full text-left text-sm"
onclick={() => openNote(file.path)}
type="button"
>
<span
class={cn(
"size-2 justify-self-center rounded-full",
file.writes ? "bg-primary" : "bg-kind-branch"
)}
></span>
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate font-medium">{file.path}</span>
<span class="truncate text-muted-foreground text-xs"
>{touch(file)}</span
>
</span>
<span class="tabular text-muted-foreground text-xs">
{fmtRelative(file.last_at)}
</span>
</button>
{/each}
</div>
{#if data.files.length > FILES_SHOWN}
<button
class="rule-word self-start text-xs"
onclick={() => {
filesOpen = !filesOpen;
}}
type="button"
>
{filesOpen ? "fewer" : `all ${data.files.length}`}
</button>
{/if}
{/if}
</section>
</div>
</div>
{/if}
</div>
+166 -69
View File
@@ -1,15 +1,16 @@
<script lang="ts"> <script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import GitBranchIcon from "@lucide/svelte/icons/git-branch"; import GitBranchIcon from "@lucide/svelte/icons/git-branch";
import type { ApiClient } from "$lib/api/client"; import type { ApiClient } from "$lib/api/client";
import type { LiveState } from "$lib/api/live.svelte"; import type { LiveState } from "$lib/api/live.svelte";
import type { ConversationInfo } from "$lib/api/types"; import type { ConversationInfo } from "$lib/api/types";
import KindBadge from "$lib/components/kind-badge.svelte"; import { fmtRelative, fmtTokens, shortId } from "$lib/format";
import LiveDot from "$lib/components/live-dot.svelte"; import KindMark from "$lib/shell/kind-mark.svelte";
import StatusPill from "$lib/components/status-pill.svelte"; import { cn } from "$lib/utils";
import { Button } from "$lib/components/ui/button"; import BindingsList from "./bindings-list.svelte";
import { clip, fmtRelative, shortId } from "$lib/format";
import BranchDialog from "./branch-dialog.svelte"; import BranchDialog from "./branch-dialog.svelte";
import ConversationMenu from "./conversation-menu.svelte"; import ConversationMenu from "./conversation-menu.svelte";
import QueueList from "./queue-list.svelte";
let { let {
client, client,
@@ -20,63 +21,119 @@
onChanged, onChanged,
onOpen, onOpen,
showTitle = true, showTitle = true,
view = $bindable("chat"),
onContext,
}: { }: {
client: ApiClient; client: ApiClient;
info: ConversationInfo; info: ConversationInfo;
liveState: LiveState; liveState: LiveState;
liveDetail?: string | null; liveDetail?: string | null;
// Where another conversation lives as a link; without it, parents open in place.
href?: ((id: string) => string) | null; href?: ((id: string) => string) | null;
onChanged: () => void; onChanged: () => void;
onOpen: (id: string) => void; onOpen: (id: string) => void;
showTitle?: boolean; showTitle?: boolean;
view?: string;
onContext?: () => void;
} = $props(); } = $props();
let branchOpen = $state(false); const VIEWS = [
{ label: "Thread", value: "chat" },
{ label: "Activity", value: "activity" },
{ label: "Raw", value: "raw" },
{ label: "Context", value: "context" },
];
const WINDOW_MAX = 56; let branchOpen = $state(false);
const windows = $derived(info.bindings.filter((b) => b.visible)); let detailsOpen = $state(false);
const mood = $derived.by(() => {
if (info.running_turn) {
return {
dot: "bg-signal animate-pulse-dot",
text: "in motion",
tone: "text-signal",
};
}
if (info.pending_question) {
return {
dot: "bg-attention",
text: "waiting for you",
tone: "text-attention-foreground",
};
}
if (info.status !== "open") {
return {
dot: "bg-muted-foreground/50",
text: info.status,
tone: "text-muted-foreground",
};
}
return {
dot: "bg-muted-foreground/50",
text: `quiet · ${fmtRelative(info.last_activity_at)}`,
tone: "text-muted-foreground",
};
});
const queued = $derived( const queued = $derived(
info.queue.filter((item) => item.status === "queued").length info.queue.filter((item) => item.status === "queued").length
); );
const connection = $derived(
liveState === "open" ? null : (liveDetail ?? liveState)
);
</script> </script>
<header class="flex flex-col gap-1.5 border-b px-3 py-2.5 @md:px-6"> <header class="flex flex-col border-b bg-background/80 backdrop-blur-md">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1"> <div class="flex items-center gap-2 px-3 py-2 @md:px-6">
{#if showTitle} {#if showTitle}
<KindBadge kind={info.kind} /> <KindMark kind={info.kind} />
<h1 class="min-w-0 truncate font-semibold text-base tracking-tight"> <h1 class="min-w-0 truncate font-semibold text-base tracking-tight">
{info.title || `${info.kind} ${shortId(info.id)}`} {info.title || `${info.kind} ${shortId(info.id)}`}
</h1> </h1>
{/if} {/if}
<StatusPill status={info.running_turn ? "running" : info.status} /> <span class={cn("flex shrink-0 items-center gap-1.5 text-xs", mood.tone)}>
{#if info.pending_question} <span class={cn("size-1.5 rounded-full", mood.dot)}></span>
<span class="font-medium text-link text-xs">question pending</span> <span class="hidden @sm:inline">{mood.text}</span>
</span>
{#if queued > 0}
<span
class="tabular text-note text-xs"
title="messages waiting for the next turn"
>
+{queued}
</span>
{/if} {/if}
<div class="ml-auto flex items-center gap-1"> {#if connection}
<LiveDot <span class="truncate text-warn text-xs" title={liveDetail ?? undefined}>
class="hidden @md:inline-flex" {connection}
detail={liveDetail} </span>
state={liveState} {/if}
/> <div class="ml-auto flex shrink-0 items-center gap-1">
<LiveDot {#if info.context_tokens}
class="@md:hidden" <button
detail={liveDetail} class="rule-word tabular inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs hover:bg-accent"
label={false} onclick={() => {
state={liveState} view = "context";
/> onContext?.();
<Button }}
title="context of the last turn - what the model is holding"
type="button"
>
{fmtTokens(info.context_tokens)}
<span class="text-muted-foreground/70">/ 1M</span>
</button>
{/if}
<button
aria-label="Branch off this conversation" aria-label="Branch off this conversation"
class="rule-word inline-flex size-7 items-center justify-center rounded-md hover:bg-accent disabled:opacity-40"
disabled={info.status !== "open"} disabled={info.status !== "open"}
onclick={() => { onclick={() => {
branchOpen = true; branchOpen = true;
}} }}
size="sm" title="Branch off"
variant="outline" type="button"
> >
<GitBranchIcon class="size-4" /> <GitBranchIcon class="size-4" />
<span class="hidden @md:inline">Branch</span> </button>
</Button>
<ConversationMenu <ConversationMenu
{client} {client}
current current
@@ -86,18 +143,54 @@
{onChanged} {onChanged}
{onOpen} {onOpen}
/> />
</div> <button
</div> aria-expanded={detailsOpen}
<!-- Below @md the column is the live thread's; the rest of this lives in Meta. --> aria-label="Details"
<div class={cn(
class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs" "rule-word inline-flex size-7 items-center justify-center rounded-md hover:bg-accent",
detailsOpen && "bg-accent text-foreground"
)}
onclick={() => {
detailsOpen = !detailsOpen;
}}
title="Details"
type="button"
> >
<span class="hidden @md:inline">{info.agent}</span> <ChevronDownIcon
<span class="hidden @md:inline">via {info.origin}</span> class={cn("size-4 transition-transform", detailsOpen && "rotate-180")}
/>
</button>
</div>
</div>
<div class="flex items-center gap-4 px-3 pb-1.5 @md:px-6">
{#each VIEWS as item (item.value)}
<button
class="rule-word text-xs"
data-active={view === item.value}
onclick={() => {
view = item.value;
}}
type="button"
>
{item.label}
</button>
{/each}
</div>
{#if detailsOpen}
<div
class="animate-fade-in grid gap-x-6 gap-y-3 border-t px-3 py-3 text-xs @md:grid-cols-2 @md:px-6"
>
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1">
<dt class="text-muted-foreground">agent</dt>
<dd>{info.agent}</dd>
<dt class="text-muted-foreground">opened via</dt>
<dd>{info.origin}</dd>
{#if info.parent} {#if info.parent}
<dt class="text-muted-foreground">parent</dt>
<dd>
{#if href} {#if href}
<a class="text-link hover:underline" href={href(info.parent)}> <a class="text-link hover:underline" href={href(info.parent)}>
parent {shortId(info.parent)} {shortId(info.parent)}
</a> </a>
{:else} {:else}
<button <button
@@ -105,40 +198,44 @@
onclick={() => info.parent && onOpen(info.parent)} onclick={() => info.parent && onOpen(info.parent)}
type="button" type="button"
> >
parent {shortId(info.parent)} {shortId(info.parent)}
</button> </button>
{/if} {/if}
</dd>
{/if} {/if}
<span class="tabular hidden @md:inline" title={info.id}> <dt class="text-muted-foreground">id</dt>
{shortId(info.id)} <dd class="tabular break-all">{info.id}</dd>
</span> <dt class="text-muted-foreground">session</dt>
<span class="tabular hidden @md:inline"> <dd class="tabular break-all">
{info.live ? "session live" : "no live session"} {info.session_id ?? "none yet"}
{info.busy ? " · busy" : ""} {info.live ? " · process alive" : ""}
</span> </dd>
<span class="tabular"> {#if Object.keys(info.flags).length > 0}
last activity {fmtRelative(info.last_activity_at)} <dt class="text-muted-foreground">flags</dt>
</span> <dd class="tabular">
{#if queued > 0} {Object.entries(info.flags)
<span .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
class="tabular font-medium text-note" .join(" ")}
title="messages waiting for the next turn" </dd>
> {/if}
{queued} </dl>
queued <div class="flex flex-col gap-3">
</span> <div class="flex flex-col gap-1">
<span class="text-muted-foreground">windows</span>
<BindingsList
bindings={info.bindings}
{client}
conversationId={info.id}
{onChanged}
/>
</div>
{#if info.queue.length > 0}
<div class="flex flex-col gap-1">
<span class="text-muted-foreground">queue</span>
<QueueList items={info.queue} />
</div>
{/if} {/if}
</div> </div>
{#if windows.length > 0}
<div
class="hidden flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs @md:flex"
>
{#each windows as window (window.frontend + window.external_id)}
<span class="truncate" title="{window.frontend}: {window.external_id}">
<span class="text-foreground/70">{window.frontend}</span>
{clip(window.external_id, WINDOW_MAX)}
</span>
{/each}
</div> </div>
{/if} {/if}
</header> </header>
-219
View File
@@ -1,219 +0,0 @@
<script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check";
import Link2Icon from "@lucide/svelte/icons/link-2";
import Link2OffIcon from "@lucide/svelte/icons/link-2-off";
import type { ApiClient } from "$lib/api/client";
import type { ConversationSummary, Kind } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Skeleton } from "$lib/components/ui/skeleton";
import ConversationRow from "./conversation-row.svelte";
import type { ConversationIndex } from "./index.svelte";
let {
client,
index,
selected = null,
onOpen,
follow = $bindable(false),
showFollow = false,
autofocus = false,
}: {
client: ApiClient;
index: ConversationIndex;
selected?: string | null;
onOpen: (id: string) => void;
follow?: boolean;
showFollow?: boolean;
autofocus?: boolean;
} = $props();
const GROUPS: { kind: Kind; label: string }[] = [
{ kind: "master", label: "Master" },
{ kind: "branch", label: "Branches" },
{ kind: "deep", label: "Deep chats" },
{ kind: "job", label: "Jobs" },
{ kind: "fork", label: "Forks" },
];
let search = $state("");
let withClosed = $state(false);
let list = $state<HTMLDivElement | null>(null);
let searchInput = $state<HTMLInputElement | null>(null);
function activityOf(row: ConversationSummary): string {
return row.last_activity_at ?? row.created_at ?? "";
}
function byActivity(a: ConversationSummary, b: ConversationSummary) {
if (Boolean(a.running_turn) !== Boolean(b.running_turn)) {
return a.running_turn ? -1 : 1;
}
return activityOf(b).localeCompare(activityOf(a));
}
const rows = $derived.by(() => {
const needle = search.trim().toLowerCase();
return index.conversations
.filter(
(row) => withClosed || row.status === "open" || row.id === selected
)
.filter(
(row) =>
!needle ||
(row.title ?? "").toLowerCase().includes(needle) ||
row.id.startsWith(needle) ||
row.agent.toLowerCase().includes(needle) ||
(row.last_item?.text ?? "").toLowerCase().includes(needle)
)
.sort(byActivity);
});
const groups = $derived(
GROUPS.map((group) => ({
...group,
rows: rows.filter((row) => row.kind === group.kind),
})).filter((group) => group.rows.length > 0)
);
function refresh() {
index.load().catch(() => undefined);
}
// ↑/↓ walk the rows, / jumps to the search box, Enter is the button's own.
function onKeydown(event: KeyboardEvent) {
if (event.key === "/" && event.target !== searchInput) {
event.preventDefault();
searchInput?.focus();
return;
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
return;
}
const buttons = [
...(list?.querySelectorAll<HTMLButtonElement>("[data-row]") ?? []),
];
if (buttons.length === 0) {
return;
}
event.preventDefault();
const at = buttons.indexOf(document.activeElement as HTMLButtonElement);
const step = event.key === "ArrowDown" ? 1 : -1;
const next = at < 0 ? 0 : (at + step + buttons.length) % buttons.length;
buttons[next].focus();
}
$effect(() => {
if (autofocus) {
searchInput?.focus();
}
});
</script>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
aria-label="Conversations"
class="flex h-full min-h-0 flex-col"
onkeydown={onKeydown}
role="listbox"
tabindex="-1"
>
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
<Input
aria-label="Search conversations"
class="h-8 min-w-0 flex-1 text-xs"
placeholder="search"
bind:ref={searchInput}
bind:value={search}
/>
<Button
aria-pressed={withClosed}
class="text-xs"
onclick={() => {
withClosed = !withClosed;
}}
size="xs"
title={withClosed
? "Hide closed conversations"
: "Show closed conversations"}
variant={withClosed ? "secondary" : "ghost"}
>
{#if withClosed}
<CheckIcon class="size-3" />
{/if}
closed
</Button>
{#if showFollow}
<Button
aria-label="Follow the active note"
aria-pressed={follow}
onclick={() => {
follow = !follow;
}}
size="icon-sm"
title={follow
? "Following the active note - click to pin this conversation"
: "Pinned - click to follow the active note"}
variant={follow ? "secondary" : "ghost"}
>
{#if follow}
<Link2Icon class="size-4" />
{:else}
<Link2OffIcon class="size-4" />
{/if}
</Button>
{/if}
</div>
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={list}>
{#if index.live.state === "failed"}
<div class="p-3">
<ErrorNote
message={index.live.detail ?? "event stream failed"}
retry={() => index.start()}
/>
</div>
{:else if !index.loaded}
<div class="flex flex-col gap-2 p-3">
<Skeleton class="h-12 w-full" />
<Skeleton class="h-12 w-full" />
<Skeleton class="h-12 w-full" />
</div>
{:else if rows.length === 0}
<div class="p-3">
<EmptyState
hint={search
? "Nothing matches. Try fewer letters, or include closed ones."
: "Nothing is open. Say something to the master in Telegram, or send a note from Obsidian."}
title="No conversations"
/>
</div>
{:else}
{#each groups as group (group.kind)}
<section class="flex flex-col">
<h2
class="sticky top-0 z-10 bg-background/95 px-3 pt-3 pb-1 font-medium text-muted-foreground text-xs uppercase tracking-wide backdrop-blur-sm"
>
{group.label}
<span class="tabular ml-1 normal-case tracking-normal">
{group.rows.length}
</span>
</h2>
<ul class="flex flex-col">
{#each group.rows as row (row.id)}
<li class="border-b last:border-b-0">
<ConversationRow
{client}
onChanged={refresh}
{onOpen}
{row}
selected={selected === row.id}
/>
</li>
{/each}
</ul>
</section>
{/each}
{/if}
</div>
</div>
-91
View File
@@ -1,91 +0,0 @@
<script lang="ts">
import type { ApiClient } from "$lib/api/client";
import type { ConversationSummary } from "$lib/api/types";
import KindBadge from "$lib/components/kind-badge.svelte";
import StatusPill from "$lib/components/status-pill.svelte";
import { clip, fmtRelative, shortId } from "$lib/format";
import { cn } from "$lib/utils";
import ConversationMenu from "./conversation-menu.svelte";
import { usePanelHost } from "./host";
let {
client,
row,
selected = false,
onOpen,
onChanged,
}: {
client: ApiClient;
row: ConversationSummary;
selected?: boolean;
onOpen: (id: string) => void;
onChanged: () => void;
} = $props();
const host = usePanelHost();
const TITLE_MAX = 60;
const PREVIEW_MAX = 90;
const title = $derived(
row.title
? clip(row.title, TITLE_MAX)
: `${row.kind === "master" ? "Master" : row.kind} · ${shortId(row.id)}`
);
const when = $derived(row.last_activity_at ?? row.created_at ?? null);
</script>
<ConversationMenu
class={cn("group/row block", selected && "bg-sidebar-accent")}
{client}
current={selected}
id={row.id}
mode="context"
{onChanged}
{onOpen}
>
<div class="relative">
<button
aria-current={selected ? "true" : undefined}
class="row-hover flex w-full flex-col items-stretch justify-start gap-1 px-3 py-2 text-left text-sm"
data-row={row.id}
onclick={() => onOpen(row.id)}
type="button"
>
<span class="flex w-full items-center gap-2">
<KindBadge kind={row.kind} />
<span class="min-w-0 flex-1 truncate font-medium">{title}</span>
<span class="tabular shrink-0 text-muted-foreground text-xs">
{fmtRelative(when)}
</span>
</span>
<span class="flex w-full items-center gap-2 pr-7 text-xs">
<StatusPill status={row.running_turn ? "running" : row.status} />
<span class="truncate text-muted-foreground">{row.agent}</span>
{#if row.pending_question}
<span class="shrink-0 font-medium text-link">question</span>
{/if}
</span>
{#if row.last_item}
<span
class="block w-full min-w-0 truncate text-muted-foreground text-xs"
>
<span class="text-foreground/70">{row.last_item.origin}:</span>
{clip(row.last_item.text, PREVIEW_MAX)}
</span>
{/if}
</button>
<ConversationMenu
class={cn(
"absolute right-1.5 bottom-1 size-7 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/row:opacity-100 aria-expanded:opacity-100",
host.touch && "opacity-100",
row.last_item && "bottom-6"
)}
{client}
current={selected}
id={row.id}
mode="button"
{onChanged}
{onOpen}
/>
</div>
</ConversationMenu>
+24 -93
View File
@@ -2,15 +2,12 @@
import { onMount, untrack } from "svelte"; import { onMount, untrack } from "svelte";
import type { ApiClient } from "$lib/api/client"; import type { ApiClient } from "$lib/api/client";
import ErrorNote from "$lib/components/error-note.svelte"; import ErrorNote from "$lib/components/error-note.svelte";
import { Skeleton } from "$lib/components/ui/skeleton";
import * as Tabs from "$lib/components/ui/tabs";
import ActivityFeed from "./activity-feed.svelte"; import ActivityFeed from "./activity-feed.svelte";
import BindingsList from "./bindings-list.svelte";
import ChatView from "./chat-view.svelte"; import ChatView from "./chat-view.svelte";
import Composer from "./composer.svelte"; import Composer from "./composer.svelte";
import ContextView from "./context-view.svelte";
import { ConversationFeed } from "./conversation.svelte"; import { ConversationFeed } from "./conversation.svelte";
import ConversationHeader from "./conversation-header.svelte"; import ConversationHeader from "./conversation-header.svelte";
import QueueList from "./queue-list.svelte";
import RawEntries from "./raw-entries.svelte"; import RawEntries from "./raw-entries.svelte";
let { let {
@@ -19,6 +16,7 @@
href = null, href = null,
onOpen, onOpen,
showTitle = true, showTitle = true,
initialView = "chat",
}: { }: {
client: ApiClient; client: ApiClient;
id: string; id: string;
@@ -26,13 +24,15 @@
onOpen: (id: string) => void; onOpen: (id: string) => void;
// Off when a switcher above the thread already names it. // Off when a switcher above the thread already names it.
showTitle?: boolean; showTitle?: boolean;
// "activity" beside a deep chat's own note: the file is the thread.
initialView?: string;
} = $props(); } = $props();
const feed = new ConversationFeed( const feed = new ConversationFeed(
() => client, () => client,
untrack(() => id) untrack(() => id)
); );
let tab = $state("chat"); let view = $state(untrack(() => initialView));
let historyKey = $state(0); let historyKey = $state(0);
onMount(() => { onMount(() => {
@@ -61,9 +61,10 @@
{#if feed.error && !info} {#if feed.error && !info}
<div class="p-4"><ErrorNote message={feed.error} retry={refresh} /></div> <div class="p-4"><ErrorNote message={feed.error} retry={refresh} /></div>
{:else if !info} {:else if !info}
<div class="flex flex-col gap-3 p-4"> <div class="flex flex-1 items-center justify-center p-4">
<Skeleton class="h-8 w-1/2" /> <span
<Skeleton class="h-24 w-full" /> class="size-2 animate-pulse-dot rounded-full bg-muted-foreground/40"
></span>
</div> </div>
{:else} {:else}
<ConversationHeader <ConversationHeader
@@ -75,49 +76,36 @@
onChanged={refresh} onChanged={refresh}
{onOpen} {onOpen}
{showTitle} {showTitle}
bind:view
/> />
<div class="flex min-h-0 flex-1 flex-col"> <div class="flex min-h-0 flex-1 flex-col">
<Tabs.Root class="flex min-h-0 flex-1 flex-col gap-0" bind:value={tab}> {#if view === "chat"}
<div class="border-b px-3 py-2 @md:px-6">
<Tabs.List class="w-fit max-w-full">
<Tabs.Trigger value="chat">Chat</Tabs.Trigger>
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
<Tabs.Trigger value="raw">Raw</Tabs.Trigger>
<Tabs.Trigger value="meta">Meta</Tabs.Trigger>
</Tabs.List>
</div>
<Tabs.Content class="flex min-h-0 flex-1 flex-col" value="chat">
<ChatView <ChatView
{client} {client}
conversationId={id} conversationId={id}
model={feed.model} model={feed.model}
refreshKey={historyKey} refreshKey={historyKey}
/> />
</Tabs.Content> {:else if view === "activity"}
<Tabs.Content <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 px-3 py-3 @md:px-6"
value="activity"
>
<ActivityFeed <ActivityFeed
{client} {client}
connected={feed.live.state === "open"} connected={feed.live.state === "open"}
conversationId={id} conversationId={id}
model={feed.model} model={feed.model}
/> />
</Tabs.Content> </div>
<Tabs.Content {:else if view === "context"}
class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6" <div class="min-h-0 flex-1 overflow-y-auto px-3 py-4 @md:px-6">
value="raw" {#key historyKey}
> <ContextView {client} conversationId={id} />
{/key}
</div>
{:else}
<div class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6">
<RawEntries {client} conversationId={id} /> <RawEntries {client} conversationId={id} />
</Tabs.Content> </div>
<Tabs.Content {/if}
class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
value="meta"
>
{@render meta()}
</Tabs.Content>
</Tabs.Root>
<Composer <Composer
{client} {client}
conversationId={id} conversationId={id}
@@ -126,60 +114,3 @@
</div> </div>
{/if} {/if}
</div> </div>
{#snippet meta()}
{#if info}
<div class="flex max-w-3xl flex-col gap-5">
<section class="flex flex-col gap-1.5">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Queue
</h2>
<QueueList items={info.queue} />
</section>
<section class="flex flex-col gap-1.5">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Windows
</h2>
<BindingsList
bindings={info.bindings}
{client}
conversationId={id}
onChanged={refresh}
/>
</section>
<section class="flex flex-col gap-1.5">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Flags
</h2>
{#if Object.keys(info.flags).length === 0}
<p class="text-muted-foreground text-xs">No flags set.</p>
{:else}
<dl
class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs"
>
{#each Object.entries(info.flags) as [key, value] (key)}
<dt class="text-muted-foreground">{key}</dt>
<dd class="truncate">{JSON.stringify(value)}</dd>
{/each}
</dl>
{/if}
</section>
<section class="flex flex-col gap-1.5">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Session
</h2>
<p class="tabular break-all text-muted-foreground text-xs">
{info.session_id ?? "no session yet"}
</p>
</section>
</div>
{/if}
{/snippet}
+4
View File
@@ -1,4 +1,5 @@
import { getContext, setContext } from "svelte"; import { getContext, setContext } from "svelte";
import { type PanelCache, panelCache } from "./cache";
export type LinkKind = "internal" | "external"; export type LinkKind = "internal" | "external";
@@ -7,6 +8,8 @@ export type LinkKind = "internal" | "external";
// Obsidian plugin renders through MarkdownRenderer and opens notes in // Obsidian plugin renders through MarkdownRenderer and opens notes in
// place. Anything the host leaves undefined falls back to the browser way. // place. Anything the host leaves undefined falls back to the browser way.
export interface PanelHost { export interface PanelHost {
// Where the index and the last threads persist between opens.
cache?: PanelCache;
// Renders ``text`` into ``node``; returns the cleanup. Absent: marked + DOMPurify. // Renders ``text`` into ``node``; returns the cleanup. Absent: marked + DOMPurify.
markdown?: (node: HTMLElement, text: string) => (() => void) | undefined; markdown?: (node: HTMLElement, text: string) => (() => void) | undefined;
name: "browser" | "obsidian"; name: "browser" | "obsidian";
@@ -35,6 +38,7 @@ export function browserHost(): PanelHost {
return browser; return browser;
} }
browser = { browser = {
cache: panelCache("beaver.panel"),
name: "browser", name: "browser",
openLink(target, kind) { openLink(target, kind) {
if (kind === "internal") { if (kind === "internal") {
+12 -1
View File
@@ -1,9 +1,12 @@
import type { ApiClient } from "$lib/api/client"; import type { ApiClient } from "$lib/api/client";
import { LiveStream } from "$lib/api/live.svelte"; import { LiveStream } from "$lib/api/live.svelte";
import type { BusEvent, ConversationSummary } from "$lib/api/types"; import type { BusEvent, ConversationSummary } from "$lib/api/types";
import type { PanelCache } from "./cache";
const RELOAD_DEBOUNCE_MS = 1500; const RELOAD_DEBOUNCE_MS = 1500;
const PAGE = 500; const PAGE = 500;
const CACHED_ROWS = 200;
const INDEX_KEY = "index";
// The conversation index kept fresh by ``/api/events``: rows land as // The conversation index kept fresh by ``/api/events``: rows land as
// ``conversation.*`` events arrive, turn markers flip ``running_turn`` // ``conversation.*`` events arrive, turn markers flip ``running_turn``
@@ -14,10 +17,17 @@ export class ConversationIndex {
loaded = $state(false); loaded = $state(false);
readonly live: LiveStream; readonly live: LiveStream;
protected readonly client: () => ApiClient | null; protected readonly client: () => ApiClient | null;
private readonly cache: PanelCache | null;
private reloadTimer: ReturnType<typeof setTimeout> | null = null; private reloadTimer: ReturnType<typeof setTimeout> | null = null;
constructor(client: () => ApiClient | null) { constructor(client: () => ApiClient | null, cache: PanelCache | null = null) {
this.client = client; this.client = client;
this.cache = cache;
const cached = cache?.get<ConversationSummary[]>(INDEX_KEY);
if (cached && cached.length > 0) {
this.conversations = cached;
this.loaded = true;
}
this.live = new LiveStream(client, "/api/events", { this.live = new LiveStream(client, "/api/events", {
onEvent: (event) => this.apply(event), onEvent: (event) => this.apply(event),
prepare: () => this.load(), prepare: () => this.load(),
@@ -44,6 +54,7 @@ export class ConversationIndex {
const list = await client.conversations({ limit: PAGE }); const list = await client.conversations({ limit: PAGE });
this.conversations = list.conversations; this.conversations = list.conversations;
this.loaded = true; this.loaded = true;
this.cache?.set(INDEX_KEY, list.conversations.slice(0, CACHED_ROWS));
} }
apply(event: BusEvent): void { apply(event: BusEvent): void {
+22 -28
View File
@@ -3,19 +3,20 @@
import Link2Icon from "@lucide/svelte/icons/link-2"; import Link2Icon from "@lucide/svelte/icons/link-2";
import Link2OffIcon from "@lucide/svelte/icons/link-2-off"; import Link2OffIcon from "@lucide/svelte/icons/link-2-off";
import type { ApiClient } from "$lib/api/client"; import type { ApiClient } from "$lib/api/client";
import KindBadge from "$lib/components/kind-badge.svelte";
import LiveDot from "$lib/components/live-dot.svelte";
import { Button } from "$lib/components/ui/button";
import * as Popover from "$lib/components/ui/popover"; import * as Popover from "$lib/components/ui/popover";
import { clip, shortId } from "$lib/format"; import { clip, shortId } from "$lib/format";
import ConversationPicker from "./conversation-picker.svelte"; import Rail from "$lib/rail/rail.svelte";
import KindMark from "$lib/shell/kind-mark.svelte";
import { cn } from "$lib/utils";
import type { ConversationIndex } from "./index.svelte"; import type { ConversationIndex } from "./index.svelte";
import PanelIsland from "./panel-island.svelte";
let { let {
client, client,
index, index,
selected = null, selected = null,
onOpen, onOpen,
onOpenFile,
follow = $bindable(false), follow = $bindable(false),
showFollow = false, showFollow = false,
}: { }: {
@@ -23,6 +24,7 @@
index: ConversationIndex; index: ConversationIndex;
selected?: string | null; selected?: string | null;
onOpen: (id: string) => void; onOpen: (id: string) => void;
onOpenFile?: (path: string) => void;
follow?: boolean; follow?: boolean;
showFollow?: boolean; showFollow?: boolean;
} = $props(); } = $props();
@@ -30,10 +32,10 @@
const TITLE_MAX = 48; const TITLE_MAX = 48;
let switcherOpen = $state(false); let switcherOpen = $state(false);
const current = $derived(selected ? index.byId(selected) : undefined); const current = $derived(selected ? index.byId(selected) : undefined);
const running = $derived(index.running.length);
</script> </script>
<div class="flex items-center gap-1 border-b px-2 py-1.5"> <div class="flex items-center gap-1.5 border-b px-2 py-1.5">
<PanelIsland compact {index} {onOpen} />
<Popover.Root bind:open={switcherOpen}> <Popover.Root bind:open={switcherOpen}>
<Popover.Trigger> <Popover.Trigger>
{#snippet child({ props })} {#snippet child({ props })}
@@ -44,7 +46,7 @@
type="button" type="button"
> >
{#if current} {#if current}
<KindBadge kind={current.kind} /> <KindMark kind={current.kind} />
<span class="min-w-0 flex-1 truncate font-medium"> <span class="min-w-0 flex-1 truncate font-medium">
{current.title {current.title
? clip(current.title, TITLE_MAX) ? clip(current.title, TITLE_MAX)
@@ -59,14 +61,6 @@
Pick a conversation Pick a conversation
</span> </span>
{/if} {/if}
{#if running > 0 && !current?.running_turn}
<span
class="tabular shrink-0 rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
title="{running} running elsewhere"
>
{running}
</span>
{/if}
<ChevronDownIcon class="size-3.5 shrink-0 text-icon" /> <ChevronDownIcon class="size-3.5 shrink-0 text-icon" />
</button> </button>
{/snippet} {/snippet}
@@ -76,44 +70,44 @@
class="w-[min(24rem,calc(100vw-1rem))] gap-0 overflow-hidden p-0" class="w-[min(24rem,calc(100vw-1rem))] gap-0 overflow-hidden p-0"
sideOffset={6} sideOffset={6}
> >
<div class="flex max-h-[min(70vh,32rem)] flex-col"> <div class="flex h-[min(70vh,32rem)] flex-col">
<ConversationPicker <Rail
autofocus
{client} {client}
{index} {index}
onOpen={(id) => { onOpen={(id) => {
switcherOpen = false; switcherOpen = false;
onOpen(id); onOpen(id);
}} }}
onOpenFile={(path) => {
switcherOpen = false;
onOpenFile?.(path);
}}
{selected} {selected}
/> />
</div> </div>
</Popover.Content> </Popover.Content>
</Popover.Root> </Popover.Root>
<LiveDot
class="px-1"
detail={index.live.detail}
label={false}
state={index.live.state}
/>
{#if showFollow} {#if showFollow}
<Button <button
aria-label="Follow the active note" aria-label="Follow the active note"
aria-pressed={follow} aria-pressed={follow}
class={cn(
"rule-word inline-flex size-7 shrink-0 items-center justify-center rounded-md",
follow && "bg-accent text-foreground"
)}
onclick={() => { onclick={() => {
follow = !follow; follow = !follow;
}} }}
size="icon-sm"
title={follow title={follow
? "Following the active note - click to pin this conversation" ? "Following the active note - click to pin this conversation"
: "Pinned - click to follow the active note"} : "Pinned - click to follow the active note"}
variant={follow ? "secondary" : "ghost"} type="button"
> >
{#if follow} {#if follow}
<Link2Icon class="size-4" /> <Link2Icon class="size-4" />
{:else} {:else}
<Link2OffIcon class="size-4" /> <Link2OffIcon class="size-4" />
{/if} {/if}
</Button> </button>
{/if} {/if}
</div> </div>
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import { cn } from "$lib/utils";
import type { ConversationIndex } from "./index.svelte";
let {
index,
onOpen,
compact = false,
class: className = "",
}: {
index: ConversationIndex;
onOpen: (id: string) => void;
compact?: boolean;
class?: string;
} = $props();
const waiting = $derived(index.open.filter((row) => row.pending_question));
const running = $derived(
index.open.filter((row) => row.running_turn && !row.pending_question)
);
const live = $derived(index.live.state);
const dot = $derived.by(() => {
if (live === "failed") {
return "bg-destructive";
}
if (live !== "open") {
return "bg-warn";
}
return running.length > 0 ? "bg-signal animate-pulse-dot" : "bg-ok";
});
const headline = $derived.by(() => {
if (live === "failed") {
return "offline";
}
if (live !== "open") {
return "connecting";
}
if (running.length === 0) {
return "idle";
}
return running.length === 1 ? "1 in motion" : `${running.length} in motion`;
});
const target = $derived(waiting[0] ?? running[0] ?? null);
const questions = $derived.by(() => {
if (compact) {
return String(waiting.length);
}
return waiting.length === 1 ? "1 question" : `${waiting.length} questions`;
});
</script>
<button
aria-label={target ? `Open ${target.title ?? target.kind}` : headline}
class={cn("island", compact && "gap-1.5 px-2.5", className)}
disabled={!target}
onclick={() => target && onOpen(target.id)}
title={index.live.detail ?? headline}
type="button"
>
<span class={cn("size-2 shrink-0 rounded-full", dot)}></span>
{#if !compact}
<span class="font-medium">{headline}</span>
{/if}
{#if waiting.length > 0}
{#if !compact}
<span class="island-sep"></span>
{/if}
<span class="font-medium text-attention-foreground">{questions}</span>
{/if}
</button>
+30 -31
View File
@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { ApiClient } from "$lib/api/client"; import type { ApiClient } from "$lib/api/client";
import EmptyState from "$lib/components/empty-state.svelte"; import Rail from "$lib/rail/rail.svelte";
import ConversationPicker from "./conversation-picker.svelte";
import ConversationView from "./conversation-view.svelte"; import ConversationView from "./conversation-view.svelte";
import type { ConversationIndex } from "./index.svelte"; import type { ConversationIndex } from "./index.svelte";
import PanelBar from "./panel-bar.svelte"; import PanelBar from "./panel-bar.svelte";
import PanelIsland from "./panel-island.svelte";
let { let {
client, client,
@@ -13,12 +13,17 @@
selected = $bindable(null), selected = $bindable(null),
follow = $bindable(false), follow = $bindable(false),
showFollow = false, showFollow = false,
companion = false,
onOpenFile,
}: { }: {
client: ApiClient; client: ApiClient;
index: ConversationIndex; index: ConversationIndex;
selected?: string | null; selected?: string | null;
follow?: boolean; follow?: boolean;
showFollow?: boolean; showFollow?: boolean;
// The thread lives in the note beside this panel: open on activity.
companion?: boolean;
onOpenFile?: (path: string) => void;
} = $props(); } = $props();
// 48rem of panel: below it the switcher names the thread, above it the rail does. // 48rem of panel: below it the switcher names the thread, above it the rail does.
@@ -37,19 +42,17 @@
</script> </script>
<!-- <!--
impeccable direction contract (mode operate, brief-pinned world: beaver-calendar's Obsidian skin) impeccable direction contract (seed 071a8c77, mode operate, strip board × live activity, in Obsidian's skin)
THESIS: the agent's terminal, not a chat app. The thread is always on screen; picking a THESIS: the agent's front inside the vault, not a website in a pane. The island carries what is
conversation is a header switch, never a screen of its own. Refuses the messenger's list-first alive, the rail is the day's strips, the thread owns its column.
layout and the admin's chrome. OWN-WORLD: Obsidian's own variables through .beaver-root; strips with a state edge, hairline
OWN-WORLD: Obsidian's own variables through .beaver-root - its font, radii, accent, and status rules, tabular numerals; amber only for a question waiting; nothing decorative.
colors; hairline rows, tabular numerals, uppercase 12px section labels; nothing pink. STORY: the operator opens the tab and reads the island, picks today's strip, answers a question,
STORY: the operator opens a note, the panel follows to its conversation, they watch tools and watches tools stream; beside a deep chat the sidedock shows the agent's activity, never a
subagents stream, answer a question, branch off with a chosen seed, send the branch back to second copy of the note.
Telegram, and close a deep chat with memory on or off - all from the sidedock. FIRST VIEWPORT: narrow: island + switcher over the thread and the composer pinned low. Wide:
FIRST VIEWPORT: narrow column: the switcher (kind badge, title, live dot, follow toggle) over the rail with the island on top, the thread to the right.
the thread header, tabs, the live thread and the composer pinned at the bottom. Wide tab: FORM: strip board × live activity; user-steered; seed key 071a8c77.
the same with the grouped conversation rail on the left. Primary action: write to the agent.
FORM: two-pane operator console; pinned by the brief, no roll.
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review,
the verdict, and DESIGN.md the verdict, and DESIGN.md
--> -->
@@ -59,15 +62,11 @@ the verdict, and DESIGN.md
> >
<div class="flex min-h-0 flex-1"> <div class="flex min-h-0 flex-1">
{#if wide} {#if wide}
<aside class="flex w-72 shrink-0 flex-col border-r bg-sidebar/40"> <aside class="flex w-80 shrink-0 flex-col border-r">
<ConversationPicker <div class="flex items-center gap-2 px-3 pt-3 pb-1">
{client} <PanelIsland {index} onOpen={open} />
{index} </div>
onOpen={open} <Rail {client} {index} onOpen={open} {onOpenFile} {selected} />
{selected}
{showFollow}
bind:follow
/>
</aside> </aside>
{/if} {/if}
<section class="flex min-w-0 flex-1 flex-col"> <section class="flex min-w-0 flex-1 flex-col">
@@ -76,6 +75,7 @@ the verdict, and DESIGN.md
{client} {client}
{index} {index}
onOpen={open} onOpen={open}
{onOpenFile}
{selected} {selected}
{showFollow} {showFollow}
bind:follow bind:follow
@@ -86,19 +86,18 @@ the verdict, and DESIGN.md
<ConversationView <ConversationView
{client} {client}
id={selected} id={selected}
initialView={companion ? "activity" : "chat"}
onOpen={open} onOpen={open}
showTitle={wide} showTitle={wide}
/> />
{/key} {/key}
{:else} {:else}
<div class="flex flex-1 items-start justify-center p-4 @md:p-6"> <div class="flex flex-1 items-start justify-center p-4 @md:p-6">
<EmptyState <p class="max-w-md text-muted-foreground text-sm">
class="w-full max-w-md" {showFollow
hint={showFollow ? "Open a note with a conversation in its frontmatter, or pick a strip."
? "Open a note with conversation_id in its frontmatter, or pick one from the list." : "Pick a strip: the master, a branch, a deep chat."}
: "Pick one from the list: the master, a branch, a deep chat."} </p>
title="No conversation on screen"
/>
</div> </div>
{/if} {/if}
</section> </section>
+143
View File
@@ -68,3 +68,146 @@
column-gap: 0.75rem; column-gap: 0.75rem;
align-items: center; align-items: center;
} }
/* Strips: the one row vocabulary of the console. A strip is a physical
object in a bay: fixed columns, a state edge, lifts on hover, cocked
(pushed out) while it waits for the operator. */
@layer components {
.strip {
position: relative;
display: grid;
grid-template-columns: 1.5rem minmax(0, 1fr) auto;
column-gap: 0.75rem;
align-items: center;
min-height: 2.75rem;
padding: 0.375rem 0.875rem 0.375rem 0.625rem;
color: var(--foreground);
text-decoration: none;
background: var(--strip);
border-bottom: 1px solid var(--border);
transition:
transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1),
box-shadow 200ms cubic-bezier(0.2, 0.8, 0.2, 1),
background-color 150ms ease-out;
}
.strip::before {
position: absolute;
top: 0.375rem;
bottom: 0.375rem;
left: 0;
width: 3px;
content: "";
background: transparent;
border-radius: 0 2px 2px 0;
transition: background-color 200ms ease-out;
}
.strip:first-child {
border-top-left-radius: var(--radius-md);
border-top-right-radius: var(--radius-md);
}
.strip:last-child {
border-bottom: 0;
border-bottom-right-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.strip:hover,
.strip:focus-visible {
z-index: 1;
box-shadow: var(--shadow-lift);
transform: translateY(-1px);
}
.strip[data-state="running"]::before {
background: var(--signal);
animation: pulse-edge 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.strip[data-state="waiting"] {
z-index: 1;
box-shadow: var(--shadow-lift);
transform: translateX(0.5rem);
}
.strip[data-state="waiting"]::before {
background: var(--attention);
}
.strip[data-state="waiting"]:hover {
transform: translateX(0.5rem) translateY(-1px);
}
.strip[aria-current="true"] {
background: color-mix(in oklab, var(--strip) 85%, var(--primary) 15%);
}
.strip[data-state="closed"] {
color: var(--muted-foreground);
}
.strip-open {
display: block;
padding: 0.25rem 0.875rem 0.875rem 3rem;
background: var(--strip);
border-bottom: 1px solid var(--border);
}
.strip-open:last-child {
border-bottom: 0;
border-bottom-right-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.bay {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.bay-rack {
background: color-mix(in oklab, var(--rack) 70%, var(--strip) 30%);
border: 1px solid var(--border);
border-radius: calc(var(--radius-md) + 1px);
box-shadow: inset 0 1px 0
color-mix(in oklab, var(--foreground) 4%, transparent);
}
.island {
display: inline-flex;
gap: 0.625rem;
align-items: center;
height: 2.125rem;
padding: 0 0.875rem 0 0.75rem;
font-size: var(--text-sm);
color: var(--foreground);
white-space: nowrap;
background: var(--strip);
border: 1px solid var(--border);
border-radius: 999px;
box-shadow: 0 1px 2px oklch(0.2 0.06 340 / 0.06);
transition:
transform 220ms cubic-bezier(0.2, 0.9, 0.25, 1.1),
box-shadow 220ms ease-out,
background-color 150ms ease-out;
}
.island:hover,
.island[aria-expanded="true"] {
box-shadow: var(--shadow-lift);
transform: scale(1.02);
}
.island-sep {
width: 1px;
height: 1rem;
background: var(--border);
}
.doc {
max-width: 78ch;
}
}
@keyframes pulse-edge {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
@layer components {
.strip-child {
padding-left: 1.75rem;
}
.strip-child::before {
left: 1.125rem;
}
}
+1 -1
View File
@@ -28,7 +28,7 @@
<article <article
class={cn( class={cn(
"flex animate-rise flex-col gap-2 border-b py-3", "flex flex-col gap-2 border-b py-3",
turn.status === "running" && "bg-signal/[0.03]" turn.status === "running" && "bg-signal/[0.03]"
)} )}
> >
+109
View File
@@ -0,0 +1,109 @@
import type { ConversationSummary } from "$lib/api/types";
import { parseDate } from "$lib/format";
import { byActivity } from "$lib/shell/state";
export interface DayGroup {
branches: ConversationSummary[];
day: string;
deep: ConversationSummary[];
label: string;
live: boolean;
master: ConversationSummary | null;
others: ConversationSummary[];
}
const DAY_MS = 86_400_000;
function localDay(iso: string | null | undefined): string {
const date = parseDate(iso) ?? new Date();
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
export function dayLabel(day: string, now = new Date()): string {
const today = localDay(now.toISOString());
const yesterday = localDay(new Date(now.getTime() - DAY_MS).toISOString());
if (day === today) {
return "Today";
}
if (day === yesterday) {
return "Yesterday";
}
const date = new Date(`${day}T12:00:00`);
return date.toLocaleDateString(undefined, {
day: "numeric",
month: "short",
weekday: "short",
});
}
function activityOf(row: ConversationSummary): string {
return row.last_activity_at ?? row.created_at ?? "";
}
// Days as the operator remembers them: a master per day, its branches
// under it, deep chats by the day they last moved, forks with their
// parent's day. Jobs never enter the rail.
export function groupByDay(
rows: ConversationSummary[],
now = new Date()
): DayGroup[] {
const groups = new Map<string, DayGroup>();
const dayOfMaster = new Map<string, string>();
const group = (day: string): DayGroup => {
let found = groups.get(day);
if (!found) {
found = {
branches: [],
day,
deep: [],
label: dayLabel(day, now),
live: false,
master: null,
others: [],
};
groups.set(day, found);
}
return found;
};
const masters = rows
.filter((row) => row.kind === "master")
.sort((a, b) => (b.created_at ?? "").localeCompare(a.created_at ?? ""));
for (const master of masters) {
const day = localDay(master.created_at);
dayOfMaster.set(master.id, day);
const g = group(day);
if (!g.master || master.status === "open") {
g.master = master;
} else {
g.others.push(master);
}
}
for (const row of rows) {
if (row.kind === "master" || row.kind === "job") {
continue;
}
const parentDay = row.parent ? dayOfMaster.get(row.parent) : undefined;
const day = parentDay ?? localDay(activityOf(row));
const g = group(day);
if (row.kind === "branch") {
g.branches.push(row);
} else if (row.kind === "deep") {
g.deep.push(row);
} else {
g.others.push(row);
}
}
const out = [...groups.values()];
for (const g of out) {
g.branches.sort(byActivity);
g.deep.sort(byActivity);
g.others.sort(byActivity);
g.live = [g.master, ...g.branches, ...g.deep, ...g.others].some(
(row) => row && (row.running_turn || row.pending_question)
);
}
return out.sort((a, b) => b.day.localeCompare(a.day));
}
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client";
import type { AgentInfo } from "$lib/api/types";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select";
let {
client,
open = $bindable(false),
onCreated,
}: {
client: ApiClient;
open?: boolean;
onCreated: (id: string) => void;
} = $props();
const KINDS = ["deep", "branch", "master", "job"];
let agents = $state<AgentInfo[]>([]);
let form = $state({ agent: "", kind: "deep", text: "", title: "" });
let busy = $state(false);
$effect(() => {
if (open && agents.length === 0) {
client
.agents()
.then((result) => {
({ agents } = result);
})
.catch(() => undefined);
}
});
const agentsForKind = $derived(
agents.filter((a) =>
a.kinds.includes(form.kind as AgentInfo["kinds"][number])
)
);
async function create() {
busy = true;
try {
const created = await client.spawn({
agent: form.agent || undefined,
kind: form.kind,
seed: "clean",
text: form.text || undefined,
title: form.title || undefined,
});
open = false;
form = { agent: "", kind: "deep", text: "", title: "" };
onCreated(created.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>New conversation</Dialog.Title>
<Dialog.Description>
It opens in the home window of its kind (a vault file, a Telegram topic)
and stays silent until someone speaks.
</Dialog.Description>
</Dialog.Header>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<Label>Kind</Label>
<Select.Root
onValueChange={(value) => {
form.kind = value;
form.agent = "";
}}
type="single"
value={form.kind}
>
<Select.Trigger class="w-full">{form.kind}</Select.Trigger>
<Select.Content>
{#each KINDS as option (option)}
<Select.Item label={option} value={option} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col gap-1.5">
<Label>Agent</Label>
<Select.Root
onValueChange={(value) => {
form.agent = value === "default" ? "" : value;
}}
type="single"
value={form.agent || "default"}
>
<Select.Trigger class="w-full">
{form.agent || "frontend default"}
</Select.Trigger>
<Select.Content>
<Select.Item label="frontend default" value="default" />
{#each agentsForKind as agent (agent.name)}
<Select.Item label={agent.name} value={agent.name} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="col-span-2 flex flex-col gap-1.5">
<Label for="new-title">Title</Label>
<Input id="new-title" placeholder="optional" bind:value={form.title} />
</div>
<div class="col-span-2 flex flex-col gap-1.5">
<Label for="new-text">First message</Label>
<textarea
class="min-h-20 rounded-md border bg-input/30 px-3 py-2 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
id="new-text"
placeholder="optional - without it the window waits"
bind:value={form.text}
></textarea>
</div>
</div>
<Dialog.Footer>
<Button
onclick={() => {
// biome-ignore lint/suspicious/noGlobalAssign: bindable prop, not window.open
open = false;
}}
variant="ghost"
>
Cancel
</Button>
<Button disabled={busy} onclick={create}>Create</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+320
View File
@@ -0,0 +1,320 @@
<script lang="ts">
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import PlusIcon from "@lucide/svelte/icons/plus";
import SearchIcon from "@lucide/svelte/icons/search";
import { onMount } from "svelte";
import type { ApiClient } from "$lib/api/client";
import type {
ConversationSummary,
SearchFile,
SearchResponse,
} from "$lib/api/types";
import ErrorNote from "$lib/components/error-note.svelte";
import { clip } from "$lib/format";
import type { ConversationIndex } from "$lib/panel/index.svelte";
import Strip from "$lib/shell/strip.svelte";
import { cn } from "$lib/utils";
import { type DayGroup, groupByDay } from "./days";
import NewConversation from "./new-conversation.svelte";
let {
client,
index,
selected = null,
href,
onOpen,
onOpenFile,
class: className = "",
}: {
client: ApiClient;
index: ConversationIndex;
selected?: string | null;
href?: (id: string) => string;
onOpen?: (id: string) => void;
onOpenFile?: (path: string) => void;
class?: string;
} = $props();
const SEARCH_MIN = 2;
const SEARCH_DEBOUNCE_MS = 250;
const DAYS_SHOWN = 14;
const TICK_MS = 30_000;
let query = $state("");
let expanded = $state<Set<string>>(new Set());
let remote = $state<SearchResponse | null>(null);
let searching = $state(false);
let createOpen = $state(false);
let now = $state(Date.now());
let timer: ReturnType<typeof setTimeout> | undefined;
onMount(() => {
const tick = setInterval(() => {
now = Date.now();
}, TICK_MS);
return () => clearInterval(tick);
});
const needle = $derived(query.trim().toLowerCase());
const groups = $derived(groupByDay(index.conversations, new Date(now)));
const local = $derived.by((): ConversationSummary[] => {
if (!needle) {
return [];
}
return index.conversations.filter(
(row) =>
row.kind !== "job" &&
((row.title ?? "").toLowerCase().includes(needle) ||
row.id.startsWith(needle) ||
(row.last_item?.text ?? "").toLowerCase().includes(needle))
);
});
const found = $derived.by((): ConversationSummary[] => {
const seen = new Set(local.map((row) => row.id));
const extra = (remote?.query === needle ? remote.conversations : []).filter(
(row) => !seen.has(row.id)
);
return [...local, ...extra];
});
const files = $derived<SearchFile[]>(
remote?.query === needle ? remote.files : []
);
$effect(() => {
const q = needle;
clearTimeout(timer);
if (q.length < SEARCH_MIN) {
remote = null;
searching = false;
return;
}
searching = true;
timer = setTimeout(async () => {
try {
const result = await client.search(q);
if (result.query === query.trim().toLowerCase()) {
remote = result;
}
} catch {
remote = null;
} finally {
searching = false;
}
}, SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
});
function isOpen(group: DayGroup, position: number): boolean {
return position === 0 || group.live || expanded.has(group.day);
}
function toggle(day: string) {
const next = new Set(expanded);
if (next.has(day)) {
next.delete(day);
} else {
next.add(day);
}
expanded = next;
}
function summary(group: DayGroup): string {
const parts: string[] = [];
if (group.branches.length) {
parts.push(
`${group.branches.length} ${group.branches.length === 1 ? "branch" : "branches"}`
);
}
if (group.deep.length) {
parts.push(`${group.deep.length} deep`);
}
if (group.others.length) {
parts.push(`${group.others.length} more`);
}
return parts.join(" · ");
}
const linkOf = (id: string) => href?.(id);
</script>
<div class={cn("flex h-full min-h-0 flex-col", className)}>
<div class="flex items-center gap-2 px-3 py-2">
<label
class="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md bg-strip px-2 ring-1 ring-border focus-within:ring-ring"
>
<SearchIcon class="size-3.5 shrink-0 text-icon" />
<input
aria-label="Search conversations and memory"
class="h-full w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
placeholder="Search"
spellcheck="false"
type="search"
bind:value={query}
>
{#if searching}
<span
class="size-1.5 shrink-0 animate-pulse-dot rounded-full bg-signal"
></span>
{/if}
</label>
<button
aria-label="New conversation"
class="rule-word inline-flex size-8 items-center justify-center rounded-md hover:bg-accent"
onclick={() => {
createOpen = true;
}}
type="button"
>
<PlusIcon class="size-4" />
</button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-3 pb-6">
{#if index.live.state === "failed"}
<ErrorNote
message={index.live.detail ?? "event stream failed"}
retry={() => index.start()}
/>
{:else if !index.loaded}
<p class="px-1 py-3 text-muted-foreground text-sm">Loading…</p>
{:else if needle}
<section class="flex flex-col gap-2 pt-1">
<h2 class="label-quiet px-1">
{found.length === 0 && !searching ? "Nothing matches" : "Conversations"}
</h2>
{#if found.length > 0}
<div class="bay-rack">
{#each found as row (row.id)}
<Strip
current={row.id === selected}
detail={row.last_item?.text ?? ""}
href={linkOf(row.id)}
{now}
onclick={onOpen}
{row}
/>
{/each}
</div>
{/if}
{#if files.length > 0}
<h2 class="label-quiet px-1 pt-2">Memory</h2>
<div class="bay-rack">
{#each files as file (file.path)}
<button
class="strip w-full text-left text-sm"
onclick={() => onOpenFile?.(file.path)}
type="button"
>
<span class="size-5"></span>
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate font-medium">{file.path}</span>
{#if file.snippet}
<span class="truncate text-muted-foreground text-xs">
{clip(file.snippet, 96)}
</span>
{/if}
</span>
<span class="tabular text-muted-foreground text-xs">
{file.line ? `:${file.line}` : ""}
</span>
</button>
{/each}
</div>
{/if}
</section>
{:else}
{#each groups.slice(0, DAYS_SHOWN) as group, position (group.day)}
{@const shown = isOpen(group, position)}
<section class="flex flex-col gap-1.5 pt-2">
<button
aria-expanded={shown}
class="flex items-center gap-1.5 px-1 text-left"
onclick={() => toggle(group.day)}
type="button"
>
<ChevronRightIcon
class={cn(
"size-3.5 text-icon transition-transform duration-150",
shown && "rotate-90"
)}
/>
<span
class={cn(
"font-medium text-sm",
position === 0 ? "text-foreground" : "text-muted-foreground"
)}
>
{group.label}
</span>
{#if group.live}
<span
class="size-1.5 rounded-full bg-signal animate-pulse-dot"
></span>
{/if}
<span class="truncate text-muted-foreground text-xs">
{summary(group)}
</span>
</button>
{#if shown}
<div class="bay-rack">
{#if group.master}
<Strip
current={group.master.id === selected}
detail={group.master.last_item?.text ?? ""}
href={linkOf(group.master.id)}
{now}
onclick={onOpen}
row={group.master}
/>
{/if}
{#each group.branches as row (row.id)}
<Strip
class="strip-child"
current={row.id === selected}
detail={row.last_item?.text ?? ""}
href={linkOf(row.id)}
{now}
onclick={onOpen}
{row}
/>
{/each}
{#each group.deep as row (row.id)}
<Strip
current={row.id === selected}
detail={row.last_item?.text ?? ""}
href={linkOf(row.id)}
{now}
onclick={onOpen}
{row}
/>
{/each}
{#each group.others as row (row.id)}
<Strip
class="strip-child"
current={row.id === selected}
href={linkOf(row.id)}
{now}
onclick={onOpen}
{row}
/>
{/each}
</div>
{/if}
</section>
{/each}
{#if groups.length === 0}
<p class="px-1 py-3 text-muted-foreground text-sm">
No conversations yet. Start one, or wait for the morning master.
</p>
{/if}
{/if}
</div>
</div>
<NewConversation
{client}
onCreated={(id) => onOpen?.(id)}
bind:open={createOpen}
/>
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { cn } from "$lib/utils";
let {
label,
count = null,
tone = "default",
hint = "",
class: className = "",
children,
aside,
}: {
label: string;
count?: number | null;
tone?: "default" | "attention" | "signal";
hint?: string;
class?: string;
children?: Snippet;
aside?: Snippet;
} = $props();
const TONE: Record<string, string> = {
attention: "text-attention-foreground",
default: "text-muted-foreground",
signal: "text-signal",
};
</script>
<section class={cn("bay", className)}>
<header class="flex items-baseline gap-2 px-1">
<h2 class={cn("font-medium text-sm", TONE[tone])}>{label}</h2>
{#if count !== null}
<span class={cn("tabular text-xs", TONE[tone])}>{count}</span>
{/if}
{#if hint}
<span class="truncate text-muted-foreground text-xs">{hint}</span>
{/if}
{#if aside}
<span class="ml-auto">{@render aside()}</span>
{/if}
</header>
<div class="bay-rack">
{@render children?.()}
</div>
</section>
+376
View File
@@ -0,0 +1,376 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import type { ConversationSummary, VaultGraph } from "$lib/api/types";
import { clip, fmtTime, shortId } from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { summarizeInput, toolLabel } from "$lib/panel/activity.svelte";
import QuestionCard from "$lib/panel/question-card.svelte";
import { session } from "$lib/session.svelte";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils";
import Bay from "./bay.svelte";
import Graph, { type GraphEdge, type GraphNode } from "./graph.svelte";
import Instruments from "./instruments.svelte";
import { now as board } from "./now.svelte";
import { stateOf } from "./state";
import Strip from "./strip.svelte";
let {
compact = false,
}: {
compact?: boolean;
} = $props();
const TICK_MS = 1000;
const QUIET_SHOWN = 6;
const TAPE_SHOWN = 24;
const HOURS_DAY = 24;
let now = $state(Date.now());
let spend = $state<number | null>(null);
let vault = $state<VaultGraph | null>(null);
let vaultFor = $state<string | null>(null);
let quietOpen = $state(false);
let tapeOpen = $state(false);
onMount(() => {
const tick = setInterval(() => {
now = Date.now();
}, TICK_MS);
const { client } = session;
if (client) {
client
.usage({ group_by: "agent", hours: HOURS_DAY })
.then((usage) => {
spend = usage.total.cost_usd;
})
.catch(() => undefined);
}
return () => clearInterval(tick);
});
$effect(() => {
const key = board.liveIds.join("|");
if (key.length >= 0) {
untrack(() => board.refreshSnapshots().catch(() => undefined));
}
});
const href = (id: string) => `${base}/conversations/${id}`;
const GRAPH_FILES = 24;
async function loadVault(masterId: string) {
const { client } = session;
if (!client) {
return;
}
try {
const context = await client.context(masterId);
const paths = context.files.slice(0, GRAPH_FILES).map((f) => f.path);
vault = paths.length > 0 ? await client.vaultGraph(paths, 60) : null;
} catch {
vault = null;
}
vaultFor = masterId;
}
$effect(() => {
const { master } = board;
if (!master || master.running_turn) {
return;
}
const key = `${master.id}:${master.last_activity_at ?? ""}`;
if (vaultFor !== key) {
vaultFor = key;
untrack(() => loadVault(master.id));
}
});
function open(id: string) {
ui.closeAll();
goto(href(id));
}
function lastTool(row: ConversationSummary): string {
const info = board.snapshots[row.id];
const tools = info?.turn?.tools ?? [];
const live = tools.filter((t) => !t.ended_at);
const tool = live.at(-1) ?? tools.at(-1);
if (!tool) {
return info?.turn?.text ? clip(info.turn.text, 80) : "thinking";
}
const what = summarizeInput(tool.name, tool.input);
const short = what.startsWith("/")
? what.split("/").slice(-2).join("/")
: what;
return `${toolLabel(tool.name)} · ${clip(short, 72)}`;
}
const quiet = $derived(
quietOpen ? board.quiet : board.quiet.slice(0, QUIET_SHOWN)
);
const windows = $derived(gateway.limits?.windows ?? []);
const tape = $derived(gateway.tape.slice(0, TAPE_SHOWN));
const MD_SUFFIX = /\.md$/;
function conversationNodes(master: ConversationSummary | null): {
nodes: GraphNode[];
edges: GraphEdge[];
} {
const nodes: GraphNode[] = [];
const edges: GraphEdge[] = [];
const rows = gateway.conversations.filter(
(row) => row.status === "open" || row.running_turn
);
if (master) {
nodes.push({
href: href(master.id),
id: master.id,
kind: "master",
label: master.title ?? "master",
ring: 0,
state: stateOf(master),
});
}
for (const row of rows) {
if (row.id === master?.id || row.kind === "master") {
continue;
}
const child = row.kind === "branch" || row.kind === "fork";
nodes.push({
href: href(row.id),
id: row.id,
kind: row.kind,
label: row.title ?? `${row.kind} ${shortId(row.id)}`,
ring: child ? 1 : 2,
state: stateOf(row),
});
if (master && child) {
edges.push({ from: master.id, to: row.id });
}
}
return { edges, nodes };
}
function noteState(touched: boolean, exists: boolean): GraphNode["state"] {
if (!touched) {
return "ghost";
}
return exists ? "quiet" : "closed";
}
function vaultNodes(
master: ConversationSummary,
graphData: VaultGraph
): { nodes: GraphNode[]; edges: GraphEdge[] } {
const nodes: GraphNode[] = graphData.nodes.map((note) => ({
href: note.exists ? note.path : undefined,
id: note.path,
kind: "file",
label: note.title,
ring: note.touched ? 1 : 2,
state: noteState(note.touched, note.exists),
}));
const edges: GraphEdge[] = graphData.nodes
.filter((note) => note.touched)
.map((note) => ({ from: master.id, to: note.path }));
for (const edge of graphData.edges) {
edges.push({ from: edge.from, to: edge.to });
}
return { edges, nodes };
}
const graph = $derived.by((): { nodes: GraphNode[]; edges: GraphEdge[] } => {
const { master } = board;
const own = conversationNodes(master);
if (!(vault && master)) {
return own;
}
const extra = vaultNodes(master, vault);
return {
edges: [...own.edges, ...extra.edges],
nodes: [...own.nodes, ...extra.nodes],
};
});
function openNode(id: string) {
if (id.endsWith(".md")) {
window.open(
`obsidian://open?file=${encodeURIComponent(id.replace(MD_SUFFIX, ""))}`
);
return;
}
open(id);
}
</script>
<div
class={cn(
"@container grid gap-8",
compact
? "grid-cols-1"
: "grid-cols-1 lg:grid-cols-[minmax(0,1fr)_20rem] xl:grid-cols-[minmax(0,1fr)_24rem]"
)}
>
<div class="flex min-w-0 flex-col gap-8">
<Instruments
{compact}
contextTokens={board.contextTokens}
contextWindow={board.contextWindow}
{now}
{spend}
{windows}
/>
{#if board.waiting.length > 0}
<Bay
count={board.waiting.length}
hint="the agent asked and stopped"
label="Waiting for you"
tone="attention"
>
{#each board.waiting as row (row.id)}
{@const info = board.snapshots[row.id]}
<Strip
detail={info?.question?.questions[0]?.question ?? "question pending"}
href={href(row.id)}
{now}
open={Boolean(info?.question)}
{row}
state="waiting"
>
{#if info?.question && session.client}
<QuestionCard
client={session.client}
conversationId={row.id}
question={info.question}
/>
{/if}
</Strip>
{/each}
</Bay>
{/if}
<Bay
count={board.running.length}
hint={board.running.length === 0 ? "nothing in a turn" : ""}
label="In motion"
tone="signal"
>
{#if board.running.length === 0}
<p class="px-4 py-3 text-muted-foreground text-sm">
Idle. The next message or inject lights a strip here.
</p>
{:else}
{#each board.running as row (row.id)}
<Strip
detail={lastTool(row)}
href={href(row.id)}
{now}
{row}
state="running"
/>
{/each}
{/if}
</Bay>
<Bay
count={board.quiet.length}
hint="open, waiting for the next word"
label="Quiet"
>
{#snippet aside()}
{#if board.quiet.length > QUIET_SHOWN}
<button
class="rule-word text-xs"
onclick={() => {
quietOpen = !quietOpen;
}}
type="button"
>
{quietOpen ? "fewer" : `all ${board.quiet.length}`}
</button>
{/if}
{/snippet}
{#if !gateway.loaded}
<p class="px-4 py-3 text-muted-foreground text-sm">Loading…</p>
{:else if quiet.length === 0}
<p class="px-4 py-3 text-muted-foreground text-sm">
No open conversations.
</p>
{:else}
{#each quiet as row (row.id)}
<Strip
detail={row.last_item?.text ?? ""}
href={href(row.id)}
{now}
{row}
state="quiet"
/>
{/each}
{/if}
</Bay>
{#if !compact}
<section class="flex flex-col gap-2">
<button
aria-expanded={tapeOpen}
class="rule-word flex items-center gap-2 self-start px-1 text-sm"
onclick={() => {
tapeOpen = !tapeOpen;
}}
type="button"
>
Recent events
<span class="tabular text-xs">{gateway.tape.length}</span>
</button>
{#if tapeOpen}
<ul class="flex flex-col px-1 text-xs">
{#each tape as item (item.seq)}
<li
class="ledger-grid grid-cols-[5rem_8rem_minmax(0,1fr)] py-0.5"
>
<span class="tabular text-muted-foreground">
{fmtTime(item.ts)}
</span>
<span class="truncate">{item.type}</span>
<span class="truncate text-muted-foreground">
{#if item.conversation_id}
<a
class="hover:underline"
href={href(item.conversation_id)}
>
{shortId(item.conversation_id)}
</a>
{/if}
{#if typeof item.name === "string"}
{item.name}
{/if}
{#if typeof item.text === "string"}
{item.text.slice(0, 80)}
{/if}
</span>
</li>
{/each}
</ul>
{/if}
</section>
{/if}
</div>
{#if !compact}
<aside class="hidden flex-col gap-2 lg:flex">
<h2 class="label-quiet px-1">Around the master</h2>
<div class="rounded-lg border bg-strip/60 p-2">
<Graph edges={graph.edges} nodes={graph.nodes} onOpen={openNode} />
</div>
<p class="px-1 text-muted-foreground text-xs">
Inner ring: branches and the notes the master touched today. Hollow dots
are one link away, not reached. Click a note to open it in Obsidian.
</p>
</aside>
{/if}
</div>
+235
View File
@@ -0,0 +1,235 @@
<script lang="ts">
import { onMount } from "svelte";
import { clip } from "$lib/format";
import { cn } from "$lib/utils";
export interface GraphNode {
href?: string;
id: string;
kind: string;
label: string;
// 0 = hub, 1 = first ring, 2 = second ring, 3 = ghost
ring: number;
state: "running" | "waiting" | "quiet" | "closed" | "ghost";
}
export interface GraphEdge {
from: string;
to: string;
}
let {
nodes,
edges,
onOpen,
class: className = "",
}: {
nodes: GraphNode[];
edges: GraphEdge[];
onOpen?: (id: string) => void;
class?: string;
} = $props();
const SIZE = 320;
const CENTER = SIZE / 2;
const RING = [0, 74, 118, 148];
const DRIFT = 3.5;
const LABEL_MAX = 18;
const TAU = Math.PI * 2;
const HASH_MOD = 1_000_003;
const HASH_BASE = 31;
let time = $state(0);
let reduced = false;
onMount(() => {
reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced) {
return;
}
let frame = 0;
const start = performance.now();
const tick = (ts: number) => {
time = (ts - start) / 1000;
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
});
function hash(id: string): number {
let h = 0;
for (const ch of id) {
h = (h * HASH_BASE + ch.charCodeAt(0)) % HASH_MOD;
}
return h;
}
interface Placed extends GraphNode {
x: number;
y: number;
}
function place(
node: GraphNode,
slot: number,
total: number,
t: number
): Placed {
const ring = Math.min(node.ring, RING.length - 1);
const dist = RING[ring];
const seed = hash(node.id);
const angle = (slot / total) * TAU + (seed % 100) / 100 + node.ring * 0.7;
const wobble = reduced ? 0 : Math.sin(t * 0.35 + (seed % 7)) * DRIFT;
const wobble2 = reduced ? 0 : Math.cos(t * 0.27 + (seed % 5)) * DRIFT;
if (node.ring === 0) {
return { ...node, x: CENTER + wobble * 0.3, y: CENTER + wobble2 * 0.3 };
}
return {
...node,
x: CENTER + Math.cos(angle) * dist + wobble,
y: CENTER + Math.sin(angle) * dist + wobble2,
};
}
const placed = $derived.by((): Placed[] => {
const byRing = new Map<number, GraphNode[]>();
for (const node of nodes) {
const list = byRing.get(node.ring) ?? [];
list.push(node);
byRing.set(node.ring, list);
}
const out: Placed[] = [];
for (const list of byRing.values()) {
let slot = 0;
for (const node of list) {
out.push(place(node, slot, list.length, time));
slot += 1;
}
}
return out;
});
const byId = $derived(new Map(placed.map((node) => [node.id, node])));
const RADIUS: Record<string, number> = {
closed: 3.5,
ghost: 2.5,
quiet: 4.5,
running: 6.5,
waiting: 6,
};
const FILL: Record<string, string> = {
branch: "var(--color-kind-branch)",
deep: "var(--color-kind-deep)",
file: "var(--foreground)",
fork: "var(--color-kind-fork)",
job: "var(--color-kind-job)",
master: "var(--color-kind-master)",
};
function radius(node: Placed): number {
const base = RADIUS[node.state] ?? 4;
return node.ring === 0 ? base + 4 : base;
}
function strokeOf(node: Placed): string {
if (node.state === "ghost") {
return "var(--muted-foreground)";
}
return node.state === "waiting" ? "var(--attention)" : "none";
}
function fillOf(node: Placed): string {
return node.state === "ghost"
? "none"
: (FILL[node.kind] ?? "var(--foreground)");
}
function textOf(node: Placed): string {
return node.state === "ghost" || node.state === "closed"
? "var(--muted-foreground)"
: "var(--foreground)";
}
function ghostEdge(edge: GraphEdge): boolean {
return (
byId.get(edge.from)?.state === "ghost" ||
byId.get(edge.to)?.state === "ghost"
);
}
</script>
{#snippet dot(node: Placed)}
{@const r = radius(node)}
{#if node.state === "running"}
<circle cx={node.x} cy={node.y} fill="url(#graph-glow)" r={r * 4} />
{/if}
<circle
cx={node.x}
cy={node.y}
fill={fillOf(node)}
fill-opacity={node.state === "closed" ? 0.35 : 1}
{r}
stroke={strokeOf(node)}
stroke-opacity={node.state === "ghost" ? 0.6 : 1}
stroke-width={node.state === "waiting" ? 2.5 : 1}
/>
<text
dominant-baseline="hanging"
fill={textOf(node)}
font-size="10"
font-weight={node.ring === 0 ? 600 : 400}
text-anchor="middle"
x={node.x}
y={node.y + r + 4}
>
{clip(node.label, LABEL_MAX)}
</text>
{/snippet}
<svg
aria-label="what the agent is touching"
class={cn("h-auto w-full select-none", className)}
role="img"
viewBox="0 0 {SIZE} {SIZE}"
>
<defs>
<radialGradient id="graph-glow">
<stop offset="0%" stop-color="var(--signal)" stop-opacity="0.35" />
<stop offset="100%" stop-color="var(--signal)" stop-opacity="0" />
</radialGradient>
</defs>
{#each edges as edge (edge.from + edge.to)}
{@const a = byId.get(edge.from)}
{@const b = byId.get(edge.to)}
{#if a && b}
<line
stroke="var(--foreground)"
stroke-dasharray={ghostEdge(edge) ? "2 4" : undefined}
stroke-opacity={ghostEdge(edge) ? 0.18 : 0.14}
stroke-width="1"
x1={a.x}
x2={b.x}
y1={a.y}
y2={b.y}
/>
{/if}
{/each}
{#each placed as node (node.id)}
{#if node.href}
<a
class="cursor-pointer"
href={node.href}
onclick={(event) => {
event.preventDefault();
onOpen?.(node.id);
}}
>
{@render dot(node)}
</a>
{:else}
<g>{@render dot(node)}</g>
{/if}
{/each}
</svg>
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
import type { LimitWindow } from "$lib/api/types";
import { fmtCountdown, fmtMoney, fmtRelative, fmtTokens } from "$lib/format";
import { limitLabel } from "$lib/limits";
import { cn } from "$lib/utils";
let {
contextTokens,
contextWindow,
windows,
spend = null,
now,
compact = false,
}: {
contextTokens: number;
contextWindow: number;
windows: LimitWindow[];
spend?: number | null;
now: number;
compact?: boolean;
} = $props();
const TONE: Record<string, string> = {
allowed: "bg-primary",
allowed_warning: "bg-primary",
rejected: "bg-destructive",
};
const contextPct = $derived(
Math.min(100, Math.round((contextTokens / contextWindow) * 100))
);
const known = (w: LimitWindow) =>
w.utilization !== null && w.utilization !== undefined;
const pct = (w: LimitWindow) =>
Math.min(100, Math.round((w.utilization ?? 0) * 100));
</script>
<div
class={cn(
"grid gap-x-6 gap-y-4",
compact
? "@lg:grid-cols-5 grid-cols-2"
: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
)}
>
<div class="flex flex-col gap-1.5">
<span class="label-quiet">Context</span>
<span class="tabular font-semibold text-2xl leading-none tracking-tight">
{fmtTokens(contextTokens)}
<span class="font-normal text-muted-foreground text-sm">
/ {fmtTokens(contextWindow)}
</span>
</span>
<span
aria-label="context share"
aria-valuemax="100"
aria-valuemin="0"
aria-valuenow={contextPct}
class="h-0.5 w-full overflow-hidden rounded-full bg-muted"
role="progressbar"
>
<span
class="block h-full rounded-full bg-primary transition-[width] duration-500"
style="width: {contextPct}%"
></span>
</span>
</div>
{#each windows as w (w.window)}
<div class="flex flex-col gap-1.5">
<span class="label-quiet">{limitLabel(w.window)}</span>
{#if known(w)}
<span
class={cn(
"tabular font-semibold text-2xl leading-none tracking-tight",
w.status === "rejected" && "text-destructive"
)}
>
{pct(w)}
<span class="font-normal text-muted-foreground text-sm">%</span>
<span class="font-normal text-muted-foreground text-xs">
{fmtCountdown(w.resets_at, now).replace("resets ", "")}
</span>
</span>
<span
aria-label="{limitLabel(w.window)} utilization"
aria-valuemax="100"
aria-valuemin="0"
aria-valuenow={pct(w)}
class="h-0.5 w-full overflow-hidden rounded-full bg-muted"
role="progressbar"
>
<span
class={cn(
"block h-full rounded-full transition-[width] duration-500",
TONE[w.status]
)}
style="width: {pct(w)}%"
></span>
</span>
{:else}
<span class="text-muted-foreground text-sm leading-none">
no report
</span>
<span class="text-muted-foreground text-xs">
last seen {fmtRelative(w.ts, now)}
</span>
{/if}
</div>
{/each}
{#if spend !== null}
<div class="flex flex-col gap-1.5">
<span class="label-quiet">Spend · 24 h</span>
<span class="tabular font-semibold text-2xl leading-none tracking-tight">
{fmtMoney(spend)}
</span>
<span class="text-muted-foreground text-xs">API-price equivalent</span>
</div>
{/if}
</div>
+86
View File
@@ -0,0 +1,86 @@
<script lang="ts">
import { fmtTokens } from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils";
import { now } from "./now.svelte";
let { class: className = "" }: { class?: string } = $props();
const running = $derived(now.running.length);
const waiting = $derived(now.waiting.length);
const worst = $derived.by(() => {
const windows = gateway.limits?.windows ?? [];
let top: number | null = null;
for (const w of windows) {
if (w.utilization !== null && w.utilization !== undefined) {
top = Math.max(top ?? 0, w.utilization);
}
}
return top;
});
const live = $derived(gateway.live.state);
const dot = $derived.by(() => {
if (live === "failed") {
return "bg-destructive";
}
if (live !== "open") {
return "bg-warn";
}
return running > 0 ? "bg-signal animate-pulse-dot" : "bg-ok";
});
const headline = $derived.by(() => {
if (live === "failed") {
return "offline";
}
if (live !== "open") {
return "connecting";
}
if (running === 0) {
return "idle";
}
return running === 1 ? "1 in motion" : `${running} in motion`;
});
</script>
<button
aria-expanded={ui.island}
aria-haspopup="dialog"
aria-label="What is happening now"
class={cn("island", className)}
onclick={() => ui.toggleIsland()}
type="button"
>
<span class={cn("size-2 shrink-0 rounded-full", dot)}></span>
<span class="font-medium">{headline}</span>
{#if waiting > 0}
<span class="island-sep"></span>
<span class="font-medium text-attention-foreground">
{waiting === 1 ? "1 question" : `${waiting} questions`}
</span>
{/if}
{#if now.contextTokens > 0}
<span class="island-sep hidden sm:block"></span>
<span
class="tabular hidden text-muted-foreground sm:inline"
title="master context"
>
{fmtTokens(now.contextTokens)}
</span>
{/if}
{#if worst !== null}
<span class="island-sep hidden sm:block"></span>
<span
class="tabular hidden text-muted-foreground sm:inline"
title="highest quota window"
>
{Math.round(worst * 100)}%
</span>
{/if}
<kbd
class="ml-1 hidden rounded border px-1 font-sans text-[10px] text-muted-foreground md:inline"
title="open the board"
>
⌘K
</kbd>
</button>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import type { Kind } from "$lib/api/types";
import { cn } from "$lib/utils";
let { kind, class: className = "" }: { kind: Kind | string; class?: string } =
$props();
const LETTER: Record<string, string> = {
branch: "B",
deep: "D",
fork: "F",
job: "J",
master: "M",
};
const TONE: Record<string, string> = {
branch: "text-kind-branch bg-kind-branch/12",
deep: "text-kind-deep bg-kind-deep/12",
fork: "text-kind-fork bg-kind-fork/12",
job: "text-kind-job bg-kind-job/12",
master: "text-kind-master bg-kind-master/12",
};
</script>
<span
aria-label={kind}
class={cn(
"inline-flex size-5 shrink-0 items-center justify-center rounded-full font-semibold text-[10px] leading-none",
TONE[kind] ?? "bg-muted text-muted-foreground",
className
)}
role="img"
title={kind}
>
{LETTER[kind] ?? "?"}
</span>
+97
View File
@@ -0,0 +1,97 @@
import type { ConversationInfo, ConversationSummary } from "$lib/api/types";
import { gateway } from "$lib/gateway.svelte";
import { session } from "$lib/session.svelte";
import { byActivity } from "./state";
const CONTEXT_WINDOW = 1_000_000;
const SNAPSHOT_MIN_GAP_MS = 400;
// What the board shows: the open conversations sorted into bays, the
// open master's context, and a snapshot (tools in flight, the pending
// question) for every strip that is waiting or running.
class Now {
snapshots = $state<Record<string, ConversationInfo>>({});
private snapshotAt = 0;
private inFlight: Promise<void> | null = null;
get master(): ConversationSummary | null {
return (
gateway.conversations.find(
(row) => row.kind === "master" && row.status === "open"
) ?? null
);
}
get waiting(): ConversationSummary[] {
return gateway.open.filter((row) => row.pending_question).sort(byActivity);
}
get running(): ConversationSummary[] {
return gateway.open
.filter((row) => row.running_turn && !row.pending_question)
.sort(byActivity);
}
get quiet(): ConversationSummary[] {
return gateway.open
.filter(
(row) =>
!(row.running_turn || row.pending_question) && row.kind !== "job"
)
.sort(byActivity);
}
get contextTokens(): number {
return this.master?.context_tokens ?? 0;
}
get contextShare(): number {
return Math.min(1, this.contextTokens / CONTEXT_WINDOW);
}
get contextWindow(): number {
return CONTEXT_WINDOW;
}
get liveIds(): string[] {
return [...this.waiting, ...this.running].map((row) => row.id);
}
// One round of ``GET /api/conversations/{id}`` for every live strip,
// coalesced so a burst of turn events costs one fetch.
refreshSnapshots(): Promise<void> {
if (this.inFlight) {
return this.inFlight;
}
const gap = Date.now() - this.snapshotAt;
this.inFlight = (async () => {
if (gap < SNAPSHOT_MIN_GAP_MS) {
await new Promise((resolve) =>
setTimeout(resolve, SNAPSHOT_MIN_GAP_MS - gap)
);
}
const { client } = session;
const ids = this.liveIds;
if (!client || ids.length === 0) {
this.snapshots = {};
return;
}
const results = await Promise.all(
ids.map((id) => client.conversation(id).catch(() => null))
);
const next: Record<string, ConversationInfo> = {};
for (const info of results) {
if (info) {
next[info.id] = info;
}
}
this.snapshots = next;
this.snapshotAt = Date.now();
})().finally(() => {
this.inFlight = null;
});
return this.inFlight;
}
}
export const now = new Now();
+164
View File
@@ -0,0 +1,164 @@
<script lang="ts">
import SearchIcon from "@lucide/svelte/icons/search";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import type { ConversationSummary } from "$lib/api/types";
import { clip, fmtRelative, shortId } from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { SECTIONS, SYSTEM_PAGES } from "$lib/nav";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils";
import KindMark from "./kind-mark.svelte";
import { byActivity } from "./state";
interface Item {
hint: string;
href: string;
id: string;
kind: "section" | "conversation";
label: string;
row?: ConversationSummary;
}
const LIMIT = 10;
let query = $state("");
let cursor = $state(0);
let input = $state<HTMLInputElement | null>(null);
const items = $derived.by((): Item[] => {
const needle = query.trim().toLowerCase();
const sections: Item[] = [...SECTIONS, ...SYSTEM_PAGES]
.filter((s) => !needle || s.label.toLowerCase().includes(needle))
.map((s) => ({
hint: "section",
href: `${base}${s.href}`,
id: `s:${s.href}`,
kind: "section",
label: s.label,
}));
const conversations: Item[] = gateway.conversations
.filter(
(row) =>
!needle ||
(row.title ?? "").toLowerCase().includes(needle) ||
row.id.startsWith(needle) ||
row.agent.toLowerCase().includes(needle) ||
(row.last_item?.text ?? "").toLowerCase().includes(needle)
)
.sort(byActivity)
.slice(0, needle ? LIMIT : 5)
.map((row) => ({
hint: `${row.status === "open" ? "" : `${row.status} · `}${fmtRelative(row.last_activity_at ?? row.created_at)}`,
href: `${base}/conversations/${row.id}`,
id: row.id,
kind: "conversation",
label: row.title ?? `${row.kind} · ${shortId(row.id)}`,
row,
}));
return needle
? [...conversations, ...sections]
: [...sections.slice(0, 4), ...conversations];
});
$effect(() => {
if (ui.palette) {
query = "";
cursor = 0;
queueMicrotask(() => input?.focus());
}
});
$effect(() => {
if (cursor >= items.length) {
cursor = Math.max(0, items.length - 1);
}
});
function go(item: Item) {
ui.closeAll();
goto(item.href);
}
function onKeydown(event: KeyboardEvent) {
if (event.key === "ArrowDown") {
event.preventDefault();
cursor = Math.min(items.length - 1, cursor + 1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
cursor = Math.max(0, cursor - 1);
} else if (event.key === "Enter" && items[cursor]) {
event.preventDefault();
go(items[cursor]);
}
}
</script>
{#if ui.palette}
<div
aria-label="Go to"
aria-modal="true"
class="fixed inset-0 z-40 flex items-start justify-center px-4 pt-[12vh]"
role="dialog"
>
<button
aria-label="Close"
class="absolute inset-0 animate-fade-in bg-foreground/15 backdrop-blur-[2px]"
onclick={() => ui.closePalette()}
type="button"
></button>
<div
class="relative flex w-full max-w-lg animate-island-in flex-col overflow-hidden rounded-xl border bg-popover shadow-float"
>
<label class="flex items-center gap-2 border-b px-3">
<SearchIcon class="size-4 shrink-0 text-icon" />
<input
autocomplete="off"
class="h-11 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
onkeydown={onKeydown}
placeholder="Conversation, section…"
spellcheck="false"
type="text"
bind:this={input}
bind:value={query}
>
<kbd
class="rounded border px-1 font-sans text-[10px] text-muted-foreground"
>
esc
</kbd>
</label>
<div class="max-h-[50vh] overflow-y-auto py-1" role="listbox">
{#each items as item, index (item.id)}
<div
aria-selected={index === cursor}
class={cn(
"flex cursor-pointer items-center gap-2.5 px-3 py-2 text-sm",
index === cursor && "bg-accent"
)}
onclick={() => go(item)}
onkeydown={(event) => event.key === "Enter" && go(item)}
onmousemove={() => {
cursor = index;
}}
role="option"
tabindex="-1"
>
{#if item.row}
<KindMark kind={item.row.kind} />
{:else}
<span class="size-5 shrink-0"></span>
{/if}
<span class="min-w-0 flex-1 truncate">{clip(item.label, 80)}</span>
<span class="tabular shrink-0 text-muted-foreground text-xs">
{item.hint}
</span>
</div>
{:else}
<p class="px-3 py-3 text-muted-foreground text-sm">
Nothing matches.
</p>
{/each}
</div>
</div>
</div>
{/if}
+21
View File
@@ -0,0 +1,21 @@
import type { ConversationSummary } from "$lib/api/types";
export type StripState = "waiting" | "running" | "quiet" | "closed";
function activityOf(row: ConversationSummary): string {
return row.last_activity_at ?? row.created_at ?? "";
}
export function byActivity(a: ConversationSummary, b: ConversationSummary) {
return activityOf(b).localeCompare(activityOf(a));
}
export function stateOf(row: ConversationSummary): StripState {
if (row.status !== "open") {
return "closed";
}
if (row.pending_question) {
return "waiting";
}
return row.running_turn ? "running" : "quiet";
}
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ConversationSummary } from "$lib/api/types";
import { clip, fmtRelative, fmtTokens, shortId } from "$lib/format";
import { cn } from "$lib/utils";
import KindMark from "./kind-mark.svelte";
import { type StripState, stateOf } from "./state";
let {
row,
href,
state = stateOf(row),
current = false,
open = false,
detail = "",
now = Date.now(),
class: className = "",
children,
onclick,
}: {
row: ConversationSummary;
href?: string;
state?: StripState;
current?: boolean;
open?: boolean;
detail?: string;
now?: number;
class?: string;
children?: Snippet;
onclick?: (id: string) => void;
} = $props();
const TITLE_MAX = 72;
const DETAIL_MAX = 96;
const title = $derived(
row.title
? clip(row.title, TITLE_MAX)
: `${row.kind === "master" ? "Master" : row.kind} · ${shortId(row.id)}`
);
const when = $derived(row.last_activity_at ?? row.created_at ?? null);
const tag = $derived(href ? "a" : "button");
</script>
<svelte:element
aria-current={current ? "true" : undefined}
class={cn("strip text-left text-sm", className)}
data-row={row.id}
data-state={state}
href={href ?? undefined}
onclick={onclick ? () => onclick?.(row.id) : undefined}
role={href ? undefined : "button"}
this={tag}
type={href ? undefined : "button"}
>
<KindMark kind={row.kind} />
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate font-medium">{title}</span>
{#if detail}
<span class="truncate text-muted-foreground text-xs">
{clip(detail, DETAIL_MAX)}
</span>
{/if}
</span>
<span
class="tabular flex shrink-0 items-center gap-3 text-muted-foreground text-xs"
>
{#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>
{#if children && open}
<div class="strip-open">{@render children()}</div>
{/if}
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
import LogOutIcon from "@lucide/svelte/icons/log-out";
import MoonIcon from "@lucide/svelte/icons/moon";
import SunIcon from "@lucide/svelte/icons/sun";
import { mode, toggleMode } from "mode-watcher";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import { page } from "$app/state";
import { isSectionActive, SECTIONS, SYSTEM } from "$lib/nav";
import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils";
import Island from "./island.svelte";
async function signOut() {
await session.logout();
await goto(`${base}/login`);
}
</script>
<header
class="relative z-30 grid h-12 shrink-0 grid-cols-[1fr_auto_1fr] items-center gap-3 border-b bg-rack/85 px-3 backdrop-blur-md sm:px-5"
>
<nav aria-label="Sections" class="flex min-w-0 items-center gap-4">
<a
aria-label="Beaver"
class="flex size-6 shrink-0 items-center justify-center"
href="{base}/"
>
<span class="size-2.5 rounded-[3px] bg-primary"></span>
</a>
<div class="hidden items-center gap-4 sm:flex">
{#each SECTIONS as item (item.href)}
{@const active = isSectionActive(page.url.pathname, base, item.href)}
<a
aria-current={active ? "page" : undefined}
class={cn("rule-word", active && "text-foreground")}
href="{base}{item.href}"
title="{item.label} ({item.key})"
>
{item.label}
</a>
{/each}
</div>
</nav>
<Island />
<div class="flex min-w-0 items-center justify-end gap-3">
<a
aria-current={isSectionActive(page.url.pathname, base, SYSTEM.href)
? "page"
: undefined}
class="rule-word hidden sm:inline"
href="{base}{SYSTEM.href}"
title="{SYSTEM.label} ({SYSTEM.key})"
>
{SYSTEM.label}
</a>
<button
aria-label="Toggle theme"
class="rule-word inline-flex size-7 items-center justify-center rounded-md"
onclick={toggleMode}
title="Toggle theme"
type="button"
>
{#if mode.current === "dark"}
<SunIcon class="size-4" />
{:else}
<MoonIcon class="size-4" />
{/if}
</button>
<button
aria-label="Sign out"
class="rule-word hidden size-7 items-center justify-center rounded-md sm:inline-flex"
onclick={signOut}
title="Sign out"
type="button"
>
<LogOutIcon class="size-4" />
</button>
</div>
</header>
+35 -16
View File
@@ -1,6 +1,5 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
const NAV_KEY = "beaver.ui.nav";
const RAIL_KEY = "beaver.ui.rail"; const RAIL_KEY = "beaver.ui.rail";
function stored(key: string, fallback: boolean): boolean { function stored(key: string, fallback: boolean): boolean {
@@ -17,28 +16,48 @@ function store(key: string, value: boolean): void {
} }
} }
// Chrome state: the section sidebar and the conversation rail. Explicit // Chrome state: the island's board, the command palette, the conversation
// toggles persist; the automatic collapse on the conversations pages does // rail. The rail toggle persists; the overlays never do.
// not, so leaving them restores what the user had.
class Ui { class Ui {
nav = $state(stored(NAV_KEY, true));
rail = $state(stored(RAIL_KEY, true)); rail = $state(stored(RAIL_KEY, true));
island = $state(false);
setNav(open: boolean, persist = false): void { palette = $state(false);
this.nav = open;
if (persist) {
store(NAV_KEY, open);
}
}
toggleNav(): void {
this.setNav(!this.nav, true);
}
toggleRail(): void { toggleRail(): void {
this.rail = !this.rail; this.rail = !this.rail;
store(RAIL_KEY, this.rail); store(RAIL_KEY, this.rail);
} }
openIsland(): void {
this.palette = false;
this.island = true;
}
closeIsland(): void {
this.island = false;
}
toggleIsland(): void {
if (this.island) {
this.closeIsland();
} else {
this.openIsland();
}
}
openPalette(): void {
this.island = false;
this.palette = true;
}
closePalette(): void {
this.palette = false;
}
closeAll(): void {
this.island = false;
this.palette = false;
}
} }
export const ui = new Ui(); export const ui = new Ui();
+67 -20
View File
@@ -1,27 +1,29 @@
<script lang="ts"> <script lang="ts">
import "./layout.css"; import "./layout.css";
import { ModeWatcher } from "mode-watcher"; import { ModeWatcher } from "mode-watcher";
import { onMount, untrack } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { base } from "$app/paths"; import { base } from "$app/paths";
import { page } from "$app/state"; import { page } from "$app/state";
import favicon from "$lib/assets/favicon.svg"; import favicon from "$lib/assets/favicon.svg";
import AppSidebar from "$lib/components/app-sidebar.svelte";
import BottomNav from "$lib/components/bottom-nav.svelte"; import BottomNav from "$lib/components/bottom-nav.svelte";
import { Toaster } from "$lib/components/ui/sonner"; import { Toaster } from "$lib/components/ui/sonner";
import * as Tooltip from "$lib/components/ui/tooltip"; import * as Tooltip from "$lib/components/ui/tooltip";
import { gateway } from "$lib/gateway.svelte"; import { gateway } from "$lib/gateway.svelte";
import { SECTIONS, SYSTEM } from "$lib/nav";
import { session } from "$lib/session.svelte"; import { session } from "$lib/session.svelte";
import Board from "$lib/shell/board.svelte";
import Palette from "$lib/shell/palette.svelte";
import TopBar from "$lib/shell/top-bar.svelte";
import { ui } from "$lib/ui.svelte"; import { ui } from "$lib/ui.svelte";
let { children } = $props(); let { children } = $props();
const isLogin = $derived(page.url.pathname === `${base}/login`); const isLogin = $derived(page.url.pathname === `${base}/login`);
const isPanel = $derived(page.url.pathname.startsWith(`${base}/panel`)); const isPanel = $derived(page.url.pathname.startsWith(`${base}/panel`));
const inConversations = $derived( const isHome = $derived(
page.url.pathname.startsWith(`${base}/conversations`) page.url.pathname === `${base}/` || page.url.pathname === base
); );
let navBefore: boolean | null = null;
onMount(() => { onMount(() => {
if (!isPanel) { if (!isPanel) {
@@ -48,23 +50,47 @@
}); });
$effect(() => { $effect(() => {
if (inConversations) { if (page.url.pathname) {
untrack(() => { ui.closeAll();
if (navBefore === null) {
navBefore = ui.nav;
ui.setNav(false);
}
});
} else if (navBefore !== null) {
ui.setNav(navBefore);
navBefore = null;
} }
}); });
function typing(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
return Boolean(
el &&
(el.tagName === "INPUT" ||
el.tagName === "TEXTAREA" ||
el.isContentEditable)
);
}
function onKeydown(event: KeyboardEvent) { function onKeydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key === "b") { if ((event.metaKey || event.ctrlKey) && event.key === "k") {
event.preventDefault(); event.preventDefault();
ui.toggleNav(); if (ui.palette) {
ui.closePalette();
} else {
ui.openPalette();
}
return;
}
if (event.key === "Escape" && (ui.island || ui.palette)) {
ui.closeAll();
return;
}
if (
event.metaKey ||
event.ctrlKey ||
event.altKey ||
typing(event.target)
) {
return;
}
const section = [...SECTIONS, SYSTEM].find((s) => s.key === event.key);
if (section) {
event.preventDefault();
goto(`${base}${section.href}`);
} }
} }
</script> </script>
@@ -80,13 +106,34 @@
{#if isLogin || isPanel} {#if isLogin || isPanel}
{@render children()} {@render children()}
{:else if session.ready && session.user} {:else if session.ready && session.user}
<div class="flex h-dvh flex-col overflow-hidden sm:flex-row"> <div class="flex h-dvh flex-col overflow-hidden">
<AppSidebar /> <TopBar />
<main class="flex min-w-0 flex-1 flex-col overflow-hidden"> <div class="relative flex min-h-0 flex-1 flex-col">
<main class="flex min-h-0 flex-1 flex-col overflow-hidden">
{@render children()} {@render children()}
</main> </main>
{#if ui.island && !isHome}
<div
class="absolute inset-0 z-20 flex items-start justify-center px-3 pt-3 sm:px-6"
>
<button
aria-label="Close the board"
class="absolute inset-0 animate-fade-in bg-foreground/10 backdrop-blur-[1.5px]"
onclick={() => ui.closeIsland()}
type="button"
></button>
<section
aria-label="Now"
class="relative max-h-full w-full max-w-4xl animate-island-in overflow-y-auto rounded-2xl border bg-background p-5 shadow-float sm:p-6"
>
<Board compact />
</section>
</div>
{/if}
</div>
<BottomNav /> <BottomNav />
</div> </div>
<Palette />
{:else if session.ready && session.error} {:else if session.ready && session.error}
<div class="flex h-dvh items-center justify-center p-6 text-sm"> <div class="flex h-dvh items-center justify-center p-6 text-sm">
<p class="text-destructive">{session.error}</p> <p class="text-destructive">{session.error}</p>
+3 -388
View File
@@ -1,396 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import Board from "$lib/shell/board.svelte";
import { base } from "$app/paths";
import type {
AgentsResponse,
SessionsResponse,
UsageResponse,
} from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import Endpoints from "$lib/components/endpoints.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import KindBadge from "$lib/components/kind-badge.svelte";
import LimitBar from "$lib/components/limit-bar.svelte";
import PageHeader from "$lib/components/page-header.svelte";
import { Skeleton } from "$lib/components/ui/skeleton";
import {
cacheShare,
elapsedMs,
fmtBytes,
fmtDuration,
fmtMoney,
fmtPct,
fmtSeconds,
fmtTime,
fmtTokens,
shortId,
} from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils";
const HOURS_5H = 5;
const HOURS_WEEK = 168;
const SESSIONS_POLL_MS = 10_000;
const TICK_MS = 1000;
let sessions = $state<SessionsResponse | null>(null);
let usage5h = $state<UsageResponse | null>(null);
let usageWeek = $state<UsageResponse | null>(null);
let agents = $state<AgentsResponse | null>(null);
let failure = $state<string | null>(null);
let now = $state(Date.now());
async function load() {
const { client } = session;
if (!client) {
return;
}
failure = null;
try {
[sessions, usage5h, usageWeek, agents] = await Promise.all([
client.sessions(),
client.usage({ group_by: "agent", hours: HOURS_5H }),
client.usage({ group_by: "agent", hours: HOURS_WEEK }),
client.agents(),
]);
await gateway.refreshLimits();
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
}
async function pollSessions() {
const { client } = session;
if (!client) {
return;
}
sessions = await client.sessions().catch(() => sessions);
}
onMount(() => {
load();
const poll = setInterval(pollSessions, SESSIONS_POLL_MS);
const tick = setInterval(() => {
now = Date.now();
}, TICK_MS);
return () => {
clearInterval(poll);
clearInterval(tick);
};
});
const running = $derived(gateway.running);
const subtitle = $derived.by(() => {
if (running.length > 0) {
return `${running.length} running`;
}
return gateway.loaded ? "idle" : "";
});
const windows = $derived(gateway.limits?.windows ?? []);
const tape = $derived(gateway.tape.slice(0, 40));
</script> </script>
<svelte:head><title>Now · Beaver</title></svelte:head> <svelte:head><title>Now · Beaver</title></svelte:head>
<PageHeader {subtitle} title="Now" />
<div class="min-h-0 flex-1 overflow-y-auto"> <div class="min-h-0 flex-1 overflow-y-auto">
<div class="flex flex-col gap-8 px-4 py-4 sm:px-6"> <div class="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-6 sm:px-6">
{#if failure} <Board />
<ErrorNote message={failure} retry={load} />
{/if}
<section class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Running
</h2>
{#if !gateway.loaded}
<Skeleton class="h-10 w-full" />
{:else if running.length === 0}
<EmptyState
hint="Nothing is in a turn. The ledger fills in the moment a message or an inject lands."
title="The agents are idle"
/>
{:else}
<ul class="flex flex-col">
{#each running as row (row.id)}
<li>
<a
class="row-hover ledger-grid grid-cols-[auto_auto_minmax(0,1fr)_auto] border-b py-2 text-sm"
href="{base}/conversations/{row.id}"
>
<span
class="size-2 animate-pulse-dot rounded-full bg-signal"
></span>
<KindBadge kind={row.kind} />
<span class="flex min-w-0 flex-col">
<span class="truncate font-medium">
{row.title || `${row.kind} ${shortId(row.id)}`}
</span>
<span class="truncate text-muted-foreground text-xs">
{row.agent}
{row.pending_question ? " · waiting for an answer" : ""}
</span>
</span>
<span class="tabular text-muted-foreground text-xs">
{fmtDuration(elapsedMs(row.last_activity_at ?? "", null, now))}
</span>
</a>
</li>
{/each}
</ul>
{/if}
</section>
<section class="grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
<div class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Subscription quota
</h2>
{#if windows.length === 0}
<EmptyState
hint="The SDK reports a window the first time its state changes; until then there is nothing to show."
title="No rate-limit reports yet"
/>
{:else}
<div class="flex flex-col divide-y">
{#each windows as w (w.window)}
<LimitBar {now} window={w} />
{/each}
</div>
{/if}
</div>
<div class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Spend via gateway
</h2>
{#if usage5h && usageWeek}
<table class="w-full text-sm">
<thead class="text-muted-foreground text-xs">
<tr class="border-b text-left">
<th class="py-1.5 pr-3 font-medium">range</th>
<th class="py-1.5 pr-3 text-right font-medium">cost</th>
<th class="py-1.5 pr-3 text-right font-medium">turns</th>
<th class="py-1.5 pr-3 text-right font-medium">in / out</th>
<th class="py-1.5 pr-3 text-right font-medium">cache share</th>
<th class="py-1.5 text-right font-medium">written</th>
</tr>
</thead>
<tbody>
{#each [["last 5 h", usage5h.total], ["last 7 d", usageWeek.total]] as [label, total] (label)}
{@const t = total as UsageResponse["total"]}
{@const share = cacheShare(t.input, t.cache_read)}
<tr class="border-b">
<td class="py-1.5 pr-3 font-medium">{label}</td>
<td class="tabular py-1.5 pr-3 text-right font-semibold">
{fmtMoney(t.cost_usd)}
</td>
<td class="tabular py-1.5 pr-3 text-right">{t.turns}</td>
<td class="tabular py-1.5 pr-3 text-right">
{fmtTokens(t.input)}
/ {fmtTokens(t.output)}
</td>
<td
class={cn("tabular py-1.5 pr-3 text-right", share !== null && share < 0.5 && t.turns > 0 && "text-warn")}
>
{fmtPct(share)}
</td>
<td class="tabular py-1.5 text-right">
{fmtTokens(t.cache_creation)}
</td>
</tr>
{/each}
</tbody>
</table>
<a class="text-link text-xs hover:underline" href="{base}/usage">
Full breakdown →
</a>
{:else}
<Skeleton class="h-32 w-full" />
{/if}
</div>
</section>
<section class="flex flex-col gap-2">
<div class="flex items-baseline gap-3">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Live sessions
</h2>
{#if sessions}
<span class="tabular text-muted-foreground text-xs">
{sessions.sessions.length}
processes · {fmtBytes(sessions.rss)}
{#if sessions.rss_limit}
of {fmtBytes(sessions.rss_limit)}
{/if}
</span>
{/if}
</div>
{#if !sessions}
<Skeleton class="h-10 w-full" />
{:else if sessions.sessions.length === 0}
<EmptyState
hint="Sessions spawn on the first turn and are reaped by idle time or memory pressure."
title="No claude processes alive"
/>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-muted-foreground text-xs">
<tr class="border-b text-left">
<th class="py-1.5 pr-3 font-medium">agent</th>
<th class="py-1.5 pr-3 font-medium">kind</th>
<th class="py-1.5 pr-3 font-medium">state</th>
<th class="py-1.5 pr-3 text-right font-medium">rss</th>
<th class="py-1.5 pr-3 text-right font-medium">idle</th>
<th class="py-1.5 pr-3 text-right font-medium">age</th>
<th class="py-1.5 pr-3 text-right font-medium">turns</th>
<th class="py-1.5 font-medium">conversation</th>
</tr>
</thead>
<tbody>
{#each sessions.sessions as s (s.key)}
<tr class="row-hover border-b">
<td class="py-1.5 pr-3 font-medium">{s.agent}</td>
<td class="py-1.5 pr-3"><KindBadge kind={s.kind} /></td>
<td class="py-1.5 pr-3 text-xs">
<span
class={cn(s.busy ? "text-signal" : "text-muted-foreground")}
>
{s.busy ? "busy" : "idle"}
</span>
{#if s.pinned}
<span class="ml-1 text-muted-foreground">pinned</span>
{/if}
{#if s.dirty}
<span class="ml-1 text-warn">dirty</span>
{/if}
{#if s.pending_question}
<span class="ml-1 text-link">question</span>
{/if}
</td>
<td class="tabular py-1.5 pr-3 text-right">
{fmtBytes(s.rss)}
</td>
<td class="tabular py-1.5 pr-3 text-right">
{fmtSeconds(s.idle_seconds)}
</td>
<td class="tabular py-1.5 pr-3 text-right">
{fmtSeconds(s.age_seconds)}
</td>
<td class="tabular py-1.5 pr-3 text-right">{s.turns}</td>
<td class="tabular py-1.5 text-xs">
<a
class="text-link hover:underline"
href="{base}/conversations/{s.key}"
>
{shortId(s.key)}
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<section class="grid gap-6 lg:grid-cols-2">
<div class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Agents
</h2>
{#if agents}
<ul class="flex flex-col">
{#each agents.agents as a (a.name)}
<li
class="ledger-grid grid-cols-[minmax(0,1fr)_auto] border-b py-1.5 text-sm"
>
<span class="flex min-w-0 flex-col">
<span class="truncate font-medium">{a.name}</span>
<span class="truncate text-muted-foreground text-xs">
{a.model}{a.effort ? ` · ${a.effort}` : ""}
</span>
</span>
<span class="flex gap-1">
{#each a.kinds as k (k)}
<KindBadge kind={k} />
{/each}
</span>
</li>
{/each}
</ul>
{:else}
<Skeleton class="h-20 w-full" />
{/if}
</div>
<div class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Endpoints
</h2>
{#if agents}
<Endpoints catalog={agents} />
{:else}
<Skeleton class="h-20 w-full" />
{/if}
</div>
</section>
<section class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Event tape
</h2>
{#if tape.length === 0}
<p class="text-muted-foreground text-xs">
Quiet. Bus events (turns, tools, injects, questions) scroll here as
they happen.
</p>
{:else}
<ul class="flex flex-col font-mono 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="{base}/conversations/{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}
{#if typeof item.stop === "string"}
{item.stop}
{/if}
</span>
</li>
{/each}
</ul>
{/if}
</section>
</div> </div>
</div> </div>
-105
View File
@@ -1,105 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import type { AuditRecord } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import PageHeader from "$lib/components/page-header.svelte";
import { Button } from "$lib/components/ui/button";
import { Skeleton } from "$lib/components/ui/skeleton";
import { fmtDateTime } from "$lib/format";
import { session } from "$lib/session.svelte";
const PAGE = 100;
let records = $state<AuditRecord[] | null>(null);
let nextBefore = $state<number | null>(null);
let failure = $state<string | null>(null);
let busy = $state(false);
async function load(more = false) {
const { client } = session;
if (!client) {
return;
}
failure = null;
busy = true;
try {
const page = await client.audit({
before: more && nextBefore ? nextBefore : undefined,
limit: PAGE,
});
records = more ? [...(records ?? []), ...page.records] : page.records;
nextBefore = page.next_before;
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
} finally {
busy = false;
}
}
onMount(() => {
load();
});
function detail(record: AuditRecord): string {
if (typeof record.detail === "string") {
return record.detail;
}
return Object.entries(record.detail)
.map(
([key, value]) =>
`${key}=${typeof value === "string" ? value : JSON.stringify(value)}`
)
.join(" ");
}
</script>
<svelte:head><title>Audit · Beaver</title></svelte:head>
<PageHeader title="Audit" />
<div class="min-h-0 flex-1 overflow-y-auto">
<div class="flex flex-col gap-3 px-4 py-4 sm:px-6">
{#if failure}
<ErrorNote message={failure} retry={() => load()} />
{:else if records === null}
<Skeleton class="h-40 w-full" />
{:else if records.length === 0}
<EmptyState
hint="Logins, token changes and API writes land here."
title="Nothing audited yet"
/>
{:else}
<ul class="flex flex-col text-xs">
{#each records as record (record.id)}
<li
class="ledger-grid grid-cols-[9rem_7rem_9rem_minmax(0,1fr)] border-b py-1.5"
>
<span class="tabular text-muted-foreground"
>{fmtDateTime(record.ts)}</span
>
<span class="truncate font-medium">{record.kind}</span>
<span class="truncate text-muted-foreground">{record.actor}</span>
<span class="truncate font-mono text-muted-foreground">
{#if record.agent}
{record.agent}
·
{/if}
{detail(record)}
</span>
</li>
{/each}
</ul>
{#if nextBefore !== null}
<Button
class="self-start"
disabled={busy}
onclick={() => load(true)}
size="sm"
variant="outline"
>
Older
</Button>
{/if}
{/if}
</div>
</div>
+25 -20
View File
@@ -1,16 +1,18 @@
<script lang="ts"> <script lang="ts">
import PanelLeftOpenIcon from "@lucide/svelte/icons/panel-left-open"; import PanelLeftOpenIcon from "@lucide/svelte/icons/panel-left-open";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import { page } from "$app/state"; import { page } from "$app/state";
import ConversationList from "$lib/components/conversation-list.svelte";
import { Button } from "$lib/components/ui/button";
import { gateway } from "$lib/gateway.svelte"; import { gateway } from "$lib/gateway.svelte";
import Rail from "$lib/rail/rail.svelte";
import { session } from "$lib/session.svelte";
import { ui } from "$lib/ui.svelte"; import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
let { children } = $props(); let { children } = $props();
const selected = $derived(page.params.id ?? null); const selected = $derived(page.params.id ?? null);
const running = $derived(gateway.running.length); const href = (id: string) => `${base}/conversations/${id}`;
</script> </script>
<svelte:head><title>Conversations · Beaver</title></svelte:head> <svelte:head><title>Conversations · Beaver</title></svelte:head>
@@ -20,39 +22,42 @@
"grid min-h-0 flex-1 grid-cols-1", "grid min-h-0 flex-1 grid-cols-1",
ui.rail ui.rail
? "lg:grid-cols-[22rem_minmax(0,1fr)]" ? "lg:grid-cols-[22rem_minmax(0,1fr)]"
: "lg:grid-cols-[2.75rem_minmax(0,1fr)]" : "lg:grid-cols-[2.5rem_minmax(0,1fr)]"
)} )}
> >
<div <div
class={cn( class={cn(
"min-h-0 border-r bg-sidebar/40", "min-h-0 border-r",
selected ? "hidden lg:block" : "block" selected ? "hidden lg:block" : "block"
)} )}
> >
{#if ui.rail} {#if ui.rail}
<ConversationList {selected} /> {#if session.client}
<Rail
client={session.client}
{href}
index={gateway}
onOpenFile={(path) =>
goto(`${base}/memory?path=${encodeURIComponent(path)}`)}
{selected}
/>
{/if}
{:else} {:else}
<div class="hidden h-full flex-col items-center gap-2 py-2 lg:flex"> <div class="hidden h-full flex-col items-center py-2 lg:flex">
<Button <button
aria-label="Show conversations" aria-label="Show conversations"
class="rule-word inline-flex size-7 items-center justify-center rounded-md"
onclick={() => ui.toggleRail()} onclick={() => ui.toggleRail()}
size="icon-sm"
title="Show conversations" title="Show conversations"
variant="ghost" type="button"
> >
<PanelLeftOpenIcon class="size-4" /> <PanelLeftOpenIcon class="size-4" />
</Button> </button>
{#if running > 0}
<span
class="tabular rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
title="{running} running"
>
{running}
</span>
{/if}
</div> </div>
<div class="h-full lg:hidden"> <div class="h-full lg:hidden">
<ConversationList {selected} /> {#if session.client}
<Rail client={session.client} {href} index={gateway} {selected} />
{/if}
</div> </div>
{/if} {/if}
</div> </div>
+3 -5
View File
@@ -5,12 +5,10 @@
</script> </script>
<div <div
class="flex flex-1 items-center justify-center p-6 text-muted-foreground text-sm" class="flex flex-1 flex-col items-center justify-center gap-1 p-6 text-center text-muted-foreground text-sm"
> >
<p>Pick a strip on the left.</p>
{#if running > 0} {#if running > 0}
{running} <p class="text-signal">{running} in motion right now.</p>
running - pick one on the left.
{:else}
Pick a conversation on the left.
{/if} {/if}
</div> </div>
-251
View File
@@ -1,251 +0,0 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import type { JobsResponse, QueuedJob } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import PageHeader from "$lib/components/page-header.svelte";
import StatusPill from "$lib/components/status-pill.svelte";
import { Button } from "$lib/components/ui/button";
import { Skeleton } from "$lib/components/ui/skeleton";
import { clip, fmtDateTime, fmtPct, fmtRelative } from "$lib/format";
import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils";
const TICK_MS = 15_000;
const PAYLOAD_MAX = 120;
let data = $state<JobsResponse | null>(null);
let failure = $state<string | null>(null);
let busy = $state<string | null>(null);
let timer: ReturnType<typeof setInterval> | undefined;
async function load() {
const { client } = session;
if (!client) {
return;
}
failure = null;
try {
data = await client.jobs();
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
}
async function run(name: string) {
const { client } = session;
if (!client) {
return;
}
busy = name;
try {
await client.runJob(name);
await load();
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
} finally {
busy = null;
}
}
async function cancel(job: QueuedJob) {
const { client } = session;
if (!client) {
return;
}
busy = `queue:${job.id}`;
try {
await client.cancelJob(job.id);
await load();
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
} finally {
busy = null;
}
}
function describe(job: QueuedJob): string {
const { payload } = job;
const { text } = payload;
if (typeof text === "string") {
return text;
}
const raw = JSON.stringify(payload);
return raw === "{}" ? "" : raw;
}
function triggers(job: JobsResponse["jobs"][number]): string {
const parts: string[] = [];
if (job.cron) {
parts.push(job.cron);
}
if (job.webhook) {
parts.push(`POST /hooks/${job.name}`);
}
for (const event of job.events) {
parts.push(`on ${event}`);
}
return parts.join(" · ") || "manual";
}
onMount(() => {
load();
timer = setInterval(load, TICK_MS);
});
onDestroy(() => clearInterval(timer));
const pending = $derived(
data?.queue.filter((q) => q.status === "queued") ?? []
);
const picked = $derived(
data?.queue.filter((q) => q.status !== "queued") ?? []
);
</script>
<svelte:head><title>Jobs · Beaver</title></svelte:head>
<PageHeader
subtitle={data
? `${data.jobs.length} jobs · ${pending.length} queued · window ${fmtPct(data.utilization)}${data.throttled ? " · throttled" : ""}`
: ""}
title="Jobs"
/>
<div class="min-h-0 flex-1 overflow-y-auto">
<div class="flex flex-col gap-6 px-4 py-4 sm:px-6">
{#if failure}
<ErrorNote message={failure} retry={load} />
{:else if data === null}
<Skeleton class="h-24 w-full" />
{:else}
{#if !data.enabled}
<EmptyState
hint="The gateway is not on Postgres: cron and webhooks are off, `schedule` is unavailable. Event jobs still run in-process."
title="Scheduler is off"
/>
{/if}
{#if data.throttled}
<p class="text-sm text-warn">
Subscription window at {fmtPct(data.utilization)} (threshold
{fmtPct(
data.threshold
)}): non-critical jobs are deferred.
</p>
{/if}
<section class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Jobs
</h2>
{#if data.jobs.length === 0}
<p class="text-muted-foreground text-sm">No jobs in config.</p>
{:else}
<ul class="flex flex-col">
{#each data.jobs as job (job.name)}
<li
class="ledger-grid grid-cols-[10rem_minmax(0,1fr)_9rem_9rem_5rem] items-center border-b py-2 text-sm"
>
<span class="truncate font-medium">
{job.name}
{#if !job.critical}
<span class="text-muted-foreground text-xs">· soft</span>
{/if}
</span>
<span class="truncate text-muted-foreground text-xs">
{triggers(job)}
</span>
<span
class="tabular text-muted-foreground text-xs"
title={fmtDateTime(job.next_run)}
>
{job.next_run ? `next ${fmtRelative(job.next_run)}` : ""}
</span>
<span class="flex items-center gap-2 text-xs">
{#if job.run}
<StatusPill status={job.run.status} />
<span
class="text-muted-foreground"
title={fmtDateTime(job.run.started_at)}
>
{fmtRelative(job.run.started_at)}
· {job.run.trigger}
</span>
{:else if job.last_run}
<span
class="text-muted-foreground"
title={fmtDateTime(job.last_run)}
>
ran {fmtRelative(job.last_run)}
</span>
{/if}
</span>
<Button
disabled={busy === job.name}
onclick={() => run(job.name)}
size="sm"
variant="outline"
>
Run
</Button>
</li>
{/each}
</ul>
{/if}
</section>
<section class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Queue
</h2>
{#if data.queue.length === 0}
<p class="text-muted-foreground text-sm">
Nothing queued: no deferred injects, no pending webhooks.
</p>
{:else}
<ul class="flex flex-col">
{#each [...picked, ...pending] as job (job.id)}
<li
class={cn(
"ledger-grid grid-cols-[8rem_7rem_minmax(0,1fr)_9rem_5rem] items-center border-b py-2 text-sm",
job.status !== "queued" && "text-muted-foreground"
)}
>
<span
class="tabular text-xs"
title={fmtDateTime(job.execute_after)}
>
{job.status === "queued"
? fmtRelative(job.execute_after)
: job.status}
</span>
<span class="truncate text-xs">{job.entrypoint}</span>
<span class="truncate" title={JSON.stringify(job.payload)}>
{clip(describe(job), PAYLOAD_MAX)}
</span>
<span class="tabular text-right text-muted-foreground text-xs">
{fmtDateTime(job.execute_after)}
</span>
{#if job.status === "queued"}
<Button
disabled={busy === `queue:${job.id}`}
onclick={() => cancel(job)}
size="sm"
variant="ghost"
>
Cancel
</Button>
{:else}
<span></span>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
{/if}
</div>
</div>
+159 -90
View File
@@ -10,27 +10,31 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--background: oklch(0.99 0.004 340); --rack: oklch(0.968 0.007 340);
--foreground: oklch(0.24 0.03 340); --strip: oklch(0.996 0.002 340);
--card: oklch(0.985 0.006 340); --background: var(--rack);
--card-foreground: oklch(0.24 0.03 340); --foreground: oklch(0.23 0.03 340);
--popover: oklch(0.985 0.006 340); --card: var(--strip);
--popover-foreground: oklch(0.24 0.03 340); --card-foreground: oklch(0.23 0.03 340);
--popover: var(--strip);
--popover-foreground: oklch(0.23 0.03 340);
--primary: oklch(0.47 0.13 340); --primary: oklch(0.47 0.13 340);
--primary-foreground: oklch(0.99 0.005 340); --primary-foreground: oklch(0.99 0.005 340);
--secondary: oklch(0.96 0.012 340); --secondary: oklch(0.95 0.012 340);
--secondary-foreground: oklch(0.24 0.03 340); --secondary-foreground: oklch(0.23 0.03 340);
--muted: oklch(0.96 0.012 340); --muted: oklch(0.945 0.012 340);
--muted-foreground: oklch(0.5 0.04 340); --muted-foreground: oklch(0.5 0.035 340);
--accent: oklch(0.96 0.012 340); --accent: oklch(0.95 0.012 340);
--accent-foreground: oklch(0.24 0.03 340); --accent-foreground: oklch(0.23 0.03 340);
--destructive: oklch(0.58 0.22 25); --destructive: oklch(0.58 0.22 25);
--destructive-foreground: oklch(0.99 0 0); --destructive-foreground: oklch(0.99 0 0);
--border: oklch(0.9 0.02 340); --border: oklch(0.9 0.016 340);
--input: oklch(0.9 0.02 340); --input: oklch(0.9 0.016 340);
--ring: oklch(0.55 0.2 342); --ring: oklch(0.55 0.2 342);
--icon: oklch(0.55 0.03 340); --icon: oklch(0.55 0.03 340);
--signal: oklch(0.48 0.21 342); --signal: oklch(0.5 0.21 342);
--attention: oklch(0.72 0.16 72);
--attention-foreground: oklch(0.5 0.14 70);
--note: oklch(0.52 0.13 78); --note: oklch(0.52 0.13 78);
--warn: oklch(0.55 0.13 75); --warn: oklch(0.55 0.13 75);
--link: var(--primary); --link: var(--primary);
@@ -43,64 +47,81 @@
--status-meeting: oklch(0.5 0.2 300); --status-meeting: oklch(0.5 0.2 300);
--status-work: oklch(0.55 0.13 195); --status-work: oklch(0.55 0.13 195);
--radius: 0.45rem; --radius: 0.5rem;
--sidebar: oklch(0.975 0.01 340); --sidebar: var(--rack);
--sidebar-foreground: oklch(0.24 0.03 340); --sidebar-foreground: var(--foreground);
--sidebar-primary: oklch(0.47 0.13 340); --sidebar-primary: var(--primary);
--sidebar-primary-foreground: oklch(0.99 0.005 340); --sidebar-primary-foreground: var(--primary-foreground);
--sidebar-accent: oklch(0.94 0.016 340); --sidebar-accent: oklch(0.93 0.016 340);
--sidebar-accent-foreground: oklch(0.24 0.03 340); --sidebar-accent-foreground: var(--foreground);
--sidebar-border: oklch(0.9 0.02 340); --sidebar-border: var(--border);
--sidebar-ring: oklch(0.55 0.2 342); --sidebar-ring: var(--ring);
--shadow-lift:
0 8px 20px -12px oklch(0.25 0.06 340 / 0.35),
0 1px 2px oklch(0.25 0.06 340 / 0.08);
--shadow-float:
0 24px 60px -24px oklch(0.2 0.06 340 / 0.45),
0 2px 6px oklch(0.2 0.06 340 / 0.08);
} }
.dark { .dark {
--background: #22111e; --rack: oklch(0.2 0.025 335);
--foreground: #f5f5f5; --strip: oklch(0.255 0.028 335);
--card: #341d2f; --background: var(--rack);
--card-foreground: #f5f5f5; --foreground: oklch(0.96 0.005 340);
--popover: #341d2f; --card: var(--strip);
--popover-foreground: #f5f5f5; --card-foreground: oklch(0.96 0.005 340);
--primary: #7c3871; --popover: oklch(0.27 0.03 335);
--primary-foreground: #f5f5f5; --popover-foreground: oklch(0.96 0.005 340);
--secondary: #341d2f; --primary: oklch(0.72 0.16 340);
--secondary-foreground: #f5f5f5; --primary-foreground: oklch(0.18 0.03 340);
--muted: #40283a; --secondary: oklch(0.29 0.03 335);
--muted-foreground: #a989a3; --secondary-foreground: oklch(0.96 0.005 340);
--accent: #40283a; --muted: oklch(0.3 0.03 335);
--accent-foreground: #f5f5f5; --muted-foreground: oklch(0.72 0.03 340);
--destructive: oklch(0.62 0.2 25); --accent: oklch(0.3 0.03 335);
--destructive-foreground: #f5f5f5; --accent-foreground: oklch(0.96 0.005 340);
--border: oklch(0.92 0.04 340 / 10%); --destructive: oklch(0.66 0.2 25);
--input: oklch(0.92 0.04 340 / 14%); --destructive-foreground: oklch(0.98 0 0);
--ring: #ff82f3; --border: oklch(0.96 0.02 340 / 10%);
--icon: #877384; --input: oklch(0.96 0.02 340 / 14%);
--signal: #ff82f3; --ring: oklch(0.8 0.15 340);
--icon: oklch(0.65 0.03 340);
--signal: oklch(0.8 0.17 340);
--attention: oklch(0.8 0.15 75);
--attention-foreground: oklch(0.85 0.14 78);
--note: oklch(0.84 0.14 88); --note: oklch(0.84 0.14 88);
--warn: oklch(0.8 0.13 75); --warn: oklch(0.8 0.13 75);
--link: #ff82f3; --link: oklch(0.8 0.15 340);
--status-new: #ff82f3; --status-new: oklch(0.8 0.17 340);
--status-done: oklch(0.74 0.14 155); --status-done: oklch(0.74 0.14 155);
--status-skip: #a989a3; --status-skip: oklch(0.7 0.03 340);
--status-reply: oklch(0.72 0.13 235); --status-reply: oklch(0.72 0.13 235);
--status-snooze: oklch(0.8 0.13 75); --status-snooze: oklch(0.8 0.13 75);
--status-meeting: oklch(0.72 0.16 300); --status-meeting: oklch(0.72 0.16 300);
--status-work: oklch(0.75 0.12 195); --status-work: oklch(0.75 0.12 195);
--sidebar: #2c1b29; --sidebar: var(--rack);
--sidebar-foreground: #f5f5f5; --sidebar-foreground: var(--foreground);
--sidebar-primary: #ff82f3; --sidebar-primary: var(--primary);
--sidebar-primary-foreground: #22111e; --sidebar-primary-foreground: var(--primary-foreground);
--sidebar-accent: #341d2f; --sidebar-accent: oklch(0.3 0.03 335);
--sidebar-accent-foreground: #f5f5f5; --sidebar-accent-foreground: var(--foreground);
--sidebar-border: oklch(0.92 0.04 340 / 10%); --sidebar-border: var(--border);
--sidebar-ring: #ff82f3; --sidebar-ring: var(--ring);
--shadow-lift:
0 8px 20px -12px oklch(0 0 0 / 0.7), 0 1px 2px oklch(0 0 0 / 0.3);
--shadow-float:
0 24px 60px -24px oklch(0 0 0 / 0.8), 0 2px 6px oklch(0 0 0 / 0.3);
} }
@theme inline { @theme inline {
--font-sans: "Inter Variable", sans-serif; --font-sans:
--font-heading: "Inter Variable", sans-serif; -apple-system, BlinkMacSystemFont, "Inter Variable", system-ui, sans-serif;
--font-heading: var(--font-sans);
--text-xs: 0.75rem; --text-xs: 0.75rem;
--text-sm: 0.8125rem; --text-sm: 0.8125rem;
@@ -108,6 +129,12 @@
--text-lg: 1rem; --text-lg: 1rem;
--text-xl: 1.25rem; --text-xl: 1.25rem;
--text-2xl: 1.5rem; --text-2xl: 1.5rem;
--text-3xl: 2rem;
--color-rack: var(--rack);
--color-strip: var(--strip);
--color-attention: var(--attention);
--color-attention-foreground: var(--attention-foreground);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
@@ -150,11 +177,37 @@
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-background: var(--background); --color-background: var(--background);
--shadow-lift: var(--shadow-lift);
--shadow-float: var(--shadow-float);
--radius-sm: calc(var(--radius) * 0.6); --radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8); --radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4); --radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8); --radius-2xl: calc(var(--radius) * 1.8);
--animate-island-in: island-in 260ms cubic-bezier(0.2, 0.9, 0.25, 1.05) both;
--animate-fade-in: fade-in 180ms ease-out both;
}
@keyframes island-in {
from {
opacity: 0;
transform: translateY(-6px) scale(0.985);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
} }
@layer base { @layer base {
@@ -163,6 +216,7 @@
} }
body { body {
font-feature-settings: "cv11", "ss01"; font-feature-settings: "cv11", "ss01";
-webkit-font-smoothing: antialiased;
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
html, html,
@@ -177,18 +231,6 @@
::selection { ::selection {
background: color-mix(in oklab, var(--primary) 28%, transparent); background: color-mix(in oklab, var(--primary) 28%, transparent);
} }
}
@utility hue-chip {
color: oklch(0.45 0.13 var(--hue));
background: oklch(0.6 0.09 var(--hue) / 0.12);
border-color: oklch(0.6 0.09 var(--hue) / 0.4);
.dark & {
color: oklch(0.82 0.08 var(--hue));
}
}
@layer base {
* { * {
scrollbar-color: color-mix(in oklab, var(--foreground) 22%, transparent) scrollbar-color: color-mix(in oklab, var(--foreground) 22%, transparent)
transparent; transparent;
@@ -210,6 +252,14 @@
textarea { textarea {
caret-color: var(--primary); caret-color: var(--primary);
} }
input:focus,
textarea:focus,
select:focus {
--tw-ring-color: transparent;
outline: none;
border-color: var(--border);
box-shadow: none;
}
a { a {
text-underline-offset: 3px; text-underline-offset: 3px;
} }
@@ -229,10 +279,38 @@
} }
} }
@utility hue-chip {
color: oklch(0.45 0.13 var(--hue));
background: oklch(0.6 0.09 var(--hue) / 0.12);
border-color: oklch(0.6 0.09 var(--hue) / 0.4);
.dark & {
color: oklch(0.82 0.08 var(--hue));
}
}
@utility hairline { @utility hairline {
border-color: color-mix(in oklab, var(--border) 100%, transparent); border-color: color-mix(in oklab, var(--border) 100%, transparent);
} }
@utility label-quiet {
font-size: var(--text-xs);
font-weight: 500;
color: var(--muted-foreground);
letter-spacing: 0.02em;
}
@utility rule-word {
font-size: var(--text-sm);
font-weight: 500;
color: var(--muted-foreground);
transition: color 150ms ease-out;
&:hover,
&[aria-current="page"],
&[data-active="true"] {
color: var(--foreground);
}
}
/* chat markdown: typography plugin on the theme tokens, compact rhythm */ /* chat markdown: typography plugin on the theme tokens, compact rhythm */
.prose { .prose {
--tw-prose-body: var(--foreground); --tw-prose-body: var(--foreground);
@@ -258,34 +336,25 @@
:where(p, ul, ol, pre, blockquote, table):not( :where(p, ul, ol, pre, blockquote, table):not(
:where([class~="not-prose"] *) :where([class~="not-prose"] *)
) { ) {
margin-block: 0.4em; margin-top: 0.4em;
margin-bottom: 0.4em;
} }
.prose :where(h1, h2, h3, h4):not(:where([class~="not-prose"] *)) { .prose :where(h1, h2, h3, h4):not(:where([class~="not-prose"] *)) {
margin-block: 0.8em 0.4em; margin-top: 0.9em;
margin-bottom: 0.3em;
font-size: 1em; font-size: 1em;
font-weight: 600; font-weight: 600;
} }
.prose :where(li):not(:where([class~="not-prose"] *)) { .prose :where(code):not(:where([class~="not-prose"] *)) {
margin-block: 0.15em; padding: 0.1em 0.3em;
font-weight: 500;
background: var(--muted);
border-radius: 0.3em;
} }
.prose :where(code):not(:where([class~="not-prose"] *))::before, .prose :where(code):not(:where([class~="not-prose"] *))::before,
.prose :where(code):not(:where([class~="not-prose"] *))::after { .prose :where(code):not(:where([class~="not-prose"] *))::after {
content: none; content: none;
} }
.prose :where(code):not(:where(pre *)):not(:where([class~="not-prose"] *)) { .prose :where(a.internal-link):not(:where([class~="not-prose"] *)) {
padding: 0.1em 0.3em; text-decoration-style: dotted;
font-weight: 500;
background: color-mix(in oklch, var(--muted) 70%, transparent);
border-radius: 0.25rem;
}
.prose :where(pre):not(:where([class~="not-prose"] *)) {
padding: 0.6em 0.8em;
font-size: 0.8125rem;
border-radius: 0.375rem;
}
.prose > :first-child {
margin-top: 0;
}
.prose > :last-child {
margin-bottom: 0;
} }
+92 -50
View File
@@ -1,14 +1,11 @@
<script lang="ts"> <script lang="ts">
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right"; import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import FileTextIcon from "@lucide/svelte/icons/file-text";
import FolderIcon from "@lucide/svelte/icons/folder";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { page } from "$app/state";
import type { MemoryFile, MemoryNode, MemoryTree } from "$lib/api/types"; import type { MemoryFile, MemoryNode, MemoryTree } from "$lib/api/types";
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 PageHeader from "$lib/components/page-header.svelte";
import { Skeleton } from "$lib/components/ui/skeleton";
import { fmtBytes, fmtDateTime, fmtRelative } from "$lib/format"; import { fmtBytes, fmtDateTime, fmtRelative } from "$lib/format";
import Markdown from "$lib/panel/markdown.svelte";
import { session } from "$lib/session.svelte"; import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
@@ -18,7 +15,9 @@
let selected = $state<string | null>(null); let selected = $state<string | null>(null);
let file = $state<MemoryFile | null>(null); let file = $state<MemoryFile | null>(null);
let fileError = $state<string | null>(null); let fileError = $state<string | null>(null);
let collapsed = $state<Set<string>>(new Set()); let expanded = $state<Set<string>>(new Set());
let raw = $state(false);
const MD_SUFFIX = /\.md$/;
async function load() { async function load() {
const { client } = session; const { client } = session;
@@ -47,6 +46,12 @@
selected = path; selected = path;
file = null; file = null;
fileError = null; fileError = null;
const parts = path.split("/");
const next = new Set(expanded);
for (let i = 1; i < parts.length; i += 1) {
next.add(parts.slice(0, i).join("/"));
}
expanded = next;
try { try {
file = await client.memoryFile(path); file = await client.memoryFile(path);
} catch (cause) { } catch (cause) {
@@ -55,16 +60,22 @@
} }
function toggle(path: string) { function toggle(path: string) {
const next = new Set(collapsed); const next = new Set(expanded);
if (next.has(path)) { if (next.has(path)) {
next.delete(path); next.delete(path);
} else { } else {
next.add(path); next.add(path);
} }
collapsed = next; expanded = next;
} }
onMount(load); onMount(async () => {
await load();
const wanted = page.url.searchParams.get("path");
if (wanted) {
open(wanted);
}
});
const count = $derived.by(() => { const count = $derived.by(() => {
let files = 0; let files = 0;
@@ -80,68 +91,95 @@
walk(tree?.tree ?? []); walk(tree?.tree ?? []);
return files; return files;
}); });
const isMarkdown = $derived(file?.path.endsWith(".md") ?? false);
</script> </script>
<svelte:head><title>Memory · Beaver</title></svelte:head> <svelte:head><title>Memory · Beaver</title></svelte:head>
<PageHeader <div class="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[20rem_minmax(0,1fr)]">
subtitle={tree ? `${count} files · ${tree.root}` : ""}
title="Memory"
/>
<div class="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[18rem_minmax(0,1fr)]">
<div <div
class="min-h-0 overflow-y-auto border-b bg-sidebar/50 p-2 md:border-r md:border-b-0" class="min-h-0 overflow-y-auto border-b px-3 py-3 md:border-r md:border-b-0"
> >
<div class="flex items-baseline gap-2 px-1 pb-2">
<h1 class="font-semibold text-base tracking-tight">Memory</h1>
{#if tree}
<span class="tabular text-muted-foreground text-xs">{count} files</span>
{/if}
</div>
{#if failure} {#if failure}
<ErrorNote message={failure} retry={load} /> <ErrorNote message={failure} retry={load} />
{:else if notConfigured} {:else if notConfigured}
<EmptyState <p class="doc px-1 text-muted-foreground text-sm">
hint="Set ApiFrontend(memory_root=...) in config.py to the agent's zone of the vault." No memory root. Point ApiFrontend(memory_root=…) at the agent's zone of
title="No memory root" the vault.
/> </p>
{:else if !tree} {:else if !tree}
<div class="flex flex-col gap-2 p-1"> <p class="px-1 text-muted-foreground text-sm">Loading…</p>
<Skeleton class="h-6 w-3/4" />
<Skeleton class="h-6 w-1/2" />
<Skeleton class="h-6 w-2/3" />
</div>
{:else if tree.tree.length === 0} {:else if tree.tree.length === 0}
<EmptyState hint="The zone is empty." title="Nothing here" /> <p class="px-1 text-muted-foreground text-sm">The zone is empty.</p>
{:else} {:else}
{@render nodes(tree.tree, 0)} {@render nodes(tree.tree, 0)}
{/if} {/if}
</div> </div>
<div class="min-h-0 overflow-y-auto"> <div class="min-h-0 overflow-y-auto">
{#if !selected} {#if !selected}
<div class="p-6 text-muted-foreground text-sm"> <div class="mx-auto max-w-2xl px-6 py-10 text-muted-foreground text-sm">
Pick a file to read it. This is the agent's own zone: state, handouts, <p>
prompts, skills - everything it can write. The agent's own zone: state, handouts, observations, prompts, skills.
Pick a file on the left, or search for a line with ⌘K.
</p>
</div> </div>
{:else if fileError} {:else if fileError}
<div class="p-4"> <div class="p-4">
<ErrorNote message={fileError} retry={() => open(selected ?? "")} /> <ErrorNote message={fileError} retry={() => open(selected ?? "")} />
</div> </div>
{:else if !file} {:else if !file}
<div class="flex flex-col gap-2 p-6"> <p class="px-6 py-10 text-center text-muted-foreground text-xs">
<Skeleton class="h-5 w-1/3" /> Opening…
<Skeleton class="h-40 w-full" /> </p>
</div>
{:else} {:else}
<div class="flex items-baseline gap-3 border-b px-4 py-2 text-xs sm:px-6"> <article class="mx-auto flex w-full max-w-3xl flex-col gap-4 px-6 py-6">
<span class="font-medium text-sm">{file.path}</span> <header class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span class="tabular text-muted-foreground">{fmtBytes(file.size)}</span> <h2 class="min-w-0 truncate font-semibold text-lg tracking-tight">
{file.path.split("/").at(-1)?.replace(MD_SUFFIX, "")}
</h2>
{#if file.path.includes("/")}
<span class="truncate text-muted-foreground text-xs">
{file.path.split("/").slice(0, -1).join(" / ")}
</span>
{/if}
<span class="tabular text-muted-foreground text-xs">
{fmtBytes(file.size)}
</span>
<span <span
class="tabular text-muted-foreground" class="tabular text-muted-foreground text-xs"
title={fmtDateTime(file.mtime)} title={fmtDateTime(file.mtime)}
> >
modified {fmtRelative(file.mtime)} {fmtRelative(file.mtime)}
</span> </span>
</div> {#if isMarkdown}
<button
class="rule-word ml-auto text-xs"
data-active={raw}
onclick={() => {
raw = !raw;
}}
type="button"
>
{raw ? "rendered" : "source"}
</button>
{/if}
</header>
{#if isMarkdown && !raw}
<Markdown class="prose-base" text={file.content} />
{:else}
<pre <pre
class="max-w-[90ch] px-4 py-3 text-sm whitespace-pre-wrap break-words sm:px-6" class="whitespace-pre-wrap break-words text-sm leading-relaxed"
>{file.content}</pre> >{file.content}</pre>
{/if} {/if}
</article>
{/if}
</div> </div>
</div> </div>
@@ -150,23 +188,28 @@
{#each list as node (node.path)} {#each list as node (node.path)}
<li> <li>
{#if node.type === "dir"} {#if node.type === "dir"}
{@const isOpen = expanded.has(node.path)}
<button <button
aria-expanded={!collapsed.has(node.path)} aria-expanded={isOpen}
class="row-hover flex h-7 w-full items-center gap-1.5 rounded-md pr-2 text-left text-sm" class="row-hover flex h-7 w-full items-center gap-1.5 rounded-md pr-2 text-left text-sm"
onclick={() => toggle(node.path)} onclick={() => toggle(node.path)}
style="padding-left: {depth * 0.75 + 0.25}rem" style="padding-left: {depth * 0.875 + 0.25}rem"
type="button" type="button"
> >
<ChevronRightIcon <ChevronRightIcon
class={cn( class={cn(
"size-3.5 shrink-0 text-icon transition-transform duration-150", "size-3.5 shrink-0 text-icon transition-transform duration-150",
!collapsed.has(node.path) && "rotate-90" isOpen && "rotate-90"
)} )}
/> />
<FolderIcon class="size-4 shrink-0 text-icon" /> <span class="truncate font-medium">{node.name}</span>
<span class="truncate">{node.name}</span> {#if !isOpen && node.children}
<span class="tabular ml-auto text-muted-foreground text-xs">
{node.children.length}
</span>
{/if}
</button> </button>
{#if !collapsed.has(node.path) && node.children} {#if isOpen && node.children}
{@render nodes(node.children, depth + 1)} {@render nodes(node.children, depth + 1)}
{/if} {/if}
{:else} {:else}
@@ -174,14 +217,13 @@
aria-current={selected === node.path ? "true" : undefined} aria-current={selected === node.path ? "true" : undefined}
class={cn( class={cn(
"row-hover flex h-7 w-full items-center gap-1.5 rounded-md pr-2 text-left text-sm", "row-hover flex h-7 w-full items-center gap-1.5 rounded-md pr-2 text-left text-sm",
selected === node.path && "bg-sidebar-accent font-medium" selected === node.path && "bg-accent font-medium"
)} )}
onclick={() => open(node.path)} onclick={() => open(node.path)}
style="padding-left: {depth * 0.75 + 1.5}rem" style="padding-left: {depth * 0.875 + 1.5}rem"
type="button" type="button"
> >
<FileTextIcon class="size-4 shrink-0 text-icon" /> <span class="truncate">{node.name.replace(MD_SUFFIX, "")}</span>
<span class="truncate">{node.name}</span>
<span class="tabular ml-auto text-muted-foreground text-xs"> <span class="tabular ml-auto text-muted-foreground text-xs">
{fmtBytes(node.size)} {fmtBytes(node.size)}
</span> </span>
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
import { base } from "$app/paths";
import { page } from "$app/state";
import { isActive, SYSTEM_PAGES } from "$lib/nav";
import { cn } from "$lib/utils";
let { children } = $props();
</script>
<svelte:head><title>System · Beaver</title></svelte:head>
<div class="min-h-0 flex-1 overflow-y-auto">
<div class="mx-auto flex w-full max-w-5xl flex-col gap-6 px-4 py-6 sm:px-6">
<nav
aria-label="System pages"
class="flex flex-wrap items-baseline gap-x-5 gap-y-1"
>
<h1 class="font-semibold text-lg tracking-tight">System</h1>
{#each SYSTEM_PAGES as item (item.href)}
{@const active = isActive(page.url.pathname, base, item.href)}
<a
aria-current={active ? "page" : undefined}
class={cn("rule-word", active && "text-foreground")}
href="{base}{item.href}"
>
{item.label}
</a>
{/each}
</nav>
{@render children()}
</div>
</div>
+114
View File
@@ -0,0 +1,114 @@
<script lang="ts">
import { onMount } from "svelte";
import { base } from "$app/paths";
import type { SessionsResponse } from "$lib/api/types";
import ErrorNote from "$lib/components/error-note.svelte";
import { fmtBytes, fmtSeconds, shortId } from "$lib/format";
import { session } from "$lib/session.svelte";
import KindMark from "$lib/shell/kind-mark.svelte";
import { cn } from "$lib/utils";
const POLL_MS = 10_000;
let sessions = $state<SessionsResponse | null>(null);
let failure = $state<string | null>(null);
async function load() {
const { client } = session;
if (!client) {
return;
}
try {
sessions = await client.sessions();
failure = null;
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
}
onMount(() => {
load();
const poll = setInterval(load, POLL_MS);
return () => clearInterval(poll);
});
</script>
<section class="flex flex-col gap-3">
<div class="flex items-baseline gap-3">
<h2 class="font-medium text-sm">Live claude processes</h2>
{#if sessions}
<span class="tabular text-muted-foreground text-xs">
{sessions.sessions.length}
· {fmtBytes(sessions.rss)}
{#if sessions.rss_limit}
of {fmtBytes(sessions.rss_limit)}
{/if}
</span>
{/if}
</div>
{#if failure}
<ErrorNote message={failure} retry={load} />
{:else if !sessions}
<p class="text-muted-foreground text-sm">Loading…</p>
{:else if sessions.sessions.length === 0}
<p class="text-muted-foreground text-sm">
No claude processes alive. Sessions spawn on the first turn and are reaped
by idle time or memory pressure.
</p>
{:else}
<div class="overflow-x-auto rounded-lg border bg-strip">
<table class="w-full text-sm">
<thead class="text-muted-foreground text-xs">
<tr class="border-b text-left">
<th class="px-3 py-2 font-medium">conversation</th>
<th class="px-3 py-2 font-medium">agent</th>
<th class="px-3 py-2 font-medium">state</th>
<th class="px-3 py-2 text-right font-medium">rss</th>
<th class="px-3 py-2 text-right font-medium">idle</th>
<th class="px-3 py-2 text-right font-medium">age</th>
<th class="px-3 py-2 text-right font-medium">turns</th>
</tr>
</thead>
<tbody>
{#each sessions.sessions as s (s.key)}
<tr class="row-hover border-b last:border-b-0">
<td class="px-3 py-1.5">
<a
class="flex items-center gap-2 hover:underline"
href="{base}/conversations/{s.key}"
>
<KindMark kind={s.kind} />
<span class="tabular text-xs">{shortId(s.key)}</span>
</a>
</td>
<td class="px-3 py-1.5">{s.agent}</td>
<td class="px-3 py-1.5 text-xs">
<span
class={cn(s.busy ? "text-signal" : "text-muted-foreground")}
>
{s.busy ? "busy" : "idle"}
</span>
{#if s.pinned}
<span class="ml-1 text-muted-foreground">pinned</span>
{/if}
{#if s.dirty}
<span class="ml-1 text-warn">dirty</span>
{/if}
{#if s.pending_question}
<span class="ml-1 text-attention-foreground">question</span>
{/if}
</td>
<td class="tabular px-3 py-1.5 text-right">{fmtBytes(s.rss)}</td>
<td class="tabular px-3 py-1.5 text-right">
{fmtSeconds(s.idle_seconds)}
</td>
<td class="tabular px-3 py-1.5 text-right">
{fmtSeconds(s.age_seconds)}
</td>
<td class="tabular px-3 py-1.5 text-right">{s.turns}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
+66
View File
@@ -0,0 +1,66 @@
<script lang="ts">
import { onMount } from "svelte";
import type { AgentsResponse } from "$lib/api/types";
import Endpoints from "$lib/components/endpoints.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import { session } from "$lib/session.svelte";
import KindMark from "$lib/shell/kind-mark.svelte";
let agents = $state<AgentsResponse | null>(null);
let failure = $state<string | null>(null);
async function load() {
const { client } = session;
if (!client) {
return;
}
try {
agents = await client.agents();
failure = null;
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
}
onMount(load);
</script>
{#if failure}
<ErrorNote message={failure} retry={load} />
{:else if !agents}
<p class="text-muted-foreground text-sm">Loading…</p>
{:else}
<section class="flex flex-col gap-3">
<h2 class="font-medium text-sm">Agents</h2>
<ul class="rounded-lg border bg-strip">
{#each agents.agents as a (a.name)}
<li
class="ledger-grid grid-cols-[minmax(0,1fr)_auto] border-b px-3 py-2 text-sm last:border-b-0"
>
<span class="flex min-w-0 flex-col">
<span class="truncate font-medium">{a.name}</span>
<span class="truncate text-muted-foreground text-xs">
{a.model}{a.effort ? ` · ${a.effort}` : ""}
· {a.type}
</span>
</span>
<span class="flex gap-1">
{#each a.kinds as k (k)}
<KindMark kind={k} />
{/each}
</span>
</li>
{/each}
</ul>
</section>
<section class="flex flex-col gap-3">
<h2 class="font-medium text-sm">Endpoints</h2>
<p class="doc text-muted-foreground text-sm">
Where clients connect. Copy a URL into the Obsidian plugin, Cursor, or
curl together with a token of the matching scope.
</p>
<div class="rounded-lg border bg-strip px-3">
<Endpoints catalog={agents} />
</div>
</section>
{/if}
+97
View File
@@ -0,0 +1,97 @@
<script lang="ts">
import { onMount } from "svelte";
import type { AuditRecord } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import { Button } from "$lib/components/ui/button";
import { fmtDateTime } from "$lib/format";
import { session } from "$lib/session.svelte";
const PAGE = 100;
let records = $state<AuditRecord[] | null>(null);
let nextBefore = $state<number | null>(null);
let failure = $state<string | null>(null);
let busy = $state(false);
async function load(more = false) {
const { client } = session;
if (!client) {
return;
}
failure = null;
busy = true;
try {
const page = await client.audit({
before: more && nextBefore ? nextBefore : undefined,
limit: PAGE,
});
records = more ? [...(records ?? []), ...page.records] : page.records;
nextBefore = page.next_before;
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
} finally {
busy = false;
}
}
onMount(() => {
load();
});
function detail(record: AuditRecord): string {
if (typeof record.detail === "string") {
return record.detail;
}
return Object.entries(record.detail)
.map(
([key, value]) =>
`${key}=${typeof value === "string" ? value : JSON.stringify(value)}`
)
.join(" ");
}
</script>
<div class="flex flex-col gap-6">
{#if failure}
<ErrorNote message={failure} retry={() => load()} />
{:else if records === null}
<p class="text-muted-foreground text-sm">Loading…</p>
{:else if records.length === 0}
<EmptyState
hint="Logins, token changes and API writes land here."
title="Nothing audited yet"
/>
{:else}
<ul class="flex flex-col text-xs">
{#each records as record (record.id)}
<li
class="ledger-grid grid-cols-[9rem_7rem_9rem_minmax(0,1fr)] border-b py-1.5"
>
<span class="tabular text-muted-foreground"
>{fmtDateTime(record.ts)}</span
>
<span class="truncate font-medium">{record.kind}</span>
<span class="truncate text-muted-foreground">{record.actor}</span>
<span class="truncate font-mono text-muted-foreground">
{#if record.agent}
{record.agent}
·
{/if}
{detail(record)}
</span>
</li>
{/each}
</ul>
{#if nextBefore !== null}
<Button
class="self-start"
disabled={busy}
onclick={() => load(true)}
size="sm"
variant="outline"
>
Older
</Button>
{/if}
{/if}
</div>
+269
View File
@@ -0,0 +1,269 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { base } from "$app/paths";
import type { JobsResponse, QueuedJob } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import StatusPill from "$lib/components/status-pill.svelte";
import { Button } from "$lib/components/ui/button";
import { clip, fmtDateTime, fmtPct, fmtRelative } from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { session } from "$lib/session.svelte";
import Strip from "$lib/shell/strip.svelte";
import { cn } from "$lib/utils";
const TICK_MS = 15_000;
const PAYLOAD_MAX = 120;
let data = $state<JobsResponse | null>(null);
let failure = $state<string | null>(null);
let busy = $state<string | null>(null);
let timer: ReturnType<typeof setInterval> | undefined;
async function load() {
const { client } = session;
if (!client) {
return;
}
failure = null;
try {
data = await client.jobs();
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
}
async function run(name: string) {
const { client } = session;
if (!client) {
return;
}
busy = name;
try {
await client.runJob(name);
await load();
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
} finally {
busy = null;
}
}
async function cancel(job: QueuedJob) {
const { client } = session;
if (!client) {
return;
}
busy = `queue:${job.id}`;
try {
await client.cancelJob(job.id);
await load();
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
} finally {
busy = null;
}
}
function describe(job: QueuedJob): string {
const { payload } = job;
const { text } = payload;
if (typeof text === "string") {
return text;
}
const raw = JSON.stringify(payload);
return raw === "{}" ? "" : raw;
}
function triggers(job: JobsResponse["jobs"][number]): string {
const parts: string[] = [];
if (job.cron) {
parts.push(job.cron);
}
if (job.webhook) {
parts.push(`POST /hooks/${job.name}`);
}
for (const event of job.events) {
parts.push(`on ${event}`);
}
return parts.join(" · ") || "manual";
}
onMount(() => {
load();
timer = setInterval(load, TICK_MS);
});
onDestroy(() => clearInterval(timer));
const runs = $derived(
gateway.conversations
.filter((row) => row.kind === "job" || row.kind === "fork")
.slice(0, 30)
);
const pending = $derived(
data?.queue.filter((q) => q.status === "queued") ?? []
);
const picked = $derived(
data?.queue.filter((q) => q.status !== "queued") ?? []
);
</script>
<div class="flex flex-wrap items-baseline gap-3">
<h2 class="font-medium text-sm">Scheduler</h2>
{#if data}
<span class="tabular text-muted-foreground text-xs">
{data.jobs.length}
jobs · {pending.length} queued · window {fmtPct(data.utilization)}
{data.throttled ? " · throttled" : ""}
</span>
{/if}
</div>
<div class="flex flex-col gap-6">
{#if failure}
<ErrorNote message={failure} retry={load} />
{:else if data === null}
<p class="text-muted-foreground text-sm">Loading…</p>
{:else}
{#if !data.enabled}
<EmptyState
hint="The gateway is not on Postgres: cron and webhooks are off, `schedule` is unavailable. Event jobs still run in-process."
title="Scheduler is off"
/>
{/if}
{#if data.throttled}
<p class="text-sm text-warn">
Subscription window at {fmtPct(data.utilization)} (threshold
{fmtPct(
data.threshold
)}): non-critical jobs are deferred.
</p>
{/if}
<section class="flex flex-col gap-2">
<h3 class="label-quiet">Jobs</h3>
{#if data.jobs.length === 0}
<p class="text-muted-foreground text-sm">No jobs in config.</p>
{:else}
<ul class="flex flex-col">
{#each data.jobs as job (job.name)}
<li
class="ledger-grid grid-cols-[10rem_minmax(0,1fr)_9rem_9rem_5rem] items-center border-b py-2 text-sm"
>
<span class="truncate font-medium">
{job.name}
{#if !job.critical}
<span class="text-muted-foreground text-xs">· soft</span>
{/if}
</span>
<span class="truncate text-muted-foreground text-xs">
{triggers(job)}
</span>
<span
class="tabular text-muted-foreground text-xs"
title={fmtDateTime(job.next_run)}
>
{job.next_run ? `next ${fmtRelative(job.next_run)}` : ""}
</span>
<span class="flex items-center gap-2 text-xs">
{#if job.run}
<StatusPill status={job.run.status} />
<span
class="text-muted-foreground"
title={fmtDateTime(job.run.started_at)}
>
{fmtRelative(job.run.started_at)}
· {job.run.trigger}
</span>
{:else if job.last_run}
<span
class="text-muted-foreground"
title={fmtDateTime(job.last_run)}
>
ran {fmtRelative(job.last_run)}
</span>
{/if}
</span>
<Button
disabled={busy === job.name}
onclick={() => run(job.name)}
size="sm"
variant="outline"
>
Run
</Button>
</li>
{/each}
</ul>
{/if}
</section>
<section class="flex flex-col gap-2">
<h3 class="label-quiet">Queue</h3>
{#if data.queue.length === 0}
<p class="text-muted-foreground text-sm">
Nothing queued: no deferred injects, no pending webhooks.
</p>
{:else}
<ul class="flex flex-col">
{#each [...picked, ...pending] as job (job.id)}
<li
class={cn(
"ledger-grid grid-cols-[8rem_7rem_minmax(0,1fr)_9rem_5rem] items-center border-b py-2 text-sm",
job.status !== "queued" && "text-muted-foreground"
)}
>
<span
class="tabular text-xs"
title={fmtDateTime(job.execute_after)}
>
{job.status === "queued"
? fmtRelative(job.execute_after)
: job.status}
</span>
<span class="truncate text-xs">{job.entrypoint}</span>
<span class="truncate" title={JSON.stringify(job.payload)}>
{clip(describe(job), PAYLOAD_MAX)}
</span>
<span class="tabular text-right text-muted-foreground text-xs">
{fmtDateTime(job.execute_after)}
</span>
{#if job.status === "queued"}
<Button
disabled={busy === `queue:${job.id}`}
onclick={() => cancel(job)}
size="sm"
variant="ghost"
>
Cancel
</Button>
{:else}
<span></span>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
{/if}
<section class="flex flex-col gap-2">
<h3 class="label-quiet">Background conversations</h3>
<p class="text-muted-foreground text-xs">
Headless runs the scheduler and the merges spawned: they never enter the
rail.
</p>
{#if runs.length === 0}
<p class="text-muted-foreground text-sm">None yet.</p>
{:else}
<div class="bay-rack">
{#each runs as row (row.id)}
<Strip
detail={row.last_item?.text ?? ""}
href="{base}/conversations/{row.id}"
{row}
/>
{/each}
</div>
{/if}
</section>
</div>
@@ -5,13 +5,11 @@
import type { TokenRow } from "$lib/api/types"; import type { TokenRow } 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 PageHeader from "$lib/components/page-header.svelte";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog"; import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input"; import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select"; import * as Select from "$lib/components/ui/select";
import { Skeleton } from "$lib/components/ui/skeleton";
import { Switch } from "$lib/components/ui/switch"; import { Switch } from "$lib/components/ui/switch";
import { fmtDateTime, fmtRelative } from "$lib/format"; import { fmtDateTime, fmtRelative } from "$lib/format";
import { session } from "$lib/session.svelte"; import { session } from "$lib/session.svelte";
@@ -92,28 +90,26 @@
} }
</script> </script>
<svelte:head><title>Tokens · Beaver</title></svelte:head> <div class="flex flex-wrap items-center gap-4">
<h2 class="font-medium text-sm">Bearer tokens</h2>
<PageHeader title="Tokens">
<span class="flex items-center gap-2 text-muted-foreground text-xs"> <span class="flex items-center gap-2 text-muted-foreground text-xs">
<Switch aria-label="Show revoked tokens" bind:checked={includeRevoked} /> <Switch aria-label="Show revoked tokens" bind:checked={includeRevoked} />
show revoked show revoked
</span> </span>
{#snippet actions()}
<Button <Button
class="ml-auto"
onclick={() => { onclick={() => {
createOpen = true; createOpen = true;
}} }}
size="sm" size="sm"
variant="outline"
> >
<PlusIcon class="size-4" /> <PlusIcon class="size-4" />
New token New token
</Button> </Button>
{/snippet} </div>
</PageHeader>
<div class="min-h-0 flex-1 overflow-y-auto"> <div class="flex flex-col gap-6">
<div class="flex flex-col gap-4 px-4 py-4 sm:px-6">
{#if created} {#if created}
<div <div
class="flex flex-col gap-2 rounded-lg border border-primary/40 bg-primary/5 p-3 text-sm" class="flex flex-col gap-2 rounded-lg border border-primary/40 bg-primary/5 p-3 text-sm"
@@ -152,7 +148,7 @@
{#if failure} {#if failure}
<ErrorNote message={failure} retry={() => load()} /> <ErrorNote message={failure} retry={() => load()} />
{:else if tokens === null} {:else if tokens === null}
<Skeleton class="h-24 w-full" /> <p class="text-muted-foreground text-sm">Loading…</p>
{:else if tokens.length === 0} {:else if tokens.length === 0}
<EmptyState <EmptyState
hint="Bearer tokens let clients (Cursor, the Obsidian plugin, curl) reach the gateway. The plaintext is shown once at creation; only the hash is stored." hint="Bearer tokens let clients (Cursor, the Obsidian plugin, curl) reach the gateway. The plaintext is shown once at creation; only the hash is stored."
@@ -169,22 +165,23 @@
</Button> </Button>
</EmptyState> </EmptyState>
{:else} {:else}
<div class="overflow-x-auto rounded-lg border bg-strip">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="text-muted-foreground text-xs"> <thead class="text-muted-foreground text-xs">
<tr class="border-b text-left"> <tr class="border-b text-left">
<th class="py-1.5 pr-3 font-medium">name</th> <th class="px-3 py-2 font-medium">name</th>
<th class="py-1.5 pr-3 font-medium">scope</th> <th class="px-3 py-2 font-medium">scope</th>
<th class="py-1.5 pr-3 font-medium">created</th> <th class="px-3 py-2 font-medium">created</th>
<th class="py-1.5 pr-3 font-medium">last used</th> <th class="px-3 py-2 font-medium">last used</th>
<th class="py-1.5 text-right font-medium"></th> <th class="px-3 py-2 text-right font-medium"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each tokens as token (token.id)} {#each tokens as token (token.id)}
<tr <tr
class={cn("row-hover border-b", token.revoked_at && "text-muted-foreground")} class={cn("row-hover border-b last:border-b-0", token.revoked_at && "text-muted-foreground")}
> >
<td class="py-1.5 pr-3 font-medium"> <td class="px-3 py-1.5 font-medium">
{token.name} {token.name}
{#if token.revoked_at} {#if token.revoked_at}
<span class="ml-2 text-xs" <span class="ml-2 text-xs"
@@ -192,18 +189,18 @@
> >
{/if} {/if}
</td> </td>
<td class="py-1.5 pr-3"> <td class="px-3 py-1.5">
<code class="rounded bg-muted px-1.5 py-0.5" <code class="rounded bg-muted px-1.5 py-0.5"
>{token.scope}</code >{token.scope}</code
> >
</td> </td>
<td class="tabular py-1.5 pr-3 text-xs"> <td class="tabular px-3 py-1.5 text-xs">
{fmtDateTime(token.created_at)} {fmtDateTime(token.created_at)}
</td> </td>
<td class="tabular py-1.5 pr-3 text-xs"> <td class="tabular px-3 py-1.5 text-xs">
{token.last_used_at ? fmtRelative(token.last_used_at) : "never"} {token.last_used_at ? fmtRelative(token.last_used_at) : "never"}
</td> </td>
<td class="py-1.5 text-right"> <td class="px-3 py-1.5 text-right">
{#if !token.revoked_at} {#if !token.revoked_at}
<Button <Button
onclick={() => revoke(token)} onclick={() => revoke(token)}
@@ -218,8 +215,8 @@
{/each} {/each}
</tbody> </tbody>
</table> </table>
{/if}
</div> </div>
{/if}
</div> </div>
<Dialog.Root bind:open={createOpen}> <Dialog.Root bind:open={createOpen}>
+162 -106
View File
@@ -2,26 +2,21 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { base } from "$app/paths"; import { base } from "$app/paths";
import type { UsageGroup, UsageResponse, UsageRow } from "$lib/api/types"; import type { UsageGroup, UsageResponse, UsageRow } from "$lib/api/types";
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 KindBadge from "$lib/components/kind-badge.svelte";
import LimitBar from "$lib/components/limit-bar.svelte";
import PageHeader from "$lib/components/page-header.svelte";
import Stat from "$lib/components/stat.svelte";
import StatusPill from "$lib/components/status-pill.svelte";
import { Skeleton } from "$lib/components/ui/skeleton";
import * as Tabs from "$lib/components/ui/tabs";
import { import {
cacheShare, cacheShare,
fmtCountdown,
fmtDateTime, fmtDateTime,
fmtMoney, fmtMoney,
fmtPct, fmtPct,
fmtRelative,
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 } from "$lib/limits";
import { session } from "$lib/session.svelte"; import { session } from "$lib/session.svelte";
import KindMark from "$lib/shell/kind-mark.svelte";
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
const RANGES: { value: string; label: string; hours: number }[] = [ const RANGES: { value: string; label: string; hours: number }[] = [
@@ -36,12 +31,19 @@
{ label: "by day", value: "day" }, { label: "by day", value: "day" },
]; ];
const TICK_MS = 30_000; const TICK_MS = 30_000;
const LOW_CACHE = 0.5;
const BAR: Record<string, string> = {
allowed: "bg-primary",
allowed_warning: "bg-primary",
rejected: "bg-destructive",
};
let range = $state("week"); let range = $state("week");
let group = $state<UsageGroup>("agent"); let group = $state<UsageGroup>("agent");
let data = $state<UsageResponse | null>(null); let data = $state<UsageResponse | null>(null);
let failure = $state<string | null>(null); let failure = $state<string | null>(null);
let now = $state(Date.now()); let now = $state(Date.now());
let historyOpen = $state(false);
async function load(rangeValue: string, groupValue: UsageGroup) { async function load(rangeValue: string, groupValue: UsageGroup) {
const { client } = session; const { client } = session;
@@ -85,137 +87,181 @@
} }
return row.key; return row.key;
} }
const pct = (u: number | null | undefined) =>
Math.min(100, Math.round((u ?? 0) * 100));
</script> </script>
<svelte:head><title>Usage · Beaver</title></svelte:head> <svelte:head><title>Usage · Beaver</title></svelte:head>
<PageHeader
subtitle={data ? `${fmtDateTime(data.since)} now` : ""}
title="Usage"
>
<Tabs.Root
onValueChange={(value) => {
range = value;
}}
value={range}
>
<Tabs.List>
{#each RANGES as r (r.value)}
<Tabs.Trigger value={r.value}>{r.label}</Tabs.Trigger>
{/each}
</Tabs.List>
</Tabs.Root>
</PageHeader>
<div class="min-h-0 flex-1 overflow-y-auto"> <div class="min-h-0 flex-1 overflow-y-auto">
<div class="flex flex-col gap-8 px-4 py-4 sm:px-6"> <div class="mx-auto flex w-full max-w-5xl flex-col gap-10 px-4 py-6 sm:px-6">
{#if failure} {#if failure}
<ErrorNote message={failure} retry={() => load(range, group)} /> <ErrorNote message={failure} retry={() => load(range, group)} />
{/if} {/if}
<section class="flex flex-col gap-2"> <section class="flex flex-col gap-4">
<h2 <h1 class="font-semibold text-lg tracking-tight">Subscription</h1>
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Subscription quota
</h2>
{#if windows.length === 0} {#if windows.length === 0}
<EmptyState <p class="doc text-muted-foreground text-sm">
hint="Windows show up once the SDK emits its first rate-limit event. Utilization is for the whole subscription; the gateway's own share is the line under each bar." No rate-limit report yet. The SDK sends one the first time a window
title="No rate-limit reports yet" changes state; until then there is nothing to show.
/> </p>
{:else} {:else}
<div class="flex flex-col divide-y"> <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)}
<LimitBar {now} window={w} /> {@const known = w.utilization !== null && w.utilization !== undefined}
<div class="flex flex-col gap-2">
<span class="label-quiet">{limitLabel(w.window)}</span>
{#if known}
<span
class={cn(
"tabular font-semibold text-4xl leading-none tracking-tight",
w.status === "rejected" && "text-destructive"
)}
>
{pct(w.utilization)}
<span class="font-normal text-lg text-muted-foreground"
>%</span
>
</span>
<span
aria-valuemax="100"
aria-valuemin="0"
aria-valuenow={pct(w.utilization)}
class="h-1 w-full overflow-hidden rounded-full bg-muted"
role="progressbar"
>
<span
class={cn("block h-full rounded-full", BAR[w.status])}
style="width: {pct(w.utilization)}%"
></span>
</span>
<span class="tabular text-muted-foreground text-xs">
{fmtCountdown(w.resets_at, now)}
· gateway share
{fmtTokens(
w.gateway.input + w.gateway.output + w.gateway.cache_creation
)}
tokens · {fmtMoney(w.gateway.cost_usd)}
</span>
{:else}
<span class="text-2xl text-muted-foreground leading-none">
no report
</span>
<span class="text-muted-foreground text-xs">
last seen {fmtRelative(w.ts, now)} ·
{fmtCountdown(
w.resets_at,
now
)}
</span>
{/if}
</div>
{/each} {/each}
</div> </div>
{/if} {/if}
</section> </section>
<section class="flex flex-col gap-3"> <section class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-baseline gap-x-5 gap-y-2">
<h2 <h2 class="font-semibold text-lg tracking-tight">Tokens</h2>
class="font-medium text-muted-foreground text-xs uppercase tracking-wide" <span class="flex items-center gap-3">
> {#each RANGES as r (r.value)}
Tokens and API-price equivalent <button
</h2> class="rule-word text-sm"
<Tabs.Root data-active={range === r.value}
class="ml-auto" onclick={() => {
onValueChange={(value) => { range = r.value;
group = value as UsageGroup;
}} }}
value={group} type="button"
> >
<Tabs.List> {r.label}
{#each GROUPS as g (g.value)} </button>
<Tabs.Trigger value={g.value}>{g.label}</Tabs.Trigger>
{/each} {/each}
</Tabs.List> </span>
</Tabs.Root> <span class="ml-auto flex items-center gap-3">
{#each GROUPS as g (g.value)}
<button
class="rule-word text-xs"
data-active={group === g.value}
onclick={() => {
group = g.value;
}}
type="button"
>
{g.label}
</button>
{/each}
</span>
</div> </div>
{#if !(data && total)} {#if !(data && total)}
<Skeleton class="h-40 w-full" /> <p class="text-muted-foreground text-sm">Summing…</p>
{:else} {:else}
<div <div
class="grid grid-cols-2 gap-4 border-y py-3 sm:grid-cols-4 lg:grid-cols-6" class="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3 lg:grid-cols-6"
> >
<Stat label="cost" value={fmtMoney(total.cost_usd)} /> {#each [["spent", fmtMoney(total.cost_usd), ""], ["turns", String(total.turns), ""], ["input", fmtTokens(total.input), ""], ["output", fmtTokens(total.output), ""], ["cache share", fmtPct(share), share !== null && share < LOW_CACHE && total.turns > 0 ? "warn" : ""], ["cache written", fmtTokens(total.cache_creation), ""]] as [name, value, tone] (name)}
<Stat label="turns" value={String(total.turns)} /> <div class="flex flex-col gap-1">
<Stat label="input" value={fmtTokens(total.input)} /> <span class="label-quiet">{name}</span>
<Stat label="output" value={fmtTokens(total.output)} /> <span
<Stat class={cn(
hint="cache read of all input" "tabular font-semibold text-2xl leading-none tracking-tight",
label="cache share" tone === "warn" && "text-primary"
tone={share !== null && share < 0.5 && total.turns > 0 ? "warn" : "default"} )}
value={fmtPct(share)} >
/> {value}
<Stat </span>
hint={total.web_searches ? `${total.web_searches} web searches` : ""}
label="cache written"
value={fmtTokens(total.cache_creation)}
/>
</div> </div>
{/each}
</div>
<p class="text-muted-foreground text-xs">
API-price equivalent of what went through the gateway since
{fmtDateTime(
data.since
)}; the subscription is billed differently.
</p>
{#if data.rows.length === 0} {#if data.rows.length === 0}
<EmptyState <p class="text-muted-foreground text-sm">
hint="No turns in this range. Widen it, or wait for the agents to work." No turns in this range. Widen it, or wait for the agents to work.
title="Nothing to sum" </p>
/>
{:else} {:else}
<div class="overflow-x-auto"> <div class="overflow-x-auto rounded-lg border bg-strip">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="text-muted-foreground text-xs"> <thead class="text-muted-foreground text-xs">
<tr class="border-b text-left"> <tr class="border-b text-left">
<th class="py-1.5 pr-3 font-medium">{group}</th> <th class="px-3 py-2 font-medium">
<th class="w-32 py-1.5 pr-3 font-medium">cost</th> {group.replace("_", " ")}
<th class="py-1.5 pr-3 text-right font-medium">turns</th>
<th class="py-1.5 pr-3 text-right font-medium">in</th>
<th class="py-1.5 pr-3 text-right font-medium">out</th>
<th class="py-1.5 pr-3 text-right font-medium">cache read</th>
<th class="py-1.5 pr-3 text-right font-medium">
cache write
</th> </th>
<th class="py-1.5 text-right font-medium">cache share</th> <th class="w-36 px-3 py-2 font-medium">spent</th>
<th class="px-3 py-2 text-right font-medium">turns</th>
<th class="px-3 py-2 text-right font-medium">in</th>
<th class="px-3 py-2 text-right font-medium">out</th>
<th class="px-3 py-2 text-right font-medium">cache read</th>
<th class="px-3 py-2 text-right font-medium">cache write</th>
<th class="px-3 py-2 text-right font-medium">cache share</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each data.rows as row (row.key)} {#each data.rows as row (row.key)}
{@const rowShare = cacheShare(row.input, row.cache_read)} {@const rowShare = cacheShare(row.input, row.cache_read)}
<tr class="row-hover border-b"> <tr class="row-hover border-b last:border-b-0">
<td class="max-w-64 py-1.5 pr-3"> <td class="max-w-64 px-3 py-1.5">
<span class="flex min-w-0 items-center gap-2"> <span class="flex min-w-0 items-center gap-2">
{#if group === "conversation"} {#if group === "conversation"}
{#if row.kind} {#if row.kind}
<KindBadge kind={row.kind} /> <KindMark kind={row.kind} />
{/if} {/if}
<a <a
class="truncate font-medium text-link hover:underline" class="truncate font-medium hover:underline"
href="{base}/conversations/{row.key}" href="{base}/conversations/{row.key}"
> >
{label(row)} {label(row)}
</a> </a>
{#if row.status && row.status !== "open"} {#if row.status && row.status !== "open"}
<StatusPill status={row.status} /> <span class="text-muted-foreground text-xs"
>{row.status}</span
>
{/if} {/if}
{:else} {:else}
<span class="truncate font-medium">{label(row)}</span> <span class="truncate font-medium">{label(row)}</span>
@@ -227,10 +273,10 @@
{/if} {/if}
</span> </span>
</td> </td>
<td class="py-1.5 pr-3"> <td class="px-3 py-1.5">
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<span <span
class="h-1.5 w-16 overflow-hidden rounded-full bg-muted" class="h-1 w-16 overflow-hidden rounded-full bg-muted"
> >
<span <span
class="block h-full rounded-full bg-primary" class="block h-full rounded-full bg-primary"
@@ -242,23 +288,23 @@
<span class="tabular">{fmtMoney(row.cost_usd)}</span> <span class="tabular">{fmtMoney(row.cost_usd)}</span>
</span> </span>
</td> </td>
<td class="tabular py-1.5 pr-3 text-right">{row.turns}</td> <td class="tabular px-3 py-1.5 text-right">{row.turns}</td>
<td class="tabular py-1.5 pr-3 text-right"> <td class="tabular px-3 py-1.5 text-right">
{fmtTokens(row.input)} {fmtTokens(row.input)}
</td> </td>
<td class="tabular py-1.5 pr-3 text-right"> <td class="tabular px-3 py-1.5 text-right">
{fmtTokens(row.output)} {fmtTokens(row.output)}
</td> </td>
<td class="tabular py-1.5 pr-3 text-right"> <td class="tabular px-3 py-1.5 text-right">
{fmtTokens(row.cache_read)} {fmtTokens(row.cache_read)}
</td> </td>
<td class="tabular py-1.5 pr-3 text-right"> <td class="tabular px-3 py-1.5 text-right">
{fmtTokens(row.cache_creation)} {fmtTokens(row.cache_creation)}
</td> </td>
<td <td
class={cn( class={cn(
"tabular py-1.5 text-right", "tabular px-3 py-1.5 text-right",
rowShare !== null && rowShare < 0.5 && "text-warn" rowShare !== null && rowShare < LOW_CACHE && "text-primary"
)} )}
> >
{fmtPct(rowShare)} {fmtPct(rowShare)}
@@ -274,11 +320,18 @@
{#if history.length > 0} {#if history.length > 0}
<section class="flex flex-col gap-2"> <section class="flex flex-col gap-2">
<h2 <button
class="font-medium text-muted-foreground text-xs uppercase tracking-wide" aria-expanded={historyOpen}
class="rule-word flex items-center gap-2 self-start text-sm"
onclick={() => {
historyOpen = !historyOpen;
}}
type="button"
> >
Rate-limit history Rate-limit reports
</h2> <span class="tabular text-xs">{history.length}</span>
</button>
{#if historyOpen}
<ul class="flex flex-col text-xs"> <ul class="flex flex-col text-xs">
{#each history as h (h.id)} {#each history as h (h.id)}
<li <li
@@ -289,10 +342,13 @@
> >
<span>{limitLabel(h.window)}</span> <span>{limitLabel(h.window)}</span>
<span class="tabular">{fmtPct(h.utilization)}</span> <span class="tabular">{fmtPct(h.utilization)}</span>
<StatusPill status={h.status} /> <span class="text-muted-foreground"
>{h.status.replace("_", " ")}</span
>
</li> </li>
{/each} {/each}
</ul> </ul>
{/if}
</section> </section>
{/if} {/if}
</div> </div>