feat(ui,api): chat view with live tail and autoscroll, conversation rail with last message previews
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import type { ContentBlock, HistoryMessage } from "$lib/api/types";
|
||||
import EmptyState from "$lib/components/empty-state.svelte";
|
||||
@@ -7,37 +8,96 @@
|
||||
import { Switch } from "$lib/components/ui/switch";
|
||||
import { clip } from "$lib/format";
|
||||
import { cn } from "$lib/utils";
|
||||
import type { ActivityModel } from "./activity.svelte";
|
||||
import { summarizeInput, toolLabel } from "./activity.svelte";
|
||||
import QuestionCard from "./question-card.svelte";
|
||||
import TurnCard from "./turn-card.svelte";
|
||||
|
||||
let {
|
||||
client,
|
||||
conversationId,
|
||||
model,
|
||||
refreshKey = 0,
|
||||
}: {
|
||||
client: ApiClient;
|
||||
conversationId: string;
|
||||
model: ActivityModel;
|
||||
refreshKey?: number;
|
||||
} = $props();
|
||||
|
||||
const RESULT_CLIP = 600;
|
||||
const NEAR_BOTTOM_PX = 120;
|
||||
const TICK_MS = 1000;
|
||||
|
||||
let messages = $state<HistoryMessage[] | null>(null);
|
||||
let failure = $state<string | null>(null);
|
||||
let showResults = $state(false);
|
||||
let loadedAt = $state(new Date(0).toISOString());
|
||||
let scroller = $state<HTMLDivElement | null>(null);
|
||||
let now = $state(Date.now());
|
||||
let pinned = true;
|
||||
|
||||
const tail = $derived(
|
||||
model.turns
|
||||
.filter((turn) => turn.status === "running" || turn.startedAt > loadedAt)
|
||||
.reverse()
|
||||
);
|
||||
const tailSize = $derived(
|
||||
tail.reduce(
|
||||
(n, turn) => n + turn.text.length + Object.keys(turn.nodes).length,
|
||||
0
|
||||
)
|
||||
);
|
||||
|
||||
async function load() {
|
||||
failure = null;
|
||||
try {
|
||||
({ messages } = await client.history(conversationId));
|
||||
loadedAt = new Date().toISOString();
|
||||
} catch (cause) {
|
||||
failure = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
|
||||
function nearBottom(): boolean {
|
||||
if (!scroller) {
|
||||
return true;
|
||||
}
|
||||
const left =
|
||||
scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
|
||||
return left < NEAR_BOTTOM_PX;
|
||||
}
|
||||
|
||||
async function scrollToBottom() {
|
||||
await tick();
|
||||
if (scroller) {
|
||||
scroller.scrollTop = scroller.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (refreshKey >= 0) {
|
||||
pinned = true;
|
||||
load();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const count = (messages?.length ?? 0) + tailSize;
|
||||
if (pinned && count >= 0) {
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const timer = setInterval(() => {
|
||||
if (model.running) {
|
||||
now = Date.now();
|
||||
}
|
||||
}, TICK_MS);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
|
||||
function blocks(message: HistoryMessage): ContentBlock[] {
|
||||
return typeof message.content === "string"
|
||||
? [{ text: message.content, type: "text" }]
|
||||
@@ -60,11 +120,15 @@
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const RESULT_CLIP = 600;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-4 py-3 sm:px-6"
|
||||
onscroll={() => {
|
||||
pinned = nearBottom();
|
||||
}}
|
||||
bind:this={scroller}
|
||||
>
|
||||
<span class="flex items-center gap-2 self-end text-muted-foreground text-xs">
|
||||
<Switch aria-label="Show tool results" bind:checked={showResults} />
|
||||
show tool results
|
||||
@@ -77,10 +141,10 @@
|
||||
<Skeleton class="h-16 w-full" />
|
||||
<Skeleton class="h-10 w-1/2" />
|
||||
</div>
|
||||
{:else if messages.length === 0}
|
||||
{:else if messages.length === 0 && tail.length === 0}
|
||||
<EmptyState
|
||||
hint="The transcript mirror is empty - nothing has been said in this conversation yet."
|
||||
title="No history"
|
||||
hint="Nothing has been said here yet. Write below, or wait for the first inject."
|
||||
title="Empty conversation"
|
||||
/>
|
||||
{:else}
|
||||
{#each messages as message, index (index)}
|
||||
@@ -98,9 +162,7 @@
|
||||
<p
|
||||
class={cn(
|
||||
"max-w-[75ch] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm",
|
||||
message.role === "user"
|
||||
? "bg-primary/10"
|
||||
: "bg-muted/50"
|
||||
message.role === "user" ? "bg-primary/10" : "bg-muted/50"
|
||||
)}
|
||||
>
|
||||
{block.text}
|
||||
@@ -116,7 +178,9 @@
|
||||
<pre
|
||||
class={cn(
|
||||
"max-h-48 max-w-full overflow-auto whitespace-pre-wrap break-all rounded-md p-2 text-xs",
|
||||
block.is_error ? "bg-destructive/8 text-destructive" : "bg-muted/40"
|
||||
block.is_error
|
||||
? "bg-destructive/8 text-destructive"
|
||||
: "bg-muted/40"
|
||||
)}
|
||||
>{clip(resultText(block), RESULT_CLIP)}</pre>
|
||||
{:else if block.type === "thinking"}
|
||||
@@ -127,4 +191,10 @@
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{#if model.question}
|
||||
<QuestionCard {client} {conversationId} question={model.question} />
|
||||
{/if}
|
||||
{#each tail as turn (turn.id)}
|
||||
<TurnCard {now} {turn} />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -6,10 +6,10 @@
|
||||
import * as Tabs from "$lib/components/ui/tabs";
|
||||
import ActivityFeed from "./activity-feed.svelte";
|
||||
import BindingsList from "./bindings-list.svelte";
|
||||
import ChatView from "./chat-view.svelte";
|
||||
import Composer from "./composer.svelte";
|
||||
import { ConversationFeed } from "./conversation.svelte";
|
||||
import ConversationHeader from "./conversation-header.svelte";
|
||||
import HistoryView from "./history-view.svelte";
|
||||
import QueueList from "./queue-list.svelte";
|
||||
import RawEntries from "./raw-entries.svelte";
|
||||
|
||||
@@ -31,9 +31,8 @@
|
||||
() => client,
|
||||
untrack(() => id)
|
||||
);
|
||||
let tab = $state("activity");
|
||||
let tab = $state("chat");
|
||||
let historyKey = $state(0);
|
||||
let landed = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
feed.start();
|
||||
@@ -55,13 +54,6 @@
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (feed.loaded && !landed) {
|
||||
landed = true;
|
||||
tab = feed.model.running ? "activity" : "history";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
@@ -90,34 +82,44 @@
|
||||
<div class="flex min-h-0 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="history">History</Tabs.Trigger>
|
||||
<Tabs.Trigger value="raw">Raw</Tabs.Trigger>
|
||||
<Tabs.Trigger class={compact ? "" : "lg:hidden"} value="meta"
|
||||
>Meta</Tabs.Trigger
|
||||
>
|
||||
<Tabs.Trigger class={compact ? "" : "lg:hidden"} value="meta">
|
||||
Meta
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6">
|
||||
<Tabs.Content value="activity">
|
||||
<ActivityFeed
|
||||
{client}
|
||||
connected={feed.live.state === "open"}
|
||||
conversationId={id}
|
||||
model={feed.model}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="history">
|
||||
<HistoryView
|
||||
{client}
|
||||
conversationId={id}
|
||||
refreshKey={historyKey}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="raw">
|
||||
<RawEntries {client} conversationId={id} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="meta">{@render rail()}</Tabs.Content>
|
||||
</div>
|
||||
<Tabs.Content class="flex min-h-0 flex-1 flex-col" value="chat">
|
||||
<ChatView
|
||||
{client}
|
||||
conversationId={id}
|
||||
model={feed.model}
|
||||
refreshKey={historyKey}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
|
||||
value="activity"
|
||||
>
|
||||
<ActivityFeed
|
||||
{client}
|
||||
connected={feed.live.state === "open"}
|
||||
conversationId={id}
|
||||
model={feed.model}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm: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"
|
||||
value="meta"
|
||||
>
|
||||
{@render rail()}
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
<Composer
|
||||
{client}
|
||||
|
||||
Reference in New Issue
Block a user