Skip to content

Commit c0b97b0

Browse files
committed
feat(chat): configure follow-up sending with immediate default
Add a persisted immediate-or-queue preference for native conversations. Reuse the existing send-now path and preserve ACP queue behavior. Expose the preference in Input & interaction, ordered after voice input and before text selection, with keyboard shortcuts last. Cover legacy defaults, persistence failures, and device isolation, and add localized settings labels and user documentation.
1 parent 9e51317 commit c0b97b0

21 files changed

Lines changed: 486 additions & 10 deletions

‎src/crates/contracts/config-contracts/src/types.rs‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,24 @@ pub struct AppFlowChatConfig {
440440
/// Automatically show excerpt actions after selecting transcript text.
441441
#[serde(default = "default_auto_show_selection_toolbar")]
442442
pub auto_show_selection_toolbar: bool,
443+
/// How the shared composer submits follow-ups to a running native session.
444+
#[serde(skip_serializing_if = "ChatInputSendDuringTurn::is_immediate")]
445+
pub send_during_turn: ChatInputSendDuringTurn,
446+
}
447+
448+
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
449+
#[serde(rename_all = "snake_case")]
450+
pub enum ChatInputSendDuringTurn {
451+
Queue,
452+
#[default]
453+
#[serde(other)]
454+
Immediate,
455+
}
456+
457+
impl ChatInputSendDuringTurn {
458+
fn is_immediate(&self) -> bool {
459+
*self == Self::Immediate
460+
}
443461
}
444462

445463
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -469,6 +487,7 @@ impl Default for AppFlowChatConfig {
469487
last_mode_id: None,
470488
show_permission_mode_control: default_show_permission_mode_control(),
471489
auto_show_selection_toolbar: default_auto_show_selection_toolbar(),
490+
send_during_turn: ChatInputSendDuringTurn::default(),
472491
}
473492
}
474493
}
@@ -3080,6 +3099,48 @@ mod tests {
30803099
assert!(super::AppFlowChatConfig::default().auto_show_selection_toolbar);
30813100
}
30823101

