mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(web): clamp preset card descriptions instead of sizing the roster
A preset publishes its own description, of any length, and `.cards` sizes rows with `grid-auto-rows: 1fr` — which makes every implicit row the same height, not just the row holding the tall card. One long description therefore set the height of the whole roster. The description now clamps to four lines and offers the rest through the shared Tooltip, attached only while the element actually overflows. Card height stays derived: with the description bounded, `grid-auto-rows: 1fr` already equalizes, and a card carrying the broken-preset reason or a revealed path still sizes itself. Tooltip gains an optional `maxWidth`; its default half-viewport cap renders a description wider than the settings dialog it belongs to. The failed-shape-check badge reads "Failed to load" rather than "Broken": discovery reports a composition that is missing, unreadable, or malformed, which overstates as damage. Fixes #2238
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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 .agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.md
|
||||
2026-08-11-preset-card-description-clamp.md: cd16080f10d5e4b0286babb9476c926993ee3ffa
|
||||
2026-08-11-preset-card-description-clamp.zh.md: cb251f6b0c5b1268e210d01c44738de5a61ff2b1
|
||||
@@ -0,0 +1,42 @@
|
||||
# Agent Note: Preset cards clamp their description instead of sizing the roster
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-11-preset-card-description-clamp.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A preset publishes its own `description`, of any length, and the settings section renders the roster as a card grid. The description had a `min-height` and no upper bound, while the grid sizes rows with `grid-auto-rows: 1fr` — which makes every implicit row the same height, not just the row holding the tall card. One long description therefore set the height of the whole roster: with a 250-character description in the custom group, all four cards measured 421px and the short-description cards filled with blank space.
|
||||
|
||||
The description is also the field that tells presets apart, so hiding it is not an option; the card has to bound it and still make the whole text reachable.
|
||||
|
||||
## Decision
|
||||
|
||||
The description clamps to four lines and offers the rest through the shared `Tooltip`, attached only while the element actually overflows (`scrollHeight > clientHeight`, re-measured through a ResizeObserver because the settings pane width follows the window). This mirrors the chat stats line, which clamps to one line on the same measure-then-attach rule.
|
||||
|
||||
Card height stays derived rather than fixed. With the description bounded, `grid-auto-rows: 1fr` already equalizes the grid, and a card carrying the broken-preset reason or a revealed path still sizes itself — a pixel height would clip both.
|
||||
|
||||
Three smaller decisions ride along:
|
||||
|
||||
- `.cardId` takes the card's free space with `margin-top: auto`, and the description no longer grows. A flex-stretched box leaves the clamp height and the box height disagreeing; sizing the clamped box by content alone keeps the behavior independent of that interaction.
|
||||
- The description carries `title=""`. An empty `title` means the element has no advisory information and the lookup stops there, so the card body's native tooltip does not climb to the description and a cut-off description answers with one bubble instead of two.
|
||||
- `Tooltip` gains an optional `maxWidth`. Its default half-viewport cap renders a description as a slab wider than the settings dialog it belongs to, spilling across the application behind it.
|
||||
|
||||
A roster row that failed its shape check is badged `Failed to load` (`加载失败`) rather than `Broken` (`已损坏`). Discovery sets `broken` when the composition file is missing, unreadable, or malformed — most often a file the user just edited or deleted — so a damage claim overstates what was observed, and the verbatim reason under the badge already names the file and the fix.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **A fixed card height.** It states the intent directly but clips the two rows whose height legitimately varies: the broken-preset reason and the revealed preset directory.
|
||||
- **The native `title` attribute carrying the full description.** No measurement and no component, but a roughly one-second delay, operating-system styling, and it takes over the card's `set as default` hint across most of the card's area.
|
||||
- **Attaching the tooltip unconditionally.** It drops the ResizeObserver, at the cost of answering a hover over a short description with a bubble repeating what is already on the card.
|
||||
- **Expanding the clamp on hover.** It shows the text in place, and moves the grid under the pointer.
|
||||
|
||||
## Consequences
|
||||
|
||||
The section owns a small measured component and the shared primitive owns one more optional prop. In exchange, no card's height follows the longest description anywhere in the roster, and the whole description stays in the accessibility tree because the clamp is CSS rather than truncated text.
|
||||
|
||||
The `title=""` suppression is pinned by a DOM assertion, not by observing the native tooltip: a browser tooltip is drawn outside the page and cannot be captured. If a browser ever resumes climbing past an empty `title`, the fallback is to drop the card body's `title` — its content is already in the body's `aria-label`.
|
||||
|
||||
## Testing
|
||||
|
||||
Package tests cover the three measurement outcomes (cut off, fitting, and a runtime without `ResizeObserver`) and the tooltip width cap. The web e2e goldens replay unchanged except `damaged.expected.md`, re-recorded for the badge copy.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Agent Note: 预设卡片截断自身描述,而不是由描述决定整份名单的高度
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-11-preset-card-description-clamp.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
preset 自行发布 `description`,长度不限,而设置分区把名单渲染为卡片网格。描述只有 `min-height` 没有上限,网格则以 `grid-auto-rows: 1fr` 排布行——该取值让每一个隐式行等高,而不只是承载高卡片的那一行。因此一条长描述决定了整份名单的高度:自定义组里放入一条 250 字的描述后,四张卡片全部量得 421px,短描述卡片被大片空白填满。
|
||||
|
||||
描述同时又是区分各个 preset 的字段,因此不能藏起来;卡片必须既给它设上限,又让全文仍然可达。
|
||||
|
||||
## 决定
|
||||
|
||||
描述截断为四行,其余内容通过共享的 `Tooltip` 呈现,且仅在元素确实溢出时才挂载(`scrollHeight > clientHeight`,并经 ResizeObserver 重新测量,因为设置面板宽度跟随窗口)。这与聊天统计行一致:它按同样的「先测量再挂载」规则截断为一行。
|
||||
|
||||
卡片高度仍是推导得出而非固定。描述有了上限之后,`grid-auto-rows: 1fr` 本身就让网格等高,而承载损坏原因或已展示目录的卡片仍能按自身内容定高——写死像素高度会把两者一并裁掉。
|
||||
|
||||
随之而来三个更小的决定:
|
||||
|
||||
- `.cardId` 以 `margin-top: auto` 吃掉卡片的空余空间,描述不再拉伸。被 flex 拉伸的盒子会让截断高度与盒子高度不一致;让截断盒子只按内容定高,行为便不依赖这层交互。
|
||||
- 描述带有 `title=""`。空 `title` 表示该元素没有提示信息,查找就此停止,因此卡片主体的原生 tooltip 不会向上找到描述,被裁切的描述只回应一个气泡而不是两个。
|
||||
- `Tooltip` 新增可选的 `maxWidth`。它默认的半视口上限会把描述渲染成比所属设置弹窗还宽的一整块,溢出到背后的应用界面上。
|
||||
|
||||
形状检查未通过的名单行,徽记从 `Broken`(`已损坏`)改为 `Failed to load`(`加载失败`)。discovery 在组装文件缺失、读不出或格式错误时置位 `broken`——最常见的是用户刚编辑或删除的文件——因此断言损坏超出了观察到的事实,而徽记下方原样展示的原因本就点名了文件与修法。
|
||||
|
||||
## 备选方案
|
||||
|
||||
- **写死卡片高度。** 它直接表达了意图,却会裁掉两处高度本就可变的行:损坏预设的原因行和已展示的预设目录。
|
||||
- **用原生 `title` 属性承载完整描述。** 无需测量也无需组件,代价是约一秒的延迟、操作系统的样式,以及在卡片大部分区域内顶替掉「设为默认」的提示。
|
||||
- **无条件挂载 tooltip。** 省掉 ResizeObserver,代价是把鼠标停在短描述上时,弹出一个重复卡片已有内容的气泡。
|
||||
- **hover 时展开截断。** 它就地展示文本,同时让网格在指针下方发生位移。
|
||||
|
||||
## 后果
|
||||
|
||||
分区多了一个带测量的小组件,共享基元多了一个可选 prop。换来的是:任何卡片的高度都不再跟随名单中最长的那条描述;而且截断由 CSS 完成而非截短文本,完整描述始终留在无障碍树中。
|
||||
|
||||
`title=""` 的抑制作用由一条 DOM 断言钉住,而非通过观察原生 tooltip:浏览器 tooltip 画在页面之外,无法被捕获。若某个浏览器日后重新越过空 `title` 继续向上查找,退路是去掉卡片主体的 `title`——它的内容已经在主体的 `aria-label` 里。
|
||||
|
||||
## 测试
|
||||
|
||||
包内测试覆盖三种测量结果(被裁切、放得下、运行时没有 `ResizeObserver`)以及 tooltip 的宽度上限。web e2e golden 除 `damaged.expected.md` 按徽记文案重录外,其余原样回放通过。
|
||||
@@ -196,18 +196,18 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
|
||||
const dialog = settingsDialog()
|
||||
await dialog.getByRole('button', { name: '通用设置' }).click()
|
||||
await dialog.getByRole('button', { name: 'Agent 预设' }).click()
|
||||
await dialog.getByText('已损坏').first().waitFor({ timeout: 10_000 })
|
||||
await dialog.getByText('加载失败').first().waitFor({ timeout: 10_000 })
|
||||
|
||||
const snapshot = withPresetRoot(
|
||||
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
|
||||
await compareOrRefreshGolden(DAMAGED_EXPECTED, snapshot, MODE)
|
||||
// Both damage shapes surface as marked, unselectable, uncopyable cards
|
||||
// that still carry their metadata and the discovery-reported reason.
|
||||
expect(snapshot).toContain('已损坏: broken-yaml')
|
||||
expect(snapshot).toContain('已损坏: 幽灵预设')
|
||||
expect(snapshot).toContain('加载失败: broken-yaml')
|
||||
expect(snapshot).toContain('加载失败: 幽灵预设')
|
||||
expect(snapshot).toContain('not valid YAML')
|
||||
expect(snapshot).toContain('agent.cordis.yml is missing')
|
||||
expect(await dialog.getByRole('button', { name: '已损坏: broken-yaml' }).isDisabled()).toBe(true)
|
||||
expect(await dialog.getByRole('button', { name: '加载失败: broken-yaml' }).isDisabled()).toBe(true)
|
||||
expect(await dialog.getByRole('button', { name: '复制: 幽灵预设' }).isDisabled()).toBe(true)
|
||||
// A broken card offers no "set default" affordance at all — the aria name
|
||||
// IS the broken marking, so the picking name must not exist.
|
||||
|
||||
@@ -61,8 +61,8 @@
|
||||
- heading "自定义" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "已损坏: broken-yaml" [disabled]':
|
||||
- text: broken-yaml 已损坏 自定义 暂无描述。
|
||||
- 'button "加载失败: broken-yaml" [disabled]':
|
||||
- text: broken-yaml 加载失败 自定义 暂无描述。
|
||||
- alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)"
|
||||
- code: broken-yaml
|
||||
- 'button "查看路径: broken-yaml"':
|
||||
@@ -70,13 +70,13 @@
|
||||
- text: 查看路径
|
||||
- 'button "复制: broken-yaml" [disabled]':
|
||||
- img
|
||||
- text: 预设已损坏,无法复制
|
||||
- text: 预设加载失败,不能复制
|
||||
- 'button "删除: broken-yaml"':
|
||||
- img
|
||||
- text: 删除
|
||||
- listitem:
|
||||
- 'button "已损坏: 幽灵预设" [disabled]':
|
||||
- text: 幽灵预设 已损坏 自定义 composition 已被手动删除。
|
||||
- 'button "加载失败: 幽灵预设" [disabled]':
|
||||
- text: 幽灵预设 加载失败 自定义 composition 已被手动删除。
|
||||
- alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file
|
||||
- code: ghost
|
||||
- 'button "查看路径: 幽灵预设"':
|
||||
@@ -84,7 +84,7 @@
|
||||
- text: 查看路径
|
||||
- 'button "复制: 幽灵预设" [disabled]':
|
||||
- img
|
||||
- text: 预设已损坏,无法复制
|
||||
- text: 预设加载失败,不能复制
|
||||
- 'button "删除: 幽灵预设"':
|
||||
- img
|
||||
- text: 删除
|
||||
|
||||
@@ -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-agent-preset/README.md
|
||||
README.md: 008066114e9c49e5c74299979e24c27a4c9621c9
|
||||
README.zh.md: e07d5994ae196cd03be7818fe4ade1aafda9aa55
|
||||
README.md: 3b0db5a3eedca256a00b65a3bd2738f22c0eb62e
|
||||
README.zh.md: 6f3c350f973119c201572f2c03145338b5cc5b00
|
||||
|
||||
@@ -36,6 +36,8 @@ A fourth surface, its own settings page (`settings.section` id `agent-presets`,
|
||||
|
||||
The browser edits no composition text. Editing YAML in a web textarea was a weak surface (no completion, no highlighting, no diff), so a new preset is a host-side copy of an existing one — the dialog collects an id (it becomes the directory name, which is why it must be named up front and cannot change later) and an optional display name, and `{ from, id, name? }` is all that crosses the wire. Everything else — description, composition, skills — is edited in the preset's own files, and the page's other job is getting the user TO those files: the copy completes by opening the new directory, and every custom row keeps a location action. Where the host has no desktop opener (`hasDocument: false` on the roster; remote and container deployments), the same actions answer the directory as text on the row instead of offering a button that would spawn into nothing.
|
||||
|
||||
A preset publishes its own description, of any length, and the grid sizes every card row alike — so an unbounded description would set the height of the whole roster. Cards clamp it to four lines and offer the rest in a tooltip, attached only while the text is actually cut off. The clamp is CSS, so the whole description stays in the accessibility tree whatever the card shows.
|
||||
|
||||
A shipped preset opens in the read-only viewer. It is the known-good composition a copy starts from, so reading it is the point; it offers no location and no delete — its install is overwritten by upgrades and is not the user's to manage. The intro carries the guidance a create button used to imply: duplicate an existing preset and make it yours, or let the agent draft one in Creator mode.
|
||||
|
||||
Beside copying sits the conversational entry: when the roster carries the self-referential `cordis` preset, a dashed add-card (the Models page's affordance) stages it and starts a new session — the section closes the settings panel through the shell's owner-prop `close` and the new-session chip's own applier composes the blank session the workspace flow produces. The seat keeps a late roster load from regressing the display: staged pick first, then the composition the current session already carries, then the deployment default.
|
||||
@@ -44,7 +46,7 @@ The dialog mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and re
|
||||
|
||||
Deleting removes the preset directory. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file.
|
||||
|
||||
A roster row carrying `broken` (the host's shape check found the composition missing or unloadable) renders as a marked card: red border, a Broken badge, the reason verbatim, the body disabled — it cannot become the default — and duplication disabled, since a copy of a broken preset is another broken preset. A broken custom row keeps its location and delete actions, because the files are where it gets fixed and deleting is how a ghost directory (composition deleted by hand, directory still blocking the id) is cleared; a broken shipped row withholds the viewer too — there is no readable composition to show. The two pickers (the General row and the new-session chip) drop broken presets entirely: they choose the NEXT session's composition, and offering one that cannot compose would only defer the failure to the session start.
|
||||
A roster row carrying `broken` (the host's shape check found the composition missing or unloadable) renders as a marked card: red border, a "Failed to load" badge (what discovery observed, not a claim that the files are damaged — the usual cause is a composition the user just edited or deleted), the reason verbatim, the body disabled — it cannot become the default — and duplication disabled, since a copy of a broken preset is another broken preset. A broken custom row keeps its location and delete actions, because the files are where it gets fixed and deleting is how a ghost directory (composition deleted by hand, directory still blocking the id) is cleared; a broken shipped row withholds the viewer too — there is no readable composition to show. The two pickers (the General row and the new-session chip) drop broken presets entirely: they choose the NEXT session's composition, and offering one that cannot compose would only defer the failure to the session start.
|
||||
|
||||
Setting the default writes the `agent-presets` settings namespace, which the host exposes to configuration clients ([`dsh-apiproxy`](../../host/apiproxy/README.md) keeps an explicit allowlist — a namespace outside it makes a picker move and then silently forget).
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ preset 文件提供一套未国际化的 `name` 与 `description`,Web 将其
|
||||
|
||||
浏览器不再编辑任何组装文本。在网页文本域里编 YAML 是弱功能(无补全、无高亮、无 diff),因此新 preset 是宿主端对既有 preset 的一次复制——对话框只收集一个 id(它将成为目录名,所以必须当场取好、事后无法更改)与一个可选显示名,跨越传输层的只有 `{ from, id, name? }`。其余一切——描述、组装、skills——都在 preset 自己的文件里编辑,而本页的另一职责正是把用户送到那些文件面前:复制以打开新目录作为收尾,每张自定义卡片也保有一个位置操作。宿主没有桌面打开器时(名单上的 `hasDocument: false`;远程与容器部署),同样的操作改为把目录以文本显示在卡片上,而不是提供一个点了没反应的按钮。
|
||||
|
||||
preset 自行发布描述,长度不限,而网格让每一行卡片等高——因此不加约束的描述会决定整份名单的高度。卡片把描述截断为四行,其余内容由 tooltip 承载,且仅在文本确实被裁切时才挂载。截断由 CSS 完成,因此无论卡片显示多少,完整描述始终留在无障碍树中。
|
||||
|
||||
随附 preset 在只读查看器中打开。它是副本据以出发的已知良好组装,因此能读到它正是意义所在;它不提供位置也不提供删除——它的安装目录会被升级覆盖,不归用户管理。开篇引导语承担了从前创建按钮所暗示的信息:复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
|
||||
|
||||
复制旁边是对话式入口:名单携带自指的 `cordis` preset 时,一张虚线添加卡(模型页的同款样式)会暂存它并开启新会话——分区经外壳的 owner-prop `close` 关闭设置面板,新会话 chip 自己的应用器负责组装工作区流程产出的空白会话。seat 会防止晚到的名单加载回退显示:暂存选择优先,其次是当前会话已携带的组装,最后才是部署默认值。
|
||||
@@ -44,7 +46,7 @@ preset 文件提供一套未国际化的 `name` 与 `description`,Web 将其
|
||||
|
||||
删除会移除整个 preset 目录。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。
|
||||
|
||||
名单行携带 `broken`(宿主的形状检查发现组装缺失或不可加载)时渲染为标记卡片:红色边框、「已损坏」徽记、原样展示的原因、卡片主体禁用——它不能成为默认——复制也禁用,因为损坏 preset 的副本只是又一个损坏的 preset。损坏的自定义行保留位置与删除动作:文件正是修复它的地方,而删除正是清掉幽灵目录(组装文件被手动删除、目录仍占着 id)的方式;损坏的内置行连查看器也不提供——没有可读的组装可展示。两个选择器(通用设置行与新会话 chip)则完全不列出损坏的 preset:它们选的是下一个会话的组装,列出无法组装的选项只会把失败推迟到会话启动。
|
||||
名单行携带 `broken`(宿主的形状检查发现组装缺失或不可加载)时渲染为标记卡片:红色边框、「加载失败」徽记(discovery 观察到的事实,而非断言文件已损坏——常见起因是用户刚编辑或删除了组装文件)、原样展示的原因、卡片主体禁用——它不能成为默认——复制也禁用,因为损坏 preset 的副本只是又一个损坏的 preset。损坏的自定义行保留位置与删除动作:文件正是修复它的地方,而删除正是清掉幽灵目录(组装文件被手动删除、目录仍占着 id)的方式;损坏的内置行连查看器也不提供——没有可读的组装可展示。两个选择器(通用设置行与新会话 chip)则完全不列出损坏的 preset:它们选的是下一个会话的组装,列出无法组装的选项只会把失败推迟到会话启动。
|
||||
|
||||
设置默认值写入的是 `agent-presets` settings 命名空间,宿主需将其暴露给配置客户端([`dsh-apiproxy`](../../host/apiproxy/README.md) 维护一份显式白名单——不在其中的命名空间会让选择器动一下然后悄悄忘记)。
|
||||
|
||||
|
||||
@@ -161,15 +161,28 @@
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
/* Bounded to four lines. A preset publishes its own description, so one long
|
||||
one would otherwise stretch every card in its grid row (`.cards` sizes rows
|
||||
1fr). Clamping is CSS alone: the whole text stays in the DOM for assistive
|
||||
tech, and the card offers it on hover when it is actually cut off. The
|
||||
description does not grow to fill the card — `-webkit-line-clamp` on a
|
||||
flex-stretched box leaves the clamp height and the box height disagreeing,
|
||||
so `.cardId` takes the free space with an auto margin instead. */
|
||||
.cardDesc {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
flex: 1;
|
||||
min-height: 42px;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
overflow: hidden;
|
||||
/* A user-authored description may carry an unbreakable path or URL. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cardId {
|
||||
margin-top: auto;
|
||||
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
* mounted once at session creation and nothing re-reads the file.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal,
|
||||
Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -137,6 +137,39 @@ function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one card's description, clamped by CSS and offered in full on hover.
|
||||
* The tooltip is attached only while the text is actually cut off, so a short
|
||||
* description does not answer a hover with a bubble repeating the card.
|
||||
* @param props.text - the description as rendered, already localized.
|
||||
* @returns the description element, tooltip-anchored while it overflows.
|
||||
*/
|
||||
function CardDescription({ text }: { text: string }): ReactNode {
|
||||
const ref = useRef<HTMLSpanElement | null>(null)
|
||||
const [truncated, setTruncated] = useState(false)
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current
|
||||
/* v8 ignore next -- the ref is attached before layout effects run. */
|
||||
if (el === null) return
|
||||
const measure = () => { setTruncated(el.scrollHeight > el.clientHeight) }
|
||||
measure()
|
||||
// Card width follows the settings pane, which resizes with the window.
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(el)
|
||||
return () => { observer.disconnect() }
|
||||
}, [text])
|
||||
return (
|
||||
// Capped near the card's own width: the default half-viewport bubble would
|
||||
// spill a description out of the settings dialog and across the app behind it.
|
||||
<Tooltip label={text} side="bottom" delayMs={400} disabled={!truncated} maxWidth={360}>
|
||||
{/* The empty title stops the card body's native tooltip from climbing to
|
||||
this span: a cut-off description answers with one bubble, not two. */}
|
||||
<span ref={ref} className={css.cardDesc} title="">{text}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Agent presets section content column.
|
||||
* @param props - composed slot props.
|
||||
@@ -247,7 +280,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
</span>
|
||||
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
|
||||
</span>
|
||||
<span className={css.cardDesc}>{text.description ?? t('noDescription')}</span>
|
||||
<CardDescription text={text.description ?? t('noDescription')} />
|
||||
{row.broken === undefined
|
||||
? null
|
||||
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
|
||||
|
||||
@@ -57,8 +57,8 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
builtInGroup: 'Built-in',
|
||||
customGroup: 'Custom',
|
||||
noDescription: 'No description.',
|
||||
brokenBadge: 'Broken',
|
||||
brokenNoCopy: 'Broken presets cannot be duplicated',
|
||||
brokenBadge: 'Failed to load',
|
||||
brokenNoCopy: 'A preset that failed to load cannot be duplicated',
|
||||
copyOf: 'Copied from',
|
||||
composition: 'Composition (agent.cordis.yml)',
|
||||
cancel: 'Cancel',
|
||||
@@ -117,8 +117,8 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
builtInGroup: '内置',
|
||||
customGroup: '自定义',
|
||||
noDescription: '暂无描述。',
|
||||
brokenBadge: '已损坏',
|
||||
brokenNoCopy: '预设已损坏,无法复制',
|
||||
brokenBadge: '加载失败',
|
||||
brokenNoCopy: '预设加载失败,不能复制',
|
||||
copyOf: '复制自',
|
||||
composition: '组装(agent.cordis.yml)',
|
||||
cancel: '取消',
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* action follows the host's desktop capability.
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx'
|
||||
@@ -452,3 +452,68 @@ describe('deleting a preset', () => {
|
||||
expect(actions.remove).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a long card description', () => {
|
||||
/** jsdom has no ResizeObserver; the description watches its own box through one. */
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
const LONG = '始终用简体中文交流的友好通用助手,提供持久 bash 与文件编辑能力。'.repeat(8)
|
||||
|
||||
/** Force the clamp to report an overflow: jsdom lays nothing out, so both heights are 0. */
|
||||
function clamp(overflowing: boolean): void {
|
||||
vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(overflowing ? 400 : 80)
|
||||
vi.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValue(80)
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) })
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('offers the whole description on hover once the card cuts it off', () => {
|
||||
clamp(true)
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, name: '中文助手', description: LONG }] })
|
||||
|
||||
fireEvent.mouseEnter(within(rowFor('zh')).getByText(LONG))
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
|
||||
expect(screen.getByRole('tooltip').textContent).toBe(LONG)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('stays quiet when the description already fits', () => {
|
||||
clamp(false)
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, name: '中文助手', description: '短描述。' }] })
|
||||
|
||||
fireEvent.mouseEnter(within(rowFor('zh')).getByText('短描述。'))
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
|
||||
// A bubble repeating what is already fully on the card is noise.
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders where the runtime has no ResizeObserver', () => {
|
||||
vi.unstubAllGlobals()
|
||||
clamp(true)
|
||||
|
||||
expect(() => {
|
||||
renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, description: LONG }] })
|
||||
}).not.toThrow()
|
||||
// The first measurement does not depend on the observer.
|
||||
expect(within(rowFor('zh')).getByText(LONG).getAttribute('title')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,10 +33,12 @@ type TooltipLabel = string | (() => string)
|
||||
* @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate.
|
||||
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
|
||||
* toggling never remounts it (which would cut its CSS transitions).
|
||||
* @param props.maxWidth - bubble width cap in pixels, for labels long enough that the default
|
||||
* half-viewport cap would render a slab wider than the surface the anchor sits on.
|
||||
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
|
||||
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
|
||||
*/
|
||||
export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement<AnchorProps> }) {
|
||||
export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; maxWidth?: number; children: ReactElement<AnchorProps> }) {
|
||||
const anchor = useRef<HTMLElement | null>(null)
|
||||
// React 18 keeps the element's ref outside props; forward it so wrapping an
|
||||
// anchor in Tooltip never silently severs the owner's ref.
|
||||
@@ -132,7 +134,13 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
||||
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
|
||||
})}
|
||||
{pos !== null && (
|
||||
<span ref={bubble} className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">
|
||||
<span
|
||||
ref={bubble}
|
||||
className={css.bubble}
|
||||
data-side={side}
|
||||
style={{ left: pos.x, top: pos.y, ...maxWidth === undefined ? {} : { maxWidth } }}
|
||||
role="tooltip"
|
||||
>
|
||||
{resolvedLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -95,6 +95,18 @@ describe('Tooltip', () => {
|
||||
const rect = (left: number, right: number): DOMRect =>
|
||||
({ left, right, top: 0, bottom: 20, width: right - left, height: 20, x: left, y: 0, toJSON: () => ({}) })
|
||||
|
||||
it('caps the bubble width where the label would otherwise slab across the surface', () => {
|
||||
render(
|
||||
<Tooltip label="A description long enough to need a cap" side="bottom" maxWidth={360}>
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||
|
||||
// The stylesheet's half-viewport cap stays the default; this one overrides it.
|
||||
expect(screen.getByRole('tooltip').style.maxWidth).toBe('360px')
|
||||
})
|
||||
|
||||
it('clamps a bubble overflowing the right viewport edge back inside', () => {
|
||||
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(900, 1100))
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user