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\)/,
+ );
+});