Merge remote-tracking branch 'origin/master' into worktree/web-plugin-config

# Conflicts:
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	docs/module-graph.i18n.yaml
#	docs/module-graph.md
#	docs/module-graph.zh.md
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.md
#	packages/client/ui-conversation/README.zh.md
#	packages/client/ui-conversation/package.json
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-08-10 15:50:49 +08:00
352 changed files with 7220 additions and 781 deletions
@@ -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')
@@ -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,
@@ -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()}
@@ -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'
@@ -57,6 +59,10 @@ interface BenchOptions {
subagent?: Exclude<ConversationSnapshot['subagent'], null>
disabled?: boolean
promptError?: ConversationSnapshot['promptError']
/** Authoritative queue rows served to the machine overlay (empty = none). */
queue?: ConversationSnapshot['queue']
/** The hub's steer-all face (empty-draft accelerated Enter). */
steerQueue?: () => void
variant?: 'hero' | 'composer'
placeholder?: string
t?: InputBarProps['t']
@@ -65,19 +71,41 @@ 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
}
/** One pending queue row (the runtime snapshot shape, as the dock tests build it). */
function row(id: string): ConversationSnapshot['queue'][number] {
return {
id: id as never, messageId: `message-${id}` as never, placement: 'queued',
content: [{ type: 'text', text: id }], preview: id, text: id,
}
}
/** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
function bench(over?: BenchOptions) {
const sink = vi.fn()
const lex = over?.lexicon
const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({
running: over?.running ?? false,
subagent: over?.subagent ?? null,
removed: over?.disabled ?? false,
promptError: over?.promptError ?? null,
queue: over?.queue ?? [],
}))
type ShellDeps = ConstructorParameters<typeof SessionInputShell>[0]
const shell = new SessionInputShell({
actx: SCTX,
defaultSink: sink,
queue: {
getSnapshot: () => session.getSnapshot().queue,
subscribe: fn => session.subscribe(fn),
},
...(over?.steerQueue !== undefined ? { steerQueue: over.steerQueue } : {}),
// Lexicon-only stub: adjudication untouched (undefined slash methods are
// never reached — these benches drive plain-draft flows only).
...(lex !== undefined
@@ -89,13 +117,9 @@ function bench(over?: BenchOptions) {
: {}),
})
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({
running: over?.running ?? false,
subagent: over?.subagent ?? null,
removed: over?.disabled ?? false,
promptError: over?.promptError ?? null,
}))
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) => {
@@ -121,6 +145,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'
@@ -150,15 +180,122 @@ 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() })
expect(textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
})
it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => {
expect(bench({ running: true }).textarea.placeholder).toBe('给智能体发消息')
expect(bench({ queue: [row('q-1')] }).textarea.placeholder).toBe('给智能体发消息')
expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).textarea.placeholder).toBe('给智能体发消息')
expect(bench({
running: true,
queue: [row('q-1')],
subagent: {
address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
},
}).textarea.placeholder).toBe('给智能体发消息')
expect(bench({
running: true,
queue: [row('q-1')],
placeholder: '上层指定提示',
}).textarea.placeholder).toBe('上层指定提示')
// The command menu owns Enter while open: neither the hint nor the
// gesture may claim the chord.
expect(bench({
running: true,
queue: [row('q-1')],
commandMenuOpen: true,
}).textarea.placeholder).toBe('给智能体发消息')
// The steer hint intentionally outranks the plan placeholder: while it
// shows, the whole-queue gesture is genuinely available in plan mode.
expect(bench({
running: true,
queue: [row('q-1')],
plan: { active: true, pending: false },
}).textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
})
it('an open command menu withholds the whole-queue steering gesture', () => {
const steerQueue = vi.fn()
const { textarea, sink } = bench({
running: true,
queue: [row('q-1')],
commandMenuOpen: true,
steerQueue,
})
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
expect(steerQueue).not.toHaveBeenCalled()
expect(sink).not.toHaveBeenCalled()
})
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: ' ' })
@@ -183,15 +320,87 @@ 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', () => {
const steerQueue = vi.fn()
const queue = [row('q-1'), row('q-2')]
const meta = bench({ running: true, queue, steerQueue })
fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true })
expect(meta.steerQueue).toHaveBeenCalledTimes(1)
expect(meta.sink).not.toHaveBeenCalled()
const ctrl = bench({ running: true, queue, steerQueue: vi.fn() })
fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true })
expect(ctrl.steerQueue).toHaveBeenCalledTimes(1)
expect(ctrl.sink).not.toHaveBeenCalled()
})
it('queue steering stays gated: idle, subagent, plain Enter, empty queue, or steering-only rows', () => {
// Idle: the gesture falls through to the machine's empty-draft no-op.
const idle = bench({ queue: [row('q-1')], steerQueue: vi.fn() })
fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true })
expect(idle.steerQueue).not.toHaveBeenCalled()
expect(idle.sink).not.toHaveBeenCalled()
// Plain Enter never steers the queue, even under the busy Steer preference.
const plain = bench({ running: true, busyEnter: 'steer', queue: [row('q-1')], steerQueue: vi.fn() })
fireEvent.keyDown(plain.textarea, { key: 'Enter' })
expect(plain.steerQueue).not.toHaveBeenCalled()
expect(plain.sink).not.toHaveBeenCalled()
// Subagent sessions keep the queue transport (no steering face).
const subagent = {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'continuable' as const,
},
parentAvailable: true,
}
const child = bench({ running: true, subagent, queue: [row('q-1')], steerQueue: vi.fn() })
fireEvent.keyDown(child.textarea, { key: 'Enter', metaKey: true })
expect(child.steerQueue).not.toHaveBeenCalled()
expect(child.sink).not.toHaveBeenCalled()
// No queued rows: the empty draft stays a no-op.
const none = bench({ running: true, steerQueue: vi.fn() })
fireEvent.keyDown(none.textarea, { key: 'Enter', metaKey: true })
expect(none.steerQueue).not.toHaveBeenCalled()
expect(none.sink).not.toHaveBeenCalled()
// Pending steering rows are not the queue: nothing to flush.
const steering = bench({
running: true,
queue: [{ ...row('s-1'), placement: 'steering' }],
steerQueue: vi.fn(),
})
fireEvent.keyDown(steering.textarea, { key: 'Enter', metaKey: true })
expect(steering.steerQueue).not.toHaveBeenCalled()
expect(steering.sink).not.toHaveBeenCalled()
})
it('draft content outranks the queue: accelerated Enter steers the draft only', () => {
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(steerQueue).not.toHaveBeenCalled()
})
it('empty-draft accelerated Enter without a steerQueue face stays a silent no-op', () => {
const { textarea, sink } = bench({ running: true, queue: [row('q-1')] })
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
expect(sink).not.toHaveBeenCalled()
})
it('platform undo/redo chords route to the machine, never the browser stack', () => {
@@ -232,7 +441,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)
@@ -241,17 +450,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', () => {
@@ -271,7 +480,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)
})
@@ -328,11 +537,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', () => {
@@ -345,7 +554,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)
})
@@ -470,7 +679,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
@@ -479,12 +688,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
@@ -492,7 +701,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.
@@ -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')
})
})
@@ -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()
})
@@ -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)
})
})
@@ -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)
@@ -5,12 +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 { 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 } }))
@@ -18,18 +21,20 @@ 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.
const hub = new InputHub(runtime.ctx, makeTranslate(zh, {}))
const fiber = runtime.ctx.plugin(ConversationService, {
input: new InputHub(runtime.ctx),
input: hub,
blocks: new ComposerBlockRegistry(),
})
await fiber.await()
const root = runtime.ctx.get('conversation') as ConversationService
const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService
return { runtime, root, scoped, prompt, updateQueue, cancel, loadOlder }
const shell = hub.shellFor(runtime.sessions.binding('s1')!)
return { runtime, fiber, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder }
}
describe('ConversationService', () => {
@@ -78,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/)
@@ -87,10 +138,88 @@ describe('ConversationService', () => {
// No SessionsService at all: a bare context (the runtime always provides one).
const bare = new Context()
await bare.plugin(ConversationService, {
input: new InputHub(bare),
input: new InputHub(bare, makeTranslate(zh, {})),
blocks: new ComposerBlockRegistry(),
}).await()
const orphan = bare.get('conversation') as ConversationService
await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/)
})
})
describe('InputHub queue steering (empty-draft accelerated Enter)', () => {
const row = (id: string): QueuedMessage => ({
id: id as never,
messageId: `message-${id}` as never,
placement: 'queued',
content: [{ type: 'text', text: id }],
preview: id,
text: id,
})
it('steers every queued row in FIFO order and leaves steering rows alone', async () => {
const b = await bench()
await b.runtime.sessions.updateSnapshot('s1', (draft) => {
draft.queue = [row('q-1'), { ...row('q-2'), placement: 'steering' }, row('q-3')]
})
b.shell.steerQueue()
await vi.waitFor(() => {
expect(b.updateQueue).toHaveBeenCalledTimes(2)
})
expect(b.updateQueue).toHaveBeenNthCalledWith(1, 'q-1', { kind: 'steer' })
expect(b.updateQueue).toHaveBeenNthCalledWith(2, 'q-3', { kind: 'steer' })
expect(b.shell.notices.getSnapshot()).toBeNull()
await b.runtime.dispose()
})
it('converges silently when the turn closes or a row is claimed mid-steer', async () => {
const b = await bench()
await b.runtime.sessions.updateSnapshot('s1', (draft) => {
draft.queue = [row('q-1'), row('q-2')]
})
// The turn closes before the second row: the flush stops, silently.
b.updateQueue.mockResolvedValueOnce({
ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} },
} as never)
b.shell.steerQueue()
await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(1) })
expect(b.shell.notices.getSnapshot()).toBeNull()
// A row the host already claimed (e.g. a repeated empty-draft chord):
// the duplicate strict steer is a silent no-op.
await b.runtime.sessions.updateSnapshot('s1', (draft) => {
draft.queue = [row('q-3')]
})
b.updateQueue.mockResolvedValueOnce({
ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} },
} as never)
b.shell.steerQueue()
await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(2) })
expect(b.shell.notices.getSnapshot()).toBeNull()
await b.runtime.dispose()
})
it('surfaces one notice on a genuine steer failure and stops', async () => {
const b = await bench()
await b.runtime.sessions.updateSnapshot('s1', (draft) => {
draft.queue = [row('q-1'), row('q-2')]
})
b.updateQueue.mockResolvedValueOnce({
ok: false, error: { code: 'internal', message: 'broken', details: {} },
} as never)
b.shell.steerQueue()
await vi.waitFor(() => {
expect(b.shell.notices.getSnapshot()).toEqual(
expect.objectContaining({ level: 'error', text: '插话发送失败,请重试。' }),
)
})
expect(b.updateQueue).toHaveBeenCalledTimes(1)
await b.runtime.dispose()
})
it('no-ops without queued rows', async () => {
const b = await bench()
b.shell.steerQueue()
expect(b.updateQueue).not.toHaveBeenCalled()
await b.runtime.dispose()
})
})
@@ -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)}
@@ -301,7 +305,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()
})