Compare commits
17
Commits
04400c5076
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43d1e1a4a3 | ||
|
|
223560c8da | ||
|
|
70cf985aa1 | ||
|
|
413d68fc87 | ||
|
|
a04ad0094f | ||
|
|
cc46abc34f | ||
|
|
a76ee29255 | ||
|
|
68870864a6 | ||
|
|
0887728a8c | ||
|
|
cec5aef98c | ||
|
|
126e8b0451 | ||
|
|
b619c75119 | ||
|
|
353ee143f6 | ||
|
|
ab22efff7b | ||
|
|
b46dab194b | ||
|
|
8cefc54552 | ||
|
|
8e74439b4d |
No files matched your search
@@ -4,6 +4,7 @@ node_modules/
|
|||||||
# build output (regenerated by `make install` / `make zip`)
|
# build output (regenerated by `make install` / `make zip`)
|
||||||
main.js
|
main.js
|
||||||
styles.css
|
styles.css
|
||||||
|
.tailwind.css
|
||||||
*.zip
|
*.zip
|
||||||
preview/bundle.js
|
preview/bundle.js
|
||||||
tests/.entry.ts
|
tests/.entry.ts
|
||||||
@@ -17,3 +18,4 @@ data.json
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
preview/shots/
|
||||||
@@ -7,10 +7,14 @@ 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- **Beaver: Open panel** - the right sidedock (also the ribbon icon).
|
- **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.
|
- **Beaver: Open panel** - the narrow column in the right sidedock; a tab and a column can be open at once, each command reuses its own.
|
||||||
- **Beaver: Open chat in panel** - the active note has `conversation_id` in its frontmatter; the panel shows that conversation.
|
- **Beaver: Open chat in panel** - the active note has `conversation_id` in its frontmatter; the panel shows that conversation.
|
||||||
- **Beaver: Send using selected agent** / **Send using different agent** - the note has `agent:` in its frontmatter.
|
- **Beaver: Send using selected agent** / **Send using different agent** - the note has `agent:` in its frontmatter.
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -38,7 +42,7 @@ Changing settings remounts open panels.
|
|||||||
```
|
```
|
||||||
bun install
|
bun install
|
||||||
bun run dev # css + esbuild watch
|
bun run dev # css + esbuild watch
|
||||||
bun run build # svelte-check + css (minified) + bundle
|
bun run build # svelte-check + css (minified, scoped) + bundle
|
||||||
bun run test # vitest (theme rules) + tests/smoke.mjs (jsdom, fake gateway)
|
bun run test # vitest (theme rules) + tests/smoke.mjs (jsdom, fake gateway)
|
||||||
bun run preview # the panel in a browser, no Obsidian: http://localhost:4174/#<scene>
|
bun run preview # the panel in a browser, no Obsidian: http://localhost:4174/#<scene>
|
||||||
make check # ultracite + svelte-check
|
make check # ultracite + svelte-check
|
||||||
@@ -48,6 +52,8 @@ make zip
|
|||||||
|
|
||||||
Preview scenes (`#thread` default, `#switcher`, `#closed`, `#menu`, `#actions`, `#activity`, `#branch`, `#question`, `#deep`, `#empty`, `#offline`, `#stream`; suffix `-light` for the light theme). Width is the window: run headless Chrome with `--window-size=340,720` for the sidedock, `1100,720` for a tab, `390,800` for a phone. Set `VAULT=` or `THEME=` to preview against a community theme; `app.css` is read from the Obsidian install.
|
Preview scenes (`#thread` default, `#switcher`, `#closed`, `#menu`, `#actions`, `#activity`, `#branch`, `#question`, `#deep`, `#empty`, `#offline`, `#stream`; suffix `-light` for the light theme). Width is the window: run headless Chrome with `--window-size=340,720` for the sidedock, `1100,720` for a tab, `390,800` for a phone. Set `VAULT=` or `THEME=` to preview against a community theme; `app.css` is read from the Obsidian install.
|
||||||
|
|
||||||
|
`styles.css` is Tailwind output run through `scripts/scope-css.mjs`: every selector gets `:is(.beaver-root, .beaver-root *)` in front. Other Tailwind plugins (beaver-calendar for one) put their utilities into the same `utilities` cascade layer, and without the extra specificity whichever plugin loaded last would win - its `.hidden` over ours and the other way round.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
`make install VAULT=…` copies `manifest.json`, `main.js`, `styles.css`, `versions.json` into `<vault>/.obsidian/plugins/beaver`; then enable **Beaver** in Settings → Community plugins. On a phone, sync the four files with Obsidian Sync ("Installed community plugins") or any file sync, and point the settings at a URL the phone can reach.
|
`make install VAULT=…` copies `manifest.json`, `main.js`, `styles.css`, `versions.json` into `<vault>/.obsidian/plugins/beaver`; then enable **Beaver** in Settings → Community plugins. On a phone, sync the four files with Obsidian Sync ("Installed community plugins") or any file sync, and point the settings at a URL the phone can reach.
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"includes": ["tests/**", "preview/**"],
|
"includes": ["tests/**", "preview/**", "scripts/**"],
|
||||||
"linter": {
|
"linter": {
|
||||||
"rules": {
|
"rules": {
|
||||||
"performance": {
|
"performance": {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"bits-ui": "^2.19.0",
|
"bits-ui": "^2.19.0",
|
||||||
"builtin-modules": "^5.3.0",
|
"builtin-modules": "^5.3.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
"dompurify": "^3.4.14",
|
"dompurify": "^3.4.14",
|
||||||
"esbuild": "^0.28.2",
|
"esbuild": "^0.28.2",
|
||||||
"esbuild-svelte": "^0.9.5",
|
"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=="],
|
"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=="],
|
"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=="],
|
"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 |
+4
-3
@@ -6,10 +6,10 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun run check && bun run css -- --minify && node esbuild.config.mjs production",
|
"build": "bun run check && node scripts/css.mjs --minify && node esbuild.config.mjs production",
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.json --threshold error",
|
"check": "svelte-check --tsconfig ./tsconfig.json --threshold error",
|
||||||
"css": "tailwindcss -i src/tailwind.css -o styles.css",
|
"css": "node scripts/css.mjs",
|
||||||
"css:watch": "tailwindcss -i src/tailwind.css -o styles.css --watch",
|
"css:watch": "node scripts/css.mjs --watch",
|
||||||
"dev": "bun run css && node esbuild.config.mjs",
|
"dev": "bun run css && node esbuild.config.mjs",
|
||||||
"fix": "ultracite fix",
|
"fix": "ultracite fix",
|
||||||
"lint": "ultracite check",
|
"lint": "ultracite check",
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"bits-ui": "^2.19.0",
|
"bits-ui": "^2.19.0",
|
||||||
"builtin-modules": "^5.3.0",
|
"builtin-modules": "^5.3.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
"dompurify": "^3.4.14",
|
"dompurify": "^3.4.14",
|
||||||
"esbuild": "^0.28.2",
|
"esbuild": "^0.28.2",
|
||||||
"esbuild-svelte": "^0.9.5",
|
"esbuild-svelte": "^0.9.5",
|
||||||
|
|||||||
+16
-8
@@ -25,6 +25,7 @@ function press(element: Element | null | undefined): void {
|
|||||||
|
|
||||||
// Scenes for screenshots: open the page with a hash to land in a state.
|
// 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.
|
// Width comes from the window (``--window-size``), the state from here.
|
||||||
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one flat scene list
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const client = new FakeClient();
|
const client = new FakeClient();
|
||||||
const state = new PanelState();
|
const state = new PanelState();
|
||||||
@@ -33,7 +34,7 @@ async function main(): Promise<void> {
|
|||||||
if (scene === "empty") {
|
if (scene === "empty") {
|
||||||
state.selected = null;
|
state.selected = null;
|
||||||
}
|
}
|
||||||
if (scene === "question") {
|
if (scene === "question" || scene === "toggle") {
|
||||||
state.selected = BRANCH_BLASTER;
|
state.selected = BRANCH_BLASTER;
|
||||||
}
|
}
|
||||||
if (scene === "deep") {
|
if (scene === "deep") {
|
||||||
@@ -53,21 +54,16 @@ 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,
|
||||||
});
|
});
|
||||||
await wait(80);
|
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")) {
|
if (scene.startsWith("switcher")) {
|
||||||
document
|
document
|
||||||
.querySelector<HTMLButtonElement>('[aria-label="Switch conversation"]')
|
.querySelector<HTMLButtonElement>('[aria-label="Switch conversation"]')
|
||||||
@@ -95,18 +91,30 @@ async function main(): Promise<void> {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (scene.startsWith("toggle")) {
|
||||||
|
press(document.querySelector('[data-slot="switch"]'));
|
||||||
|
}
|
||||||
if (scene.startsWith("actions")) {
|
if (scene.startsWith("actions")) {
|
||||||
press(document.querySelector('[aria-label="Conversation actions"]'));
|
press(document.querySelector('[aria-label="Conversation actions"]'));
|
||||||
}
|
}
|
||||||
if (scene.startsWith("activity")) {
|
if (scene.startsWith("activity")) {
|
||||||
for (const trigger of document.querySelectorAll<HTMLButtonElement>(
|
for (const trigger of document.querySelectorAll<HTMLButtonElement>(
|
||||||
'[data-slot="tabs-trigger"]'
|
".rule-word"
|
||||||
)) {
|
)) {
|
||||||
if (trigger.textContent?.trim() === "Activity") {
|
if (trigger.textContent?.trim() === "Activity") {
|
||||||
press(trigger);
|
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")) {
|
if (scene.startsWith("branch")) {
|
||||||
document
|
document
|
||||||
.querySelector<HTMLButtonElement>(
|
.querySelector<HTMLButtonElement>(
|
||||||
|
|||||||
+101
-2
@@ -8,7 +8,12 @@ import type {
|
|||||||
|
|
||||||
const MINUTE = 60_000;
|
const MINUTE = 60_000;
|
||||||
const HOUR = 60 * MINUTE;
|
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();
|
const ago = (ms: number) => new Date(now - ms).toISOString();
|
||||||
|
|
||||||
export const MASTER = "c1master0000";
|
export const MASTER = "c1master0000";
|
||||||
@@ -23,6 +28,7 @@ function summary(
|
|||||||
): ConversationSummary {
|
): ConversationSummary {
|
||||||
return {
|
return {
|
||||||
agent: "beaver-dispatcher",
|
agent: "beaver-dispatcher",
|
||||||
|
context_tokens: 84_000,
|
||||||
created_at: ago(9 * HOUR),
|
created_at: ago(9 * HOUR),
|
||||||
flags: {},
|
flags: {},
|
||||||
last_activity_at: ago(4 * MINUTE),
|
last_activity_at: ago(4 * MINUTE),
|
||||||
@@ -66,6 +72,7 @@ const ROWS: ConversationSummary[] = [
|
|||||||
status: "done",
|
status: "done",
|
||||||
text: "правило для 62990 добавлено, проверь снаружи",
|
text: "правило для 62990 добавлено, проверь снаружи",
|
||||||
},
|
},
|
||||||
|
parent: MASTER,
|
||||||
parent_row: 1,
|
parent_row: 1,
|
||||||
title: "ufw на dell",
|
title: "ufw на dell",
|
||||||
}),
|
}),
|
||||||
@@ -73,6 +80,7 @@ const ROWS: ConversationSummary[] = [
|
|||||||
id: BRANCH_BLASTER,
|
id: BRANCH_BLASTER,
|
||||||
kind: "branch",
|
kind: "branch",
|
||||||
last_activity_at: ago(3 * HOUR),
|
last_activity_at: ago(3 * HOUR),
|
||||||
|
parent: MASTER,
|
||||||
parent_row: 1,
|
parent_row: 1,
|
||||||
pending_question: true,
|
pending_question: true,
|
||||||
title: "бластер - что заказать",
|
title: "бластер - что заказать",
|
||||||
@@ -203,6 +211,16 @@ const INFO: Record<string, Partial<ConversationInfo>> = {
|
|||||||
[FORK_MERGE]: { parent: MASTER },
|
[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[]> = {
|
const HISTORY: Record<string, HistoryMessage[]> = {
|
||||||
[MASTER]: [
|
[MASTER]: [
|
||||||
{ content: "с утра: дневник приехал? что в хендауте", role: "user" },
|
{ content: "с утра: дневник приехал? что в хендауте", role: "user" },
|
||||||
@@ -292,6 +310,87 @@ export class FakeClient extends ApiClient {
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
pattern: /^\/api\/agents$/,
|
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 }),
|
handle: () => ({ conversations: this.rows }),
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -303,7 +402,7 @@ export class FakeClient extends ApiClient {
|
|||||||
pattern: /^\/api\/conversations\/([^/]+)$/,
|
pattern: /^\/api\/conversations\/([^/]+)$/,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
handle: ([, id]) => ({ id, messages: HISTORY[id] ?? [] }),
|
handle: ([, id]) => ({ id, messages: stamped(HISTORY[id] ?? []) }),
|
||||||
method: "GET",
|
method: "GET",
|
||||||
pattern: /^\/api\/conversations\/([^/]+)\/history$/,
|
pattern: /^\/api\/conversations\/([^/]+)\/history$/,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// node scripts/css.mjs [--minify] [--watch]
|
||||||
|
//
|
||||||
|
// Tailwind writes to a scratch file; the scoped, optionally minified result
|
||||||
|
// is what lands in styles.css. See scope-css.mjs for why the extra step.
|
||||||
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
|
import { readFileSync, watch, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { scopeCss } from "./scope-css.mjs";
|
||||||
|
|
||||||
|
const root = new URL("..", import.meta.url).pathname;
|
||||||
|
const args = new Set(process.argv.slice(2));
|
||||||
|
const minify = args.has("--minify");
|
||||||
|
const watching = args.has("--watch");
|
||||||
|
|
||||||
|
const input = join(root, "src/tailwind.css");
|
||||||
|
const scratch = join(root, ".tailwind.css");
|
||||||
|
const output = join(root, "styles.css");
|
||||||
|
const cli = join(root, "node_modules/.bin/tailwindcss");
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
const built = readFileSync(scratch, "utf8");
|
||||||
|
writeFileSync(output, scopeCss(built, { minify }));
|
||||||
|
console.log(`css: wrote styles.css (${minify ? "minified, " : ""}scoped)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (watching) {
|
||||||
|
writeFileSync(scratch, "");
|
||||||
|
const child = spawn(cli, ["-i", input, "-o", scratch, "--watch"], {
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
let timer = null;
|
||||||
|
watch(scratch, () => {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
try {
|
||||||
|
finish();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("css:", error);
|
||||||
|
}
|
||||||
|
}, 60);
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => process.exit(code ?? 0));
|
||||||
|
} else {
|
||||||
|
const run = spawnSync(cli, ["-i", input, "-o", scratch], {
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
if (run.status !== 0) {
|
||||||
|
process.exit(run.status ?? 1);
|
||||||
|
}
|
||||||
|
finish();
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { transform } from "lightningcss";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obsidian loads every plugin's stylesheet into one document, and Tailwind
|
||||||
|
* puts its utilities into a cascade layer called `utilities` in each of them.
|
||||||
|
* Layers with the same name merge, so another Tailwind plugin's
|
||||||
|
* `.hidden{display:none!important}` and this plugin's
|
||||||
|
* `@container … .\@min-\[30rem\]\:inline-block{…!important}` end up in the
|
||||||
|
* same layer with the same specificity, and whichever plugin loaded last
|
||||||
|
* wins. Prefixing every selector with `:is(.beaver-root, .beaver-root *)` adds a
|
||||||
|
* class of specificity that no unscoped utility can match, whatever the load
|
||||||
|
* order, while still reaching the view root itself and the portal layer,
|
||||||
|
* both of which carry `.beaver-root`.
|
||||||
|
*
|
||||||
|
* Tailwind's own theme variables stay on `:root, :host`: moved onto the view
|
||||||
|
* root they would sit above Obsidian's `body` values, and `--color-accent`
|
||||||
|
* is a name both sides use — the accent would turn into the hover grey.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ROOT = "beaver-root";
|
||||||
|
|
||||||
|
const SCOPE = {
|
||||||
|
kind: "is",
|
||||||
|
selectors: [
|
||||||
|
[{ name: ROOT, type: "class" }],
|
||||||
|
[
|
||||||
|
{ name: ROOT, type: "class" },
|
||||||
|
{ type: "combinator", value: "descendant" },
|
||||||
|
{ type: "universal" },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
type: "pseudo-class",
|
||||||
|
};
|
||||||
|
|
||||||
|
function isPseudo(selector, kind) {
|
||||||
|
return (
|
||||||
|
selector.length === 1 &&
|
||||||
|
selector[0].type === "pseudo-class" &&
|
||||||
|
selector[0].kind === kind
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mentions(selector, name) {
|
||||||
|
for (const part of selector) {
|
||||||
|
if (part.type === "class" && part.name === name) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (part.selectors?.some((inner) => mentions(inner, name))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeSelector(selector) {
|
||||||
|
if (isPseudo(selector, "root") || isPseudo(selector, "host")) {
|
||||||
|
return selector;
|
||||||
|
}
|
||||||
|
if (mentions(selector, ROOT)) {
|
||||||
|
return selector;
|
||||||
|
}
|
||||||
|
// The scope goes on the subject — the last compound — not the first:
|
||||||
|
// `.theme-dark .hue-chip` must keep matching with `.theme-dark` on body,
|
||||||
|
// outside the root.
|
||||||
|
let at = 0;
|
||||||
|
for (let i = 0; i < selector.length; i += 1) {
|
||||||
|
if (selector[i].type === "combinator") {
|
||||||
|
at = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const head = selector.slice(0, at);
|
||||||
|
const subject = selector.slice(at);
|
||||||
|
// `*` and type selectors must open a compound; :is() goes in their place
|
||||||
|
// or right after them.
|
||||||
|
if (subject[0]?.type === "universal") {
|
||||||
|
return [...head, SCOPE, ...subject.slice(1)];
|
||||||
|
}
|
||||||
|
if (subject[0]?.type === "type") {
|
||||||
|
return [...head, subject[0], SCOPE, ...subject.slice(1)];
|
||||||
|
}
|
||||||
|
return [...head, SCOPE, ...subject];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rewrites a built stylesheet so its rules only reach the plugin's own DOM. */
|
||||||
|
export function scopeCss(code, { minify = false } = {}) {
|
||||||
|
// The selector visitor, not the rule one: handing a whole rule back makes
|
||||||
|
// lightningcss re-read its declarations, and some of Tailwind's it cannot.
|
||||||
|
const result = transform({
|
||||||
|
code: Buffer.from(code),
|
||||||
|
filename: "styles.css",
|
||||||
|
minify,
|
||||||
|
visitor: { Selector: scopeSelector },
|
||||||
|
});
|
||||||
|
return result.code.toString();
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
+23
-8
@@ -163,7 +163,7 @@ export default class BeaverPlugin extends Plugin {
|
|||||||
this.addSettingTab(new BeaverSettingsTab(this.app, this));
|
this.addSettingTab(new BeaverSettingsTab(this.app, this));
|
||||||
this.registerView(VIEW_TYPE_PANEL, (leaf) => new PanelView(leaf, this));
|
this.registerView(VIEW_TYPE_PANEL, (leaf) => new PanelView(leaf, this));
|
||||||
this.addRibbonIcon("bot", "Beaver", () => {
|
this.addRibbonIcon("bot", "Beaver", () => {
|
||||||
this.openPanel("side").catch(() => undefined);
|
this.openPanel("tab").catch(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
@@ -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> {
|
||||||
@@ -279,10 +288,16 @@ export default class BeaverPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reveal a panel (creating one on first use) and, given an id, show it.
|
// Reveal a panel (creating one on first use) and, given an id, show it.
|
||||||
|
// A tab and a sidedock column can coexist; each placement reuses its own.
|
||||||
async openPanel(placement: "side" | "tab", id?: string): Promise<void> {
|
async openPanel(placement: "side" | "tab", id?: string): Promise<void> {
|
||||||
const { workspace } = this.app;
|
const { workspace } = this.app;
|
||||||
|
const inTabs = (candidate: WorkspaceLeaf) =>
|
||||||
|
candidate.getRoot() === workspace.rootSplit;
|
||||||
let leaf: WorkspaceLeaf | null =
|
let leaf: WorkspaceLeaf | null =
|
||||||
workspace.getLeavesOfType(VIEW_TYPE_PANEL)[0] ?? null;
|
workspace
|
||||||
|
.getLeavesOfType(VIEW_TYPE_PANEL)
|
||||||
|
.find((candidate) => inTabs(candidate) === (placement === "tab")) ??
|
||||||
|
null;
|
||||||
if (!leaf) {
|
if (!leaf) {
|
||||||
leaf =
|
leaf =
|
||||||
placement === "tab"
|
placement === "tab"
|
||||||
@@ -425,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
@@ -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();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,4 +5,6 @@ export class PanelState {
|
|||||||
selected = $state<string | null>(null);
|
selected = $state<string | null>(null);
|
||||||
follow = $state(true);
|
follow = $state(true);
|
||||||
sourcePath = $state("");
|
sourcePath = $state("");
|
||||||
|
// The selected conversation is the open note's own: show activity, not a copy.
|
||||||
|
companion = $state(false);
|
||||||
}
|
}
|
||||||
+124
-1
@@ -47,10 +47,21 @@
|
|||||||
|
|
||||||
--sidebar: var(--background-secondary);
|
--sidebar: var(--background-secondary);
|
||||||
--sidebar-accent: var(--background-modifier-hover);
|
--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;
|
--radius: 0.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-dark .beaver-root {
|
.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);
|
--note: oklch(0.84 0.14 88);
|
||||||
--warn: oklch(0.8 0.13 75);
|
--warn: oklch(0.8 0.13 75);
|
||||||
--status-done: oklch(0.74 0.14 155);
|
--status-done: oklch(0.74 0.14 155);
|
||||||
@@ -75,6 +86,12 @@
|
|||||||
--color-note: var(--note);
|
--color-note: var(--note);
|
||||||
--color-sidebar: var(--sidebar);
|
--color-sidebar: var(--sidebar);
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
--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-new: var(--status-new);
|
||||||
--color-status-done: var(--status-done);
|
--color-status-done: var(--status-done);
|
||||||
@@ -112,7 +129,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.beaver-root {
|
.beaver-root {
|
||||||
|
box-sizing: border-box;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
padding-bottom: var(--beaver-inset-bottom, 0px);
|
||||||
font-family: var(--font-interface);
|
font-family: var(--font-interface);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
@@ -176,10 +195,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.beaver-root.beaver-root button {
|
.beaver-root.beaver-root button {
|
||||||
text-align: inherit;
|
justify-content: flex-start;
|
||||||
|
width: auto;
|
||||||
|
text-align: left;
|
||||||
cursor: pointer;
|
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 {
|
.beaver-root.beaver-root button:hover {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
@@ -358,8 +385,104 @@
|
|||||||
.beaver-root [data-slot="select-content"]::before {
|
.beaver-root [data-slot="select-content"]::before {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
/* Obsidian's own toggle: border-hover track, accent when on, a white thumb.
|
||||||
|
Tripled class: luma's group/data variants carry three classes' worth of
|
||||||
|
specificity, and the utilities are all !important. */
|
||||||
|
.beaver-root.beaver-root.beaver-root [data-slot="switch"] {
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
width: 40px !important;
|
||||||
|
height: 20px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
background: var(--background-modifier-border-hover) !important;
|
||||||
|
border: 0 !important;
|
||||||
|
border-radius: var(--toggle-radius, 18px) !important;
|
||||||
|
}
|
||||||
|
.beaver-root.beaver-root.beaver-root
|
||||||
|
[data-slot="switch"][data-state="checked"] {
|
||||||
|
background: var(--interactive-accent) !important;
|
||||||
|
}
|
||||||
|
.beaver-root.beaver-root.beaver-root [data-slot="switch-thumb"] {
|
||||||
|
display: block !important;
|
||||||
|
width: 16px !important;
|
||||||
|
height: 16px !important;
|
||||||
|
margin-left: 2px !important;
|
||||||
|
background: var(--toggle-thumb-color, white) !important;
|
||||||
|
border-radius: 50% !important;
|
||||||
|
box-shadow: var(--shadow-s) !important;
|
||||||
|
}
|
||||||
|
.beaver-root.beaver-root.beaver-root [data-slot="switch-thumb"] {
|
||||||
|
translate: 0 !important;
|
||||||
|
}
|
||||||
|
.beaver-root.beaver-root.beaver-root
|
||||||
|
[data-slot="switch-thumb"][data-state="checked"] {
|
||||||
|
translate: 20px 0 !important;
|
||||||
|
}
|
||||||
.beaver-root [data-slot="button"][data-variant="default"],
|
.beaver-root [data-slot="button"][data-variant="default"],
|
||||||
.beaver-root [data-slot="button"]:not([class*="bg-"]) {
|
.beaver-root [data-slot="button"]:not([class*="bg-"]) {
|
||||||
font-weight: var(--font-medium, 500) !important;
|
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
@@ -5,6 +5,7 @@
|
|||||||
import type { ApiClient } from "$lib/api/client";
|
import type { ApiClient } from "$lib/api/client";
|
||||||
import EmptyState from "$lib/components/empty-state.svelte";
|
import EmptyState from "$lib/components/empty-state.svelte";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { panelCache } from "$lib/panel/cache";
|
||||||
import { providePanelHost } from "$lib/panel/host";
|
import { providePanelHost } from "$lib/panel/host";
|
||||||
import { ConversationIndex } from "$lib/panel/index.svelte";
|
import { ConversationIndex } from "$lib/panel/index.svelte";
|
||||||
import PanelShell from "$lib/panel/panel-shell.svelte";
|
import PanelShell from "$lib/panel/panel-shell.svelte";
|
||||||
@@ -13,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
|
||||||
@@ -36,17 +37,19 @@
|
|||||||
|
|
||||||
// Props are read once: the view remounts the tree when settings change.
|
// Props are read once: the view remounts the tree when settings change.
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
providePanelHost(
|
const cache = panelCache(`beaver.panel:${client?.base ?? "none"}`);
|
||||||
obsidianHost({
|
const host = providePanelHost({
|
||||||
|
...obsidianHost({
|
||||||
app,
|
app,
|
||||||
|
chatsFolder,
|
||||||
component,
|
component,
|
||||||
sourcePath: () => state.sourcePath,
|
sourcePath: () => state.sourcePath,
|
||||||
vaultSubpath,
|
}),
|
||||||
})
|
cache,
|
||||||
);
|
});
|
||||||
|
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
const index = client ? new ConversationIndex(() => client) : null;
|
const index = client ? new ConversationIndex(() => client, cache) : null;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BitsConfig defaultPortalTo={layer}>
|
<BitsConfig defaultPortalTo={layer}>
|
||||||
@@ -54,7 +57,10 @@
|
|||||||
{#if client && index}
|
{#if client && index}
|
||||||
<PanelShell
|
<PanelShell
|
||||||
{client}
|
{client}
|
||||||
|
companion={state.companion}
|
||||||
|
fallbackToMaster
|
||||||
{index}
|
{index}
|
||||||
|
onOpenFile={(path) => host.openNote?.(`мета/бобер/${path}`)}
|
||||||
showFollow
|
showFollow
|
||||||
bind:follow={state.follow}
|
bind:follow={state.follow}
|
||||||
bind:selected={state.selected}
|
bind:selected={state.selected}
|
||||||
|
|||||||
+5
-4
@@ -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,
|
||||||
|
|||||||
+115
-1
@@ -1,12 +1,31 @@
|
|||||||
import type { ViewStateResult, WorkspaceLeaf } from "obsidian";
|
import type { ViewStateResult, WorkspaceLeaf } from "obsidian";
|
||||||
import { ItemView, TFile } from "obsidian";
|
import { ItemView, TFile } from "obsidian";
|
||||||
import { mount, unmount } from "svelte";
|
import { mount, unmount } from "svelte";
|
||||||
|
import type { Rect } from "./inset";
|
||||||
|
import { insetFor } from "./inset";
|
||||||
import type BeaverPlugin from "./main";
|
import type BeaverPlugin from "./main";
|
||||||
import { PanelState } from "./state.svelte";
|
import { PanelState } from "./state.svelte";
|
||||||
import App from "./ui/app.svelte";
|
import App from "./ui/app.svelte";
|
||||||
|
|
||||||
export const VIEW_TYPE_PANEL = "beaver-panel";
|
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 {
|
interface SavedState {
|
||||||
follow?: unknown;
|
follow?: unknown;
|
||||||
selected?: unknown;
|
selected?: unknown;
|
||||||
@@ -19,6 +38,7 @@ export class PanelView extends ItemView {
|
|||||||
readonly state = new PanelState();
|
readonly state = new PanelState();
|
||||||
private readonly plugin: BeaverPlugin;
|
private readonly plugin: BeaverPlugin;
|
||||||
private root: Record<string, unknown> | null = null;
|
private root: Record<string, unknown> | null = null;
|
||||||
|
private settling: number | null = null;
|
||||||
|
|
||||||
constructor(leaf: WorkspaceLeaf, plugin: BeaverPlugin) {
|
constructor(leaf: WorkspaceLeaf, plugin: BeaverPlugin) {
|
||||||
super(leaf);
|
super(leaf);
|
||||||
@@ -75,12 +95,100 @@ export class PanelView extends ItemView {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
this.registerEvent(this.app.workspace.on("resize", () => this.settle()));
|
||||||
|
this.registerEvent(
|
||||||
|
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.follow(this.app.workspace.getActiveFile());
|
||||||
this.plugin.views.add(this);
|
this.plugin.views.add(this);
|
||||||
this.mountApp();
|
this.mountApp();
|
||||||
|
this.settle();
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
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> {
|
async onClose(): Promise<void> {
|
||||||
this.plugin.views.delete(this);
|
this.plugin.views.delete(this);
|
||||||
await this.unmountApp();
|
await this.unmountApp();
|
||||||
@@ -100,11 +208,17 @@ export class PanelView extends ItemView {
|
|||||||
const id = this.plugin.conversationIdOf(file);
|
const id = this.plugin.conversationIdOf(file);
|
||||||
if (id) {
|
if (id) {
|
||||||
this.state.selected = id;
|
this.state.selected = id;
|
||||||
|
this.state.companion = !this.inTabs();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
show(id: string): void {
|
show(id: string): void {
|
||||||
this.state.selected = id;
|
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.
|
// Settings changed: the client is built from them, so rebuild the tree.
|
||||||
@@ -117,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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { scopeCss } from "../scripts/scope-css.mjs";
|
||||||
|
|
||||||
|
const one = (css: string) => scopeCss(css).replace(/\s+/gu, " ").trim();
|
||||||
|
|
||||||
|
describe("scoping the built stylesheet", () => {
|
||||||
|
it("prefixes a utility with the view root, keeping the root itself in reach", () => {
|
||||||
|
expect(one(".flex{display:flex!important}")).toBe(
|
||||||
|
":is(.beaver-root, .beaver-root *).flex { display: flex !important; }"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("outranks the same utility from another plugin's Tailwind", () => {
|
||||||
|
// Class specificity: theirs is (0,1,0); ours must be (0,2,0).
|
||||||
|
const out = one(".hidden{display:none!important}");
|
||||||
|
expect(out.startsWith(":is(.beaver-root, .beaver-root *).hidden")).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps pseudo-elements at the end where they are valid", () => {
|
||||||
|
expect(one(".x::placeholder{color:red}")).toBe(
|
||||||
|
":is(.beaver-root, .beaver-root *).x::placeholder { color: red; }"
|
||||||
|
);
|
||||||
|
expect(one(".y::first-letter{t:1}")).toContain(".y:first-letter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes rules nested in container queries and layers", () => {
|
||||||
|
const out = one(
|
||||||
|
"@layer utilities{@container (min-width:30rem){.a{display:inline-block!important}}}"
|
||||||
|
);
|
||||||
|
// lightningcss rewrites the query into range syntax; Obsidian's Chromium
|
||||||
|
// has understood that inside @container since container queries landed.
|
||||||
|
expect(out).toContain("@container (width >= 30rem)");
|
||||||
|
expect(out).toContain(":is(.beaver-root, .beaver-root *).a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a leading universal selector instead of producing `:is()*`", () => {
|
||||||
|
const out = one("*,:before,::backdrop{--tw-a:initial}");
|
||||||
|
expect(out).toContain(":is(.beaver-root, .beaver-root *),");
|
||||||
|
expect(out).toContain(":is(.beaver-root, .beaver-root *):before");
|
||||||
|
expect(out).not.toContain("*)*");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves tailwind's theme variables on :root, under obsidian's body values", () => {
|
||||||
|
expect(one(":root,:host{--spacing:.25rem}")).toBe(
|
||||||
|
":root, :host { --spacing: .25rem; }"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes the subject, so an ancestor outside the root still matches", () => {
|
||||||
|
expect(one(".theme-dark .hue-chip{c:1}")).toBe(
|
||||||
|
".theme-dark :is(.beaver-root, .beaver-root *).hue-chip { c: 1; }"
|
||||||
|
);
|
||||||
|
expect(one(".a > button{c:2}")).toBe(
|
||||||
|
".a > button:is(.beaver-root, .beaver-root *) { c: 2; }"
|
||||||
|
);
|
||||||
|
expect(one(".a *{c:3}")).toBe(
|
||||||
|
".a :is(.beaver-root, .beaver-root *) { c: 3; }"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves rules that already name the root alone", () => {
|
||||||
|
const reset = ".beaver-root.beaver-root button{cursor:pointer}";
|
||||||
|
expect(one(reset)).toBe(
|
||||||
|
".beaver-root.beaver-root button { cursor: pointer; }"
|
||||||
|
);
|
||||||
|
expect(one(".theme-dark .beaver-root{--note:red}")).toBe(
|
||||||
|
".theme-dark .beaver-root { --note: red; }"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes variant selectors without breaking their inner :where()", () => {
|
||||||
|
const out = one(".dark\\:bg-x:where(.theme-dark,.theme-dark *){b:1}");
|
||||||
|
expect(out).toBe(
|
||||||
|
":is(.beaver-root, .beaver-root *).dark\\:bg-x:where(.theme-dark, .theme-dark *) { b: 1; }"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+21
-6
@@ -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,
|
||||||
});
|
});
|
||||||
@@ -161,7 +161,7 @@ check(
|
|||||||
check("thread header shows the master", text().includes("Master · 29 Aug"));
|
check("thread header shows the master", text().includes("Master · 29 Aug"));
|
||||||
check(
|
check(
|
||||||
"the running turn's tools are on screen",
|
"the running turn's tools are on screen",
|
||||||
text().includes("Task") && text().includes("Bash")
|
text().includes("Agent") && text().includes("Bash")
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
"history rendered through the host renderer",
|
"history rendered through the host renderer",
|
||||||
@@ -191,6 +191,23 @@ check(
|
|||||||
text().includes("Xiaomi Mi Blaster")
|
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");
|
const composer = target.querySelector("textarea");
|
||||||
check("composer is there", composer !== null);
|
check("composer is there", composer !== null);
|
||||||
if (composer) {
|
if (composer) {
|
||||||
@@ -226,10 +243,8 @@ flushSync();
|
|||||||
await settle();
|
await settle();
|
||||||
const rows = [...dom.window.document.querySelectorAll("[data-row]")];
|
const rows = [...dom.window.document.querySelectorAll("[data-row]")];
|
||||||
check(
|
check(
|
||||||
"switcher lists open conversations grouped",
|
"switcher lists open conversations grouped by day",
|
||||||
rows.length >= 4 &&
|
rows.length >= 4 && text().includes("Today"),
|
||||||
text().includes("Branches") &&
|
|
||||||
text().includes("Deep chats"),
|
|
||||||
`${rows.length}`
|
`${rows.length}`
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
|
|||||||
@@ -89,4 +89,12 @@ describe("cascade against Obsidian", () => {
|
|||||||
expect(source).not.toMatch(/@import "tailwindcss"\s*[;l]/);
|
expect(source).not.toMatch(/@import "tailwindcss"\s*[;l]/);
|
||||||
expect(source).not.toMatch(/@import\s+"tailwindcss\/preflight/);
|
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/
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user