feat(ui,api): strip board redesign - island, rail by day, context view, server search, vault graph

This commit is contained in:
hh
2026-09-02 03:48:26 +02:00
parent 85b14e2c2f
commit 025f2dbc3f
60 changed files with 5071 additions and 2347 deletions
+98 -56
View File
@@ -1,14 +1,11 @@
<script lang="ts">
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import FileTextIcon from "@lucide/svelte/icons/file-text";
import FolderIcon from "@lucide/svelte/icons/folder";
import { onMount } from "svelte";
import { page } from "$app/state";
import type { MemoryFile, MemoryNode, MemoryTree } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import PageHeader from "$lib/components/page-header.svelte";
import { Skeleton } from "$lib/components/ui/skeleton";
import { fmtBytes, fmtDateTime, fmtRelative } from "$lib/format";
import Markdown from "$lib/panel/markdown.svelte";
import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils";
@@ -18,7 +15,9 @@
let selected = $state<string | null>(null);
let file = $state<MemoryFile | null>(null);
let fileError = $state<string | null>(null);
let collapsed = $state<Set<string>>(new Set());
let expanded = $state<Set<string>>(new Set());
let raw = $state(false);
const MD_SUFFIX = /\.md$/;
async function load() {
const { client } = session;
@@ -47,6 +46,12 @@
selected = path;
file = null;
fileError = null;
const parts = path.split("/");
const next = new Set(expanded);
for (let i = 1; i < parts.length; i += 1) {
next.add(parts.slice(0, i).join("/"));
}
expanded = next;
try {
file = await client.memoryFile(path);
} catch (cause) {
@@ -55,16 +60,22 @@
}
function toggle(path: string) {
const next = new Set(collapsed);
const next = new Set(expanded);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
collapsed = next;
expanded = next;
}
onMount(load);
onMount(async () => {
await load();
const wanted = page.url.searchParams.get("path");
if (wanted) {
open(wanted);
}
});
const count = $derived.by(() => {
let files = 0;
@@ -80,67 +91,94 @@
walk(tree?.tree ?? []);
return files;
});
const isMarkdown = $derived(file?.path.endsWith(".md") ?? false);
</script>
<svelte:head><title>Memory · Beaver</title></svelte:head>
<PageHeader
subtitle={tree ? `${count} files · ${tree.root}` : ""}
title="Memory"
/>
<div class="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[18rem_minmax(0,1fr)]">
<div class="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[20rem_minmax(0,1fr)]">
<div
class="min-h-0 overflow-y-auto border-b bg-sidebar/50 p-2 md:border-r md:border-b-0"
class="min-h-0 overflow-y-auto border-b px-3 py-3 md:border-r md:border-b-0"
>
<div class="flex items-baseline gap-2 px-1 pb-2">
<h1 class="font-semibold text-base tracking-tight">Memory</h1>
{#if tree}
<span class="tabular text-muted-foreground text-xs">{count} files</span>
{/if}
</div>
{#if failure}
<ErrorNote message={failure} retry={load} />
{:else if notConfigured}
<EmptyState
hint="Set ApiFrontend(memory_root=...) in config.py to the agent's zone of the vault."
title="No memory root"
/>
<p class="doc px-1 text-muted-foreground text-sm">
No memory root. Point ApiFrontend(memory_root=…) at the agent's zone of
the vault.
</p>
{:else if !tree}
<div class="flex flex-col gap-2 p-1">
<Skeleton class="h-6 w-3/4" />
<Skeleton class="h-6 w-1/2" />
<Skeleton class="h-6 w-2/3" />
</div>
<p class="px-1 text-muted-foreground text-sm">Loading…</p>
{:else if tree.tree.length === 0}
<EmptyState hint="The zone is empty." title="Nothing here" />
<p class="px-1 text-muted-foreground text-sm">The zone is empty.</p>
{:else}
{@render nodes(tree.tree, 0)}
{/if}
</div>
<div class="min-h-0 overflow-y-auto">
{#if !selected}
<div class="p-6 text-muted-foreground text-sm">
Pick a file to read it. This is the agent's own zone: state, handouts,
prompts, skills - everything it can write.
<div class="mx-auto max-w-2xl px-6 py-10 text-muted-foreground text-sm">
<p>
The agent's own zone: state, handouts, observations, prompts, skills.
Pick a file on the left, or search for a line with ⌘K.
</p>
</div>
{:else if fileError}
<div class="p-4">
<ErrorNote message={fileError} retry={() => open(selected ?? "")} />
</div>
{:else if !file}
<div class="flex flex-col gap-2 p-6">
<Skeleton class="h-5 w-1/3" />
<Skeleton class="h-40 w-full" />
</div>
<p class="px-6 py-10 text-center text-muted-foreground text-xs">
Opening…
</p>
{:else}
<div class="flex items-baseline gap-3 border-b px-4 py-2 text-xs sm:px-6">
<span class="font-medium text-sm">{file.path}</span>
<span class="tabular text-muted-foreground">{fmtBytes(file.size)}</span>
<span
class="tabular text-muted-foreground"
title={fmtDateTime(file.mtime)}
>
modified {fmtRelative(file.mtime)}
</span>
</div>
<pre
class="max-w-[90ch] px-4 py-3 text-sm whitespace-pre-wrap break-words sm:px-6"
>{file.content}</pre>
<article class="mx-auto flex w-full max-w-3xl flex-col gap-4 px-6 py-6">
<header class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h2 class="min-w-0 truncate font-semibold text-lg tracking-tight">
{file.path.split("/").at(-1)?.replace(MD_SUFFIX, "")}
</h2>
{#if file.path.includes("/")}
<span class="truncate text-muted-foreground text-xs">
{file.path.split("/").slice(0, -1).join(" / ")}
</span>
{/if}
<span class="tabular text-muted-foreground text-xs">
{fmtBytes(file.size)}
</span>
<span
class="tabular text-muted-foreground text-xs"
title={fmtDateTime(file.mtime)}
>
{fmtRelative(file.mtime)}
</span>
{#if isMarkdown}
<button
class="rule-word ml-auto text-xs"
data-active={raw}
onclick={() => {
raw = !raw;
}}
type="button"
>
{raw ? "rendered" : "source"}
</button>
{/if}
</header>
{#if isMarkdown && !raw}
<Markdown class="prose-base" text={file.content} />
{:else}
<pre
class="whitespace-pre-wrap break-words text-sm leading-relaxed"
>{file.content}</pre>
{/if}
</article>
{/if}
</div>
</div>
@@ -150,23 +188,28 @@
{#each list as node (node.path)}
<li>
{#if node.type === "dir"}
{@const isOpen = expanded.has(node.path)}
<button
aria-expanded={!collapsed.has(node.path)}
aria-expanded={isOpen}
class="row-hover flex h-7 w-full items-center gap-1.5 rounded-md pr-2 text-left text-sm"
onclick={() => toggle(node.path)}
style="padding-left: {depth * 0.75 + 0.25}rem"
style="padding-left: {depth * 0.875 + 0.25}rem"
type="button"
>
<ChevronRightIcon
class={cn(
"size-3.5 shrink-0 text-icon transition-transform duration-150",
!collapsed.has(node.path) && "rotate-90"
isOpen && "rotate-90"
)}
/>
<FolderIcon class="size-4 shrink-0 text-icon" />
<span class="truncate">{node.name}</span>
<span class="truncate font-medium">{node.name}</span>
{#if !isOpen && node.children}
<span class="tabular ml-auto text-muted-foreground text-xs">
{node.children.length}
</span>
{/if}
</button>
{#if !collapsed.has(node.path) && node.children}
{#if isOpen && node.children}
{@render nodes(node.children, depth + 1)}
{/if}
{:else}
@@ -174,14 +217,13 @@
aria-current={selected === node.path ? "true" : undefined}
class={cn(
"row-hover flex h-7 w-full items-center gap-1.5 rounded-md pr-2 text-left text-sm",
selected === node.path && "bg-sidebar-accent font-medium"
selected === node.path && "bg-accent font-medium"
)}
onclick={() => open(node.path)}
style="padding-left: {depth * 0.75 + 1.5}rem"
style="padding-left: {depth * 0.875 + 1.5}rem"
type="button"
>
<FileTextIcon class="size-4 shrink-0 text-icon" />
<span class="truncate">{node.name}</span>
<span class="truncate">{node.name.replace(MD_SUFFIX, "")}</span>
<span class="tabular ml-auto text-muted-foreground text-xs">
{fmtBytes(node.size)}
</span>