feat(ui): interactive graph, strip context menus, days by activity, message times, limit status words

This commit is contained in:
hh
2026-09-02 04:24:15 +02:00
parent 256b2a68d6
commit d41f18504c
27 changed files with 1619 additions and 381 deletions
+74 -13
View File
@@ -4,7 +4,7 @@
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 } from "$lib/format";
import { clip, fmtDateTime, fmtTime } from "$lib/format";
import { cn } from "$lib/utils";
import type { ActivityModel } from "./activity.svelte";
import { summarizeInput, toolLabel } from "./activity.svelte";
@@ -37,6 +37,10 @@
let loadedAt = $state(new Date(0).toISOString());
let scroller = $state<HTMLDivElement | null>(null);
let body = $state<HTMLDivElement | null>(null);
let end = $state<HTMLDivElement | null>(null);
const SETTLE_MS = 2500;
const SETTLE_STEP_MS = 120;
let settleUntil = 0;
let now = $state(Date.now());
let pinned = true;
@@ -65,6 +69,7 @@
try {
({ messages } = await client.history(conversationId));
loadedAt = new Date().toISOString();
settle();
host.cache?.set(
`history:${conversationId}`,
messages.slice(-CACHED_MESSAGES)
@@ -83,11 +88,40 @@
return left < NEAR_BOTTOM_PX;
}
async function scrollToBottom() {
await tick();
function toBottom() {
if (scroller) {
scroller.scrollTop = scroller.scrollHeight;
}
// Whatever actually scrolls - this box, or a host's pane around it.
end?.scrollIntoView({ block: "end" });
}
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(() => {
@@ -129,6 +163,8 @@
});
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 {
@@ -175,13 +211,15 @@
</script>
<div
class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 @md:px-6"
onscroll={() => {
pinned = nearBottom();
if (Date.now() > settleUntil) {
pinned = nearBottom();
}
}}
bind:this={scroller}
>
<div class="flex flex-col gap-3" bind:this={body}>
<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}
@@ -208,31 +246,51 @@
{@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)}
{#if newDay}
<p class="label-quiet self-center pt-2">
{fmtDateTime(message.ts).replace(DAY_TIME, "")}
</p>
{/if}
{#if system}
<button
aria-expanded={openSystem.has(index)}
class="self-center max-w-[75ch] rounded-md px-2 py-1 text-left font-mono text-muted-foreground text-xs hover:bg-accent"
class="@md:max-w-[75ch] max-w-full self-center 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="truncate">{clip(system.split("\n")[0], 120)}</span>
<span class="block truncate"
>{clip(system.split("\n")[0], 120)}</span
>
{/if}
</button>
{:else if !(isResultOnly && !showResults)}
<div
class={cn(
"flex flex-col gap-1.5",
"group flex min-w-0 max-w-full flex-col gap-1.5",
message.role === "user" && !isResultOnly && "items-end"
)}
>
{#if message.ts && newMinute && !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(
"max-w-[75ch]",
"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"
@@ -240,11 +298,13 @@
text={block.text}
/>
{:else if block.type === "tool_use"}
<p class="flex items-baseline gap-2 px-1 text-xs">
<span class="font-medium"
<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="truncate text-muted-foreground">
<span class="min-w-0 truncate text-muted-foreground">
{clip(summarizeInput(block.name ?? "", block.input), 160)}
</span>
</p>
@@ -271,5 +331,6 @@
{#each tail as turn (turn.id)}
<TurnCard {now} {turn} />
{/each}
<div aria-hidden="true" class="h-px shrink-0" bind:this={end}></div>
</div>
</div>