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 6b7de6f03d
commit 256b2a68d6
60 changed files with 5071 additions and 2347 deletions
+176 -79
View File
@@ -1,15 +1,16 @@
<script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import GitBranchIcon from "@lucide/svelte/icons/git-branch";
import type { ApiClient } from "$lib/api/client";
import type { LiveState } from "$lib/api/live.svelte";
import type { ConversationInfo } from "$lib/api/types";
import KindBadge from "$lib/components/kind-badge.svelte";
import LiveDot from "$lib/components/live-dot.svelte";
import StatusPill from "$lib/components/status-pill.svelte";
import { Button } from "$lib/components/ui/button";
import { clip, fmtRelative, shortId } from "$lib/format";
import { fmtRelative, fmtTokens, shortId } from "$lib/format";
import KindMark from "$lib/shell/kind-mark.svelte";
import { cn } from "$lib/utils";
import BindingsList from "./bindings-list.svelte";
import BranchDialog from "./branch-dialog.svelte";
import ConversationMenu from "./conversation-menu.svelte";
import QueueList from "./queue-list.svelte";
let {
client,
@@ -20,63 +21,119 @@
onChanged,
onOpen,
showTitle = true,
view = $bindable("chat"),
onContext,
}: {
client: ApiClient;
info: ConversationInfo;
liveState: LiveState;
liveDetail?: string | null;
// Where another conversation lives as a link; without it, parents open in place.
href?: ((id: string) => string) | null;
onChanged: () => void;
onOpen: (id: string) => void;
showTitle?: boolean;
view?: string;
onContext?: () => void;
} = $props();
let branchOpen = $state(false);
const VIEWS = [
{ label: "Thread", value: "chat" },
{ label: "Activity", value: "activity" },
{ label: "Raw", value: "raw" },
{ label: "Context", value: "context" },
];
const WINDOW_MAX = 56;
const windows = $derived(info.bindings.filter((b) => b.visible));
let branchOpen = $state(false);
let detailsOpen = $state(false);
const mood = $derived.by(() => {
if (info.running_turn) {
return {
dot: "bg-signal animate-pulse-dot",
text: "in motion",
tone: "text-signal",
};
}
if (info.pending_question) {
return {
dot: "bg-attention",
text: "waiting for you",
tone: "text-attention-foreground",
};
}
if (info.status !== "open") {
return {
dot: "bg-muted-foreground/50",
text: info.status,
tone: "text-muted-foreground",
};
}
return {
dot: "bg-muted-foreground/50",
text: `quiet · ${fmtRelative(info.last_activity_at)}`,
tone: "text-muted-foreground",
};
});
const queued = $derived(
info.queue.filter((item) => item.status === "queued").length
);
const connection = $derived(
liveState === "open" ? null : (liveDetail ?? liveState)
);
</script>
<header class="flex flex-col gap-1.5 border-b px-3 py-2.5 @md:px-6">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<header class="flex flex-col border-b bg-background/80 backdrop-blur-md">
<div class="flex items-center gap-2 px-3 py-2 @md:px-6">
{#if showTitle}
<KindBadge kind={info.kind} />
<KindMark kind={info.kind} />
<h1 class="min-w-0 truncate font-semibold text-base tracking-tight">
{info.title || `${info.kind} ${shortId(info.id)}`}
</h1>
{/if}
<StatusPill status={info.running_turn ? "running" : info.status} />
{#if info.pending_question}
<span class="font-medium text-link text-xs">question pending</span>
<span class={cn("flex shrink-0 items-center gap-1.5 text-xs", mood.tone)}>
<span class={cn("size-1.5 rounded-full", mood.dot)}></span>
<span class="hidden @sm:inline">{mood.text}</span>
</span>
{#if queued > 0}
<span
class="tabular text-note text-xs"
title="messages waiting for the next turn"
>
+{queued}
</span>
{/if}
<div class="ml-auto flex items-center gap-1">
<LiveDot
class="hidden @md:inline-flex"
detail={liveDetail}
state={liveState}
/>
<LiveDot
class="@md:hidden"
detail={liveDetail}
label={false}
state={liveState}
/>
<Button
{#if connection}
<span class="truncate text-warn text-xs" title={liveDetail ?? undefined}>
{connection}
</span>
{/if}
<div class="ml-auto flex shrink-0 items-center gap-1">
{#if info.context_tokens}
<button
class="rule-word tabular inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs hover:bg-accent"
onclick={() => {
view = "context";
onContext?.();
}}
title="context of the last turn - what the model is holding"
type="button"
>
{fmtTokens(info.context_tokens)}
<span class="text-muted-foreground/70">/ 1M</span>
</button>
{/if}
<button
aria-label="Branch off this conversation"
class="rule-word inline-flex size-7 items-center justify-center rounded-md hover:bg-accent disabled:opacity-40"
disabled={info.status !== "open"}
onclick={() => {
branchOpen = true;
}}
size="sm"
variant="outline"
title="Branch off"
type="button"
>
<GitBranchIcon class="size-4" />
<span class="hidden @md:inline">Branch</span>
</Button>
</button>
<ConversationMenu
{client}
current
@@ -86,59 +143,99 @@
{onChanged}
{onOpen}
/>
<button
aria-expanded={detailsOpen}
aria-label="Details"
class={cn(
"rule-word inline-flex size-7 items-center justify-center rounded-md hover:bg-accent",
detailsOpen && "bg-accent text-foreground"
)}
onclick={() => {
detailsOpen = !detailsOpen;
}}
title="Details"
type="button"
>
<ChevronDownIcon
class={cn("size-4 transition-transform", detailsOpen && "rotate-180")}
/>
</button>
</div>
</div>
<!-- Below @md the column is the live thread's; the rest of this lives in Meta. -->
<div
class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs"
>
<span class="hidden @md:inline">{info.agent}</span>
<span class="hidden @md:inline">via {info.origin}</span>
{#if info.parent}
{#if href}
<a class="text-link hover:underline" href={href(info.parent)}>
parent {shortId(info.parent)}
</a>
{:else}
<button
class="text-link hover:underline"
onclick={() => info.parent && onOpen(info.parent)}
type="button"
>
parent {shortId(info.parent)}
</button>
{/if}
{/if}
<span class="tabular hidden @md:inline" title={info.id}>
{shortId(info.id)}
</span>
<span class="tabular hidden @md:inline">
{info.live ? "session live" : "no live session"}
{info.busy ? " · busy" : ""}
</span>
<span class="tabular">
last activity {fmtRelative(info.last_activity_at)}
</span>
{#if queued > 0}
<span
class="tabular font-medium text-note"
title="messages waiting for the next turn"
<div class="flex items-center gap-4 px-3 pb-1.5 @md:px-6">
{#each VIEWS as item (item.value)}
<button
class="rule-word text-xs"
data-active={view === item.value}
onclick={() => {
view = item.value;
}}
type="button"
>
{queued}
queued
</span>
{/if}
{item.label}
</button>
{/each}
</div>
{#if windows.length > 0}
{#if detailsOpen}
<div
class="hidden flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs @md:flex"
class="animate-fade-in grid gap-x-6 gap-y-3 border-t px-3 py-3 text-xs @md:grid-cols-2 @md:px-6"
>
{#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}
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1">
<dt class="text-muted-foreground">agent</dt>
<dd>{info.agent}</dd>
<dt class="text-muted-foreground">opened via</dt>
<dd>{info.origin}</dd>
{#if info.parent}
<dt class="text-muted-foreground">parent</dt>
<dd>
{#if href}
<a class="text-link hover:underline" href={href(info.parent)}>
{shortId(info.parent)}
</a>
{:else}
<button
class="text-link hover:underline"
onclick={() => info.parent && onOpen(info.parent)}
type="button"
>
{shortId(info.parent)}
</button>
{/if}
</dd>
{/if}
<dt class="text-muted-foreground">id</dt>
<dd class="tabular break-all">{info.id}</dd>
<dt class="text-muted-foreground">session</dt>
<dd class="tabular break-all">
{info.session_id ?? "none yet"}
{info.live ? " · process alive" : ""}
</dd>
{#if Object.keys(info.flags).length > 0}
<dt class="text-muted-foreground">flags</dt>
<dd class="tabular">
{Object.entries(info.flags)
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.join(" ")}
</dd>
{/if}
</dl>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1">
<span class="text-muted-foreground">windows</span>
<BindingsList
bindings={info.bindings}
{client}
conversationId={info.id}
{onChanged}
/>
</div>
{#if info.queue.length > 0}
<div class="flex flex-col gap-1">
<span class="text-muted-foreground">queue</span>
<QueueList items={info.queue} />
</div>
{/if}
</div>
</div>
{/if}
</header>