feat(ui,api): strip board redesign - island, rail by day, context view, server search, vault graph
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
// A small keyed cache behind the panel: the conversation index and the
|
||||
// last threads land here so the next open paints before the network
|
||||
// answers. localStorage when it exists, memory otherwise.
|
||||
export interface PanelCache {
|
||||
get: <T>(key: string) => T | null;
|
||||
set: (key: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
const memory = new Map<string, string>();
|
||||
|
||||
function storage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
return localStorage;
|
||||
}
|
||||
} catch {
|
||||
/* sandboxed */
|
||||
}
|
||||
return {
|
||||
getItem: (key) => memory.get(key) ?? null,
|
||||
removeItem: (key) => {
|
||||
memory.delete(key);
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
memory.set(key, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function panelCache(prefix: string): PanelCache {
|
||||
const store = storage();
|
||||
return {
|
||||
get<T>(key: string): T | null {
|
||||
try {
|
||||
const raw = store.getItem(`${prefix}:${key}`);
|
||||
return raw ? (JSON.parse(raw) as T) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
set(key: string, value: unknown): void {
|
||||
try {
|
||||
store.setItem(`${prefix}:${key}`, JSON.stringify(value));
|
||||
} catch {
|
||||
store.removeItem(`${prefix}:${key}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { tick, untrack } from "svelte";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import type { ContentBlock, HistoryMessage } from "$lib/api/types";
|
||||
import EmptyState from "$lib/components/empty-state.svelte";
|
||||
import ErrorNote from "$lib/components/error-note.svelte";
|
||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
||||
import { Switch } from "$lib/components/ui/switch";
|
||||
import { clip } from "$lib/format";
|
||||
import { cn } from "$lib/utils";
|
||||
import type { ActivityModel } from "./activity.svelte";
|
||||
import { summarizeInput, toolLabel } from "./activity.svelte";
|
||||
import { usePanelHost } from "./host";
|
||||
import Markdown from "./markdown.svelte";
|
||||
import QuestionCard from "./question-card.svelte";
|
||||
import TurnCard from "./turn-card.svelte";
|
||||
@@ -27,6 +26,8 @@
|
||||
} = $props();
|
||||
|
||||
const RESULT_CLIP = 600;
|
||||
const CACHED_MESSAGES = 80;
|
||||
const host = usePanelHost();
|
||||
const NEAR_BOTTOM_PX = 120;
|
||||
const TICK_MS = 1000;
|
||||
|
||||
@@ -53,9 +54,21 @@
|
||||
|
||||
async function load() {
|
||||
failure = null;
|
||||
if (messages === null) {
|
||||
const cached = host.cache?.get<HistoryMessage[]>(
|
||||
`history:${conversationId}`
|
||||
);
|
||||
if (cached) {
|
||||
messages = cached;
|
||||
}
|
||||
}
|
||||
try {
|
||||
({ messages } = await client.history(conversationId));
|
||||
loadedAt = new Date().toISOString();
|
||||
host.cache?.set(
|
||||
`history:${conversationId}`,
|
||||
messages.slice(-CACHED_MESSAGES)
|
||||
);
|
||||
} catch (cause) {
|
||||
failure = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
@@ -80,7 +93,7 @@
|
||||
$effect(() => {
|
||||
if (refreshKey >= 0) {
|
||||
pinned = true;
|
||||
load();
|
||||
untrack(() => load());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -115,6 +128,28 @@
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
|
||||
const SYSTEM_HEAD = /^\[[^\]\n]+\]/;
|
||||
let openSystem = $state<Set<number>>(new Set());
|
||||
|
||||
function systemText(message: HistoryMessage): string | null {
|
||||
if (message.role !== "user" || typeof message.content !== "string") {
|
||||
return null;
|
||||
}
|
||||
return SYSTEM_HEAD.test(message.content.trimStart())
|
||||
? message.content
|
||||
: null;
|
||||
}
|
||||
|
||||
function toggleSystem(index: number) {
|
||||
const next = new Set(openSystem);
|
||||
if (next.has(index)) {
|
||||
next.delete(index);
|
||||
} else {
|
||||
next.add(index);
|
||||
}
|
||||
openSystem = next;
|
||||
}
|
||||
|
||||
function blocks(message: HistoryMessage): ContentBlock[] {
|
||||
return typeof message.content === "string"
|
||||
? [{ text: message.content, type: "text" }]
|
||||
@@ -147,20 +182,22 @@
|
||||
bind:this={scroller}
|
||||
>
|
||||
<div class="flex flex-col gap-3" bind:this={body}>
|
||||
<span
|
||||
class="flex items-center gap-2 self-end text-muted-foreground text-xs"
|
||||
<button
|
||||
class="rule-word self-end text-xs"
|
||||
data-active={showResults}
|
||||
onclick={() => {
|
||||
showResults = !showResults;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Switch aria-label="Show tool results" bind:checked={showResults} />
|
||||
show tool results
|
||||
</span>
|
||||
{showResults ? "hide tool results" : "show tool results"}
|
||||
</button>
|
||||
{#if failure}
|
||||
<ErrorNote message={failure} retry={load} />
|
||||
{:else if messages === null}
|
||||
<div class="flex flex-col gap-2">
|
||||
<Skeleton class="h-10 w-2/3" />
|
||||
<Skeleton class="h-16 w-full" />
|
||||
<Skeleton class="h-10 w-1/2" />
|
||||
</div>
|
||||
<p class="py-6 text-center text-muted-foreground text-xs">
|
||||
Loading the thread…
|
||||
</p>
|
||||
{:else if messages.length === 0 && tail.length === 0}
|
||||
<EmptyState
|
||||
hint="Nothing has been said here yet. Write below, or wait for the first inject."
|
||||
@@ -170,7 +207,21 @@
|
||||
{#each messages as message, index (index)}
|
||||
{@const parts = blocks(message)}
|
||||
{@const isResultOnly = parts.every((b) => b.type === "tool_result")}
|
||||
{#if !(isResultOnly && !showResults)}
|
||||
{@const system = systemText(message)}
|
||||
{#if system}
|
||||
<button
|
||||
aria-expanded={openSystem.has(index)}
|
||||
class="self-center max-w-[75ch] rounded-md px-2 py-1 text-left font-mono text-muted-foreground text-xs hover:bg-accent"
|
||||
onclick={() => toggleSystem(index)}
|
||||
type="button"
|
||||
>
|
||||
{#if openSystem.has(index)}
|
||||
<span class="whitespace-pre-wrap break-words">{system}</span>
|
||||
{:else}
|
||||
<span class="truncate">{clip(system.split("\n")[0], 120)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else if !(isResultOnly && !showResults)}
|
||||
<div
|
||||
class={cn(
|
||||
"flex flex-col gap-1.5",
|
||||
@@ -181,9 +232,11 @@
|
||||
{#if block.type === "text" && block.text}
|
||||
<Markdown
|
||||
class={cn(
|
||||
"max-w-[75ch] rounded-lg px-3 py-2",
|
||||
message.role === "user" ? "bg-primary/10" : "bg-muted/50"
|
||||
)}
|
||||
"max-w-[75ch]",
|
||||
message.role === "user"
|
||||
? "rounded-2xl rounded-br-md bg-primary/10 px-3.5 py-2"
|
||||
: "px-1 py-1"
|
||||
)}
|
||||
text={block.text}
|
||||
/>
|
||||
{:else if block.type === "tool_use"}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import SendHorizontalIcon from "@lucide/svelte/icons/send-horizontal";
|
||||
import ArrowUpIcon from "@lucide/svelte/icons/arrow-up";
|
||||
import { toast } from "svelte-sonner";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
let {
|
||||
client,
|
||||
@@ -17,28 +16,31 @@
|
||||
|
||||
type Mode = "message" | "inject" | "urgent";
|
||||
const MODES: { value: Mode; label: string; hint: string }[] = [
|
||||
{ hint: "as you, mirrored to the window", label: "Say", value: "message" },
|
||||
{
|
||||
hint: "as the user, mirrored to Telegram",
|
||||
label: "Message",
|
||||
value: "message",
|
||||
},
|
||||
{
|
||||
hint: "system note, rides with the next turn",
|
||||
hint: "a system note for the next turn",
|
||||
label: "Inject",
|
||||
value: "inject",
|
||||
},
|
||||
{
|
||||
hint: "interrupts the running turn",
|
||||
label: "Urgent inject",
|
||||
value: "urgent",
|
||||
},
|
||||
{ hint: "interrupts the running turn", label: "Urgent", value: "urgent" },
|
||||
];
|
||||
const MAX_ROWS = 10;
|
||||
const LINE_PX = 22;
|
||||
|
||||
let mode = $state<Mode>("message");
|
||||
let text = $state("");
|
||||
let busy = $state(false);
|
||||
let area = $state<HTMLTextAreaElement | null>(null);
|
||||
const current = $derived(MODES.find((m) => m.value === mode) ?? MODES[0]);
|
||||
|
||||
function grow() {
|
||||
if (!area) {
|
||||
return;
|
||||
}
|
||||
area.style.height = "auto";
|
||||
area.style.height = `${Math.min(area.scrollHeight, LINE_PX * MAX_ROWS)}px`;
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const body = text.trim();
|
||||
if (!body || busy) {
|
||||
@@ -56,6 +58,7 @@
|
||||
);
|
||||
}
|
||||
text = "";
|
||||
queueMicrotask(grow);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
@@ -72,57 +75,60 @@
|
||||
</script>
|
||||
|
||||
<form
|
||||
class="flex flex-col gap-2 border-t bg-background px-3 py-2"
|
||||
class="flex flex-col gap-1.5 px-3 pt-2 pb-[max(0.5rem,var(--beaver-inset-bottom,0px))] @md:px-6"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
send();
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
aria-label="Message"
|
||||
class="min-h-16 w-full resize-y rounded-md border bg-input/30 px-3 py-2 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
|
||||
{disabled}
|
||||
onkeydown={onKeydown}
|
||||
placeholder={mode === "message"
|
||||
? "Say something to the agent…"
|
||||
: "Text of the inject…"}
|
||||
rows="2"
|
||||
bind:value={text}
|
||||
></textarea>
|
||||
<div class="flex items-center gap-2">
|
||||
<Select.Root
|
||||
onValueChange={(value) => {
|
||||
mode = value as Mode;
|
||||
}}
|
||||
type="single"
|
||||
value={mode}
|
||||
>
|
||||
<Select.Trigger class="h-8 text-xs" size="sm"
|
||||
>{current.label}</Select.Trigger
|
||||
>
|
||||
<Select.Content>
|
||||
{#each MODES as option (option.value)}
|
||||
<Select.Item label={option.label} value={option.value}>
|
||||
<span class="flex flex-col">
|
||||
<span>{option.label}</span>
|
||||
<span class="text-muted-foreground text-xs">{option.hint}</span>
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<span class="hidden text-muted-foreground text-xs @md:inline"
|
||||
>{current.hint}</span
|
||||
>
|
||||
<Button
|
||||
class="ml-auto"
|
||||
<div
|
||||
class={cn(
|
||||
"flex items-end gap-2 rounded-xl bg-strip px-3 py-2 shadow-lift ring-1 ring-border focus-within:ring-ring",
|
||||
disabled && "opacity-60"
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
aria-label="Message"
|
||||
class="max-h-56 min-h-6 w-full resize-none bg-transparent py-0.5 text-sm leading-[22px] outline-none placeholder:text-muted-foreground"
|
||||
{disabled}
|
||||
oninput={grow}
|
||||
onkeydown={onKeydown}
|
||||
placeholder={disabled
|
||||
? "This conversation is closed."
|
||||
: `${current.label} something… (⌘↩)`}
|
||||
rows="1"
|
||||
bind:this={area}
|
||||
bind:value={text}
|
||||
></textarea>
|
||||
<button
|
||||
aria-label="Send"
|
||||
class="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity disabled:opacity-30"
|
||||
disabled={disabled || busy || !text.trim()}
|
||||
size="sm"
|
||||
type="submit"
|
||||
>
|
||||
<SendHorizontalIcon class="size-4" />
|
||||
Send
|
||||
<kbd class="hidden text-[10px] opacity-70 @md:inline">⌘↩</kbd>
|
||||
</Button>
|
||||
<ArrowUpIcon class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 px-1">
|
||||
{#each MODES as option (option.value)}
|
||||
<button
|
||||
class={cn(
|
||||
"rule-word text-xs",
|
||||
mode === option.value && "text-foreground",
|
||||
option.value === "urgent" && mode === option.value && "text-destructive"
|
||||
)}
|
||||
data-active={mode === option.value}
|
||||
onclick={() => {
|
||||
mode = option.value;
|
||||
}}
|
||||
title={option.hint}
|
||||
type="button"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
{/each}
|
||||
<span class="hidden truncate text-muted-foreground text-xs @md:inline">
|
||||
{current.hint}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import type { ContextResponse, VaultGraph } from "$lib/api/types";
|
||||
import ErrorNote from "$lib/components/error-note.svelte";
|
||||
import { fmtRelative, fmtTokens } from "$lib/format";
|
||||
import Graph, {
|
||||
type GraphEdge,
|
||||
type GraphNode,
|
||||
} from "$lib/shell/graph.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
import { usePanelHost } from "./host";
|
||||
|
||||
let {
|
||||
client,
|
||||
conversationId,
|
||||
window = 1_000_000,
|
||||
}: {
|
||||
client: ApiClient;
|
||||
conversationId: string;
|
||||
window?: number;
|
||||
} = $props();
|
||||
|
||||
const HARNESS_EST = 15_000;
|
||||
const FILES_SHOWN = 12;
|
||||
const MD_SUFFIX = /\.md$/;
|
||||
|
||||
const host = usePanelHost();
|
||||
let data = $state<ContextResponse | null>(null);
|
||||
let graph = $state<VaultGraph | null>(null);
|
||||
let graphError = $state<string | null>(null);
|
||||
let failure = $state<string | null>(null);
|
||||
let promptOpen = $state(false);
|
||||
let promptText = $state<string | null>(null);
|
||||
let filesOpen = $state(false);
|
||||
|
||||
async function load() {
|
||||
failure = null;
|
||||
try {
|
||||
data = await client.context(conversationId);
|
||||
} catch (cause) {
|
||||
failure = cause instanceof Error ? cause.message : String(cause);
|
||||
return;
|
||||
}
|
||||
const paths = data.files.map((f) => f.path);
|
||||
if (paths.length === 0) {
|
||||
graph = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
graph = await client.vaultGraph(paths.slice(0, 40));
|
||||
graphError = null;
|
||||
} catch (cause) {
|
||||
graph = null;
|
||||
graphError = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
|
||||
async function showPrompt() {
|
||||
promptOpen = !promptOpen;
|
||||
if (promptOpen && promptText === null) {
|
||||
try {
|
||||
const full = await client.context(conversationId, true);
|
||||
promptText = full.prompt_text ?? "";
|
||||
} catch (cause) {
|
||||
promptText = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
interface Layer {
|
||||
label: string;
|
||||
tokens: number;
|
||||
tone: string;
|
||||
}
|
||||
|
||||
const layers = $derived.by((): Layer[] => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
const prompt = data.prompt.tokens_est;
|
||||
const skills = data.skills_tokens_est;
|
||||
const harness = Math.min(
|
||||
HARNESS_EST,
|
||||
Math.max(0, data.context_tokens - prompt - skills)
|
||||
);
|
||||
const history = Math.max(
|
||||
0,
|
||||
data.context_tokens - prompt - skills - harness
|
||||
);
|
||||
return [
|
||||
{ label: "harness", tokens: harness, tone: "bg-muted-foreground/40" },
|
||||
{ label: "prompt", tokens: prompt, tone: "bg-primary" },
|
||||
{ label: "skills", tokens: skills, tone: "bg-kind-deep" },
|
||||
{ label: "history & tools", tokens: history, tone: "bg-kind-branch" },
|
||||
];
|
||||
});
|
||||
const total = $derived(data?.context_tokens ?? 0);
|
||||
const share = (tokens: number) =>
|
||||
total > 0 ? `${Math.max(0.5, (tokens / total) * 100)}%` : "0%";
|
||||
|
||||
const graphNodes = $derived.by((): GraphNode[] => {
|
||||
if (!graph) {
|
||||
return [];
|
||||
}
|
||||
const single = graph.nodes.filter((n) => n.touched).length === 1;
|
||||
return graph.nodes.map((n) => ({
|
||||
href: n.exists ? n.path : undefined,
|
||||
id: n.path,
|
||||
kind: "file",
|
||||
label: n.title,
|
||||
ring: ringOf(n.touched, single),
|
||||
state: stateOf(n.touched, n.exists),
|
||||
}));
|
||||
});
|
||||
const graphEdges = $derived.by((): GraphEdge[] =>
|
||||
graph ? graph.edges.map((e) => ({ from: e.from, to: e.to })) : []
|
||||
);
|
||||
const files = $derived(
|
||||
filesOpen ? (data?.files ?? []) : (data?.files ?? []).slice(0, FILES_SHOWN)
|
||||
);
|
||||
|
||||
function ringOf(touched: boolean, single: boolean): number {
|
||||
if (!touched) {
|
||||
return 2;
|
||||
}
|
||||
return single ? 0 : 1;
|
||||
}
|
||||
|
||||
function stateOf(touched: boolean, exists: boolean): GraphNode["state"] {
|
||||
if (!touched) {
|
||||
return "ghost";
|
||||
}
|
||||
return exists ? "quiet" : "closed";
|
||||
}
|
||||
|
||||
function openNote(path: string) {
|
||||
host.openLink(path.replace(MD_SUFFIX, ""), "internal");
|
||||
}
|
||||
|
||||
function touch(file: {
|
||||
reads: number;
|
||||
writes: number;
|
||||
other: number;
|
||||
}): string {
|
||||
const parts: string[] = [];
|
||||
if (file.reads) {
|
||||
parts.push(`read ×${file.reads}`);
|
||||
}
|
||||
if (file.writes) {
|
||||
parts.push(`wrote ×${file.writes}`);
|
||||
}
|
||||
if (file.other) {
|
||||
parts.push(`touched ×${file.other}`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="@container flex max-w-5xl flex-col gap-8">
|
||||
{#if failure}
|
||||
<ErrorNote message={failure} retry={load} />
|
||||
{:else if !data}
|
||||
<p class="text-muted-foreground text-sm">Reading the context…</p>
|
||||
{:else}
|
||||
<section class="flex flex-col gap-3">
|
||||
<div class="flex items-baseline gap-3">
|
||||
<span
|
||||
class="tabular font-semibold text-3xl leading-none tracking-tight"
|
||||
>
|
||||
{fmtTokens(total)}
|
||||
</span>
|
||||
<span class="text-muted-foreground text-sm">
|
||||
of {fmtTokens(window)} · {data.turns}
|
||||
{data.turns === 1 ? "turn" : "turns"}
|
||||
· {data.agent.model}
|
||||
{data.agent.effort
|
||||
? ` · ${data.agent.effort}`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
{#each layers as layer (layer.label)}
|
||||
{#if layer.tokens > 0}
|
||||
<span
|
||||
class={cn("h-full", layer.tone)}
|
||||
style="width: {share(layer.tokens)}"
|
||||
title="{layer.label}: {fmtTokens(layer.tokens)}"
|
||||
></span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-5 gap-y-1 text-xs">
|
||||
{#each layers as layer (layer.label)}
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class={cn("size-2 rounded-full", layer.tone)}></span>
|
||||
<span class="text-muted-foreground">{layer.label}</span>
|
||||
<span class="tabular">{fmtTokens(layer.tokens)}</span>
|
||||
</span>
|
||||
{/each}
|
||||
<span class="text-muted-foreground/70"
|
||||
>estimates, except the total</span
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-8 @3xl:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
<div class="flex min-w-0 flex-col gap-8">
|
||||
<section class="flex flex-col gap-2">
|
||||
<div class="flex items-baseline gap-3">
|
||||
<h2 class="font-medium text-sm">System prompt</h2>
|
||||
<span class="tabular text-muted-foreground text-xs">
|
||||
{fmtTokens(data.prompt.tokens_est)}
|
||||
· {data.prompt.granules.length} granules
|
||||
</span>
|
||||
<button
|
||||
class="rule-word ml-auto text-xs"
|
||||
data-active={promptOpen}
|
||||
onclick={showPrompt}
|
||||
type="button"
|
||||
>
|
||||
{promptOpen ? "hide text" : "read it"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="bay-rack">
|
||||
{#each data.prompt.granules as g, index (index)}
|
||||
<div class="strip text-sm">
|
||||
<span
|
||||
class="size-2 justify-self-center rounded-full bg-primary"
|
||||
></span>
|
||||
<span class="flex min-w-0 flex-col leading-tight">
|
||||
<button
|
||||
class="truncate text-left font-medium hover:underline"
|
||||
onclick={() => g.path && openNote(g.path)}
|
||||
type="button"
|
||||
>
|
||||
{g.path ?? "verbatim prompt"}
|
||||
</button>
|
||||
{#if g.tag}
|
||||
<span
|
||||
class="truncate font-mono text-[10px] text-muted-foreground"
|
||||
>
|
||||
<{g.tag}>
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="tabular text-muted-foreground text-xs">
|
||||
{fmtTokens(g.tokens_est)}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if promptOpen}
|
||||
<pre
|
||||
class="max-h-[32rem] overflow-auto rounded-lg border bg-strip p-3 text-xs whitespace-pre-wrap break-words"
|
||||
>{promptText ?? "…"}</pre>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-2">
|
||||
<div class="flex items-baseline gap-3">
|
||||
<h2 class="font-medium text-sm">Skills</h2>
|
||||
<span class="tabular text-muted-foreground text-xs">
|
||||
{data.skills.reduce((n, s) => n + s.skills.length, 0)}
|
||||
loaded
|
||||
</span>
|
||||
</div>
|
||||
{#if data.skills.length === 0}
|
||||
<p class="text-muted-foreground text-sm">None for this kind.</p>
|
||||
{:else}
|
||||
<div class="bay-rack">
|
||||
{#each data.skills as set (set.path)}
|
||||
{#each set.skills as skill (skill.path)}
|
||||
<div class="strip text-sm">
|
||||
<span
|
||||
class="size-2 justify-self-center rounded-full bg-kind-deep"
|
||||
></span>
|
||||
<span class="flex min-w-0 flex-col leading-tight">
|
||||
<span class="truncate font-medium">{skill.name}</span>
|
||||
{#if skill.description}
|
||||
<span class="truncate text-muted-foreground text-xs">
|
||||
{skill.description}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-muted-foreground text-xs">{set.set}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-2">
|
||||
<h2 class="font-medium text-sm">Tools</h2>
|
||||
<dl
|
||||
class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs"
|
||||
>
|
||||
{#if Object.keys(data.tool_counts).length > 0}
|
||||
<dt class="text-muted-foreground">used</dt>
|
||||
<dd class="tabular">
|
||||
{Object.entries(data.tool_counts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([name, n]) => `${name.replace("mcp__", "")} ×${n}`)
|
||||
.join(" ")}
|
||||
</dd>
|
||||
{/if}
|
||||
{#if data.tools.gateway.length}
|
||||
<dt class="text-muted-foreground">gateway</dt>
|
||||
<dd>{data.tools.gateway.join(", ")}</dd>
|
||||
{/if}
|
||||
{#if data.tools.mcps.length}
|
||||
<dt class="text-muted-foreground">mcp</dt>
|
||||
<dd>{data.tools.mcps.join(", ")}</dd>
|
||||
{/if}
|
||||
{#if data.tools.allowed}
|
||||
<dt class="text-muted-foreground">allowed</dt>
|
||||
<dd>{data.tools.allowed.join(", ")}</dd>
|
||||
{/if}
|
||||
{#if data.tools.disallowed.length}
|
||||
<dt class="text-muted-foreground">off</dt>
|
||||
<dd class="text-muted-foreground">
|
||||
{data.tools.disallowed.join(", ")}
|
||||
</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-8">
|
||||
<section class="flex flex-col gap-2">
|
||||
<div class="flex items-baseline gap-3">
|
||||
<h2 class="font-medium text-sm">Notes it reached</h2>
|
||||
<span class="tabular text-muted-foreground text-xs"
|
||||
>{data.files.length}</span
|
||||
>
|
||||
</div>
|
||||
{#if graph && graph.nodes.length > 0}
|
||||
<div class="rounded-lg border bg-strip/60 p-2">
|
||||
<Graph edges={graphEdges} nodes={graphNodes} onOpen={openNote} />
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Solid: read or written by this thread. Hollow: one link away, not
|
||||
reached.
|
||||
</p>
|
||||
{:else if graphError}
|
||||
<p class="text-muted-foreground text-xs">{graphError}</p>
|
||||
{/if}
|
||||
{#if data.files.length === 0}
|
||||
<p class="text-muted-foreground text-sm">No files touched yet.</p>
|
||||
{:else}
|
||||
<div class="bay-rack">
|
||||
{#each files as file (file.path)}
|
||||
<button
|
||||
class="strip w-full text-left text-sm"
|
||||
onclick={() => openNote(file.path)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class={cn(
|
||||
"size-2 justify-self-center rounded-full",
|
||||
file.writes ? "bg-primary" : "bg-kind-branch"
|
||||
)}
|
||||
></span>
|
||||
<span class="flex min-w-0 flex-col leading-tight">
|
||||
<span class="truncate font-medium">{file.path}</span>
|
||||
<span class="truncate text-muted-foreground text-xs"
|
||||
>{touch(file)}</span
|
||||
>
|
||||
</span>
|
||||
<span class="tabular text-muted-foreground text-xs">
|
||||
{fmtRelative(file.last_at)}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if data.files.length > FILES_SHOWN}
|
||||
<button
|
||||
class="rule-word self-start text-xs"
|
||||
onclick={() => {
|
||||
filesOpen = !filesOpen;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{filesOpen ? "fewer" : `all ${data.files.length}`}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
<script lang="ts">
|
||||
import CheckIcon from "@lucide/svelte/icons/check";
|
||||
import Link2Icon from "@lucide/svelte/icons/link-2";
|
||||
import Link2OffIcon from "@lucide/svelte/icons/link-2-off";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import type { ConversationSummary, Kind } from "$lib/api/types";
|
||||
import EmptyState from "$lib/components/empty-state.svelte";
|
||||
import ErrorNote from "$lib/components/error-note.svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
||||
import ConversationRow from "./conversation-row.svelte";
|
||||
import type { ConversationIndex } from "./index.svelte";
|
||||
|
||||
let {
|
||||
client,
|
||||
index,
|
||||
selected = null,
|
||||
onOpen,
|
||||
follow = $bindable(false),
|
||||
showFollow = false,
|
||||
autofocus = false,
|
||||
}: {
|
||||
client: ApiClient;
|
||||
index: ConversationIndex;
|
||||
selected?: string | null;
|
||||
onOpen: (id: string) => void;
|
||||
follow?: boolean;
|
||||
showFollow?: boolean;
|
||||
autofocus?: boolean;
|
||||
} = $props();
|
||||
|
||||
const GROUPS: { kind: Kind; label: string }[] = [
|
||||
{ kind: "master", label: "Master" },
|
||||
{ kind: "branch", label: "Branches" },
|
||||
{ kind: "deep", label: "Deep chats" },
|
||||
{ kind: "job", label: "Jobs" },
|
||||
{ kind: "fork", label: "Forks" },
|
||||
];
|
||||
|
||||
let search = $state("");
|
||||
let withClosed = $state(false);
|
||||
let list = $state<HTMLDivElement | null>(null);
|
||||
let searchInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
function activityOf(row: ConversationSummary): string {
|
||||
return row.last_activity_at ?? row.created_at ?? "";
|
||||
}
|
||||
|
||||
function byActivity(a: ConversationSummary, b: ConversationSummary) {
|
||||
if (Boolean(a.running_turn) !== Boolean(b.running_turn)) {
|
||||
return a.running_turn ? -1 : 1;
|
||||
}
|
||||
return activityOf(b).localeCompare(activityOf(a));
|
||||
}
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
return index.conversations
|
||||
.filter(
|
||||
(row) => withClosed || row.status === "open" || row.id === selected
|
||||
)
|
||||
.filter(
|
||||
(row) =>
|
||||
!needle ||
|
||||
(row.title ?? "").toLowerCase().includes(needle) ||
|
||||
row.id.startsWith(needle) ||
|
||||
row.agent.toLowerCase().includes(needle) ||
|
||||
(row.last_item?.text ?? "").toLowerCase().includes(needle)
|
||||
)
|
||||
.sort(byActivity);
|
||||
});
|
||||
const groups = $derived(
|
||||
GROUPS.map((group) => ({
|
||||
...group,
|
||||
rows: rows.filter((row) => row.kind === group.kind),
|
||||
})).filter((group) => group.rows.length > 0)
|
||||
);
|
||||
|
||||
function refresh() {
|
||||
index.load().catch(() => undefined);
|
||||
}
|
||||
|
||||
// ↑/↓ walk the rows, / jumps to the search box, Enter is the button's own.
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "/" && event.target !== searchInput) {
|
||||
event.preventDefault();
|
||||
searchInput?.focus();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
|
||||
return;
|
||||
}
|
||||
const buttons = [
|
||||
...(list?.querySelectorAll<HTMLButtonElement>("[data-row]") ?? []),
|
||||
];
|
||||
if (buttons.length === 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const at = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const step = event.key === "ArrowDown" ? 1 : -1;
|
||||
const next = at < 0 ? 0 : (at + step + buttons.length) % buttons.length;
|
||||
buttons[next].focus();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (autofocus) {
|
||||
searchInput?.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
aria-label="Conversations"
|
||||
class="flex h-full min-h-0 flex-col"
|
||||
onkeydown={onKeydown}
|
||||
role="listbox"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
|
||||
<Input
|
||||
aria-label="Search conversations"
|
||||
class="h-8 min-w-0 flex-1 text-xs"
|
||||
placeholder="search"
|
||||
bind:ref={searchInput}
|
||||
bind:value={search}
|
||||
/>
|
||||
<Button
|
||||
aria-pressed={withClosed}
|
||||
class="text-xs"
|
||||
onclick={() => {
|
||||
withClosed = !withClosed;
|
||||
}}
|
||||
size="xs"
|
||||
title={withClosed
|
||||
? "Hide closed conversations"
|
||||
: "Show closed conversations"}
|
||||
variant={withClosed ? "secondary" : "ghost"}
|
||||
>
|
||||
{#if withClosed}
|
||||
<CheckIcon class="size-3" />
|
||||
{/if}
|
||||
closed
|
||||
</Button>
|
||||
{#if showFollow}
|
||||
<Button
|
||||
aria-label="Follow the active note"
|
||||
aria-pressed={follow}
|
||||
onclick={() => {
|
||||
follow = !follow;
|
||||
}}
|
||||
size="icon-sm"
|
||||
title={follow
|
||||
? "Following the active note - click to pin this conversation"
|
||||
: "Pinned - click to follow the active note"}
|
||||
variant={follow ? "secondary" : "ghost"}
|
||||
>
|
||||
{#if follow}
|
||||
<Link2Icon class="size-4" />
|
||||
{:else}
|
||||
<Link2OffIcon class="size-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={list}>
|
||||
{#if index.live.state === "failed"}
|
||||
<div class="p-3">
|
||||
<ErrorNote
|
||||
message={index.live.detail ?? "event stream failed"}
|
||||
retry={() => index.start()}
|
||||
/>
|
||||
</div>
|
||||
{:else if !index.loaded}
|
||||
<div class="flex flex-col gap-2 p-3">
|
||||
<Skeleton class="h-12 w-full" />
|
||||
<Skeleton class="h-12 w-full" />
|
||||
<Skeleton class="h-12 w-full" />
|
||||
</div>
|
||||
{:else if rows.length === 0}
|
||||
<div class="p-3">
|
||||
<EmptyState
|
||||
hint={search
|
||||
? "Nothing matches. Try fewer letters, or include closed ones."
|
||||
: "Nothing is open. Say something to the master in Telegram, or send a note from Obsidian."}
|
||||
title="No conversations"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
{#each groups as group (group.kind)}
|
||||
<section class="flex flex-col">
|
||||
<h2
|
||||
class="sticky top-0 z-10 bg-background/95 px-3 pt-3 pb-1 font-medium text-muted-foreground text-xs uppercase tracking-wide backdrop-blur-sm"
|
||||
>
|
||||
{group.label}
|
||||
<span class="tabular ml-1 normal-case tracking-normal">
|
||||
{group.rows.length}
|
||||
</span>
|
||||
</h2>
|
||||
<ul class="flex flex-col">
|
||||
{#each group.rows as row (row.id)}
|
||||
<li class="border-b last:border-b-0">
|
||||
<ConversationRow
|
||||
{client}
|
||||
onChanged={refresh}
|
||||
{onOpen}
|
||||
{row}
|
||||
selected={selected === row.id}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,91 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import type { ConversationSummary } from "$lib/api/types";
|
||||
import KindBadge from "$lib/components/kind-badge.svelte";
|
||||
import StatusPill from "$lib/components/status-pill.svelte";
|
||||
import { clip, fmtRelative, shortId } from "$lib/format";
|
||||
import { cn } from "$lib/utils";
|
||||
import ConversationMenu from "./conversation-menu.svelte";
|
||||
import { usePanelHost } from "./host";
|
||||
|
||||
let {
|
||||
client,
|
||||
row,
|
||||
selected = false,
|
||||
onOpen,
|
||||
onChanged,
|
||||
}: {
|
||||
client: ApiClient;
|
||||
row: ConversationSummary;
|
||||
selected?: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
onChanged: () => void;
|
||||
} = $props();
|
||||
|
||||
const host = usePanelHost();
|
||||
const TITLE_MAX = 60;
|
||||
const PREVIEW_MAX = 90;
|
||||
|
||||
const title = $derived(
|
||||
row.title
|
||||
? clip(row.title, TITLE_MAX)
|
||||
: `${row.kind === "master" ? "Master" : row.kind} · ${shortId(row.id)}`
|
||||
);
|
||||
const when = $derived(row.last_activity_at ?? row.created_at ?? null);
|
||||
</script>
|
||||
|
||||
<ConversationMenu
|
||||
class={cn("group/row block", selected && "bg-sidebar-accent")}
|
||||
{client}
|
||||
current={selected}
|
||||
id={row.id}
|
||||
mode="context"
|
||||
{onChanged}
|
||||
{onOpen}
|
||||
>
|
||||
<div class="relative">
|
||||
<button
|
||||
aria-current={selected ? "true" : undefined}
|
||||
class="row-hover flex w-full flex-col items-stretch justify-start gap-1 px-3 py-2 text-left text-sm"
|
||||
data-row={row.id}
|
||||
onclick={() => onOpen(row.id)}
|
||||
type="button"
|
||||
>
|
||||
<span class="flex w-full items-center gap-2">
|
||||
<KindBadge kind={row.kind} />
|
||||
<span class="min-w-0 flex-1 truncate font-medium">{title}</span>
|
||||
<span class="tabular shrink-0 text-muted-foreground text-xs">
|
||||
{fmtRelative(when)}
|
||||
</span>
|
||||
</span>
|
||||
<span class="flex w-full items-center gap-2 pr-7 text-xs">
|
||||
<StatusPill status={row.running_turn ? "running" : row.status} />
|
||||
<span class="truncate text-muted-foreground">{row.agent}</span>
|
||||
{#if row.pending_question}
|
||||
<span class="shrink-0 font-medium text-link">question</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if row.last_item}
|
||||
<span
|
||||
class="block w-full min-w-0 truncate text-muted-foreground text-xs"
|
||||
>
|
||||
<span class="text-foreground/70">{row.last_item.origin}:</span>
|
||||
{clip(row.last_item.text, PREVIEW_MAX)}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<ConversationMenu
|
||||
class={cn(
|
||||
"absolute right-1.5 bottom-1 size-7 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/row:opacity-100 aria-expanded:opacity-100",
|
||||
host.touch && "opacity-100",
|
||||
row.last_item && "bottom-6"
|
||||
)}
|
||||
{client}
|
||||
current={selected}
|
||||
id={row.id}
|
||||
mode="button"
|
||||
{onChanged}
|
||||
{onOpen}
|
||||
/>
|
||||
</div>
|
||||
</ConversationMenu>
|
||||
@@ -2,15 +2,12 @@
|
||||
import { onMount, untrack } from "svelte";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import ErrorNote from "$lib/components/error-note.svelte";
|
||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
||||
import * as Tabs from "$lib/components/ui/tabs";
|
||||
import ActivityFeed from "./activity-feed.svelte";
|
||||
import BindingsList from "./bindings-list.svelte";
|
||||
import ChatView from "./chat-view.svelte";
|
||||
import Composer from "./composer.svelte";
|
||||
import ContextView from "./context-view.svelte";
|
||||
import { ConversationFeed } from "./conversation.svelte";
|
||||
import ConversationHeader from "./conversation-header.svelte";
|
||||
import QueueList from "./queue-list.svelte";
|
||||
import RawEntries from "./raw-entries.svelte";
|
||||
|
||||
let {
|
||||
@@ -19,6 +16,7 @@
|
||||
href = null,
|
||||
onOpen,
|
||||
showTitle = true,
|
||||
initialView = "chat",
|
||||
}: {
|
||||
client: ApiClient;
|
||||
id: string;
|
||||
@@ -26,13 +24,15 @@
|
||||
onOpen: (id: string) => void;
|
||||
// Off when a switcher above the thread already names it.
|
||||
showTitle?: boolean;
|
||||
// "activity" beside a deep chat's own note: the file is the thread.
|
||||
initialView?: string;
|
||||
} = $props();
|
||||
|
||||
const feed = new ConversationFeed(
|
||||
() => client,
|
||||
untrack(() => id)
|
||||
);
|
||||
let tab = $state("chat");
|
||||
let view = $state(untrack(() => initialView));
|
||||
let historyKey = $state(0);
|
||||
|
||||
onMount(() => {
|
||||
@@ -61,9 +61,10 @@
|
||||
{#if feed.error && !info}
|
||||
<div class="p-4"><ErrorNote message={feed.error} retry={refresh} /></div>
|
||||
{:else if !info}
|
||||
<div class="flex flex-col gap-3 p-4">
|
||||
<Skeleton class="h-8 w-1/2" />
|
||||
<Skeleton class="h-24 w-full" />
|
||||
<div class="flex flex-1 items-center justify-center p-4">
|
||||
<span
|
||||
class="size-2 animate-pulse-dot rounded-full bg-muted-foreground/40"
|
||||
></span>
|
||||
</div>
|
||||
{:else}
|
||||
<ConversationHeader
|
||||
@@ -75,49 +76,36 @@
|
||||
onChanged={refresh}
|
||||
{onOpen}
|
||||
{showTitle}
|
||||
bind:view
|
||||
/>
|
||||
<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}>
|
||||
<div class="border-b px-3 py-2 @md:px-6">
|
||||
<Tabs.List class="w-fit max-w-full">
|
||||
<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>
|
||||
</div>
|
||||
<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-3 py-3 @md:px-6"
|
||||
value="activity"
|
||||
>
|
||||
{#if view === "chat"}
|
||||
<ChatView
|
||||
{client}
|
||||
conversationId={id}
|
||||
model={feed.model}
|
||||
refreshKey={historyKey}
|
||||
/>
|
||||
{:else if view === "activity"}
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6">
|
||||
<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-3 py-3 @md:px-6"
|
||||
value="raw"
|
||||
>
|
||||
</div>
|
||||
{:else if view === "context"}
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-3 py-4 @md:px-6">
|
||||
{#key historyKey}
|
||||
<ContextView {client} conversationId={id} />
|
||||
{/key}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6">
|
||||
<RawEntries {client} conversationId={id} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
|
||||
value="meta"
|
||||
>
|
||||
{@render meta()}
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
{/if}
|
||||
<Composer
|
||||
{client}
|
||||
conversationId={id}
|
||||
@@ -126,60 +114,3 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet meta()}
|
||||
{#if info}
|
||||
<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"
|
||||
>
|
||||
Queue
|
||||
</h2>
|
||||
<QueueList items={info.queue} />
|
||||
</section>
|
||||
<section class="flex flex-col gap-1.5">
|
||||
<h2
|
||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
||||
>
|
||||
Windows
|
||||
</h2>
|
||||
<BindingsList
|
||||
bindings={info.bindings}
|
||||
{client}
|
||||
conversationId={id}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
</section>
|
||||
<section class="flex flex-col gap-1.5">
|
||||
<h2
|
||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
||||
>
|
||||
Flags
|
||||
</h2>
|
||||
{#if Object.keys(info.flags).length === 0}
|
||||
<p class="text-muted-foreground text-xs">No flags set.</p>
|
||||
{:else}
|
||||
<dl
|
||||
class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs"
|
||||
>
|
||||
{#each Object.entries(info.flags) as [key, value] (key)}
|
||||
<dt class="text-muted-foreground">{key}</dt>
|
||||
<dd class="truncate">{JSON.stringify(value)}</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
{/if}
|
||||
</section>
|
||||
<section class="flex flex-col gap-1.5">
|
||||
<h2
|
||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
||||
>
|
||||
Session
|
||||
</h2>
|
||||
<p class="tabular break-all text-muted-foreground text-xs">
|
||||
{info.session_id ?? "no session yet"}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getContext, setContext } from "svelte";
|
||||
import { type PanelCache, panelCache } from "./cache";
|
||||
|
||||
export type LinkKind = "internal" | "external";
|
||||
|
||||
@@ -7,6 +8,8 @@ export type LinkKind = "internal" | "external";
|
||||
// Obsidian plugin renders through MarkdownRenderer and opens notes in
|
||||
// place. Anything the host leaves undefined falls back to the browser way.
|
||||
export interface PanelHost {
|
||||
// Where the index and the last threads persist between opens.
|
||||
cache?: PanelCache;
|
||||
// Renders ``text`` into ``node``; returns the cleanup. Absent: marked + DOMPurify.
|
||||
markdown?: (node: HTMLElement, text: string) => (() => void) | undefined;
|
||||
name: "browser" | "obsidian";
|
||||
@@ -35,6 +38,7 @@ export function browserHost(): PanelHost {
|
||||
return browser;
|
||||
}
|
||||
browser = {
|
||||
cache: panelCache("beaver.panel"),
|
||||
name: "browser",
|
||||
openLink(target, kind) {
|
||||
if (kind === "internal") {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import { LiveStream } from "$lib/api/live.svelte";
|
||||
import type { BusEvent, ConversationSummary } from "$lib/api/types";
|
||||
import type { PanelCache } from "./cache";
|
||||
|
||||
const RELOAD_DEBOUNCE_MS = 1500;
|
||||
const PAGE = 500;
|
||||
const CACHED_ROWS = 200;
|
||||
const INDEX_KEY = "index";
|
||||
|
||||
// The conversation index kept fresh by ``/api/events``: rows land as
|
||||
// ``conversation.*`` events arrive, turn markers flip ``running_turn``
|
||||
@@ -14,10 +17,17 @@ export class ConversationIndex {
|
||||
loaded = $state(false);
|
||||
readonly live: LiveStream;
|
||||
protected readonly client: () => ApiClient | null;
|
||||
private readonly cache: PanelCache | null;
|
||||
private reloadTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(client: () => ApiClient | null) {
|
||||
constructor(client: () => ApiClient | null, cache: PanelCache | null = null) {
|
||||
this.client = client;
|
||||
this.cache = cache;
|
||||
const cached = cache?.get<ConversationSummary[]>(INDEX_KEY);
|
||||
if (cached && cached.length > 0) {
|
||||
this.conversations = cached;
|
||||
this.loaded = true;
|
||||
}
|
||||
this.live = new LiveStream(client, "/api/events", {
|
||||
onEvent: (event) => this.apply(event),
|
||||
prepare: () => this.load(),
|
||||
@@ -44,6 +54,7 @@ export class ConversationIndex {
|
||||
const list = await client.conversations({ limit: PAGE });
|
||||
this.conversations = list.conversations;
|
||||
this.loaded = true;
|
||||
this.cache?.set(INDEX_KEY, list.conversations.slice(0, CACHED_ROWS));
|
||||
}
|
||||
|
||||
apply(event: BusEvent): void {
|
||||
|
||||
@@ -3,19 +3,20 @@
|
||||
import Link2Icon from "@lucide/svelte/icons/link-2";
|
||||
import Link2OffIcon from "@lucide/svelte/icons/link-2-off";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import KindBadge from "$lib/components/kind-badge.svelte";
|
||||
import LiveDot from "$lib/components/live-dot.svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Popover from "$lib/components/ui/popover";
|
||||
import { clip, shortId } from "$lib/format";
|
||||
import ConversationPicker from "./conversation-picker.svelte";
|
||||
import Rail from "$lib/rail/rail.svelte";
|
||||
import KindMark from "$lib/shell/kind-mark.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
import type { ConversationIndex } from "./index.svelte";
|
||||
import PanelIsland from "./panel-island.svelte";
|
||||
|
||||
let {
|
||||
client,
|
||||
index,
|
||||
selected = null,
|
||||
onOpen,
|
||||
onOpenFile,
|
||||
follow = $bindable(false),
|
||||
showFollow = false,
|
||||
}: {
|
||||
@@ -23,6 +24,7 @@
|
||||
index: ConversationIndex;
|
||||
selected?: string | null;
|
||||
onOpen: (id: string) => void;
|
||||
onOpenFile?: (path: string) => void;
|
||||
follow?: boolean;
|
||||
showFollow?: boolean;
|
||||
} = $props();
|
||||
@@ -30,10 +32,10 @@
|
||||
const TITLE_MAX = 48;
|
||||
let switcherOpen = $state(false);
|
||||
const current = $derived(selected ? index.byId(selected) : undefined);
|
||||
const running = $derived(index.running.length);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 border-b px-2 py-1.5">
|
||||
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
|
||||
<PanelIsland compact {index} {onOpen} />
|
||||
<Popover.Root bind:open={switcherOpen}>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
@@ -44,7 +46,7 @@
|
||||
type="button"
|
||||
>
|
||||
{#if current}
|
||||
<KindBadge kind={current.kind} />
|
||||
<KindMark kind={current.kind} />
|
||||
<span class="min-w-0 flex-1 truncate font-medium">
|
||||
{current.title
|
||||
? clip(current.title, TITLE_MAX)
|
||||
@@ -59,14 +61,6 @@
|
||||
Pick a conversation
|
||||
</span>
|
||||
{/if}
|
||||
{#if running > 0 && !current?.running_turn}
|
||||
<span
|
||||
class="tabular shrink-0 rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
|
||||
title="{running} running elsewhere"
|
||||
>
|
||||
{running}
|
||||
</span>
|
||||
{/if}
|
||||
<ChevronDownIcon class="size-3.5 shrink-0 text-icon" />
|
||||
</button>
|
||||
{/snippet}
|
||||
@@ -76,44 +70,44 @@
|
||||
class="w-[min(24rem,calc(100vw-1rem))] gap-0 overflow-hidden p-0"
|
||||
sideOffset={6}
|
||||
>
|
||||
<div class="flex max-h-[min(70vh,32rem)] flex-col">
|
||||
<ConversationPicker
|
||||
autofocus
|
||||
<div class="flex h-[min(70vh,32rem)] flex-col">
|
||||
<Rail
|
||||
{client}
|
||||
{index}
|
||||
onOpen={(id) => {
|
||||
switcherOpen = false;
|
||||
onOpen(id);
|
||||
}}
|
||||
onOpenFile={(path) => {
|
||||
switcherOpen = false;
|
||||
onOpenFile?.(path);
|
||||
}}
|
||||
{selected}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
<LiveDot
|
||||
class="px-1"
|
||||
detail={index.live.detail}
|
||||
label={false}
|
||||
state={index.live.state}
|
||||
/>
|
||||
{#if showFollow}
|
||||
<Button
|
||||
<button
|
||||
aria-label="Follow the active note"
|
||||
aria-pressed={follow}
|
||||
class={cn(
|
||||
"rule-word inline-flex size-7 shrink-0 items-center justify-center rounded-md",
|
||||
follow && "bg-accent text-foreground"
|
||||
)}
|
||||
onclick={() => {
|
||||
follow = !follow;
|
||||
}}
|
||||
size="icon-sm"
|
||||
title={follow
|
||||
? "Following the active note - click to pin this conversation"
|
||||
: "Pinned - click to follow the active note"}
|
||||
variant={follow ? "secondary" : "ghost"}
|
||||
type="button"
|
||||
>
|
||||
{#if follow}
|
||||
<Link2Icon class="size-4" />
|
||||
{:else}
|
||||
<Link2OffIcon class="size-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils";
|
||||
import type { ConversationIndex } from "./index.svelte";
|
||||
|
||||
let {
|
||||
index,
|
||||
onOpen,
|
||||
compact = false,
|
||||
class: className = "",
|
||||
}: {
|
||||
index: ConversationIndex;
|
||||
onOpen: (id: string) => void;
|
||||
compact?: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const waiting = $derived(index.open.filter((row) => row.pending_question));
|
||||
const running = $derived(
|
||||
index.open.filter((row) => row.running_turn && !row.pending_question)
|
||||
);
|
||||
const live = $derived(index.live.state);
|
||||
const dot = $derived.by(() => {
|
||||
if (live === "failed") {
|
||||
return "bg-destructive";
|
||||
}
|
||||
if (live !== "open") {
|
||||
return "bg-warn";
|
||||
}
|
||||
return running.length > 0 ? "bg-signal animate-pulse-dot" : "bg-ok";
|
||||
});
|
||||
const headline = $derived.by(() => {
|
||||
if (live === "failed") {
|
||||
return "offline";
|
||||
}
|
||||
if (live !== "open") {
|
||||
return "connecting";
|
||||
}
|
||||
if (running.length === 0) {
|
||||
return "idle";
|
||||
}
|
||||
return running.length === 1 ? "1 in motion" : `${running.length} in motion`;
|
||||
});
|
||||
const target = $derived(waiting[0] ?? running[0] ?? null);
|
||||
const questions = $derived.by(() => {
|
||||
if (compact) {
|
||||
return String(waiting.length);
|
||||
}
|
||||
return waiting.length === 1 ? "1 question" : `${waiting.length} questions`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<button
|
||||
aria-label={target ? `Open ${target.title ?? target.kind}` : headline}
|
||||
class={cn("island", compact && "gap-1.5 px-2.5", className)}
|
||||
disabled={!target}
|
||||
onclick={() => target && onOpen(target.id)}
|
||||
title={index.live.detail ?? headline}
|
||||
type="button"
|
||||
>
|
||||
<span class={cn("size-2 shrink-0 rounded-full", dot)}></span>
|
||||
{#if !compact}
|
||||
<span class="font-medium">{headline}</span>
|
||||
{/if}
|
||||
{#if waiting.length > 0}
|
||||
{#if !compact}
|
||||
<span class="island-sep"></span>
|
||||
{/if}
|
||||
<span class="font-medium text-attention-foreground">{questions}</span>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import EmptyState from "$lib/components/empty-state.svelte";
|
||||
import ConversationPicker from "./conversation-picker.svelte";
|
||||
import Rail from "$lib/rail/rail.svelte";
|
||||
import ConversationView from "./conversation-view.svelte";
|
||||
import type { ConversationIndex } from "./index.svelte";
|
||||
import PanelBar from "./panel-bar.svelte";
|
||||
import PanelIsland from "./panel-island.svelte";
|
||||
|
||||
let {
|
||||
client,
|
||||
@@ -13,12 +13,17 @@
|
||||
selected = $bindable(null),
|
||||
follow = $bindable(false),
|
||||
showFollow = false,
|
||||
companion = false,
|
||||
onOpenFile,
|
||||
}: {
|
||||
client: ApiClient;
|
||||
index: ConversationIndex;
|
||||
selected?: string | null;
|
||||
follow?: boolean;
|
||||
showFollow?: boolean;
|
||||
// The thread lives in the note beside this panel: open on activity.
|
||||
companion?: boolean;
|
||||
onOpenFile?: (path: string) => void;
|
||||
} = $props();
|
||||
|
||||
// 48rem of panel: below it the switcher names the thread, above it the rail does.
|
||||
@@ -37,19 +42,17 @@
|
||||
</script>
|
||||
|
||||
<!--
|
||||
impeccable direction contract (mode operate, brief-pinned world: beaver-calendar's Obsidian skin)
|
||||
THESIS: the agent's terminal, not a chat app. The thread is always on screen; picking a
|
||||
conversation is a header switch, never a screen of its own. Refuses the messenger's list-first
|
||||
layout and the admin's chrome.
|
||||
OWN-WORLD: Obsidian's own variables through .beaver-root - its font, radii, accent, and status
|
||||
colors; hairline rows, tabular numerals, uppercase 12px section labels; nothing pink.
|
||||
STORY: the operator opens a note, the panel follows to its conversation, they watch tools and
|
||||
subagents stream, answer a question, branch off with a chosen seed, send the branch back to
|
||||
Telegram, and close a deep chat with memory on or off - all from the sidedock.
|
||||
FIRST VIEWPORT: narrow column: the switcher (kind badge, title, live dot, follow toggle) over
|
||||
the thread header, tabs, the live thread and the composer pinned at the bottom. Wide tab:
|
||||
the same with the grouped conversation rail on the left. Primary action: write to the agent.
|
||||
FORM: two-pane operator console; pinned by the brief, no roll.
|
||||
impeccable direction contract (seed 071a8c77, mode operate, strip board × live activity, in Obsidian's skin)
|
||||
THESIS: the agent's front inside the vault, not a website in a pane. The island carries what is
|
||||
alive, the rail is the day's strips, the thread owns its column.
|
||||
OWN-WORLD: Obsidian's own variables through .beaver-root; strips with a state edge, hairline
|
||||
rules, tabular numerals; amber only for a question waiting; nothing decorative.
|
||||
STORY: the operator opens the tab and reads the island, picks today's strip, answers a question,
|
||||
watches tools stream; beside a deep chat the sidedock shows the agent's activity, never a
|
||||
second copy of the note.
|
||||
FIRST VIEWPORT: narrow: island + switcher over the thread and the composer pinned low. Wide:
|
||||
the rail with the island on top, the thread to the right.
|
||||
FORM: strip board × live activity; user-steered; seed key 071a8c77.
|
||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review,
|
||||
the verdict, and DESIGN.md
|
||||
-->
|
||||
@@ -59,15 +62,11 @@ the verdict, and DESIGN.md
|
||||
>
|
||||
<div class="flex min-h-0 flex-1">
|
||||
{#if wide}
|
||||
<aside class="flex w-72 shrink-0 flex-col border-r bg-sidebar/40">
|
||||
<ConversationPicker
|
||||
{client}
|
||||
{index}
|
||||
onOpen={open}
|
||||
{selected}
|
||||
{showFollow}
|
||||
bind:follow
|
||||
/>
|
||||
<aside class="flex w-80 shrink-0 flex-col border-r">
|
||||
<div class="flex items-center gap-2 px-3 pt-3 pb-1">
|
||||
<PanelIsland {index} onOpen={open} />
|
||||
</div>
|
||||
<Rail {client} {index} onOpen={open} {onOpenFile} {selected} />
|
||||
</aside>
|
||||
{/if}
|
||||
<section class="flex min-w-0 flex-1 flex-col">
|
||||
@@ -76,6 +75,7 @@ the verdict, and DESIGN.md
|
||||
{client}
|
||||
{index}
|
||||
onOpen={open}
|
||||
{onOpenFile}
|
||||
{selected}
|
||||
{showFollow}
|
||||
bind:follow
|
||||
@@ -86,19 +86,18 @@ the verdict, and DESIGN.md
|
||||
<ConversationView
|
||||
{client}
|
||||
id={selected}
|
||||
initialView={companion ? "activity" : "chat"}
|
||||
onOpen={open}
|
||||
showTitle={wide}
|
||||
/>
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="flex flex-1 items-start justify-center p-4 @md:p-6">
|
||||
<EmptyState
|
||||
class="w-full max-w-md"
|
||||
hint={showFollow
|
||||
? "Open a note with conversation_id in its frontmatter, or pick one from the list."
|
||||
: "Pick one from the list: the master, a branch, a deep chat."}
|
||||
title="No conversation on screen"
|
||||
/>
|
||||
<p class="max-w-md text-muted-foreground text-sm">
|
||||
{showFollow
|
||||
? "Open a note with a conversation in its frontmatter, or pick a strip."
|
||||
: "Pick a strip: the master, a branch, a deep chat."}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -68,3 +68,146 @@
|
||||
column-gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Strips: the one row vocabulary of the console. A strip is a physical
|
||||
object in a bay: fixed columns, a state edge, lifts on hover, cocked
|
||||
(pushed out) while it waits for the operator. */
|
||||
@layer components {
|
||||
.strip {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1.5rem minmax(0, 1fr) auto;
|
||||
column-gap: 0.75rem;
|
||||
align-items: center;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.375rem 0.875rem 0.375rem 0.625rem;
|
||||
color: var(--foreground);
|
||||
text-decoration: none;
|
||||
background: var(--strip);
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition:
|
||||
transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
box-shadow 200ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
background-color 150ms ease-out;
|
||||
}
|
||||
.strip::before {
|
||||
position: absolute;
|
||||
top: 0.375rem;
|
||||
bottom: 0.375rem;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
content: "";
|
||||
background: transparent;
|
||||
border-radius: 0 2px 2px 0;
|
||||
transition: background-color 200ms ease-out;
|
||||
}
|
||||
.strip:first-child {
|
||||
border-top-left-radius: var(--radius-md);
|
||||
border-top-right-radius: var(--radius-md);
|
||||
}
|
||||
.strip:last-child {
|
||||
border-bottom: 0;
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
}
|
||||
.strip:hover,
|
||||
.strip:focus-visible {
|
||||
z-index: 1;
|
||||
box-shadow: var(--shadow-lift);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.strip[data-state="running"]::before {
|
||||
background: var(--signal);
|
||||
animation: pulse-edge 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
.strip[data-state="waiting"] {
|
||||
z-index: 1;
|
||||
box-shadow: var(--shadow-lift);
|
||||
transform: translateX(0.5rem);
|
||||
}
|
||||
.strip[data-state="waiting"]::before {
|
||||
background: var(--attention);
|
||||
}
|
||||
.strip[data-state="waiting"]:hover {
|
||||
transform: translateX(0.5rem) translateY(-1px);
|
||||
}
|
||||
.strip[aria-current="true"] {
|
||||
background: color-mix(in oklab, var(--strip) 85%, var(--primary) 15%);
|
||||
}
|
||||
.strip[data-state="closed"] {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.strip-open {
|
||||
display: block;
|
||||
padding: 0.25rem 0.875rem 0.875rem 3rem;
|
||||
background: var(--strip);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.strip-open:last-child {
|
||||
border-bottom: 0;
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
}
|
||||
.bay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.bay-rack {
|
||||
background: color-mix(in oklab, var(--rack) 70%, var(--strip) 30%);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius-md) + 1px);
|
||||
box-shadow: inset 0 1px 0
|
||||
color-mix(in oklab, var(--foreground) 4%, transparent);
|
||||
}
|
||||
.island {
|
||||
display: inline-flex;
|
||||
gap: 0.625rem;
|
||||
align-items: center;
|
||||
height: 2.125rem;
|
||||
padding: 0 0.875rem 0 0.75rem;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
background: var(--strip);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 1px 2px oklch(0.2 0.06 340 / 0.06);
|
||||
transition:
|
||||
transform 220ms cubic-bezier(0.2, 0.9, 0.25, 1.1),
|
||||
box-shadow 220ms ease-out,
|
||||
background-color 150ms ease-out;
|
||||
}
|
||||
.island:hover,
|
||||
.island[aria-expanded="true"] {
|
||||
box-shadow: var(--shadow-lift);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.island-sep {
|
||||
width: 1px;
|
||||
height: 1rem;
|
||||
background: var(--border);
|
||||
}
|
||||
.doc {
|
||||
max-width: 78ch;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-edge {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.strip-child {
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
.strip-child::before {
|
||||
left: 1.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
<article
|
||||
class={cn(
|
||||
"flex animate-rise flex-col gap-2 border-b py-3",
|
||||
"flex flex-col gap-2 border-b py-3",
|
||||
turn.status === "running" && "bg-signal/[0.03]"
|
||||
)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user