Phase 006: migrate the chat page from naive-ui to Arco Design Vue

Swaps the rendering layer only. Chat store logic, keyboard handling, and the
/chat-run Socket.IO contract are untouched, so a mixed UI cannot hide a chat
regression behind a library swap.

Arco's dropdowns take their items from a #content slot of a-doption children
rather than a droplist prop, and Arco Triggers cannot be nested, so the two
toolbar menus lost their a-tooltip in favour of a native title. Both rules are
recorded in docs/arco-mapping.md, and tests/e2e/chat-input-dropdown.spec.ts plus
tests/e2e/chat-rendering-smoke.spec.ts keep the menus, the streaming markdown
rendering, and the no-Arco-inside-the-bubble red line from regressing silently.
This commit is contained in:
2026-10-17 22:17:32 +08:00
parent 4baccd5d88
commit effc29085c
20 changed files with 1356 additions and 819 deletions
+76 -31
View File
@@ -3,6 +3,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { nextTick } from 'vue'
import ArcoVue, { Message } from '@arco-design/web-vue'
import { useChatStore } from '@/stores/hermes/chat'
import { useSettingsStore } from '@/stores/hermes/settings'
import ChatInput from '@/components/hermes/chat/ChatInput.vue'
@@ -13,34 +14,63 @@ vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('naive-ui', () => ({
NButton: { template: '<button type="button" v-bind="$attrs"><slot /><slot name="icon" /></button>' },
NTooltip: { template: '<div><slot name="trigger" /><slot /></div>' },
NSwitch: { template: '<button type="button"></button>' },
NDropdown: { template: '<div><slot /></div>' },
NModal: { template: '<div><slot /><slot name="footer" /></div>' },
NInputNumber: { template: '<input />' },
NPopselect: {
props: ['value', 'options'],
emits: ['update:value'],
template: `
<div class="n-popselect-stub">
<slot />
<button
v-for="option in options"
:key="option.value"
type="button"
class="n-popselect-option"
:data-value="option.value"
@click="$emit('update:value', option.value)"
>
{{ option.label }}
</button>
</div>
`,
// Arco's imperative message API needs a live DOM; the rest runs for real.
vi.mock('@arco-design/web-vue', async () => {
const actual =
await vi.importActual<typeof import('@arco-design/web-vue')>(
'@arco-design/web-vue',
)
return {
...actual,
Message: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
loading: vi.fn(),
},
}
})
/**
* a-modal / a-dropdown teleport to body in jsdom, so stub them to render inline.
* Arco is registered globally, so stubs must key off Arco's internal component
* name (`Modal`, `Dropdown`), not the `a-*` template tag.
*/
const AModalStub = {
name: 'Modal',
props: ['visible'],
template: '<div v-if="visible" class="a-modal-stub"><slot /><slot name="footer" /></div>',
}
/**
* Arco 的 a-dropdown 通过 #content 插槽 + a-doption 声明菜单,没有 droplist prop。
* 这里把 content 插槽内联渲染出来,好让测试直接点到某个推理档位;真实 Arco 里
* 是 Doption 通过 inject 调 dropdown 的 onOptionClick,stub 用事件冒泡等价实现。
*/
const ADropdownStub = {
name: 'Dropdown',
emits: ['select'],
methods: {
forwardSelect(event: MouseEvent) {
const target = (event.target as HTMLElement | null)?.closest('[data-value]')
const value = target?.getAttribute('data-value')
if (value !== undefined && value !== null) this.$emit('select', value)
},
},
useMessage: () => ({ error: vi.fn(), success: vi.fn() }),
}))
template: `
<div class="a-dropdown-stub" @click="forwardSelect">
<slot />
<slot name="content" />
</div>
`,
}
const ADoptionStub = {
name: 'Doption',
props: ['value'],
template: '<button type="button" class="a-dropdown-option" :data-value="value"><slot /></button>',
}
vi.mock('@/api/hermes/sessions', () => ({
fetchContextLength: vi.fn().mockResolvedValue(256000),
@@ -72,7 +102,13 @@ function mountForSession(
chatStore.activeSessionId = sessionId
chatStore.activeSession = chatStore.sessions[0]
settingsStore.display = displayOverrides
return mount(ChatInput, { global: { plugins: [pinia] } })
return mount(ChatInput, {
global: {
plugins: [pinia, ArcoVue],
// Overlays teleport in jsdom, so render dropdown and modal inline.
stubs: { Dropdown: ADropdownStub, Doption: ADoptionStub, Modal: AModalStub },
},
})
}
describe('ChatInput draft persistence', () => {
@@ -171,15 +207,24 @@ describe('ChatInput draft persistence', () => {
const wrapper = mountForSession('session-reasoning-effort')
await nextTick()
expect(wrapper.find('.n-popselect-stub').exists()).toBe(true)
expect(wrapper.find('[data-value="high"]').exists()).toBe(true)
expect(wrapper.find('.reasoning-effort-button').exists()).toBe(true)
expect(
wrapper.find('.reasoning-effort-button').element.closest('.a-dropdown-stub'),
).not.toBeNull()
})
it('stores the selected reasoning effort for the active session', async () => {
const wrapper = mountForSession('session-reasoning')
const store = useChatStore()
await wrapper.get('[data-value="high"]').trigger('click')
const effortDropdown = wrapper
.find('.reasoning-effort-button')
.element.closest('.a-dropdown-stub')
expect(effortDropdown).not.toBeNull()
const effortOptions = wrapper.findAll('.a-dropdown-stub [data-value="high"]')
expect(effortOptions.length).toBeGreaterThan(0)
await effortOptions[0].trigger('click')
await nextTick()
expect(store.sessions[0].reasoningEffort).toBe('high')
+41 -28
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { config, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
const mermaidMock = vi.hoisted(() => ({
@@ -33,30 +33,44 @@ vi.mock('vue-i18n', () => ({
}),
}))
vi.mock('naive-ui', () => ({
NDrawer: {
props: ['show', 'width'],
template: '<div v-if="show" class="n-drawer-stub" :data-width="width"><slot /></div>',
},
NDrawerContent: {
props: {
title: { type: String, default: '' },
closable: { type: Boolean, default: false },
bodyContentStyle: { type: [Object, String], default: undefined },
// The text preview drawer teleports to body in jsdom, so stub the overlays to
// render inline with the same DOM shape the component expects.
vi.mock('@arco-design/web-vue', async () => {
const actual =
await vi.importActual<typeof import('@arco-design/web-vue')>(
'@arco-design/web-vue',
)
return {
...actual,
Message: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
loading: vi.fn(),
},
template: '<section class="n-drawer-content-stub" :data-body-padding="bodyContentStyle && bodyContentStyle.padding"><header class="n-drawer-header-stub">{{ title }}<button v-if="closable" class="n-drawer-close-stub" @click="$emit(\'close\')">x</button></header><slot /></section>',
}
})
config.global.stubs = {
ADrawer: {
name: 'ADrawer',
props: ['visible', 'width'],
emits: ['update:visible'],
template: `
<div v-if="visible" class="a-drawer-stub" :data-width="width">
<header class="a-drawer-title-stub"><slot name="title" /></header>
<button class="a-drawer-close-stub" @click="$emit('update:visible', false)">x</button>
<slot />
</div>
`,
},
NSpin: {
props: ['show'],
template: '<div class="n-spin-stub"><slot /></div>',
ASpin: {
name: 'ASpin',
props: ['loading'],
template: '<div class="a-spin-stub"><slot /></div>',
},
useMessage: () => ({
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}),
}))
}
vi.mock('@/api/hermes/download', () => ({
downloadFile: downloadApiMock.downloadFile,
@@ -316,7 +330,7 @@ describe('MarkdownRenderer', () => {
expect(downloadApiMock.downloadFile).toHaveBeenCalledTimes(1)
expect(downloadApiMock.downloadFile).toHaveBeenCalledWith('/tmp/notes.txt', 'notes.txt')
expect(downloadApiMock.fetchFileText).not.toHaveBeenCalled()
expect(wrapper.find('.n-drawer-stub').exists()).toBe(false)
expect(wrapper.find('.a-drawer-stub').exists()).toBe(false)
})
it('opens text previews in a responsive drawer with a close control', async () => {
@@ -330,17 +344,16 @@ describe('MarkdownRenderer', () => {
await Promise.resolve()
await nextTick()
const drawer = wrapper.find('.n-drawer-stub')
const drawer = wrapper.find('.a-drawer-stub')
expect(drawer.exists()).toBe(true)
expect(drawer.attributes('data-width')).toBe('min(800px, 100vw)')
expect(drawer.find('.n-drawer-content-stub').attributes('data-body-padding')).toBe('0')
expect(drawer.text()).toContain('download.contentDisplay')
expect(downloadApiMock.fetchFileText).toHaveBeenCalledWith('/tmp/notes.txt', 'notes.txt')
await drawer.find('.n-drawer-close-stub').trigger('click')
await drawer.find('.a-drawer-close-stub').trigger('click')
await nextTick()
expect(wrapper.find('.n-drawer-stub').exists()).toBe(false)
expect(wrapper.find('.a-drawer-stub').exists()).toBe(false)
})
it('renders markdown file previews as markdown content', async () => {
@@ -355,7 +368,7 @@ describe('MarkdownRenderer', () => {
await Promise.resolve()
await nextTick()
const drawer = wrapper.find('.n-drawer-stub')
const drawer = wrapper.find('.a-drawer-stub')
expect(drawer.exists()).toBe(true)
expect(drawer.find('.text-preview-markdown').exists()).toBe(true)
expect(drawer.find('.text-preview-body').exists()).toBe(false)
+168
View File
@@ -0,0 +1,168 @@
// @vitest-environment jsdom
/**
* Phase 006 §1 red line: message bubbles must not contain Arco components.
*
* During streaming, MessageItem re-renders on every token. Any stateful Arco
* component inside the bubble adds diff work to that hot path, so bubbles stay
* on native HTML plus the project's own SCSS. Overlays such as the tool-change
* drawer are allowed, but only outside the bubble.
*
* Arco is registered globally here exactly like main.ts does, so an Arco
* component leaking into the bubble renders a real .arco-* node and fails.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h } from 'vue'
import ArcoVue from '@arco-design/web-vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
// Arco's imperative message API is the one part that cannot mount in jsdom.
vi.mock('@arco-design/web-vue', async () => {
const actual =
await vi.importActual<typeof import('@arco-design/web-vue')>(
'@arco-design/web-vue',
)
return {
...actual,
Message: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
loading: vi.fn(),
},
}
})
import MessageItem from '@/components/hermes/chat/MessageItem.vue'
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
import type { Message } from '@/stores/hermes/chat'
const ARCO_CLASS = /(^|\s)arco-/
const globalConfig = { plugins: [ArcoVue] }
function baseMessage(overrides: Partial<Message>): Message {
return {
id: 'm1',
role: 'assistant',
content: '',
timestamp: 1_700_000_000_000,
...overrides,
} as Message
}
/** MessageItem mounts MarkdownRenderer and ProfileAvatar; both are irrelevant here. */
const stubs = {
MarkdownRenderer: { template: '<div class="markdown-body" />' },
ProfileAvatar: true,
}
function mountItem(message: Message): VueWrapper {
return mount(MessageItem, {
props: { message },
global: { plugins: globalConfig.plugins, stubs },
})
}
function arcoDescendants(wrapper: VueWrapper, selector: string): string[] {
const root = wrapper.find(selector)
expect(root.exists()).toBe(true)
return Array.from(root.element.querySelectorAll('*'))
.map((node) => node as HTMLElement)
.filter((node) => ARCO_CLASS.test(node.className || ''))
.map((node) => `${node.tagName.toLowerCase()}.${node.className}`)
}
function stubDomGlobals() {
Object.defineProperty(window, 'isSecureContext', {
configurable: true,
value: true,
})
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockResolvedValue(undefined) },
})
Object.defineProperty(window, 'speechSynthesis', {
configurable: true,
value: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
getVoices: vi.fn(() => []),
speak: vi.fn(),
cancel: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
},
})
}
describe('MessageItem bubble contains no Arco components (Phase 006 §1)', () => {
beforeEach(() => {
setActivePinia(createPinia())
stubDomGlobals()
})
it('renders the assistant bubble with only native markup and project classes', () => {
const wrapper = mountItem(baseMessage({ content: 'hello **world**' }))
expect(arcoDescendants(wrapper, '.message-bubble')).toEqual([])
})
it('renders the user bubble with only native markup', () => {
const wrapper = mountItem(baseMessage({ role: 'user', content: 'hi there' }))
expect(arcoDescendants(wrapper, '.message-bubble')).toEqual([])
})
it('keeps the bubble Arco-free for a tool-role message', () => {
const wrapper = mountItem(
baseMessage({
role: 'tool',
content: '',
toolName: 'web_search',
toolArgs: '{"q":"arco"}',
toolResult: '{"ok":true}',
}),
)
expect(arcoDescendants(wrapper, '.message')).toEqual([])
})
it('keeps the bubble Arco-free across streaming re-renders', async () => {
const wrapper = mountItem(baseMessage({ content: 'partial' }))
for (const chunk of ['partial ans', 'partial answer', 'partial answer.']) {
await wrapper.setProps({ message: baseMessage({ content: chunk }) })
expect(arcoDescendants(wrapper, '.message-bubble')).toEqual([])
}
})
it('detects an Arco component that leaks into the bubble', () => {
// Guards the guard: if the matcher ever stops working, this fails too.
const Leaky = defineComponent({
setup() {
return () => h('div', { class: 'message' }, [h('div', { class: 'message-bubble' }, [h('span', { class: 'arco-tag' })])])
},
})
const wrapper = mount(Leaky, { global: globalConfig })
expect(arcoDescendants(wrapper, '.message-bubble')).toEqual(['span.arco-tag'])
})
})
describe('MarkdownRenderer preview drawer (Phase 006 §1)', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('does not mount the Arco drawer until a preview is requested', () => {
const wrapper = mount(MarkdownRenderer, {
props: { content: '# title' },
global: globalConfig,
})
expect(wrapper.find('.arco-drawer').exists()).toBe(false)
})
})
+28 -27
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { nextTick, defineComponent, h } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { config, mount } from '@vue/test-utils'
const apiMocks = vi.hoisted(() => ({
fetchSessionsMock: vi.fn(),
@@ -40,31 +40,32 @@ vi.mock('vue-i18n', () => ({
}),
}))
vi.mock('naive-ui', async () => {
const actual = await vi.importActual<any>('naive-ui')
return {
...actual,
useMessage: () => ({
error: vi.fn(),
}),
NModal: {
props: ['show'],
emits: ['update:show'],
template: '<div v-if="show" class="n-modal-stub"><slot /></div>',
},
NInput: {
props: ['value', 'size'],
emits: ['update:value', 'keydown'],
template: '<input class="n-input-stub" :value="value" @input="$emit(\'update:value\', $event.target.value)" @keydown="$emit(\'keydown\', $event)" />',
},
NSpin: {
template: '<div class="n-spin-stub"><slot /></div>',
},
NButton: {
template: '<button class="n-button-stub"><slot /></button>',
},
}
})
// The search modal teleports to body in jsdom, so stub the overlays to render
// inline and keep the DOM shape the assertions below rely on.
config.global.stubs = {
AModal: {
name: 'AModal',
props: ['visible'],
emits: ['update:visible'],
template: '<div v-if="visible" class="a-modal-stub"><slot /></div>',
},
AInput: {
name: 'AInput',
// `size` is an Arco prop, not an HTML input attribute; declaring it keeps
// it off the native element.
props: ['modelValue', 'size'],
emits: ['update:modelValue'],
template: '<input class="a-input-stub" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
},
ASpin: {
name: 'ASpin',
template: '<div class="a-spin-stub"><slot /></div>',
},
AButton: {
name: 'AButton',
template: '<button class="a-button-stub"><slot /></button>',
},
}
import SessionSearchModal from '@/components/hermes/chat/SessionSearchModal.vue'
import { useSessionSearch } from '@/composables/useSessionSearch'
@@ -167,7 +168,7 @@ describe('session search modal', () => {
await flushPromises()
await nextTick()
const input = wrapper.find('input.n-input-stub')
const input = wrapper.find('input.a-input-stub')
await input.setValue('docker')
await vi.advanceTimersByTimeAsync(200)
await flushPromises()
+19 -11
View File
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { defineComponent } from 'vue'
import ArcoVue from '@arco-design/web-vue'
import { useChatStore } from '@/stores/hermes/chat'
import VoiceDialogueControls from '@/components/hermes/chat/VoiceDialogueControls.vue'
import ChatInput from '@/components/hermes/chat/ChatInput.vue'
@@ -73,16 +74,23 @@ vi.mock('vue-i18n', () => ({
}),
}))
vi.mock('naive-ui', () => ({
NButton: { template: '<button type="button" v-bind="$attrs"><slot /><slot name="icon" /></button>' },
NTooltip: { template: '<div><slot name="trigger" /><slot /></div>' },
NSwitch: { template: '<button type="button"></button>' },
NDropdown: { template: '<div><slot /></div>' },
NModal: { template: '<div><slot /><slot name="footer" /></div>' },
NInputNumber: { template: '<input />' },
NPopselect: { template: '<div><slot /></div>' },
useMessage: () => ({ error: vi.fn(), success: vi.fn() }),
}))
// Arco's imperative message API needs a live DOM; the rest renders for real.
vi.mock('@arco-design/web-vue', async () => {
const actual =
await vi.importActual<typeof import('@arco-design/web-vue')>(
'@arco-design/web-vue',
)
return {
...actual,
Message: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
loading: vi.fn(),
},
}
})
vi.mock('@/api/hermes/sessions', () => ({
fetchContextLength: vi.fn().mockResolvedValue(256000),
@@ -174,7 +182,7 @@ describe('VoiceDialogueControls', () => {
const wrapper = mount(ChatInput, {
...options,
global: {
plugins: [pinia],
plugins: [pinia, ArcoVue],
...options.global,
},
})
+65
View File
@@ -0,0 +1,65 @@
import { expect, test } from '@playwright/test'
import { authenticate, mockChatSocket, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
const sampleSession = {
id: 'sess-1',
title: 'First session',
source: 'cli',
created_at: 1760000000000,
updated_at: 1760000000000,
message_count: 2,
input_tokens: 0,
output_tokens: 0,
reasoning_tokens: 0,
}
// Arco 的 a-dropdown 没有 droplist prop:菜单内容必须走 #content 插槽 + a-doption。
// 传 :droplist 会被静默忽略,弹层能打开但列表是空的(历史上就是这么坏的)。
test('reasoning effort dropdown renders its options and applies the selection', async ({ page }) => {
await authenticate(page, TEST_ACCESS_KEY, 'research')
await mockHermesApi(page, { sessions: [sampleSession] })
await mockChatSocket(page)
await page.goto('/#/hermes/chat')
await page.locator('.reasoning-effort-button').click()
const popup = page.locator('.arco-trigger-popup').filter({ hasText: 'Minimal' })
await expect(popup).toBeVisible()
await expect(popup.locator('.arco-dropdown-option')).toHaveCount(7)
await popup.locator('.arco-dropdown-option', { hasText: 'High' }).first().click()
await expect(page.locator('.reasoning-effort-label')).toHaveText('High')
})
test('input settings dropdown renders its options and toggles auto play voice', async ({ page }) => {
await authenticate(page, TEST_ACCESS_KEY, 'research')
await mockHermesApi(page, { sessions: [sampleSession] })
await mockChatSocket(page)
await page.goto('/#/hermes/chat')
await page.locator('.input-settings-button').click()
const popup = page.locator('.arco-trigger-popup').filter({ hasText: 'Auto-play voice' })
await expect(popup).toBeVisible()
await expect(popup.locator('.arco-dropdown-option')).toHaveCount(2)
await popup.locator('.arco-dropdown-option').first().click()
await expect(page.locator('.settings-check').first()).toHaveText('✓')
})
test('session context menu renders flat items and its export submenu', async ({ page }) => {
await authenticate(page, TEST_ACCESS_KEY, 'research')
await mockHermesApi(page, { sessions: [sampleSession] })
await mockChatSocket(page)
await page.goto('/#/hermes/chat')
await page.locator('.session-item').first().click({ button: 'right' })
const popup = page.locator('.arco-trigger-popup').filter({ hasText: 'Rename' })
await expect(popup).toBeVisible()
// pin / rename / archive / workspace / model / export(submenu) / open-link / copy-link / copy-id
await expect(popup.locator('.arco-dropdown-option')).toHaveCount(9)
await popup.locator('.arco-dropdown-option', { hasText: 'Export' }).click()
await expect(page.locator('.arco-trigger-popup').filter({ hasText: 'Compressed' })).toBeVisible()
})
+104
View File
@@ -0,0 +1,104 @@
import { expect, test, type Page } from '@playwright/test'
import { authenticate, mockChatSocket, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
const inputPlaceholder = 'Type a message... (Enter to send, Shift+Enter for new line)'
const sampleSession = {
id: 'sess-1',
title: 'First session',
source: 'cli',
created_at: 1760000000000,
updated_at: 1760000000000,
message_count: 2,
input_tokens: 0,
output_tokens: 0,
reasoning_tokens: 0,
}
async function sendChatMessage(page: Page, message: string) {
const input = page.getByPlaceholder(inputPlaceholder)
await expect(input).toBeVisible()
await input.fill(message)
await page.getByRole('button', { name: 'Send' }).click()
}
async function waitForRunSessionId(page: Page, index = 0) {
const handle = await page.waitForFunction((runIndex) => {
const state = (window as any).__PW_CHAT_SOCKET__
const runs = state?.emitted?.filter((item: any) => item.event === 'run') || []
const run = runs[runIndex]
return run ? { sessionId: run.payload?.session_id } : null
}, index)
return handle.jsonValue() as Promise<any>
}
// Covers the Phase 006 manual checklist rows for streaming, markdown rendering,
// and the run lifecycle, so a rendering regression fails in CI instead of only
// showing up when someone opens the chat page by hand.
test('streams a run and renders markdown, code, math and mermaid without leaking Arco into the bubble', async ({ page }) => {
await authenticate(page, TEST_ACCESS_KEY, 'research')
const api = await mockHermesApi(page)
await mockChatSocket(page)
await page.goto('/#/hermes/chat')
await sendChatMessage(page, 'render everything')
const { sessionId } = await waitForRunSessionId(page)
await expect(page.getByRole('button', { name: 'Stop' })).toBeVisible()
await page.evaluate((sid) => {
const socket = (window as any).__PW_CHAT_SOCKET__.latest
const delta = (text: string) => socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'r1', delta: text })
delta('Hello **bold** and `code`')
delta('\n\n```js\nconst a = 1\n```\n')
delta('\n$$\nE = mc^2\n$$\n')
delta('\n```mermaid\ngraph TD;\nA-->B;\n```\n')
}, sessionId)
const bubble = page.locator('.message').last()
await expect(bubble.locator('strong')).toHaveText('bold')
await expect(bubble.locator('code')).toHaveCount(2)
await expect(bubble.locator('.katex')).toHaveCount(1)
// mermaid renders asynchronously into the .mermaid-diagram placeholder
await expect(bubble.locator('.mermaid-diagram svg')).toHaveCount(1)
// §1 red line: no Arco component inside the message bubble
await expect(bubble.locator('.arco-btn, .arco-tag, .arco-input')).toHaveCount(0)
// the mock socket has no server-side cancel, so end the run the way the server would
await page.evaluate((sid) => {
const socket = (window as any).__PW_CHAT_SOCKET__.latest
socket.__trigger('run.completed', {
event: 'run.completed',
session_id: sid,
run_id: 'r1',
output: 'done',
inputTokens: 5,
outputTokens: 3,
})
}, sessionId)
await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0)
expect(api.unexpectedRequests).toEqual([])
})
test('opens the session model dialog and the session search from the chat header', async ({ page }) => {
await authenticate(page, TEST_ACCESS_KEY, 'research')
await mockHermesApi(page, { sessions: [sampleSession] })
await mockChatSocket(page)
await page.goto('/#/hermes/chat')
await page.locator('.session-item').first().click({ button: 'right' })
const ctxPopup = page.locator('.arco-trigger-popup').filter({ hasText: 'Rename' })
await expect(ctxPopup).toBeVisible()
await ctxPopup.locator('.arco-dropdown-option', { hasText: 'Set model' }).click()
const modelModal = page.locator('.arco-modal').filter({ hasText: 'Set Session Model' })
await expect(modelModal).toBeVisible()
await expect(modelModal.getByPlaceholder('Search models...')).toBeVisible()
await page.keyboard.press('Escape')
await page.getByRole('button', { name: /search/i }).first().click()
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible()
})