feat(ui,api): strip board redesign - island, rail by day, context view, server search, vault graph
This commit is contained in:
@@ -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)
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
-->
|
-->
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
>
|
||||||
|
<{g.tag}>
|
||||||
|
</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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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}
|
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
@@ -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}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user