feat(ui,api): chat view with live tail and autoscroll, conversation rail with last message previews

This commit is contained in:
hh
2026-08-29 00:18:08 +02:00
parent 8ea4cea3ef
commit 3ca5843868
9 changed files with 532 additions and 334 deletions
+200
View File
@@ -0,0 +1,200 @@
<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";
import ErrorNote from "$lib/components/error-note.svelte";
import { Skeleton } from "$lib/components/ui/skeleton";
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" }]
: message.content;
}
function resultText(block: ContentBlock): string {
const { content } = block;
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
return content
.map((part) =>
part && typeof part === "object" && "text" in part
? String((part as { text: unknown }).text)
: ""
)
.join("\n");
}
return "";
}
</script>
<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
</span>
{#if failure}
<ErrorNote message={failure} retry={load} />
{:else if messages === null}
<div class="flex flex-col gap-2">
<Skeleton class="h-10 w-2/3" />
<Skeleton class="h-16 w-full" />
<Skeleton class="h-10 w-1/2" />
</div>
{:else if messages.length === 0 && tail.length === 0}
<EmptyState
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)}
{@const parts = blocks(message)}
{@const isResultOnly = parts.every((b) => b.type === "tool_result")}
{#if !(isResultOnly && !showResults)}
<div
class={cn(
"flex flex-col gap-1.5",
message.role === "user" && !isResultOnly && "items-end"
)}
>
{#each parts as block, blockIndex (blockIndex)}
{#if block.type === "text" && block.text}
<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"
)}
>
{block.text}
</p>
{:else if block.type === "tool_use"}
<p class="flex items-baseline gap-2 px-1 text-xs">
<span class="font-medium">{toolLabel(block.name ?? "?")}</span>
<span class="truncate text-muted-foreground">
{clip(summarizeInput(block.name ?? "", block.input), 160)}
</span>
</p>
{:else if block.type === "tool_result" && showResults}
<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"
)}
>{clip(resultText(block), RESULT_CLIP)}</pre>
{:else if block.type === "thinking"}
<p class="px-1 text-kind-deep text-xs">thinking</p>
{/if}
{/each}
</div>
{/if}
{/each}
{/if}
{#if model.question}
<QuestionCard {client} {conversationId} question={model.question} />
{/if}
{#each tail as turn (turn.id)}
<TurnCard {now} {turn} />
{/each}
</div>