Merge remote-tracking branch 'origin/master' into codex/pr-555-ci-fix
# Conflicts: # docs/module-graph.i18n.yaml # docs/module-graph.md # docs/module-graph.zh.md # packages/client/ui-conversation/src/client/input/hub.ts # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts
This commit is contained in:
@@ -159,7 +159,7 @@ export function apply(ctx: Context): void {
|
||||
|
||||
// The per-session input machine registry (InputService face; published as
|
||||
// ctx.conversation.input by the service below sharing this one instance).
|
||||
const inputHub = new InputHub(ctx)
|
||||
const inputHub = new InputHub(ctx, t)
|
||||
|
||||
// The composer-block registry: a plugin that knows a session cannot send —
|
||||
// ui-model, when no adapter serves the session's route — raises a block
|
||||
|
||||
@@ -104,6 +104,12 @@ export interface ComposerKeyboard {
|
||||
setDraft(text: string, editRange?: EditRange): void
|
||||
/** Submit with an explicit delivery mode resolved by the keyboard policy. */
|
||||
submit(mode: InputSubmitMode): void
|
||||
/**
|
||||
* Steer every still-pending queued message into the running turn (the
|
||||
* empty-draft accelerated-Enter gesture; the queue dock's per-row steer
|
||||
* button is the same operation applied to the whole queue).
|
||||
*/
|
||||
steerQueue(): void
|
||||
undo(): void
|
||||
redo(): void
|
||||
/** Paste over the selection (sync components ride the same transaction). */
|
||||
|
||||
@@ -39,6 +39,11 @@ export interface SessionInputDeps {
|
||||
popup?: (() => PopupDismissFace | undefined) | undefined
|
||||
/** Queue read face; overlaid onto InputState.queue (absent = empty). */
|
||||
queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined
|
||||
/**
|
||||
* Steer every still-pending queued message into the running turn, in FIFO
|
||||
* order (the empty-draft accelerated-Enter gesture); absent = unsupported.
|
||||
*/
|
||||
steerQueue?: (() => void) | undefined
|
||||
/** The plain-message sink (send choreography / materialize fork — the hub owns it). */
|
||||
defaultSink(text: string, imageIds: readonly DraftAttachmentId[], mode: InputSubmitMode): void
|
||||
}
|
||||
@@ -223,6 +228,16 @@ export class SessionInputShell implements SessionInput {
|
||||
return this.deps.slash?.()?.arbitrate(key, composing) ?? 'pass'
|
||||
}
|
||||
|
||||
/**
|
||||
* Steer every still-pending queued message into the running turn (the
|
||||
* empty-draft accelerated-Enter gesture). Execution belongs to the hub's
|
||||
* queue choreography; absent dep = the gesture falls back to the machine's
|
||||
* empty-draft no-op.
|
||||
*/
|
||||
steerQueue(): void {
|
||||
this.deps.steerQueue?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* Space adjudication over the controller's hot state.
|
||||
* @returns true = a claim/insert was applied — the caller preventDefaults.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { queueReadFaceOf } from '../queue/store.ts'
|
||||
import type { ComposerKeyboard, DraftAttachmentId, InputService, SessionInput } from './contract.ts'
|
||||
import type { InputSubmitMode } from '../contract/composer-submission.ts'
|
||||
@@ -36,8 +37,14 @@ interface ConversationAttachmentFace {
|
||||
export class InputHub implements InputService {
|
||||
private readonly shells = new Map<SessionId, SessionInputShell>()
|
||||
|
||||
/** @param ctx - client root context (services resolved lazily per call — boot order stays free). */
|
||||
constructor(private readonly rootCtx: ClientContext) {}
|
||||
/**
|
||||
* @param ctx - client root context (services resolved lazily per call — boot order stays free).
|
||||
* @param t - conversation-namespace translate thunk (reads the active locale at call time).
|
||||
*/
|
||||
constructor(
|
||||
private readonly rootCtx: ClientContext,
|
||||
private readonly t: TranslateNS<'conversation'>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolve the facade for one session-scope ctx (InputService face).
|
||||
@@ -69,6 +76,7 @@ export class InputHub implements InputService {
|
||||
popup: () => this.popup(actx),
|
||||
queue: queueReadFaceOf(session),
|
||||
defaultSink: (text, imageIds, mode) => { this.sink(session, text, imageIds, mode) },
|
||||
steerQueue: () => { void this.steerQueue(session, shell) },
|
||||
})
|
||||
this.shells.set(id, shell)
|
||||
// The one teardown axis: listeners, shell, and map entries all ride the
|
||||
@@ -159,6 +167,30 @@ export class InputHub implements InputService {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Steer every still-pending queued message into the running turn, in FIFO
|
||||
* order — the same strict-steer operation as the queue dock's per-row
|
||||
* button. A turn closing mid-way (`steer-unavailable`) or a row already
|
||||
* claimed by the agent (`queue-item-not-found`) converges silently, while a
|
||||
* genuine failure surfaces as one composer notice. Repeated triggers
|
||||
* (e.g. two rapid empty-draft chords) rely on that `queue-item-not-found`
|
||||
* convergence: the snapshot may still list a row the host already steered,
|
||||
* and the duplicate strict steer is a silent no-op.
|
||||
* @param session - the addressed host session.
|
||||
* @param shell - the resident shell (notice outlet).
|
||||
*/
|
||||
private async steerQueue(session: SessionFace, shell: SessionInputShell): Promise<void> {
|
||||
const queued = session.getSnapshot().queue.filter(item => item.placement === 'queued')
|
||||
if (queued.length === 0) return
|
||||
for (const item of queued) {
|
||||
const result = await session.updateQueue(item.id, { kind: 'steer' })
|
||||
if (result.ok) continue
|
||||
if (result.error.code === 'steer-unavailable' || result.error.code === 'queue-item-not-found') return
|
||||
shell.notify('error', this.t('queue.steerFailed'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private controller(actx: ClientContext): SlashController | undefined {
|
||||
const slash = this.rootCtx.get('slash')
|
||||
return slash?.sessionOf(actx)
|
||||
|
||||
@@ -23,6 +23,7 @@ export const zh = {
|
||||
'input.commands': '命令',
|
||||
'input.stop': '停止生成',
|
||||
'input.send': '发送消息',
|
||||
'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
|
||||
'input.accessMode': '访问模式,当前:{name}',
|
||||
'image.dropHint': '松开以添加图片',
|
||||
'image.pending': '待发送图片',
|
||||
@@ -180,6 +181,7 @@ export const en = {
|
||||
'input.commands': 'Commands',
|
||||
'input.stop': 'Stop generating',
|
||||
'input.send': 'Send message',
|
||||
'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages',
|
||||
'input.accessMode': 'Access mode, current: {name}',
|
||||
'image.dropHint': 'Drop to add images',
|
||||
'image.pending': 'Pending images',
|
||||
|
||||
@@ -109,6 +109,8 @@ export function InputBar({
|
||||
// be disabled do lock it — there is no session to choose a model for.
|
||||
const modelSeatLocked = removed || inert || !live
|
||||
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
|
||||
const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && subagent === null
|
||||
&& input.queue.some(row => row.placement === 'queued')
|
||||
|
||||
useEffect(() => {
|
||||
if (input === undefined || inputActions === undefined) return
|
||||
@@ -278,9 +280,19 @@ export function InputBar({
|
||||
e.preventDefault()
|
||||
if (e.repeat) return // held-down Enter must not machine-gun sends
|
||||
if (locked || machineBusy) return
|
||||
const accelerated = e.ctrlKey || e.metaKey
|
||||
// Empty-draft accelerated Enter acts on the queue instead of the (empty)
|
||||
// draft: the machine rejects empty drafts, so the gesture steers every
|
||||
// still-pending queued message into the running turn (the dock's per-row
|
||||
// steer button applied to the whole queue). Steering needs the same
|
||||
// window as the per-row button: a running ordinary session.
|
||||
if (accelerated && canSteerQueue) {
|
||||
keyboard.steerQueue()
|
||||
return
|
||||
}
|
||||
keyboard.submit(resolveSubmitMode(
|
||||
running,
|
||||
e.ctrlKey || e.metaKey ? 'accelerated' : 'enter',
|
||||
accelerated ? 'accelerated' : 'enter',
|
||||
subagent === null,
|
||||
))
|
||||
}
|
||||
@@ -585,7 +597,12 @@ export function InputBar({
|
||||
? t('placeholder.parentOffline')
|
||||
: disabled
|
||||
? t('placeholder.unavailable')
|
||||
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
|
||||
// The steer hint deliberately outranks the plan placeholder:
|
||||
// while it shows, the whole-queue gesture is genuinely available
|
||||
// (the gate never consults plan mode), so the actionable hint wins.
|
||||
: canSteerQueue
|
||||
? t('placeholder.steerQueue')
|
||||
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
|
||||
rows={2}
|
||||
onChange={(event) => {
|
||||
setDropError(null)
|
||||
|
||||
Reference in New Issue
Block a user