fix(gui): harden multimodal image attachments
This commit is contained in:
@@ -74,6 +74,7 @@ async function bench() {
|
||||
manager: { get: () => sessionFake },
|
||||
scope: (id: SessionId) => mint(id),
|
||||
cell: () => undefined,
|
||||
hostDescription: () => undefined,
|
||||
create: vi.fn(() => Promise.resolve(ROOT)),
|
||||
open: vi.fn(),
|
||||
}
|
||||
@@ -200,12 +201,17 @@ describe('details and empty inject surfaces', () => {
|
||||
expect(details).toBe(conv)
|
||||
})
|
||||
|
||||
it('empty injects the startSession chain only (no store, cwds derive in-component)', async () => {
|
||||
it('empty injects draft-image lifecycle and the startSession chain without a store', async () => {
|
||||
const b = await bench()
|
||||
const entry = b.entryOf('conversation.empty')
|
||||
expect(entry.store).toBeUndefined()
|
||||
const injected = (entry.inject as unknown as () => EmptyStateInjected)()
|
||||
expect(Object.keys(injected)).toEqual(['startSession'])
|
||||
expect(Object.keys(injected)).toEqual([
|
||||
'createDraftImages',
|
||||
'releaseDraftImage',
|
||||
'releaseDraftImages',
|
||||
'startSession',
|
||||
])
|
||||
await injected.startSession({ text: 'go', mode: 'queue' })
|
||||
expect(b.sessionsFake.create).toHaveBeenCalled()
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
|
||||
@@ -132,8 +132,9 @@ describe('error strip and variants', () => {
|
||||
|
||||
describe('image draft rail', () => {
|
||||
it('collects supported clipboard images and leaves non-image clipboard data to the browser', () => {
|
||||
const onAddImages = vi.fn()
|
||||
const { textarea } = setup({ draft: '', onAddImages })
|
||||
const onAddImages = vi.fn((files: readonly File[]) =>
|
||||
files.some(file => file.type === 'video/mp4') ? '不支持的图片格式:video/mp4' : null)
|
||||
const { view, textarea } = setup({ draft: '', onAddImages })
|
||||
const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
|
||||
const prevented = fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
@@ -141,19 +142,25 @@ describe('image draft rail', () => {
|
||||
{ kind: 'string', type: 'text/plain', getAsFile: () => null },
|
||||
{ kind: 'file', type: 'image/png', getAsFile: () => image },
|
||||
],
|
||||
getData: () => '同时粘贴的文字',
|
||||
},
|
||||
})
|
||||
expect(prevented).toBe(false)
|
||||
expect(prevented).toBe(true)
|
||||
expect(onAddImages).toHaveBeenCalledWith([image])
|
||||
|
||||
const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' })
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: { items: [{ kind: 'file', type: 'video/mp4', getAsFile: () => image }] },
|
||||
clipboardData: {
|
||||
items: [{ kind: 'file', type: 'video/mp4', getAsFile: () => video }],
|
||||
getData: () => '',
|
||||
},
|
||||
})
|
||||
expect(onAddImages).toHaveBeenCalledTimes(1)
|
||||
expect(onAddImages).toHaveBeenCalledTimes(2)
|
||||
expect(view.getByText(/不支持的图片格式/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('accepts supported image drops, highlights the target, and prevents browser navigation', () => {
|
||||
const onAddImages = vi.fn()
|
||||
const onAddImages = vi.fn(() => null)
|
||||
const { view } = setup({ draft: '', onAddImages })
|
||||
const card = view.container.querySelector('[class*="card"]')!
|
||||
const image = new File([Uint8Array.of(1, 2, 3)], 'dropped.png', { type: 'image/png' })
|
||||
@@ -172,15 +179,16 @@ describe('image draft rail', () => {
|
||||
})
|
||||
|
||||
it('ignores unsupported dropped files and refuses drops while locked', () => {
|
||||
const onAddImages = vi.fn()
|
||||
const onAddImages = vi.fn((files: readonly File[]) =>
|
||||
files.some(file => file.type === 'text/plain') ? '不支持的图片格式:text/plain' : null)
|
||||
const { view } = setup({ draft: '', onAddImages })
|
||||
const card = view.container.querySelector('[class*="card"]')!
|
||||
const documentFile = new File(['hello'], 'notes.txt', { type: 'text/plain' })
|
||||
fireEvent.drop(card, {
|
||||
dataTransfer: { types: ['Files'], files: [documentFile], dropEffect: 'none' },
|
||||
})
|
||||
expect(view.getByText(/暂仅支持 PNG/)).toBeTruthy()
|
||||
expect(onAddImages).not.toHaveBeenCalled()
|
||||
expect(view.getByText(/不支持的图片格式/)).toBeTruthy()
|
||||
expect(onAddImages).toHaveBeenCalledWith([documentFile])
|
||||
|
||||
const image = new File([Uint8Array.of(1)], 'locked.png', { type: 'image/png' })
|
||||
const locked = setup({ draft: '', disabled: true, onAddImages })
|
||||
@@ -191,12 +199,12 @@ describe('image draft rail', () => {
|
||||
fireEvent.dragOver(lockedCard, { dataTransfer })
|
||||
expect(dataTransfer.dropEffect).toBe('none')
|
||||
fireEvent.drop(lockedCard, { dataTransfer })
|
||||
expect(onAddImages).not.toHaveBeenCalled()
|
||||
expect(onAddImages).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('allows image-only send, removes a thumbnail, and opens original preview on double-click', () => {
|
||||
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
const attachment = { id: 'draft-1', file, previewUrl: 'blob:draft-1' }
|
||||
const attachment = { kind: 'image' as const, id: 'draft-1', file, previewUrl: 'blob:draft-1' }
|
||||
const onRemoveAttachment = vi.fn()
|
||||
const { view, textarea, props } = setup({
|
||||
draft: '', attachments: [attachment], onRemoveAttachment,
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { MessageImage } from '../src/client/chat/MessageImage.tsx'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -41,4 +42,23 @@ describe('MessageImage', () => {
|
||||
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps assistant images at their original position between text blocks', async () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
blocks={[
|
||||
{ kind: 'text', text: 'before' },
|
||||
{ kind: 'image', attachment, alt: 'middle' },
|
||||
{ kind: 'text', text: 'after' },
|
||||
]}
|
||||
streaming={false}
|
||||
loadImage={() => Promise.resolve('blob:middle')}
|
||||
/>,
|
||||
)
|
||||
const image = await view.findByAltText('middle')
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
* for the declared chat store (chat-store.spec.ts / selection-survival.spec.ts).
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
@@ -32,9 +34,17 @@ const SCOPE_TAG: symbol = (() => {
|
||||
interface SessionDouble {
|
||||
prompt: ReturnType<typeof vi.fn>
|
||||
cancel: ReturnType<typeof vi.fn>
|
||||
readAttachment: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
async function bench(opts?: { sessions?: boolean }) {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function bench(opts?: {
|
||||
sessions?: boolean
|
||||
description?: ReturnType<SessionsService['hostDescription']>
|
||||
}) {
|
||||
const ctx = new Context()
|
||||
const sessionDoubles = new Map<SessionId, SessionDouble>()
|
||||
const scopes = new Map<SessionId, Context>()
|
||||
@@ -57,6 +67,7 @@ async function bench(opts?: { sessions?: boolean }) {
|
||||
s = {
|
||||
prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
cancel: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
readAttachment: vi.fn(() => Promise.reject(new Error('attachment response not configured'))),
|
||||
}
|
||||
sessionDoubles.set(id, s)
|
||||
}
|
||||
@@ -66,6 +77,7 @@ async function bench(opts?: { sessions?: boolean }) {
|
||||
create: createMock,
|
||||
open: openMock,
|
||||
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
|
||||
hostDescription: () => opts?.description,
|
||||
} as unknown as SessionsService
|
||||
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
|
||||
const fiber = ctx.plugin((pluginCtx) => { void new ConversationService(pluginCtx) })
|
||||
@@ -133,6 +145,127 @@ describe('send / cancel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('image admission and URL lifecycle', () => {
|
||||
const description: NonNullable<ReturnType<SessionsService['hostDescription']>> = {
|
||||
version: '0',
|
||||
cwd: '/f',
|
||||
attachedSessions: 0,
|
||||
activeModel: {
|
||||
provider: 'anthropic',
|
||||
id: 'claude-opus-4-8',
|
||||
name: 'Opus',
|
||||
inputModalities: ['text', 'image'],
|
||||
outputModalities: ['text'],
|
||||
},
|
||||
imageLimits: {
|
||||
maxImageBytes: 3,
|
||||
maxImagesPerMessage: 2,
|
||||
maxMessageImageBytes: 4,
|
||||
maxImagePixels: 100,
|
||||
mediaTypes: ['image/png'],
|
||||
},
|
||||
}
|
||||
|
||||
it('preflights host limits before allocating previews and releases draft URLs', async () => {
|
||||
const createObjectURL = vi.fn(() => 'blob:draft')
|
||||
const revokeObjectURL = vi.fn()
|
||||
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
|
||||
const b = await bench({ description })
|
||||
const first = new File([Uint8Array.of(1, 2, 3)], 'first.png', { type: 'image/png' })
|
||||
const second = new File([Uint8Array.of(4, 5)], 'second.png', { type: 'image/png' })
|
||||
|
||||
const attachments = b.svc.createDraftImages([first])
|
||||
expect(attachments[0]).toMatchObject({ kind: 'image', file: first, previewUrl: 'blob:draft' })
|
||||
expect(() => b.svc.createDraftImages([second], attachments)).toThrow(/总大小/)
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1)
|
||||
|
||||
b.svc.releaseDraftImages(attachments)
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:draft')
|
||||
})
|
||||
|
||||
it('rejects unsupported model capability, media type, count, and per-image bytes', async () => {
|
||||
const createObjectURL = vi.fn(() => 'blob:unexpected')
|
||||
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL: vi.fn() })
|
||||
const textOnly = await bench({
|
||||
description: {
|
||||
...description,
|
||||
activeModel: { ...description.activeModel!, inputModalities: ['text'] },
|
||||
},
|
||||
})
|
||||
const png = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
expect(() => textOnly.svc.createDraftImages([png], [], true)).toThrow(/当前模型不支持图片/)
|
||||
|
||||
const b = await bench({ description })
|
||||
const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' })
|
||||
expect(() => b.svc.createDraftImages([video])).toThrow(/不支持的图片格式/)
|
||||
const large = new File([Uint8Array.of(1, 2, 3, 4)], 'large.png', { type: 'image/png' })
|
||||
expect(() => b.svc.createDraftImages([large])).toThrow(/单张大小限制/)
|
||||
const existing = b.svc.createDraftImages([png, png])
|
||||
expect(() => b.svc.createDraftImages([png], existing)).toThrow(/最多添加 2 张/)
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('deduplicates historical loads and revokes their URLs when the session scope ends', async () => {
|
||||
const createObjectURL = vi.fn()
|
||||
.mockReturnValueOnce('blob:history-1')
|
||||
.mockReturnValueOnce('blob:history-2')
|
||||
const revokeObjectURL = vi.fn()
|
||||
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
|
||||
const b = await bench()
|
||||
const ref: ImageAttachmentRef = {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}
|
||||
b.sessionsFake.manager.get(sid('s1'))
|
||||
const session = b.sessionDoubles.get(sid('s1'))!
|
||||
session.readAttachment.mockResolvedValue({
|
||||
ok: true,
|
||||
value: { attachment: ref, data: [1] },
|
||||
})
|
||||
|
||||
await expect(Promise.all([
|
||||
b.svc.resolveImage(sid('s1'), ref),
|
||||
b.svc.resolveImage(sid('s1'), ref),
|
||||
])).resolves.toEqual(['blob:history-1', 'blob:history-1'])
|
||||
expect(session.readAttachment).toHaveBeenCalledTimes(1)
|
||||
|
||||
b.svc.releaseSessionImages(sid('s1'))
|
||||
await vi.waitFor(() => { expect(revokeObjectURL).toHaveBeenCalledWith('blob:history-1') })
|
||||
await expect(b.svc.resolveImage(sid('s1'), ref)).resolves.toBe('blob:history-2')
|
||||
expect(session.readAttachment).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('revokes a historical URL whose load completes after its session scope was released', async () => {
|
||||
const createObjectURL = vi.fn(() => 'blob:late')
|
||||
const revokeObjectURL = vi.fn()
|
||||
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
|
||||
const b = await bench()
|
||||
const ref: ImageAttachmentRef = {
|
||||
attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`),
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}
|
||||
const response = Promise.withResolvers<{
|
||||
ok: true
|
||||
value: { attachment: ImageAttachmentRef; data: number[] }
|
||||
}>()
|
||||
b.sessionsFake.manager.get(sid('s1'))
|
||||
b.sessionDoubles.get(sid('s1'))!.readAttachment.mockReturnValue(response.promise)
|
||||
|
||||
const pending = b.svc.resolveImage(sid('s1'), ref)
|
||||
b.svc.releaseSessionImages(sid('s1'))
|
||||
response.resolve({ ok: true, value: { attachment: ref, data: [1] } })
|
||||
|
||||
await expect(pending).rejects.toThrow(/scope was released/)
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:late')
|
||||
})
|
||||
})
|
||||
|
||||
describe('startSession chain', () => {
|
||||
it('creates, navigates through sessions.open, then sends through the new scope', async () => {
|
||||
const b = await bench()
|
||||
|
||||
@@ -71,9 +71,10 @@ describe('ConversationRoot branches', () => {
|
||||
useStore={hookOf(chat)}
|
||||
actions={chat.actions}
|
||||
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
|
||||
addImages={vi.fn()}
|
||||
addImages={vi.fn(() => null)}
|
||||
removeImage={vi.fn()}
|
||||
draftImages={() => []}
|
||||
releaseSessionImages={vi.fn()}
|
||||
send={vi.fn()}
|
||||
stop={vi.fn()}
|
||||
openDetails={vi.fn()}
|
||||
@@ -132,9 +133,10 @@ describe('ConversationRoot branches', () => {
|
||||
useStore={hookOf(chat)}
|
||||
actions={chat.actions}
|
||||
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
|
||||
addImages={vi.fn()}
|
||||
addImages={vi.fn(() => null)}
|
||||
removeImage={vi.fn()}
|
||||
draftImages={() => []}
|
||||
releaseSessionImages={vi.fn()}
|
||||
send={vi.fn()}
|
||||
stop={vi.fn()}
|
||||
openDetails={vi.fn()}
|
||||
@@ -240,7 +242,13 @@ describe('EmptyState branches', () => {
|
||||
it('keeps the draft and surfaces a local error strip when startSession rejects', async () => {
|
||||
const startSession = vi.fn(() => Promise.reject(new Error('create down')))
|
||||
const view = render(
|
||||
<EmptyState useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])} startSession={startSession} />,
|
||||
<EmptyState
|
||||
useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])}
|
||||
createDraftImages={() => []}
|
||||
releaseDraftImage={() => {}}
|
||||
releaseDraftImages={() => {}}
|
||||
startSession={startSession}
|
||||
/>,
|
||||
)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
fireEvent.change(textarea, { target: { value: 'first task' } })
|
||||
@@ -252,7 +260,13 @@ describe('EmptyState branches', () => {
|
||||
it('non-Error rejection reasons stringify into the error strip', async () => {
|
||||
const startSession = vi.fn(() => Promise.reject('plain-string'))
|
||||
const view = render(
|
||||
<EmptyState useSessions={listHook([])} startSession={startSession} />,
|
||||
<EmptyState
|
||||
useSessions={listHook([])}
|
||||
createDraftImages={() => []}
|
||||
releaseDraftImage={() => {}}
|
||||
releaseDraftImages={() => {}}
|
||||
startSession={startSession}
|
||||
/>,
|
||||
)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
fireEvent.change(textarea, { target: { value: 'go' } })
|
||||
@@ -268,6 +282,9 @@ describe('EmptyState branches', () => {
|
||||
{ id: 'a', title: 'a', cwd: '/proj' },
|
||||
{ id: 'b', title: 'b' }, // no cwd: filtered from the option set
|
||||
])}
|
||||
createDraftImages={() => []}
|
||||
releaseDraftImage={() => {}}
|
||||
releaseDraftImages={() => {}}
|
||||
startSession={startSession}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -68,7 +68,15 @@ describe('EmptyState', () => {
|
||||
])
|
||||
let reject!: (e: Error) => void
|
||||
const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej }))
|
||||
render(<EmptyState useSessions={useSessions} startSession={startSession} />)
|
||||
render(
|
||||
<EmptyState
|
||||
useSessions={useSessions}
|
||||
createDraftImages={() => []}
|
||||
releaseDraftImage={() => {}}
|
||||
releaseDraftImages={() => {}}
|
||||
startSession={startSession}
|
||||
/>,
|
||||
)
|
||||
|
||||
const select = screen.getByRole('combobox', { name: '项目目录' })
|
||||
expect([...(select as HTMLSelectElement).options].map(o => o.value))
|
||||
@@ -87,12 +95,59 @@ describe('EmptyState', () => {
|
||||
|
||||
it('new-directory option swaps the select for a free-form input', () => {
|
||||
const { useSessions } = fakeSessions([])
|
||||
render(<EmptyState useSessions={useSessions} startSession={() => Promise.resolve()} />)
|
||||
render(
|
||||
<EmptyState
|
||||
useSessions={useSessions}
|
||||
createDraftImages={() => []}
|
||||
releaseDraftImage={() => {}}
|
||||
releaseDraftImages={() => {}}
|
||||
startSession={() => Promise.resolve()}
|
||||
/>,
|
||||
)
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: '::new-directory' } })
|
||||
const custom = screen.getByPlaceholderText(/目录路径/)
|
||||
fireEvent.change(custom, { target: { value: '/tmp/fresh' } })
|
||||
expect((custom as HTMLInputElement).value).toBe('/tmp/fresh')
|
||||
})
|
||||
|
||||
it('routes empty-state draft image creation and release through the injected lifecycle', () => {
|
||||
const { useSessions } = fakeSessions([])
|
||||
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
const attachment = {
|
||||
kind: 'image' as const,
|
||||
id: 'draft-1',
|
||||
file,
|
||||
previewUrl: 'blob:draft-1',
|
||||
}
|
||||
const createDraftImages = vi.fn()
|
||||
.mockReturnValueOnce([attachment])
|
||||
.mockImplementationOnce(() => { throw new Error('图片过大') })
|
||||
const releaseDraftImage = vi.fn()
|
||||
const releaseDraftImages = vi.fn()
|
||||
const view = render(
|
||||
<EmptyState
|
||||
useSessions={useSessions}
|
||||
createDraftImages={createDraftImages}
|
||||
releaseDraftImage={releaseDraftImage}
|
||||
releaseDraftImages={releaseDraftImages}
|
||||
startSession={() => Promise.resolve()}
|
||||
/>,
|
||||
)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
const clipboardData = {
|
||||
items: [{ kind: 'file', type: 'image/png', getAsFile: () => file }],
|
||||
getData: () => '',
|
||||
}
|
||||
fireEvent.paste(textarea, { clipboardData })
|
||||
expect(createDraftImages).toHaveBeenCalledWith([file], [])
|
||||
fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' }))
|
||||
expect(releaseDraftImage).toHaveBeenCalledWith('draft-1')
|
||||
|
||||
fireEvent.paste(textarea, { clipboardData })
|
||||
expect(view.getByText('图片过大')).toBeTruthy()
|
||||
view.unmount()
|
||||
expect(releaseDraftImages).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConversationRoot', () => {
|
||||
@@ -121,9 +176,10 @@ describe('ConversationRoot', () => {
|
||||
subscribe: () => () => {},
|
||||
version: () => 1,
|
||||
}}
|
||||
addImages={vi.fn()}
|
||||
addImages={vi.fn(() => null)}
|
||||
removeImage={vi.fn()}
|
||||
draftImages={() => []}
|
||||
releaseSessionImages={vi.fn()}
|
||||
send={send}
|
||||
stop={stop}
|
||||
openDetails={openDetails}
|
||||
|
||||
Reference in New Issue
Block a user