Skip to content

Commit ef2e325

Browse files
gary149claude
andauthored
Fix thinking-block collapse and Safari layout shifts (#2426)
* Fix thinking-block collapse and Safari layout shifts Reproduced three shift mechanisms around reasoning blocks with harness probes before fixing: - When thinking outgrew the send-anchor's fill slack, its ~300px collapse at answer-start re-inflated the spacer and re-anchored the sent message — a measured 267px scroll of the view mid-read. The spacer is now monotonic within a turn (fresh turns still inflate, the composer-clearance floor still wins), so the collapse rides the controller's clamp path instead, which keeps everything below the block viewport-stable. - Safari has no native scroll anchoring (overflow-anchor unsupported), so any above-viewport shrink — a collapsing thinking block, a late image — shoved a detached reader's text by the full delta (350px in the probe; 0px on Chrome, whose native anchoring compensates). Where anchoring is unavailable, the chat glue now tracks the message at the viewport top while detached (binary search per scroll event) and restores its position after content resizes through a new attribution-safe controller adjustment. - The collapse itself was a single-frame teleport, and for the common lone-thinking case it couldn't even be animated: the answer-start branch flip destroyed the expanded component and mounted a collapsed one. ChatMessage now renders one renderUnits template for both the streaming and settled phases, keeping the lone thinking block's instance alive across the flip, and its expand/collapse is a height slide (220ms, disabled under prefers-reduced-motion). Side effect: earlier multi-block tool runs stay summarized while a later step streams instead of re-expanding flat. Two regression tests pin the fixed behaviors (in-turn collapse keeps content below stable; simulated-Safari detached reader stays put through an above-viewport collapse). WebKit itself cannot launch in this container, so Safari is simulated by forcing overflow-anchor off — the only engine difference relevant to these bugs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148VmxBDazfhczvfE122XVW * Anchor manual scroll compensation to in-message descendants The Safari manual-anchoring fallback tracked only top-level message wrappers. With the viewport top inside a long assistant message, a thinking block collapsing (or an image loading) above the reading position within that same message leaves the wrapper's top unchanged — delta reads zero and the jump survives. Descend from the straddling wrapper to the deepest element at the viewport top (linear scans below the top level, where children are few and can sit out of flow), which is what native scroll anchoring does. Regression test covers the in-message collapse. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148VmxBDazfhczvfE122XVW * Re-anchor manual scroll compensation when the tracked node is replaced Markdown worker swaps and streaming re-renders replace exactly the deep paragraph nodes the manual anchor tracks. A dead node made compensation early-return, silencing it until the next user scroll — reintroducing the Safari jump for rendered/streaming markdown. The anchor is now a deepest-first ancestor chain: compensation falls back to the nearest surviving ancestor (still covering every shift above it) and re-resolves a fresh deep anchor after every pass, so a replacement can never leave a stale chain behind. Regression test replaces the anchored node in the same pass as an above-viewport shrink and checks the following pass too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148VmxBDazfhczvfE122XVW * Collapse long thoughts in their capped streaming shape When loading flipped false, the template swapped the reasoning content from the capped streaming viewport to the uncapped settled prose in the same flush, BEFORE the auto-collapse effect flipped isOpen — so the slide-out of a long thought started from the full reasoning height (potentially thousands of px), an expand-then-collapse bounce worse than the jump the animation replaced. The open/collapse tracker now runs pre-render ($effect.pre), so the outro starts from the still-capped DOM, and the inner branch keeps the streaming shape whenever the block is closing, so no mid-outro re-render can grow the collapsing box. The regression test asserts the settled prose never appears during the collapse and the box never exceeds its streaming height (shape-based assertions: Tailwind's cap utilities don't load in the browser test environment, which is also why the pre-existing mask test fails there). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148VmxBDazfhczvfE122XVW --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent be4e935 commit ef2e325

6 files changed

Lines changed: 481 additions & 81 deletions

File tree

src/lib/components/chat/ChatMessage.svelte

Lines changed: 48 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -410,62 +410,57 @@
410410
{#if isLast && loading && blocks.length === 0}
411411
<IconLoading classNames="loading inline ml-2 first:ml-0" />
412412
{/if}
413-
{#if isProcessStreaming}
414-
<!-- Streaming the thinking / tool phase: render every block flat and
415-
inline, exactly like today. Nesting kicks in once the answer starts. -->
416-
{#each blocks as block, blockIndex (block.type === "tool" ? `tool-${block.uuid}-${blockIndex}` : `block-${blockIndex}`)}
417-
{#if block.type === "text"}
418-
{#if block.content.trim().length > 0}
419-
<div class={proseClasses}>
420-
<MarkdownRenderer content={block.content} loading={isLast && loading} />
421-
</div>
422-
{/if}
423-
{:else if block.type === "artifact"}
424-
<ArtifactCard op={block.op} messageId={message.id} opIndex={block.opIndex} />
425-
{:else}
426-
<div data-exclude-from-copy class="not-last:mb-1 has-[+.prose]:mb-2! [.prose+&]:mt-3">
427-
{#if block.type === "think"}
428-
<OpenReasoningResults
429-
content={block.content}
430-
loading={isLast && loading && !block.closed}
431-
/>
432-
{:else}
433-
<ToolUpdate tool={block.updates} {loading} />
434-
{/if}
413+
<!-- One template for the streaming and settled phases: the trailing
414+
process group renders live while thinking/tools stream and settles
415+
in place when the answer starts. A single branch per unit shape
416+
keeps component instances alive across that flip — a lone thinking
417+
block ANIMATES its collapse instead of being remounted ~300px
418+
shorter in one frame (the layout jump this replaces). -->
419+
{#each renderUnits as unit, unitIndex (`${unit.kind}-${unitIndex}`)}
420+
{#if unit.kind === "text"}
421+
{#if isLast && loading && unit.content.length === 0}
422+
<IconLoading classNames="loading inline ml-2 first:ml-0" />
423+
{:else if unit.content.trim().length > 0}
424+
<div class={proseClasses}>
425+
<MarkdownRenderer content={unit.content} loading={isLast && loading} />
435426
</div>
436427
{/if}
437-
{/each}
438-
{:else}
439-
<!-- Answer started or generation finished: nest the process blocks. -->
440-
{#each renderUnits as unit, unitIndex (`${unit.kind}-${unitIndex}`)}
441-
{#if unit.kind === "text"}
442-
{#if isLast && loading && unit.content.length === 0}
443-
<IconLoading classNames="loading inline ml-2 first:ml-0" />
444-
{:else if unit.content.trim().length > 0}
445-
<div class={proseClasses}>
446-
<MarkdownRenderer content={unit.content} loading={isLast && loading} />
447-
</div>
448-
{/if}
449-
{:else if unit.kind === "artifact"}
450-
<ArtifactCard op={unit.op} messageId={message.id} opIndex={unit.opIndex} />
451-
{:else if unit.kind === "group"}
452-
<div data-exclude-from-copy class="not-last:mb-1 has-[+.prose]:mb-2! [.prose+&]:mt-3">
453-
{#if unit.blocks.length > 1}
454-
<!-- Collapse the whole run into a single summary -->
455-
<ToolCallsSummary blocks={unit.blocks} toolCount={unit.toolCount} />
456-
{:else}
457-
<!-- A lone process block stays standalone -->
458-
{@const only = unit.blocks[0]}
459-
{#if only.type === "think"}
460-
<OpenReasoningResults content={only.content} loading={false} />
461-
{:else}
462-
<ToolUpdate tool={only.updates} loading={false} />
463-
{/if}
428+
{:else if unit.kind === "artifact"}
429+
<ArtifactCard op={unit.op} messageId={message.id} opIndex={unit.opIndex} />
430+
{:else if unit.kind === "group"}
431+
{@const isLiveGroup = isProcessStreaming && unitIndex === renderUnits.length - 1}
432+
<div data-exclude-from-copy class="not-last:mb-1 has-[+.prose]:mb-2! [.prose+&]:mt-3">
433+
{#if unit.blocks.length === 1 && unit.blocks[0].type === "think"}
434+
<OpenReasoningResults
435+
content={unit.blocks[0].content}
436+
loading={isLiveGroup && isLast && loading && !unit.blocks[0].closed}
437+
/>
438+
{:else if isLiveGroup}
439+
<!-- Live multi-block run (thinking + tools): flat and inline -->
440+
{#each unit.blocks as block, blockIndex (block.type === "tool" ? `tool-${block.uuid}` : `think-${blockIndex}`)}
441+
<div class="not-last:mb-1">
442+
{#if block.type === "think"}
443+
<OpenReasoningResults
444+
content={block.content}
445+
loading={isLast && loading && !block.closed}
446+
/>
447+
{:else}
448+
<ToolUpdate tool={block.updates} {loading} />
449+
{/if}
450+
</div>
451+
{/each}
452+
{:else if unit.blocks.length > 1}
453+
<!-- Collapse the whole settled run into a single summary -->
454+
<ToolCallsSummary blocks={unit.blocks} toolCount={unit.toolCount} />
455+
{:else}
456+
{@const only = unit.blocks[0]}
457+
{#if only.type === "tool"}
458+
<ToolUpdate tool={only.updates} loading={false} />
464459
{/if}
465-
</div>
466-
{/if}
467-
{/each}
468-
{/if}
460+
{/if}
461+
</div>
462+
{/if}
463+
{/each}
469464
</div>
470465
</div>
471466

src/lib/components/chat/OpenReasoningResults.svelte

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
<script lang="ts">
2+
import { slide } from "svelte/transition";
3+
import { cubicOut } from "svelte/easing";
4+
import { browser } from "$app/environment";
25
import MarkdownRenderer from "./MarkdownRenderer.svelte";
36
import BlockWrapper from "./BlockWrapper.svelte";
47
import CarbonChevronRight from "~icons/carbon/chevron-right";
@@ -9,6 +12,12 @@
912
}
1013
1114
let { content, loading = false }: Props = $props();
15+
16+
// Expand/collapse animates as a height slide instead of a single-frame
17+
// ~300px layout change: the scroll system tracks geometry per frame, so a
18+
// smooth height change means no jump for anything below the block.
19+
const collapseDuration =
20+
browser && window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 220;
1221
let isOpen = $state(false);
1322
let wasLoading = $state(false);
1423
let initialized = $state(false);
@@ -18,8 +27,13 @@
1827
let viewportHeight = $state(0);
1928
let contentHeight = $state(0);
2029
21-
// Track loading transitions to auto-expand/collapse
22-
$effect(() => {
30+
// Track loading transitions to auto-expand/collapse. Runs PRE-render:
31+
// when loading flips false, the collapse must be decided before the
32+
// template re-renders, so the slide-out starts from the still-capped
33+
// streaming viewport — a post-render effect would let the uncapped
34+
// settled prose mount first and a long thought would bounce to its full
35+
// height for a frame before collapsing.
36+
$effect.pre(() => {
2337
if (!initialized) {
2438
initialized = true;
2539
if (loading) {
@@ -63,31 +77,36 @@
6377

6478
<!-- Expandable content -->
6579
{#if isOpen}
66-
{#if loading}
67-
<!--
68-
Streaming view: fixed-height viewport, content bottom-aligned so newly
69-
arriving tokens stay visible while older lines scroll off the top behind
70-
a gradient fade. Works for any model output format (no parsing).
71-
-->
72-
<div
73-
bind:clientHeight={viewportHeight}
74-
class="thinking-viewport mt-2 flex max-h-56 flex-col justify-end overflow-hidden md:max-h-80"
75-
class:has-overflow={contentHeight > viewportHeight}
76-
>
80+
<div transition:slide={{ duration: collapseDuration, easing: cubicOut }}>
81+
<!-- `|| !isOpen`: while the slide-out is running, any re-render must
82+
keep the capped streaming shape — switching to the uncapped
83+
settled prose mid-outro would grow the collapsing box. -->
84+
{#if loading || !isOpen}
85+
<!--
86+
Streaming view: fixed-height viewport, content bottom-aligned so newly
87+
arriving tokens stay visible while older lines scroll off the top behind
88+
a gradient fade. Works for any model output format (no parsing).
89+
-->
90+
<div
91+
bind:clientHeight={viewportHeight}
92+
class="thinking-viewport mt-2 flex max-h-56 flex-col justify-end overflow-hidden md:max-h-80"
93+
class:has-overflow={contentHeight > viewportHeight}
94+
>
95+
<div
96+
bind:clientHeight={contentHeight}
97+
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"
98+
>
99+
<MarkdownRenderer {content} {loading} />
100+
</div>
101+
</div>
102+
{:else}
77103
<div
78-
bind:clientHeight={contentHeight}
79-
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"
104+
class="prose prose-sm mt-2 max-w-none text-sm leading-relaxed text-gray-500 dark:text-gray-400 dark:prose-invert"
80105
>
81106
<MarkdownRenderer {content} {loading} />
82107
</div>
83-
</div>
84-
{:else}
85-
<div
86-
class="prose prose-sm mt-2 max-w-none text-sm leading-relaxed text-gray-500 dark:text-gray-400 dark:prose-invert"
87-
>
88-
<MarkdownRenderer {content} {loading} />
89-
</div>
90-
{/if}
108+
{/if}
109+
</div>
91110
{/if}
92111
</BlockWrapper>
93112

src/lib/components/chat/OpenReasoningResults.svelte.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,38 @@ describe("OpenReasoningResults", () => {
3232
expect(page.getByText("Paragraph 1:", { exact: false })).toBeInTheDocument();
3333
});
3434

35+
it("collapses in the capped streaming shape at answer-start (no expand bounce)", async () => {
36+
const { container, rerender } = render(OpenReasoningResults, {
37+
content: LONG_REASONING,
38+
loading: true,
39+
});
40+
await new Promise((r) => setTimeout(r, 100));
41+
const initial = container.getBoundingClientRect().height;
42+
expect(container.querySelector(".thinking-viewport")).not.toBeNull();
43+
44+
// Answer starts: loading flips false and the auto-collapse slides out.
45+
// The OUTGOING content must keep the capped .thinking-viewport shape for
46+
// the whole slide — if the uncapped settled prose mounts first (isOpen
47+
// still true for one render), a long thought bounces to its full height
48+
// before collapsing. The settled prose is any .prose OUTSIDE the capped
49+
// viewport; the box must also never grow past its streaming height.
50+
await rerender({ loading: false });
51+
let sawSettledProse = false;
52+
let maxHeight = 0;
53+
for (let i = 0; i < 30; i++) {
54+
await new Promise((r) => requestAnimationFrame(r));
55+
maxHeight = Math.max(maxHeight, container.getBoundingClientRect().height);
56+
sawSettledProse ||= [...container.querySelectorAll(".prose")].some(
57+
(p) => !p.closest(".thinking-viewport")
58+
);
59+
}
60+
expect(sawSettledProse).toBe(false);
61+
expect(maxHeight).toBeLessThanOrEqual(initial + 8);
62+
// The slide finished: streaming viewport unmounted, header-row height.
63+
expect(container.querySelector(".thinking-viewport")).toBeNull();
64+
expect(container.getBoundingClientRect().height).toBeLessThan(initial / 2);
65+
});
66+
3567
it("renders the full text view when not loading", async () => {
3668
const { baseElement } = render(OpenReasoningResults, {
3769
content: LONG_REASONING,

0 commit comments

Comments
 (0)