344 lines
10 KiB
Svelte
344 lines
10 KiB
Svelte
<script lang="ts">
|
|
import { tick, untrack } 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 { clip, fmtDateTime, fmtTime } from "$lib/format";
|
|
import { cn } from "$lib/utils";
|
|
import type { ActivityModel } from "./activity.svelte";
|
|
import { summarizeInput, toolLabel } from "./activity.svelte";
|
|
import { usePanelHost } from "./host";
|
|
import Markdown from "./markdown.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 CACHED_MESSAGES = 80;
|
|
const host = usePanelHost();
|
|
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 body = $state<HTMLDivElement | null>(null);
|
|
const SETTLE_MS = 2500;
|
|
const SETTLE_STEP_MS = 120;
|
|
let settleUntil = 0;
|
|
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;
|
|
if (messages === null) {
|
|
const cached = host.cache?.get<HistoryMessage[]>(
|
|
`history:${conversationId}`
|
|
);
|
|
if (cached) {
|
|
messages = cached;
|
|
}
|
|
}
|
|
try {
|
|
({ messages } = await client.history(conversationId));
|
|
loadedAt = new Date().toISOString();
|
|
settle();
|
|
host.cache?.set(
|
|
`history:${conversationId}`,
|
|
messages.slice(-CACHED_MESSAGES)
|
|
);
|
|
} catch (cause) {
|
|
failure = cause instanceof Error ? cause.message : String(cause);
|
|
}
|
|
}
|
|
|
|
// The scroller is column-reverse: its origin is the bottom, scrollTop is
|
|
// zero there and goes negative upward, and the browser keeps the view at
|
|
// the bottom while content grows - no matter how late markdown lands.
|
|
function nearBottom(): boolean {
|
|
return !scroller || Math.abs(scroller.scrollTop) < NEAR_BOTTOM_PX;
|
|
}
|
|
|
|
function toBottom() {
|
|
if (scroller) {
|
|
scroller.scrollTop = 0;
|
|
}
|
|
}
|
|
|
|
async function scrollToBottom() {
|
|
await tick();
|
|
toBottom();
|
|
}
|
|
|
|
// A fresh thread keeps growing after it is on screen: markdown renders
|
|
// late, embeds load, fonts land. Hold the bottom for a while after a load.
|
|
function settle() {
|
|
settleUntil = Date.now() + SETTLE_MS;
|
|
const step = () => {
|
|
if (!pinned || Date.now() > settleUntil) {
|
|
return;
|
|
}
|
|
toBottom();
|
|
setTimeout(step, SETTLE_STEP_MS);
|
|
};
|
|
step();
|
|
}
|
|
|
|
// The date changes rarely; say it once, then only the time.
|
|
function dayOf(iso: string | null | undefined): string {
|
|
return iso ? iso.slice(0, 10) : "";
|
|
}
|
|
|
|
function minuteOf(iso: string | null | undefined): string {
|
|
return iso ? fmtTime(iso).replace(SECONDS, "") : "";
|
|
}
|
|
|
|
$effect(() => {
|
|
if (refreshKey >= 0) {
|
|
pinned = true;
|
|
untrack(() => load());
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
const count = (messages?.length ?? 0) + tailSize;
|
|
if (pinned && count >= 0) {
|
|
scrollToBottom();
|
|
}
|
|
});
|
|
|
|
// Markdown lands asynchronously (Obsidian renders after the DOM exists),
|
|
// so follow the content's height, not just the message count.
|
|
$effect(() => {
|
|
if (!(scroller && body)) {
|
|
return;
|
|
}
|
|
const observer = new ResizeObserver(() => {
|
|
if (pinned && scroller) {
|
|
scroller.scrollTop = scroller.scrollHeight;
|
|
}
|
|
});
|
|
observer.observe(body);
|
|
return () => observer.disconnect();
|
|
});
|
|
|
|
$effect(() => {
|
|
const timer = setInterval(() => {
|
|
if (model.running) {
|
|
now = Date.now();
|
|
}
|
|
}, TICK_MS);
|
|
return () => clearInterval(timer);
|
|
});
|
|
|
|
const SYSTEM_HEAD = /^\[[^\]\n]+\]/;
|
|
const SECONDS = /:\d{2}$/;
|
|
const DAY_TIME = /,?\s*\d{2}:\d{2}$/;
|
|
let openSystem = $state<Set<number>>(new Set());
|
|
|
|
function systemText(message: HistoryMessage): string | null {
|
|
if (message.role !== "user" || typeof message.content !== "string") {
|
|
return null;
|
|
}
|
|
return SYSTEM_HEAD.test(message.content.trimStart())
|
|
? message.content
|
|
: null;
|
|
}
|
|
|
|
function toggleSystem(index: number) {
|
|
const next = new Set(openSystem);
|
|
if (next.has(index)) {
|
|
next.delete(index);
|
|
} else {
|
|
next.add(index);
|
|
}
|
|
openSystem = next;
|
|
}
|
|
|
|
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-reverse overflow-y-auto overflow-x-hidden px-3 py-3 [overflow-anchor:none] @md:px-6"
|
|
onscroll={() => {
|
|
if (Date.now() > settleUntil) {
|
|
pinned = nearBottom();
|
|
}
|
|
}}
|
|
bind:this={scroller}
|
|
>
|
|
<div class="flex min-w-0 max-w-full flex-col gap-3" bind:this={body}>
|
|
<button
|
|
class="rule-word self-end text-xs"
|
|
data-active={showResults}
|
|
onclick={() => {
|
|
showResults = !showResults;
|
|
}}
|
|
type="button"
|
|
>
|
|
{showResults ? "hide tool results" : "show tool results"}
|
|
</button>
|
|
{#if failure}
|
|
<ErrorNote message={failure} retry={load} />
|
|
{:else if messages === null}
|
|
<p class="py-6 text-center text-muted-foreground text-xs">
|
|
Loading the thread…
|
|
</p>
|
|
{: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")}
|
|
{@const system = systemText(message)}
|
|
{@const newDay =
|
|
message.ts && dayOf(message.ts) !== dayOf(messages[index - 1]?.ts)}
|
|
{@const newMinute =
|
|
message.ts &&
|
|
minuteOf(message.ts) !== minuteOf(messages[index - 1]?.ts)}
|
|
{@const spoken =
|
|
message.role === "user" ||
|
|
parts.some((b) => b.type === "text" && Boolean(b.text))}
|
|
{#if newDay}
|
|
<p class="label-quiet self-center pt-2">
|
|
{fmtDateTime(message.ts).replace(DAY_TIME, "")}
|
|
</p>
|
|
{/if}
|
|
{#if system}
|
|
<div class="flex min-w-0 items-start justify-center gap-2">
|
|
{#if message.ts}
|
|
<span
|
|
class="tabular shrink-0 py-1 text-[11px] text-muted-foreground opacity-60"
|
|
title={fmtDateTime(message.ts)}
|
|
>
|
|
{minuteOf(message.ts)}
|
|
</span>
|
|
{/if}
|
|
<button
|
|
aria-expanded={openSystem.has(index)}
|
|
class="@md:max-w-[75ch] min-w-0 max-w-full rounded-md px-2 py-1 text-left font-mono text-muted-foreground text-xs hover:bg-accent"
|
|
onclick={() => toggleSystem(index)}
|
|
type="button"
|
|
>
|
|
{#if openSystem.has(index)}
|
|
<span class="whitespace-pre-wrap break-words">{system}</span>
|
|
{:else}
|
|
<span class="block truncate"
|
|
>{clip(system.split("\n")[0], 120)}</span
|
|
>
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
{:else if !(isResultOnly && !showResults)}
|
|
<div
|
|
class={cn(
|
|
"group flex min-w-0 max-w-full flex-col gap-1.5",
|
|
message.role === "user" && !isResultOnly && "items-end"
|
|
)}
|
|
>
|
|
{#if message.ts && (newMinute || spoken) && !isResultOnly}
|
|
<span
|
|
class="tabular px-1 text-[11px] text-muted-foreground opacity-60 transition-opacity group-hover:opacity-100"
|
|
title={fmtDateTime(message.ts)}
|
|
>
|
|
{minuteOf(message.ts)}
|
|
</span>
|
|
{/if}
|
|
{#each parts as block, blockIndex (blockIndex)}
|
|
{#if block.type === "text" && block.text}
|
|
<Markdown
|
|
class={cn(
|
|
"min-w-0 @md:max-w-[75ch] max-w-full",
|
|
message.role === "user"
|
|
? "rounded-2xl rounded-br-md bg-primary/10 px-3.5 py-2"
|
|
: "px-1 py-1"
|
|
)}
|
|
text={block.text}
|
|
/>
|
|
{:else if block.type === "tool_use"}
|
|
<p
|
|
class="flex min-w-0 max-w-full items-baseline gap-2 px-1 text-xs"
|
|
>
|
|
<span class="shrink-0 font-medium"
|
|
>{toolLabel(block.name ?? "?")}</span
|
|
>
|
|
<span class="min-w-0 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>
|
|
</div>
|