feat: one gateway port with path-mounted frontends, markdown chat, collapsible sidebars

This commit is contained in:
hh
2026-08-29 00:49:06 +02:00
parent 3ca5843868
commit 932a7a80af
32 changed files with 691 additions and 441 deletions
+5 -5
View File
@@ -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>
+22 -1
View File
@@ -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}>
+46 -63
View File
@@ -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"
+14
View File
@@ -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>
+18
View File
@@ -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 -10
View File
@@ -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…