Compare commits

..
11 Commits
Author SHA1 Message Date
hh 91699a382e feat(view,tests): query blocks stay code in text the agent did not write 2026-10-13 01:23:49 +02:00
hh 43d1e1a4a3 feat(settings,docs): vault subpath becomes the chats folder, panel screenshots in the readme 2026-09-05 04:56:03 +02:00
hh 223560c8da fix(view): follow the keyboard through the navbar hiding behind it
Same two misses as the calendar's: the four-frame watch stopped before
Obsidian hid its navbar - a second move of the ground, after the keyboard's
own - and a trigger that arrived while a watch was running was dropped rather
than restarting it. The watch now needs twelve steady frames, restarts on
every trigger, and listens to Obsidian's Capacitor keyboard events too.
2026-09-05 01:15:11 +02:00
hh 70cf985aa1 refactor(inset): the bottom-inset arithmetic becomes testable, and is tested
The measurement lived inside the view, where the only way to check it was to
run Obsidian on a phone. It moves to a pure `insetFor(leaf, bars, visible)`
against the numbers a phone reports - including the case the events hid: while
the keyboard is up the navbar is behind it, so the deeper of the two is the
inset and adding them would push the composer twice as far as it must go.
2026-09-05 00:49:54 +02:00
hh 413d68fc87 fix(view): the inset chases the keyboard by frame, and comes back when it goes
Two fixed delays into the keyboard's animation is not where it ends: the
composer read the viewport mid-slide, and on the way out - when the navbar
comes back - nothing re-measured at all, so the panel stayed pushed up or
sat back under the bar. In a sidedock drawer no resize event fires either,
so a phone's keyboard moved nothing there.

The inset is now re-measured every frame until the reading holds still, and
blur is a trigger as much as focus. `inset()` returns what it wrote so the
loop can tell it has settled rather than counting milliseconds.
2026-09-05 00:47:44 +02:00
hh a04ad0094f fix(css): an icon-only grid button centres its icon again
Obsidian styles every `button` with `justify-content: center`, so the reset
answers with `flex-start` - which also lands on a grid button and overrides
the inline half of its `place-items: center`. The rail's connection dot came
out pinned 12px left of centre inside its own hit area.
2026-09-05 00:28:50 +02:00
hh cc46abc34f fix(view,panel): the composer clears the navbar and the keyboard, panel opens on the master
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.
2026-09-05 00:20:33 +02:00
hh a76ee29255 test(smoke): subagent tool calls read as Agent 2026-09-04 20:24:02 +02:00
hh 68870864a6 build: d3-force for the shared graph 2026-09-02 04:35:13 +02:00
hh 0887728a8c feat(preview): stamped history for message times 2026-09-02 04:24:20 +02:00
hh cec5aef98c feat(panel): island, rail by day, companion mode beside a deep note, cached index and threads, obsidian skin for strips 2026-09-02 03:48:42 +02:00
23 changed files with 620 additions and 63 deletions

No files matched your search

