feat: one gateway port with path-mounted frontends, markdown chat, collapsible sidebars
This commit is contained in:
@@ -127,9 +127,9 @@ export interface FrontendInfo {
|
||||
default_agents: Record<string, string>;
|
||||
kinds: Kind[];
|
||||
name: string;
|
||||
port: number | null;
|
||||
public_base_url: string | null;
|
||||
path: string | null;
|
||||
type: string;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface AgentsResponse {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import LogOutIcon from "@lucide/svelte/icons/log-out";
|
||||
import PanelLeftCloseIcon from "@lucide/svelte/icons/panel-left-close";
|
||||
import PanelLeftOpenIcon from "@lucide/svelte/icons/panel-left-open";
|
||||
import { goto } from "$app/navigation";
|
||||
import { base } from "$app/paths";
|
||||
import { page } from "$app/state";
|
||||
@@ -9,9 +11,11 @@
|
||||
import { gateway } from "$lib/gateway.svelte";
|
||||
import { isActive, NAV } from "$lib/nav";
|
||||
import { session } from "$lib/session.svelte";
|
||||
import { ui } from "$lib/ui.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
const running = $derived(gateway.running.length);
|
||||
const open = $derived(ui.nav);
|
||||
|
||||
async function signOut() {
|
||||
await session.logout();
|
||||
@@ -20,56 +24,114 @@
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="hidden w-52 shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground sm:flex"
|
||||
class={cn(
|
||||
"hidden shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground transition-[width] duration-150 sm:flex",
|
||||
open ? "w-52" : "w-12"
|
||||
)}
|
||||
>
|
||||
<a
|
||||
class="flex h-12 items-center gap-2 border-b px-4 font-semibold tracking-tight"
|
||||
class={cn(
|
||||
"flex h-12 items-center gap-2 border-b font-semibold tracking-tight",
|
||||
open ? "px-4" : "justify-center"
|
||||
)}
|
||||
href="{base}/"
|
||||
title="Beaver"
|
||||
>
|
||||
<span class="size-2.5 rounded-sm bg-primary"></span>
|
||||
Beaver
|
||||
<span class="size-2.5 shrink-0 rounded-sm bg-primary"></span>
|
||||
{#if open}
|
||||
Beaver
|
||||
{/if}
|
||||
</a>
|
||||
<nav aria-label="Sections" class="flex flex-1 flex-col gap-0.5 p-2">
|
||||
{#each NAV as item (item.href)}
|
||||
{@const active = isActive(page.url.pathname, base, item.href)}
|
||||
<a
|
||||
aria-current={active ? "page" : undefined}
|
||||
aria-label={item.label}
|
||||
class={cn(
|
||||
"flex h-8 items-center gap-2.5 rounded-md px-2.5 text-sm transition-colors",
|
||||
"relative flex h-8 items-center gap-2.5 rounded-md text-sm transition-colors",
|
||||
open ? "px-2.5" : "justify-center",
|
||||
active
|
||||
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"
|
||||
)}
|
||||
href="{base}{item.href}"
|
||||
title={open ? undefined : item.label}
|
||||
>
|
||||
<item.icon class="size-4 shrink-0 text-icon" />
|
||||
<span class="flex-1">{item.label}</span>
|
||||
{#if open}
|
||||
<span class="flex-1">{item.label}</span>
|
||||
{/if}
|
||||
{#if item.href === "/" && running > 0}
|
||||
<span
|
||||
class="tabular rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
|
||||
>
|
||||
{running}
|
||||
</span>
|
||||
{#if open}
|
||||
<span
|
||||
class="tabular rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
|
||||
>
|
||||
{running}
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="absolute top-1 right-1 size-1.5 rounded-full bg-signal"
|
||||
></span>
|
||||
{/if}
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="flex flex-col gap-2 border-t p-3">
|
||||
<LiveDot detail={gateway.live.detail} state={gateway.live.state} />
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="truncate text-muted-foreground text-xs">{session.user}</span>
|
||||
<div class="flex items-center">
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
aria-label="Sign out"
|
||||
onclick={signOut}
|
||||
size="icon-sm"
|
||||
title="Sign out"
|
||||
variant="ghost"
|
||||
>
|
||||
<LogOutIcon class="size-4" />
|
||||
</Button>
|
||||
<div
|
||||
class={cn(
|
||||
"flex flex-col gap-2 border-t",
|
||||
open ? "p-3" : "items-center p-2"
|
||||
)}
|
||||
>
|
||||
<LiveDot
|
||||
detail={gateway.live.detail}
|
||||
label={open}
|
||||
state={gateway.live.state}
|
||||
/>
|
||||
{#if open}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="truncate text-muted-foreground text-xs">
|
||||
{session.user}
|
||||
</span>
|
||||
<div class="flex items-center">
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
aria-label="Sign out"
|
||||
onclick={signOut}
|
||||
size="icon-sm"
|
||||
title="Sign out"
|
||||
variant="ghost"
|
||||
>
|
||||
<LogOutIcon class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
aria-label="Sign out"
|
||||
onclick={signOut}
|
||||
size="icon-sm"
|
||||
title="Sign out"
|
||||
variant="ghost"
|
||||
>
|
||||
<LogOutIcon class="size-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
aria-label={open ? "Collapse sidebar" : "Expand sidebar"}
|
||||
class={open ? "self-end" : ""}
|
||||
onclick={() => ui.toggleNav()}
|
||||
size="icon-sm"
|
||||
title="{open ? 'Collapse' : 'Expand'} sidebar (⌘B)"
|
||||
variant="ghost"
|
||||
>
|
||||
{#if open}
|
||||
<PanelLeftCloseIcon class="size-4" />
|
||||
{:else}
|
||||
<PanelLeftOpenIcon class="size-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import PanelLeftCloseIcon from "@lucide/svelte/icons/panel-left-close";
|
||||
import PlusIcon from "@lucide/svelte/icons/plus";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -17,6 +18,7 @@
|
||||
import { clip, fmtRelative, shortId } from "$lib/format";
|
||||
import { gateway } from "$lib/gateway.svelte";
|
||||
import { session } from "$lib/session.svelte";
|
||||
import { ui } from "$lib/ui.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
let { selected = null }: { selected?: string | null } = $props();
|
||||
@@ -162,6 +164,16 @@
|
||||
<Button aria-label="New conversation" onclick={openCreate} size="icon-sm">
|
||||
<PlusIcon class="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Hide conversations"
|
||||
class="hidden lg:inline-flex"
|
||||
onclick={() => ui.toggleRail()}
|
||||
size="icon-sm"
|
||||
title="Hide conversations"
|
||||
variant="ghost"
|
||||
>
|
||||
<PanelLeftCloseIcon class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
{#if gateway.live.state === "failed"}
|
||||
|
||||
@@ -22,12 +22,10 @@
|
||||
}
|
||||
|
||||
function baseOf(fe: FrontendInfo): string {
|
||||
if (fe.public_base_url) {
|
||||
return fe.public_base_url;
|
||||
if (fe.url) {
|
||||
return fe.url;
|
||||
}
|
||||
return fe.port
|
||||
? `${window.location.protocol}//${window.location.hostname}:${fe.port}`
|
||||
: "";
|
||||
return fe.path ? `${window.location.origin}${fe.path}` : "";
|
||||
}
|
||||
|
||||
function describe(fe: FrontendInfo): Group {
|
||||
@@ -92,14 +90,12 @@
|
||||
{
|
||||
hint: "Obsidian plugin → API origin; the admin uses it too. Routes live under /api/…",
|
||||
label: "api origin",
|
||||
url: fe.public_base_url
|
||||
? fe.public_base_url.replace(API_SUFFIX, "")
|
||||
: base,
|
||||
url: base.replace(API_SUFFIX, ""),
|
||||
},
|
||||
{
|
||||
hint: "SSE of the whole gateway; per conversation: /api/conversations/{id}/events",
|
||||
label: "events",
|
||||
url: `${fe.public_base_url ? fe.public_base_url.replace(API_SUFFIX, "") : base}/api/events`,
|
||||
url: `${base}/events`,
|
||||
},
|
||||
],
|
||||
frontend: fe,
|
||||
@@ -114,7 +110,7 @@
|
||||
case "AdminFrontend":
|
||||
return {
|
||||
endpoints: [
|
||||
{ hint: "this console", label: "admin", url: `${base}/admin/` },
|
||||
{ hint: "this console", label: "admin", url: `${base}/` },
|
||||
],
|
||||
frontend: fe,
|
||||
note: "",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { cn } from "$lib/utils";
|
||||
import type { ActivityModel } from "./activity.svelte";
|
||||
import { summarizeInput, toolLabel } from "./activity.svelte";
|
||||
import Markdown from "./markdown.svelte";
|
||||
import QuestionCard from "./question-card.svelte";
|
||||
import TurnCard from "./turn-card.svelte";
|
||||
|
||||
@@ -159,14 +160,13 @@
|
||||
>
|
||||
{#each parts as block, blockIndex (blockIndex)}
|
||||
{#if block.type === "text" && block.text}
|
||||
<p
|
||||
<Markdown
|
||||
class={cn(
|
||||
"max-w-[75ch] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm",
|
||||
"max-w-[75ch] rounded-lg px-3 py-2",
|
||||
message.role === "user" ? "bg-primary/10" : "bg-muted/50"
|
||||
)}
|
||||
>
|
||||
{block.text}
|
||||
</p>
|
||||
text={block.text}
|
||||
/>
|
||||
{:else if block.type === "tool_use"}
|
||||
<p class="flex items-baseline gap-2 px-1 text-xs">
|
||||
<span class="font-medium">{toolLabel(block.name ?? "?")}</span>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { fmtRelative, shortId } from "$lib/format";
|
||||
import { clip, fmtRelative, shortId } from "$lib/format";
|
||||
|
||||
let {
|
||||
client,
|
||||
@@ -43,6 +43,9 @@
|
||||
let branchText = $state("");
|
||||
let newTitle = $state("");
|
||||
|
||||
const WINDOW_MAX = 56;
|
||||
const windows = $derived(info.bindings.filter((b) => b.visible));
|
||||
|
||||
const SEEDS = [
|
||||
{ label: "clean - empty context", value: "clean" },
|
||||
{ label: "copy - copy of this history", value: "copy" },
|
||||
@@ -193,7 +196,25 @@
|
||||
<span class="tabular">
|
||||
last activity {fmtRelative(info.last_activity_at)}
|
||||
</span>
|
||||
{#if info.queue.length > 0}
|
||||
<span class="tabular font-medium text-note">
|
||||
{info.queue.length}
|
||||
queued
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if windows.length > 0}
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs"
|
||||
>
|
||||
{#each windows as window (window.frontend + window.external_id)}
|
||||
<span class="truncate" title="{window.frontend}: {window.external_id}">
|
||||
<span class="text-foreground/70">{window.frontend}</span>
|
||||
{clip(window.external_id, WINDOW_MAX)}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<Dialog.Root bind:open={branchOpen}>
|
||||
|
||||
@@ -18,13 +18,11 @@
|
||||
id,
|
||||
href,
|
||||
onOpen,
|
||||
compact = false,
|
||||
}: {
|
||||
client: ApiClient;
|
||||
id: string;
|
||||
href: (id: string) => string;
|
||||
onOpen: (id: string) => void;
|
||||
compact?: boolean;
|
||||
} = $props();
|
||||
|
||||
const feed = new ConversationFeed(
|
||||
@@ -74,73 +72,58 @@
|
||||
onChanged={refresh}
|
||||
{onOpen}
|
||||
/>
|
||||
<div
|
||||
class="grid min-h-0 flex-1 grid-cols-1 {compact
|
||||
? ''
|
||||
: 'lg:grid-cols-[minmax(0,1fr)_18rem]'}"
|
||||
>
|
||||
<div class="flex min-h-0 flex-col">
|
||||
<Tabs.Root class="flex min-h-0 flex-1 flex-col gap-0" bind:value={tab}>
|
||||
<Tabs.List class="mx-4 mt-2 w-fit sm:mx-6">
|
||||
<Tabs.Trigger value="chat">Chat</Tabs.Trigger>
|
||||
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
|
||||
<Tabs.Trigger value="raw">Raw</Tabs.Trigger>
|
||||
<Tabs.Trigger class={compact ? "" : "lg:hidden"} value="meta">
|
||||
Meta
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content class="flex min-h-0 flex-1 flex-col" value="chat">
|
||||
<ChatView
|
||||
{client}
|
||||
conversationId={id}
|
||||
model={feed.model}
|
||||
refreshKey={historyKey}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
|
||||
value="activity"
|
||||
>
|
||||
<ActivityFeed
|
||||
{client}
|
||||
connected={feed.live.state === "open"}
|
||||
conversationId={id}
|
||||
model={feed.model}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
|
||||
value="raw"
|
||||
>
|
||||
<RawEntries {client} conversationId={id} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
|
||||
value="meta"
|
||||
>
|
||||
{@render rail()}
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
<Composer
|
||||
{client}
|
||||
conversationId={id}
|
||||
disabled={info.status !== "open"}
|
||||
/>
|
||||
</div>
|
||||
{#if !compact}
|
||||
<aside
|
||||
class="hidden min-h-0 overflow-y-auto border-l bg-sidebar/50 p-4 lg:block"
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<Tabs.Root class="flex min-h-0 flex-1 flex-col gap-0" bind:value={tab}>
|
||||
<Tabs.List class="mx-4 mt-2 w-fit sm:mx-6">
|
||||
<Tabs.Trigger value="chat">Chat</Tabs.Trigger>
|
||||
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
|
||||
<Tabs.Trigger value="raw">Raw</Tabs.Trigger>
|
||||
<Tabs.Trigger value="meta">Meta</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content class="flex min-h-0 flex-1 flex-col" value="chat">
|
||||
<ChatView
|
||||
{client}
|
||||
conversationId={id}
|
||||
model={feed.model}
|
||||
refreshKey={historyKey}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
|
||||
value="activity"
|
||||
>
|
||||
{@render rail()}
|
||||
</aside>
|
||||
{/if}
|
||||
<ActivityFeed
|
||||
{client}
|
||||
connected={feed.live.state === "open"}
|
||||
conversationId={id}
|
||||
model={feed.model}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
|
||||
value="raw"
|
||||
>
|
||||
<RawEntries {client} conversationId={id} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
|
||||
value="meta"
|
||||
>
|
||||
{@render meta()}
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
<Composer
|
||||
{client}
|
||||
conversationId={id}
|
||||
disabled={info.status !== "open"}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet rail()}
|
||||
{#snippet meta()}
|
||||
{#if info}
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="flex max-w-3xl flex-col gap-5">
|
||||
<section class="flex flex-col gap-1.5">
|
||||
<h2
|
||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils";
|
||||
import { renderMarkdown } from "./markdown";
|
||||
|
||||
let { text, class: className = "" }: { text: string; class?: string } =
|
||||
$props();
|
||||
|
||||
const html = $derived(renderMarkdown(text));
|
||||
</script>
|
||||
|
||||
<div class={cn("prose prose-sm max-w-none", className)}>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in renderMarkdown -->
|
||||
{@html html}
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
|
||||
marked.use({ breaks: true, gfm: true });
|
||||
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
||||
if (node.tagName === "A") {
|
||||
node.setAttribute("target", "_blank");
|
||||
node.setAttribute("rel", "noopener");
|
||||
}
|
||||
});
|
||||
|
||||
// The one place chat text becomes HTML. The Obsidian panel swaps this for
|
||||
// Obsidian's own MarkdownRenderer; the admin uses marked + DOMPurify.
|
||||
export function renderMarkdown(text: string): string {
|
||||
const html = marked.parse(text, { async: false });
|
||||
return DOMPurify.sanitize(html, { ADD_ATTR: ["target"] });
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
} from "$lib/format";
|
||||
import { cn } from "$lib/utils";
|
||||
import type { Turn } from "./activity.svelte";
|
||||
import Markdown from "./markdown.svelte";
|
||||
import ToolNodeView from "./tool-node.svelte";
|
||||
|
||||
let { turn, now }: { turn: Turn; now: number } = $props();
|
||||
@@ -65,11 +66,10 @@
|
||||
{/if}
|
||||
</header>
|
||||
{#if turn.userText}
|
||||
<p
|
||||
class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5 text-sm whitespace-pre-wrap"
|
||||
>
|
||||
{turn.userText}
|
||||
</p>
|
||||
<Markdown
|
||||
class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5"
|
||||
text={turn.userText}
|
||||
/>
|
||||
{/if}
|
||||
{#if turn.roots.length > 0}
|
||||
<div class="flex flex-col">
|
||||
@@ -81,13 +81,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#each turn.says as text, index (index)}
|
||||
<p class="mx-1 flex gap-2 rounded-md bg-primary/8 px-2.5 py-1.5 text-sm">
|
||||
<MessageSquareIcon class="mt-0.5 size-3.5 shrink-0 text-link" />
|
||||
<span class="whitespace-pre-wrap">{text}</span>
|
||||
</p>
|
||||
<div class="mx-1 flex gap-2 rounded-md bg-primary/8 px-2.5 py-1.5">
|
||||
<MessageSquareIcon class="mt-1 size-3.5 shrink-0 text-link" />
|
||||
<Markdown class="min-w-0 flex-1" {text} />
|
||||
</div>
|
||||
{/each}
|
||||
{#if turn.text}
|
||||
<p class="mx-1 px-1 text-sm whitespace-pre-wrap">{turn.text}</p>
|
||||
<Markdown class="mx-1 px-1" text={turn.text} />
|
||||
{:else if turn.status === "running" && turn.roots.length === 0 && turn.thinking === 0}
|
||||
<p class="mx-1 px-1 text-muted-foreground text-sm">
|
||||
Waiting for the model…
|
||||
|
||||
@@ -2,7 +2,6 @@ import { base } from "$app/paths";
|
||||
import { ApiClient } from "./api/client";
|
||||
|
||||
interface SessionPayload {
|
||||
api_base: string | null;
|
||||
token: string;
|
||||
user: string;
|
||||
}
|
||||
@@ -37,7 +36,7 @@ class Session {
|
||||
return;
|
||||
}
|
||||
this.user = payload.user;
|
||||
this.apiBase = payload.api_base ?? window.location.origin;
|
||||
this.apiBase = window.location.origin;
|
||||
this.client = new ApiClient(this.apiBase, payload.token, {
|
||||
onUnauthorized: () => this.refreshToken(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
const NAV_KEY = "beaver.ui.nav";
|
||||
const RAIL_KEY = "beaver.ui.rail";
|
||||
|
||||
function stored(key: string, fallback: boolean): boolean {
|
||||
if (!browser) {
|
||||
return fallback;
|
||||
}
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw === null ? fallback : raw === "1";
|
||||
}
|
||||
|
||||
function store(key: string, value: boolean): void {
|
||||
if (browser) {
|
||||
localStorage.setItem(key, value ? "1" : "0");
|
||||
}
|
||||
}
|
||||
|
||||
// Chrome state: the section sidebar and the conversation rail. Explicit
|
||||
// toggles persist; the automatic collapse on the conversations pages does
|
||||
// not, so leaving them restores what the user had.
|
||||
class Ui {
|
||||
nav = $state(stored(NAV_KEY, true));
|
||||
rail = $state(stored(RAIL_KEY, true));
|
||||
|
||||
setNav(open: boolean, persist = false): void {
|
||||
this.nav = open;
|
||||
if (persist) {
|
||||
store(NAV_KEY, open);
|
||||
}
|
||||
}
|
||||
|
||||
toggleNav(): void {
|
||||
this.setNav(!this.nav, true);
|
||||
}
|
||||
|
||||
toggleRail(): void {
|
||||
this.rail = !this.rail;
|
||||
store(RAIL_KEY, this.rail);
|
||||
}
|
||||
}
|
||||
|
||||
export const ui = new Ui();
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import "./layout.css";
|
||||
import { ModeWatcher } from "mode-watcher";
|
||||
import { onMount } from "svelte";
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { base } from "$app/paths";
|
||||
import { page } from "$app/state";
|
||||
@@ -12,10 +12,15 @@
|
||||
import * as Tooltip from "$lib/components/ui/tooltip";
|
||||
import { gateway } from "$lib/gateway.svelte";
|
||||
import { session } from "$lib/session.svelte";
|
||||
import { ui } from "$lib/ui.svelte";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const isLogin = $derived(page.url.pathname === `${base}/login`);
|
||||
const inConversations = $derived(
|
||||
page.url.pathname.startsWith(`${base}/conversations`)
|
||||
);
|
||||
let navBefore: boolean | null = null;
|
||||
|
||||
onMount(() => {
|
||||
session.check();
|
||||
@@ -38,8 +43,31 @@
|
||||
return () => gateway.stop();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (inConversations) {
|
||||
untrack(() => {
|
||||
if (navBefore === null) {
|
||||
navBefore = ui.nav;
|
||||
ui.setNav(false);
|
||||
}
|
||||
});
|
||||
} else if (navBefore !== null) {
|
||||
ui.setNav(navBefore);
|
||||
navBefore = null;
|
||||
}
|
||||
});
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "b") {
|
||||
event.preventDefault();
|
||||
ui.toggleNav();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<svelte:head><link href={favicon} rel="icon"></svelte:head>
|
||||
|
||||
<ModeWatcher />
|
||||
|
||||
@@ -1,26 +1,66 @@
|
||||
<script lang="ts">
|
||||
import PanelLeftOpenIcon from "@lucide/svelte/icons/panel-left-open";
|
||||
import { page } from "$app/state";
|
||||
import ConversationList from "$lib/components/conversation-list.svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { gateway } from "$lib/gateway.svelte";
|
||||
import { ui } from "$lib/ui.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const selected = $derived(page.params.id ?? null);
|
||||
const running = $derived(gateway.running.length);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Conversations · Beaver</title></svelte:head>
|
||||
|
||||
<div class="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[22rem_minmax(0,1fr)]">
|
||||
<div
|
||||
class={cn(
|
||||
"grid min-h-0 flex-1 grid-cols-1",
|
||||
ui.rail
|
||||
? "lg:grid-cols-[22rem_minmax(0,1fr)]"
|
||||
: "lg:grid-cols-[2.75rem_minmax(0,1fr)]"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
class={cn(
|
||||
"min-h-0 border-r bg-sidebar/40",
|
||||
selected ? "hidden lg:block" : "block"
|
||||
)}
|
||||
>
|
||||
<ConversationList {selected} />
|
||||
{#if ui.rail}
|
||||
<ConversationList {selected} />
|
||||
{:else}
|
||||
<div class="hidden h-full flex-col items-center gap-2 py-2 lg:flex">
|
||||
<Button
|
||||
aria-label="Show conversations"
|
||||
onclick={() => ui.toggleRail()}
|
||||
size="icon-sm"
|
||||
title="Show conversations"
|
||||
variant="ghost"
|
||||
>
|
||||
<PanelLeftOpenIcon class="size-4" />
|
||||
</Button>
|
||||
{#if running > 0}
|
||||
<span
|
||||
class="tabular rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
|
||||
title="{running} running"
|
||||
>
|
||||
{running}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="h-full lg:hidden">
|
||||
<ConversationList {selected} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class={cn("min-h-0", selected ? "flex flex-col" : "hidden lg:flex lg:flex-col")}
|
||||
class={cn(
|
||||
"min-h-0",
|
||||
selected ? "flex flex-col" : "hidden lg:flex lg:flex-col"
|
||||
)}
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -297,3 +297,60 @@
|
||||
@utility hairline {
|
||||
border-color: color-mix(in oklab, var(--border) 100%, transparent);
|
||||
}
|
||||
|
||||
/* chat markdown: typography plugin on the theme tokens, compact rhythm */
|
||||
.prose {
|
||||
--tw-prose-body: var(--foreground);
|
||||
--tw-prose-headings: var(--foreground);
|
||||
--tw-prose-lead: var(--muted-foreground);
|
||||
--tw-prose-links: var(--link);
|
||||
--tw-prose-bold: var(--foreground);
|
||||
--tw-prose-counters: var(--muted-foreground);
|
||||
--tw-prose-bullets: var(--muted-foreground);
|
||||
--tw-prose-hr: var(--border);
|
||||
--tw-prose-quotes: var(--foreground);
|
||||
--tw-prose-quote-borders: var(--border);
|
||||
--tw-prose-captions: var(--muted-foreground);
|
||||
--tw-prose-code: var(--foreground);
|
||||
--tw-prose-pre-code: var(--foreground);
|
||||
--tw-prose-pre-bg: var(--muted);
|
||||
--tw-prose-th-borders: var(--border);
|
||||
--tw-prose-td-borders: var(--border);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.prose
|
||||
:where(p, ul, ol, pre, blockquote, table):not(
|
||||
:where([class~="not-prose"] *)
|
||||
) {
|
||||
margin-block: 0.4em;
|
||||
}
|
||||
.prose :where(h1, h2, h3, h4):not(:where([class~="not-prose"] *)) {
|
||||
margin-block: 0.8em 0.4em;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose :where(li):not(:where([class~="not-prose"] *)) {
|
||||
margin-block: 0.15em;
|
||||
}
|
||||
.prose :where(code):not(:where([class~="not-prose"] *))::before,
|
||||
.prose :where(code):not(:where([class~="not-prose"] *))::after {
|
||||
content: none;
|
||||
}
|
||||
.prose :where(code):not(:where(pre *)):not(:where([class~="not-prose"] *)) {
|
||||
padding: 0.1em 0.3em;
|
||||
font-weight: 500;
|
||||
background: color-mix(in oklch, var(--muted) 70%, transparent);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.prose :where(pre):not(:where([class~="not-prose"] *)) {
|
||||
padding: 0.6em 0.8em;
|
||||
font-size: 0.8125rem;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.prose > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.prose > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user