From f1138e95fc7273e3c2c9fd149e82b4c0f5e545c4 Mon Sep 17 00:00:00 2001 From: tendtoyj Date: Tue, 10 Feb 2026 21:52:07 +0900 Subject: [PATCH 1/3] feat(frontend): add streaming cursor style for assistant prose --- frontend/pluto_duck_frontend/app/globals.css | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/frontend/pluto_duck_frontend/app/globals.css b/frontend/pluto_duck_frontend/app/globals.css index 476c4a1..0a2bf92 100644 --- a/frontend/pluto_duck_frontend/app/globals.css +++ b/frontend/pluto_duck_frontend/app/globals.css @@ -119,6 +119,23 @@ word-break: break-word; } +/* Streaming cursor for assistant markdown content */ +[role="log"] .prose.prose-streaming > .size-full > :last-child::after { + content: "│"; + display: inline; + margin-left: 1px; + color: var(--dot-idle); + animation: blink 0.8s step-end infinite; + font-weight: 300; +} + +@media (prefers-reduced-motion: reduce) { + [role="log"] .prose.prose-streaming > .size-full > :last-child::after { + animation: none; + opacity: 1; + } +} + /* Thin scrollbar */ .scrollbar-thin { scrollbar-width: thin; From 032c0f8a0258e287af8c34e69a1d09ad400937d4 Mon Sep 17 00:00:00 2001 From: tendtoyj Date: Tue, 10 Feb 2026 21:55:07 +0900 Subject: [PATCH 2/3] feat(frontend): wire streaming prose class for assistant messages --- .../chat/renderers/AssistantMessageRenderer.tsx | 3 ++- .../AssistantMessageRenderer.proseClassName.test.ts | 13 +++++++++++++ .../renderers/assistantMessageProseClassName.ts | 8 ++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 frontend/pluto_duck_frontend/components/chat/renderers/__tests__/AssistantMessageRenderer.proseClassName.test.ts create mode 100644 frontend/pluto_duck_frontend/components/chat/renderers/assistantMessageProseClassName.ts diff --git a/frontend/pluto_duck_frontend/components/chat/renderers/AssistantMessageRenderer.tsx b/frontend/pluto_duck_frontend/components/chat/renderers/AssistantMessageRenderer.tsx index b307538..081a409 100644 --- a/frontend/pluto_duck_frontend/components/chat/renderers/AssistantMessageRenderer.tsx +++ b/frontend/pluto_duck_frontend/components/chat/renderers/AssistantMessageRenderer.tsx @@ -15,6 +15,7 @@ import type { AssistantMessageItem } from '../../../types/chatRenderItem'; import { getAssistantActionsClassName, } from './assistantActionsPolicy'; +import { getAssistantMessageProseClassName } from './assistantMessageProseClassName'; import { writeAssistantMessageToClipboard } from './assistantClipboard'; export type FeedbackType = 'like' | 'dislike' | null; @@ -83,7 +84,7 @@ export const AssistantMessageRenderer = memo(function AssistantMessageRenderer({ return (
-
+
{item.content}
diff --git a/frontend/pluto_duck_frontend/components/chat/renderers/__tests__/AssistantMessageRenderer.proseClassName.test.ts b/frontend/pluto_duck_frontend/components/chat/renderers/__tests__/AssistantMessageRenderer.proseClassName.test.ts new file mode 100644 index 0000000..add2a79 --- /dev/null +++ b/frontend/pluto_duck_frontend/components/chat/renderers/__tests__/AssistantMessageRenderer.proseClassName.test.ts @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { getAssistantMessageProseClassName } from '../assistantMessageProseClassName.ts'; + +test('includes prose-streaming class while assistant message is streaming', () => { + const className = getAssistantMessageProseClassName(true); + assert.match(className, /\bprose-streaming\b/); +}); + +test('removes prose-streaming class after streaming completes', () => { + const className = getAssistantMessageProseClassName(false); + assert.doesNotMatch(className, /\bprose-streaming\b/); +}); diff --git a/frontend/pluto_duck_frontend/components/chat/renderers/assistantMessageProseClassName.ts b/frontend/pluto_duck_frontend/components/chat/renderers/assistantMessageProseClassName.ts new file mode 100644 index 0000000..d507102 --- /dev/null +++ b/frontend/pluto_duck_frontend/components/chat/renderers/assistantMessageProseClassName.ts @@ -0,0 +1,8 @@ +const ASSISTANT_PROSE_BASE_CLASS = 'prose prose-sm dark:prose-invert max-w-none'; + +export function getAssistantMessageProseClassName(isStreaming: boolean): string { + if (!isStreaming) { + return ASSISTANT_PROSE_BASE_CLASS; + } + return `${ASSISTANT_PROSE_BASE_CLASS} prose-streaming`; +} From c6ed8c183eec6372f86e3c520491b72583bda421 Mon Sep 17 00:00:00 2001 From: tendtoyj Date: Tue, 10 Feb 2026 22:10:34 +0900 Subject: [PATCH 3/3] fix(frontend): harden streaming cursor selector and prose class regression test --- frontend/pluto_duck_frontend/app/globals.css | 9 +++++---- .../components/ai-elements/response.tsx | 2 +- .../AssistantMessageRenderer.proseClassName.test.ts | 10 ++++++---- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/frontend/pluto_duck_frontend/app/globals.css b/frontend/pluto_duck_frontend/app/globals.css index 0a2bf92..c510cac 100644 --- a/frontend/pluto_duck_frontend/app/globals.css +++ b/frontend/pluto_duck_frontend/app/globals.css @@ -120,17 +120,18 @@ } /* Streaming cursor for assistant markdown content */ -[role="log"] .prose.prose-streaming > .size-full > :last-child::after { +/* Contract: cursor assumes Streamdown root renders markdown blocks as direct children. */ +[role="log"] .prose.prose-streaming > .streamdown-root > :last-child::after { content: "│"; display: inline; margin-left: 1px; color: var(--dot-idle); - animation: blink 0.8s step-end infinite; + animation: cursor-blink 0.8s step-end infinite; font-weight: 300; } @media (prefers-reduced-motion: reduce) { - [role="log"] .prose.prose-streaming > .size-full > :last-child::after { + [role="log"] .prose.prose-streaming > .streamdown-root > :last-child::after { animation: none; opacity: 1; } @@ -238,7 +239,7 @@ } } -@keyframes blink { +@keyframes cursor-blink { 50% { opacity: 0; } diff --git a/frontend/pluto_duck_frontend/components/ai-elements/response.tsx b/frontend/pluto_duck_frontend/components/ai-elements/response.tsx index 688b456..66d56f1 100644 --- a/frontend/pluto_duck_frontend/components/ai-elements/response.tsx +++ b/frontend/pluto_duck_frontend/components/ai-elements/response.tsx @@ -10,7 +10,7 @@ export const Response = memo( ({ className, ...props }: ResponseProps) => ( *:first-child]:mt-0 [&>*:last-child]:mb-0", + "streamdown-root size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0", className )} {...props} diff --git a/frontend/pluto_duck_frontend/components/chat/renderers/__tests__/AssistantMessageRenderer.proseClassName.test.ts b/frontend/pluto_duck_frontend/components/chat/renderers/__tests__/AssistantMessageRenderer.proseClassName.test.ts index add2a79..eddafa9 100644 --- a/frontend/pluto_duck_frontend/components/chat/renderers/__tests__/AssistantMessageRenderer.proseClassName.test.ts +++ b/frontend/pluto_duck_frontend/components/chat/renderers/__tests__/AssistantMessageRenderer.proseClassName.test.ts @@ -2,12 +2,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { getAssistantMessageProseClassName } from '../assistantMessageProseClassName.ts'; -test('includes prose-streaming class while assistant message is streaming', () => { +const ASSISTANT_PROSE_BASE_CLASS = 'prose prose-sm dark:prose-invert max-w-none'; + +test('returns base prose classes plus prose-streaming while assistant message is streaming', () => { const className = getAssistantMessageProseClassName(true); - assert.match(className, /\bprose-streaming\b/); + assert.equal(className, `${ASSISTANT_PROSE_BASE_CLASS} prose-streaming`); }); -test('removes prose-streaming class after streaming completes', () => { +test('returns base prose classes after streaming completes', () => { const className = getAssistantMessageProseClassName(false); - assert.doesNotMatch(className, /\bprose-streaming\b/); + assert.equal(className, ASSISTANT_PROSE_BASE_CLASS); });