Compare commits

...
2 Commits
Author SHA1 Message Date
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
10 changed files with 68 additions and 32 deletions

No files matched your search

+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. - **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. - **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 ## Commands
- **Beaver: Open panel in a tab** - the wide layout with the conversation rail on the left (also the ribbon icon). - **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. 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. 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`. - **Base URL** - markdown frontend root, e.g. `https://host/md`.
- **API origin** - where `/api/…` lives; empty derives it from the base URL (`…/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). - **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. Changing settings remounts open panels.
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+1 -1
View File
@@ -54,11 +54,11 @@ async function main(): Promise<void> {
mount(App, { mount(App, {
props: { props: {
app: app as never, app: app as never,
chatsFolder: () => "💬 чаты",
client: scene === "offline" ? null : client, client: scene === "offline" ? null : client,
component: new Component() as never, component: new Component() as never,
onOpenSettings: () => console.log("settings"), onOpenSettings: () => console.log("settings"),
state, state,
vaultSubpath: () => "💬 чаты",
}, },
target: document.getElementById("app") as HTMLElement, target: document.getElementById("app") as HTMLElement,
}); });
+15 -6
View File
@@ -230,7 +230,16 @@ export default class BeaverPlugin extends Plugin {
} }
async loadSettings(): Promise<void> { 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> { async saveSettings(): Promise<void> {
@@ -431,16 +440,16 @@ export default class BeaverPlugin extends Plugin {
} }
private gatewayFilename(file: TFile): string | null { private gatewayFilename(file: TFile): string | null {
const subpath = this.settings.vaultSubpath; const folder = this.settings.chatsFolder;
if (!subpath) { if (!folder) {
return file.path; return file.path;
} }
const prefix = `${subpath}/`; const prefix = `${folder}/`;
if (file.path === subpath || file.path.startsWith(prefix)) { if (file.path === folder || file.path.startsWith(prefix)) {
return file.path.slice(prefix.length); return file.path.slice(prefix.length);
} }
new Notice( new Notice(
`Beaver: file ${file.path} is outside the configured vault subpath (${subpath})`, `Beaver: file ${file.path} is outside the chats folder (${folder})`,
8000 8000
); );
return null; return null;
+11 -8
View File
@@ -5,18 +5,20 @@ import type BeaverPlugin from "./main";
export interface BeaverSettings { export interface BeaverSettings {
apiBaseUrl: string; apiBaseUrl: string;
baseUrl: 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; token: string;
vaultSubpath: string;
} }
export const DEFAULT_SETTINGS: BeaverSettings = { export const DEFAULT_SETTINGS: BeaverSettings = {
apiBaseUrl: "", apiBaseUrl: "",
baseUrl: "http://localhost:62993", baseUrl: "http://localhost:62993",
chatsFolder: "",
token: "", token: "",
vaultSubpath: "",
}; };
export function normalizeSubpath(raw: string): string { export function normalizeFolder(raw: string): string {
return raw.trim().replace(/^\/+|\/+$/g, ""); return raw.trim().replace(/^\/+|\/+$/g, "");
} }
@@ -93,17 +95,18 @@ export class BeaverSettingsTab extends PluginSettingTab {
}); });
new Setting(containerEl) new Setting(containerEl)
.setName("Vault subpath") .setName("Chats folder")
.setDesc( .setDesc(
"Folder in this Obsidian vault that maps to the gateway's vault root. " + "Folder in this vault where the deep chats live - the one the gateway's " +
"Leave empty if the two vault roots match. Example: `💬 чаты`." "markdown frontend writes to. Leave empty if the chats sit at the vault root. " +
"Example: `💬 чаты`."
) )
.addText((text) => .addText((text) =>
text text
.setPlaceholder("") .setPlaceholder("")
.setValue(this.plugin.settings.vaultSubpath) .setValue(this.plugin.settings.chatsFolder)
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.vaultSubpath = normalizeSubpath(value); this.plugin.settings.chatsFolder = normalizeFolder(value);
await this.plugin.saveSettings(); await this.plugin.saveSettings();
}) })
); );
+3 -3
View File
@@ -14,14 +14,14 @@
interface Props { interface Props {
app: ObsidianApp; app: ObsidianApp;
chatsFolder: () => string;
client: ApiClient | null; client: ApiClient | null;
component: Component; component: Component;
onOpenSettings: () => void; onOpenSettings: () => void;
state: PanelState; state: PanelState;
vaultSubpath: () => string;
} }
let { app, client, component, onOpenSettings, state, vaultSubpath }: Props = let { app, client, component, onOpenSettings, state, chatsFolder }: Props =
$props(); $props();
// Menus hang off body, not off the view: Obsidian's sidedocks paint above // Menus hang off body, not off the view: Obsidian's sidedocks paint above
@@ -41,9 +41,9 @@
const host = providePanelHost({ const host = providePanelHost({
...obsidianHost({ ...obsidianHost({
app, app,
chatsFolder,
component, component,
sourcePath: () => state.sourcePath, sourcePath: () => state.sourcePath,
vaultSubpath,
}), }),
cache, cache,
}); });
+5 -4
View File
@@ -4,11 +4,12 @@ import type { PanelHost } from "$lib/panel/host";
export interface HostOptions { export interface HostOptions {
app: App; 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; component: Component;
// The note the panel currently follows; link resolution starts from it. // The note the panel currently follows; link resolution starts from it.
sourcePath: () => string; sourcePath: () => string;
// Folder in this vault that maps to the gateway's vault root.
vaultSubpath: () => string;
} }
// The Obsidian side of the panel: markdown through the app's own renderer, // The Obsidian side of the panel: markdown through the app's own renderer,
@@ -47,8 +48,8 @@ export function obsidianHost(options: HostOptions): PanelHost {
window.open(target); window.open(target);
}, },
openNote(path) { openNote(path) {
const subpath = options.vaultSubpath(); const folder = options.chatsFolder();
const full = subpath ? `${subpath}/${path}` : path; const full = folder ? `${folder}/${path}` : path;
app.workspace.openLinkText(full, "", false).catch(() => undefined); app.workspace.openLinkText(full, "", false).catch(() => undefined);
}, },
touch: Platform.isMobile, touch: Platform.isMobile,
+26 -7
View File
@@ -13,9 +13,18 @@ export const VIEW_TYPE_PANEL = "beaver-panel";
// sits under every view, including the one filling the screen. // sits under every view, including the one filling the screen.
const BOTTOM_BARS = [".status-bar", ".mobile-navbar", ".mobile-toolbar"]; const BOTTOM_BARS = [".status-bar", ".mobile-navbar", ".mobile-toolbar"];
// The keyboard and the navbar both animate. Rather than guess how long that // The keyboard and the navbar both animate, and Obsidian hides the navbar
// takes, the inset is re-measured every frame until the reading holds still. // *after* the keyboard is up: the inset is re-measured every frame until the
const STEADY_FRAMES = 4; // 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 { interface SavedState {
follow?: unknown; follow?: unknown;
@@ -106,6 +115,14 @@ export class PanelView extends ItemView {
// out the navbar comes back, so the leaf has to be re-measured after. // 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, "focusin", () => this.settle());
this.registerDomEvent(this.contentEl, "focusout", () => 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.follow(this.app.workspace.getActiveFile());
this.plugin.views.add(this); this.plugin.views.add(this);
this.mountApp(); this.mountApp();
@@ -121,11 +138,13 @@ export class PanelView extends ItemView {
* which is the case in a sidedock drawer. * which is the case in a sidedock drawer.
*/ */
private settle(): void { private settle(): void {
if (this.settling !== null) {
return;
}
let last = Number.NaN; let last = Number.NaN;
let steady = 0; 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);
}
const tick = () => { const tick = () => {
const floor = this.inset(); const floor = this.inset();
steady = floor === last ? steady + 1 : 0; steady = floor === last ? steady + 1 : 0;
@@ -212,11 +231,11 @@ export class PanelView extends ItemView {
this.root = mount(App, { this.root = mount(App, {
props: { props: {
app: this.app, app: this.app,
chatsFolder: () => this.plugin.settings.chatsFolder,
client: this.plugin.apiClient(), client: this.plugin.apiClient(),
component: this, component: this,
onOpenSettings: () => this.plugin.openSettings(), onOpenSettings: () => this.plugin.openSettings(),
state: this.state, state: this.state,
vaultSubpath: () => this.plugin.settings.vaultSubpath,
}, },
target: this.contentEl, target: this.contentEl,
}); });
+1 -1
View File
@@ -137,11 +137,11 @@ const target = dom.window.document.body;
const instance = mount(App, { const instance = mount(App, {
props: { props: {
app, app,
chatsFolder: () => "💬 чаты",
client, client,
component: new Component(), component: new Component(),
onOpenSettings: () => {}, onOpenSettings: () => {},
state, state,
vaultSubpath: () => "💬 чаты",
}, },
target, target,
}); });