The inset only knew the desktop status bar, so on a phone the composer sat under the navbar - which paints over a leaf instead of shortening it - and the software keyboard hid it outright. Every bottom bar is measured now, and the keyboard is read off `visualViewport`: it shrinks the visible area without resizing the layout, so no workspace event fires and the panel had no way to know. Focus re-measures once iOS has finished animating the keys in. The panel asks the shell to fall back to the latest master, so opening it on a phone lands in the day's thread rather than on a prompt. The preview fixtures anchor at midday instead of the wall clock: spread over hours, a run just after midnight pushed half of them into yesterday and emptied the "Today" group the switcher smoke checks.
612 lines
17 KiB
TypeScript
612 lines
17 KiB
TypeScript
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;
|
||
// Anchored inside the day rather than at the wall clock: the fixtures are
|
||
// spread over hours, and a run just after midnight would push half of them
|
||
// into yesterday and empty the "Today" group the rail is checked against.
|
||
const wall = new Date();
|
||
const now =
|
||
wall.getHours() < 12 ? new Date(wall).setHours(12, 0, 0, 0) : wall.getTime();
|
||
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",
|
||
context_tokens: 84_000,
|
||
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: MASTER,
|
||
parent_row: 1,
|
||
title: "ufw на dell",
|
||
}),
|
||
summary({
|
||
id: BRANCH_BLASTER,
|
||
kind: "branch",
|
||
last_activity_at: ago(3 * HOUR),
|
||
parent: MASTER,
|
||
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 },
|
||
};
|
||
|
||
// Ten minutes apart, ending now: enough to show a time on every message.
|
||
const STEP_MS = 600_000;
|
||
function stamped(messages: HistoryMessage[]): HistoryMessage[] {
|
||
const end = Date.now();
|
||
return messages.map((message, index) => ({
|
||
...message,
|
||
ts: new Date(end - (messages.length - index) * STEP_MS).toISOString(),
|
||
}));
|
||
}
|
||
|
||
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: [], files: [], query: "" }),
|
||
method: "GET",
|
||
pattern: /^\/api\/search/,
|
||
},
|
||
{
|
||
handle: () => ({ edges: [], nodes: [], root: "" }),
|
||
method: "GET",
|
||
pattern: /^\/api\/vault\/graph/,
|
||
},
|
||
{
|
||
handle: ([, id]) => ({
|
||
agent: {
|
||
effort: "high",
|
||
model: "claude-opus-5",
|
||
name: "beaver-dispatcher",
|
||
},
|
||
context_tokens: 84_000,
|
||
files: [
|
||
{
|
||
last_at: ago(MINUTE),
|
||
other: 0,
|
||
path: "📅 дни/2026-08-29.md",
|
||
reads: 1,
|
||
writes: 0,
|
||
},
|
||
{
|
||
last_at: ago(5 * MINUTE),
|
||
other: 0,
|
||
path: "👤 люди/Марта Ветрова.md",
|
||
reads: 2,
|
||
writes: 1,
|
||
},
|
||
],
|
||
history_tokens_est: 60_000,
|
||
id,
|
||
kind: "master",
|
||
prompt: {
|
||
bytes: 24_000,
|
||
granules: [
|
||
{
|
||
bytes: 9000,
|
||
path: "мета/бобер/промпты/гранулы/голос/role.md",
|
||
tag: "role",
|
||
tokens_est: 2800,
|
||
},
|
||
{
|
||
bytes: 15_000,
|
||
path: "мета/бобер/промпты/гранулы/диспетчер.md",
|
||
tag: "dispatcher",
|
||
tokens_est: 4700,
|
||
},
|
||
],
|
||
tokens_est: 7500,
|
||
},
|
||
skills: [
|
||
{
|
||
path: "мета/бобер/скиллы/общие",
|
||
set: "общие",
|
||
skills: [
|
||
{
|
||
description: "серверы и стеки",
|
||
name: "ops",
|
||
path: "мета/бобер/скиллы/общие/ops",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
skills_tokens_est: 20,
|
||
tool_counts: { Bash: 1, Read: 3 },
|
||
tools: {
|
||
allowed: null,
|
||
disallowed: [],
|
||
gateway: ["say", "spawn"],
|
||
mcps: ["komodo"],
|
||
},
|
||
turns: 4,
|
||
}),
|
||
method: "GET",
|
||
pattern: /^\/api\/conversations\/([^/]+)\/context/,
|
||
},
|
||
{
|
||
handle: () => ({ conversations: this.rows }),
|
||
method: "GET",
|
||
pattern: /^\/api\/conversations$/,
|
||
},
|
||
{
|
||
handle: ([, id]) => this.info(id),
|
||
method: "GET",
|
||
pattern: /^\/api\/conversations\/([^/]+)$/,
|
||
},
|
||
{
|
||
handle: ([, id]) => ({ id, messages: stamped(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);
|
||
}
|
||
}
|
||
}
|