From 9b6b4d7d83548444340e6385c267879572769b62 Mon Sep 17 00:00:00 2001 From: Totopo27 Date: Sat, 3 Oct 2026 13:15:22 -0600 Subject: [PATCH] fix(chat): stabilize layout and cursor during stream rendering Prevent vertical layout jumping and reflow during assistant stream rendering by keeping the smooth-stream cursor active throughout active streaming, positioning the cursor inline at leaf elements and list items rather than container lists, and disabling orphan-rebalancing line wrapping during active streams. Fixes #1345 --- .../chat/transcript/AssistantTurnParts.tsx | 2 +- apps/desktop/src/styles/messages.css | 18 +++++-- .../test/chat-streaming-layout.test.mjs | 47 +++++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/test/chat-streaming-layout.test.mjs diff --git a/apps/desktop/src/features/chat/transcript/AssistantTurnParts.tsx b/apps/desktop/src/features/chat/transcript/AssistantTurnParts.tsx index 48587f6eb..a070d88e0 100644 --- a/apps/desktop/src/features/chat/transcript/AssistantTurnParts.tsx +++ b/apps/desktop/src/features/chat/transcript/AssistantTurnParts.tsx @@ -19,7 +19,7 @@ const SmoothMessageBubble = memo(function SmoothMessageBubble({ message, streami window.matchMedia("(prefers-reduced-motion: reduce)").matches; const enabled = smoothStreaming && !prefersReducedMotion; const displayContent = useSmoothText(message.content || "", streaming, enabled); - const showCursor = streaming && enabled && (displayContent.length < (message.content || "").length); + const showCursor = streaming && enabled && Boolean(displayContent); return (
:last-child::after { +.assistant-turn-fragment.streaming .prose-chat { + text-wrap: wrap; +} + +.assistant-turn-fragment.smooth-cursor .prose-chat > :last-child:not(ul):not(ol):not(blockquote):not(pre):not(table):not(.table-wrap)::after, +.assistant-turn-fragment.smooth-cursor .prose-chat > :is(ul, ol):last-child li:last-child:not(:has(ul, ol)):not(:has(p))::after, +.assistant-turn-fragment.smooth-cursor .prose-chat > :is(ul, ol):last-child li:last-child > :last-child::after, +.assistant-turn-fragment.smooth-cursor .prose-chat > blockquote:last-child > :last-child::after, +.assistant-turn-fragment.smooth-cursor .prose-chat > pre:last-child > code::after { content: ""; display: inline-block; width: 2px; @@ -3294,7 +3302,11 @@ } @media (prefers-reduced-motion: reduce) { - .assistant-turn-fragment.smooth-cursor .prose-chat > :last-child::after { + .assistant-turn-fragment.smooth-cursor .prose-chat > :last-child:not(ul):not(ol):not(blockquote):not(pre):not(table):not(.table-wrap)::after, + .assistant-turn-fragment.smooth-cursor .prose-chat > :is(ul, ol):last-child li:last-child:not(:has(ul, ol)):not(:has(p))::after, + .assistant-turn-fragment.smooth-cursor .prose-chat > :is(ul, ol):last-child li:last-child > :last-child::after, + .assistant-turn-fragment.smooth-cursor .prose-chat > blockquote:last-child > :last-child::after, + .assistant-turn-fragment.smooth-cursor .prose-chat > pre:last-child > code::after { animation: none; opacity: 0.7; } diff --git a/apps/desktop/test/chat-streaming-layout.test.mjs b/apps/desktop/test/chat-streaming-layout.test.mjs new file mode 100644 index 000000000..e44b9f6df --- /dev/null +++ b/apps/desktop/test/chat-streaming-layout.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const assistantTurnPartsSource = await readFile( + new URL("../src/features/chat/transcript/AssistantTurnParts.tsx", import.meta.url), + "utf8", +); +const messagesCss = await readFile( + new URL("../src/styles/messages.css", import.meta.url), + "utf8", +); + +test("streaming cursor remains steady throughout active streaming without flapping", () => { + // Must not flap on/off between network chunks when buffer catches up with current source length + assert.match( + assistantTurnPartsSource, + /const showCursor = streaming && enabled && Boolean\(displayContent\);/, + ); + assert.doesNotMatch( + assistantTurnPartsSource, + /displayContent\.length < \(message\.content \|\| ""\)\.length/, + ); +}); + +test("streaming prose chat stabilizes line wrapping against orphan-rebalancing reflow", () => { + assert.match( + messagesCss, + /\.assistant-turn-fragment\.streaming \.prose-chat\s*\{\s*text-wrap:\s*wrap;\s*\}/, + ); +}); + +test("streaming cursor attaches to inline leaf blocks and list items instead of container lists", () => { + // List cursor must be placed on the last li / last li's child to prevent orphan line boxes under ul/ol + assert.match( + messagesCss, + /\.assistant-turn-fragment\.smooth-cursor \.prose-chat > :is\(ul, ol\):last-child li:last-child:not\(:has\(ul, ol\)\):not\(:has\(p\)\)::after/, + ); + assert.match( + messagesCss, + /\.assistant-turn-fragment\.smooth-cursor \.prose-chat > :is\(ul, ol\):last-child li:last-child > :last-child::after/, + ); + assert.match( + messagesCss, + /\.assistant-turn-fragment\.smooth-cursor \.prose-chat > :last-child:not\(ul\):not\(ol\)/, + ); +});