+1
View File
@@ -18,3 +18,4 @@ data.json
.DS_Store
.vscode/
.idea/
preview/shots/
+6 -2
View File
@@ -7,6 +7,10 @@ Two things:
- **The panel**: Beaver's conversations next to your notes - the master thread, branches, deep chats - with the live tree of tool calls and subagents, the composer, and the same actions the admin has (branch with a seed, return a branch to Telegram or hide it, the memory flag, close with a digest). A narrow sidedock column, a full tab, or the phone screen. It follows the active note's `conversation_id` unless you pin it.
- **Send the current note** to Beaver's markdown frontend (`POST /chat/stream`) and stream the agent's reply back into the buffer.
![The panel as a tab: the conversation rail on the left, the master thread with its tool calls on the right](docs/panel-tab.webp)
![The panel as a column in the right sidedock, next to the note](docs/panel-column.webp)
## Commands
- **Beaver: Open panel in a tab** - the wide layout with the conversation rail on the left (also the ribbon icon).
@@ -20,7 +24,7 @@ The UI is `beaver-gateway/ui/src/lib/panel`, compiled into this plugin by `esbui
Theme: `src/tailwind.css` maps the panel's tokens onto Obsidian's variables on `.beaver-root`, exactly as beaver-calendar does with `.bcal-root` - the app's font, accent, radii and the current theme, light or dark. Menus and dialogs portal to a `.beaver-root.beaver-portal` layer on `body`, above the sidedocks.
Markdown in the thread goes through Obsidian's `MarkdownRenderer`: `[[links]]` open the note, external links open the browser. A deep chat's row offers **Open note** (its markdown binding, under the vault subpath).
Markdown in the thread goes through Obsidian's `MarkdownRenderer`: `[[links]]` open the note, external links open the browser. A deep chat's row offers **Open note** (its markdown binding, under the chats folder).
Layout: below 48rem of panel width the conversation switcher sits in the header (kind, title, live dot, follow toggle); wider than that the grouped rail (master, branches, deep chats, jobs) sits on the left. Right-click a row for its actions; on touch the `…` button is always visible. `↑`/`↓` walk the rows, `/` jumps to the search, `⌘↩` sends.
@@ -29,7 +33,7 @@ Layout: below 48rem of panel width the conversation switcher sits in the header
- **Base URL** - markdown frontend root, e.g. `https://host/md`.
- **API origin** - where `/api/…` lives; empty derives it from the base URL (`…/md` → `…`).
- **Bearer token** - `messages` scope for sending, `api` scope for the panel (or a `*` bootstrap token).
- **Vault subpath** - the folder in this vault that maps to the gateway's vault root.
- **Chats folder** - the folder in this vault where the deep chats live, the one the gateway's markdown frontend writes to. Paths the gateway hands out are relative to it.
Changing settings remounts open panels.
+9
View File
@@ -13,6 +13,7 @@
"bits-ui": "^2.19.0",
"builtin-modules": "^5.3.0",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
"dompurify": "^3.4.14",
"esbuild": "^0.28.2",
"esbuild-svelte": "^0.9.5",
@@ -347,6 +348,14 @@
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
"d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
"d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+1
View File
@@ -25,6 +25,7 @@
"bits-ui": "^2.19.0",
"builtin-modules": "^5.3.0",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
"dompurify": "^3.4.14",
"esbuild": "^0.28.2",
"esbuild-svelte": "^0.9.5",
+11 -7
View File
@@ -54,21 +54,16 @@ async function main(): Promise<void> {
mount(App, {
props: {
app: app as never,
chatsFolder: () => "💬 чаты",
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"]')
@@ -104,13 +99,22 @@ async function main(): Promise<void> {
}
if (scene.startsWith("activity")) {
for (const trigger of document.querySelectorAll<HTMLButtonElement>(
'[data-slot="tabs-trigger"]'
".rule-word"
)) {
if (trigger.textContent?.trim() === "Activity") {
press(trigger);
}
}
}
if (scene.startsWith("context")) {
for (const trigger of document.querySelectorAll<HTMLButtonElement>(
".rule-word"
)) {
if (trigger.textContent?.trim() === "Context") {
press(trigger);
}
}
}
if (scene.startsWith("branch")) {
document
.querySelector<HTMLButtonElement>(
+118 -3
View File
@@ -8,7 +8,12 @@ import type {
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const now = Date.now();
// 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";
@@ -23,6 +28,7 @@ function summary(
): ConversationSummary {
return {
agent: "beaver-dispatcher",
context_tokens: 84_000,
created_at: ago(9 * HOUR),
flags: {},
last_activity_at: ago(4 * MINUTE),
@@ -66,6 +72,7 @@ const ROWS: ConversationSummary[] = [
status: "done",
text: "правило для 62990 добавлено, проверь снаружи",
},
parent: MASTER,
parent_row: 1,
title: "ufw на dell",
}),
@@ -73,6 +80,7 @@ const ROWS: ConversationSummary[] = [
id: BRANCH_BLASTER,
kind: "branch",
last_activity_at: ago(3 * HOUR),
parent: MASTER,
parent_row: 1,
pending_question: true,
title: "бластер - что заказать",
@@ -203,9 +211,24 @@ const INFO: Record<string, Partial<ConversationInfo>> = {
[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: "с утра: дневник приехал? что в хендауте",
role: "user",
service:
"[envelope - background signal, not a message; react only if it bears on the question]\ntime: 2026-08-29 06:04 (Warsaw)\nvault, changed since 05:09: 📅 дни/2026-08-29.md (+4)\n--- 📅 дни/2026-08-29.md, added lines only ---\n+ # планы\n+ просрочены:\n+ ```tasks\n+ not done\n+ 06:04 - пожалуй пойду спать",
},
{
content:
"Приехал. В [[2026-08-28|хендауте]] два хвоста: **бластер** ждёт выбора модели, и **ufw на dell** - порт 62990 снаружи молчит. Ветка по ufw открыта, см. [[ufw на dell]].",
@@ -214,6 +237,11 @@ const HISTORY: Record<string, HistoryMessage[]> = {
{ content: "ок, гони ufw", role: "user" },
{
content: [
{
thinking:
"The firewall rule and the proxy config can disagree in two ways: the port is closed, or nothing listens behind it. Reading the Caddy site first tells me which port the outside is supposed to reach at all.",
type: "thinking",
},
{
text: "Смотрю правила через `ssh dell` и сверяю с `site.caddy`.",
type: "text",
@@ -224,6 +252,12 @@ const HISTORY: Record<string, HistoryMessage[]> = {
name: "Bash",
type: "tool_use",
},
{
id: "tu_say",
input: { text: "Нашёл: снаружи должен отвечать 443, не 62990." },
name: "mcp__gateway__say",
type: "tool_use",
},
],
role: "assistant",
},
@@ -292,6 +326,87 @@ export class FakeClient extends ApiClient {
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",
@@ -303,7 +418,7 @@ export class FakeClient extends ApiClient {
pattern: /^\/api\/conversations\/([^/]+)$/,
},
{
handle: ([, id]) => ({ id, messages: HISTORY[id] ?? [] }),
handle: ([, id]) => ({ id, messages: stamped(HISTORY[id] ?? []) }),
method: "GET",
pattern: /^\/api\/conversations\/([^/]+)\/history$/,
},
+47
View File
@@ -0,0 +1,47 @@
/**
* How much of a leaf's bottom is covered by something the operator cannot
* move: Obsidian's desktop status bar, a phone's navbar (which paints over a
* leaf rather than shortening it), and the software keyboard, which shrinks
* the visual viewport while the layout keeps its full height.
*
* Pure on purpose - the view feeds it live rectangles, the tests feed it the
* numbers a phone actually reports.
*/
export interface Rect {
bottom: number;
height: number;
left: number;
right: number;
top: number;
}
export interface Visible {
/** `visualViewport.height`: what is left once the keyboard is up. */
height: number;
/** `visualViewport.offsetTop`: where the visible area starts. */
offsetTop: number;
}
export function insetFor(
leaf: Rect,
bars: Rect[],
visible: Visible | null
): number {
if (leaf.height <= 0) {
return 0;
}
let overlap = 0;
for (const bar of bars) {
// A bar that does not sit over this leaf horizontally covers nothing of
// it - the desktop status bar next to a left-hand sidedock, say.
if (bar.height > 0 && bar.left < leaf.right && bar.right > leaf.left) {
overlap = Math.max(overlap, leaf.bottom - bar.top);
}
}
if (visible) {
const floor = visible.offsetTop + visible.height;
overlap = Math.max(overlap, leaf.bottom - floor);
}
return Math.max(0, Math.round(overlap));
}
+15 -6
View File
@@ -230,7 +230,16 @@ export default class BeaverPlugin extends Plugin {
}
async loadSettings(): Promise<void> {
this.settings = { ...DEFAULT_SETTINGS, ...(await this.loadData()) };
const stored = ((await this.loadData()) ??
{}) as Partial<BeaverSettings> & {
vaultSubpath?: string;
};
// The setting used to be called "vault subpath"; carry the value over.
const { vaultSubpath, ...rest } = stored;
this.settings = { ...DEFAULT_SETTINGS, ...rest };
if (vaultSubpath && !rest.chatsFolder) {
this.settings.chatsFolder = vaultSubpath;
}
}
async saveSettings(): Promise<void> {
@@ -431,16 +440,16 @@ export default class BeaverPlugin extends Plugin {
}
private gatewayFilename(file: TFile): string | null {
const subpath = this.settings.vaultSubpath;
if (!subpath) {
const folder = this.settings.chatsFolder;
if (!folder) {
return file.path;
}
const prefix = `${subpath}/`;
if (file.path === subpath || file.path.startsWith(prefix)) {
const prefix = `${folder}/`;
if (file.path === folder || file.path.startsWith(prefix)) {
return file.path.slice(prefix.length);
}
new Notice(
`Beaver: file ${file.path} is outside the configured vault subpath (${subpath})`,
`Beaver: file ${file.path} is outside the chats folder (${folder})`,
8000
);
return null;
+11 -8
View File
@@ -5,18 +5,20 @@ import type BeaverPlugin from "./main";
export interface BeaverSettings {
apiBaseUrl: string;
baseUrl: string;
// Folder in this vault where the deep chats live: the gateway's markdown
// frontend reads and writes there, so paths it hands out are relative to it.
chatsFolder: string;
token: string;
vaultSubpath: string;
}
export const DEFAULT_SETTINGS: BeaverSettings = {
apiBaseUrl: "",
baseUrl: "http://localhost:62993",
chatsFolder: "",
token: "",
vaultSubpath: "",
};
export function normalizeSubpath(raw: string): string {
export function normalizeFolder(raw: string): string {
return raw.trim().replace(/^\/+|\/+$/g, "");
}
@@ -93,17 +95,18 @@ export class BeaverSettingsTab extends PluginSettingTab {
});
new Setting(containerEl)
.setName("Vault subpath")
.setName("Chats folder")
.setDesc(
"Folder in this Obsidian vault that maps to the gateway's vault root. " +
"Leave empty if the two vault roots match. Example: `💬 чаты`."
"Folder in this vault where the deep chats live - the one the gateway's " +
"markdown frontend writes to. Leave empty if the chats sit at the vault root. " +
"Example: `💬 чаты`."
)
.addText((text) =>
text
.setPlaceholder("")
.setValue(this.plugin.settings.vaultSubpath)
.setValue(this.plugin.settings.chatsFolder)
.onChange(async (value) => {
this.plugin.settings.vaultSubpath = normalizeSubpath(value);
this.plugin.settings.chatsFolder = normalizeFolder(value);
await this.plugin.saveSettings();
})
);
+2
View File
@@ -5,4 +5,6 @@ export class PanelState {
selected = $state<string | null>(null);
follow = $state(true);
sourcePath = $state("");
// The selected conversation is the open note's own: show activity, not a copy.
companion = $state(false);
}
+90 -1
View File
@@ -47,10 +47,21 @@
--sidebar: var(--background-secondary);
--sidebar-accent: var(--background-modifier-hover);
--rack: var(--background-secondary);
--strip: var(--background-primary);
--attention: oklch(0.72 0.16 72);
--attention-foreground: oklch(0.5 0.14 70);
--shadow-lift:
0 6px 16px -10px rgb(0 0 0 / 0.35), 0 1px 2px rgb(0 0 0 / 0.08);
--shadow-float:
0 20px 50px -20px rgb(0 0 0 / 0.5), 0 2px 6px rgb(0 0 0 / 0.1);
--radius: 0.45rem;
}
.theme-dark .beaver-root {
--attention: oklch(0.8 0.15 75);
--attention-foreground: oklch(0.85 0.14 78);
--strip: var(--background-primary-alt);
--note: oklch(0.84 0.14 88);
--warn: oklch(0.8 0.13 75);
--status-done: oklch(0.74 0.14 155);
@@ -75,6 +86,12 @@
--color-note: var(--note);
--color-sidebar: var(--sidebar);
--color-sidebar-accent: var(--sidebar-accent);
--color-rack: var(--rack);
--color-strip: var(--strip);
--color-attention: var(--attention);
--color-attention-foreground: var(--attention-foreground);
--shadow-lift: var(--shadow-lift);
--shadow-float: var(--shadow-float);
--color-status-new: var(--status-new);
--color-status-done: var(--status-done);
@@ -178,10 +195,18 @@
}
.beaver-root.beaver-root button {
text-align: inherit;
justify-content: flex-start;
width: auto;
text-align: left;
cursor: pointer;
}
/* A grid button centres by `place-items`, which the rule above would override
for its inline axis: an icon-only button ends up pinned to its left edge. */
.beaver-root.beaver-root button:is(.grid, .inline-grid) {
justify-content: initial;
}
.beaver-root.beaver-root button:hover {
background: transparent;
}
@@ -397,3 +422,67 @@
font-weight: var(--font-medium, 500) !important;
}
}
/* Strips and the island are buttons here; the reset above flattens buttons,
so the vocabulary is restated at the reset's own specificity. */
.beaver-root.beaver-root .strip {
display: grid;
grid-template-columns: 1.5rem minmax(0, 1fr) auto;
align-items: center;
justify-content: stretch;
justify-items: stretch;
width: 100%;
padding: 0.375rem 0.875rem 0.375rem 0.625rem;
text-align: left;
background: var(--strip);
border-bottom: 1px solid var(--border);
border-radius: 0;
}
.beaver-root.beaver-root .strip:hover,
.beaver-root.beaver-root .strip:focus-visible {
background: var(--strip);
box-shadow: var(--shadow-lift);
}
.beaver-root.beaver-root .strip:first-child {
border-top-left-radius: var(--radius-md);
border-top-right-radius: var(--radius-md);
}
.beaver-root.beaver-root .strip:last-child {
border-bottom: 0;
border-bottom-right-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.beaver-root.beaver-root .strip[aria-current="true"] {
background: color-mix(in oklab, var(--strip) 85%, var(--primary) 15%);
}
.beaver-root.beaver-root .strip[data-state="waiting"] {
box-shadow: var(--shadow-lift);
transform: translateX(0.5rem);
}
.beaver-root.beaver-root .strip.strip-child {
padding-left: 1.75rem;
}
.beaver-root.beaver-root button.island {
display: inline-flex;
gap: 0.625rem;
align-items: center;
height: 2.125rem;
padding: 0 0.875rem 0 0.75rem;
white-space: nowrap;
background: var(--strip);
border: 1px solid var(--border);
border-radius: 999px;
}
.beaver-root.beaver-root button.island:hover {
background: var(--strip);
}
.beaver-root.beaver-root button.island:disabled {
opacity: 1;
}
.beaver-root.beaver-root .bay-rack {
border: 1px solid var(--border);
border-radius: calc(var(--radius-md) + 1px);
}
.beaver-root.beaver-root .strip-child::before {
left: 1.125rem;
}
+14 -8
View File
@@ -5,6 +5,7 @@
import type { ApiClient } from "$lib/api/client";
import EmptyState from "$lib/components/empty-state.svelte";
import { Button } from "$lib/components/ui/button";
import { panelCache } from "$lib/panel/cache";
import { providePanelHost } from "$lib/panel/host";
import { ConversationIndex } from "$lib/panel/index.svelte";
import PanelShell from "$lib/panel/panel-shell.svelte";
@@ -13,14 +14,14 @@
interface Props {
app: ObsidianApp;
chatsFolder: () => string;
client: ApiClient | null;
component: Component;
onOpenSettings: () => void;
state: PanelState;
vaultSubpath: () => string;
}
let { app, client, component, onOpenSettings, state, vaultSubpath }: Props =
let { app, client, component, onOpenSettings, state, chatsFolder }: Props =
$props();
// Menus hang off body, not off the view: Obsidian's sidedocks paint above
@@ -36,17 +37,19 @@
// Props are read once: the view remounts the tree when settings change.
// svelte-ignore state_referenced_locally
providePanelHost(
obsidianHost({
const cache = panelCache(`beaver.panel:${client?.base ?? "none"}`);
const host = providePanelHost({
...obsidianHost({
app,
chatsFolder,
component,
sourcePath: () => state.sourcePath,
vaultSubpath,
})
);
}),
cache,
});
// svelte-ignore state_referenced_locally
const index = client ? new ConversationIndex(() => client) : null;
const index = client ? new ConversationIndex(() => client, cache) : null;
</script>
<BitsConfig defaultPortalTo={layer}>
@@ -54,7 +57,10 @@
{#if client && index}
<PanelShell
{client}
companion={state.companion}
fallbackToMaster
{index}
onOpenFile={(path) => host.openNote?.(`мета/бобер/${path}`)}
showFollow
bind:follow={state.follow}
bind:selected={state.selected}
+20 -7
View File
@@ -1,14 +1,27 @@
import type { App, Component } from "obsidian";
import { MarkdownRenderer, Platform } from "obsidian";
import { MarkdownPreviewRenderer, MarkdownRenderer, Platform } from "obsidian";
import { inertFences } from "$lib/panel/fences";
import type { PanelHost } from "$lib/panel/host";
export interface HostOptions {
app: App;
// Folder in this vault that maps to the gateway's vault root.
// Where the deep chats live in this vault; gateway paths are relative to it.
chatsFolder: () => string;
component: Component;
// The note the panel currently follows; link resolution starts from it.
sourcePath: () => string;
// Folder in this vault that maps to the gateway's vault root.
vaultSubpath: () => string;
}
const QUERY_LANGUAGES = new Set(["tasks", "dataview", "dataviewjs", "query"]);
function runs(language: string): boolean {
const registered = (
MarkdownPreviewRenderer as unknown as {
codeBlockPostProcessors?: Record<string, unknown>;
}
).codeBlockPostProcessors;
return QUERY_LANGUAGES.has(language) || language in (registered ?? {});
}
// The Obsidian side of the panel: markdown through the app's own renderer,
@@ -16,12 +29,12 @@ export interface HostOptions {
export function obsidianHost(options: HostOptions): PanelHost {
const { app, component } = options;
return {
markdown(node, text) {
markdown(node, text, { inert = false } = {}) {
let alive = true;
const staging = document.createElement("div");
MarkdownRenderer.render(
app,
text,
inert ? inertFences(text, runs) : text,
staging,
options.sourcePath(),
component
@@ -47,8 +60,8 @@ export function obsidianHost(options: HostOptions): PanelHost {
window.open(target);
},
openNote(path) {
const subpath = options.vaultSubpath();
const full = subpath ? `${subpath}/${path}` : path;
const folder = options.chatsFolder();
const full = folder ? `${folder}/${path}` : path;
app.workspace.openLinkText(full, "", false).catch(() => undefined);
},
touch: Platform.isMobile,
+110 -15
View File
@@ -1,12 +1,31 @@
import type { ViewStateResult, WorkspaceLeaf } from "obsidian";
import { ItemView, TFile } from "obsidian";
import { mount, unmount } from "svelte";
import type { Rect } from "./inset";
import { insetFor } from "./inset";
import type BeaverPlugin from "./main";
import { PanelState } from "./state.svelte";
import App from "./ui/app.svelte";
export const VIEW_TYPE_PANEL = "beaver-panel";
// Bars that paint over a leaf instead of shortening it. On a phone the navbar
// sits under every view, including the one filling the screen.
const BOTTOM_BARS = [".status-bar", ".mobile-navbar", ".mobile-toolbar"];
// The keyboard and the navbar both animate, and Obsidian hides the navbar
// *after* the keyboard is up: the inset is re-measured every frame until the
// reading has held still long enough to outlast both moves.
const STEADY_FRAMES = 12;
/** Obsidian's Capacitor keyboard events on mobile; absent on the desktop. */
const KEYBOARD_EVENTS = [
"keyboardWillShow",
"keyboardDidShow",
"keyboardWillHide",
"keyboardDidHide",
] as const;
interface SavedState {
follow?: unknown;
selected?: unknown;
@@ -19,6 +38,7 @@ export class PanelView extends ItemView {
readonly state = new PanelState();
private readonly plugin: BeaverPlugin;
private root: Record<string, unknown> | null = null;
private settling: number | null = null;
constructor(leaf: WorkspaceLeaf, plugin: BeaverPlugin) {
super(leaf);
@@ -75,29 +95,98 @@ export class PanelView extends ItemView {
}
})
);
this.registerEvent(this.app.workspace.on("resize", () => this.inset()));
this.registerEvent(this.app.workspace.on("resize", () => this.settle()));
this.registerEvent(
this.app.workspace.on("layout-change", () => this.inset())
this.app.workspace.on("layout-change", () => this.settle())
);
// The keyboard resizes the visual viewport only: no workspace event
// fires, so the composer would stay behind it without these two.
const vv = window.visualViewport;
if (vv) {
const onViewport = () => this.settle();
vv.addEventListener("resize", onViewport);
vv.addEventListener("scroll", onViewport);
this.register(() => {
vv.removeEventListener("resize", onViewport);
vv.removeEventListener("scroll", onViewport);
});
}
// Focus opens the keyboard, blur closes it; both animate, and on the way
// out the navbar comes back, so the leaf has to be re-measured after.
this.registerDomEvent(this.contentEl, "focusin", () => this.settle());
this.registerDomEvent(this.contentEl, "focusout", () => this.settle());
// Obsidian's own keyboard events (Capacitor, mobile only) fire before the
// animation and again as the navbar returns, which visualViewport alone
// does not always report.
for (const name of KEYBOARD_EVENTS) {
const onKeyboard = () => this.settle();
window.addEventListener(name, onKeyboard);
this.register(() => window.removeEventListener(name, onKeyboard));
}
this.follow(this.app.workspace.getActiveFile());
this.plugin.views.add(this);
this.mountApp();
requestAnimationFrame(() => this.inset());
this.settle();
return Promise.resolve();
}
// Obsidian's status bar floats over the bottom-right of the workspace; a
// leaf that reaches down there gets padding so the composer stays clear.
private inset(): void {
const bar = document.querySelector<HTMLElement>(".status-bar");
const box = this.contentEl.getBoundingClientRect();
let overlap = 0;
if (bar && box.height > 0) {
const b = bar.getBoundingClientRect();
const crosses = b.left < box.right && b.right > box.left;
overlap = crosses ? Math.max(0, box.bottom - b.top) : 0;
/**
* Re-measure until the viewport stops moving. The keyboard animates both
* ways and Obsidian hides its navbar while it is up, so a fixed delay lands
* mid-animation; a frame loop that stops once two readings agree does not
* care how long the animation takes, or whether any event fires at all -
* which is the case in a sidedock drawer.
*/
private settle(): void {
let last = Number.NaN;
let steady = 0;
// A fresh trigger restarts the watch rather than being dropped: the navbar
// coming back is a second move, after the first one has settled.
if (this.settling !== null) {
cancelAnimationFrame(this.settling);
}
this.contentEl.style.setProperty("--beaver-inset-bottom", `${overlap}px`);
const tick = () => {
const floor = this.inset();
steady = floor === last ? steady + 1 : 0;
last = floor;
if (steady >= STEADY_FRAMES) {
this.settling = null;
return;
}
this.settling = requestAnimationFrame(tick);
};
this.settling = requestAnimationFrame(tick);
this.register(() => {
if (this.settling !== null) {
cancelAnimationFrame(this.settling);
this.settling = null;
}
});
}
// Whatever floats over the bottom of this leaf, measured rather than
// guessed. Returns the inset it wrote, so the settle loop can tell when the
// reading stops changing; the arithmetic itself lives in ``inset.ts``.
private inset(): number {
const box = this.contentEl.getBoundingClientRect();
if (box.height <= 0) {
return Number.NaN;
}
const bars: Rect[] = [];
for (const selector of BOTTOM_BARS) {
const node = document.querySelector<HTMLElement>(selector);
if (node && node.offsetParent !== null) {
bars.push(node.getBoundingClientRect());
}
}
const vv = window.visualViewport;
const inset = insetFor(
box,
bars,
vv ? { height: vv.height, offsetTop: vv.offsetTop } : null
);
this.contentEl.style.setProperty("--beaver-inset-bottom", `${inset}px`);
return inset;
}
async onClose(): Promise<void> {
@@ -119,11 +208,17 @@ export class PanelView extends ItemView {
const id = this.plugin.conversationIdOf(file);
if (id) {
this.state.selected = id;
this.state.companion = !this.inTabs();
}
}
show(id: string): void {
this.state.selected = id;
this.state.companion = false;
}
private inTabs(): boolean {
return this.leaf.getRoot() === this.app.workspace.rootSplit;
}
// Settings changed: the client is built from them, so rebuild the tree.
@@ -136,11 +231,11 @@ export class PanelView extends ItemView {
this.root = mount(App, {
props: {
app: this.app,
chatsFolder: () => this.plugin.settings.chatsFolder,
client: this.plugin.apiClient(),
component: this,
onOpenSettings: () => this.plugin.openSettings(),
state: this.state,
vaultSubpath: () => this.plugin.settings.vaultSubpath,
},
target: this.contentEl,
});
+35
View File
@@ -0,0 +1,35 @@
// Evaluate an expression inside a running Obsidian started with
// --remote-debugging-port. Usage: bun t/cdp-eval.ts <port> "<js expression>"
const [port, expression] = process.argv.slice(2);
const targets = (await (
await fetch(`http://localhost:${port}/json`)
).json()) as {
type: string;
title: string;
webSocketDebuggerUrl: string;
}[];
const page = targets.find((t) => t.type === "page");
if (!page) {
throw new Error("no page target");
}
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((resolve) => ws.addEventListener("open", resolve));
const reply = new Promise<string>((resolve) => {
ws.addEventListener("message", (event) => {
const data = JSON.parse(String(event.data));
if (data.id === 1) {
resolve(
JSON.stringify(data.result?.result?.value ?? data.result ?? data)
);
}
});
});
ws.send(
JSON.stringify({
id: 1,
method: "Runtime.evaluate",
params: { awaitPromise: true, expression, returnByValue: true },
})
);
console.log(await reply);
ws.close();
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { inertFences } from "$lib/panel/fences";
const runs = (language: string) => ["tasks", "dataview"].includes(language);
describe("inertFences", () => {
it("turns a query block into code", () => {
expect(inertFences("```tasks\nnot done\n```", runs)).toBe(
"```text\nnot done\n```"
);
expect(inertFences("~~~Dataview\nlist\n~~~", runs)).toBe(
"~~~text\nlist\n~~~"
);
});
it("reaches a block opened inside a list item or a quote", () => {
expect(inertFences("+ ```tasks\n+ not done", runs)).toBe(
"+ ```text\n+ not done"
);
expect(inertFences("> 1. ````tasks {x}", runs)).toBe("> 1. ````text {x}");
});
it("leaves ordinary code and closing fences alone", () => {
const text = "```python\nprint(1)\n```\ninline `tasks` stays";
expect(inertFences(text, runs)).toBe(text);
});
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import type { Rect } from "../src/inset";
import { insetFor } from "../src/inset";
const rect = (over: Partial<Rect> = {}): Rect => ({
bottom: 780,
height: 780,
left: 0,
right: 390,
top: 0,
...over,
});
// A phone: 780 tall, a 56px navbar over the bottom, 340px of keyboard.
const NAVBAR = rect({ bottom: 780, height: 56, top: 724 });
describe("insetFor", () => {
it("clears nothing when the leaf stands alone", () => {
expect(insetFor(rect(), [], null)).toBe(0);
});
it("clears the navbar that paints over the leaf", () => {
expect(insetFor(rect(), [NAVBAR], null)).toBe(56);
});
it("clears the keyboard, which shortens no layout", () => {
expect(insetFor(rect(), [], { height: 440, offsetTop: 0 })).toBe(340);
});
it("takes the deeper of the two rather than their sum", () => {
// While the keyboard is up the navbar is behind it: adding them would
// push the composer twice as far as it has to go.
expect(insetFor(rect(), [NAVBAR], { height: 440, offsetTop: 0 })).toBe(340);
});
it("comes back to the navbar once the keyboard goes", () => {
expect(insetFor(rect(), [NAVBAR], { height: 780, offsetTop: 0 })).toBe(56);
});
it("ignores a bar that sits beside the leaf, not over it", () => {
// The desktop status bar floats bottom-right; a left-hand sidedock never
// reaches it.
const sidedock = rect({ right: 300 });
const statusBar = rect({
bottom: 780,
height: 20,
left: 700,
right: 980,
top: 760,
});
expect(insetFor(sidedock, [statusBar], null)).toBe(0);
});
it("measures from where the visible area starts, not from zero", () => {
// iOS scrolls the page up under the keyboard instead of resizing it.
expect(insetFor(rect(), [], { height: 440, offsetTop: 100 })).toBe(240);
});
it("says nothing about a leaf with no height yet", () => {
expect(insetFor(rect({ bottom: 0, height: 0 }), [NAVBAR], null)).toBe(0);
});
it("never returns a negative inset", () => {
const short = rect({ bottom: 300, height: 300 });
expect(insetFor(short, [], { height: 780, offsetTop: 0 })).toBe(0);
});
});
+6
View File
@@ -34,6 +34,12 @@ export const moment = () => undefined;
export const requestUrl = () =>
Promise.reject(new Error("no network in the stub"));
export const setIcon = () => undefined;
export const MarkdownPreviewRenderer = {
codeBlockPostProcessors: { tasks: () => undefined } as Record<
string,
unknown
>,
};
function escapeHtml(value: string): string {
return value
+21 -6
View File
@@ -137,11 +137,11 @@ const target = dom.window.document.body;
const instance = mount(App, {
props: {
app,
chatsFolder: () => "💬 чаты",
client,
component: new Component(),
onOpenSettings: () => {},
state,
vaultSubpath: () => "💬 чаты",
},
target,
});
@@ -161,7 +161,7 @@ check(
check("thread header shows the master", text().includes("Master · 29 Aug"));
check(
"the running turn's tools are on screen",
text().includes("Task") && text().includes("Bash")
text().includes("Agent") && text().includes("Bash")
);
check(
"history rendered through the host renderer",
@@ -191,6 +191,23 @@ check(
text().includes("Xiaomi Mi Blaster")
);
// Nothing picked - the panel just opened on a phone - lands on the master
// rather than on a prompt to pick something.
state.selected = null;
flushSync();
await settle();
flushSync();
check(
"with nothing picked it falls back to the master",
state.selected === MASTER,
String(state.selected)
);
state.selected = BRANCH_BLASTER;
flushSync();
await settle();
flushSync();
check("an explicit pick still wins", state.selected === BRANCH_BLASTER);
const composer = target.querySelector("textarea");
check("composer is there", composer !== null);
if (composer) {
@@ -226,10 +243,8 @@ flushSync();
await settle();
const rows = [...dom.window.document.querySelectorAll("[data-row]")];
check(
"switcher lists open conversations grouped",
rows.length >= 4 &&
text().includes("Branches") &&
text().includes("Deep chats"),
"switcher lists open conversations grouped by day",
rows.length >= 4 && text().includes("Today"),
`${rows.length}`
);
check(
+8
View File
@@ -89,4 +89,12 @@ describe("cascade against Obsidian", () => {
expect(source).not.toMatch(/@import "tailwindcss"\s*[;l]/);
expect(source).not.toMatch(/@import\s+"tailwindcss\/preflight/);
});
// Obsidian's own `button` rules are what the reset answers, but a grid
// button must keep centring itself or an icon-only one drifts left.
it("leaves grid buttons to their own placement", () => {
expect(source).toMatch(
/button:is\(\.grid, \.inline-grid\) \{\s*justify-content: initial/
);
});
});
+1
View File
@@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
$lib: new URL("../beaver-gateway/ui/src/lib", import.meta.url).pathname,
obsidian: new URL("./tests/obsidian-stub.ts", import.meta.url).pathname,
},
},