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:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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> = {
|
||||
'"': """,
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user