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 be114e6eaf
commit 60dc7db925
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;
}