feat(ui): interactive graph, strip context menus, days by activity, message times, limit status words
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user