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
+23 -4
View File
@@ -21,13 +21,15 @@ Phase 005 起,新代码与迁移代码一律使用 Arco Design Vue。本文表
| Naive | Arco | 用量 | 备注 |
|---|---|---|---|
| `NButton` | `a-button` | 732 | `block`→`long`、`size="tiny"`→`size="mini"`、`type="warning"`→`type="primary" status="warning"`、`quaternary`→`status="secondary"` |
| `NButton` | `a-button` | 732 | `block`→`long`、`size="tiny"`→`size="mini"`、`type="warning"`→`type="primary" status="warning"`、`quaternary`/`secondary`→`type="secondary"`(Arco `status` 只有 `normal\|success\|warning\|danger`,没有 `secondary`) |
| `NInput` | `a-input` / `a-textarea` | 160 | `type="textarea"` 用独立 `a-textarea`;`a-input` 关闭了 attrs 透传,`autofocus` 无效,需 `ref` + `focus()` |
| `NModal` | `a-modal` | 138 | `v-model:show`→`v-model:visible`;`preset="dialog"` 无对应,信息型弹窗用 `:footer="false"`(`footer` 为 Boolean prop,默认 true) |
| `NFormItem` | `a-form-item` | 136 | 校验规则 API 不同(`rules` 结构、`required` 表达式)需适配 |
| `NSelect` | `a-select` | 97 | `options` 的 `label/value` 语义一致;`consistent-menu-width` 等 prop 无对应 |
| `NSpin` | `a-spin` | 86 | `show`→`loading` |
| `NTooltip` | `a-tooltip` | 76 | `trigger="hover"` 默认一致;`placement` 语义基本一致 |
| `NTooltip` | `a-tooltip` | 76 | `trigger="hover"` 默认一致;`placement`→`position`;**触发元素走默认插槽**,naive 的 `<template #trigger>` 必须拆掉 |
| `NPopconfirm` / `NTooltip` 的 `#trigger` 槽 | Arco 默认插槽 | — | Arco 的 `a-tooltip` / `a-popconfirm` / `a-dropdown` 都用默认插槽承载触发元素,naive 的 `<template #trigger>` 写法会静默丢内容 |
| Arco Trigger 嵌套 | 不可嵌套,无论谁包谁 | — | `a-tooltip` / `a-dropdown` / `a-popconfirm` 内部都用 `mergeFirstChild` 找首个元素节点并各自接管事件;嵌套时两个 popup 容器互相踩,菜单**点不开**(`.arco-trigger-popup-wrapper` 卡在过渡中间态、选项数为 0)。同一触发元素要同时有说明文案和下拉时,说明文案改用原生 `:title`,不要套 `a-tooltip` |
| `NTag` | `a-tag` | 57 | `bordered` prop 存在(默认 true);`size="small"` 一致;naive `type="info"`→`color="arcoblue"` 等内建色(`red/orange/green/arcoblue/...`),自定义色用 `color` 传 CSS 颜色 |
| `NSwitch` | `a-switch` | 53 | `value`/`v-model` 一致 |
| `NSpace` | `a-space` | 51 | `size` 语义一致(数字/预设名) |
@@ -36,7 +38,7 @@ Phase 005 起,新代码与迁移代码一律使用 Arco Design Vue。本文表
| `NForm` | `a-form` | 32 | 校验规则 API 不同,需适配 |
| `NTabPane` | `a-tab-pane` | 30 | `NTabs/NTabPane` → `a-tabs/a-tab-pane` |
| `NDrawer` / `NDrawerContent` | `a-drawer` | 27 | Arco 无独立 content 组件;`footer` slot 用 `a-drawer` 的 `footer` |
| `NDropdown` | `a-dropdown` | 25 | `options` 结构不同(`label`→`content`,需转换函数) |
| `NDropdown` | `a-dropdown` | 25 | **Arco 没有 `droplist` prop**:菜单内容必须写进 `#content` 插槽、用 `a-doption` / `a-dsubmenu` 声明。传 `:droplist` 会被静默忽略,弹层能打开但 `<ul class="arco-dropdown-list">` 是空的;子菜单用 `a-dsubmenu` + 自己的 `#content` 插槽 |
| `NDescriptions` / `NDescriptionsItem` | `a-descriptions` / `a-descriptions-item` | 19 | 基本一致 |
| `NAlert` | `a-alert` | 19 | `type` 一致;`show-icon` 一致 |
| `NRadioButton` / `NRadioGroup` / `NRadio` | `a-radio`(`type="radio-button"`)/ `a-radio-group` | 32 | Arco radio-button 是 `a-radio` 的 `type` prop |
@@ -50,7 +52,7 @@ Phase 005 起,新代码与迁移代码一律使用 Arco Design Vue。本文表
| `NDataTable` | `a-table` | 4 | **列定义 API 差异最大**(`columns`/`render`/`slots`),逐例处理 |
| `NSlider` | `a-slider` | 3 | 基本一致 |
| `NScrollbar` | `a-scrollbar` | 3 | Arco 全局可用,无需组件包裹注册 |
| `NPopselect` | `a-select`(`trigger-element`) | 3 | 无直接对应,改用 `a-dropdown` 或带 trigger 的 `a-select` |
| `NPopselect` | `a-dropdown` | 3 | 无直接对应。实测 `a-dropdown` + `:droplist`(`key`/`content`)可完整替代 |
| `NPopover` | `a-popover` | 3 | `trigger` 语义有差异 |
| `NIcon` | `<icon-*>`(`@arco-design/web-vue/es/icon`) | 3 | Arco 图标是独立组件,需 import |
| `NBadge` | `a-badge` | 3 | `value`→`count` |
@@ -63,3 +65,20 @@ Phase 005 起,新代码与迁移代码一律使用 Arco Design Vue。本文表
- Phase 006:聊天页(`components/hermes/chat/`)——消息气泡内部**不用** Arco 组件(性能红线)
- Phase 007:其余视图 + `stores/` + `composables/` 批量替换 + 删除 naive-ui 依赖
## 例外
- 聊天虚拟滚动区不用 `a-scrollbar` 包裹。`VirtualMessageList.vue` 直接用
`vue-virtual-scroller` 的 `DynamicScroller` 独占滚动(原本也没有 `NScrollbar`),
额外包一层滚动容器会破坏高度测量与 sticky 定位。
- 测试里 Arco 组件的 stub 必须用 Arco 内部组件名(`Dropdown` / `Modal` / `Spin`),
而不是模板里的 `a-*` 标签名:`app.use(ArcoVue)` 之后组件已全局注册,VTU 的
`stubs` 只按内部名匹配,用 `ADropdown` 会静默不生效。
- `ChatInput.vue` 的推理强度按钮、输入设置按钮放弃 `a-tooltip`,改用原生 `:title`。
这两个按钮必须挂 `a-dropdown`,而 Trigger 不能嵌套(见上表同名行),因此失去
Arco 的悬浮气泡;文案与 `aria-label` 都保留,信息量不变。
- Arco(和 Vue 本身)不会对拼错的 prop 报错,未声明的 prop 会作为普通 attribute
落到根元素上。所以「弹层能打开、但内容是空的」几乎总是 prop 写错而不是数据为空。
排查手法:`el.__vueParentComponent.props` 看目标组件到底认不认这个 prop,
再看 `el.outerHTML` 确认渲染出来的骨架。回归由
`tests/e2e/chat-input-dropdown.spec.ts` 卡住。
@@ -0,0 +1,96 @@
---
date: 2026-10-17
pr: pending
feature: Phase 006 - chat page naive-ui to Arco Design Vue rendering migration
impact: No chat-chain behavior change; chat components only swap naive-ui components for Arco equivalents. Chat run flow, chat store logic, keyboard handling, and the /chat-run Socket.IO contract are untouched.
---
# Phase 006 — Chat page Arco migration
Phase: `docs/phases/phase_006.md`
## What Changed
Rendering-layer only, across `packages/client/src/components/hermes/chat/`:
- `ChatInput.vue` — `NPopselect` reasoning-effort picker and `NDropdown`
settings menu became `a-dropdown` whose `#content` slot declares `a-doption`
items; `NModal` became `a-modal` (`:footer="false"` for informational
dialogs, custom `#footer` for confirm dialogs); `NInputNumber` became
`a-input-number hide-button`. The two menu buttons use a native `:title`
instead of `a-tooltip` because Arco Triggers cannot be nested.
`useKeyboard` wiring and the textarea height auto-resize logic were not
touched.
- `ChatPanel.vue` — session context menu moved from `NDropdown trigger="manual"`
to `a-dropdown trigger="contextMenu" align-point` wrapping `.session-items`
(the three-level export submenu became nested `a-dsubmenu` / `a-doption`);
4 dialogs, the new-chat drawer, 5 `NSelect`, 6 `NTooltip`, and the
`NPopconfirm` confirmations became Arco equivalents.
- `MarkdownRenderer.vue` — the text preview `NDrawer`/`NDrawerContent` pair
became a single `a-drawer`, and it is now lazily mounted behind
`v-if="textPreviewVisible"`. The component's public interface is unchanged
because it is reused by 8 call sites (including Phase 007's GroupMessageItem).
- `MessageItem.vue` — the tool-change `NDrawer`/`NDrawerContent` became a
single `a-drawer`. It uses `:visible` + `@update:visible` rather than
`v-model:visible` so the existing "refuse to close with unsaved changes"
guard still runs.
- `MessageList.vue` — approval / clarification overlay buttons and inputs.
- `FolderPicker.vue` — folder tree context menu moved to
`a-trigger trigger="contextMenu" align-point` with an `#content` slot;
`NModal preset="dialog"` became `a-modal` with a custom `#footer`.
- `SessionListItem.vue`, `SessionSearchModal.vue`, `TerminalPanel.vue`,
`FilesPanel.vue` — remaining buttons, checkboxes, tooltips, popconfirms,
selects, and spinners.
`VirtualMessageList.vue` already had no `NScrollbar` and keeps
`vue-virtual-scroller` as the sole scroll owner (recorded as an exception in
`docs/arco-mapping.md`).
`stores/hermes/chat.ts`, `stores/hermes/files.ts`, and `ChatView.vue` needed no
changes.
## Contract Check
`/chat-run` event names, field names, field types, and emission timing are
unchanged. No file under `packages/server/`, `services/hermes/group-chat/`,
`lib/context-compressor/`, or `services/hermes/context-engine/` was modified,
so no bridge, compressor, or context-engine behavior changed.
The §1 red line (no Arco components inside the message bubble) is enforced by
`tests/client/message-item-arko.test.ts`, which registers the real Arco plugin
and asserts that no `.arco-*` element appears in the rendered bubble for
assistant, user, and tool messages, including across streaming re-renders.
## Verification
```bash
grep -cE "from ['\"]naive-ui['\"]" packages/client/src/components/hermes/chat/*.vue \
packages/client/src/views/hermes/ChatView.vue \
packages/client/src/stores/hermes/chat.ts
npm run harness:check
npm run test
npm run test:e2e
npm run build
```
Result: grep 0 across all chat files; 274 test files / 2026 tests passing;
35 e2e tests passing; harness check and build clean.
## Follow-up Fix
Arco's `a-dropdown` has no `droplist` prop — its menu content must be declared
in the `#content` slot with `a-doption` / `a-dsubmenu` children. The first
migration pass used `:droplist`, which Arco silently ignored (unknown props
fall through as plain attributes), so all three dropdowns opened an empty
popup. Fixed by declaring the options in the `#content` slot, and locked down
by `tests/e2e/chat-input-dropdown.spec.ts`, which asserts the reasoning
effort menu (7 options, selection applied), the input settings menu
(2 options, toggle applied), and the session context menu plus its export
submenu.
The §9.2 manual checklist rows that need a live run are covered by
`tests/e2e/chat-rendering-smoke.spec.ts`: streamed deltas rendering bold text,
inline code, a highlighted code block, KaTeX math, and a Mermaid diagram; the
Stop button appearing on run start and clearing on `run.completed`; the
"no Arco inside the bubble" red line; and the session model dialog plus session
search opening from the chat UI.
+1 -1
View File
@@ -193,7 +193,7 @@ const message = useMessage() // 我们的 composable,签名一致
```bash
cd /Users/jack/source/MyProject/js/hermes-ui
grep -c "from 'naive-ui'" packages/client/src/components/hermes/chat/*.vue \
grep -cE "from ['\"]naive-ui['\"]" packages/client/src/components/hermes/chat/*.vue \
packages/client/src/views/hermes/ChatView.vue \
packages/client/src/stores/hermes/chat.ts
# 期望:全部 0