feat(*): svelte 5 panel view sharing the gateway ui, obsidian theme, preview and tests

This commit is contained in:
hh
2026-08-29 18:30:31 +02:00
parent a1bf4db905
commit 04400c5076
33 changed files with 3378 additions and 1803 deletions
+132
View File
@@ -0,0 +1,132 @@
import { Component } from "obsidian";
import { mount } from "svelte";
import { PanelState } from "../src/state.svelte";
import App from "../src/ui/app.svelte";
import { BRANCH_BLASTER, DEEP_PANEL, FakeClient, MASTER } from "./fake-client";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// bits-ui opens menus on pointerdown and tabs on pointer or click: send both.
function press(element: Element | null | undefined): void {
if (!element) {
return;
}
element.dispatchEvent(
new PointerEvent("pointerdown", {
bubbles: true,
button: 0,
cancelable: true,
pointerType: "mouse",
})
);
(element as HTMLElement).focus();
(element as HTMLElement).click();
}
// Scenes for screenshots: open the page with a hash to land in a state.
// Width comes from the window (``--window-size``), the state from here.
async function main(): Promise<void> {
const client = new FakeClient();
const state = new PanelState();
const scene = location.hash.slice(1) || "thread";
state.selected = MASTER;
if (scene === "empty") {
state.selected = null;
}
if (scene === "question") {
state.selected = BRANCH_BLASTER;
}
if (scene === "deep") {
state.selected = DEEP_PANEL;
}
if (scene === "offline") {
state.selected = MASTER;
}
document.body.classList.toggle("theme-light", scene.endsWith("-light"));
document.body.classList.toggle("theme-dark", !scene.endsWith("-light"));
const app = {
workspace: {
openLinkText: (target: string) => console.log("open", target),
},
};
mount(App, {
props: {
app: app as never,
client: scene === "offline" ? null : client,
component: new Component() as never,
onOpenSettings: () => console.log("settings"),
state,
vaultSubpath: () => "💬 чаты",
},
target: document.getElementById("app") as HTMLElement,
});
await wait(80);
if (scene.startsWith("closed")) {
press(document.querySelector('[aria-label="Switch conversation"]'));
await wait(60);
press(document.querySelector('[title="Show closed conversations"]'));
}
if (scene.startsWith("switcher")) {
document
.querySelector<HTMLButtonElement>('[aria-label="Switch conversation"]')
?.click();
}
if (scene.startsWith("menu")) {
const row =
document.querySelector<HTMLElement>(`[data-row="${BRANCH_BLASTER}"]`) ??
(await (async () => {
document
.querySelector<HTMLButtonElement>(
'[aria-label="Switch conversation"]'
)
?.click();
await wait(60);
return document.querySelector<HTMLElement>(
`[data-row="${BRANCH_BLASTER}"]`
);
})());
row?.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
clientX: row.getBoundingClientRect().left + 120,
clientY: row.getBoundingClientRect().top + 16,
})
);
}
if (scene.startsWith("actions")) {
press(document.querySelector('[aria-label="Conversation actions"]'));
}
if (scene.startsWith("activity")) {
for (const trigger of document.querySelectorAll<HTMLButtonElement>(
'[data-slot="tabs-trigger"]'
)) {
if (trigger.textContent?.trim() === "Activity") {
press(trigger);
}
}
}
if (scene.startsWith("branch")) {
document
.querySelector<HTMLButtonElement>(
'[aria-label="Branch off this conversation"]'
)
?.click();
}
await wait(60);
if (scene.startsWith("stream")) {
// A tool lands while you watch: the tree grows without a reload.
client.emit({
conversation_id: MASTER,
input: { command: "curl -sI https://b.hhhhh.dev/healthz" },
name: "Bash",
parent_tool_use_id: null,
tool_use_id: "tu_live",
turn_id: "t-run",
type: "tool",
});
}
}
main().catch(console.error);
+512
View File
@@ -0,0 +1,512 @@
import { ApiClient, ApiError } from "$lib/api/client";
import type {
BusEvent,
ConversationInfo,
ConversationSummary,
HistoryMessage,
} from "$lib/api/types";
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const now = Date.now();
const ago = (ms: number) => new Date(now - ms).toISOString();
export const MASTER = "c1master0000";
export const BRANCH_UFW = "c2ufw0000000";
export const BRANCH_BLASTER = "c3blaster000";
export const DEEP_PANEL = "c4panel00000";
export const JOB_TRIAGE = "c5triage0000";
export const FORK_MERGE = "c6fork000000";
function summary(
over: Partial<ConversationSummary> & Pick<ConversationSummary, "id" | "kind">
): ConversationSummary {
return {
agent: "beaver-dispatcher",
created_at: ago(9 * HOUR),
flags: {},
last_activity_at: ago(4 * MINUTE),
last_item: null,
last_user_activity_at: ago(12 * MINUTE),
origin: "telegram",
parent_row: null,
pending_question: false,
running_turn: null,
session_id: "9c0c3fb2-2ac7-4d6b-8d4e-1a3f0c1a2b3c",
status: "open",
title: null,
...over,
};
}
const ROWS: ConversationSummary[] = [
summary({
id: MASTER,
kind: "master",
last_item: {
created_at: ago(4 * MINUTE),
id: 41,
origin: "telegram",
priority: "user",
status: "running",
text: "посмотри, что с ufw на dell - порт 62990 снаружи не отвечает",
},
running_turn: "t-run",
title: "Master · 29 Aug",
}),
summary({
id: BRANCH_UFW,
kind: "branch",
last_activity_at: ago(35 * MINUTE),
last_item: {
created_at: ago(35 * MINUTE),
id: 40,
origin: "telegram",
priority: "user",
status: "done",
text: "правило для 62990 добавлено, проверь снаружи",
},
parent_row: 1,
title: "ufw на dell",
}),
summary({
id: BRANCH_BLASTER,
kind: "branch",
last_activity_at: ago(3 * HOUR),
parent_row: 1,
pending_question: true,
title: "бластер - что заказать",
}),
summary({
agent: "beaver-opus-high",
id: DEEP_PANEL,
kind: "deep",
last_activity_at: ago(50 * MINUTE),
origin: "markdown",
title: "2026-08-29 - панель в Obsidian",
}),
summary({
agent: "beaver-triage",
id: JOB_TRIAGE,
kind: "job",
last_activity_at: ago(2 * HOUR),
origin: "cron",
status: "closed",
title: "триаж 14:00",
}),
summary({
id: FORK_MERGE,
kind: "fork",
last_activity_at: ago(6 * HOUR),
origin: "api",
status: "merged",
title: "слив: утренняя ветка",
}),
];
const INFO: Record<string, Partial<ConversationInfo>> = {
[MASTER]: {
bindings: [
{ external_id: "-1002233:general", frontend: "telegram", visible: true },
],
busy: true,
live: true,
queue: [
{
created_at: ago(2 * MINUTE),
id: 42,
origin: "watch",
priority: "normal",
status: "queued",
text: "vault: 📅 дни/2026-08-29.md +3 строки",
},
],
turn: {
id: "t-run",
origin: "user",
started_at: ago(48_000),
text: "посмотри, что с ufw на dell - порт 62990 снаружи не отвечает",
tools: [
{
content: null,
ended_at: null,
input: {
description: "проверить ufw на dell",
prompt:
"ssh dell 'sudo ufw status numbered' и сравнить с site.caddy",
},
is_error: null,
name: "Task",
parent_tool_use_id: null,
started_at: ago(40_000),
tool_use_id: "tu_task",
},
{
content:
"Status: active\n[ 1] 22/tcp ALLOW IN Anywhere\n[ 2] 443/tcp ALLOW IN Anywhere",
ended_at: ago(30_000),
input: { command: "ssh dell 'sudo ufw status numbered'" },
is_error: false,
name: "Bash",
parent_tool_use_id: "tu_task",
started_at: ago(38_000),
tool_use_id: "tu_bash",
},
{
content: null,
ended_at: null,
input: { file_path: "/vault/мета/бобер/скиллы/ops/komodo/SKILL.md" },
is_error: null,
name: "Read",
parent_tool_use_id: "tu_task",
started_at: ago(8000),
tool_use_id: "tu_read",
},
],
},
},
[BRANCH_UFW]: {
bindings: [
{ external_id: "-1002233:517", frontend: "telegram", visible: true },
],
parent: MASTER,
},
[BRANCH_BLASTER]: {
bindings: [
{ external_id: "-1002233:498", frontend: "telegram", visible: false },
],
parent: MASTER,
question: {
id: "q1",
questions: [
{
options: [
{ description: "Ozon, 3 дня", label: "Xiaomi Mi Blaster" },
{ description: "AliExpress, 2 недели", label: "Broadlink RM4" },
],
question: "Какой ИК-бластер заказать?",
},
],
},
},
[DEEP_PANEL]: {
bindings: [
{
external_id: "2026-08-29 - панель в Obsidian.md",
frontend: "markdown",
visible: true,
},
],
flags: { memory: true },
},
[JOB_TRIAGE]: { flags: { critical: false } },
[FORK_MERGE]: { parent: MASTER },
};
const HISTORY: Record<string, HistoryMessage[]> = {
[MASTER]: [
{ content: "с утра: дневник приехал? что в хендауте", role: "user" },
{
content:
"Приехал. В [[2026-08-28|хендауте]] два хвоста: **бластер** ждёт выбора модели, и **ufw на dell** - порт 62990 снаружи молчит. Ветка по ufw открыта, см. [[ufw на dell]].",
role: "assistant",
},
{ content: "ок, гони ufw", role: "user" },
{
content: [
{
text: "Смотрю правила через `ssh dell` и сверяю с `site.caddy`.",
type: "text",
},
{
id: "tu_prev",
input: { command: "cat /root/beaver-agent/caddy/site.caddy" },
name: "Bash",
type: "tool_use",
},
],
role: "assistant",
},
{
content: [
{
content: "beaver.hhhhh.dev {\n reverse_proxy localhost:62990\n}",
tool_use_id: "tu_prev",
type: "tool_result",
},
],
role: "user",
},
{
content:
"Caddy проксирует на `localhost:62990`, значит снаружи должен отвечать 443, а не 62990 - проверю, открыт ли он вообще.",
role: "assistant",
},
],
[BRANCH_UFW]: [
{ content: "правило для 62990 добавлено, проверь снаружи", role: "user" },
{
content:
"Снаружи `curl -sI https://b.hhhhh.dev/healthz` → 200. Порт 62990 наружу не нужен: его слушает только Caddy. Предлагаю правило убрать и слить ветку.",
role: "assistant",
},
],
[BRANCH_BLASTER]: [
{ content: "нужен ИК-бластер для кондиционера, выбери", role: "user" },
{ content: "Два варианта, спрошу кнопками.", role: "assistant" },
],
[DEEP_PANEL]: [
{
content: "как панель в Obsidian должна вести себя в узкой колонке?",
role: "user",
},
{
content:
"Тред всегда на виду; переключатель разговоров - в шапке. Список как отдельный экран - только на телефоне.\n\n- узкая колонка: шапка + тред + композер\n- вкладка: рейл слева\n\nСм. [[архитектура#2. Окна]].",
role: "assistant",
},
],
};
interface Route {
handle: (match: RegExpMatchArray, body: unknown) => unknown;
method: string;
pattern: RegExp;
}
// The gateway as the panel sees it, in memory: enough of ``/api`` for the
// preview scenes and the smoke test, with a scripted event stream.
export class FakeClient extends ApiClient {
rows = ROWS.map((row) => ({ ...row }));
readonly sent: { id: string; text: string; origin: string }[] = [];
private readonly listeners = new Set<(event: BusEvent) => void>();
private seq = 1;
constructor() {
super("http://fake", "token");
}
private readonly routes: Route[] = [
{
handle: () => ({ agents: [], frontends: [], mcps: [] }),
method: "GET",
pattern: /^\/api\/agents$/,
},
{
handle: () => ({ conversations: this.rows }),
method: "GET",
pattern: /^\/api\/conversations$/,
},
{
handle: ([, id]) => this.info(id),
method: "GET",
pattern: /^\/api\/conversations\/([^/]+)$/,
},
{
handle: ([, id]) => ({ id, messages: HISTORY[id] ?? [] }),
method: "GET",
pattern: /^\/api\/conversations\/([^/]+)\/history$/,
},
{
handle: ([, id]) => ({
entries: [],
id,
offset: 0,
subpath: "",
subpaths: [],
total: 0,
}),
method: "GET",
pattern: /^\/api\/conversations\/([^/]+)\/entries$/,
},
{
handle: ([, id], body) => {
const { text, origin } = body as { text: string; origin: string };
this.sent.push({ id, origin, text });
return { id, item: this.sent.length, status: "queued" };
},
method: "POST",
pattern: /^\/api\/conversations\/([^/]+)\/messages$/,
},
{
handle: ([, id], body) => {
const { text, origin } = body as { text: string; origin: string };
this.sent.push({ id, origin: `inject:${origin}`, text });
return { id, item: this.sent.length, priority: "normal" };
},
method: "POST",
pattern: /^\/api\/conversations\/([^/]+)\/inject$/,
},
{
handle: ([, id], body) => {
const child = summary({
id: `child${this.rows.length}`,
kind: "branch",
parent_row: 1,
title: (body as { title?: string }).title ?? "новая ветка",
});
this.rows.unshift(child);
return this.info(child.id, id);
},
method: "POST",
pattern: /^\/api\/conversations\/([^/]+)\/branch$/,
},
{
handle: ([, id], body) => {
const {
frontend,
external_id: external,
visible,
} = body as {
frontend: string;
external_id?: string;
visible: boolean;
};
if (!INFO[id]) {
INFO[id] = {};
}
const info = INFO[id];
const bindings = (info.bindings ?? []).filter(
(b) => b.frontend !== frontend
);
bindings.push({
external_id: external ?? `${frontend}:new`,
frontend,
visible,
});
info.bindings = bindings;
return this.info(id);
},
method: "POST",
pattern: /^\/api\/conversations\/([^/]+)\/bind$/,
},
{
handle: ([, id], body) => {
const row = this.row(id);
row.flags = { ...row.flags, ...(body as Record<string, unknown>) };
return row;
},
method: "PATCH",
pattern: /^\/api\/conversations\/([^/]+)\/flags$/,
},
{
handle: ([, id], body) => {
const row = this.row(id);
const patch = body as {
status?: ConversationSummary["status"];
title?: string;
};
if (patch.status) {
row.status = patch.status;
}
if (typeof patch.title === "string") {
row.title = patch.title;
}
return row;
},
method: "PATCH",
pattern: /^\/api\/conversations\/([^/]+)$/,
},
{
handle: ([, id]) => {
this.row(id).status = "merged";
return { fork: "f", id, status: "merged", text: "слито" };
},
method: "POST",
pattern: /^\/api\/conversations\/([^/]+)\/merge$/,
},
{
handle: ([, id]) => {
this.row(id).status = "closed";
return {
digest: "мета/бобер/выжимки/2026-08-29 - панель.md",
id,
status: "closed",
};
},
method: "POST",
pattern: /^\/api\/conversations\/([^/]+)\/close$/,
},
{
handle: ([, id], body) => ({
id,
question_id: (body as { question_id: string }).question_id,
}),
method: "POST",
pattern: /^\/api\/conversations\/([^/]+)\/answer$/,
},
];
private row(id: string): ConversationSummary {
const row = this.rows.find((r) => r.id === id);
if (!row) {
throw new ApiError(404, `unknown conversation ${id}`, null);
}
return row;
}
private info(id: string, parent?: string): ConversationInfo {
const row = this.row(id);
const extra = INFO[id] ?? {};
return {
...row,
bindings: [],
busy: false,
live: Boolean(row.session_id),
parent: parent ?? null,
question: null,
queue: [],
turn: null,
...extra,
};
}
private route(method: string, path: string, body?: unknown): unknown {
for (const route of this.routes) {
const match = route.method === method ? path.match(route.pattern) : null;
if (match) {
return route.handle(match, body);
}
}
throw new ApiError(404, `no route for ${method} ${path}`, null);
}
override get<T>(path: string): Promise<T> {
return Promise.resolve(this.route("GET", path) as T);
}
override post<T>(path: string, body?: unknown): Promise<T> {
return Promise.resolve(this.route("POST", path, body ?? {}) as T);
}
override patch<T>(path: string, body?: unknown): Promise<T> {
return Promise.resolve(this.route("PATCH", path, body ?? {}) as T);
}
override stream(
_path: string,
onEvent: (event: BusEvent) => void,
signal: AbortSignal
): Promise<void> {
return new Promise((resolve) => {
onEvent({ seq: 0, ts: new Date().toISOString(), type: "hello" });
this.listeners.add(onEvent);
signal.addEventListener("abort", () => {
this.listeners.delete(onEvent);
resolve();
});
});
}
emit(event: Omit<BusEvent, "seq" | "ts"> & { ts?: string }): void {
this.seq += 1;
const full = {
seq: this.seq,
ts: new Date().toISOString(),
...event,
} as BusEvent;
for (const listener of this.listeners) {
listener(full);
}
}
}
+48
View File
@@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1" name="viewport">
<title>beaver panel preview</title>
<link href="/app.css" rel="stylesheet">
<link href="/theme.css" rel="stylesheet">
<link href="/styles.css" rel="stylesheet">
<style>
body.theme-dark,
body.theme-light {
--accent-h: 254;
--accent-s: 80%;
--accent-l: 68%;
--font-interface:
-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto,
sans-serif;
}
body {
height: 100vh;
margin: 0;
overflow: hidden;
background: var(--background-primary);
}
#app {
height: 100vh;
}
</style>
</head>
<body class="theme-dark">
<!--
impeccable direction contract (mode operate, brief-pinned world: beaver-calendar's Obsidian skin)
THESIS: the agent's terminal, not a chat app; the thread is always on screen, picking a
conversation is a header switch. OWN-WORLD: Obsidian's own variables through .beaver-root;
hairline rows, tabular numerals, uppercase 12px section labels; nothing pink.
STORY: follow the note, watch tools stream, answer, branch with a seed, send back to Telegram.
FIRST VIEWPORT: switcher over the thread header, tabs, live thread, composer pinned below;
wide: the grouped rail on the left. FORM: two-pane operator console, pinned by the brief.
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review,
the verdict, and DESIGN.md
-->
<div id="app"></div>
<script defer src="/preview/bundle.js"></script>
</body>
</html>
+126
View File
@@ -0,0 +1,126 @@
import {
createReadStream,
existsSync,
mkdtempSync,
readdirSync,
statSync,
writeFileSync,
} from "node:fs";
import { createServer } from "node:http";
import { homedir, tmpdir } from "node:os";
import { extname, join, normalize } from "node:path";
import { extractFile } from "@electron/asar";
import esbuild from "esbuild";
import { root, sharedLib, svelte } from "../esbuild.config.mjs";
const port = Number(process.env.PORT ?? 4174);
const ASAR_CANDIDATES = [
process.env.OBSIDIAN_ASAR,
"/Applications/Obsidian.app/Contents/Resources/obsidian.asar",
join(homedir(), "Applications/Obsidian.app/Contents/Resources/obsidian.asar"),
"/opt/Obsidian/resources/obsidian.asar",
"/usr/lib/obsidian/resources/obsidian.asar",
].filter(Boolean);
function findAppCss() {
for (const candidate of ASAR_CANDIDATES) {
if (!existsSync(candidate)) {
continue;
}
try {
const path = join(mkdtempSync(join(tmpdir(), "beaver-app-")), "app.css");
writeFileSync(path, extractFile(candidate, "app.css"));
return path;
} catch {
/* try the next candidate */
}
}
console.warn(
"preview: could not read app.css from an Obsidian install. Set OBSIDIAN_ASAR to point at obsidian.asar; without it the chrome will not match the real app."
);
return null;
}
function findTheme() {
if (process.env.THEME) {
return process.env.THEME;
}
const vault = process.env.VAULT;
if (!vault) {
return null;
}
const themes = join(vault, ".obsidian/themes");
if (!existsSync(themes)) {
return null;
}
for (const name of readdirSync(themes)) {
const css = join(themes, name, "theme.css");
if (existsSync(css)) {
return css;
}
}
return null;
}
const appCss = findAppCss();
const theme = findTheme();
const ctx = await esbuild.context({
bundle: true,
conditions: ["svelte", "browser"],
entryPoints: [join(root, "preview/entry.ts")],
format: "iife",
logLevel: "info",
mainFields: ["svelte", "browser", "module", "main"],
outfile: join(root, "preview/bundle.js"),
plugins: [
sharedLib({ obsidian: join(root, "tests/obsidian-stub.ts") }),
svelte(),
],
sourcemap: "inline",
target: "es2022",
});
await ctx.rebuild();
await ctx.watch();
const TYPES = {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
};
createServer((request, response) => {
const url = decodeURIComponent((request.url ?? "/").split("?")[0]);
const external = { "/app.css": appCss, "/theme.css": theme };
if (url in external) {
const file = external[url];
if (!file) {
response.writeHead(200, { "content-type": TYPES[".css"] }).end("");
return;
}
response.writeHead(200, {
"cache-control": "no-store",
"content-type": TYPES[".css"],
});
createReadStream(file).pipe(response);
return;
}
const file = join(root, normalize(url === "/" ? "/preview/index.html" : url));
if (
!(file.startsWith(root) && existsSync(file)) ||
statSync(file).isDirectory()
) {
response.writeHead(404).end("not found");
return;
}
response.writeHead(200, {
"cache-control": "no-store",
"content-type": TYPES[extname(file)] ?? "application/octet-stream",
});
createReadStream(file).pipe(response);
}).listen(port, () => {
console.log(`preview: http://localhost:${port}/`);
});
+81
View File
@@ -0,0 +1,81 @@
// bun preview/shot.ts <out.png> <url> [width] [height]
// Headless Chrome over CDP: the preview server must be running.
const [out, url, w = "340", h = "720"] = process.argv.slice(2);
const port = 9334;
const proc = Bun.spawn(
[
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"--headless=new",
"--disable-gpu",
"--hide-scrollbars",
`--remote-debugging-port=${port}`,
`--window-size=${w},${h}`,
"--user-data-dir=/tmp/beaver-preview-chrome",
"about:blank",
],
{ stderr: "ignore", stdout: "ignore" }
);
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
let targets: { type: string; webSocketDebuggerUrl: string }[] = [];
for (let i = 0; i < 40; i += 1) {
try {
// biome-ignore lint/performance/noAwaitInLoops: polling until Chrome is up
targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
if (targets.length) {
break;
}
} catch {
/* not up yet */
}
await wait(250);
}
const page = targets.find((t) => t.type === "page");
if (!page) {
proc.kill();
throw new Error("no page target");
}
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((resolve) => {
ws.onopen = resolve;
});
let id = 0;
const pending = new Map<number, (v: { result?: { data?: string } }) => void>();
ws.onmessage = (m) => {
const d = JSON.parse(String(m.data));
if (d.id && pending.has(d.id)) {
pending.get(d.id)?.(d);
pending.delete(d.id);
}
};
const send = (method: string, params: Record<string, unknown> = {}) =>
new Promise<{ result?: { data?: string } }>((resolve) => {
id += 1;
pending.set(id, resolve);
ws.send(JSON.stringify({ id, method, params }));
});
await send("Emulation.setDeviceMetricsOverride", {
deviceScaleFactor: 2,
height: Number(h),
mobile: Number(w) < 500,
width: Number(w),
});
await send("Page.enable");
await send("Page.navigate", { url });
await wait(2500);
if (process.env.EVAL) {
const evaluated = await send("Runtime.evaluate", {
expression: process.env.EVAL,
returnByValue: true,
});
console.log(
JSON.stringify(
(evaluated.result as { result?: { value?: unknown } }).result?.value
)
);
await wait(400);
}
const shot = await send("Page.captureScreenshot", { format: "png" });
await Bun.write(out, Buffer.from(shot.result?.data ?? "", "base64"));
console.log(`wrote ${out}`);
ws.close();
proc.kill();