Skip to content

Commit 12fbef8

Browse files
feat: Queue vs Steer follow-up parity with intent shortcuts (#467)
Co-authored-by: Thomas Ricouard <ricouard77@gmail.com>
1 parent ac15441 commit 12fbef8

33 files changed

Lines changed: 1046 additions & 223 deletions

AGENTS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ Use project aliases for frontend imports:
7676

7777
For broader path maps, use `docs/codebase-map.md`.
7878

79+
## Follow-up Behavior Map
80+
81+
For Queue vs Steer follow-up behavior, start here:
82+
83+
- Settings model + defaults: `src/types.ts`, `src/features/settings/hooks/useAppSettings.ts`
84+
- Settings persistence/migration: `src-tauri/src/types.rs`, `src-tauri/src/storage.rs`
85+
- Composer runtime behavior: `src/features/composer/components/Composer.tsx`
86+
- Send intent routing: `src/features/threads/hooks/useQueuedSend.ts`, `src/features/threads/hooks/useThreadMessaging.ts`
87+
- App/layout wiring: `src/features/app/hooks/useComposerController.ts`, `src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx`, `src/App.tsx`
88+
7989
## App/Daemon Parity Checklist
8090

8191
When changing backend behavior that can run remotely:

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ CodexMonitor is a Tauri app for orchestrating multiple Codex agents across local
1717

1818
### Composer & Agent Controls
1919

20-
- Compose with queueing plus image attachments (picker, drag/drop, paste).
20+
- Compose with image attachments (picker, drag/drop, paste) and configurable follow-up behavior (`Queue` vs `Steer` while a run is active).
21+
- Use `Shift+Cmd+Enter` (macOS) or `Shift+Ctrl+Enter` (Windows/Linux) to send the opposite follow-up action for a single message.
2122
- Autocomplete for skills (`$`), prompts (`/prompts:`), reviews (`/review`), and file paths (`@`).
2223
- Model picker, collaboration modes (when enabled), reasoning effort, access mode, and context usage ring.
2324
- Dictation with hold-to-talk shortcuts and live waveform (Whisper).
@@ -250,8 +251,8 @@ src-tauri/
250251
## Notes
251252

252253
- Workspaces persist to `workspaces.json` under the app data directory.
253-
- App settings persist to `settings.json` under the app data directory (theme, backend mode/provider, remote endpoints/tokens, Codex path, default access mode, UI scale).
254-
- Feature settings are supported in the UI and synced to `$CODEX_HOME/config.toml` (or `~/.codex/config.toml`) on load/save. Stable: Collaboration modes (`features.collaboration_modes`), personality (`personality`), Steer mode (`features.steer`), and Background terminal (`features.unified_exec`). Experimental: Apps (`features.apps`).
254+
- App settings persist to `settings.json` under the app data directory (theme, backend mode/provider, remote endpoints/tokens, Codex path, default access mode, UI scale, follow-up message behavior).
255+
- Feature settings are supported in the UI and synced to `$CODEX_HOME/config.toml` (or `~/.codex/config.toml`) on load/save. Stable: Collaboration modes (`features.collaboration_modes`), personality (`personality`), and Background terminal (`features.unified_exec`). Experimental: Apps (`features.apps`). Steering capability still follows Codex `features.steer`, but follow-up default behavior is controlled in Settings → Composer.
255256
- On launch and on window focus, the app reconnects and refreshes thread lists for each workspace.
256257
- Threads are restored by filtering `thread/list` results using the workspace `cwd`.
257258
- Selecting a thread always calls `thread/resume` to refresh messages from disk.

docs/app-server-events.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ These are v2 request methods CodexMonitor currently sends to Codex app-server:
125125
- `thread/compact/start`
126126
- `thread/name/set`
127127
- `turn/start`
128-
- `turn/steer` (best-effort; falls back to `turn/start` when unsupported)
128+
- `turn/steer` (used for explicit steer follow-ups while a turn is active)
129129
- `turn/interrupt`
130130
- `review/start`
131131
- `model/list`
@@ -253,9 +253,9 @@ Use this when the method list is unchanged but behavior looks off.
253253
- Stored in `useThreadsReducer.ts` (`turnDiffByThread`)
254254
- Exposed by `useThreads.ts` for UI consumers
255255
- Steering behavior while a turn is processing:
256-
- CodexMonitor attempts `turn/steer` when steering is enabled and an active turn exists.
257-
- If the server/daemon reports unknown `turn/steer`/`turn_steer`, CodexMonitor
258-
degrades to `turn/start` and caches that workspace as steer-unsupported.
256+
- CodexMonitor attempts `turn/steer` only when steer capability is enabled, the thread is processing, and an active turn id exists.
257+
- If `turn/steer` fails, CodexMonitor does not fall back to `turn/start`; it clears stale processing/turn state when applicable, surfaces an error, and returns `steer_failed`.
258+
- Local queue fallback on `steer_failed` is handled in the composer queued-send flow (`useQueuedSend`), not by all direct `sendUserMessageToThread` callers.
259259
- Feature toggles in Settings:
260260
- `experimentalFeature/list` is an app-server request.
261261
- Toggle writes use local/daemon command surfaces (`set_codex_feature_flag` and app settings update),

src-tauri/src/storage.rs

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@ pub(crate) fn read_settings(path: &PathBuf) -> Result<AppSettings, String> {
2929
return Ok(AppSettings::default());
3030
}
3131
let data = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
32-
match serde_json::from_str(&data) {
32+
let mut value: Value = serde_json::from_str(&data).map_err(|e| e.to_string())?;
33+
migrate_follow_up_message_behavior(&mut value);
34+
match serde_json::from_value(value.clone()) {
3335
Ok(settings) => Ok(settings),
3436
Err(_) => {
35-
let mut value: Value = serde_json::from_str(&data).map_err(|e| e.to_string())?;
3637
sanitize_remote_settings_for_tcp_only(&mut value);
38+
migrate_follow_up_message_behavior(&mut value);
3739
serde_json::from_value(value).map_err(|e| e.to_string())
3840
}
3941
}
@@ -72,6 +74,24 @@ fn sanitize_remote_settings_for_tcp_only(value: &mut Value) {
7274
root.retain(|key, _| !key.to_ascii_lowercase().starts_with("orb"));
7375
}
7476

77+
fn migrate_follow_up_message_behavior(value: &mut Value) {
78+
let Value::Object(root) = value else {
79+
return;
80+
};
81+
if root.contains_key("followUpMessageBehavior") {
82+
return;
83+
}
84+
let steer_enabled = root
85+
.get("steerEnabled")
86+
.or_else(|| root.get("experimentalSteerEnabled"))
87+
.and_then(Value::as_bool)
88+
.unwrap_or(true);
89+
root.insert(
90+
"followUpMessageBehavior".to_string(),
91+
Value::String(if steer_enabled { "steer" } else { "queue" }.to_string()),
92+
);
93+
}
94+
7595
#[cfg(test)]
7696
mod tests {
7797
use super::{read_settings, read_workspaces, write_workspaces};
@@ -154,4 +174,64 @@ mod tests {
154174
));
155175
assert_eq!(settings.theme, "dark");
156176
}
177+
178+
#[test]
179+
fn read_settings_migrates_follow_up_behavior_from_legacy_steer_enabled_true() {
180+
let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4()));
181+
std::fs::create_dir_all(&temp_dir).expect("create temp dir");
182+
let path = temp_dir.join("settings.json");
183+
184+
std::fs::write(
185+
&path,
186+
r#"{
187+
"steerEnabled": true,
188+
"theme": "dark"
189+
}"#,
190+
)
191+
.expect("write settings");
192+
193+
let settings = read_settings(&path).expect("read settings");
194+
assert!(settings.steer_enabled);
195+
assert_eq!(settings.follow_up_message_behavior, "steer");
196+
}
197+
198+
#[test]
199+
fn read_settings_migrates_follow_up_behavior_from_legacy_steer_enabled_false() {
200+
let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4()));
201+
std::fs::create_dir_all(&temp_dir).expect("create temp dir");
202+
let path = temp_dir.join("settings.json");
203+
204+
std::fs::write(
205+
&path,
206+
r#"{
207+
"steerEnabled": false,
208+
"theme": "dark"
209+
}"#,
210+
)
211+
.expect("write settings");
212+
213+
let settings = read_settings(&path).expect("read settings");
214+
assert!(!settings.steer_enabled);
215+
assert_eq!(settings.follow_up_message_behavior, "queue");
216+
}
217+
218+
#[test]
219+
fn read_settings_keeps_existing_follow_up_behavior() {
220+
let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4()));
221+
std::fs::create_dir_all(&temp_dir).expect("create temp dir");
222+
let path = temp_dir.join("settings.json");
223+
224+
std::fs::write(
225+
&path,
226+
r#"{
227+
"steerEnabled": true,
228+
"followUpMessageBehavior": "queue",
229+
"theme": "dark"
230+
}"#,
231+
)
232+
.expect("write settings");
233+
234+
let settings = read_settings(&path).expect("read settings");
235+
assert_eq!(settings.follow_up_message_behavior, "queue");
236+
}
157237
}

