Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 48 additions & 53 deletions src/lib/components/chat/ChatMessage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -410,62 +410,57 @@
{#if isLast && loading && blocks.length === 0}
<IconLoading classNames="loading inline ml-2 first:ml-0" />
{/if}
{#if isProcessStreaming}
<!-- Streaming the thinking / tool phase: render every block flat and
inline, exactly like today. Nesting kicks in once the answer starts. -->
{#each blocks as block, blockIndex (block.type === "tool" ? `tool-${block.uuid}-${blockIndex}` : `block-${blockIndex}`)}
{#if block.type === "text"}
{#if block.content.trim().length > 0}
<div class={proseClasses}>
<MarkdownRenderer content={block.content} loading={isLast && loading} />
</div>
{/if}
{:else if block.type === "artifact"}
<ArtifactCard op={block.op} messageId={message.id} opIndex={block.opIndex} />
{:else}
<div data-exclude-from-copy class="not-last:mb-1 has-[+.prose]:mb-2! [.prose+&]:mt-3">
{#if block.type === "think"}
<OpenReasoningResults
content={block.content}
loading={isLast && loading && !block.closed}
/>
{:else}
<ToolUpdate tool={block.updates} {loading} />
{/if}
<!-- One template for the streaming and settled phases: the trailing
process group renders live while thinking/tools stream and settles
in place when the answer starts. A single branch per unit shape
keeps component instances alive across that flip — a lone thinking
block ANIMATES its collapse instead of being remounted ~300px
shorter in one frame (the layout jump this replaces). -->
{#each renderUnits as unit, unitIndex (`${unit.kind}-${unitIndex}`)}
{#if unit.kind === "text"}
{#if isLast && loading && unit.content.length === 0}
<IconLoading classNames="loading inline ml-2 first:ml-0" />
{:else if unit.content.trim().length > 0}
<div class={proseClasses}>
<MarkdownRenderer content={unit.content} loading={isLast && loading} />
</div>
{/if}
{/each}
{:else}
<!-- Answer started or generation finished: nest the process blocks. -->
{#each renderUnits as unit, unitIndex (`${unit.kind}-${unitIndex}`)}
{#if unit.kind === "text"}
{#if isLast && loading && unit.content.length === 0}
<IconLoading classNames="loading inline ml-2 first:ml-0" />
{:else if unit.content.trim().length > 0}
<div class={proseClasses}>
<MarkdownRenderer content={unit.content} loading={isLast && loading} />
</div>
{/if}
{:else if unit.kind === "artifact"}
<ArtifactCard op={unit.op} messageId={message.id} opIndex={unit.opIndex} />
{:else if unit.kind === "group"}
<div data-exclude-from-copy class="not-last:mb-1 has-[+.prose]:mb-2! [.prose+&]:mt-3">
{#if unit.blocks.length > 1}
<!-- Collapse the whole run into a single summary -->
<ToolCallsSummary blocks={unit.blocks} toolCount={unit.toolCount} />
{:else}
<!-- A lone process block stays standalone -->
{@const only = unit.blocks[0]}
{#if only.type === "think"}
<OpenReasoningResults content={only.content} loading={false} />
{:else}
<ToolUpdate tool={only.updates} loading={false} />
{/if}
{:else if unit.kind === "artifact"}
<ArtifactCard op={unit.op} messageId={message.id} opIndex={unit.opIndex} />
{:else if unit.kind === "group"}
{@const isLiveGroup = isProcessStreaming && unitIndex === renderUnits.length - 1}
<div data-exclude-from-copy class="not-last:mb-1 has-[+.prose]:mb-2! [.prose+&]:mt-3">
{#if unit.blocks.length === 1 && unit.blocks[0].type === "think"}
<OpenReasoningResults
content={unit.blocks[0].content}
loading={isLiveGroup && isLast && loading && !unit.blocks[0].closed}
/>
{:else if isLiveGroup}
<!-- Live multi-block run (thinking + tools): flat and inline -->
{#each unit.blocks as block, blockIndex (block.type === "tool" ? `tool-${block.uuid}` : `think-${blockIndex}`)}
<div class="not-last:mb-1">
{#if block.type === "think"}
<OpenReasoningResults
content={block.content}
loading={isLast && loading && !block.closed}
/>
{:else}
<ToolUpdate tool={block.updates} {loading} />
{/if}
</div>
{/each}
{:else if unit.blocks.length > 1}
<!-- Collapse the whole settled run into a single summary -->
<ToolCallsSummary blocks={unit.blocks} toolCount={unit.toolCount} />
{:else}
{@const only = unit.blocks[0]}
{#if only.type === "tool"}
<ToolUpdate tool={only.updates} loading={false} />
{/if}
</div>
{/if}
{/each}
{/if}
{/if}
</div>
{/if}
{/each}
</div>
</div>

Expand Down
65 changes: 42 additions & 23 deletions src/lib/components/chat/OpenReasoningResults.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
<script lang="ts">
import { slide } from "svelte/transition";
import { cubicOut } from "svelte/easing";
import { browser } from "$app/environment";
import MarkdownRenderer from "./MarkdownRenderer.svelte";
import BlockWrapper from "./BlockWrapper.svelte";
import CarbonChevronRight from "~icons/carbon/chevron-right";
Expand All @@ -9,6 +12,12 @@
}
let { content, loading = false }: Props = $props();
// Expand/collapse animates as a height slide instead of a single-frame
// ~300px layout change: the scroll system tracks geometry per frame, so a
// smooth height change means no jump for anything below the block.
const collapseDuration =
browser && window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 220;
let isOpen = $state(false);
let wasLoading = $state(false);
let initialized = $state(false);
Expand All @@ -18,8 +27,13 @@
let viewportHeight = $state(0);
let contentHeight = $state(0);
// Track loading transitions to auto-expand/collapse
$effect(() => {
// Track loading transitions to auto-expand/collapse. Runs PRE-render:
// when loading flips false, the collapse must be decided before the
// template re-renders, so the slide-out starts from the still-capped
// streaming viewport — a post-render effect would let the uncapped
// settled prose mount first and a long thought would bounce to its full
// height for a frame before collapsing.
$effect.pre(() => {
if (!initialized) {
initialized = true;
if (loading) {
Expand Down Expand Up @@ -63,31 +77,36 @@

<!-- Expandable content -->
{#if isOpen}
{#if loading}
<!--
Streaming view: fixed-height viewport, content bottom-aligned so newly
arriving tokens stay visible while older lines scroll off the top behind
a gradient fade. Works for any model output format (no parsing).
-->
<div
bind:clientHeight={viewportHeight}
class="thinking-viewport mt-2 flex max-h-56 flex-col justify-end overflow-hidden md:max-h-80"
class:has-overflow={contentHeight > viewportHeight}
>
<div transition:slide={{ duration: collapseDuration, easing: cubicOut }}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep collapsing thoughts capped during outro

When a long streaming thought finishes, loading flips to false while isOpen is still true until the effect closes it, so this slide wrapper is measured after the inner branch has switched from the capped .thinking-viewport to the uncapped settled prose. For reasoning longer than the 56/80 max-height viewport, the block can expand to the full reasoning height before the outro starts, producing a larger layout jump than the one this change is meant to smooth; keep the outgoing content in the capped streaming shape until the collapse is complete.

Useful? React with 👍 / 👎.

<!-- `|| !isOpen`: while the slide-out is running, any re-render must
keep the capped streaming shape — switching to the uncapped
settled prose mid-outro would grow the collapsing box. -->
{#if loading || !isOpen}
<!--
Streaming view: fixed-height viewport, content bottom-aligned so newly
arriving tokens stay visible while older lines scroll off the top behind
a gradient fade. Works for any model output format (no parsing).
-->
<div
bind:clientHeight={viewportHeight}
class="thinking-viewport mt-2 flex max-h-56 flex-col justify-end overflow-hidden md:max-h-80"
class:has-overflow={contentHeight > viewportHeight}
>
<div
bind:clientHeight={contentHeight}
class="prose prose-sm max-w-none text-sm leading-relaxed text-gray-500 *:first:mt-0 *:last:mb-0 dark:text-gray-400 dark:prose-invert"
>
<MarkdownRenderer {content} {loading} />
</div>
</div>
{:else}
<div
bind:clientHeight={contentHeight}
class="prose prose-sm max-w-none text-sm leading-relaxed text-gray-500 *:first:mt-0 *:last:mb-0 dark:text-gray-400 dark:prose-invert"
class="prose prose-sm mt-2 max-w-none text-sm leading-relaxed text-gray-500 dark:text-gray-400 dark:prose-invert"
>
<MarkdownRenderer {content} {loading} />
</div>
</div>
{:else}
<div
class="prose prose-sm mt-2 max-w-none text-sm leading-relaxed text-gray-500 dark:text-gray-400 dark:prose-invert"
>
<MarkdownRenderer {content} {loading} />
</div>
{/if}
{/if}
</div>
{/if}
</BlockWrapper>

Expand Down
32 changes: 32 additions & 0 deletions src/lib/components/chat/OpenReasoningResults.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,38 @@ describe("OpenReasoningResults", () => {
expect(page.getByText("Paragraph 1:", { exact: false })).toBeInTheDocument();
});

it("collapses in the capped streaming shape at answer-start (no expand bounce)", async () => {
const { container, rerender } = render(OpenReasoningResults, {
content: LONG_REASONING,
loading: true,
});
await new Promise((r) => setTimeout(r, 100));
const initial = container.getBoundingClientRect().height;
expect(container.querySelector(".thinking-viewport")).not.toBeNull();

// Answer starts: loading flips false and the auto-collapse slides out.
// The OUTGOING content must keep the capped .thinking-viewport shape for
// the whole slide — if the uncapped settled prose mounts first (isOpen
// still true for one render), a long thought bounces to its full height
// before collapsing. The settled prose is any .prose OUTSIDE the capped
// viewport; the box must also never grow past its streaming height.
await rerender({ loading: false });
let sawSettledProse = false;
let maxHeight = 0;
for (let i = 0; i < 30; i++) {
await new Promise((r) => requestAnimationFrame(r));
maxHeight = Math.max(maxHeight, container.getBoundingClientRect().height);
sawSettledProse ||= [...container.querySelectorAll(".prose")].some(
(p) => !p.closest(".thinking-viewport")
);
}
expect(sawSettledProse).toBe(false);
expect(maxHeight).toBeLessThanOrEqual(initial + 8);
// The slide finished: streaming viewport unmounted, header-row height.
expect(container.querySelector(".thinking-viewport")).toBeNull();
expect(container.getBoundingClientRect().height).toBeLessThan(initial / 2);
});

it("renders the full text view when not loading", async () => {
const { baseElement } = render(OpenReasoningResults, {
content: LONG_REASONING,
Expand Down
Loading
Loading