3102+
#[test]
3103+
fn app_flow_chat_send_during_turn_defaults_and_round_trips() {
3104+
use super::{AppFlowChatConfig, ChatInputSendDuringTurn};
3105+
3106+
assert_eq!(
3107+
AppFlowChatConfig::default().send_during_turn,
3108+
ChatInputSendDuringTurn::Immediate
3109+
);
3110+
let legacy = serde_json::json!({
3111+
"default_mode_id": "Standard",
3112+
"auto_show_selection_toolbar": false
3113+
});
3114+
let config: AppFlowChatConfig = serde_json::from_value(legacy.clone()).unwrap();
3115+
assert_eq!(config.send_during_turn, ChatInputSendDuringTurn::Immediate);
3116+
let written = serde_json::to_value(config).unwrap();
3117+
assert_eq!(written, legacy);
3118+
let reloaded: AppFlowChatConfig = serde_json::from_value(written).unwrap();
3119+
assert_eq!(
3120+
reloaded.send_during_turn,
3121+
ChatInputSendDuringTurn::Immediate
3122+
);
3123+
3124+
let queued = serde_json::json!({
3125+
"send_during_turn": "queue",
3126+
"auto_show_selection_toolbar": true
3127+
});
3128+
let config: AppFlowChatConfig = serde_json::from_value(queued.clone()).unwrap();
3129+
assert_eq!(config.send_during_turn, ChatInputSendDuringTurn::Queue);
3130+
let written = serde_json::to_value(config).unwrap();
3131+
assert_eq!(written, queued);
3132+
let reloaded: AppFlowChatConfig = serde_json::from_value(written).unwrap();
3133+
assert_eq!(reloaded.send_during_turn, ChatInputSendDuringTurn::Queue);
3134+
3135+
for value in ["immediate", "future_policy"] {
3136+
let config: AppFlowChatConfig = serde_json::from_value(serde_json::json!({
3137+
"send_during_turn": value
3138+
}))
3139+
.unwrap();
3140+
assert_eq!(config.send_during_turn, ChatInputSendDuringTurn::Immediate);
3141+
}
3142+
}
3143+
30833144
#[test]
30843145
fn default_model_serialization_contains_no_retired_reasoning_fields() {
30853146
let config = AIModelConfig::default();

‎src/web-ui/README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,18 @@ configuration changes cannot silently overwrite one another. This management
2929
surface edits the local Desktop user's configuration; project-scoped editing,
3030
Peer Device management, and standalone Web management are not exposed by it.
3131

32+
## Sending follow-up messages
33+
34+
In **Settings → Input & interaction → Message sending**, choose how follow-up
35+
messages are sent during a native conversation. **Send immediately** is the
36+
default, including after upgrading: it delivers instructions to the running task
37+
through the existing send-now action. **Queue messages** waits for the current
38+
turn to finish. Buttons and keyboard submission share this preference.
39+
Already queued messages retain their individual send-now action. ACP sessions
40+
still wait for the current turn; Detached Dispatch sessions keep appending
41+
messages to the target task. In Peer Device Mode, the preference belongs to the
42+
active host, as do the other conversation settings.
43+
3244
## Floating conversations
3345

3446
The bottom-right window opens the persistent OpenBitFun control conversation in

‎src/web-ui/README.zh-CN.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@
2525
配置可继续使用 JSON。并发修改通过版本校验阻止静默覆盖。此管理页编辑本机 Desktop
2626
用户配置,不提供项目级、Peer Device 或独立 Web 端的配置管理入口。
2727

28+
## 对话中途发送消息
29+
30+
在 **设置 → 输入与交互 → 消息发送** 中选择原生会话执行期间的发送行为。
31+
默认(包括升级后)为 **立即发送**,通过现有的“立即发送”操作向当前任务补充指令;
32+
选择 **排队发送** 则等待当前轮次结束。按钮和键盘提交共用此偏好。
33+
已排队的消息仍可单独点击“立即发送”。ACP 会话仍等待当前轮次结束,远程派发会话
34+
继续向目标任务追加消息。Peer Device 模式下,与其他对话设置一样,此偏好属于当前主机。
35+
2836
## 浮动会话
2937

3038
右下角窗口默认以文字模式打开持久保存的 OpenBitFun 控制会话。将主标签栏的会话

‎src/web-ui/src/app/scenes/settings/pages/application/InputSettingsPage.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { SettingsPage } from '../shared/SettingsPage';
22
import KeyboardShortcutsSection from './KeyboardShortcutsSection';
33
import VoiceSettingsSection from './VoiceSettingsSection';
44
import TextSelectionSettingsSection from './TextSelectionSettingsSection';
5+
import MessageSendingSettingsSection from './MessageSendingSettingsSection';
56

67
export default function InputSettingsPage() {
78
return (
89
<SettingsPage pageId="application.input">
9-
<TextSelectionSettingsSection />
1010
<VoiceSettingsSection />
11+
<MessageSendingSettingsSection />
12+
<TextSelectionSettingsSection />
1113
<KeyboardShortcutsSection />
1214
</SettingsPage>
1315
);
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// @vitest-environment jsdom
2+
import { act } from 'react';
3+
import { createRoot, type Root } from 'react-dom/client';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
import { activateSurface } from '@/infrastructure/peer-device/deviceSurface';
6+
import MessageSendingSettingsSection from './MessageSendingSettingsSection';
7+
8+
const mocks = vi.hoisted(() => ({ get: vi.fn(), set: vi.fn(), error: vi.fn(), listeners: new Set<() => void>() }));
9+
vi.mock('@/infrastructure/config/services/ConfigManager', () => ({ configManager: {
10+
hasCachedConfig: () => false, getCachedConfig: () => undefined,
11+
getOptionalConfig: mocks.get, setConfig: mocks.set,
12+
watch: (_path: string, listener: () => void) => {
13+
mocks.listeners.add(listener);
14+
return () => mocks.listeners.delete(listener);
15+
},
16+
} }));
17+
vi.mock('@openbitfun/ui', async original => ({
18+
...await original<typeof import('@openbitfun/ui')>(),
19+
Select: ({ value, disabled, options, onValueChange }: {
20+
value: string; disabled: boolean; options: Array<{ value: string; label: string }>;
21+
onValueChange: (value: string) => void;
22+
}) => <select value={value} disabled={disabled} onChange={event => onValueChange(event.target.value)}>
23+
{options.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
24+
</select>,
25+
}));
26+
vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }));
27+
vi.mock('@/shared/notification-system', () => ({ notificationService: { error: mocks.error } }));
28+
29+
describe('message sending settings persistence', () => {
30+
let root: Root;
31+
let container: HTMLDivElement;
32+
const select = () => container.querySelector('select')!;
33+
const choose = async (value: string) => act(async () => {
34+
select().value = value;
35+
select().dispatchEvent(new Event('change', { bubbles: true }));
36+
});
37+
beforeEach(() => {
38+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
39+
activateSurface('local');
40+
mocks.get.mockReset().mockResolvedValue(undefined);
41+
mocks.set.mockReset().mockImplementation(async (_path: string, value: string) => {
42+
mocks.get.mockResolvedValue(value);
43+
mocks.listeners.forEach(listener => listener());
44+
});
45+
mocks.error.mockReset();
46+
mocks.listeners.clear();
47+
container = document.createElement('div');
48+
document.body.append(container);
49+
root = createRoot(container);
50+
});
51+
afterEach(() => { act(() => root.unmount()); container.remove(); activateSurface('local'); });
52+
53+
it('starts with immediate and persists both choices using only the preference leaf', async () => {
54+
await act(async () => root.render(<MessageSendingSettingsSection />));
55+
expect(select().value).toBe('immediate');
56+
await choose('queue');
57+
expect(mocks.set).toHaveBeenLastCalledWith('app.flow_chat.send_during_turn', 'queue');
58+
expect(select().value).toBe('queue');
59+
await choose('immediate');
60+
expect(mocks.set).toHaveBeenLastCalledWith('app.flow_chat.send_during_turn', 'immediate');
61+
expect(select().value).toBe('immediate');
62+
expect(select().disabled).toBe(false);
63+
});
64+
65+
it('retains the saved behavior when persistence fails', async () => {
66+
mocks.set.mockRejectedValue(new Error('Write failed'));
67+
await act(async () => root.render(<MessageSendingSettingsSection />));
68+
await choose('queue');
69+
expect(select().value).toBe('immediate');
70+
expect(mocks.error).toHaveBeenCalledWith('messageSending.saveFailed');
71+
expect(select().disabled).toBe(false);
72+
});
73+
74+
it('keeps the picker unavailable after a failed read and allows recovery', async () => {
75+
mocks.get.mockRejectedValue(new Error('Host unavailable'));
76+
await act(async () => root.render(<MessageSendingSettingsSection />));
77+
expect(select()).toBeNull();
78+
expect(container.textContent).toContain('messageSending.loadFailed');
79+
mocks.get.mockResolvedValue('queue');
80+
await act(async () => container.querySelector('button')!.click());
81+
expect(select().value).toBe('queue');
82+
});
83+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { useState } from 'react';
2+
import { Select } from '@openbitfun/ui';
3+
import { useI18n } from '@/infrastructure/i18n';
4+
import { ConfigLoadingState, ConfigPageRow, ConfigPageSection, ConfigRetryState } from '@/infrastructure/config/components/common';
5+
import { useChatInputSendPreference } from '@/infrastructure/config/hooks/useChatInputSendPreference';
6+
import { configManager } from '@/infrastructure/config/services/ConfigManager';
7+
import { SEND_DURING_TURN_CONFIG_PATH, type ChatInputSendDuringTurn } from '@/infrastructure/config/services/ChatInputSendPreference';
8+
import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface';
9+
import { notificationService } from '@/shared/notification-system';
10+
import { createLogger } from '@/shared/utils/logger';
11+
import { useSettingsSectionAnchor } from '../shared/useSettingsSectionAnchor';
12+
13+
const log = createLogger('MessageSendingSettingsSection');
14+
15+
export default function MessageSendingSettingsSection() {
16+
const { t } = useI18n('settings/application');
17+
const { behavior, error, reload } = useChatInputSendPreference();
18+
const [savingEpoch, setSavingEpoch] = useState<number | null>(null);
19+
const scope = getActiveSurfaceScope();
20+
const saving = savingEpoch === scope.epoch;
21+
const sectionAnchor = useSettingsSectionAnchor('message-sending', behavior !== null || !!error);
22+
23+
const save = async (value: ChatInputSendDuringTurn) => {
24+
if (saving || behavior === null || behavior === value) return;
25+
setSavingEpoch(scope.epoch);
26+
try {
27+
await configManager.setConfig(SEND_DURING_TURN_CONFIG_PATH, value);
28+
} catch (reason) {
29+
if (scope.isCurrent()) {
30+
log.error('Failed to save chat input send preference', reason);
31+
notificationService.error(t('messageSending.saveFailed'));
32+
}
33+
} finally {
34+
if (scope.isCurrent()) setSavingEpoch(null);
35+
}
36+
};
37+
38+
return <div id={sectionAnchor}>
39+
<ConfigPageSection title={t('messageSending.title')}>
40+
{error ? <ConfigRetryState message={t('messageSending.loadFailed')}
41+
retryLabel={t('common.retry')} onRetry={() => void reload()} />
42+
: behavior === null ? <ConfigLoadingState label={t('messageSending.loading')} />
43+
: <ConfigPageRow label={t('messageSending.duringTurn')}
44+
description={t('messageSending.description')} align="center">
45+
<Select size="sm" value={behavior} disabled={saving}
46+
aria-label={t('messageSending.duringTurn')}
47+
options={[
48+
{ value: 'immediate', label: t('messageSending.immediate') },
49+
{ value: 'queue', label: t('messageSending.queue') },
50+
]}
51+
onValueChange={value => {
52+
if (value === 'immediate' || value === 'queue') void save(value);
53+
}} />
54+
</ConfigPageRow>}
55+
</ConfigPageSection>
56+
</div>;
57+
}

‎src/web-ui/src/app/scenes/settings/settingsRegistry.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe('settings information architecture', () => {
3131
expect(new Set(SETTINGS_PAGE_MANIFESTS.map(page => page.id)).size).toBe(19);
3232
expect(SETTINGS_PAGE_MANIFESTS.filter(page => page.views?.length).map(page => page.id)).toEqual(['tools.external-agents']);
3333
expect(getSettingsPageManifest('tools.external-agents').views?.map(view => view.id)).toEqual(['local', 'ssh', 'json']);
34-
expect(getSettingsPageManifest('application.input').sections?.map(section => section.id)).toEqual(['text-selection', 'voice', 'shortcuts']);
34+
expect(getSettingsPageManifest('application.input').sections?.map(section => section.id)).toEqual(['voice', 'message-sending', 'text-selection', 'shortcuts']);
3535
expect(getSettingsPageManifest('application.pet').sections?.map(section => section.id)).toEqual(['voice-call', 'pet']);
3636
expect(getSettingsPageManifest('tools.automation').sections?.map(section => section.id)).toEqual(['quick-actions', 'hooks']);
3737
});

‎src/web-ui/src/app/scenes/settings/settingsRegistry.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,18 +129,24 @@ export const SETTINGS_PAGE_MANIFESTS: readonly SettingsPageManifest[] = [
129129
namespaces: ['settings', 'settings/application', 'settings/voice-input'],
130130
searchPhrases: [],
131131
sections: [
132-
{
133-
id: 'text-selection',
134-
labelKey: 'navigation.sections.text-selection',
135-
keywords: ['selection', 'annotation', 'excerpt', 'toolbar', 'popup', 'flowchat'],
136-
searchPhrases: [phrase('settings/application', 'textSelection.title'), phrase('settings/application', 'textSelection.autoShowToolbar'), phrase('settings/application', 'textSelection.autoShowToolbarDescription')],
137-
},
138132
{
139133
id: 'voice',
140134
labelKey: 'navigation.sections.voice',
141135
keywords: ['voice', 'speech', 'microphone', 'dictation', 'transcription'],
142136
searchPhrases: [phrase('settings/voice-input', 'title'), phrase('settings/voice-input', 'subtitle')],
143137
},
138+
{
139+
id: 'message-sending',
140+
labelKey: 'navigation.sections.message-sending',
141+
keywords: ['message', 'send', 'queue', 'immediate', 'steering', 'flowchat'],
142+
searchPhrases: [phrase('settings/application', 'messageSending.title'), phrase('settings/application', 'messageSending.duringTurn'), phrase('settings/application', 'messageSending.description')],
143+
},
144+
{
145+
id: 'text-selection',
146+
labelKey: 'navigation.sections.text-selection',
147+
keywords: ['selection', 'annotation', 'excerpt', 'toolbar', 'popup', 'flowchat'],
148+
searchPhrases: [phrase('settings/application', 'textSelection.title'), phrase('settings/application', 'textSelection.autoShowToolbar'), phrase('settings/application', 'textSelection.autoShowToolbarDescription')],
149+
},
144150
{
145151
id: 'shortcuts',
146152
labelKey: 'navigation.sections.shortcuts',

‎src/web-ui/src/app/scenes/settings/settingsTypes.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export type SettingsViewId =
3333

3434
/** Anchors in a single settings page, not nested pages or tab panels. */
3535
export type SettingsSectionId =
36+
| 'message-sending'
3637
| 'text-selection'
3738
| 'pet'
3839
| 'voice-call'

0 commit comments

Comments
 (0)