Merge remote-tracking branch 'origin/master' into worktree/web-theme-settings-integration-fde706

# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.md
#	packages/client/ui-conversation/README.zh.md
This commit is contained in:
Yichen Jiang
2026-08-10 15:21:45 +08:00
150 changed files with 940 additions and 251 deletions

View File

@@ -57,6 +57,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']
@@ -70,14 +74,34 @@ interface BenchOptions {
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,12 +113,6 @@ 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,
}))
const stop = vi.fn()
const menuLauncher = createSnapshotStore<string | null>(over?.commandMenuOpen === true ? 'command' : null)
const slotCalls: { key: string; owner: unknown }[] = []
@@ -151,10 +169,62 @@ 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,
steerQueue: over?.steerQueue,
}
}
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' })
@@ -194,6 +264,78 @@ describe('Enter semantics', () => {
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', () => {
const { textarea, shell } = bench({ draft: '' })
fireEvent.change(textarea, { target: { value: 'first' } })

View File

@@ -6,9 +6,12 @@
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 { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
import { InputHub } from '../src/client/input/hub.ts'
import { zh } from '../src/client/locales.ts'
async function bench() {
const runtime = await SlotTestRuntime.create()
@@ -22,14 +25,16 @@ async function bench() {
})
// 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, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder }
}
describe('ConversationService', () => {
@@ -87,10 +92,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()
})
})