Merge master into feature/workspace-picker-composer

This commit is contained in:
NI0317
2026-08-10 16:50:03 +08:00
265 changed files with 6107 additions and 939 deletions

View File

@@ -229,7 +229,7 @@ describe('MessageItem arms', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('consumed steering is captioned as an interjection and keeps copy without branch', () => {
it('consumed steering renders as a plain user bubble and keeps copy without branch', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
@@ -242,7 +242,7 @@ describe('MessageItem arms', () => {
} as never}
/>,
)
expect(view.getByText('插话')).toBeTruthy()
expect(view.queryByText('插话')).toBeNull()
expect(view.getByText('steer!')).toBeTruthy()
expect(view.getByText(/附加内容块/)).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: '复制' }))

View File

@@ -25,8 +25,6 @@ describe('createChatStore', () => {
store.actions.setDraft('hello')
expect(store.store.getSnapshot().draft).toBe('hello')
store.actions.clearDraft()
expect(store.store.getSnapshot().draft).toBe('')
store.actions.setView('chat')
expect(store.store.getSnapshot().view).toBe('chat')
@@ -37,17 +35,6 @@ describe('createChatStore', () => {
expect(store.store.getSnapshot().inspect).toBeNull()
})
it('restoreDraft only fills an empty draft (optimistic-send rollback contract)', () => {
const store = createChatStore().create()
// Rollback path: draft was cleared by send, nothing typed since.
store.actions.restoreDraft('failed text')
expect(store.store.getSnapshot().draft).toBe('failed text')
// The user typed something new before the failure landed: keep theirs.
store.actions.setDraft('newer input')
store.actions.restoreDraft('stale text')
expect(store.store.getSnapshot().draft).toBe('newer input')
})
it('persists per scope key and rehydrates a fresh instance', () => {
const handle = createChatStore()
const s1 = handle.create('sess-1')

View File

@@ -261,7 +261,13 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
useWorkspaces: emptyWorkspaces(),
useProjection: (() => undefined),
useInput: (() => { throw new Error('unused') }),
inputActions: { setDraft: () => {}, submit: () => {} },
inputActions: {
setDraft: () => {},
addImages: () => true,
removeImage: () => {},
pruneImages: () => {},
submit: () => {},
},
useStore: bindSnapshotSelector(chat),
actions: chat.actions,
renderSlot,
@@ -269,6 +275,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
openDetails,
openFile,
loadOlder,
loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
inspectCall,
chatScroll,
forkAt,
@@ -459,9 +466,6 @@ describe('ChatView', () => {
expect(view.queryByText('later')).toBeNull()
const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]')
expect(pendingBubble).not.toBeNull()
// Pending and durable steering carry the same interjection caption, so the
// hand-off does not change what the row says it is.
expect(within(pendingBubble as HTMLElement).getByText('插话')).toBeTruthy()
fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('interrupt now')
expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
@@ -483,7 +487,6 @@ describe('ChatView', () => {
})
expect(view.getAllByText('interrupt now')).toHaveLength(1)
expect(view.container.querySelector('[data-pending-steering]')).toBeNull()
expect(view.getAllByText('插话')).toHaveLength(1)
// Only the durable steering bubble: the turn is still running, so its
// assistant narration owns no footer yet, and a steering bubble never
// carries a branch action.

View File

@@ -122,7 +122,13 @@ describe('render branch tails', () => {
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
useProjection={(() => undefined)}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
inputActions={{
setDraft: () => {},
addImages: () => true,
removeImage: () => {},
pruneImages: () => {},
submit: () => {},
}}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
@@ -173,7 +179,13 @@ describe('render branch tails', () => {
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
useProjection={(() => undefined)}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
inputActions={{
setDraft: () => {},
addImages: () => true,
removeImage: () => {},
pruneImages: () => {},
submit: () => {},
}}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}

View File

@@ -12,6 +12,8 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { SessionInputShell } from '../src/client/input/facade.ts'
import type { ComposerAttachment } from '../src/client/contract/slots.ts'
import type { DraftAttachmentId } from '../src/client/input/contract.ts'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
import { zh } from '../src/client/locales.ts'
@@ -72,6 +74,8 @@ interface BenchOptions {
overlay?: React.ReactNode
leftItems?: React.ReactNode
rightItems?: React.ReactNode
attachments?: readonly ComposerAttachment[]
addImages?: (files: readonly File[]) => string | null
commandMenuOpen?: boolean
busyEnter?: 'queue' | 'steer'
toggleCommandMenu?: (selection: { start: number; end: number }) => void
@@ -116,7 +120,9 @@ function bench(over?: BenchOptions) {
: {}),
})
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
if (over?.attachments !== undefined) shell.addImages(over.attachments.map(attachment => attachment.id))
const stop = vi.fn()
const removeImage = vi.fn((id: DraftAttachmentId) => { shell.removeImage(id) })
const menuLauncher = createSnapshotStore<string | null>(over?.commandMenuOpen === true ? 'command' : null)
const slotCalls: { key: string; owner: unknown }[] = []
const renderSlot = ((key: string, owner: object) => {
@@ -142,6 +148,12 @@ function bench(over?: BenchOptions) {
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
addImages: over?.addImages ?? (() => null),
removeImage,
draftImages: ids => ids.flatMap((id) => {
const attachment = over?.attachments?.find(candidate => candidate.id === id)
return attachment === undefined ? [] : [attachment]
}),
resolveSubmitMode: (running, gesture, steeringAvailable) => {
if (!running || !steeringAvailable) return 'queue'
const preferred = over?.busyEnter ?? 'queue'
@@ -174,11 +186,66 @@ function bench(over?: BenchOptions) {
)!
const interruptButton = view.container.querySelector<HTMLButtonElement>('button[aria-label="停止生成"]')
return {
view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher,
view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeImage, slotCalls,
menuLauncher,
steerQueue: over?.steerQueue,
}
}
describe('image draft rail', () => {
it('collects clipboard files while preserving text from a mixed paste', () => {
const addImages = vi.fn(() => null)
const { textarea, shell } = bench({ addImages })
const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
fireEvent.paste(textarea, {
clipboardData: {
items: [
{ kind: 'string', type: 'text/plain', getAsFile: () => null },
{ kind: 'file', type: 'image/png', getAsFile: () => image },
],
getData: () => '同时粘贴的文字',
},
})
expect(addImages).toHaveBeenCalledWith([image])
expect(shell.snapshot.draft).toBe('同时粘贴的文字')
})
it('accepts file drops and prevents browser navigation', () => {
const addImages = vi.fn(() => null)
const { view } = bench({ addImages })
const card = view.container.querySelector('[class*="card"]')!
const image = new File([Uint8Array.of(1)], 'dropped.png', { type: 'image/png' })
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' }
expect(fireEvent.dragEnter(card, { dataTransfer })).toBe(false)
expect(view.getByRole('status').textContent).toContain('松开以添加图片')
expect(fireEvent.dragOver(card, { dataTransfer })).toBe(false)
expect(dataTransfer.dropEffect).toBe('copy')
expect(fireEvent.drop(card, { dataTransfer })).toBe(false)
expect(addImages).toHaveBeenCalledWith([image])
})
it('sends an image-only draft and removes its thumbnail', () => {
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, textarea, sink, removeImage } = bench({ attachments: [attachment] })
expect((view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement).disabled).toBe(false)
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue')
fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' }))
expect(removeImage).toHaveBeenCalledWith('draft-1')
})
it('opens the original image on double-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('双击查看原图'))
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
fireEvent.keyDown(window, { key: 'Escape' })
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
})
})
describe('Enter semantics', () => {
it('advertises the empty-draft whole-queue steering gesture when it is available', () => {
const { textarea } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() })
@@ -234,7 +301,7 @@ describe('Enter semantics', () => {
it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => {
const { textarea, sink } = bench({ draft: 'hello' })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('hello', 'queue')
expect(sink).toHaveBeenCalledWith('hello', [], 'queue')
fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
expect(sink).toHaveBeenCalledTimes(1)
const empty = bench({ draft: ' ' })
@@ -259,15 +326,15 @@ describe('Enter semantics', () => {
it('Ctrl/Meta+Enter sends normally while idle and steers while running', () => {
const idle = bench({ draft: 'hello' })
fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true })
expect(idle.sink).toHaveBeenCalledWith('hello', 'queue')
expect(idle.sink).toHaveBeenCalledWith('hello', [], 'queue')
const busyCtrl = bench({ running: true, draft: 'steer with ctrl' })
fireEvent.keyDown(busyCtrl.textarea, { key: 'Enter', ctrlKey: true })
expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', 'steer')
expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', [], 'steer')
const busyMeta = bench({ running: true, draft: 'steer with cmd' })
fireEvent.keyDown(busyMeta.textarea, { key: 'Enter', metaKey: true })
expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', 'steer')
expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', [], 'steer')
})
it('empty-draft Cmd/Ctrl+Enter steers the whole queue instead of submitting', () => {
@@ -332,7 +399,7 @@ describe('Enter semantics', () => {
const steerQueue = vi.fn()
const { textarea, sink } = bench({ running: true, queue: [row('q-1')], draft: '插话', steerQueue })
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
expect(sink).toHaveBeenCalledWith('插话', 'steer')
expect(sink).toHaveBeenCalledWith('插话', [], 'steer')
expect(steerQueue).not.toHaveBeenCalled()
})
@@ -380,7 +447,7 @@ describe('running and lock semantics', () => {
expect(textarea.disabled).toBe(false)
fireEvent.change(textarea, { target: { value: '排队消息2' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('排队消息2', 'queue')
expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue')
expect(button.getAttribute('aria-label')).toBe('停止生成')
fireEvent.click(button)
expect(stop).toHaveBeenCalledTimes(1)
@@ -389,17 +456,17 @@ describe('running and lock semantics', () => {
it('running plain Enter follows the busy-state Steer preference', () => {
const { textarea, sink } = bench({ running: true, busyEnter: 'steer', draft: '直接插话' })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('直接插话', 'steer')
expect(sink).toHaveBeenCalledWith('直接插话', [], 'steer')
})
it('running Cmd/Ctrl+Enter uses the opposite of the busy-state Enter preference', () => {
const meta = bench({ running: true, busyEnter: 'steer', draft: '排到下一轮' })
fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true })
expect(meta.sink).toHaveBeenCalledWith('排到下一轮', 'queue')
expect(meta.sink).toHaveBeenCalledWith('排到下一轮', [], 'queue')
const ctrl = bench({ running: true, busyEnter: 'steer', draft: 'also queue' })
fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true })
expect(ctrl.sink).toHaveBeenCalledWith('also queue', 'queue')
expect(ctrl.sink).toHaveBeenCalledWith('also queue', [], 'queue')
})
it('running continuable subagent keeps Send beside an independent Stop', () => {
@@ -419,7 +486,7 @@ describe('running and lock semantics', () => {
expect(interruptButton).not.toBeNull()
expect(textarea.disabled).toBe(false)
fireEvent.click(button)
expect(sink).toHaveBeenCalledWith('后续消息', 'queue')
expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue')
fireEvent.click(interruptButton!)
expect(stop).toHaveBeenCalledTimes(1)
})
@@ -476,11 +543,11 @@ describe('running and lock semantics', () => {
}
const plain = bench({ running: true, busyEnter: 'steer', draft: 'plain', subagent })
fireEvent.keyDown(plain.textarea, { key: 'Enter' })
expect(plain.sink).toHaveBeenCalledWith('plain', 'queue')
expect(plain.sink).toHaveBeenCalledWith('plain', [], 'queue')
const accelerated = bench({ running: true, draft: 'accelerated', subagent })
fireEvent.keyDown(accelerated.textarea, { key: 'Enter', metaKey: true })
expect(accelerated.sink).toHaveBeenCalledWith('accelerated', 'queue')
expect(accelerated.sink).toHaveBeenCalledWith('accelerated', [], 'queue')
})
it('disabled (session removed) locks the textarea and chrome', () => {
@@ -493,7 +560,7 @@ describe('running and lock semantics', () => {
it('idle primary sends and disables on empty draft', () => {
const { button, sink } = bench({ draft: 'go' })
fireEvent.click(button)
expect(sink).toHaveBeenCalledWith('go', 'queue')
expect(sink).toHaveBeenCalledWith('go', [], 'queue')
const empty = bench()
expect(empty.button.disabled).toBe(true)
})
@@ -618,7 +685,7 @@ describe('running and lock semantics', () => {
}
// Pasted text lands below the fold: scroll down by exactly the overshoot.
caretAt(500)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'pasted' } })
fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'pasted' } })
await settle()
expect(scroll.scrollTop).toBe(88) // 524 - 436
// Measured on the mirror's own text, at the index the paste left the caret
@@ -627,12 +694,12 @@ describe('running and lock semantics', () => {
expect(measured!.offset).toBe('pasted'.length)
// A caret already inside the box does not move it.
caretAt(200)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'more' } })
fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'more' } })
await settle()
expect(scroll.scrollTop).toBe(88)
// Above the fold (a cut can leave it there): scroll back up.
caretAt(60)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'again' } })
fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'again' } })
await settle()
expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60)
// A caret straight after a newline has nothing on its line to measure, so
@@ -640,7 +707,7 @@ describe('running and lock semantics', () => {
// chromium reports no client rects at all for the collapsed position.
mirror.style.lineHeight = '24px'
caretAt(500)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'block\n' } })
fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'block\n' } })
await settle()
// The four pastes accumulate at the draft's head, so the caret is at the
// end of what they inserted — and the measured index is the newline before it.

View File

@@ -48,6 +48,9 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
addImages: () => null,
removeImage: () => {},
draftImages: () => [],
resolveSubmitMode: () => 'queue',
toggleCommandMenu: vi.fn(),
useNotices: bindSnapshotSelector(shell.notices),
@@ -90,7 +93,7 @@ describe('matrix row: plain', () => {
fireEvent.change(textarea, { target: { value: '普通消息' } })
expect(shell.snapshot.claim).toBeUndefined()
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('普通消息', 'queue')
expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue')
expect(shell.snapshot.phase).toBe('plain')
})
})
@@ -189,7 +192,7 @@ describe('matrix row: locked (session disabled)', () => {
expect((textarea).disabled).toBe(false)
fireEvent.change(textarea, { target: { value: '排队' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('排队', 'queue')
expect(sink).toHaveBeenCalledWith('排队', [], 'queue')
})
})

View File

@@ -134,6 +134,9 @@ async function scopedBench(register?: (slash: SlashService) => void) {
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
addImages: () => null,
removeImage: () => {},
draftImages: () => [],
resolveSubmitMode: () => 'queue',
toggleCommandMenu: (selection) => {
const snapshot = shell.snapshot
@@ -237,7 +240,7 @@ describe('scenario D: execute-kind /compact', () => {
act(() => { b2.shell.setDraft('/compact 现在') })
fireEvent.keyDown(b2.textarea, { key: 'Enter' })
// execute with trailing → matchEnter answers undefined → default sink.
await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue') })
await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', [], 'queue') })
expect(b2.executed).toHaveLength(0)
})
})
@@ -291,7 +294,7 @@ describe('scenario I: unknown /xyz + enter', () => {
const b = await bench()
act(() => { b.shell.setDraft('/xyz 干点啥') })
fireEvent.keyDown(b.textarea, { key: 'Enter' })
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue') })
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', [], 'queue') })
expect(b.shell.snapshot.phase).toBe('plain')
expect(b.execute).not.toHaveBeenCalled()
})

View File

@@ -0,0 +1,81 @@
// @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)
})
})

