From 7b006c787c406b9754ca5fe65b111f6c8e9ee4fa Mon Sep 17 00:00:00 2001 From: Totopo27 Date: Sat, 3 Oct 2026 13:15:22 -0600 Subject: [PATCH 1/2] 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 48587f6ebe..a070d88e0e 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 0000000000..e44b9f6dfe --- /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\)/, + ); +}); From 10365d93dd3cc87e1f23f3fe1972a9cacd098177 Mon Sep 17 00:00:00 2001 From: vastsa Date: Sun, 4 Oct 2026 10:59:25 +0800 Subject: [PATCH 2/2] fix(chat): keep stream cursor out of nested list boxes The generic last-child cursor selector can still append an inline box to a nested ul or ol inside the final list item. Restrict list and quote cursors to terminal paragraphs or text leaves so nested containers do not gain a trailing line during streaming. --- apps/desktop/src/styles/messages.css | 8 ++++---- apps/desktop/test/chat-streaming-layout.test.mjs | 5 +++-- docs/spec/04-ux/09-interaction-patterns.md | 5 ++++- docs/spec/06-delivery/04-e2e-test-plan.md | 3 +++ 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/styles/messages.css b/apps/desktop/src/styles/messages.css index 76d26cdaba..e23b61edbe 100644 --- a/apps/desktop/src/styles/messages.css +++ b/apps/desktop/src/styles/messages.css @@ -3288,8 +3288,8 @@ .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 > :is(ul, ol):last-child li:last-child > p:last-child::after, +.assistant-turn-fragment.smooth-cursor .prose-chat > blockquote:last-child > p:last-child::after, .assistant-turn-fragment.smooth-cursor .prose-chat > pre:last-child > code::after { content: ""; display: inline-block; @@ -3304,8 +3304,8 @@ @media (prefers-reduced-motion: reduce) { .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 > :is(ul, ol):last-child li:last-child > p:last-child::after, + .assistant-turn-fragment.smooth-cursor .prose-chat > blockquote:last-child > p: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 index e44b9f6dfe..f941199f9c 100644 --- a/apps/desktop/test/chat-streaming-layout.test.mjs +++ b/apps/desktop/test/chat-streaming-layout.test.mjs @@ -31,15 +31,16 @@ test("streaming prose chat stabilizes line wrapping against orphan-rebalancing r }); 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 + // List cursor must be placed on inline content, never on nested ul/ol containers. 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/, + /\.assistant-turn-fragment\.smooth-cursor \.prose-chat > :is\(ul, ol\):last-child li:last-child > p:last-child::after/, ); + assert.doesNotMatch(messagesCss, /li:last-child > :last-child::after/); assert.match( messagesCss, /\.assistant-turn-fragment\.smooth-cursor \.prose-chat > :last-child:not\(ul\):not\(ol\)/, diff --git a/docs/spec/04-ux/09-interaction-patterns.md b/docs/spec/04-ux/09-interaction-patterns.md index 496b77a025..db8e67f94d 100644 --- a/docs/spec/04-ux/09-interaction-patterns.md +++ b/docs/spec/04-ux/09-interaction-patterns.md @@ -653,7 +653,10 @@ may be retained while exactly one workspace supplies the visible shell context. approaches the viewport; thinking disclosures always retain Mermaid source. - Diagram render failure or the 20,000-character / 500-edge safety limit keeps the source visible and copyable instead of failing the assistant turn. -- Cursor indicator: subtle pulsing accent dot or line at the end of streaming content +- Cursor indicator: subtle pulsing accent line at the end of streaming inline + content. For Markdown lists and quotes, attach it to the terminal paragraph + or text leaf; never append it to a nested list container, which creates an + extra line box and shifts content while the response streams. - Before the first assistant or tool event, the active turn shows one compact localized `Working…` status with elapsed time. When the runtime reports a quiet interval, that same row names the wait: starting, waiting for the diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index c1eb88c8db..c23373fa31 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -6013,6 +6013,9 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. jump-to-latest appears as soon as follow mode is released. - On send, the transcript re-pins, hides jump-to-latest, and jumps to the bottom so the new user message (and following stream) is visible. - Streaming continues to follow while pinned. + - Long Markdown responses with nested lists keep their list container line + boxes stable during streaming; the cursor follows the terminal inline text + without adding a blank line below a nested list. - Manual scroll mid-stream pauses follow and shows jump-to-latest again; clicking it resumes follow. - **Specs linked**: `04-ux/08-component-spec.md`, `04-ux/09-interaction-patterns.md` - **Acceptance**: C (chat stream), Quality / D151