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
+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}