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:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user