View File

@@ -61,7 +61,8 @@ function liveSession(initial: ConversationSnapshot) {
}
}
const INPUT_STATE: InputState = { draft: '', draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
/** InputZone owner stub (the dock reads useSession only; the zone fields satisfy the owner share). */
const INPUT_STATE: InputState = { draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
// Standard locale seat stub mirroring the real ns → common → key chain.
const t: QueueDockProps['t'] = makeTranslate(zh, commonZh)

View File

@@ -5,15 +5,15 @@
// tag probe).
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { QueuedMessage } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import type { QueuedMessage, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
import { InputHub } from '../src/client/input/hub.ts'
import { ConversationService, UnsupportedImageMediaTypeError } from '../src/client/service.ts'
import { zh } from '../src/client/locales.ts'
async function bench() {
async function bench(readAttachment?: SessionFace['readAttachment']) {
const runtime = await SlotTestRuntime.create()
const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const updateQueue = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
@@ -21,7 +21,7 @@ async function bench() {
const loadOlder = vi.fn(() => Promise.resolve())
await runtime.sessions.add({
id: 's1',
session: { prompt, updateQueue, cancel, loadOlder },
session: { prompt, updateQueue, cancel, loadOlder, ...(readAttachment === undefined ? {} : { readAttachment }) },
})
// config.input is required (the apply shares its hub with the inject
// factories); the bench passes its own instance explicitly.
@@ -34,7 +34,7 @@ async function bench() {
const root = runtime.ctx.get('conversation') as ConversationService
const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService
const shell = hub.shellFor(runtime.sessions.binding('s1')!)
return { runtime, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder }
return { runtime, fiber, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder }
}
describe('ConversationService', () => {
@@ -83,6 +83,52 @@ describe('ConversationService', () => {
await b.runtime.dispose()
})
it('releases draft previews when their session scope is disposed', async () => {
const b = await bench()
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-1')
const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
try {
const [attachment] = b.root.createDraftImages([
new File([new Uint8Array(4)], 'a.png', { type: 'image/png' }),
])
if (attachment === undefined) throw new Error('draft attachment missing')
b.root.input.for(b.runtime.sessions.scope('s1')!).addImages([attachment.id])
await b.runtime.sessions.remove('s1')
expect(b.root.draftImages([attachment.id])).toEqual([])
expect(revoked).toHaveBeenCalledWith('blob:draft-1')
} finally {
created.mockRestore()
revoked.mockRestore()
}
await b.runtime.dispose()
})
it('validates every MIME type before allocating previews', async () => {
const b = await bench()
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview')
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(UnsupportedImageMediaTypeError)
expect(created).not.toHaveBeenCalled()
created.mockRestore()
await b.runtime.dispose()
})
it('invalidates pending historical image loads when the rendered session is released', async () => {
const read = Promise.withResolvers<Awaited<ReturnType<SessionFace['readAttachment']>>>()
const b = await bench(() => read.promise)
const sessionId = b.runtime.sessions.behavior('s1').sessionId
const attachment = {
attachmentId: AttachmentId('image-1'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
} as const
const pending = b.root.resolveImage(sessionId, attachment)
b.root.releaseSessionImages(sessionId)
read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } })
await expect(pending).rejects.toThrow('historical image scope was released')
await b.runtime.dispose()
})
it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => {
const b = await bench()
await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/)

View File

@@ -179,6 +179,7 @@ function mount(
subscribe: () => () => {},
version: () => 1,
}}
releaseSessionImages={vi.fn()}
bindDraftMirror={write => wiring.bindMirror(write)}
/>
)
@@ -198,6 +199,9 @@ function mount(
useInput={useInput}
inputActions={inputActions}
keyboard={wiring}
addImages={() => null}
removeImage={() => {}}
draftImages={() => []}
resolveSubmitMode={() => 'queue'}
toggleCommandMenu={vi.fn()}
useNotices={bindSnapshotSelector(wiring.notices)}
@@ -305,7 +309,7 @@ describe('ConversationRoot resident composer', () => {
fireEvent.change(box, { target: { value: 'ordinary revised' } })
expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue')
expect(b.sink).toHaveBeenCalledWith('ordinary revised', [], 'queue')
expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true)
expect(b.view.queryByText('Root')).toBeNull()
})