From 63f88997bb796a492b02a322733c2e0c87fc0d5b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 14:36:03 +0800 Subject: [PATCH] fix(web): preserve compact icon until hover --- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/CompactionItem.tsx | 13 ++++++-- .../src/client/chat/MessageItem.module.css | 33 +++++++++++++++---- .../ui-conversation/tests/chat-view.spec.tsx | 3 ++ 6 files changed, 45 insertions(+), 12 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index ca3289ba55..d29623d0f2 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 5b37065097ef60c2edf14725f4e1e1c6a52c4366 -README.zh.md: 4ec26155a124497db0fc7f351d20ecb451a18763 +README.md: 985c78e97a8c7f46451252e67095c91e990aa694 +README.zh.md: 36a2f7a13c3f60692f9547ccf5a51ad6356d26d8 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 5b37065097..985c78e97a 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). -Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key, showing the replaced-item and estimated-token counts and disclosing the summary on click. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when summary provenance is outside the loaded window, the checkpoint remains visible but non-expandable. +Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key, showing the replaced-item and estimated-token counts and disclosing the summary on click. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when summary provenance is outside the loaded window, the checkpoint remains visible but non-expandable. The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 4ec26155a1..36a2f7a13c 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -4,7 +4,7 @@ 会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 -压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行,显示被替换条目数量和估算 token 数量,并可点击展开摘要。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;摘要溯源位于已加载窗口之外时,检查点仍然可见但不可展开。 +压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行,显示被替换条目数量和估算 token 数量,并可点击展开摘要。完成的检查点静止时保留上下文压缩图标,仅在悬停或键盘聚焦时将其替换为收起/展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;摘要溯源位于已加载窗口之外时,检查点仍然可见但不可展开。 常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 diff --git a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx index 7049688cc0..5e5f0c87b7 100644 --- a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx @@ -9,6 +9,7 @@ import { memo, useState } from 'react' import type { CompactionSummaryNode } from '@deepseek-ai/dsh-client-runtime/client' import { + IconApiOutline14, IconChevronDownOutline14, IconChevronRightOutline14, MarkdownText, @@ -56,8 +57,16 @@ export const CompactionItem = memo(function CompactionItem({ aria-expanded={expandable ? open : undefined} onClick={() => { setExpanded(value => !value) }} > - - {open ? : } + + + + + + {open ? : } + {title ?? t('message.compaction')} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 5c07ace71e..c6ca35bb2c 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -33,9 +33,9 @@ padding: 2px 0; } -/* Compaction marker: one dim 24px row with a chevron disclosure for the - summary body. Dimmed title (not label-primary) — the row is a boundary - notice, not conversation content. */ +/* Compaction marker: one dim 24px row with a context icon at rest and a + hover/focus disclosure for the summary body. Dimmed title (not + label-primary) — the row is a boundary notice, not conversation content. */ .compactionRow { padding: 2px 0; } @@ -65,15 +65,36 @@ .compactionLeading { flex: none; - display: inline-flex; - align-items: center; - justify-content: center; + display: inline-grid; + place-items: center; width: 16px; height: 16px; margin-right: 6px; color: var(--dsw-alias-label-secondary); } +.compactionContextIcon, +.compactionDisclosureIcon { + display: inline-flex; + grid-area: 1 / 1; + align-items: center; + justify-content: center; +} + +.compactionDisclosureIcon { + opacity: 0; +} + +.compactionButton:not(:disabled):hover .compactionContextIcon, +.compactionButton:not(:disabled):focus-visible .compactionContextIcon { + opacity: 0; +} + +.compactionButton:not(:disabled):hover .compactionDisclosureIcon, +.compactionButton:not(:disabled):focus-visible .compactionDisclosureIcon { + opacity: 1; +} + .compactionTitle { flex: none; font-size: 14px; diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index a0213a2407..b16a0b9317 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -1303,9 +1303,12 @@ describe('ChatView', () => { expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens)')).toBeTruthy() const row = view.getByRole('button', { name: /compact/ }) expect(row.getAttribute('aria-expanded')).toBe('false') + expect(row.querySelector('[data-compaction-icon="context"]')).not.toBeNull() + expect(row.querySelector('[data-compaction-disclosure="collapsed"]')).not.toBeNull() expect(view.queryByText('保留的事实。')).toBeNull() fireEvent.click(row) expect(row.getAttribute('aria-expanded')).toBe('true') + expect(row.querySelector('[data-compaction-disclosure="expanded"]')).not.toBeNull() expect(view.getByRole('heading', { name: '压缩摘要' })).toBeTruthy() })