From cec5aef98cc593a2f719200e08a94f2d6b394cb0 Mon Sep 17 00:00:00 2001 From: h Date: Wed, 2 Sep 2026 03:48:42 +0200 Subject: [PATCH] feat(panel): island, rail by day, companion mode beside a deep note, cached index and threads, obsidian skin for strips --- .gitignore | 1 + preview/entry.ts | 16 +++++--- preview/fake-client.ts | 84 +++++++++++++++++++++++++++++++++++++++++ src/state.svelte.ts | 2 + src/tailwind.css | 85 +++++++++++++++++++++++++++++++++++++++++- src/ui/app.svelte | 15 +++++--- src/view.ts | 6 +++ tests/smoke.mjs | 6 +-- 8 files changed, 199 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index 074c3f5..982e809 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ data.json .DS_Store .vscode/ .idea/ +preview/shots/ diff --git a/preview/entry.ts b/preview/entry.ts index b3ab331..67c7cb9 100644 --- a/preview/entry.ts +++ b/preview/entry.ts @@ -64,11 +64,6 @@ async function main(): Promise { }); 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('[aria-label="Switch conversation"]') @@ -104,13 +99,22 @@ async function main(): Promise { } if (scene.startsWith("activity")) { for (const trigger of document.querySelectorAll( - '[data-slot="tabs-trigger"]' + ".rule-word" )) { if (trigger.textContent?.trim() === "Activity") { press(trigger); } } } + if (scene.startsWith("context")) { + for (const trigger of document.querySelectorAll( + ".rule-word" + )) { + if (trigger.textContent?.trim() === "Context") { + press(trigger); + } + } + } if (scene.startsWith("branch")) { document .querySelector( diff --git a/preview/fake-client.ts b/preview/fake-client.ts index b6ae1c1..29c4c57 100644 --- a/preview/fake-client.ts +++ b/preview/fake-client.ts @@ -23,6 +23,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 +67,7 @@ const ROWS: ConversationSummary[] = [ status: "done", text: "правило для 62990 добавлено, проверь снаружи", }, + parent: MASTER, parent_row: 1, title: "ufw на dell", }), @@ -73,6 +75,7 @@ const ROWS: ConversationSummary[] = [ id: BRANCH_BLASTER, kind: "branch", last_activity_at: ago(3 * HOUR), + parent: MASTER, parent_row: 1, pending_question: true, title: "бластер - что заказать", @@ -292,6 +295,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", diff --git a/src/state.svelte.ts b/src/state.svelte.ts index 0df428c..8ad93e4 100644 --- a/src/state.svelte.ts +++ b/src/state.svelte.ts @@ -5,4 +5,6 @@ export class PanelState { selected = $state(null); follow = $state(true); sourcePath = $state(""); + // The selected conversation is the open note's own: show activity, not a copy. + companion = $state(false); } diff --git a/src/tailwind.css b/src/tailwind.css index a7ad938..a9c1e2c 100644 --- a/src/tailwind.css +++ b/src/tailwind.css @@ -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,7 +195,9 @@ } .beaver-root.beaver-root button { - text-align: inherit; + justify-content: flex-start; + width: auto; + text-align: left; cursor: pointer; } @@ -397,3 +416,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; +} diff --git a/src/ui/app.svelte b/src/ui/app.svelte index 7b6273f..0a810b4 100644 --- a/src/ui/app.svelte +++ b/src/ui/app.svelte @@ -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"; @@ -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, 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; @@ -54,7 +57,9 @@ {#if client && index} host.openNote?.(`мета/бобер/${path}`)} showFollow bind:follow={state.follow} bind:selected={state.selected} diff --git a/src/view.ts b/src/view.ts index 1ba8ae9..b641de3 100644 --- a/src/view.ts +++ b/src/view.ts @@ -119,11 +119,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. diff --git a/tests/smoke.mjs b/tests/smoke.mjs index ee7b23f..e19f708 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -226,10 +226,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(