Merge remote-tracking branch 'origin/worktree/web-multimodal-image-input' into worktree/web-multimodal-image-input

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml
#	apps/web/tests/code-mode-fixture.snapshot.ts
#	apps/web/tests/image-display.snapshot.ts
#	docs/architecture.i18n.yaml
#	docs/module-graph.md
#	packages/client/test-runtime/tests/runtime.spec.tsx
#	packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
#	packages/client/ui-conversation/tests/terminal-card.spec.tsx
#	packages/client/ui-trajectory/src/client/layout.ts
This commit is contained in:
creatixchu
2026-07-30 10:46:50 +08:00
38 changed files with 75 additions and 444 deletions

View File

@@ -49,7 +49,7 @@ interface BenchOptions {
leftItems?: React.ReactNode
rightItems?: React.ReactNode
attachments?: readonly ComposerAttachment[]
addImages?: (files: readonly File[], current: readonly ComposerAttachment[]) => string | null
addImages?: (files: readonly File[]) => string | null
}
/** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
@@ -464,7 +464,7 @@ describe('image draft rail', () => {
getData: () => '同时粘贴的文字',
},
})
expect(addImages).toHaveBeenCalledWith([image], [])
expect(addImages).toHaveBeenCalledWith([image])
expect(shell.snapshot.draft).toBe('同时粘贴的文字')
const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' })
@@ -494,7 +494,7 @@ describe('image draft rail', () => {
expect(dataTransfer.dropEffect).toBe('copy')
expect(fireEvent.drop(card, { dataTransfer })).toBe(false)
expect(view.queryByRole('status')).toBeNull()
expect(addImages).toHaveBeenCalledWith([image], [])
expect(addImages).toHaveBeenCalledWith([image])
})
it('ignores unsupported dropped files and refuses drops while locked', () => {
@@ -507,7 +507,7 @@ describe('image draft rail', () => {
dataTransfer: { types: ['Files'], files: [documentFile], dropEffect: 'none' },
})
expect(view.getByText(/不支持的图片格式/)).toBeTruthy()
expect(addImages).toHaveBeenCalledWith([documentFile], [])
expect(addImages).toHaveBeenCalledWith([documentFile])
const image = new File([Uint8Array.of(1)], 'locked.png', { type: 'image/png' })
const locked = bench({ disabled: true, addImages })

View File

@@ -69,6 +69,31 @@ describe('ConversationService', () => {
await b.runtime.dispose()
})
it('checks media type before preview allocation and leaves deployment limits to the host', async () => {
const b = await bench()
const created = vi.spyOn(URL, 'createObjectURL').mockImplementation(file => `blob:${(file as File).name}`)
try {
const files = Array.from(
{ length: 11 },
(_, index) => new File([Uint8Array.of(index)], `${index}.png`, { type: 'image/png' }),
)
expect(b.root.createDraftImages(files)).toHaveLength(11)
expect(created).toHaveBeenCalledTimes(11)
const beforeRejectedBatch = created.mock.calls.length
expect(() => {
b.root.createDraftImages([
new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }),
new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }),
])
}).toThrow('不支持的图片格式:image/svg+xml')
expect(created).toHaveBeenCalledTimes(beforeRejectedBatch)
} finally {
created.mockRestore()
}
await b.runtime.dispose()
})
it('releases in-flight send images when the scope dies before the failure lands', async () => {
const b = await bench()
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:inflight-1')

View File

@@ -395,7 +395,7 @@ describe('DetailsPanel Output section', () => {
useSessions={bindSnapshotSelector(sessions)}
useWorkspaces={bindSnapshotSelector(workspaces)}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {}, addImages: () => {}, removeImage: () => {}, pruneImages: () => {} }}
inputActions={{ setDraft: () => {}, addImages: () => {}, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
@@ -566,7 +566,7 @@ describe('DetailsPanel Output section', () => {
baselinesReady: true, recentWorkspaceId: undefined,
}))}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {}, addImages: () => {}, removeImage: () => {}, pruneImages: () => {} }}
inputActions={{ setDraft: () => {}, addImages: () => {}, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}