Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
className={`message-bubble assistant-turn-fragment${streaming ? " streaming" : ""}${showCursor ? " smooth-cursor" : ""}`}
Expand Down
18 changes: 15 additions & 3 deletions apps/desktop/src/styles/messages.css
Original file line number Diff line number Diff line change
Expand Up @@ -3275,14 +3275,22 @@
font-size: var(--text-sm);
}

/* ── Smooth streaming cursor ── */
/* ── Smooth streaming cursor and layout stabilization ── */

@keyframes smooth-stream-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}

.assistant-turn-fragment.smooth-cursor .prose-chat > :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;
Expand All @@ -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;
}
Expand Down
47 changes: 47 additions & 0 deletions apps/desktop/test/chat-streaming-layout.test.mjs
Original file line number Diff line number Diff line change
@@ -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\)/,
);
});
Loading