Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/chat-chain-changes/2026-08-09-pr-2454-busy-input-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
date: 2026-08-09
pr: 2454
feature: busy-input-mode-main-chat
impact: The main chat now has a working Queue / Interrupt / Steer selector for sending messages while a run is live; interrupt stops the current run (abort + wait for abort.completed) before sending, and steer rewrites the message to /steer so it is injected into the current bridge run instead of queued.
---

# Busy input mode for the main chat

`busy_input_mode` existed only in API types and i18n strings; the input box was never locked, but messages sent while the AI was processing were silently queued behind the whole current run, with no way to interrupt or steer from the main chat (group chat already had `interruptAgent`).

The `sendMessage` path in the chat store now honours the setting:

- `steer` — while a bridge run is live, the message is rewritten to `/steer <text>` so it is injected into the current run (skipped for coding-agent sessions, which have no bridge steer method, and for explicit slash commands).
- `interrupt` — before sending, emits `abort` and waits for `abort.completed` (synced) up to 20s, so the new message is processed immediately instead of being queued.
- `queue` (default) — existing behaviour unchanged.

Display settings gained a dropdown (Queue / Interrupt / Steer) with en/zh labels. Client-side only: the server already handled `abort` and `/steer`.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NButton, NSwitch, NInputNumber, useMessage } from 'naive-ui'
import { NButton, NSwitch, NInputNumber, NSelect, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/hermes/settings'
import { primeCompletionSound } from '@/utils/completion-sound'
Expand Down Expand Up @@ -30,6 +30,17 @@ function resetChatInputHeight() {
return save({ chat_input_height: null })
}

const busyInputModeOptions = computed(() => [
{ label: t('settings.display.busyInputModeQueue'), value: 'queue' },
{ label: t('settings.display.busyInputModeInterrupt'), value: 'interrupt' },
{ label: t('settings.display.busyInputModeSteer'), value: 'steer' },
])

function handleBusyInputModeChange(value: string | null) {
const mode = value === 'interrupt' || value === 'steer' ? value : 'queue'
return save({ busy_input_mode: mode })
}

function notificationPermissionErrorKey(result: CompletionNotificationPermissionResult): string {
if (result.reason === 'insecure') return 'settings.display.notifyOnCompleteInsecure'
if (result.reason === 'unsupported') return 'settings.display.notifyOnCompleteUnsupported'
Expand Down Expand Up @@ -159,6 +170,15 @@ async function testCompletionNotification() {
</NButton>
</div>
</SettingRow>
<SettingRow :label="t('settings.display.busyInputMode')" :hint="t('settings.display.busyInputModeHint')">
<NSelect
:value="settingsStore.display.busy_input_mode || 'queue'"
:options="busyInputModeOptions"
style="width: 160px"
size="small"
@update:value="handleBusyInputModeChange"
/>
</SettingRow>
<SettingRow :label="t('settings.display.chatInputHeight')" :hint="t('settings.display.chatInputHeightHint')">
<div class="chat-input-height-controls">
<NInputNumber
Expand Down
5 changes: 4 additions & 1 deletion packages/client/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2075,7 +2075,10 @@ export default {
notifyOnCompleteTestSent: 'Test notification sent',
notifyOnCompleteTestFailed: 'Failed to send test notification',
busyInputMode: 'Busy Input Mode',
busyInputModeHint: 'Allow input while AI is processing',
busyInputModeHint: 'What happens when you send a message while the AI is still processing',
busyInputModeQueue: 'Queue (default)',
busyInputModeInterrupt: 'Interrupt (stop current run and respond now)',
busyInputModeSteer: 'Steer (inject into current run)',
theme: 'Theme',
themeHint: 'Choose light, dark, or follow system preference',
themeLight: 'Light',
Expand Down
5 changes: 4 additions & 1 deletion packages/client/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2067,7 +2067,10 @@ export default {
notifyOnCompleteTestSent: '测试通知已发送',
notifyOnCompleteTestFailed: '测试通知发送失败',
busyInputMode: '忙碌输入模式',
busyInputModeHint: 'AI 处理中仍可输入',
busyInputModeHint: 'AI 处理中发送消息时的行为',
busyInputModeQueue: '排队(默认)',
busyInputModeInterrupt: '打断(停止当前回合,立即响应)',
busyInputModeSteer: '注入(steer 当前回合)',
theme: '主题',
themeHint: '选择浅色、暗色或跟随系统',
themeLight: '浅色',
Expand Down
75 changes: 73 additions & 2 deletions packages/client/src/stores/hermes/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3064,12 +3064,60 @@ export const useChatStore = defineStore('chat', () => {
})
}

/**
* busy_input_mode='interrupt': stop the current run and wait for abort.completed
* before the new message is sent, so the follow-up message is handled immediately
* instead of being queued behind a possibly multi-minute run.
* - Local stream (streamStates ctrl): abort locally, no server event to wait for.
* - Server-side bridge run: emit abort + wait for abort.completed (synced).
* Returns true when the abort was confirmed synced (or nothing was running),
* false on timeout/absence of socket.
*/
function interruptAndWaitForAbort(sessionId: string, timeoutMs = 20000): Promise<boolean> {
const ctrl = streamStates.value.get(sessionId)
if (ctrl) {
setAbortState(sessionId, { aborting: true, synced: null })
ctrl.abort()
const msgs = getSessionMsgs(sessionId)
const lastMsg = msgs[msgs.length - 1]
if (lastMsg?.isStreaming) {
updateMessage(sessionId, lastMsg.id, { isStreaming: false })
}
return Promise.resolve(true)
}
if (!serverWorking.value.has(sessionId)) return Promise.resolve(true)
return new Promise((resolve) => {
const socket = getChatRunSocket(runtimeTransport())
if (!socket) {
resolve(false)
return
}
let settled = false
const timer = setTimeout(() => {
if (!settled) {
settled = true
resolve(false)
}
}, timeoutMs)
const onCompleted = (data: any) => {
if (data?.session_id !== sessionId) return
if (!settled) {
settled = true
clearTimeout(timer)
resolve(Boolean(data?.synced))
}
}
socket.once('abort.completed', onCompleted)
socket.emit('abort', { session_id: sessionId })
})
}

async function sendMessage(content: string, attachments?: Attachment[]) {
if ((!content.trim() && !(attachments && attachments.length > 0))) return

primeNotificationSoundIfEnabled()

const trimmedContent = content.trim()
let trimmedContent = content.trim()

if (!activeSession.value) {
const session = createSession()
Expand All @@ -3082,6 +3130,17 @@ export const useChatStore = defineStore('chat', () => {
? activeSession.value.messageCount == null || activeSession.value.messageCount === 0
: false
const isCodingAgentSession = isCodingAgentLikeSession(activeSession.value)
const busyInputMode = useSettingsStore().display.busy_input_mode || 'queue'
// busy_input_mode='steer': while a bridge run is live, rewrite the message into
// a /steer command so it is injected into the current run instead of queued.
// Coding-agent sessions and explicit slash commands pass through unchanged.
const steerRewrite = busyInputMode === 'steer'
&& !isCodingAgentSession
&& isSessionLive(sid)
&& !isKnownBridgeSessionCommand(trimmedContent)
if (steerRewrite) {
trimmedContent = `/steer ${trimmedContent}`
}
const isBridgeSlashCommand = !isCodingAgentSession && isKnownBridgeSessionCommand(trimmedContent)
const isBridgeCompressCommand = isBridgeSlashCommand && /^\/compress(?:\s|$)/i.test(trimmedContent)
const isBridgePlanCommand = isBridgeSlashCommand && /^\/plan(?:\s|$)/i.test(trimmedContent)
Expand All @@ -3096,11 +3155,17 @@ export const useChatStore = defineStore('chat', () => {
: trimmedContent
const shouldOptimisticallyShowRunStatus = !isCodingAgentSession && !isBridgeForkCommand
const wasLiveBeforeSend = isSessionLive(sid)
// busy_input_mode='interrupt': stop the current run first so the new message
// is handled immediately instead of being queued. Skipped for bridge slash
// commands (they already bypass the queue by design).
const interruptBeforeSend = busyInputMode === 'interrupt'
&& wasLiveBeforeSend
&& !isBridgeSlashCommand
if (isBridgeForkCommand) {
if (pendingForkCommands.value.has(sid)) return
pendingForkCommands.value = new Set(pendingForkCommands.value).add(sid)
}
const shouldQueue = wasLiveBeforeSend && (
const shouldQueue = !interruptBeforeSend && wasLiveBeforeSend && (
!isBridgeSlashCommand ||
isBridgePlanCommand ||
isBridgeSkillCommand ||
Expand Down Expand Up @@ -3130,6 +3195,12 @@ export const useChatStore = defineStore('chat', () => {
}
clearMessageReference(sid)

// busy_input_mode='interrupt': abort the current run and wait for it to
// settle before submitting the new message, so it is processed immediately.
if (interruptBeforeSend) {
await interruptAndWaitForAbort(sid)
}

let runSubmitted = false
try {

Expand Down
43 changes: 40 additions & 3 deletions tests/client/display-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,23 @@ vi.mock('naive-ui', async () => {
return () => h('button', { type: 'button', onClick: () => emit('click') }, slots.default?.())
},
}),
NSelect: defineComponent({
props: {
value: { type: String, required: false, default: '' },
},
emits: ['update:value'],
setup(props, { emit }) {
return () => h('select', {
'data-testid': 'busy-input-mode',
value: props.value ?? '',
onChange: (event: Event) => emit('update:value', (event.target as HTMLSelectElement).value),
}, [
h('option', { value: 'queue' }, 'queue'),
h('option', { value: 'interrupt' }, 'interrupt'),
h('option', { value: 'steer' }, 'steer'),
])
},
}),
useMessage: () => ({
success: vi.fn(),
error: vi.fn(),
Expand Down Expand Up @@ -175,7 +192,7 @@ describe('DisplaySettings', () => {
expect(mockSettingsStore.saveSection).not.toHaveBeenCalledWith('display', { notify_on_approval: true })
})

it('does not expose the unwired busy input mode toggle', () => {
it('exposes the busy input mode selector with the configured mode', () => {
const wrapper = mount(DisplaySettings, {
global: {
stubs: {
Expand All @@ -189,8 +206,28 @@ describe('DisplaySettings', () => {
},
})

expect(wrapper.text()).not.toContain('settings.display.busyInputMode')
expect(wrapper.text()).not.toContain('settings.display.busyInputModeHint')
expect(wrapper.text()).toContain('settings.display.busyInputMode')
expect(wrapper.text()).toContain('settings.display.busyInputModeHint')
})

it('saves the chosen busy input mode', async () => {
const wrapper = mount(DisplaySettings, {
global: {
stubs: {
SettingRow: {
props: ['label', 'hint'],
template: '<div class="setting-row"><div class="setting-row-label">{{ label }}</div><div class="setting-row-hint">{{ hint }}</div><slot /></div>',
},
NSelect: true,
NSwitch: true,
},
},
})

const select = wrapper.get('[data-testid="busy-input-mode"]')
await select.setValue('steer')
await flushPromises()
expect(mockSettingsStore.saveSection).toHaveBeenCalledWith('display', { busy_input_mode: 'steer' })
})

it('saves a clamped chat input height and can reset back to automatic height', async () => {
Expand Down
Loading