From c3eeea4187be0d90965affe5435e711343f53aae Mon Sep 17 00:00:00 2001 From: Jack Date: Sun, 18 Oct 2026 03:11:35 +0800 Subject: [PATCH] fix(chat): repair Arco migration fallout in the chat page The Phase 006 walkthrough surfaced four issues the automated checks could not see. Dead naive-ui class names left behind by the migration stopped matching, tooltips that carried their label as trailing default-slot text leaked that text into the flex row, the workspace panel squeezed the chat column until the input toolbar wrapped, and the Escape fallback still looked for naive-ui's modal mask. Tooltips now pass their label through the content prop, the tool panel reserves a minimum width for the chat column, and Escape finds the visible Arco modal and clicks its close button. --- docs/arco-mapping.md | 11 +++ .../2026-10-17-phase006-arco-style-cleanup.md | 86 +++++++++++++++++++ .../src/components/hermes/chat/ChatInput.vue | 83 +++++------------- .../src/components/hermes/chat/ChatPanel.vue | 23 ++--- .../components/hermes/chat/MessageItem.vue | 2 +- .../components/hermes/chat/MessageList.vue | 8 +- .../hermes/chat/SessionSearchModal.vue | 2 +- .../components/hermes/chat/TerminalPanel.vue | 3 +- .../client/src/composables/useKeyboard.ts | 14 +-- tests/client/session-search.test.ts | 34 ++++++++ 10 files changed, 181 insertions(+), 85 deletions(-) create mode 100644 docs/chat-chain-changes/2026-10-17-phase006-arco-style-cleanup.md 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 {
- + - {{ t('chat.attachFiles') }} - + {{ compactModelLabel }} - {{ props.modelLabel || t('models.selectModel') }}
@@ -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() + }) })