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')