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

@@ -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('')
})
})