fix(ui): chat keeps the bottom pinned while async markdown lands

This commit is contained in:
hh
2026-08-29 19:26:45 +02:00
parent 60dc7db925
commit 2d12391176
+78 -56
View File
@@ -35,6 +35,7 @@
let showResults = $state(false); let showResults = $state(false);
let loadedAt = $state(new Date(0).toISOString()); let loadedAt = $state(new Date(0).toISOString());
let scroller = $state<HTMLDivElement | null>(null); let scroller = $state<HTMLDivElement | null>(null);
let body = $state<HTMLDivElement | null>(null);
let now = $state(Date.now()); let now = $state(Date.now());
let pinned = true; let pinned = true;
@@ -90,6 +91,21 @@
} }
}); });
// 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(() => { $effect(() => {
const timer = setInterval(() => { const timer = setInterval(() => {
if (model.running) { if (model.running) {
@@ -124,77 +140,83 @@
</script> </script>
<div <div
class="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3 @md:px-6" class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
onscroll={() => { onscroll={() => {
pinned = nearBottom(); pinned = nearBottom();
}} }}
bind:this={scroller} bind:this={scroller}
> >
<span class="flex items-center gap-2 self-end text-muted-foreground text-xs"> <div class="flex flex-col gap-3" bind:this={body}>
<Switch aria-label="Show tool results" bind:checked={showResults} /> <span
show tool results class="flex items-center gap-2 self-end text-muted-foreground text-xs"
</span> >
{#if failure} <Switch aria-label="Show tool results" bind:checked={showResults} />
<ErrorNote message={failure} retry={load} /> show tool results
{:else if messages === null} </span>
<div class="flex flex-col gap-2"> {#if failure}
<Skeleton class="h-10 w-2/3" /> <ErrorNote message={failure} retry={load} />
<Skeleton class="h-16 w-full" /> {:else if messages === null}
<Skeleton class="h-10 w-1/2" /> <div class="flex flex-col gap-2">
</div> <Skeleton class="h-10 w-2/3" />
{:else if messages.length === 0 && tail.length === 0} <Skeleton class="h-16 w-full" />
<EmptyState <Skeleton class="h-10 w-1/2" />
hint="Nothing has been said here yet. Write below, or wait for the first inject." </div>
title="Empty conversation" {:else if messages.length === 0 && tail.length === 0}
/> <EmptyState
{:else} hint="Nothing has been said here yet. Write below, or wait for the first inject."
{#each messages as message, index (index)} title="Empty conversation"
{@const parts = blocks(message)} />
{@const isResultOnly = parts.every((b) => b.type === "tool_result")} {:else}
{#if !(isResultOnly && !showResults)} {#each messages as message, index (index)}
<div {@const parts = blocks(message)}
class={cn( {@const isResultOnly = parts.every((b) => b.type === "tool_result")}
{#if !(isResultOnly && !showResults)}
<div
class={cn(
"flex flex-col gap-1.5", "flex flex-col gap-1.5",
message.role === "user" && !isResultOnly && "items-end" message.role === "user" && !isResultOnly && "items-end"
)} )}
> >
{#each parts as block, blockIndex (blockIndex)} {#each parts as block, blockIndex (blockIndex)}
{#if block.type === "text" && block.text} {#if block.type === "text" && block.text}
<Markdown <Markdown
class={cn( class={cn(
"max-w-[75ch] rounded-lg px-3 py-2", "max-w-[75ch] rounded-lg px-3 py-2",
message.role === "user" ? "bg-primary/10" : "bg-muted/50" message.role === "user" ? "bg-primary/10" : "bg-muted/50"
)} )}
text={block.text} text={block.text}
/> />
{:else if block.type === "tool_use"} {:else if block.type === "tool_use"}
<p class="flex items-baseline gap-2 px-1 text-xs"> <p class="flex items-baseline gap-2 px-1 text-xs">
<span class="font-medium">{toolLabel(block.name ?? "?")}</span> <span class="font-medium"
<span class="truncate text-muted-foreground"> >{toolLabel(block.name ?? "?")}</span
{clip(summarizeInput(block.name ?? "", block.input), 160)} >
</span> <span class="truncate text-muted-foreground">
</p> {clip(summarizeInput(block.name ?? "", block.input), 160)}
{:else if block.type === "tool_result" && showResults} </span>
<pre </p>
class={cn( {: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", "max-h-48 max-w-full overflow-auto whitespace-pre-wrap break-all rounded-md p-2 text-xs",
block.is_error block.is_error
? "bg-destructive/8 text-destructive" ? "bg-destructive/8 text-destructive"
: "bg-muted/40" : "bg-muted/40"
)} )}
>{clip(resultText(block), RESULT_CLIP)}</pre> >{clip(resultText(block), RESULT_CLIP)}</pre>
{:else if block.type === "thinking"} {:else if block.type === "thinking"}
<p class="px-1 text-kind-deep text-xs">thinking</p> <p class="px-1 text-kind-deep text-xs">thinking</p>
{/if} {/if}
{/each} {/each}
</div> </div>
{/if} {/if}
{/each}
{/if}
{#if model.question}
<QuestionCard {client} {conversationId} question={model.question} />
{/if}
{#each tail as turn (turn.id)}
<TurnCard {now} {turn} />
{/each} {/each}
{/if} </div>
{#if model.question}
<QuestionCard {client} {conversationId} question={model.question} />
{/if}
{#each tail as turn (turn.id)}
<TurnCard {now} {turn} />
{/each}
</div> </div>