Skip to content
Merged
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 > 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;
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 > 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;
}
Expand Down
48 changes: 48 additions & 0 deletions apps/desktop/test/chat-streaming-layout.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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 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 > 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\)/,
);
});
5 changes: 4 additions & 1 deletion docs/spec/04-ux/09-interaction-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading