diff --git a/docs/arco-mapping.md b/docs/arco-mapping.md
index b47cb8b..da6225c 100644
--- a/docs/arco-mapping.md
+++ b/docs/arco-mapping.md
@@ -82,3 +82,14 @@ Phase 005 起,新代码与迁移代码一律使用 Arco Design Vue。本文表
排查手法:`el.__vueParentComponent.props` 看目标组件到底认不认这个 prop,
再看 `el.outerHTML` 确认渲染出来的骨架。回归由
`tests/e2e/chat-input-dropdown.spec.ts` 卡住。
+- **迁移时最容易漏的是 CSS 里的 naive 类名**,因为 `grep naive-ui` 匹配不到
+ `:deep(.n-button__content)` 这种写法。聊天页原本有 35 处这类死样式,在 Arco 下
+ 全部不生效,必须逐条改写成 Arco 类名。注意三处结构差异:
+ - `a-button` **没有** content 包裹层(naive 有 `.n-button__content`),
+ `gap` / `min-width` 要直接写在 button 上
+ - `a-modal` 没有 `body-wrapper` 分层,用 `.arco-modal` / `.arco-modal-body`
+ - `a-drawer` 同理,没有 `content` / `body-content-wrapper` 分层
+- `useKeyboard.ts` 里查 `.n-modal-mask` 的 Esc 兜底属于跨组件的全局约定,迁移
+ 组件时最容易漏掉。改弹窗库时必须同步 grep `.n-modal|.n-drawer|.n-base-` 这类
+ **字符串字面量**里的类名,而不只是组件 import。回归由
+ `tests/client/session-search.test.ts` 的 Esc 用例卡住。
diff --git a/docs/chat-chain-changes/2026-10-17-phase006-arco-style-cleanup.md b/docs/chat-chain-changes/2026-10-17-phase006-arco-style-cleanup.md
new file mode 100644
index 0000000..aea65b9
--- /dev/null
+++ b/docs/chat-chain-changes/2026-10-17-phase006-arco-style-cleanup.md
@@ -0,0 +1,86 @@
+---
+date: 2026-10-17
+pr: pending
+feature: Phase 006 follow-up - Arco class-name cleanup, tooltip content props, tool panel width clamp, Escape dialog fallback
+impact: Cosmetic styling fix plus one behavior fix. Dead naive-ui CSS class names in the chat components are rewritten to Arco equivalents so the toolbar, model label, drawer body scrolling, and mobile breakpoints receive their intended styles again; Arco tooltips that carried their label as trailing default-slot text now pass it through the content prop so the text stops being rendered into the layout flow; the workspace/terminal panel no longer squeezes the chat column narrow enough to wrap the input toolbar; and Escape again closes Arco chat dialogs because the fallback no longer looks for naive-ui's .n-modal-mask. No chat store logic, no /chat-run payload, and no message rendering path is touched.
+---
+
+# Phase 006 follow-up: Arco style cleanup and Esc dialog fallback
+
+Branch: `phase/006-chat-arco`
+Touched feature: chat toolbar styling, chat dialogs, session context menu
+layout, Esc-to-close fallback
+
+## Why
+
+Phase 006 migrated the chat page to Arco, but the manual checklist walkthrough
+surfaced four problems that the automated checks could not see:
+
+1. Opening the workspace/terminal panel squeezed the chat column so far that the
+ input toolbar wrapped "Add attachment" into a vertical stack and truncated the
+ model label.
+2. 35 `:deep(.n-*)` rules in the chat components were left behind by the
+ migration. They silently stopped matching because Arco uses different class
+ names, and `grep naive-ui` never sees them because they are class names in a
+ style block rather than an import.
+3. `useKeyboard.ts` still looked for `.n-modal-mask` to close dialogs on Escape.
+ Every chat dialog had moved to Arco, so Escape no longer closed the model
+ picker or session search.
+4. Four Arco tooltips carried their label as trailing default-slot text
+ (`{{ t(...) }}`). naive-ui rendered the
+ tooltip slot as an overlay so the text was harmless; Arco renders the default
+ slot inline, so those text nodes sat in the flex row as zero-width items and
+ contributed to the toolbar squeeze above.
+
+## What changed
+
+- `ChatPanel.vue` — `toolPanelMaxWidth()` now reserves `CHAT_MAIN_MIN_WIDTH`
+ (620px) for the chat column instead of a flat 120px, so the toolbar always has
+ room. `showToolPanel` already re-clamps on open, so persisted widths from older
+ builds are corrected on the next toggle. Dead naive classes rewritten:
+ `.n-button` → `.arco-btn`, and the new-chat drawer's `-content` /
+ `-body-content-wrapper` layers collapsed onto `.arco-drawer` / `.arco-drawer-body`
+ because Arco has no such nesting.
+- `ChatInput.vue` — dropped the `.n-button__content` / `.n-button__border` /
+ `.n-button__ripple` / `.n-base-wave` rules; Arco's `a-button` is already
+ `display: flex` and carries a transparent border, so `gap` and
+ `border-color` now go on the button itself. `.n-switch` → `.arco-switch`,
+ `.n-button__icon` → `.arco-btn-icon`. The context-length, attach, and model
+ tooltips now pass their label via `:content`. `.input-toolbar` is pinned to
+ `flex-wrap: nowrap`.
+- `TerminalPanel.vue` — the new-session tooltip passes its label via `:content`.
+- `MessageList.vue`, `MessageItem.vue`, `SessionSearchModal.vue` — same
+ class-name rewrite; `.n-drawer-body-content-wrapper` → `.arco-drawer-body`,
+ `.n-modal-body-wrapper` → `.arco-modal`, `.n-input` → `.arco-input`.
+- `useKeyboard.ts` — the Escape fallback now finds the visible `.arco-modal` and
+ clicks its `.arco-modal-close-btn`.
+
+## Verification
+
+The toolbar was measured with the workspace panel open across viewport widths
+1600 / 1280 / 1100 / 1024 / 900 / 820. `.input-toolbar` stays a single 32px row
+at every width and `.input-model-label` is no longer clipped (`scrollWidth ===
+clientWidth`). Before the fix the same measurement showed a 90px-tall two-row
+toolbar and a truncated model label.
+
+## Behavior impact
+
+Cosmetic only, plus one fix. Toolbar, model label, drawer body scrolling and
+mobile breakpoints now actually receive their intended styles. Tooltip labels
+stop being emitted into the layout. Escape now closes Arco chat dialogs again. No
+chat store logic, no `/chat-run` payload, and no message rendering path was
+touched.
+
+## Regression coverage
+
+- `tests/client/session-search.test.ts` — asserts Escape clicks an open Arco
+ modal's close button and ignores leftover naive-ui markup.
+- `tests/e2e/chat-input-dropdown.spec.ts`, `tests/e2e/chat-rendering-smoke.spec.ts`
+ — unchanged, still green (35 e2e tests).
+
+```bash
+npm run test # 274 files / 2027 tests
+npm run test:e2e # 35 tests
+npm run harness:check
+npm run build
+```
diff --git a/packages/client/src/components/hermes/chat/ChatInput.vue b/packages/client/src/components/hermes/chat/ChatInput.vue
index 27f2b1c..58cc7c6 100644
--- a/packages/client/src/components/hermes/chat/ChatInput.vue
+++ b/packages/client/src/components/hermes/chat/ChatInput.vue
@@ -1048,11 +1048,10 @@ function isImage(type: string): boolean {
{{ formatTokens(totalTokens) }} /
-
+
{{ formatTokens(contextLength) }}
- {{ t('chat.contextClickToEdit') }}
· {{ t('chat.contextRemaining') }} {{ formatTokens(remainingTokens) }}
@@ -1083,13 +1082,16 @@ function isImage(type: string): boolean {
@@ -1386,8 +1391,8 @@ function isImage(type: string): boolean {
}
}
- :deep(.n-switch),
- :deep(.n-switch__rail) {
+ :deep(.arco-switch),
+ :deep(.arco-switch-track) {
margin-right: 0;
}
}
@@ -1405,12 +1410,6 @@ function isImage(type: string): boolean {
background: transparent !important;
opacity: 1;
- :deep(.n-button__state-border),
- :deep(.n-button__border),
- :deep(.n-button__ripple) {
- display: none;
- }
-
.tool-trace-icon {
display: block;
flex: 0 0 16px;
@@ -1433,16 +1432,8 @@ function isImage(type: string): boolean {
color: $text-secondary;
border-radius: 999px;
padding: 0 7px 0 6px;
-
- :deep(.n-button__content) {
- gap: 4px;
- }
-
- :deep(.n-button__state-border),
- :deep(.n-button__border),
- :deep(.n-button__ripple) {
- display: none;
- }
+ gap: 4px;
+ border-color: transparent;
}
.input-model-button {
@@ -1450,17 +1441,8 @@ function isImage(type: string): boolean {
border-radius: 999px;
max-width: 190px;
padding: 0 4px 0 6px;
-
- :deep(.n-button__content) {
- gap: 4px;
- min-width: 0;
- }
-
- :deep(.n-button__state-border),
- :deep(.n-button__border),
- :deep(.n-button__ripple) {
- display: none;
- }
+ gap: 4px;
+ border-color: transparent;
}
.input-model-label {
@@ -1476,15 +1458,12 @@ function isImage(type: string): boolean {
color: $text-secondary;
border-radius: 999px;
padding: 0 4px 0 6px;
+ gap: 4px;
+ border-color: transparent;
&.active {
color: #4caf50;
}
-
- :deep(.n-button__content) {
- gap: 4px;
- min-width: 0;
- }
}
.reasoning-effort-label {
@@ -1625,14 +1604,7 @@ function isImage(type: string): boolean {
.input-settings-button {
min-width: 36px;
padding: 0 4px 0 6px;
-
- :deep(.n-button__content) {
- gap: 2px;
- }
-
- :deep(.n-button__icon) {
- margin: 0;
- }
+ gap: 2px;
}
.input-settings-label {
@@ -1820,6 +1792,7 @@ function isImage(type: string): boolean {
gap: 12px;
margin-top: auto;
min-height: 32px;
+ flex-wrap: nowrap;
}
.toolbar-icon-button {
@@ -1833,22 +1806,12 @@ function isImage(type: string): boolean {
padding: 0 !important;
border: 0 !important;
box-shadow: none !important;
+ gap: 0;
- :deep(.n-button__icon) {
+ :deep(.arco-btn-icon) {
margin: 0 !important;
}
- :deep(.n-button__content) {
- display: none;
- }
-
- :deep(.n-button__border),
- :deep(.n-button__state-border),
- :deep(.n-button__ripple),
- :deep(.n-base-wave) {
- display: none;
- }
-
&:disabled {
color: var(--text-on-overlay);
background-color: #9f9f9f;
diff --git a/packages/client/src/components/hermes/chat/ChatPanel.vue b/packages/client/src/components/hermes/chat/ChatPanel.vue
index 6fca4e3..6bb91b7 100644
--- a/packages/client/src/components/hermes/chat/ChatPanel.vue
+++ b/packages/client/src/components/hermes/chat/ChatPanel.vue
@@ -44,6 +44,9 @@ const activeWorkspaceSessionId = computed(() => chatStore.activeSession?.workspa
const activeWorkspacePath = computed(() => chatStore.activeSession?.workspace && !chatStore.activeSession.isLocalOnly ? chatStore.activeSession.workspace : null);
const TOOL_PANEL_MIN_WIDTH = 360;
const TOOL_PANEL_DEFAULT_WIDTH = 560;
+// 工具栏要单行放下:附件 + 推理强度 + 设置 + 模型下拉(最长 190px)+ 语音 + 发送,
+// 再加输入框左右内边距。窄于此值 Arco 按钮会被挤成竖排。
+const CHAT_MAIN_MIN_WIDTH = 620;
const TOOL_PANEL_STORAGE_KEY = "hermes.chat.toolPanelWidth";
const toolPanelWidth = ref(loadToolPanelWidth());
const toolResizeStart = ref<{ x: number; width: number } | null>(null);
@@ -102,7 +105,9 @@ function toolPanelMaxWidth() {
if (typeof window === "undefined") return 1180;
if (isMobile.value) return window.innerWidth;
const available = chatContentWrapperRef.value?.clientWidth || window.innerWidth;
- return Math.max(320, Math.min(Math.floor(available * 0.88), available - 120));
+ // 主区要放得下输入工具栏(附件 + 推理强度 + 设置 + 模型 + 语音 + 发送),
+ // 否则面板一展开工具栏就被压成竖排。
+ return Math.max(320, Math.min(Math.floor(available * 0.88), available - CHAT_MAIN_MIN_WIDTH));
}
function clampToolPanelWidth(width: number) {
@@ -2068,7 +2073,7 @@ async function handleSessionModelCustomSubmit() {
gap: 4px;
height: 22px;
- .n-button {
+ .arco-btn {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -2150,24 +2155,20 @@ async function handleSessionModelCustomSubmit() {
gap: 14px;
}
-:deep(.new-chat-drawer .n-drawer-content) {
+:deep(.new-chat-drawer .arco-drawer) {
height: 100%;
display: flex;
flex-direction: column;
}
-:deep(.new-chat-drawer .n-drawer-header),
-:deep(.new-chat-drawer .n-drawer-footer) {
+:deep(.new-chat-drawer .arco-drawer-header),
+:deep(.new-chat-drawer .arco-drawer-footer) {
flex-shrink: 0;
}
-:deep(.new-chat-drawer .n-drawer-body) {
+:deep(.new-chat-drawer .arco-drawer-body) {
flex: 1 1 auto;
min-height: 0;
- overflow: hidden;
-}
-
-:deep(.new-chat-drawer .n-drawer-body-content-wrapper) {
height: 100%;
overflow-y: auto;
}
@@ -2181,7 +2182,7 @@ async function handleSessionModelCustomSubmit() {
}
@media (max-width: $breakpoint-mobile) {
- :deep(.new-chat-drawer .n-drawer-body-content-wrapper) {
+ :deep(.new-chat-drawer .arco-drawer-body) {
padding-top: 12px;
padding-bottom: 12px;
}
diff --git a/packages/client/src/components/hermes/chat/MessageItem.vue b/packages/client/src/components/hermes/chat/MessageItem.vue
index 19e1687..969f323 100644
--- a/packages/client/src/components/hermes/chat/MessageItem.vue
+++ b/packages/client/src/components/hermes/chat/MessageItem.vue
@@ -2112,7 +2112,7 @@ onBeforeUnmount(() => {
}
}
-:deep(.n-drawer-body-content-wrapper) {
+:deep(.arco-drawer-body) {
height: 100%;
}
diff --git a/packages/client/src/components/hermes/chat/MessageList.vue b/packages/client/src/components/hermes/chat/MessageList.vue
index 91c088b..3929b65 100644
--- a/packages/client/src/components/hermes/chat/MessageList.vue
+++ b/packages/client/src/components/hermes/chat/MessageList.vue
@@ -1017,12 +1017,12 @@ defineExpose({
padding: 10px 4px 0;
border-top: 1px solid $border-color;
- :deep(.n-input) {
+ :deep(.arco-input) {
flex: 1 1 auto;
min-width: 0;
}
- :deep(.n-button) {
+ :deep(.arco-btn) {
flex: 0 0 auto;
}
}
@@ -1200,7 +1200,7 @@ defineExpose({
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
- :deep(.n-button) {
+ :deep(.arco-btn) {
width: 100%;
}
}
@@ -1208,7 +1208,7 @@ defineExpose({
.clarify-float-input-row {
flex-direction: column;
- :deep(.n-button) {
+ :deep(.arco-btn) {
width: 100%;
}
}
diff --git a/packages/client/src/components/hermes/chat/SessionSearchModal.vue b/packages/client/src/components/hermes/chat/SessionSearchModal.vue
index fb0ecc6..cc9eb93 100644
--- a/packages/client/src/components/hermes/chat/SessionSearchModal.vue
+++ b/packages/client/src/components/hermes/chat/SessionSearchModal.vue
@@ -445,7 +445,7 @@ onUnmounted(() => {
}
@media (max-width: $breakpoint-mobile) {
- :deep(.n-modal-body-wrapper) {
+ :deep(.arco-modal) {
width: calc(100vw - 24px);
}
diff --git a/packages/client/src/components/hermes/chat/TerminalPanel.vue b/packages/client/src/components/hermes/chat/TerminalPanel.vue
index 660d55a..afdd85f 100644
--- a/packages/client/src/components/hermes/chat/TerminalPanel.vue
+++ b/packages/client/src/components/hermes/chat/TerminalPanel.vue
@@ -473,7 +473,7 @@ onUnmounted(() => {
>
diff --git a/packages/client/src/composables/useKeyboard.ts b/packages/client/src/composables/useKeyboard.ts
index 7f78987..bb77cd9 100644
--- a/packages/client/src/composables/useKeyboard.ts
+++ b/packages/client/src/composables/useKeyboard.ts
@@ -36,12 +36,14 @@ export function useKeyboard() {
closeSessionSearch()
return
}
- // Close any open modals — naive-ui handles this internally
- const modal = document.querySelector('.n-modal-mask')
- if (modal) {
- const closeBtn = modal.querySelector('.n-base-close') as HTMLElement
- closeBtn?.click()
- }
+ // 聊天页的弹窗已全部迁到 Arco(且都带 unmount-on-close),Esc 兜底要查
+ // Arco 的类名,否则模型选择、会话搜索这些弹窗在键盘下关不掉。
+ const openModal = Array.from(
+ document.querySelectorAll('.arco-modal'),
+ ).find((el) => el.offsetParent !== null)
+ openModal
+ ?.querySelector('.arco-modal-close-btn')
+ ?.click()
}
}
diff --git a/tests/client/session-search.test.ts b/tests/client/session-search.test.ts
index 2171aad..79b602b 100644
--- a/tests/client/session-search.test.ts
+++ b/tests/client/session-search.test.ts
@@ -209,4 +209,38 @@ describe('keyboard shortcut', () => {
expect(useSessionSearch().sessionSearchOpen.value).toBe(true)
})
+
+ // The chat page dialogs all moved to Arco in Phase 006, so the Esc fallback
+ // must look for Arco's close button, not naive-ui's.
+ it('closes an open Arco modal on Escape, and ignores naive-ui markup', () => {
+ const close = vi.fn()
+ const modal = document.createElement('div')
+ modal.className = 'arco-modal'
+ const closeBtn = document.createElement('div')
+ closeBtn.className = 'arco-modal-close-btn'
+ closeBtn.addEventListener('click', close)
+ modal.appendChild(closeBtn)
+ document.body.appendChild(modal)
+
+ const legacyModal = document.createElement('div')
+ legacyModal.className = 'n-modal n-modal-mask'
+ document.body.appendChild(legacyModal)
+
+ const Dummy = defineComponent({
+ setup() {
+ useKeyboard()
+ return () => h('div')
+ },
+ })
+ const wrapper = mount(Dummy)
+ // jsdom reports offsetParent as null, so make the modal look visible.
+ Object.defineProperty(modal, 'offsetParent', { value: document.body })
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
+ expect(close).toHaveBeenCalled()
+
+ wrapper.unmount()
+ modal.remove()
+ legacyModal.remove()
+ })
})