src-tauri/src/types.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,11 @@ pub(crate) struct AppSettings {
556556
alias = "experimentalSteerEnabled"
557557
)]
558558
pub(crate) steer_enabled: bool,
559+
#[serde(
560+
default = "default_follow_up_message_behavior",
561+
rename = "followUpMessageBehavior"
562+
)]
563+
pub(crate) follow_up_message_behavior: String,
559564
#[serde(
560565
default = "default_pause_queued_messages_when_response_required",
561566
rename = "pauseQueuedMessagesWhenResponseRequired"
@@ -905,6 +910,10 @@ fn default_steer_enabled() -> bool {
905910
true
906911
}
907912

913+
fn default_follow_up_message_behavior() -> String {
914+
"queue".to_string()
915+
}
916+
908917
fn default_pause_queued_messages_when_response_required() -> bool {
909918
true
910919
}
@@ -1145,6 +1154,7 @@ impl Default for AppSettings {
11451154
commit_message_model_id: None,
11461155
collaboration_modes_enabled: true,
11471156
steer_enabled: true,
1157+
follow_up_message_behavior: default_follow_up_message_behavior(),
11481158
pause_queued_messages_when_response_required:
11491159
default_pause_queued_messages_when_response_required(),
11501160
unified_exec_enabled: true,
@@ -1306,6 +1316,7 @@ mod tests {
13061316
assert!(settings.commit_message_prompt.contains("{diff}"));
13071317
assert!(settings.collaboration_modes_enabled);
13081318
assert!(settings.steer_enabled);
1319+
assert_eq!(settings.follow_up_message_behavior, "queue");
13091320
assert!(settings.pause_queued_messages_when_response_required);
13101321
assert!(settings.unified_exec_enabled);
13111322
assert!(!settings.experimental_apps_enabled);

src/App.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1348,6 +1348,7 @@ function MainApp() {
13481348
const activeTurnId = activeThreadId
13491349
? activeTurnIdByThread[activeThreadId] ?? null
13501350
: null;
1351+
const steerAvailable = appSettings.steerEnabled && Boolean(activeTurnId);
13511352
const hasUserInputRequestForActiveThread = Boolean(
13521353
activeThreadId &&
13531354
userInputRequests.some(
@@ -1393,7 +1394,6 @@ function MainApp() {
13931394
removeImagesForThread,
13941395
activeQueue,
13951396
handleSend,
1396-
queueMessage,
13971397
prefillDraft,
13981398
setPrefillDraft,
13991399
composerInsert,
@@ -1413,6 +1413,7 @@ function MainApp() {
14131413
isReviewing,
14141414
queueFlushPaused,
14151415
steerEnabled: appSettings.steerEnabled,
1416+
followUpMessageBehavior: appSettings.followUpMessageBehavior,
14161417
appsEnabled: appSettings.experimentalAppsEnabled,
14171418
connectWorkspace,
14181419
startThreadForWorkspace,
@@ -1792,7 +1793,6 @@ function MainApp() {
17921793
composerContextActions,
17931794
composerSendLabel,
17941795
handleComposerSend,
1795-
handleComposerQueue,
17961796
} = usePullRequestComposer({
17971797
activeWorkspace,
17981798
selectedPullRequest,
@@ -1812,12 +1812,10 @@ function MainApp() {
18121812
runPullRequestReview,
18131813
clearActiveImages,
18141814
handleSend,
1815-
queueMessage,
18161815
});
18171816

18181817
const {
18191818
handleComposerSendWithDraftStart,
1820-
handleComposerQueueWithDraftStart,
18211819
handleSelectWorkspaceInstance,
18221820
handleOpenThreadLink,
18231821
handleArchiveActiveThread,
@@ -1830,7 +1828,6 @@ function MainApp() {
18301828
pendingNewThreadSeedRef,
18311829
runWithDraftStart,
18321830
handleComposerSend,
1833-
handleComposerQueue,
18341831
clearDraftState,
18351832
exitDiffView,
18361833
resetPullRequestSelection,
@@ -2303,13 +2300,13 @@ function MainApp() {
23032300
onRevealGeneralPrompts: handleRevealGeneralPrompts,
23042301
canRevealGeneralPrompts: Boolean(activeWorkspace),
23052302
onSend: handleComposerSendWithDraftStart,
2306-
onQueue: handleComposerQueueWithDraftStart,
23072303
onStop: interruptTurn,
23082304
canStop: canInterrupt,
23092305
onFileAutocompleteActiveChange: setFileAutocompleteActive,
23102306
isReviewing,
23112307
isProcessing,
2312-
steerEnabled: appSettings.steerEnabled,
2308+
steerAvailable,
2309+
followUpMessageBehavior: appSettings.followUpMessageBehavior,
23132310
reviewPrompt,
23142311
onReviewPromptClose: closeReviewPrompt,
23152312
onReviewPromptShowPreset: showPresetStep,

src/features/app/hooks/useComposerController.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { useCallback, useMemo, useState } from "react";
2-
import type { AppMention, QueuedMessage, WorkspaceInfo } from "../../../types";
2+
import type {
3+
AppMention,
4+
ComposerSendIntent,
5+
FollowUpMessageBehavior,
6+
QueuedMessage,
7+
SendMessageResult,
8+
WorkspaceInfo,
9+
} from "../../../types";
310
import { useComposerImages } from "../../composer/hooks/useComposerImages";
411
import { useQueuedSend } from "../../threads/hooks/useQueuedSend";
512

@@ -12,6 +19,7 @@ export function useComposerController({
1219
isReviewing,
1320
queueFlushPaused = false,
1421
steerEnabled,
22+
followUpMessageBehavior,
1523
appsEnabled,
1624
connectWorkspace,
1725
startThreadForWorkspace,
@@ -33,6 +41,7 @@ export function useComposerController({
3341
isReviewing: boolean;
3442
queueFlushPaused?: boolean;
3543
steerEnabled: boolean;
44+
followUpMessageBehavior: FollowUpMessageBehavior;
3645
appsEnabled: boolean;
3746
connectWorkspace: (workspace: WorkspaceInfo) => Promise<void>;
3847
startThreadForWorkspace: (
@@ -43,13 +52,14 @@ export function useComposerController({
4352
text: string,
4453
images?: string[],
4554
appMentions?: AppMention[],
46-
) => Promise<void>;
55+
options?: { sendIntent?: ComposerSendIntent },
56+
) => Promise<{ status: "sent" | "blocked" | "steer_failed" }>;
4757
sendUserMessageToThread: (
4858
workspace: WorkspaceInfo,
4959
threadId: string,
5060
text: string,
5161
images?: string[],
52-
) => Promise<void>;
62+
) => Promise<void | SendMessageResult>;
5363
startFork: (text: string) => Promise<void>;
5464
startReview: (text: string) => Promise<void>;
5565
startResume: (text: string) => Promise<void>;
@@ -88,6 +98,7 @@ export function useComposerController({
8898
isReviewing,
8999
queueFlushPaused,
90100
steerEnabled,
101+
followUpMessageBehavior,
91102
appsEnabled,
92103
activeWorkspace,
93104
connectWorkspace,

src/features/app/hooks/usePlanReadyActions.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { useCallback } from "react";
2-
import type { CollaborationModeOption, WorkspaceInfo } from "../../../types";
2+
import type {
3+
CollaborationModeOption,
4+
SendMessageResult,
5+
WorkspaceInfo,
6+
} from "../../../types";
37
import {
48
makePlanReadyAcceptMessage,
59
makePlanReadyChangesMessage,
@@ -15,7 +19,7 @@ type SendUserMessageToThread = (
1519
message: string,
1620
imageIds: string[],
1721
options?: SendUserMessageOptions,
18-
) => Promise<void>;
22+
) => Promise<void | SendMessageResult>;
1923

2024
type UsePlanReadyActionsOptions = {
2125
activeWorkspace: WorkspaceInfo | null;

0 commit comments

Comments
 (0)