feat(web): align attachment display with DeepSeek Chat via ui-attachment atoms
Single-click original preview in the composer rail and chat history; remove control inside the thumbnail, revealed on hover/focus (always on touch); hidden-scrollbar rail overflow paged by edge arrows with wheel panning and end-reveal on add; image-intake rejections and prompt failures announce as a transient top-center toast instead of inline strips. The attachment atoms move to a new zero-cordis package @deepseek-ai/dsh-client-ui-attachment (rail, message gallery, lightbox), seeded as a platform module; the toast is a ui-primitives atom. Strings arrive as label props bridged from the conversation dictionary.
This commit is contained in:
@@ -237,15 +237,49 @@ describe('image draft rail', () => {
|
||||
expect(removeImage).toHaveBeenCalledWith('draft-1')
|
||||
})
|
||||
|
||||
it('opens the original image on double-click and closes it with Escape', () => {
|
||||
it('opens the original image on a single click and closes it with Escape', () => {
|
||||
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
|
||||
const { view } = bench({ attachments: [attachment] })
|
||||
fireEvent.doubleClick(view.getByTitle('双击查看原图'))
|
||||
fireEvent.click(view.getByTitle('查看原图'))
|
||||
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
|
||||
})
|
||||
|
||||
it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const addImages = vi.fn(() => '不支持的图片格式:text/plain')
|
||||
const { view, textarea } = bench({ addImages })
|
||||
const paste = () => {
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'note.txt', { type: 'text/plain' }) }],
|
||||
getData: () => '',
|
||||
},
|
||||
})
|
||||
}
|
||||
paste()
|
||||
expect(view.getByRole('alert').textContent).toContain('不支持的图片格式:text/plain')
|
||||
act(() => { vi.advanceTimersByTime(4000) })
|
||||
expect(view.queryByRole('alert')).toBeNull()
|
||||
// The identical rejection re-announces: the toast is keyed per show.
|
||||
paste()
|
||||
expect(view.getByRole('alert').textContent).toContain('不支持的图片格式:text/plain')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('announces a rejected drop through the same toast', () => {
|
||||
const addImages = vi.fn(() => '图片读取服务不可用')
|
||||
const { view } = bench({ addImages })
|
||||
const card = view.container.querySelector('[class*="card"]')!
|
||||
const dataTransfer = { types: ['Files'], files: [new File([Uint8Array.of(1)], 'x.png', { type: 'image/png' })], dropEffect: 'none' }
|
||||
fireEvent.drop(card, { dataTransfer })
|
||||
expect(view.getByRole('alert').textContent).toContain('图片读取服务不可用')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Enter semantics', () => {
|
||||
@@ -941,10 +975,17 @@ describe('insertText (scoped event body)', () => {
|
||||
})
|
||||
|
||||
describe('strips and variants', () => {
|
||||
it('derives the failure strip from promptError (ordinary failure — no transaction UI, no Retry)', () => {
|
||||
const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } })
|
||||
expect(send.view.container.querySelector('[role="alert"]')?.textContent).toBe('boom (agent-busy)')
|
||||
expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull()
|
||||
it('announces promptError as a fading toast (ordinary failure — no transaction UI, no Retry)', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } })
|
||||
expect(send.view.container.querySelector('[role="alert"]')?.textContent).toContain('boom (agent-busy)')
|
||||
expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull()
|
||||
act(() => { vi.advanceTimersByTime(4000) })
|
||||
expect(send.view.container.querySelector('[role="alert"]')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the notice strip from the machine notice store', () => {
|
||||
|
||||
Reference in New Issue
Block a user