feat(ui,api,telegram): shared conversation panel for obsidian and /admin/panel, bind materializes a window, inbox survives link previews

This commit is contained in:
hh
2026-08-29 18:30:33 +02:00
parent be114e6eaf
commit 60dc7db925
58 changed files with 2858 additions and 499 deletions
+6 -3
View File
@@ -17,17 +17,20 @@
let { children } = $props();
const isLogin = $derived(page.url.pathname === `${base}/login`);
const isPanel = $derived(page.url.pathname.startsWith(`${base}/panel`));
const inConversations = $derived(
page.url.pathname.startsWith(`${base}/conversations`)
);
let navBefore: boolean | null = null;
onMount(() => {
session.check();
if (!isPanel) {
session.check();
}
});
$effect(() => {
if (!session.ready) {
if (!session.ready || isPanel) {
return;
}
if (!(session.user || isLogin)) {
@@ -74,7 +77,7 @@
<Toaster position="bottom-right" richColors />
<Tooltip.Provider delayDuration={300}>
{#if isLogin}
{#if isLogin || isPanel}
{@render children()}
{:else if session.ready && session.user}
<div class="flex h-dvh flex-col overflow-hidden sm:flex-row">
+1 -66
View File
@@ -2,6 +2,7 @@
@import "tw-animate-css";
@import "shadcn-svelte/tailwind.css";
@import "@fontsource-variable/inter";
@import "../lib/panel/panel.css";
@plugin "@tailwindcss/forms";
@plugin "@tailwindcss/typography";
@@ -178,16 +179,6 @@
}
}
@utility touch-shown {
@media (hover: none) {
opacity: 1;
}
}
@utility tabular {
font-variant-numeric: tabular-nums;
}
@utility hue-chip {
color: oklch(0.45 0.13 var(--hue));
background: oklch(0.6 0.09 var(--hue) / 0.12);
@@ -197,49 +188,6 @@
}
}
@utility scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
@theme inline {
--font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--color-kind-master: var(--signal);
--color-kind-branch: var(--status-reply);
--color-kind-deep: var(--status-meeting);
--color-kind-job: var(--status-work);
--color-kind-fork: var(--status-skip);
--color-ok: var(--status-done);
--color-warn: var(--warn);
--color-link: var(--link);
--animate-pulse-dot: pulse-dot 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite;
--animate-rise: rise 220ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes pulse-dot {
0%,
100% {
box-shadow: 0 0 0 0 color-mix(in oklab, var(--signal) 55%, transparent);
}
50% {
box-shadow: 0 0 0 5px color-mix(in oklab, var(--signal) 0%, transparent);
}
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(3px);
}
to {
opacity: 1;
transform: none;
}
}
@layer base {
* {
scrollbar-color: color-mix(in oklab, var(--foreground) 22%, transparent)
@@ -281,19 +229,6 @@
}
}
@utility row-hover {
transition: background-color 150ms ease-out;
&:hover {
background: color-mix(in oklab, var(--foreground) 4%, transparent);
}
}
@utility ledger-grid {
display: grid;
column-gap: 0.75rem;
align-items: center;
}
@utility hairline {
border-color: color-mix(in oklab, var(--border) 100%, transparent);
}
+125
View File
@@ -0,0 +1,125 @@
<script lang="ts">
import { onMount } from "svelte";
import { ApiClient, ApiError } from "$lib/api/client";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Toaster } from "$lib/components/ui/sonner";
import * as Tooltip from "$lib/components/ui/tooltip";
import { ConversationIndex } from "$lib/panel/index.svelte";
import PanelShell from "$lib/panel/panel-shell.svelte";
// The browser twin of the Obsidian panel: ``?token=`` once, then the
// token lives in localStorage and the conversation id in the hash.
const TOKEN_KEY = "beaver.panel.token";
let client = $state<ApiClient | null>(null);
let index = $state<ConversationIndex | null>(null);
let selected = $state<string | null>(null);
let draft = $state("");
let failure = $state<string | null>(null);
function connect(token: string) {
localStorage.setItem(TOKEN_KEY, token);
const api = new ApiClient(window.location.origin, token, {
onUnauthorized: () => {
localStorage.removeItem(TOKEN_KEY);
failure = "The token was rejected. Paste a fresh one.";
client = null;
index = null;
return Promise.resolve(null);
},
});
client = api;
index = new ConversationIndex(() => api);
failure = null;
}
async function submit() {
const token = draft.trim();
if (!token) {
return;
}
try {
await new ApiClient(window.location.origin, token).agents();
connect(token);
draft = "";
} catch (error) {
failure =
error instanceof ApiError
? `${error.status}: ${error.message}`
: String(error);
}
}
onMount(() => {
const url = new URL(window.location.href);
const fromQuery = url.searchParams.get("token");
if (fromQuery) {
url.searchParams.delete("token");
window.history.replaceState(window.history.state, "", url);
}
const token = fromQuery ?? localStorage.getItem(TOKEN_KEY);
if (token) {
connect(token);
}
selected = window.location.hash.slice(1) || null;
});
$effect(() => {
const hash = selected ? `#${selected}` : "";
if (window.location.hash !== hash) {
window.history.replaceState(
window.history.state,
"",
`${window.location.pathname}${window.location.search}${hash}`
);
}
});
</script>
<svelte:head><title>Panel · Beaver</title></svelte:head>
<Toaster position="bottom-right" richColors />
<Tooltip.Provider delayDuration={300}>
{#if client && index}
<div class="h-dvh">
<PanelShell {client} {index} bind:selected />
</div>
{:else}
<div class="flex h-dvh items-center justify-center bg-sidebar p-6">
<form
class="flex w-full max-w-sm flex-col gap-4 rounded-xl border bg-background p-6"
onsubmit={(event) => {
event.preventDefault();
submit();
}}
style="box-shadow: 0 8px 24px -12px rgb(0 0 0 / 0.25)"
>
<div class="flex items-center gap-2">
<span class="size-2.5 rounded-sm bg-primary"></span>
<span class="font-semibold tracking-tight">Beaver panel</span>
</div>
<p class="text-muted-foreground text-sm">
Paste a token with the <code>api</code> scope. It stays in this
browser; open the page with <code>?token=…</code> to hand it over.
</p>
<div class="flex flex-col gap-1.5">
<Label for="panel-token">Token</Label>
<Input
autocomplete="off"
id="panel-token"
placeholder="bvr_…"
type="password"
bind:value={draft}
/>
</div>
{#if failure}
<p class="text-destructive text-sm" role="alert">{failure}</p>
{/if}
<Button disabled={!draft.trim()} type="submit">Open the panel</Button>
</form>
</div>
{/if}
</Tooltip.Provider>