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:
creatixchu
2026-08-11 17:01:29 +08:00
parent 5d591e55c1
commit e611e825b1
56 changed files with 1366 additions and 251 deletions

View File

@@ -0,0 +1,82 @@
// @vitest-environment jsdom
// The conversation-side bridge to the ui-attachment atoms: dictionary strings
// flow through image-labels into the gallery, and assistant images keep their
// block position between text blocks.
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
const t = makeTranslate(zh, commonZh)
const enT = makeTranslate(en, commonZh)
const attachment = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
mediaType: 'image/png' as const,
bytes: 68,
width: 640,
height: 320,
name: 'history.png',
}
describe('assistant images through the label bridge', () => {
it('resolves zh dictionary strings and opens the lightbox on a single click', async () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'image', attachment }]}
streaming={false}
loadImage={() => Promise.resolve('blob:history')}
/>,
)
const frame = await view.findByRole('button', { name: 'history.png,点击查看原图' })
expect(frame.getAttribute('title')).toBe('查看原图')
await view.findByAltText('history.png')
fireEvent.click(frame)
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
})
it('resolves the active English dictionary', async () => {
const view = render(
<AssistantMarkdown
t={enT}
blocks={[{ kind: 'image', attachment }]}
streaming={false}
loadImage={() => Promise.resolve('blob:history')}
/>,
)
const frame = await view.findByRole('button', { name: 'history.png, click to view original' })
await view.findByAltText('history.png')
fireEvent.click(frame)
expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy()
expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy()
})
it('keeps assistant images at their original position between text blocks', async () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[
{ kind: 'text', text: 'before' },
{ kind: 'image', attachment },
{ kind: 'text', text: 'after' },
]}
streaming={false}
loadImage={() => Promise.resolve('blob:middle')}
/>,
)
const image = await view.findByAltText('history.png')
const before = view.getByText('before')
const after = view.getByText('after')
expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
})
})

View File

@@ -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', () => {

View File

@@ -1,81 +0,0 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { MessageImage } from '../src/client/chat/MessageImage.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
const t = makeTranslate(zh, commonZh)
const enT = makeTranslate(en, commonZh)
const attachment = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
mediaType: 'image/png' as const,
bytes: 68,
width: 640,
height: 320,
name: 'history.png',
}
describe('MessageImage', () => {
it('loads a session-authorized URL, bounds the thumbnail, and double-clicks into the original', async () => {
const load = vi.fn().mockResolvedValue('blob:history')
const view = render(<MessageImage attachment={attachment} load={load} t={t} />)
const frame = view.getByRole('button', { name: 'history.png,双击查看原图' })
expect(frame.getAttribute('style')).toContain('width: 240px')
expect(frame.getAttribute('style')).toContain('height: 120px')
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
expect(load).toHaveBeenCalledWith(attachment)
fireEvent.doubleClick(frame)
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
})
it('surfaces a retry control when durable bytes cannot be read', async () => {
const load = vi.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce('blob:retry')
const view = render(<MessageImage attachment={attachment} load={load} t={t} />)
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
fireEvent.click(retry)
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
expect(load).toHaveBeenCalledTimes(2)
})
it('renders image controls from the active English dictionary', async () => {
const load = vi.fn().mockResolvedValue('blob:history')
const view = render(<MessageImage attachment={attachment} load={load} t={enT} />)
const frame = view.getByRole('button', { name: 'history.png, double-click to view original' })
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
fireEvent.doubleClick(frame)
expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy()
expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy()
})
it('keeps assistant images at their original position between text blocks', async () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[
{ kind: 'text', text: 'before' },
{ kind: 'image', attachment },
{ kind: 'text', text: 'after' },
]}
streaming={false}
loadImage={() => Promise.resolve('blob:middle')}
/>,
)
const image = await view.findByAltText('history.png')
const before = view.getByText('before')
const after = view.getByText('after')
expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
})
})