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.
This commit is contained in:
@@ -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 用例卡住。
|
||||
|
||||
@@ -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
|
||||
(`<a-tooltip><a-button/></a-tooltip>{{ 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
|
||||
```
|
||||
@@ -1048,11 +1048,10 @@ function isImage(type: string): boolean {
|
||||
<div v-if="showContextUsage" class="context-usage-row">
|
||||
<span class="context-info" :class="{ 'context-warning': usagePercent > 80 }">
|
||||
{{ formatTokens(totalTokens) }} /
|
||||
<a-tooltip trigger="hover" :disabled="isMobileViewport">
|
||||
<a-tooltip trigger="hover" :disabled="isMobileViewport" :content="t('chat.contextClickToEdit')">
|
||||
<span class="context-limit-editable" @click="handleEditContextLimit">
|
||||
{{ formatTokens(contextLength) }}
|
||||
</span>
|
||||
<span>{{ t('chat.contextClickToEdit') }}</span>
|
||||
</a-tooltip>
|
||||
· {{ t('chat.contextRemaining') }} {{ formatTokens(remainingTokens) }}
|
||||
</span>
|
||||
@@ -1083,13 +1082,16 @@ function isImage(type: string): boolean {
|
||||
<div class="input-toolbar">
|
||||
<!-- Bottom bar: attach + input settings + actions -->
|
||||
<div class="input-top-bar">
|
||||
<a-tooltip trigger="hover" :disabled="isMobileViewport">
|
||||
<a-tooltip
|
||||
trigger="hover"
|
||||
:disabled="isMobileViewport"
|
||||
:content="t('chat.attachFiles')"
|
||||
>
|
||||
<a-button type="secondary" size="mini" shape="circle" @click="handleAttachClick" class="toolbar-icon-button">
|
||||
<template #icon>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
||||
</template>
|
||||
</a-button>
|
||||
{{ t('chat.attachFiles') }}
|
||||
</a-tooltip>
|
||||
|
||||
<a-dropdown
|
||||
@@ -1159,7 +1161,11 @@ function isImage(type: string): boolean {
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<a-tooltip trigger="hover" :disabled="isMobileViewport">
|
||||
<a-tooltip
|
||||
trigger="hover"
|
||||
:disabled="isMobileViewport"
|
||||
:content="props.modelLabel || t('models.selectModel')"
|
||||
>
|
||||
<a-button
|
||||
type="secondary"
|
||||
size="mini"
|
||||
@@ -1193,7 +1199,6 @@ function isImage(type: string): boolean {
|
||||
<span class="input-model-label">{{ compactModelLabel }}</span>
|
||||
<svg class="toolbar-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</a-button>
|
||||
{{ props.modelLabel || t('models.selectModel') }}
|
||||
</a-tooltip>
|
||||
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2112,7 +2112,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.n-drawer-body-content-wrapper) {
|
||||
:deep(.arco-drawer-body) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,7 +445,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
:deep(.n-modal-body-wrapper) {
|
||||
:deep(.arco-modal) {
|
||||
width: calc(100vw - 24px);
|
||||
}
|
||||
|
||||
|
||||
@@ -473,7 +473,7 @@ onUnmounted(() => {
|
||||
>
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title">{{ t("terminal.sessions") }}</span>
|
||||
<a-tooltip trigger="hover">
|
||||
<a-tooltip trigger="hover" :content="t('terminal.newTab')">
|
||||
<a-button type="secondary" size="mini" @click="createSession" shape="circle">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -482,7 +482,6 @@ onUnmounted(() => {
|
||||
</svg>
|
||||
</template>
|
||||
</a-button>
|
||||
{{ t("terminal.newTab") }}
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div class="session-list">
|
||||
|
||||
@@ -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<HTMLElement>('.arco-modal'),
|
||||
).find((el) => el.offsetParent !== null)
|
||||
openModal
|
||||
?.querySelector<HTMLElement>('.arco-modal-close-btn')
|
||||
?.click()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user