feat(ui,api,telegram): shared conversation panel for obsidian and /admin/panel, bind materializes a window, inbox survives link previews

This commit is contained in:
hh
2026-08-29 18:30:33 +02:00
parent a989d2909d
commit 9efff6e814
58 changed files with 2858 additions and 499 deletions
+224
View File
@@ -0,0 +1,224 @@
import ArchiveIcon from "@lucide/svelte/icons/archive";
import ArrowRightIcon from "@lucide/svelte/icons/arrow-right";
import BookCheckIcon from "@lucide/svelte/icons/book-check";
import BrainIcon from "@lucide/svelte/icons/brain";
import CircleXIcon from "@lucide/svelte/icons/circle-x";
import CopyIcon from "@lucide/svelte/icons/copy";
import EyeOffIcon from "@lucide/svelte/icons/eye-off";
import FileTextIcon from "@lucide/svelte/icons/file-text";
import GitBranchIcon from "@lucide/svelte/icons/git-branch";
import GitMergeIcon from "@lucide/svelte/icons/git-merge";
import PencilIcon from "@lucide/svelte/icons/pencil";
import RotateCcwIcon from "@lucide/svelte/icons/rotate-ccw";
import SendIcon from "@lucide/svelte/icons/send";
import type { Component } from "svelte";
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client";
import type { ConversationInfo } from "$lib/api/types";
import type { PanelHost } from "./host";
export interface MenuAction {
// Present: rendered as a checkbox item with this state.
checked?: boolean;
danger?: boolean;
disabled?: boolean;
// A separator sits above this action.
gap?: boolean;
icon?: Component<{ class?: string }>;
label: string;
run: () => void | Promise<void>;
}
export interface ActionScope {
client: ApiClient;
// The conversation is the one on screen, so "Open" is pointless.
current: boolean;
host: PanelHost;
onBranch: () => void;
onChanged: () => void;
onOpen: (id: string) => void;
onRename: () => void;
}
const TELEGRAM = "telegram";
const MARKDOWN = "markdown";
function describe(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function attempt(
action: () => Promise<unknown>,
done: string | null
): Promise<void> {
try {
await action();
if (done) {
toast.success(done);
}
} catch (error) {
toast.error(describe(error));
}
}
function copyId(id: string): void {
navigator.clipboard
.writeText(id)
.then(() => toast.success("Id copied"))
.catch(() => toast.error("Clipboard is not available"));
}
// The one list of things you can do to a conversation, in the order the
// header menu and the row's context menu both show them.
export function conversationActions(
info: ConversationInfo,
scope: ActionScope
): MenuAction[] {
const { client, host } = scope;
const open = info.status === "open";
const telegram = info.bindings.find((b) => b.frontend === TELEGRAM);
const note = info.bindings.find((b) => b.frontend === MARKDOWN && b.visible);
const remembers = info.flags.memory !== false;
const changed = () => scope.onChanged();
const actions: MenuAction[] = [];
if (!scope.current) {
actions.push({
icon: ArrowRightIcon,
label: "Open",
run: () => scope.onOpen(info.id),
});
}
if (note && host.openNote) {
actions.push({
icon: FileTextIcon,
label: "Open note",
run: () => host.openNote?.(note.external_id),
});
}
actions.push({
disabled: !open,
gap: actions.length > 0,
icon: GitBranchIcon,
label: "Branch…",
run: scope.onBranch,
});
actions.push({
checked: remembers,
gap: true,
icon: BrainIcon,
label: "Remember on close",
run: () =>
attempt(
async () => {
await client.setFlags(info.id, { memory: !remembers });
changed();
},
remembers
? "Memory off: closing only merges"
: "Memory on: closing writes a digest"
),
});
if (info.kind === "branch") {
if (telegram?.visible) {
actions.push({
icon: EyeOffIcon,
label: "Hide from Telegram",
run: () =>
attempt(async () => {
await client.bind(info.id, TELEGRAM, telegram.external_id, false);
changed();
}, "Hidden from Telegram"),
});
} else {
actions.push({
icon: SendIcon,
label: "Return to Telegram",
run: () =>
attempt(async () => {
await client.bind(
info.id,
TELEGRAM,
telegram?.external_id ?? null,
true
);
changed();
}, "Back in Telegram"),
});
}
}
actions.push({
gap: true,
icon: PencilIcon,
label: "Rename…",
run: scope.onRename,
});
actions.push({
icon: CopyIcon,
label: "Copy id",
run: () => copyId(info.id),
});
if (info.parent && open) {
actions.push({
gap: true,
icon: GitMergeIcon,
label: "Merge into parent",
run: () =>
attempt(async () => {
await client.merge(info.id);
changed();
}, "Merged into the parent"),
});
}
if (info.kind === "deep" && open) {
actions.push({
gap: !(info.parent && open),
icon: BookCheckIcon,
label: "Close with digest",
run: () =>
attempt(async () => {
const result = await client.close(info.id);
changed();
if (result.error) {
toast.warning(`Digest: ${result.error}`);
} else if (result.digest) {
toast.message(`Digest: ${result.digest}`);
}
}, "Chat closed"),
});
}
if (open) {
actions.push({
gap: !(info.parent || info.kind === "deep"),
icon: CircleXIcon,
label: "Close",
run: () =>
attempt(async () => {
await client.update(info.id, { status: "closed" });
changed();
}, "Closed"),
});
actions.push({
danger: true,
icon: ArchiveIcon,
label: "Archive",
run: () =>
attempt(async () => {
await client.update(info.id, { status: "archived" });
changed();
}, "Archived"),
});
} else {
actions.push({
gap: true,
icon: RotateCcwIcon,
label: "Reopen",
run: () =>
attempt(async () => {
await client.update(info.id, { status: "open" });
changed();
}, "Reopened"),
});
}
return actions;
}
+131
View File
@@ -0,0 +1,131 @@
<script lang="ts">
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client";
import type { ConversationSummary } from "$lib/api/types";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select";
import { shortId } from "$lib/format";
let {
client,
parent,
open = $bindable(false),
onOpened,
}: {
client: ApiClient;
parent: Pick<ConversationSummary, "id" | "title" | "kind">;
open?: boolean;
onOpened: (id: string) => void;
} = $props();
const SEEDS = [
{ hint: "empty context", label: "clean", value: "clean" },
{ hint: "the morning handout", label: "morning", value: "morning" },
{ hint: "a copy of this history", label: "copy", value: "copy" },
{ hint: "your text as the seed", label: "brief", value: "brief" },
];
let seed = $state("clean");
let title = $state("");
let text = $state("");
let busy = $state(false);
const current = $derived(SEEDS.find((s) => s.value === seed) ?? SEEDS[0]);
const parentName = $derived(
parent.title || `${parent.kind} ${shortId(parent.id)}`
);
async function branch() {
busy = true;
try {
const child = await client.branch(parent.id, {
seed,
text: text || undefined,
title: title || undefined,
});
open = false;
title = "";
text = "";
toast.success("Branch opened");
onOpened(child.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Branch off {parentName}</Dialog.Title>
<Dialog.Description>
A branch gets its own window and session and ends with a merge back into
this thread.
</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<Label>Seed</Label>
<Select.Root
onValueChange={(value) => {
seed = value;
}}
type="single"
value={seed}
>
<Select.Trigger class="w-full">
{current.label}
<span class="text-muted-foreground">- {current.hint}</span>
</Select.Trigger>
<Select.Content>
{#each SEEDS 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>
</div>
<div class="flex flex-col gap-1.5">
<Label for="branch-title">Title</Label>
<Input id="branch-title" placeholder="optional" bind:value={title} />
</div>
<div class="flex flex-col gap-1.5">
<Label for="branch-text">First message</Label>
<textarea
class="min-h-20 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"
id="branch-text"
placeholder={seed === "brief" ? "required for a brief seed" : "optional"}
bind:value={text}
></textarea>
</div>
</div>
<Dialog.Footer>
<Button
onclick={() => {
// biome-ignore lint/suspicious/noGlobalAssign: bindable prop, not window.open
open = false;
}}
variant="ghost"
>
Cancel
</Button>
<Button
disabled={busy || (seed === "brief" && !text.trim())}
onclick={branch}
>
Open branch
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+1 -1
View File
@@ -124,7 +124,7 @@
</script>
<div
class="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-4 py-3 sm:px-6"
class="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3 @md:px-6"
onscroll={() => {
pinned = nearBottom();
}}
+2 -2
View File
@@ -111,7 +111,7 @@
{/each}
</Select.Content>
</Select.Root>
<span class="hidden text-muted-foreground text-xs sm:inline"
<span class="hidden text-muted-foreground text-xs @md:inline"
>{current.hint}</span
>
<Button
@@ -122,7 +122,7 @@
>
<SendHorizontalIcon class="size-4" />
Send
<kbd class="hidden text-[10px] opacity-70 sm:inline">⌘↩</kbd>
<kbd class="hidden text-[10px] opacity-70 @md:inline">⌘↩</kbd>
</Button>
</div>
</form>
+59 -246
View File
@@ -1,8 +1,5 @@
<script lang="ts">
import CopyIcon from "@lucide/svelte/icons/copy";
import GitBranchIcon from "@lucide/svelte/icons/git-branch";
import MoreHorizontalIcon from "@lucide/svelte/icons/more-horizontal";
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client";
import type { LiveState } from "$lib/api/live.svelte";
import type { ConversationInfo } from "$lib/api/types";
@@ -10,114 +7,67 @@
import LiveDot from "$lib/components/live-dot.svelte";
import StatusPill from "$lib/components/status-pill.svelte";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select";
import { clip, fmtRelative, shortId } from "$lib/format";
import BranchDialog from "./branch-dialog.svelte";
import ConversationMenu from "./conversation-menu.svelte";
let {
client,
info,
liveState,
liveDetail = null,
href,
href = null,
onChanged,
onOpen,
showTitle = true,
}: {
client: ApiClient;
info: ConversationInfo;
liveState: LiveState;
liveDetail?: string | null;
href: (id: string) => string;
// 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;
} = $props();
let branchOpen = $state(false);
let renameOpen = $state(false);
let busy = $state(false);
let seed = $state("clean");
let branchTitle = $state("");
let branchText = $state("");
let newTitle = $state("");
const WINDOW_MAX = 56;
const windows = $derived(info.bindings.filter((b) => b.visible));
const queued = $derived(
info.queue.filter((item) => item.status === "queued").length
);
const SEEDS = [
{ label: "clean - empty context", value: "clean" },
{ label: "copy - copy of this history", value: "copy" },
{ label: "brief - your text as the seed", value: "brief" },
{ label: "morning - the handout", value: "morning" },
];
async function run(action: () => Promise<unknown>, done: string) {
busy = true;
try {
await action();
toast.success(done);
onChanged();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
async function branch() {
busy = true;
try {
const child = await client.branch(info.id, {
seed,
text: branchText || undefined,
title: branchTitle || undefined,
});
branchOpen = false;
toast.success("Branch opened");
onOpen(child.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
async function distill() {
const result = await client.close(info.id);
if (result.error) {
toast.warning(`Digest: ${result.error}`);
} else if (result.digest) {
toast.message(`Digest: ${result.digest}`);
}
}
function copyId() {
navigator.clipboard
.writeText(info.id)
.then(() => toast.success("Id copied"))
.catch(() => toast.error("Clipboard is not available"));
}
</script>
<header class="flex flex-col gap-1.5 border-b px-4 py-2.5 sm:px-6">
<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">
<KindBadge kind={info.kind} />
<h1 class="min-w-0 truncate font-semibold text-base tracking-tight">
{info.title || `${info.kind} ${shortId(info.id)}`}
</h1>
{#if showTitle}
<KindBadge 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>
{/if}
<div class="ml-auto flex items-center gap-1">
<LiveDot detail={liveDetail} state={liveState} />
<LiveDot
class="hidden @md:inline-flex"
detail={liveDetail}
state={liveState}
/>
<LiveDot
class="@md:hidden"
detail={liveDetail}
label={false}
state={liveState}
/>
<Button
disabled={busy || info.status !== "open"}
aria-label="Branch off this conversation"
disabled={info.status !== "open"}
onclick={() => {
branchOpen = true;
}}
@@ -125,88 +75,44 @@
variant="outline"
>
<GitBranchIcon class="size-4" />
Branch
<span class="hidden @md:inline">Branch</span>
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
aria-label="More actions"
size="icon-sm"
variant="ghost"
>
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={copyId}>
<CopyIcon class="size-4" />
Copy id
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => {
newTitle = info.title ?? "";
renameOpen = true;
}}
>
Rename
</DropdownMenu.Item>
<DropdownMenu.Separator />
{#if info.parent && info.status === "open"}
<DropdownMenu.Item
onclick={() =>
run(() => client.merge(info.id), "Merged into the parent")}
>
Merge into parent
</DropdownMenu.Item>
{/if}
{#if info.kind === "deep" && info.status === "open"}
<DropdownMenu.Item onclick={() => run(distill, "Chat closed")}>
Close with digest
</DropdownMenu.Item>
{/if}
{#if info.status === "open"}
<DropdownMenu.Item
onclick={() =>
run(() => client.update(info.id, { status: "closed" }), "Closed")}
>
Close
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() =>
run(
() => client.update(info.id, { status: "archived" }),
"Archived"
)}
>
Archive
</DropdownMenu.Item>
{:else}
<DropdownMenu.Item
onclick={() =>
run(() => client.update(info.id, { status: "open" }), "Reopened")}
>
Reopen
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
<ConversationMenu
{client}
current
id={info.id}
{info}
mode="button"
{onChanged}
{onOpen}
/>
</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>{info.agent}</span>
<span>via {info.origin}</span>
<span class="hidden @md:inline">{info.agent}</span>
<span class="hidden @md:inline">via {info.origin}</span>
{#if info.parent}
<a class="text-link hover:underline" href={href(info.parent)}>
parent {shortId(info.parent)}
</a>
{#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" title={info.id}>{shortId(info.id)}</span>
<span class="tabular">
<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>
@@ -225,7 +131,7 @@
</div>
{#if windows.length > 0}
<div
class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs"
class="hidden flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs @md:flex"
>
{#each windows as window (window.frontend + window.external_id)}
<span class="truncate" title="{window.frontend}: {window.external_id}">
@@ -237,97 +143,4 @@
{/if}
</header>
<Dialog.Root bind:open={branchOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Branch off this conversation</Dialog.Title>
<Dialog.Description>
A branch gets its own window and session and ends with a merge back into
this thread.
</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<Label>Seed</Label>
<Select.Root
onValueChange={(value) => {
seed = value;
}}
type="single"
value={seed}
>
<Select.Trigger class="w-full">
{SEEDS.find((s) => s.value === seed)?.label}
</Select.Trigger>
<Select.Content>
{#each SEEDS as option (option.value)}
<Select.Item label={option.label} value={option.value} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col gap-1.5">
<Label for="branch-title">Title</Label>
<Input
id="branch-title"
placeholder="optional"
bind:value={branchTitle}
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="branch-text">First message</Label>
<textarea
class="min-h-20 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"
id="branch-text"
placeholder={seed === "brief" ? "required for a brief seed" : "optional"}
bind:value={branchText}
></textarea>
</div>
</div>
<Dialog.Footer>
<Button
onclick={() => {
branchOpen = false;
}}
variant="ghost"
>
Cancel
</Button>
<Button
disabled={busy || (seed === "brief" && !branchText.trim())}
onclick={branch}
>
Open branch
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={renameOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Rename</Dialog.Title>
</Dialog.Header>
<Input placeholder="Title" bind:value={newTitle} />
<Dialog.Footer>
<Button
onclick={() => {
renameOpen = false;
}}
variant="ghost"
>
Cancel
</Button>
<Button
disabled={busy}
onclick={() =>
run(async () => {
await client.update(info.id, { title: newTitle });
renameOpen = false;
}, "Renamed")}
>
Save
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<BranchDialog {client} onOpened={onOpen} parent={info} bind:open={branchOpen} />
+132
View File
@@ -0,0 +1,132 @@
<script lang="ts">
import MoreHorizontalIcon from "@lucide/svelte/icons/more-horizontal";
import type { Snippet } from "svelte";
import type { ApiClient } from "$lib/api/client";
import type { ConversationInfo } from "$lib/api/types";
import { Button } from "$lib/components/ui/button";
import * as ContextMenu from "$lib/components/ui/context-menu";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { Skeleton } from "$lib/components/ui/skeleton";
import { cn } from "$lib/utils";
import { type ActionScope, conversationActions } from "./actions.svelte";
import BranchDialog from "./branch-dialog.svelte";
import { usePanelHost } from "./host";
import MenuItems, { type MenuKit } from "./menu-items.svelte";
import RenameDialog from "./rename-dialog.svelte";
let {
client,
id,
info = null,
mode,
current = false,
onOpen,
onChanged,
class: className = "",
children,
}: {
client: ApiClient;
id: string;
// Known already (the thread header); otherwise fetched when the menu opens.
info?: ConversationInfo | null;
mode: "context" | "button";
current?: boolean;
onOpen: (id: string) => void;
onChanged: () => void;
class?: string;
children?: Snippet;
} = $props();
const host = usePanelHost();
let open = $state(false);
let fetched = $state<ConversationInfo | null>(null);
let failure = $state<string | null>(null);
let branchOpen = $state(false);
let renameOpen = $state(false);
const loaded = $derived(info ?? fetched);
$effect(() => {
if (!(open && info === null)) {
return;
}
failure = null;
client
.conversation(id)
.then((result) => {
fetched = result;
})
.catch((error: unknown) => {
failure = error instanceof Error ? error.message : String(error);
});
});
const scope = $derived<ActionScope>({
client,
current,
host,
onBranch: () => {
branchOpen = true;
},
onChanged,
onOpen,
onRename: () => {
renameOpen = true;
},
});
const actions = $derived(loaded ? conversationActions(loaded, scope) : []);
</script>
{#snippet body(kit: MenuKit)}
{#if loaded}
<MenuItems {actions} {kit} />
{:else if failure}
<p class="px-3 py-2 text-destructive text-xs">{failure}</p>
{:else}
<div class="flex flex-col gap-2 px-3 py-2">
<Skeleton class="h-3.5 w-28" />
<Skeleton class="h-3.5 w-36" />
<Skeleton class="h-3.5 w-24" />
</div>
{/if}
{/snippet}
{#if mode === "context"}
<ContextMenu.Root bind:open>
<ContextMenu.Trigger class={className}>
{@render children?.()}
</ContextMenu.Trigger>
<ContextMenu.Content class="min-w-52">
{@render body(ContextMenu)}
</ContextMenu.Content>
</ContextMenu.Root>
{:else}
<DropdownMenu.Root bind:open>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
aria-label="Conversation actions"
class={cn("shrink-0", className)}
size="icon-sm"
variant="ghost"
>
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="min-w-52">
{@render body(DropdownMenu)}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
{#if loaded}
<BranchDialog
{client}
onOpened={onOpen}
parent={loaded}
bind:open={branchOpen}
/>
<RenameDialog {client} info={loaded} {onChanged} bind:open={renameOpen} />
{/if}
+219
View File
@@ -0,0 +1,219 @@
<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>
+91
View File
@@ -0,0 +1,91 @@
<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>
+18 -12
View File
@@ -16,13 +16,16 @@
let {
client,
id,
href,
href = null,
onOpen,
showTitle = true,
}: {
client: ApiClient;
id: string;
href: (id: string) => string;
href?: ((id: string) => string) | null;
onOpen: (id: string) => void;
// Off when a switcher above the thread already names it.
showTitle?: boolean;
} = $props();
const feed = new ConversationFeed(
@@ -54,7 +57,7 @@
});
</script>
<div class="flex h-full min-h-0 flex-col">
<div class="@container flex h-full min-h-0 flex-col">
{#if feed.error && !info}
<div class="p-4"><ErrorNote message={feed.error} retry={refresh} /></div>
{:else if !info}
@@ -71,15 +74,18 @@
liveState={feed.live.state}
onChanged={refresh}
{onOpen}
{showTitle}
/>
<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>
<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}
@@ -89,7 +95,7 @@
/>
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
value="activity"
>
<ActivityFeed
@@ -100,13 +106,13 @@
/>
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md: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"
class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
value="meta"
>
{@render meta()}
+51
View File
@@ -0,0 +1,51 @@
import { getContext, setContext } from "svelte";
export type LinkKind = "internal" | "external";
// What the panel needs from whoever mounts it. The browser admin renders
// markdown itself and hands [[links]] to the obsidian:// scheme; the
// Obsidian plugin renders through MarkdownRenderer and opens notes in
// place. Anything the host leaves undefined falls back to the browser way.
export interface PanelHost {
// Renders ``text`` into ``node``; returns the cleanup. Absent: marked + DOMPurify.
markdown?: (node: HTMLElement, text: string) => (() => void) | undefined;
name: "browser" | "obsidian";
openLink: (target: string, kind: LinkKind) => void;
// Opens the note a conversation lives in (its markdown binding).
openNote?: (path: string) => void;
// Hover-less device: row actions stay visible instead of appearing on hover.
touch: boolean;
}
const KEY = Symbol("beaver-panel-host");
export function providePanelHost(host: PanelHost): PanelHost {
setContext(KEY, host);
return host;
}
export function usePanelHost(): PanelHost {
return getContext<PanelHost | undefined>(KEY) ?? browserHost();
}
let browser: PanelHost | null = null;
export function browserHost(): PanelHost {
if (browser) {
return browser;
}
browser = {
name: "browser",
openLink(target, kind) {
if (kind === "internal") {
window.open(`obsidian://open?file=${encodeURIComponent(target)}`);
return;
}
window.open(target, "_blank", "noopener");
},
touch:
typeof window !== "undefined" &&
window.matchMedia("(hover: none)").matches,
};
return browser;
}
+106
View File
@@ -0,0 +1,106 @@
import type { ApiClient } from "$lib/api/client";
import { LiveStream } from "$lib/api/live.svelte";
import type { BusEvent, ConversationSummary } from "$lib/api/types";
const RELOAD_DEBOUNCE_MS = 1500;
const PAGE = 500;
// The conversation index kept fresh by ``/api/events``: rows land as
// ``conversation.*`` events arrive, turn markers flip ``running_turn``
// without a refetch, and anything that moves the queue reloads once.
// Shared by the admin's rail and the panel's picker.
export class ConversationIndex {
conversations = $state<ConversationSummary[]>([]);
loaded = $state(false);
readonly live: LiveStream;
protected readonly client: () => ApiClient | null;
private reloadTimer: ReturnType<typeof setTimeout> | null = null;
constructor(client: () => ApiClient | null) {
this.client = client;
this.live = new LiveStream(client, "/api/events", {
onEvent: (event) => this.apply(event),
prepare: () => this.load(),
});
}
get running(): ConversationSummary[] {
return this.conversations.filter((row) => row.running_turn);
}
get open(): ConversationSummary[] {
return this.conversations.filter((row) => row.status === "open");
}
byId(id: string): ConversationSummary | undefined {
return this.conversations.find((row) => row.id === id);
}
async load(): Promise<void> {
const client = this.client();
if (!client) {
return;
}
const list = await client.conversations({ limit: PAGE });
this.conversations = list.conversations;
this.loaded = true;
}
apply(event: BusEvent): void {
switch (event.type) {
case "conversation.created":
case "conversation.updated": {
if (typeof event.id === "string") {
this.upsert(event as unknown as ConversationSummary);
}
return;
}
case "turn.start":
case "turn.end": {
const row = event.conversation_id
? this.byId(event.conversation_id)
: undefined;
if (row) {
row.running_turn =
event.type === "turn.start" ? (event.turn_id ?? null) : null;
row.last_activity_at = event.ts;
}
return;
}
case "message.queued":
case "inject.queued":
case "reply": {
this.reloadSoon();
return;
}
default:
}
}
start(): void {
this.live.start();
}
stop(): void {
this.live.stop();
}
private upsert(row: ConversationSummary): void {
const index = this.conversations.findIndex((c) => c.id === row.id);
if (index >= 0) {
this.conversations[index] = { ...this.conversations[index], ...row };
} else {
this.conversations.unshift(row);
}
}
private reloadSoon(): void {
if (this.reloadTimer) {
return;
}
this.reloadTimer = setTimeout(() => {
this.reloadTimer = null;
this.load().catch(() => undefined);
}, RELOAD_DEBOUNCE_MS);
}
}
+56 -6
View File
@@ -1,14 +1,64 @@
<script lang="ts">
import { cn } from "$lib/utils";
import { renderMarkdown } from "./markdown";
import { usePanelHost } from "./host";
import { linkOf, renderMarkdown } from "./markdown";
let { text, class: className = "" }: { text: string; class?: string } =
$props();
const html = $derived(renderMarkdown(text));
const host = usePanelHost();
const html = $derived(host.markdown ? "" : renderMarkdown(text));
// Links inside rendered markdown are the only interactive things here:
// real anchors, reached by pointer or by Enter on the focused link. The
// handlers ride on the container so re-rendered HTML keeps them.
function follow(event: MouseEvent | KeyboardEvent) {
if (event instanceof KeyboardEvent && event.key !== "Enter") {
return;
}
const link = linkOf(event);
if (!link) {
return;
}
event.preventDefault();
event.stopPropagation();
host.openLink(link.target, link.kind);
}
function links(node: HTMLElement) {
node.addEventListener("click", follow);
node.addEventListener("keydown", follow);
return {
destroy() {
node.removeEventListener("click", follow);
node.removeEventListener("keydown", follow);
},
};
}
function render(node: HTMLElement, value: string) {
let dispose = host.markdown?.(node, value);
return {
destroy() {
dispose?.();
},
update(next: string) {
dispose?.();
dispose = host.markdown?.(node, next);
},
};
}
</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>
{#if host.markdown}
<div
class={cn("beaver-md markdown-rendered", className)}
use:links
use:render={text}
></div>
{:else}
<div class={cn("prose prose-sm max-w-none", className)} use:links>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in renderMarkdown -->
{@html html}
</div>
{/if}
+62 -5
View File
@@ -1,18 +1,75 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
import { marked, type TokenizerAndRendererExtension } from "marked";
marked.use({ breaks: true, gfm: true });
const WIKILINK = /^\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]/;
const ESCAPES: Record<string, string> = {
'"': "&quot;",
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
};
const ESCAPABLE = /[&<>"]/g;
function escapeHtml(text: string): string {
return text.replace(ESCAPABLE, (ch) => ESCAPES[ch]);
}
// ``[[note]]`` and ``[[note|alias]]`` render like Obsidian's own internal
// links, so one click handler serves both renderers.
const wikilink: TokenizerAndRendererExtension = {
level: "inline",
name: "wikilink",
renderer(token) {
const target = String(token.target);
const text = String(token.text);
return `<a class="internal-link" data-href="${escapeHtml(target)}" href="#">${escapeHtml(text)}</a>`;
},
start(src) {
return src.indexOf("[[");
},
tokenizer(src) {
const match = WIKILINK.exec(src);
if (!match) {
return;
}
const target = match[1].trim();
return {
raw: match[0],
target,
text: (match[2] ?? target).trim(),
type: "wikilink",
};
},
};
marked.use({ breaks: true, extensions: [wikilink], gfm: true });
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName === "A") {
if (node.tagName === "A" && !node.classList.contains("internal-link")) {
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.
// The browser's markdown path; the Obsidian panel renders through the
// host's MarkdownRenderer instead (see host.ts).
export function renderMarkdown(text: string): string {
const html = marked.parse(text, { async: false });
return DOMPurify.sanitize(html, { ADD_ATTR: ["target"] });
}
// Resolves the clicked anchor, if any, to what the host should open.
export function linkOf(
event: Event
): { target: string; kind: "internal" | "external" } | null {
const anchor = (event.target as HTMLElement | null)?.closest("a");
if (!anchor) {
return null;
}
if (anchor.classList.contains("internal-link")) {
const target = anchor.dataset.href ?? anchor.getAttribute("href") ?? "";
return target && target !== "#" ? { kind: "internal", target } : null;
}
const href = anchor.getAttribute("href") ?? "";
return href ? { kind: "external", target: href } : null;
}
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts" module>
import type { Component } from "svelte";
// The dropdown and the context menu share this shape; only the primitive
// behind it differs.
export interface MenuKit {
// biome-ignore lint/suspicious/noExplicitAny: both bits-ui kits fit, neither shares a type
CheckboxItem: Component<any>;
// biome-ignore lint/suspicious/noExplicitAny: see above
Item: Component<any>;
// biome-ignore lint/suspicious/noExplicitAny: see above
Separator: Component<any>;
}
</script>
<script lang="ts">
import type { MenuAction } from "./actions.svelte";
let { kit, actions }: { kit: MenuKit; actions: MenuAction[] } = $props();
</script>
{#each actions as action, index (action.label)}
{#if action.gap && index > 0}
<kit.Separator />
{/if}
{#if action.checked === undefined}
<kit.Item
disabled={action.disabled}
onSelect={() => action.run()}
variant={action.danger ? "destructive" : "default"}
>
{#if action.icon}
<action.icon class="size-4" />
{/if}
{action.label}
</kit.Item>
{:else}
<kit.CheckboxItem
checked={action.checked}
disabled={action.disabled}
onCheckedChange={() => action.run()}
>
{#if action.icon}
<action.icon class="size-4" />
{/if}
{action.label}
</kit.CheckboxItem>
{/if}
{/each}
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
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 type { ConversationIndex } from "./index.svelte";
let {
client,
index,
selected = null,
onOpen,
follow = $bindable(false),
showFollow = false,
}: {
client: ApiClient;
index: ConversationIndex;
selected?: string | null;
onOpen: (id: string) => void;
follow?: boolean;
showFollow?: boolean;
} = $props();
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">
<Popover.Root bind:open={switcherOpen}>
<Popover.Trigger>
{#snippet child({ props })}
<button
{...props}
aria-label="Switch conversation"
class="row-hover flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md px-2 text-left text-sm aria-expanded:bg-muted"
type="button"
>
{#if current}
<KindBadge kind={current.kind} />
<span class="min-w-0 flex-1 truncate font-medium">
{current.title
? clip(current.title, TITLE_MAX)
: `${current.kind} · ${shortId(current.id)}`}
</span>
{:else if selected}
<span class="tabular min-w-0 flex-1 truncate font-medium">
{shortId(selected)}
</span>
{:else}
<span class="min-w-0 flex-1 truncate text-muted-foreground">
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}
</Popover.Trigger>
<Popover.Content
align="start"
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
{client}
{index}
onOpen={(id) => {
switcherOpen = false;
onOpen(id);
}}
{selected}
/>
</div>
</Popover.Content>
</Popover.Root>
<LiveDot
class="px-1"
detail={index.live.detail}
label={false}
state={index.live.state}
/>
{#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>
+106
View File
@@ -0,0 +1,106 @@
<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 ConversationView from "./conversation-view.svelte";
import type { ConversationIndex } from "./index.svelte";
import PanelBar from "./panel-bar.svelte";
let {
client,
index,
selected = $bindable(null),
follow = $bindable(false),
showFollow = false,
}: {
client: ApiClient;
index: ConversationIndex;
selected?: string | null;
follow?: boolean;
showFollow?: boolean;
} = $props();
// 48rem of panel: below it the switcher names the thread, above it the rail does.
const RAIL_FROM_PX = 768;
let width = $state(0);
const wide = $derived(width >= RAIL_FROM_PX);
onMount(() => {
index.start();
return () => index.stop();
});
function open(id: string) {
selected = id;
}
</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.
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review,
the verdict, and DESIGN.md
-->
<div
class="beaver-panel @container flex h-full min-h-0 flex-col bg-background text-foreground"
bind:clientWidth={width}
>
<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>
{/if}
<section class="flex min-w-0 flex-1 flex-col">
{#if !wide}
<PanelBar
{client}
{index}
onOpen={open}
{selected}
{showFollow}
bind:follow
/>
{/if}
{#if selected}
{#key selected}
<ConversationView
{client}
id={selected}
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"
/>
</div>
{/if}
</section>
</div>
</div>
+70
View File
@@ -0,0 +1,70 @@
/* Palette-neutral vocabulary shared by the browser admin and the Obsidian
panel: kind and outcome hues resolve to whatever the host theme sets,
the utilities are the ledger grammar, the two animations are the only
motion the panel owns. */
@theme inline {
--font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--color-kind-master: var(--signal);
--color-kind-branch: var(--status-reply);
--color-kind-deep: var(--status-meeting);
--color-kind-job: var(--status-work);
--color-kind-fork: var(--status-skip);
--color-ok: var(--status-done);
--color-warn: var(--warn);
--color-link: var(--link);
--animate-pulse-dot: pulse-dot 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite;
--animate-rise: rise 220ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes pulse-dot {
0%,
100% {
box-shadow: 0 0 0 0 color-mix(in oklab, var(--signal) 55%, transparent);
}
50% {
box-shadow: 0 0 0 5px color-mix(in oklab, var(--signal) 0%, transparent);
}
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(3px);
}
to {
opacity: 1;
transform: none;
}
}
@utility tabular {
font-variant-numeric: tabular-nums;
}
@utility touch-shown {
@media (hover: none) {
opacity: 1;
}
}
@utility scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
@utility row-hover {
transition: background-color 150ms ease-out;
&:hover {
background: color-mix(in oklab, var(--foreground) 4%, transparent);
}
}
@utility ledger-grid {
display: grid;
column-gap: 0.75rem;
align-items: center;
}
+8 -4
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import MessageCircleQuestionIcon from "@lucide/svelte/icons/message-circle-question";
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client";
import type { PendingQuestion } from "$lib/api/types";
@@ -37,12 +38,15 @@
<section
class="flex flex-col gap-3 rounded-lg border border-primary/40 bg-primary/5 p-3"
>
<p class="font-medium text-link text-xs uppercase tracking-wide">
The agent is asking
</p>
{#each question.questions as q, index (index)}
<div class="flex flex-col gap-2">
<p class="text-sm">{q.question}</p>
<p class="flex items-start gap-2 font-medium text-sm">
<MessageCircleQuestionIcon
aria-label="The agent asks"
class="mt-0.5 size-4 shrink-0 text-link"
/>
<span>{q.question}</span>
</p>
{#if q.options?.length}
<div class="flex flex-wrap gap-1.5">
{#each q.options as option (option.label)}
+72
View File
@@ -0,0 +1,72 @@
<script lang="ts">
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client";
import type { ConversationSummary } from "$lib/api/types";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
let {
client,
info,
open = $bindable(false),
onChanged,
}: {
client: ApiClient;
info: Pick<ConversationSummary, "id" | "title">;
open?: boolean;
onChanged: () => void;
} = $props();
let title = $state("");
let busy = $state(false);
$effect(() => {
if (open) {
title = info.title ?? "";
}
});
async function save() {
busy = true;
try {
await client.update(info.id, { title });
open = false;
toast.success("Renamed");
onChanged();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Rename</Dialog.Title>
</Dialog.Header>
<form
class="flex flex-col gap-4"
onsubmit={(event) => {
event.preventDefault();
save();
}}
>
<Input aria-label="Title" placeholder="Title" bind:value={title} />
<Dialog.Footer>
<Button
onclick={() => {
// biome-ignore lint/suspicious/noGlobalAssign: bindable prop, not window.open
open = false;
}}
variant="ghost"
>
Cancel
</Button>
<Button disabled={busy} type="submit">Save</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>