Skip to content
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ Use project aliases for frontend imports:

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

## Follow-up Behavior Map

For Queue vs Steer follow-up behavior, start here:

- Settings model + defaults: `src/types.ts`, `src/features/settings/hooks/useAppSettings.ts`
- Settings persistence/migration: `src-tauri/src/types.rs`, `src-tauri/src/storage.rs`
- Composer runtime behavior: `src/features/composer/components/Composer.tsx`
- Send intent routing: `src/features/threads/hooks/useQueuedSend.ts`, `src/features/threads/hooks/useThreadMessaging.ts`
- App/layout wiring: `src/features/app/hooks/useComposerController.ts`, `src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx`, `src/App.tsx`

## App/Daemon Parity Checklist

When changing backend behavior that can run remotely:
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ CodexMonitor is a Tauri app for orchestrating multiple Codex agents across local

### Composer & Agent Controls

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

- Workspaces persist to `workspaces.json` under the app data directory.
- 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).
- 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`).
- 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).
- 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.
- On launch and on window focus, the app reconnects and refreshes thread lists for each workspace.
- Threads are restored by filtering `thread/list` results using the workspace `cwd`.
- Selecting a thread always calls `thread/resume` to refresh messages from disk.
Expand Down
7 changes: 3 additions & 4 deletions docs/app-server-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ These are v2 request methods CodexMonitor currently sends to Codex app-server:
- `thread/compact/start`
- `thread/name/set`
- `turn/start`
- `turn/steer` (best-effort; falls back to `turn/start` when unsupported)
- `turn/steer` (used for explicit steer follow-ups while a turn is active)
- `turn/interrupt`
- `review/start`
- `model/list`
Expand Down Expand Up @@ -253,9 +253,8 @@ Use this when the method list is unchanged but behavior looks off.
- Stored in `useThreadsReducer.ts` (`turnDiffByThread`)
- Exposed by `useThreads.ts` for UI consumers
- Steering behavior while a turn is processing:
- CodexMonitor attempts `turn/steer` when steering is enabled and an active turn exists.
- If the server/daemon reports unknown `turn/steer`/`turn_steer`, CodexMonitor
degrades to `turn/start` and caches that workspace as steer-unsupported.
- CodexMonitor attempts `turn/steer` only when steer capability is enabled, the thread is processing, and an active turn id exists.
- If `turn/steer` fails, CodexMonitor does not fall back to `turn/start`; it clears stale processing/turn state, surfaces an error, and queues the follow-up message locally.
- Feature toggles in Settings:
- `experimentalFeature/list` is an app-server request.
- Toggle writes use local/daemon command surfaces (`set_codex_feature_flag` and app settings update),
Expand Down
84 changes: 82 additions & 2 deletions src-tauri/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ pub(crate) fn read_settings(path: &PathBuf) -> Result<AppSettings, String> {
return Ok(AppSettings::default());
}
let data = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
match serde_json::from_str(&data) {
let mut value: Value = serde_json::from_str(&data).map_err(|e| e.to_string())?;
migrate_follow_up_message_behavior(&mut value);
match serde_json::from_value(value.clone()) {
Ok(settings) => Ok(settings),
Err(_) => {
let mut value: Value = serde_json::from_str(&data).map_err(|e| e.to_string())?;
sanitize_remote_settings_for_tcp_only(&mut value);
migrate_follow_up_message_behavior(&mut value);
serde_json::from_value(value).map_err(|e| e.to_string())
}
}
Expand Down Expand Up @@ -72,6 +74,24 @@ fn sanitize_remote_settings_for_tcp_only(value: &mut Value) {
root.retain(|key, _| !key.to_ascii_lowercase().starts_with("orb"));
}

fn migrate_follow_up_message_behavior(value: &mut Value) {
let Value::Object(root) = value else {
return;
};
if root.contains_key("followUpMessageBehavior") {
return;
}
let steer_enabled = root
.get("steerEnabled")
.or_else(|| root.get("experimentalSteerEnabled"))
.and_then(Value::as_bool)
.unwrap_or(true);
root.insert(
"followUpMessageBehavior".to_string(),
Value::String(if steer_enabled { "steer" } else { "queue" }.to_string()),
);
}

#[cfg(test)]
mod tests {
use super::{read_settings, read_workspaces, write_workspaces};
Expand Down Expand Up @@ -154,4 +174,64 @@ mod tests {
));
assert_eq!(settings.theme, "dark");
}

#[test]
fn read_settings_migrates_follow_up_behavior_from_legacy_steer_enabled_true() {
let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("create temp dir");
let path = temp_dir.join("settings.json");

std::fs::write(
&path,
r#"{
"steerEnabled": true,
"theme": "dark"
}"#,
)
.expect("write settings");

let settings = read_settings(&path).expect("read settings");
assert!(settings.steer_enabled);
assert_eq!(settings.follow_up_message_behavior, "steer");
}

#[test]
fn read_settings_migrates_follow_up_behavior_from_legacy_steer_enabled_false() {
let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("create temp dir");
let path = temp_dir.join("settings.json");

std::fs::write(
&path,
r#"{
"steerEnabled": false,
"theme": "dark"
}"#,
)
.expect("write settings");

let settings = read_settings(&path).expect("read settings");
assert!(!settings.steer_enabled);
assert_eq!(settings.follow_up_message_behavior, "queue");
}

#[test]
fn read_settings_keeps_existing_follow_up_behavior() {
let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("create temp dir");
let path = temp_dir.join("settings.json");

std::fs::write(
&path,
r#"{
"steerEnabled": true,
"followUpMessageBehavior": "queue",
"theme": "dark"
}"#,
)
.expect("write settings");

let settings = read_settings(&path).expect("read settings");
assert_eq!(settings.follow_up_message_behavior, "queue");
}
}
11 changes: 11 additions & 0 deletions src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,11 @@ pub(crate) struct AppSettings {
alias = "experimentalSteerEnabled"
)]
pub(crate) steer_enabled: bool,
#[serde(
default = "default_follow_up_message_behavior",
rename = "followUpMessageBehavior"
)]
pub(crate) follow_up_message_behavior: String,
#[serde(
default = "default_pause_queued_messages_when_response_required",
rename = "pauseQueuedMessagesWhenResponseRequired"
Expand Down Expand Up @@ -903,6 +908,10 @@ fn default_steer_enabled() -> bool {
true
}

fn default_follow_up_message_behavior() -> String {
"queue".to_string()
}

fn default_pause_queued_messages_when_response_required() -> bool {
true
}
Expand Down Expand Up @@ -1142,6 +1151,7 @@ impl Default for AppSettings {
commit_message_prompt: default_commit_message_prompt(),
collaboration_modes_enabled: true,
steer_enabled: true,
follow_up_message_behavior: default_follow_up_message_behavior(),
pause_queued_messages_when_response_required:
default_pause_queued_messages_when_response_required(),
unified_exec_enabled: true,
Expand Down Expand Up @@ -1303,6 +1313,7 @@ mod tests {
assert!(settings.commit_message_prompt.contains("{diff}"));
assert!(settings.collaboration_modes_enabled);
assert!(settings.steer_enabled);
assert_eq!(settings.follow_up_message_behavior, "queue");
assert!(settings.pause_queued_messages_when_response_required);
assert!(settings.unified_exec_enabled);
assert!(!settings.experimental_apps_enabled);
Expand Down
11 changes: 4 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1264,6 +1264,7 @@ function MainApp() {
const activeTurnId = activeThreadId
? activeTurnIdByThread[activeThreadId] ?? null
: null;
const steerAvailable = appSettings.steerEnabled && Boolean(activeTurnId);
const hasUserInputRequestForActiveThread = Boolean(
activeThreadId &&
userInputRequests.some(
Expand Down Expand Up @@ -1309,7 +1310,6 @@ function MainApp() {
removeImagesForThread,
activeQueue,
handleSend,
queueMessage,
prefillDraft,
setPrefillDraft,
composerInsert,
Expand All @@ -1329,6 +1329,7 @@ function MainApp() {
isReviewing,
queueFlushPaused,
steerEnabled: appSettings.steerEnabled,
followUpMessageBehavior: appSettings.followUpMessageBehavior,
appsEnabled: appSettings.experimentalAppsEnabled,
connectWorkspace,
startThreadForWorkspace,
Expand Down Expand Up @@ -1705,7 +1706,6 @@ function MainApp() {
composerContextActions,
composerSendLabel,
handleComposerSend,
handleComposerQueue,
} = usePullRequestComposer({
activeWorkspace,
selectedPullRequest,
Expand All @@ -1725,12 +1725,10 @@ function MainApp() {
runPullRequestReview,
clearActiveImages,
handleSend,
queueMessage,
});

const {
handleComposerSendWithDraftStart,
handleComposerQueueWithDraftStart,
handleSelectWorkspaceInstance,
handleOpenThreadLink,
handleArchiveActiveThread,
Expand All @@ -1742,7 +1740,6 @@ function MainApp() {
pendingNewThreadSeedRef,
runWithDraftStart,
handleComposerSend,
handleComposerQueue,
clearDraftState,
exitDiffView,
resetPullRequestSelection,
Expand Down Expand Up @@ -2214,13 +2211,13 @@ function MainApp() {
onRevealGeneralPrompts: handleRevealGeneralPrompts,
canRevealGeneralPrompts: Boolean(activeWorkspace),
onSend: handleComposerSendWithDraftStart,
onQueue: handleComposerQueueWithDraftStart,
onStop: interruptTurn,
canStop: canInterrupt,
onFileAutocompleteActiveChange: setFileAutocompleteActive,
isReviewing,
isProcessing,
steerEnabled: appSettings.steerEnabled,
steerAvailable,
followUpMessageBehavior: appSettings.followUpMessageBehavior,
reviewPrompt,
onReviewPromptClose: closeReviewPrompt,
onReviewPromptShowPreset: showPresetStep,
Expand Down
17 changes: 14 additions & 3 deletions src/features/app/hooks/useComposerController.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { useCallback, useMemo, useState } from "react";
import type { AppMention, QueuedMessage, WorkspaceInfo } from "../../../types";
import type {
AppMention,
ComposerSendIntent,
FollowUpMessageBehavior,
QueuedMessage,
SendMessageResult,
WorkspaceInfo,
} from "../../../types";
import { useComposerImages } from "../../composer/hooks/useComposerImages";
import { useQueuedSend } from "../../threads/hooks/useQueuedSend";

Expand All @@ -12,6 +19,7 @@ export function useComposerController({
isReviewing,
queueFlushPaused = false,
steerEnabled,
followUpMessageBehavior,
appsEnabled,
connectWorkspace,
startThreadForWorkspace,
Expand All @@ -33,6 +41,7 @@ export function useComposerController({
isReviewing: boolean;
queueFlushPaused?: boolean;
steerEnabled: boolean;
followUpMessageBehavior: FollowUpMessageBehavior;
appsEnabled: boolean;
connectWorkspace: (workspace: WorkspaceInfo) => Promise<void>;
startThreadForWorkspace: (
Expand All @@ -43,13 +52,14 @@ export function useComposerController({
text: string,
images?: string[],
appMentions?: AppMention[],
) => Promise<void>;
options?: { sendIntent?: ComposerSendIntent },
) => Promise<{ status: "sent" | "blocked" | "steer_failed" }>;
sendUserMessageToThread: (
workspace: WorkspaceInfo,
threadId: string,
text: string,
images?: string[],
) => Promise<void>;
) => Promise<void | SendMessageResult>;
startFork: (text: string) => Promise<void>;
startReview: (text: string) => Promise<void>;
startResume: (text: string) => Promise<void>;
Expand Down Expand Up @@ -88,6 +98,7 @@ export function useComposerController({
isReviewing,
queueFlushPaused,
steerEnabled,
followUpMessageBehavior,
appsEnabled,
activeWorkspace,
connectWorkspace,
Expand Down
8 changes: 6 additions & 2 deletions src/features/app/hooks/usePlanReadyActions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { useCallback } from "react";
import type { CollaborationModeOption, WorkspaceInfo } from "../../../types";
import type {
CollaborationModeOption,
SendMessageResult,
WorkspaceInfo,
} from "../../../types";
import {
makePlanReadyAcceptMessage,
makePlanReadyChangesMessage,
Expand All @@ -15,7 +19,7 @@ type SendUserMessageToThread = (
message: string,
imageIds: string[],
options?: SendUserMessageOptions,
) => Promise<void>;
) => Promise<void | SendMessageResult>;

type UsePlanReadyActionsOptions = {
activeWorkspace: WorkspaceInfo | null;
Expand Down
Loading
Loading