Skip to content
Merged
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
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
8 changes: 4 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,9 @@ 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 when applicable, surfaces an error, and returns `steer_failed`.
- Local queue fallback on `steer_failed` is handled in the composer queued-send flow (`useQueuedSend`), not by all direct `sendUserMessageToThread` callers.
- 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 @@ -556,6 +556,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 @@ -905,6 +910,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 @@ -1145,6 +1154,7 @@ impl Default for AppSettings {
commit_message_model_id: None,
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 @@ -1306,6 +1316,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 @@ -1348,6 +1348,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 @@ -1393,7 +1394,6 @@ function MainApp() {
removeImagesForThread,
activeQueue,
handleSend,
queueMessage,
prefillDraft,
setPrefillDraft,
composerInsert,
Expand All @@ -1413,6 +1413,7 @@ function MainApp() {
isReviewing,
queueFlushPaused,
steerEnabled: appSettings.steerEnabled,
followUpMessageBehavior: appSettings.followUpMessageBehavior,
appsEnabled: appSettings.experimentalAppsEnabled,
connectWorkspace,
startThreadForWorkspace,
Expand Down Expand Up @@ -1792,7 +1793,6 @@ function MainApp() {
composerContextActions,
composerSendLabel,
handleComposerSend,
handleComposerQueue,
} = usePullRequestComposer({
activeWorkspace,
selectedPullRequest,
Expand All @@ -1812,12 +1812,10 @@ function MainApp() {
runPullRequestReview,
clearActiveImages,
handleSend,
queueMessage,
});

const {
handleComposerSendWithDraftStart,
handleComposerQueueWithDraftStart,
handleSelectWorkspaceInstance,
handleOpenThreadLink,
handleArchiveActiveThread,
Expand All @@ -1830,7 +1828,6 @@ function MainApp() {
pendingNewThreadSeedRef,
runWithDraftStart,
handleComposerSend,
handleComposerQueue,
clearDraftState,
exitDiffView,
resetPullRequestSelection,
Expand Down Expand Up @@ -2303,13 +2300,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