feat(#1766): support OpenCode steer delivery / follow-up behavior settings (#1781)

* feat: support OpenCode steer delivery / follow-up behavior settings

Implements issue #1766 — steer delivery mode for mid-turn message
insertion, replacing the old boolean queue-mode toggle with a tri-state
follow-up behavior setting (Steer / Queue / Send immediately).

- Plumbing: threaded optional delivery: 'steer' through sendMessage
  -> routeMessage -> opencodeClient.sendMessage -> promptAsync
- Store: messageQueueStore stores followUpBehavior; migration from
  legacy queueModeEnabled persisted state
- Settings: Chat -> Follow-up behavior shows three radio options
  using existing settings UI patterns
- Composer: when session is busy, a floating queue button remains;
  force-sending a queued message (via chip click) uses delivery: 'steer'
  during a busy session; Steer button intentionally omitted — steer is
  available via the two-gesture path (Enter to queue -> chip to steer)
- Keyboard: queue mode = Enter queues, Ctrl+Enter sends; otherwise
  Enter sends, Ctrl+Enter queues
- Persistence: DesktopSettings, web settings payload, and server-side
  sanitizer handle the new key with legacy fallback
- i18n: follow-up behavior section and option labels in all 9 locales
  plus new chat.chatInput.actions.queue label
- Search: settings registry updated from chat.queue-mode to
  chat.follow-up-behavior

Validation: type-check passes (no new errors), lint clean.

* fix(#1766): make steer mode actually steer

The followUpBehavior === 'steer' branch in handlePrimaryAction and the
keyboard handler was a no-op — both fell into the else branch and sent
without the delivery: 'steer' flag, so selecting 'Steer (insert into
the running turn)' in settings produced identical behavior to 'Send
immediately'.

- handlePrimaryAction: when steer mode is selected and the session is
  busy, call handleSubmit({ delivery: 'steer' }) directly
- Keyboard handler: in steer mode, Enter steers and Ctrl+Enter sends
  immediately (consistent with queue mode where Ctrl+Enter bypasses
  the special handling)

Also removes the unused chat.chatInput.actions.queue i18n key from all
9 locales (it was a dead key after the Steer button was removed from
the composer).

Validation: type-check clean, lint clean.

* refactor(#1766): flatten nested ternary in followUpBehavior resolution

Replace nested ternary with explicit if/else chain per project code style
(CONTRIBUTING.md). Import FollowUpBehavior type explicitly for the new
let declaration.

* feat(chat): drop redundant 'immediate' follow-up mode, keep Queue + Steer

'Immediate' was wire-identical to 'Steer' on a busy session: OpenCode only
supports delivery 'steer' | 'queue' and defaults to 'steer', so an immediate
send (no delivery flag) already steered into the running turn. The three-mode
UI therefore exposed two settings that did the same thing.

Collapse to two modes — Queue (unchanged: client-side queue with edit/reorder)
and Steer. Any persisted/legacy 'immediate' (and legacy queueModeEnabled=false)
now maps to 'steer', preserving prior behavior. Removes the immediate option,
its keyboard branch, the i18n label across all locales, and narrows the
followUpBehavior union to 'steer' | 'queue'.

---------

Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
bashrusakh
2026-06-29 01:28:20 +03:00
committed by GitHub
co-authored by Leonid Skorobogatyy Bohdan Triapitsyn
parent c184ddf185
commit f13f6d5540
22 changed files with 244 additions and 78 deletions
+55 -8
View File
@@ -4,6 +4,40 @@ import { getSafeStorage } from './utils/safeStorage';
import type { AttachedFile } from './types/sessionTypes';
import { updateDesktopSettings } from '@/lib/persistence';
export type FollowUpBehavior = 'steer' | 'queue';
export const DEFAULT_FOLLOW_UP_BEHAVIOR: FollowUpBehavior = 'queue';
export const isFollowUpBehavior = (value: unknown): value is FollowUpBehavior => (
value === 'steer' || value === 'queue'
);
export const normalizeFollowUpBehavior = (
value: unknown,
legacyQueueModeEnabled?: boolean | null,
): FollowUpBehavior => {
// "immediate" was removed: on a busy session it was wire-identical to
// "steer" (OpenCode only supports delivery "steer" | "queue", defaulting
// to "steer"), so collapse any persisted/legacy "immediate" onto "steer".
if (value === 'immediate') {
return 'steer';
}
if (isFollowUpBehavior(value)) {
return value;
}
if (legacyQueueModeEnabled === false) {
return 'steer';
}
if (legacyQueueModeEnabled === true) {
return 'queue';
}
return DEFAULT_FOLLOW_UP_BEHAVIOR;
};
export interface QueuedMessage {
id: string;
content: string;
@@ -20,7 +54,7 @@ export interface QueuedMessage {
interface MessageQueueState {
queuedMessages: Record<string, QueuedMessage[]>; // sessionId → queue
queueModeEnabled: boolean; // global toggle
followUpBehavior: FollowUpBehavior;
}
interface MessageQueueActions {
@@ -30,18 +64,24 @@ interface MessageQueueActions {
popToInput: (sessionId: string, messageId: string) => QueuedMessage | null;
clearQueue: (sessionId: string) => void;
clearAllQueues: () => void;
setQueueMode: (enabled: boolean) => void;
setFollowUpBehavior: (behavior: FollowUpBehavior) => void;
getQueueForSession: (sessionId: string) => QueuedMessage[];
}
type MessageQueueStore = MessageQueueState & MessageQueueActions;
type PersistedMessageQueueState = {
queuedMessages?: Record<string, QueuedMessage[]>;
followUpBehavior?: FollowUpBehavior;
queueModeEnabled?: boolean;
};
export const useMessageQueueStore = create<MessageQueueStore>()(
devtools(
persist(
(set, get) => ({
queuedMessages: {},
queueModeEnabled: true,
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
addToQueue: (sessionId, message) => {
const id = `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
@@ -149,10 +189,9 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
set({ queuedMessages: {} });
},
setQueueMode: (enabled) => {
set({ queueModeEnabled: enabled });
// Persist to settings.json (async, fire-and-forget)
void updateDesktopSettings({ queueModeEnabled: enabled });
setFollowUpBehavior: (behavior) => {
set({ followUpBehavior: behavior });
void updateDesktopSettings({ followUpBehavior: behavior });
},
getQueueForSession: (sessionId) => {
@@ -161,11 +200,19 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
}),
{
name: 'message-queue-store',
version: 1,
storage: createJSONStorage(() => getSafeStorage()),
partialize: (state) => ({
queuedMessages: state.queuedMessages,
queueModeEnabled: state.queueModeEnabled,
followUpBehavior: state.followUpBehavior,
}),
migrate: (persistedState) => {
const state = (persistedState ?? {}) as PersistedMessageQueueState;
return {
queuedMessages: state.queuedMessages ?? {},
followUpBehavior: normalizeFollowUpBehavior(state.followUpBehavior, state.queueModeEnabled ?? null),
};
},
}
),
{