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:
Yichen Jiang
2026-08-11 14:09:04 +08:00
parent a40155ad23
commit 9f5eb5da8a
14 changed files with 251 additions and 26 deletions

View File

@@ -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);

View File

@@ -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>}

View File

@@ -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: '取消',