diff --git a/src/theme/chat/markdown.css b/src/theme/chat/markdown.css index 22d8461e6..bcb9796b9 100644 --- a/src/theme/chat/markdown.css +++ b/src/theme/chat/markdown.css @@ -7,6 +7,14 @@ .chat-markdown { --shiki-dark-bg: var(--color-input); + + &[data-chat-markdown-mode='streaming'] { + [data-stream-markdown='li'], + [data-stream-markdown='code-block'], + [data-stream-markdown='code-line'] { + @apply animate-in fade-in duration-200 motion-reduce:animate-none; + } + } @apply text-[11px] leading-4; [data-stream-markdown='blockquote'], diff --git a/tests/e2e/chat/streaming-animation.spec.ts b/tests/e2e/chat/streaming-animation.spec.ts new file mode 100644 index 000000000..074a289a0 --- /dev/null +++ b/tests/e2e/chat/streaming-animation.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from '@playwright/test' + +import { CanvasHelper } from '#tests/helpers/canvas' + +test('streaming lists and code lines animate only when inserted', async ({ page }) => { + await page.goto('/?test') + await new CanvasHelper(page).waitForInit() + const fixture = await page.evaluateHandle(async () => { + const vuePath = '/node_modules/.vite/deps/vue.js' + const markdownPath = '/src/components/chat/ChatMarkdown.vue' + const { createApp, h, ref, nextTick } = await import(vuePath) + const { default: ChatMarkdown } = await import(markdownPath) + const content = ref('A paragraph.\n\n- First item\n') + const mode = ref('streaming') + const host = document.createElement('div') + host.dataset.slot = 'streaming-animation-fixture' + document.body.append(host) + const app = createApp(() => h(ChatMarkdown, { content: content.value, mode: mode.value })) + app.mount(host) + return { + async append(text: string) { + content.value += text + await nextTick() + }, + async settle() { + mode.value = 'static' + await nextTick() + }, + dispose() { + app.unmount() + host.remove() + } + } + }) + try { + const markdown = page.locator('[data-slot="streaming-animation-fixture"]') + const first = markdown.locator('[data-stream-markdown="li"]').first() + await expect(first).toHaveCSS('animation-name', 'enter') + await fixture.evaluate((state) => + state.append('\n- Second item\n\n```typescript\nconst a = 1\n') + ) + await expect(markdown.locator('[data-stream-markdown="li"]')).toHaveCount(2) + const code = markdown.locator('[data-stream-markdown="code-block"]') + await expect(code).toBeVisible() + await expect(code).toHaveCSS('animation-name', 'enter') + const line = markdown.locator('[data-stream-markdown="code-line"]').first() + await expect(line).toHaveCSS('animation-name', 'enter') + await fixture.evaluate((state) => state.append('const b = 2\n```')) + await expect(code).toContainText('const b = 2') + await page.emulateMedia({ reducedMotion: 'reduce' }) + await expect(first).toHaveCSS('animation-name', 'none') + await expect(line).toHaveCSS('animation-name', 'none') + await fixture.evaluate((state) => state.settle()) + await page.emulateMedia({ reducedMotion: 'no-preference' }) + await expect(first).toHaveCSS('animation-name', 'none') + await expect(markdown.locator('[data-stream-markdown="code-block"]')).toHaveCSS( + 'animation-name', + 'none' + ) + } finally { + await fixture.evaluate((state) => state.dispose()) + await fixture.dispose() + } +})