244 lines
7.3 KiB
Svelte
244 lines
7.3 KiB
Svelte
<script lang="ts">
|
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
|
import GitBranchIcon from "@lucide/svelte/icons/git-branch";
|
|
import type { ApiClient } from "$lib/api/client";
|
|
import type { LiveState } from "$lib/api/live.svelte";
|
|
import type { ConversationInfo } from "$lib/api/types";
|
|
import { fmtRelative, fmtTokens, shortId } from "$lib/format";
|
|
import KindMark from "$lib/shell/kind-mark.svelte";
|
|
import { cn } from "$lib/utils";
|
|
import BindingsList from "./bindings-list.svelte";
|
|
import BranchDialog from "./branch-dialog.svelte";
|
|
import ConversationMenu from "./conversation-menu.svelte";
|
|
import QueueList from "./queue-list.svelte";
|
|
|
|
let {
|
|
client,
|
|
info,
|
|
liveState,
|
|
liveDetail = null,
|
|
href = null,
|
|
onChanged,
|
|
onOpen,
|
|
showTitle = true,
|
|
view = $bindable("chat"),
|
|
onContext,
|
|
}: {
|
|
client: ApiClient;
|
|
info: ConversationInfo;
|
|
liveState: LiveState;
|
|
liveDetail?: string | null;
|
|
href?: ((id: string) => string) | null;
|
|
onChanged: () => void;
|
|
onOpen: (id: string) => void;
|
|
showTitle?: boolean;
|
|
view?: string;
|
|
onContext?: () => void;
|
|
} = $props();
|
|
|
|
const VIEWS = [
|
|
{ label: "Thread", value: "chat" },
|
|
{ label: "Activity", value: "activity" },
|
|
{ label: "Raw", value: "raw" },
|
|
{ label: "Context", value: "context" },
|
|
];
|
|
|
|
let branchOpen = $state(false);
|
|
let detailsOpen = $state(false);
|
|
|
|
const mood = $derived.by(() => {
|
|
if (info.running_turn) {
|
|
return {
|
|
dot: "bg-signal animate-pulse-dot",
|
|
text: "in motion",
|
|
tone: "text-signal",
|
|
};
|
|
}
|
|
if (info.pending_question) {
|
|
return {
|
|
dot: "bg-attention",
|
|
text: "waiting for you",
|
|
tone: "text-attention-foreground",
|
|
};
|
|
}
|
|
if (info.status !== "open") {
|
|
return {
|
|
dot: "bg-muted-foreground/50",
|
|
text: info.status,
|
|
tone: "text-muted-foreground",
|
|
};
|
|
}
|
|
return {
|
|
dot: "bg-muted-foreground/50",
|
|
text: `quiet · ${fmtRelative(info.last_activity_at)}`,
|
|
tone: "text-muted-foreground",
|
|
};
|
|
});
|
|
const queued = $derived(
|
|
info.queue.filter((item) => item.status === "queued").length
|
|
);
|
|
const connection = $derived(
|
|
liveState === "open" ? null : (liveDetail ?? liveState)
|
|
);
|
|
</script>
|
|
|
|
<header class="flex flex-col border-b bg-background/80 backdrop-blur-md">
|
|
<div class="flex items-center gap-2 px-3 py-2 @md:px-6">
|
|
{#if showTitle}
|
|
<KindMark kind={info.kind} />
|
|
<h1 class="min-w-0 truncate font-semibold text-base tracking-tight">
|
|
{info.title || `${info.kind} ${shortId(info.id)}`}
|
|
</h1>
|
|
{/if}
|
|
<span class={cn("flex shrink-0 items-center gap-1.5 text-xs", mood.tone)}>
|
|
<span class={cn("size-1.5 rounded-full", mood.dot)}></span>
|
|
<span class="hidden @sm:inline">{mood.text}</span>
|
|
</span>
|
|
{#if queued > 0}
|
|
<span
|
|
class="tabular text-note text-xs"
|
|
title="messages waiting for the next turn"
|
|
>
|
|
+{queued}
|
|
</span>
|
|
{/if}
|
|
{#if connection}
|
|
<span class="truncate text-warn text-xs" title={liveDetail ?? undefined}>
|
|
{connection}
|
|
</span>
|
|
{/if}
|
|
<div class="ml-auto flex shrink-0 items-center gap-1">
|
|
{#if info.context_tokens}
|
|
<button
|
|
class="rule-word tabular inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs hover:bg-accent"
|
|
onclick={() => {
|
|
view = "context";
|
|
onContext?.();
|
|
}}
|
|
title="context of the last turn - what the model is holding"
|
|
type="button"
|
|
>
|
|
{fmtTokens(info.context_tokens)}
|
|
<span class="text-muted-foreground/70">/ 1M</span>
|
|
</button>
|
|
{/if}
|
|
<button
|
|
aria-label="Branch off this conversation"
|
|
class="rule-word inline-flex size-7 items-center justify-center rounded-md hover:bg-accent disabled:opacity-40"
|
|
disabled={info.status !== "open"}
|
|
onclick={() => {
|
|
branchOpen = true;
|
|
}}
|
|
title="Branch off"
|
|
type="button"
|
|
>
|
|
<GitBranchIcon class="size-4" />
|
|
</button>
|
|
<ConversationMenu
|
|
{client}
|
|
current
|
|
id={info.id}
|
|
{info}
|
|
mode="button"
|
|
{onChanged}
|
|
{onOpen}
|
|
/>
|
|
<button
|
|
aria-expanded={detailsOpen}
|
|
aria-label="Details"
|
|
class={cn(
|
|
"rule-word inline-flex size-7 items-center justify-center rounded-md hover:bg-accent",
|
|
detailsOpen && "bg-accent text-foreground"
|
|
)}
|
|
onclick={() => {
|
|
detailsOpen = !detailsOpen;
|
|
}}
|
|
title="Details"
|
|
type="button"
|
|
>
|
|
<ChevronDownIcon
|
|
class={cn("size-4 transition-transform", detailsOpen && "rotate-180")}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-4 px-3 pb-1.5 @md:px-6">
|
|
{#each VIEWS as item (item.value)}
|
|
<button
|
|
class="rule-word text-xs"
|
|
data-active={view === item.value}
|
|
onclick={() => {
|
|
view = item.value;
|
|
}}
|
|
type="button"
|
|
>
|
|
{item.label}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{#if detailsOpen}
|
|
<div
|
|
class="animate-fade-in grid gap-x-6 gap-y-3 border-t px-3 py-3 text-xs @md:grid-cols-2 @md:px-6"
|
|
>
|
|
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1">
|
|
<dt class="text-muted-foreground">agent</dt>
|
|
<dd>{info.agent}</dd>
|
|
<dt class="text-muted-foreground">opened via</dt>
|
|
<dd>{info.origin}</dd>
|
|
{#if info.parent}
|
|
<dt class="text-muted-foreground">parent</dt>
|
|
<dd>
|
|
{#if href}
|
|
<a class="text-link hover:underline" href={href(info.parent)}>
|
|
{shortId(info.parent)}
|
|
</a>
|
|
{:else}
|
|
<button
|
|
class="text-link hover:underline"
|
|
onclick={() => info.parent && onOpen(info.parent)}
|
|
type="button"
|
|
>
|
|
{shortId(info.parent)}
|
|
</button>
|
|
{/if}
|
|
</dd>
|
|
{/if}
|
|
<dt class="text-muted-foreground">id</dt>
|
|
<dd class="tabular break-all">{info.id}</dd>
|
|
<dt class="text-muted-foreground">session</dt>
|
|
<dd class="tabular break-all">
|
|
{info.session_id ?? "none yet"}
|
|
{info.live ? " · process alive" : ""}
|
|
</dd>
|
|
{#if Object.keys(info.flags).length > 0}
|
|
<dt class="text-muted-foreground">flags</dt>
|
|
<dd class="tabular">
|
|
{Object.entries(info.flags)
|
|
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
|
.join(" ")}
|
|
</dd>
|
|
{/if}
|
|
</dl>
|
|
<div class="flex flex-col gap-3">
|
|
<div class="flex flex-col gap-1">
|
|
<span class="text-muted-foreground">windows</span>
|
|
<BindingsList
|
|
bindings={info.bindings}
|
|
{client}
|
|
conversationId={info.id}
|
|
{onChanged}
|
|
/>
|
|
</div>
|
|
{#if info.queue.length > 0}
|
|
<div class="flex flex-col gap-1">
|
|
<span class="text-muted-foreground">queue</span>
|
|
<QueueList items={info.queue} />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</header>
|
|
|
|
<BranchDialog {client} onOpened={onOpen} parent={info} bind:open={branchOpen} />
|