diff --git a/AGENTS.md b/AGENTS.md index bdcc25481..3eb3dab90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: diff --git a/README.md b/README.md index cd527dc5f..c7b42e3d3 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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. diff --git a/docs/app-server-events.md b/docs/app-server-events.md index 199123fb9..105b2acfd 100644 --- a/docs/app-server-events.md +++ b/docs/app-server-events.md @@ -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` @@ -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), diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index 78192481f..e9aecbf03 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -29,11 +29,13 @@ pub(crate) fn read_settings(path: &PathBuf) -> Result { 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()) } } @@ -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}; @@ -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"); + } } diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 12d4e3576..09e5e92d1 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -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" @@ -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 } @@ -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, @@ -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); diff --git a/src/App.tsx b/src/App.tsx index a0b7056ed..1bbe6d06c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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( @@ -1309,7 +1310,6 @@ function MainApp() { removeImagesForThread, activeQueue, handleSend, - queueMessage, prefillDraft, setPrefillDraft, composerInsert, @@ -1329,6 +1329,7 @@ function MainApp() { isReviewing, queueFlushPaused, steerEnabled: appSettings.steerEnabled, + followUpMessageBehavior: appSettings.followUpMessageBehavior, appsEnabled: appSettings.experimentalAppsEnabled, connectWorkspace, startThreadForWorkspace, @@ -1705,7 +1706,6 @@ function MainApp() { composerContextActions, composerSendLabel, handleComposerSend, - handleComposerQueue, } = usePullRequestComposer({ activeWorkspace, selectedPullRequest, @@ -1725,12 +1725,10 @@ function MainApp() { runPullRequestReview, clearActiveImages, handleSend, - queueMessage, }); const { handleComposerSendWithDraftStart, - handleComposerQueueWithDraftStart, handleSelectWorkspaceInstance, handleOpenThreadLink, handleArchiveActiveThread, @@ -1742,7 +1740,6 @@ function MainApp() { pendingNewThreadSeedRef, runWithDraftStart, handleComposerSend, - handleComposerQueue, clearDraftState, exitDiffView, resetPullRequestSelection, @@ -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, diff --git a/src/features/app/hooks/useComposerController.ts b/src/features/app/hooks/useComposerController.ts index 77b6e76eb..a3d08aa04 100644 --- a/src/features/app/hooks/useComposerController.ts +++ b/src/features/app/hooks/useComposerController.ts @@ -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"; @@ -12,6 +19,7 @@ export function useComposerController({ isReviewing, queueFlushPaused = false, steerEnabled, + followUpMessageBehavior, appsEnabled, connectWorkspace, startThreadForWorkspace, @@ -33,6 +41,7 @@ export function useComposerController({ isReviewing: boolean; queueFlushPaused?: boolean; steerEnabled: boolean; + followUpMessageBehavior: FollowUpMessageBehavior; appsEnabled: boolean; connectWorkspace: (workspace: WorkspaceInfo) => Promise; startThreadForWorkspace: ( @@ -43,13 +52,14 @@ export function useComposerController({ text: string, images?: string[], appMentions?: AppMention[], - ) => Promise; + options?: { sendIntent?: ComposerSendIntent }, + ) => Promise<{ status: "sent" | "blocked" | "steer_failed" }>; sendUserMessageToThread: ( workspace: WorkspaceInfo, threadId: string, text: string, images?: string[], - ) => Promise; + ) => Promise; startFork: (text: string) => Promise; startReview: (text: string) => Promise; startResume: (text: string) => Promise; @@ -88,6 +98,7 @@ export function useComposerController({ isReviewing, queueFlushPaused, steerEnabled, + followUpMessageBehavior, appsEnabled, activeWorkspace, connectWorkspace, diff --git a/src/features/app/hooks/usePlanReadyActions.ts b/src/features/app/hooks/usePlanReadyActions.ts index e9c028ed5..8113c3ca8 100644 --- a/src/features/app/hooks/usePlanReadyActions.ts +++ b/src/features/app/hooks/usePlanReadyActions.ts @@ -1,5 +1,9 @@ import { useCallback } from "react"; -import type { CollaborationModeOption, WorkspaceInfo } from "../../../types"; +import type { + CollaborationModeOption, + SendMessageResult, + WorkspaceInfo, +} from "../../../types"; import { makePlanReadyAcceptMessage, makePlanReadyChangesMessage, @@ -15,7 +19,7 @@ type SendUserMessageToThread = ( message: string, imageIds: string[], options?: SendUserMessageOptions, -) => Promise; +) => Promise; type UsePlanReadyActionsOptions = { activeWorkspace: WorkspaceInfo | null; diff --git a/src/features/app/orchestration/useThreadOrchestration.ts b/src/features/app/orchestration/useThreadOrchestration.ts index 40d189b5d..dc3b761ff 100644 --- a/src/features/app/orchestration/useThreadOrchestration.ts +++ b/src/features/app/orchestration/useThreadOrchestration.ts @@ -1,6 +1,11 @@ import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; import type { Dispatch, MutableRefObject, SetStateAction } from "react"; -import type { AccessMode, AppMention, AppSettings } from "@/types"; +import type { + AccessMode, + AppMention, + AppSettings, + ComposerSendIntent, +} from "@/types"; import { useThreadCodexParams } from "@threads/hooks/useThreadCodexParams"; import { buildThreadCodexSeedPatch, @@ -67,6 +72,7 @@ type SendOrQueueHandler = ( text: string, images: string[], appMentions?: AppMention[], + submitIntent?: ComposerSendIntent, ) => Promise; type UseThreadUiOrchestrationParams = { @@ -77,7 +83,6 @@ type UseThreadUiOrchestrationParams = { pendingNewThreadSeedRef: MutableRefObject; runWithDraftStart: (runner: () => Promise) => Promise; handleComposerSend: SendOrQueueHandler; - handleComposerQueue: SendOrQueueHandler; clearDraftState: () => void; exitDiffView: () => void; resetPullRequestSelection: () => void; @@ -308,7 +313,6 @@ export function useThreadUiOrchestration({ pendingNewThreadSeedRef, runWithDraftStart, handleComposerSend, - handleComposerQueue, clearDraftState, exitDiffView, resetPullRequestSelection, @@ -336,43 +340,22 @@ export function useThreadUiOrchestration({ ]); const handleComposerSendWithDraftStart = useCallback( - (text: string, images: string[], appMentions?: AppMention[]) => { + ( + text: string, + images: string[], + appMentions?: AppMention[], + submitIntent?: ComposerSendIntent, + ) => { rememberPendingNewThreadSeed(); return runWithDraftStart(() => appMentions && appMentions.length > 0 - ? handleComposerSend(text, images, appMentions) - : handleComposerSend(text, images), + ? handleComposerSend(text, images, appMentions, submitIntent) + : handleComposerSend(text, images, undefined, submitIntent), ); }, [handleComposerSend, rememberPendingNewThreadSeed, runWithDraftStart], ); - const handleComposerQueueWithDraftStart = useCallback( - (text: string, images: string[], appMentions?: AppMention[]) => { - const runner = activeThreadId - ? () => - appMentions && appMentions.length > 0 - ? handleComposerQueue(text, images, appMentions) - : handleComposerQueue(text, images) - : () => - appMentions && appMentions.length > 0 - ? handleComposerSend(text, images, appMentions) - : handleComposerSend(text, images); - - if (!activeThreadId) { - rememberPendingNewThreadSeed(); - } - return runWithDraftStart(runner); - }, - [ - activeThreadId, - handleComposerQueue, - handleComposerSend, - rememberPendingNewThreadSeed, - runWithDraftStart, - ], - ); - const handleSelectWorkspaceInstance = useCallback( (workspaceId: string, threadId: string) => { exitDiffView(); @@ -431,7 +414,6 @@ export function useThreadUiOrchestration({ return { handleComposerSendWithDraftStart, - handleComposerQueueWithDraftStart, handleSelectWorkspaceInstance, handleOpenThreadLink, handleArchiveActiveThread, diff --git a/src/features/composer/components/Composer.tsx b/src/features/composer/components/Composer.tsx index 84ce155d5..50441db3f 100644 --- a/src/features/composer/components/Composer.tsx +++ b/src/features/composer/components/Composer.tsx @@ -11,9 +11,11 @@ import { import type { AppMention, AppOption, + ComposerSendIntent, ComposerEditorSettings, CustomPromptOption, DictationTranscript, + FollowUpMessageBehavior, QueuedMessage, ThreadTokenUsage, } from "../../../types"; @@ -42,17 +44,22 @@ import { usePromptHistory } from "../hooks/usePromptHistory"; import { ComposerInput } from "./ComposerInput"; import { ComposerMetaBar } from "./ComposerMetaBar"; import { ComposerQueue } from "./ComposerQueue"; -import { isMobilePlatform } from "../../../utils/platformPaths"; +import { isMacPlatform, isMobilePlatform } from "../../../utils/platformPaths"; type ComposerProps = { - onSend: (text: string, images: string[], appMentions?: AppMention[]) => void; - onQueue: (text: string, images: string[], appMentions?: AppMention[]) => void; + onSend: ( + text: string, + images: string[], + appMentions?: AppMention[], + submitIntent?: ComposerSendIntent, + ) => void; onStop: () => void; canStop: boolean; disabled?: boolean; appsEnabled: boolean; isProcessing: boolean; - steerEnabled: boolean; + steerAvailable: boolean; + followUpMessageBehavior: FollowUpMessageBehavior; collaborationModes: { id: string; label: string }[]; selectedCollaborationModeId: string | null; onSelectCollaborationMode: (id: string | null) => void; @@ -150,13 +157,13 @@ const CARET_ANCHOR_GAP = 8; export const Composer = memo(function Composer({ onSend, - onQueue, onStop, canStop, disabled = false, appsEnabled, isProcessing, - steerEnabled, + steerAvailable, + followUpMessageBehavior, collaborationModes, selectedCollaborationModeId, onSelectCollaborationMode, @@ -238,6 +245,25 @@ export const Composer = memo(function Composer({ const editorSettings = editorSettingsProp ?? DEFAULT_EDITOR_SETTINGS; const isDictationBusy = dictationState !== "idle"; const canSend = text.trim().length > 0 || attachedImages.length > 0; + const isMac = isMacPlatform(); + const followUpShortcutLabel = isMac ? "Shift+Cmd+Enter" : "Shift+Ctrl+Enter"; + const effectiveFollowUpBehavior: FollowUpMessageBehavior = + followUpMessageBehavior === "steer" && steerAvailable ? "steer" : "queue"; + const oppositeFollowUpIntent: ComposerSendIntent = + effectiveFollowUpBehavior === "queue" ? "steer" : "queue"; + const oppositeFallsBackToQueue = + oppositeFollowUpIntent === "steer" && !steerAvailable; + const defaultSubmitIntent: ComposerSendIntent = isProcessing + ? effectiveFollowUpBehavior + : "default"; + const oppositeSubmitIntent: ComposerSendIntent = isProcessing + ? oppositeFollowUpIntent + : "default"; + const effectiveSendLabel = isProcessing + ? effectiveFollowUpBehavior === "steer" + ? "Steer" + : "Queue" + : sendLabel; const { expandFenceOnSpace, expandFenceOnEnter, @@ -381,7 +407,7 @@ export const Composer = memo(function Composer({ [handleHistoryTextChange, handleTextChange], ); - const handleSend = useCallback(() => { + const handleSend = useCallback((submitIntent: ComposerSendIntent = "default") => { if (disabled) { return; } @@ -394,9 +420,9 @@ export const Composer = memo(function Composer({ } const resolvedMentions = resolveBoundAppMentions(trimmed, appMentionBindings); if (resolvedMentions.length > 0) { - onSend(trimmed, attachedImages, resolvedMentions); + onSend(trimmed, attachedImages, resolvedMentions, submitIntent); } else { - onSend(trimmed, attachedImages); + onSend(trimmed, attachedImages, undefined, submitIntent); } resetHistoryNavigation(); setComposerText(""); @@ -412,37 +438,6 @@ export const Composer = memo(function Composer({ text, ]); - const handleQueue = useCallback(() => { - if (disabled) { - return; - } - const trimmed = text.trim(); - if (!trimmed && attachedImages.length === 0) { - return; - } - if (trimmed) { - recordHistory(trimmed); - } - const resolvedMentions = resolveBoundAppMentions(trimmed, appMentionBindings); - if (resolvedMentions.length > 0) { - onQueue(trimmed, attachedImages, resolvedMentions); - } else { - onQueue(trimmed, attachedImages); - } - resetHistoryNavigation(); - setComposerText(""); - setAppMentionBindings([]); - }, [ - appMentionBindings, - attachedImages, - disabled, - onQueue, - recordHistory, - resetHistoryNavigation, - setComposerText, - text, - ]); - useEffect(() => { setAppMentionBindings([]); }, [historyKey]); @@ -641,6 +636,25 @@ export const Composer = memo(function Composer({ onEditQueued={onEditQueued} onDeleteQueued={onDeleteQueued} /> + {isProcessing && ( +
+
Follow-up behavior
+
+ {oppositeFallsBackToQueue ? ( + <> + Default: Queue (Steer unavailable). Both Enter and {followUpShortcutLabel} will + queue this message. + + ) : ( + <> + Default: {effectiveFollowUpBehavior === "steer" ? "Steer" : "Queue"}. Press{" "} + {followUpShortcutLabel} to{" "} + {oppositeFollowUpIntent === "steer" ? "steer" : "queue"} this message. + + )} +
+
+ )} {contextActions.length > 0 ? (
{contextActions.map((action) => ( @@ -662,12 +676,12 @@ export const Composer = memo(function Composer({ handleSend(defaultSubmitIntent)} dictationEnabled={dictationEnabled} dictationState={dictationState} dictationLevel={dictationLevel} @@ -694,6 +708,23 @@ export const Composer = memo(function Composer({ if (event.defaultPrevented) { return; } + const isOppositeFollowUpShortcut = + event.key === "Enter" && + event.shiftKey && + (isMac ? event.metaKey : event.ctrlKey); + if (isOppositeFollowUpShortcut && !suggestionsOpen) { + if (isDictationBusy) { + event.preventDefault(); + return; + } + event.preventDefault(); + const dismissKeyboardAfterSend = canSend && isMobilePlatform(); + handleSend(oppositeSubmitIntent); + if (dismissKeyboardAfterSend) { + textareaRef.current?.blur(); + } + return; + } if ( expandFenceOnSpace && event.key === " " && @@ -713,7 +744,13 @@ export const Composer = memo(function Composer({ return; } } - if (event.key === "Enter" && event.shiftKey) { + if ( + event.key === "Enter" && + event.shiftKey && + !event.metaKey && + !event.ctrlKey && + !event.altKey + ) { if (continueListOnShiftEnter && !suggestionsOpen) { const textarea = textareaRef.current; if (textarea) { @@ -745,17 +782,6 @@ export const Composer = memo(function Composer({ applyTextInsertion(nextText, nextCursor); return; } - if ( - event.key === "Tab" && - !event.shiftKey && - steerEnabled && - isProcessing && - !suggestionsOpen - ) { - event.preventDefault(); - handleQueue(); - return; - } if (reviewPromptOpen && onReviewPromptKeyDown) { const handled = onReviewPromptKeyDown(event); if (handled) { @@ -784,7 +810,7 @@ export const Composer = memo(function Composer({ } event.preventDefault(); const dismissKeyboardAfterSend = canSend && isMobilePlatform(); - handleSend(); + handleSend(defaultSubmitIntent); if (dismissKeyboardAfterSend) { textareaRef.current?.blur(); } diff --git a/src/features/composer/components/ComposerEditorHelpers.test.tsx b/src/features/composer/components/ComposerEditorHelpers.test.tsx index f34cc828e..1aee9235f 100644 --- a/src/features/composer/components/ComposerEditorHelpers.test.tsx +++ b/src/features/composer/components/ComposerEditorHelpers.test.tsx @@ -25,12 +25,12 @@ function ComposerHarness({ initialText = "", editorSettings }: HarnessProps) { return ( {}} - onQueue={() => {}} onStop={() => {}} canStop={false} isProcessing={false} appsEnabled={true} - steerEnabled={false} + steerAvailable={false} + followUpMessageBehavior="queue" collaborationModes={[]} selectedCollaborationModeId={null} onSelectCollaborationMode={() => {}} diff --git a/src/features/composer/components/ComposerInput.tsx b/src/features/composer/components/ComposerInput.tsx index 7a0bd1602..9fc0d7566 100644 --- a/src/features/composer/components/ComposerInput.tsx +++ b/src/features/composer/components/ComposerInput.tsx @@ -706,6 +706,7 @@ export function ComposerInput({ onClick={handleActionClick} disabled={disabled || isDictationBusy || (!canStop && !canSend)} aria-label={canStop ? "Stop" : sendLabel} + title={canStop ? "Stop" : sendLabel} > {canStop ? ( <> diff --git a/src/features/composer/components/ComposerSend.test.tsx b/src/features/composer/components/ComposerSend.test.tsx index e1657bdf6..a5da89e62 100644 --- a/src/features/composer/components/ComposerSend.test.tsx +++ b/src/features/composer/components/ComposerSend.test.tsx @@ -4,7 +4,12 @@ import { useRef, useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { isMobilePlatform } from "../../../utils/platformPaths"; import { Composer } from "./Composer"; -import type { AppOption, AppMention } from "../../../types"; +import type { + AppOption, + AppMention, + ComposerSendIntent, + FollowUpMessageBehavior, +} from "../../../types"; vi.mock("../../../services/dragDrop", () => ({ subscribeWindowDragDrop: vi.fn(() => () => {}), @@ -25,23 +30,37 @@ vi.mock("../../../utils/platformPaths", async () => { }); type HarnessProps = { - onSend: (text: string, images: string[], appMentions?: AppMention[]) => void; + onSend: ( + text: string, + images: string[], + appMentions?: AppMention[], + submitIntent?: ComposerSendIntent, + ) => void; apps?: AppOption[]; + isProcessing?: boolean; + followUpMessageBehavior?: FollowUpMessageBehavior; + steerAvailable?: boolean; }; -function ComposerHarness({ onSend, apps = [] }: HarnessProps) { +function ComposerHarness({ + onSend, + apps = [], + isProcessing = false, + followUpMessageBehavior = "queue", + steerAvailable = false, +}: HarnessProps) { const [draftText, setDraftText] = useState(""); const textareaRef = useRef(null); return ( {}} onStop={() => {}} canStop={false} - isProcessing={false} + isProcessing={isProcessing} appsEnabled={true} - steerEnabled={false} + steerAvailable={steerAvailable} + followUpMessageBehavior={followUpMessageBehavior} collaborationModes={[]} selectedCollaborationModeId={null} onSelectCollaborationMode={() => {}} @@ -82,7 +101,7 @@ describe("Composer send triggers", () => { fireEvent.keyDown(textarea, { key: "Enter" }); expect(onSend).toHaveBeenCalledTimes(1); - expect(onSend).toHaveBeenCalledWith("hello world", []); + expect(onSend).toHaveBeenCalledWith("hello world", [], undefined, "default"); }); it("sends once on send-button click", () => { @@ -94,7 +113,7 @@ describe("Composer send triggers", () => { fireEvent.click(screen.getByLabelText("Send")); expect(onSend).toHaveBeenCalledTimes(1); - expect(onSend).toHaveBeenCalledWith("from button", []); + expect(onSend).toHaveBeenCalledWith("from button", [], undefined, "default"); }); it("blurs the textarea after Enter send on mobile", () => { @@ -108,7 +127,12 @@ describe("Composer send triggers", () => { fireEvent.keyDown(textarea, { key: "Enter" }); expect(onSend).toHaveBeenCalledTimes(1); - expect(onSend).toHaveBeenCalledWith("dismiss keyboard", []); + expect(onSend).toHaveBeenCalledWith( + "dismiss keyboard", + [], + undefined, + "default", + ); expect(blurSpy).toHaveBeenCalledTimes(1); }); @@ -138,6 +162,111 @@ describe("Composer send triggers", () => { "$calendar-app", [], [{ name: "Calendar App", path: "app://connector_calendar" }], + "default", + ); + }); + + it("uses queue by default while processing when follow-up behavior is queue", () => { + const onSend = vi.fn(); + render( + , + ); + + const textarea = screen.getByRole("textbox"); + fireEvent.change(textarea, { target: { value: "queue this" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSend).toHaveBeenCalledWith("queue this", [], undefined, "queue"); + }); + + it("uses opposite follow-up behavior on Shift+Ctrl+Enter while processing", () => { + const onSend = vi.fn(); + render( + , + ); + + const textarea = screen.getByRole("textbox"); + fireEvent.change(textarea, { target: { value: "steer this" } }); + fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true, ctrlKey: true }); + + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSend).toHaveBeenCalledWith("steer this", [], undefined, "steer"); + }); + + it("falls back to queue when steer is selected but unavailable", () => { + const onSend = vi.fn(); + render( + , + ); + + const textarea = screen.getByRole("textbox"); + fireEvent.change(textarea, { target: { value: "queue fallback" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + expect( + screen.getByText( + "Default: Queue (Steer unavailable). Both Enter and Shift+Ctrl+Enter will queue this message.", + ), + ).toBeTruthy(); + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSend).toHaveBeenCalledWith("queue fallback", [], undefined, "queue"); + }); + + it("treats Shift+Ctrl+Enter like normal send when not processing", () => { + const onSend = vi.fn(); + render( + , + ); + + const textarea = screen.getByRole("textbox"); + fireEvent.change(textarea, { target: { value: "normal shortcut send" } }); + fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true, ctrlKey: true }); + + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSend).toHaveBeenCalledWith( + "normal shortcut send", + [], + undefined, + "default", ); }); + + it("does not queue on Tab while processing", () => { + const onSend = vi.fn(); + render( + , + ); + + const textarea = screen.getByRole("textbox"); + fireEvent.change(textarea, { target: { value: "tab no send" } }); + fireEvent.keyDown(textarea, { key: "Tab" }); + + expect(onSend).not.toHaveBeenCalled(); + }); }); diff --git a/src/features/git/hooks/usePullRequestComposer.test.tsx b/src/features/git/hooks/usePullRequestComposer.test.tsx index f8cc681f0..8a285c827 100644 --- a/src/features/git/hooks/usePullRequestComposer.test.tsx +++ b/src/features/git/hooks/usePullRequestComposer.test.tsx @@ -58,7 +58,6 @@ const makeOptions = (overrides: Partial { await result.current.handleComposerSend("/apps", []); }); - expect(options.handleSend).toHaveBeenCalledWith("/apps", []); + expect(options.handleSend).toHaveBeenCalledWith( + "/apps", + [], + undefined, + undefined, + ); expect(options.runPullRequestReview).not.toHaveBeenCalled(); }); diff --git a/src/features/git/hooks/usePullRequestComposer.ts b/src/features/git/hooks/usePullRequestComposer.ts index c1b4b5d87..68145f984 100644 --- a/src/features/git/hooks/usePullRequestComposer.ts +++ b/src/features/git/hooks/usePullRequestComposer.ts @@ -1,6 +1,7 @@ import { useCallback, useMemo } from "react"; import type { AppMention, + ComposerSendIntent, GitHubPullRequest, PullRequestReviewAction, PullRequestReviewIntent, @@ -47,11 +48,7 @@ type UsePullRequestComposerOptions = { text: string, images: string[], appMentions?: AppMention[], - ) => Promise; - queueMessage: ( - text: string, - images: string[], - appMentions?: AppMention[], + submitIntent?: ComposerSendIntent, ) => Promise; }; @@ -74,7 +71,6 @@ export function usePullRequestComposer({ runPullRequestReview, clearActiveImages, handleSend, - queueMessage, }: UsePullRequestComposerOptions) { const isPullRequestComposer = useMemo( () => @@ -123,6 +119,7 @@ export function usePullRequestComposer({ text: string, images: string[] = [], appMentions: AppMention[] = [], + submitIntent?: ComposerSendIntent, ) => { if (pullRequestReviewLaunching) { return; @@ -143,9 +140,9 @@ export function usePullRequestComposer({ } if (KNOWN_SLASH_COMMAND_REGEX.test(trimmed)) { if (appMentions.length > 0) { - await handleSend(trimmed, images, appMentions); + await handleSend(trimmed, images, appMentions, submitIntent); } else { - await handleSend(trimmed, images); + await handleSend(trimmed, images, undefined, submitIntent); } return; } @@ -208,9 +205,6 @@ export function usePullRequestComposer({ const handleComposerSend = isPullRequestComposer ? handleSendPullRequestQuestion : handleSend; - const handleComposerQueue = isPullRequestComposer - ? handleSendPullRequestQuestion - : queueMessage; return { handleSelectPullRequest, @@ -219,6 +213,5 @@ export function usePullRequestComposer({ composerContextActions, composerSendLabel, handleComposerSend, - handleComposerQueue, }; } diff --git a/src/features/git/hooks/usePullRequestReviewActions.ts b/src/features/git/hooks/usePullRequestReviewActions.ts index d1031bb10..0cf7a45ec 100644 --- a/src/features/git/hooks/usePullRequestReviewActions.ts +++ b/src/features/git/hooks/usePullRequestReviewActions.ts @@ -6,6 +6,7 @@ import type { PullRequestReviewAction, PullRequestReviewIntent, PullRequestSelectionRange, + SendMessageResult, WorkspaceInfo, } from "@/types"; import { pushErrorToast } from "@services/toasts"; @@ -33,7 +34,7 @@ type UsePullRequestReviewActionsOptions = { threadId: string, text: string, images?: string[], - ) => Promise; + ) => Promise; }; type RunPullRequestReviewOptions = { diff --git a/src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx b/src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx index 11543483e..1c7430f0d 100644 --- a/src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx +++ b/src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx @@ -122,7 +122,6 @@ export function buildPrimaryNodes(options: LayoutNodesOptions): PrimaryLayoutNod const composerNode = options.showComposer ? ( void | Promise; - onQueue: ( - text: string, - images: string[], - appMentions?: AppMention[], + submitIntent?: ComposerSendIntent, ) => void | Promise; onStop: () => void; canStop: boolean; onFileAutocompleteActiveChange?: (active: boolean) => void; isReviewing: boolean; isProcessing: boolean; - steerEnabled: boolean; + steerAvailable: boolean; + followUpMessageBehavior: FollowUpMessageBehavior; reviewPrompt: ReviewPromptState; onReviewPromptClose: () => void; onReviewPromptShowPreset: () => void; diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 53d7985eb..a0802a850 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -118,6 +118,7 @@ const baseSettings: AppSettings = { commitMessagePrompt: DEFAULT_COMMIT_MESSAGE_PROMPT, collaborationModesEnabled: true, steerEnabled: true, + followUpMessageBehavior: "queue", pauseQueuedMessagesWhenResponseRequired: true, unifiedExecEnabled: true, experimentalAppsEnabled: false, @@ -220,6 +221,50 @@ const renderDisplaySection = ( return { onUpdateAppSettings, onToggleTransparency }; }; +const renderComposerSection = ( + options: { + appSettings?: Partial; + onUpdateAppSettings?: ComponentProps["onUpdateAppSettings"]; + } = {}, +) => { + cleanup(); + const onUpdateAppSettings = + options.onUpdateAppSettings ?? vi.fn().mockResolvedValue(undefined); + const props: ComponentProps = { + reduceTransparency: false, + onToggleTransparency: vi.fn(), + appSettings: { ...baseSettings, ...options.appSettings }, + openAppIconById: {}, + onUpdateAppSettings, + workspaceGroups: [], + groupedWorkspaces: [], + ungroupedLabel: "Ungrouped", + onClose: vi.fn(), + onMoveWorkspace: vi.fn(), + onDeleteWorkspace: vi.fn(), + onCreateWorkspaceGroup: vi.fn().mockResolvedValue(null), + onRenameWorkspaceGroup: vi.fn().mockResolvedValue(null), + onMoveWorkspaceGroup: vi.fn().mockResolvedValue(null), + onDeleteWorkspaceGroup: vi.fn().mockResolvedValue(null), + onAssignWorkspaceGroup: vi.fn().mockResolvedValue(null), + onRunDoctor: vi.fn().mockResolvedValue(createDoctorResult()), + onUpdateWorkspaceCodexBin: vi.fn().mockResolvedValue(undefined), + onUpdateWorkspaceSettings: vi.fn().mockResolvedValue(undefined), + scaleShortcutTitle: "Scale shortcut", + scaleShortcutText: "Use Command +/-", + onTestNotificationSound: vi.fn(), + onTestSystemNotification: vi.fn(), + dictationModelStatus: null, + onDownloadDictationModel: vi.fn(), + onCancelDictationDownload: vi.fn(), + onRemoveDictationModel: vi.fn(), + initialSection: "composer", + }; + + render(); + return { onUpdateAppSettings }; +}; + const renderFeaturesSection = ( options: { appSettings?: Partial; @@ -1389,25 +1434,47 @@ describe("SettingsView Features", () => { }); }); - it("toggles steer mode in stable features", async () => { - const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + it("hides steer mode dynamic feature row", async () => { renderFeaturesSection({ - onUpdateAppSettings, appSettings: { steerEnabled: true }, }); - const steerTitle = await screen.findByText("Steer mode"); - const steerRow = steerTitle.closest(".settings-toggle-row"); - expect(steerRow).not.toBeNull(); - - const toggle = within(steerRow as HTMLElement).getByRole("button"); - fireEvent.click(toggle); + await screen.findByText("Background terminal"); + expect(screen.queryByText("Steer mode")).toBeNull(); + }); - await waitFor(() => { - expect(onUpdateAppSettings).toHaveBeenCalledWith( - expect.objectContaining({ steerEnabled: false }), - ); + it("hides steer mode when returned as an experimental feature", async () => { + renderFeaturesSection({ + appSettings: { steerEnabled: true }, + experimentalFeaturesResponse: { + data: [ + { + name: "steer", + stage: "underDevelopment", + enabled: true, + defaultEnabled: true, + displayName: "Steer mode", + description: "Legacy steer feature row.", + announcement: null, + }, + { + name: "responses_websockets", + stage: "underDevelopment", + enabled: false, + defaultEnabled: false, + displayName: null, + description: null, + announcement: null, + }, + ], + nextCursor: null, + }, }); + + await screen.findByText( + "Use Responses API WebSocket transport for OpenAI by default.", + ); + expect(screen.queryByText("Steer mode")).toBeNull(); }); it("toggles background terminal in stable features", async () => { @@ -1455,6 +1522,51 @@ describe("SettingsView Features", () => { }); }); +describe("SettingsView Composer", () => { + it("updates follow-up behavior from queue to steer", async () => { + const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + renderComposerSection({ + onUpdateAppSettings, + appSettings: { + steerEnabled: true, + followUpMessageBehavior: "queue", + }, + }); + + fireEvent.click(screen.getByRole("radio", { name: "Steer" })); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ followUpMessageBehavior: "steer" }), + ); + }); + }); + + it("disables steer follow-up behavior when steer is unavailable", async () => { + const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + renderComposerSection({ + onUpdateAppSettings, + appSettings: { + steerEnabled: false, + followUpMessageBehavior: "queue", + }, + }); + + const steerOption = screen.getByRole("radio", { name: "Steer" }); + expect(steerOption.hasAttribute("disabled")).toBe(true); + expect( + screen.getByText( + "Steer is unavailable in the current Codex config. Follow-ups will queue.", + ), + ).not.toBeNull(); + + fireEvent.click(steerOption); + await waitFor(() => { + expect(onUpdateAppSettings).not.toHaveBeenCalled(); + }); + }); +}); + describe("SettingsView mobile layout", () => { it("uses a master/detail flow on narrow mobile widths", async () => { cleanup(); diff --git a/src/features/settings/components/sections/SettingsComposerSection.tsx b/src/features/settings/components/sections/SettingsComposerSection.tsx index f80abe26d..0318af0d4 100644 --- a/src/features/settings/components/sections/SettingsComposerSection.tsx +++ b/src/features/settings/components/sections/SettingsComposerSection.tsx @@ -5,6 +5,7 @@ type ComposerPreset = AppSettings["composerEditorPreset"]; type SettingsComposerSectionProps = { appSettings: AppSettings; optionKeyLabel: string; + followUpShortcutLabel: string; composerPresetLabels: Record; onComposerPresetChange: (preset: ComposerPreset) => void; onUpdateAppSettings: (next: AppSettings) => Promise; @@ -13,16 +14,78 @@ type SettingsComposerSectionProps = { export function SettingsComposerSection({ appSettings, optionKeyLabel, + followUpShortcutLabel, composerPresetLabels, onComposerPresetChange, onUpdateAppSettings, }: SettingsComposerSectionProps) { + const steerUnavailable = !appSettings.steerEnabled; return (
Composer
Control helpers and formatting behavior inside the message editor.
+
+
Follow-up behavior
+
+ + +
+
+ Choose the default while a run is active. Press {followUpShortcutLabel} to send the + opposite behavior for one message. +
+ {steerUnavailable && ( +
+ Steer is unavailable in the current Codex config. Follow-ups will queue. +
+ )} +
+
Presets
Choose a starting point and fine-tune the toggles below. diff --git a/src/features/settings/components/sections/SettingsFeaturesSection.tsx b/src/features/settings/components/sections/SettingsFeaturesSection.tsx index ca4f85c2a..f363c1e8a 100644 --- a/src/features/settings/components/sections/SettingsFeaturesSection.tsx +++ b/src/features/settings/components/sections/SettingsFeaturesSection.tsx @@ -33,7 +33,7 @@ const FEATURE_DESCRIPTION_FALLBACKS: Record = { "Allow prompting and installing missing MCP dependencies.", skill_env_var_dependency_prompt: "Prompt for missing skill environment variable dependencies.", - steer: "Enter submits immediately instead of queueing.", + steer: "Enable turn steering capability when supported by Codex.", collaboration_modes: "Enable collaboration mode presets.", personality: "Enable personality selection.", responses_websockets: diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index f13d949e6..0fb59dd0b 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -22,6 +22,7 @@ import { DEFAULT_COMMIT_MESSAGE_PROMPT } from "@utils/commitMessagePrompt"; const allowedThemes = new Set(["system", "light", "dark", "dim"]); const allowedPersonality = new Set(["friendly", "pragmatic"]); +const allowedFollowUpMessageBehavior = new Set(["queue", "steer"]); const DEFAULT_REMOTE_BACKEND_HOST = "127.0.0.1:4732"; const DEFAULT_REMOTE_BACKEND_ID = "remote-default"; const DEFAULT_REMOTE_BACKEND_NAME = "Primary remote"; @@ -182,6 +183,7 @@ function buildDefaultSettings(): AppSettings { commitMessagePrompt: DEFAULT_COMMIT_MESSAGE_PROMPT, collaborationModesEnabled: true, steerEnabled: true, + followUpMessageBehavior: "queue", pauseQueuedMessagesWhenResponseRequired: true, unifiedExecEnabled: true, experimentalAppsEnabled: false, @@ -253,6 +255,13 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { personality: allowedPersonality.has(settings.personality) ? settings.personality : "friendly", + followUpMessageBehavior: allowedFollowUpMessageBehavior.has( + settings.followUpMessageBehavior, + ) + ? settings.followUpMessageBehavior + : settings.steerEnabled + ? "steer" + : "queue", reviewDeliveryMode: settings.reviewDeliveryMode === "detached" ? "detached" : "inline", chatHistoryScrollbackItems, diff --git a/src/features/settings/hooks/useSettingsFeaturesSection.ts b/src/features/settings/hooks/useSettingsFeaturesSection.ts index ff6bfc1b3..b10166616 100644 --- a/src/features/settings/hooks/useSettingsFeaturesSection.ts +++ b/src/features/settings/hooks/useSettingsFeaturesSection.ts @@ -14,7 +14,11 @@ type UseSettingsFeaturesSectionArgs = { onUpdateAppSettings: (next: AppSettings) => Promise; }; -const HIDDEN_DYNAMIC_FEATURE_KEYS = new Set(["personality", "collab"]); +const HIDDEN_DYNAMIC_FEATURE_KEYS = new Set([ + "personality", + "collab", + "steer", +]); export type SettingsFeaturesSectionProps = { appSettings: AppSettings; @@ -236,7 +240,8 @@ export const useSettingsFeaturesSection = ({ () => features.filter( (feature) => - feature.stage === "beta" || feature.stage === "under_development", + (feature.stage === "beta" || feature.stage === "under_development") && + !HIDDEN_DYNAMIC_FEATURE_KEYS.has(feature.name), ), [features], ); diff --git a/src/features/settings/hooks/useSettingsViewOrchestration.ts b/src/features/settings/hooks/useSettingsViewOrchestration.ts index 52201598f..616ae6571 100644 --- a/src/features/settings/hooks/useSettingsViewOrchestration.ts +++ b/src/features/settings/hooks/useSettingsViewOrchestration.ts @@ -121,6 +121,9 @@ export function useSettingsViewOrchestration({ : isWindowsPlatform() ? "Windows" : "Meta"; + const followUpShortcutLabel = isMacPlatform() + ? "Shift+Cmd+Enter" + : "Shift+Ctrl+Enter"; const selectedDictationModel = useMemo(() => { return ( @@ -222,6 +225,7 @@ export function useSettingsViewOrchestration({ composerSectionProps: { appSettings, optionKeyLabel, + followUpShortcutLabel, composerPresetLabels: COMPOSER_PRESET_LABELS, onComposerPresetChange: ( preset: AppSettings["composerEditorPreset"], diff --git a/src/features/threads/hooks/useQueuedSend.test.tsx b/src/features/threads/hooks/useQueuedSend.test.tsx index d32b10ab7..be894092b 100644 --- a/src/features/threads/hooks/useQueuedSend.test.tsx +++ b/src/features/threads/hooks/useQueuedSend.test.tsx @@ -20,11 +20,12 @@ const makeOptions = ( isProcessing: false, isReviewing: false, steerEnabled: false, + followUpMessageBehavior: "queue" as const, appsEnabled: true, activeWorkspace: workspace, connectWorkspace: vi.fn().mockResolvedValue(undefined), startThreadForWorkspace: vi.fn().mockResolvedValue("thread-1"), - sendUserMessage: vi.fn().mockResolvedValue(undefined), + sendUserMessage: vi.fn().mockResolvedValue({ status: "sent" }), sendUserMessageToThread: vi.fn().mockResolvedValue(undefined), startFork: vi.fn().mockResolvedValue(undefined), startReview: vi.fn().mockResolvedValue(undefined), @@ -109,7 +110,11 @@ describe("useQueuedSend", () => { }); it("sends immediately while processing when steer is enabled", async () => { - const options = makeOptions({ isProcessing: true, steerEnabled: true }); + const options = makeOptions({ + isProcessing: true, + steerEnabled: true, + followUpMessageBehavior: "steer", + }); const { result } = renderHook((props) => useQueuedSend(props), { initialProps: options, }); @@ -119,7 +124,12 @@ describe("useQueuedSend", () => { }); expect(options.sendUserMessage).toHaveBeenCalledTimes(1); - expect(options.sendUserMessage).toHaveBeenCalledWith("Steer", []); + expect(options.sendUserMessage).toHaveBeenCalledWith( + "Steer", + [], + undefined, + { sendIntent: "steer" }, + ); expect(result.current.activeQueue).toHaveLength(0); }); @@ -127,6 +137,7 @@ describe("useQueuedSend", () => { const options = makeOptions({ isProcessing: true, steerEnabled: true, + followUpMessageBehavior: "steer", activeTurnId: null, }); const { result } = renderHook((props) => useQueuedSend(props), { @@ -142,12 +153,37 @@ describe("useQueuedSend", () => { expect(result.current.activeQueue[0]?.text).toBe("Wait for turn"); }); + it("queues the message when a forced steer attempt fails", async () => { + const options = makeOptions({ + isProcessing: true, + steerEnabled: true, + followUpMessageBehavior: "steer", + sendUserMessage: vi.fn().mockResolvedValue({ status: "steer_failed" }), + }); + const { result } = renderHook((props) => useQueuedSend(props), { + initialProps: options, + }); + + await act(async () => { + await result.current.handleSend("Fallback to queue"); + }); + + expect(options.sendUserMessage).toHaveBeenCalledWith( + "Fallback to queue", + [], + undefined, + { sendIntent: "steer" }, + ); + expect(result.current.activeQueue).toHaveLength(1); + expect(result.current.activeQueue[0]?.text).toBe("Fallback to queue"); + }); + it("retries queued send after failure", async () => { const options = makeOptions({ sendUserMessage: vi .fn() .mockRejectedValueOnce(new Error("boom")) - .mockResolvedValueOnce(undefined), + .mockResolvedValueOnce({ status: "sent" }), }); const { result } = renderHook((props) => useQueuedSend(props), { initialProps: options, @@ -217,7 +253,12 @@ describe("useQueuedSend", () => { ...workspace, connected: false, }); - expect(options.sendUserMessage).toHaveBeenCalledWith("Connect", []); + expect(options.sendUserMessage).toHaveBeenCalledWith( + "Connect", + [], + undefined, + { sendIntent: "default" }, + ); }); it("ignores images for queued review messages and blocks while reviewing", async () => { @@ -359,7 +400,12 @@ describe("useQueuedSend", () => { }); expect(startApps).not.toHaveBeenCalled(); - expect(options.sendUserMessage).toHaveBeenCalledWith("/apps now", ["img-1"]); + expect(options.sendUserMessage).toHaveBeenCalledWith( + "/apps now", + ["img-1"], + undefined, + { sendIntent: "default" }, + ); }); it("routes /resume to the resume handler", async () => { diff --git a/src/features/threads/hooks/useQueuedSend.ts b/src/features/threads/hooks/useQueuedSend.ts index f5b98cbec..09ed01a62 100644 --- a/src/features/threads/hooks/useQueuedSend.ts +++ b/src/features/threads/hooks/useQueuedSend.ts @@ -1,5 +1,12 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import type { AppMention, QueuedMessage, WorkspaceInfo } from "@/types"; +import type { + AppMention, + ComposerSendIntent, + FollowUpMessageBehavior, + QueuedMessage, + SendMessageResult, + WorkspaceInfo, +} from "@/types"; type UseQueuedSendOptions = { activeThreadId: string | null; @@ -8,6 +15,7 @@ type UseQueuedSendOptions = { isReviewing: boolean; queueFlushPaused?: boolean; steerEnabled: boolean; + followUpMessageBehavior: FollowUpMessageBehavior; appsEnabled: boolean; activeWorkspace: WorkspaceInfo | null; connectWorkspace: (workspace: WorkspaceInfo) => Promise; @@ -19,13 +27,14 @@ type UseQueuedSendOptions = { text: string, images?: string[], appMentions?: AppMention[], - ) => Promise; + options?: { sendIntent?: ComposerSendIntent }, + ) => Promise; sendUserMessageToThread: ( workspace: WorkspaceInfo, threadId: string, text: string, images?: string[], - ) => Promise; + ) => Promise; startFork: (text: string) => Promise; startReview: (text: string) => Promise; startResume: (text: string) => Promise; @@ -43,6 +52,7 @@ type UseQueuedSendResult = { text: string, images?: string[], appMentions?: AppMention[], + submitIntent?: ComposerSendIntent, ) => Promise; queueMessage: ( text: string, @@ -97,6 +107,7 @@ export function useQueuedSend({ isReviewing, queueFlushPaused = false, steerEnabled, + followUpMessageBehavior, appsEnabled, activeWorkspace, connectWorkspace, @@ -153,6 +164,17 @@ export function useQueuedSend({ })); }, []); + const createQueuedItem = useCallback( + (text: string, images: string[], appMentions: AppMention[]): QueuedMessage => ({ + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + text, + createdAt: Date.now(), + images, + ...(appMentions.length > 0 ? { appMentions } : {}), + }), + [], + ); + const runSlashCommand = useCallback( async (command: SlashCommandKind, trimmed: string) => { if (command === "fork") { @@ -210,25 +232,33 @@ export function useQueuedSend({ text: string, images: string[] = [], appMentions: AppMention[] = [], + submitIntent: ComposerSendIntent = "default", ) => { const trimmed = text.trim(); const command = parseSlashCommand(trimmed, appsEnabled); const nextImages = command ? [] : images; const nextMentions = command ? [] : appMentions; + const canSteerCurrentTurn = + isProcessing && steerEnabled && Boolean(activeTurnId); + const effectiveIntent: ComposerSendIntent = !isProcessing + ? "default" + : submitIntent === "queue" + ? "queue" + : submitIntent === "steer" + ? canSteerCurrentTurn + ? "steer" + : "queue" + : followUpMessageBehavior === "steer" && canSteerCurrentTurn + ? "steer" + : "queue"; if (!trimmed && nextImages.length === 0) { return; } if (activeThreadId && isReviewing) { return; } - if (isProcessing && activeThreadId && (!steerEnabled || !activeTurnId)) { - const item: QueuedMessage = { - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - text: trimmed, - createdAt: Date.now(), - images: nextImages, - ...(nextMentions.length > 0 ? { appMentions: nextMentions } : {}), - }; + if (isProcessing && activeThreadId && effectiveIntent === "queue") { + const item = createQueuedItem(trimmed, nextImages, nextMentions); enqueueMessage(activeThreadId, item); clearActiveImages(); return; @@ -241,10 +271,20 @@ export function useQueuedSend({ clearActiveImages(); return; } - if (nextMentions.length > 0) { - await sendUserMessage(trimmed, nextImages, nextMentions); - } else { - await sendUserMessage(trimmed, nextImages); + const sendResult = + nextMentions.length > 0 + ? await sendUserMessage(trimmed, nextImages, nextMentions, { + sendIntent: effectiveIntent, + }) + : await sendUserMessage(trimmed, nextImages, undefined, { + sendIntent: effectiveIntent, + }); + if ( + sendResult.status === "steer_failed" && + activeThreadId && + isProcessing + ) { + enqueueMessage(activeThreadId, createQueuedItem(trimmed, nextImages, nextMentions)); } clearActiveImages(); }, @@ -254,8 +294,10 @@ export function useQueuedSend({ activeWorkspace, clearActiveImages, connectWorkspace, + createQueuedItem, enqueueMessage, activeTurnId, + followUpMessageBehavior, isProcessing, isReviewing, steerEnabled, @@ -283,17 +325,18 @@ export function useQueuedSend({ if (!activeThreadId) { return; } - const item: QueuedMessage = { - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - text: trimmed, - createdAt: Date.now(), - images: nextImages, - ...(nextMentions.length > 0 ? { appMentions: nextMentions } : {}), - }; + const item = createQueuedItem(trimmed, nextImages, nextMentions); enqueueMessage(activeThreadId, item); clearActiveImages(); }, - [activeThreadId, appsEnabled, clearActiveImages, enqueueMessage, isReviewing], + [ + activeThreadId, + appsEnabled, + clearActiveImages, + createQueuedItem, + enqueueMessage, + isReviewing, + ], ); useEffect(() => { diff --git a/src/features/threads/hooks/useThreadMessaging.test.tsx b/src/features/threads/hooks/useThreadMessaging.test.tsx index 38046786e..e695e6d39 100644 --- a/src/features/threads/hooks/useThreadMessaging.test.tsx +++ b/src/features/threads/hooks/useThreadMessaging.test.tsx @@ -271,6 +271,8 @@ describe("useThreadMessaging telemetry", () => { it("does not fall back to turn/start when turn/steer fails", async () => { const pushThreadErrorMessage = vi.fn(); + const markProcessing = vi.fn(); + const setActiveTurnId = vi.fn(); vi.mocked(steerTurnService).mockResolvedValueOnce({ error: { message: "no active turn to steer" }, } as unknown as Awaited>); @@ -302,9 +304,9 @@ describe("useThreadMessaging telemetry", () => { pendingInterruptsRef: { current: new Set() }, dispatch: vi.fn(), getCustomName: vi.fn(() => undefined), - markProcessing: vi.fn(), + markProcessing, markReviewing: vi.fn(), - setActiveTurnId: vi.fn(), + setActiveTurnId, recordThreadActivity: vi.fn(), safeMessageActivity: vi.fn(), onDebug: vi.fn(), @@ -318,19 +320,93 @@ describe("useThreadMessaging telemetry", () => { ); await act(async () => { - await result.current.sendUserMessageToThread( + const sendResult = await result.current.sendUserMessageToThread( workspace, "thread-1", "steer should fail", [], ); + expect(sendResult).toEqual({ status: "steer_failed" }); }); expect(steerTurnService).toHaveBeenCalledTimes(1); expect(sendUserMessageService).not.toHaveBeenCalled(); + expect(markProcessing).toHaveBeenCalledWith("thread-1", true); + expect(markProcessing).not.toHaveBeenCalledWith("thread-1", false); + expect(setActiveTurnId).not.toHaveBeenCalledWith("thread-1", null); + expect(pushThreadErrorMessage).toHaveBeenCalledWith( + "thread-1", + "Turn steer failed: no active turn to steer. Message queued.", + ); + }); + + it("returns steer_failed and keeps processing state when turn/steer throws", async () => { + const pushThreadErrorMessage = vi.fn(); + const markProcessing = vi.fn(); + const setActiveTurnId = vi.fn(); + vi.mocked(steerTurnService).mockRejectedValueOnce( + new Error("steer network failure"), + ); + + const { result } = renderHook(() => + useThreadMessaging({ + activeWorkspace: workspace, + activeThreadId: "thread-1", + accessMode: "current", + model: null, + effort: null, + collaborationMode: null, + reviewDeliveryMode: "inline", + steerEnabled: true, + customPrompts: [], + threadStatusById: { + "thread-1": { + isProcessing: true, + isReviewing: false, + hasUnread: false, + processingStartedAt: 0, + lastDurationMs: null, + }, + }, + activeTurnIdByThread: { + "thread-1": "turn-1", + }, + rateLimitsByWorkspace: {}, + pendingInterruptsRef: { current: new Set() }, + dispatch: vi.fn(), + getCustomName: vi.fn(() => undefined), + markProcessing, + markReviewing: vi.fn(), + setActiveTurnId, + recordThreadActivity: vi.fn(), + safeMessageActivity: vi.fn(), + onDebug: vi.fn(), + pushThreadErrorMessage, + ensureThreadForActiveWorkspace: vi.fn(async () => "thread-1"), + ensureThreadForWorkspace: vi.fn(async () => "thread-1"), + refreshThread: vi.fn(async () => null), + forkThreadForWorkspace: vi.fn(async () => null), + updateThreadParent: vi.fn(), + }), + ); + + await act(async () => { + const sendResult = await result.current.sendUserMessageToThread( + workspace, + "thread-1", + "steer exception", + [], + ); + expect(sendResult).toEqual({ status: "steer_failed" }); + }); + + expect(sendUserMessageService).not.toHaveBeenCalled(); + expect(markProcessing).toHaveBeenCalledWith("thread-1", true); + expect(markProcessing).not.toHaveBeenCalledWith("thread-1", false); + expect(setActiveTurnId).not.toHaveBeenCalledWith("thread-1", null); expect(pushThreadErrorMessage).toHaveBeenCalledWith( "thread-1", - "Turn steer failed: no active turn to steer", + "Turn steer failed: steer network failure. Message queued.", ); }); }); diff --git a/src/features/threads/hooks/useThreadMessaging.ts b/src/features/threads/hooks/useThreadMessaging.ts index d17226739..c7da9a783 100644 --- a/src/features/threads/hooks/useThreadMessaging.ts +++ b/src/features/threads/hooks/useThreadMessaging.ts @@ -4,6 +4,8 @@ import * as Sentry from "@sentry/react"; import type { AccessMode, AppMention, + ComposerSendIntent, + SendMessageResult, RateLimitSnapshot, CustomPromptOption, DebugEntry, @@ -37,6 +39,7 @@ type SendMessageOptions = { collaborationMode?: Record | null; accessMode?: AccessMode; appMentions?: AppMention[]; + sendIntent?: ComposerSendIntent; }; type UseThreadMessagingOptions = { @@ -119,10 +122,10 @@ export function useThreadMessaging({ text: string, images: string[] = [], options?: SendMessageOptions, - ) => { + ): Promise => { const messageText = text.trim(); if (!messageText && images.length === 0) { - return; + return { status: "blocked" }; } let finalText = messageText; if (!options?.skipPromptExpansion) { @@ -130,7 +133,7 @@ export function useThreadMessaging({ if (promptExpansion && "error" in promptExpansion) { pushThreadErrorMessage(threadId, promptExpansion.error); safeMessageActivity(); - return; + return { status: "blocked" }; } finalText = promptExpansion?.expanded ?? messageText; } @@ -151,11 +154,18 @@ export function useThreadMessaging({ const resolvedAccessMode = options?.accessMode !== undefined ? options.accessMode : accessMode; const appMentions = options?.appMentions ?? []; + const sendIntent = options?.sendIntent ?? "default"; const isProcessing = threadStatusById[threadId]?.isProcessing ?? false; const activeTurnId = activeTurnIdByThread[threadId] ?? null; - const shouldSteer = + const canSteerCurrentTurn = isProcessing && steerEnabled && Boolean(activeTurnId); + const shouldSteer = + sendIntent === "steer" + ? canSteerCurrentTurn + : sendIntent === "queue" + ? false + : canSteerCurrentTurn; Sentry.metrics.count("prompt_sent", 1, { attributes: { workspace_id: workspace.id, @@ -165,9 +175,11 @@ export function useThreadMessaging({ model: resolvedModel ?? "unknown", effort: resolvedEffort ?? "unknown", collaboration_mode: sanitizedCollaborationMode ?? "unknown", + send_intent: sendIntent, }, }); const timestamp = Date.now(); + const customThreadName = getCustomName(workspace.id, threadId) ?? null; recordThreadActivity(workspace.id, threadId, timestamp); dispatch({ type: "setThreadTimestamp", @@ -191,6 +203,8 @@ export function useThreadMessaging({ model: resolvedModel, effort: resolvedEffort, collaborationMode: sanitizedCollaborationMode, + sendIntent, + threadCustomName: customThreadName, }, }); const requestMode: "start" | "steer" = shouldSteer ? "steer" : "start"; @@ -253,15 +267,16 @@ export function useThreadMessaging({ if (requestMode !== "steer") { markProcessing(threadId, false); setActiveTurnId(threadId, null); + pushThreadErrorMessage(threadId, `Turn failed to start: ${rpcError}`); + safeMessageActivity(); + return { status: "blocked" }; } pushThreadErrorMessage( threadId, - requestMode === "steer" - ? `Turn steer failed: ${rpcError}` - : `Turn failed to start: ${rpcError}`, + `Turn steer failed: ${rpcError}. Message queued.`, ); safeMessageActivity(); - return; + return { status: "steer_failed" }; } if (requestMode === "steer") { const result = (response?.result ?? response) as Record; @@ -269,7 +284,7 @@ export function useThreadMessaging({ if (steeredTurnId) { setActiveTurnId(threadId, steeredTurnId); } - return; + return { status: "sent" }; } const result = (response?.result ?? response) as Record; const turn = (result?.turn ?? response?.turn ?? null) as @@ -281,9 +296,10 @@ export function useThreadMessaging({ setActiveTurnId(threadId, null); pushThreadErrorMessage(threadId, "Turn failed to start."); safeMessageActivity(); - return; + return { status: "blocked" }; } setActiveTurnId(threadId, turnId); + return { status: "sent" }; } catch (error) { if (requestMode !== "steer") { markProcessing(threadId, false); @@ -299,12 +315,15 @@ export function useThreadMessaging({ pushThreadErrorMessage( threadId, requestMode === "steer" - ? `Turn steer failed: ${error instanceof Error ? error.message : String(error)}` + ? `Turn steer failed: ${ + error instanceof Error ? error.message : String(error) + }. Message queued.` : error instanceof Error ? error.message : String(error), ); safeMessageActivity(); + return { status: requestMode === "steer" ? "steer_failed" : "blocked" }; } }, [ @@ -332,13 +351,14 @@ export function useThreadMessaging({ text: string, images: string[] = [], appMentions: AppMention[] = [], - ) => { + options?: { sendIntent?: ComposerSendIntent }, + ): Promise => { if (!activeWorkspace) { - return; + return { status: "blocked" }; } const messageText = text.trim(); if (!messageText && images.length === 0) { - return; + return { status: "blocked" }; } const promptExpansion = expandCustomPromptText(messageText, customPrompts); if (promptExpansion && "error" in promptExpansion) { @@ -354,16 +374,17 @@ export function useThreadMessaging({ payload: promptExpansion.error, }); } - return; + return { status: "blocked" }; } const finalText = promptExpansion?.expanded ?? messageText; const threadId = await ensureThreadForActiveWorkspace(); if (!threadId) { - return; + return { status: "blocked" }; } - await sendMessageToThread(activeWorkspace, threadId, finalText, images, { + return sendMessageToThread(activeWorkspace, threadId, finalText, images, { skipPromptExpansion: true, appMentions, + sendIntent: options?.sendIntent, }); }, [ @@ -385,8 +406,8 @@ export function useThreadMessaging({ text: string, images: string[] = [], options?: SendMessageOptions, - ) => { - await sendMessageToThread(workspace, threadId, text, images, options); + ): Promise => { + return sendMessageToThread(workspace, threadId, text, images, options); }, [sendMessageToThread], ); diff --git a/src/features/threads/hooks/useThreads.integration.test.tsx b/src/features/threads/hooks/useThreads.integration.test.tsx index 2f8da6e6a..a8600797b 100644 --- a/src/features/threads/hooks/useThreads.integration.test.tsx +++ b/src/features/threads/hooks/useThreads.integration.test.tsx @@ -543,6 +543,7 @@ describe("useThreads UX integration", () => { isProcessing: status?.isProcessing ?? false, isReviewing: status?.isReviewing ?? false, steerEnabled: false, + followUpMessageBehavior: "queue", appsEnabled: true, activeWorkspace: workspace, connectWorkspace, diff --git a/src/features/workspaces/hooks/useWorkspaceHome.ts b/src/features/workspaces/hooks/useWorkspaceHome.ts index 297874bc3..82c8b3db2 100644 --- a/src/features/workspaces/hooks/useWorkspaceHome.ts +++ b/src/features/workspaces/hooks/useWorkspaceHome.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import type { ModelOption, WorkspaceInfo } from "../../../types"; +import type { ModelOption, SendMessageResult, WorkspaceInfo } from "../../../types"; import { generateRunMetadata } from "../../../services/tauri"; export type WorkspaceRunMode = "local" | "worktree"; @@ -52,7 +52,7 @@ type UseWorkspaceHomeOptions = { effort?: string | null; collaborationMode?: Record | null; }, - ) => Promise; + ) => Promise; onWorktreeCreated?: (worktree: WorkspaceInfo, parent: WorkspaceInfo) => Promise | void; }; diff --git a/src/styles/composer.css b/src/styles/composer.css index c92372050..a2080c140 100644 --- a/src/styles/composer.css +++ b/src/styles/composer.css @@ -70,6 +70,29 @@ color: var(--text-stronger); } +.composer-followup-hint { + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px 8px; + border-radius: 10px; + border: 1px solid var(--border-muted); + background: var(--surface-card); +} + +.composer-followup-title { + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-fainter); +} + +.composer-followup-copy { + font-size: 11px; + line-height: 1.4; + color: var(--text-subtle); +} + .composer-context-actions { display: flex; flex-wrap: wrap; diff --git a/src/styles/settings.css b/src/styles/settings.css index f2566facb..cefef11b4 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -697,6 +697,67 @@ font-size: 11px; } +.settings-segmented { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px; + border-radius: 999px; + border: 1px solid var(--border-muted); + background: var(--surface-control); +} + +.settings-segmented-option { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: transparent; + color: var(--text-muted); + font-size: 12px; + font-weight: 600; + padding: 0; + min-width: 72px; + overflow: hidden; +} + +.settings-segmented-input { + position: absolute; + inset: 0; + opacity: 0; + margin: 0; +} + +.settings-segmented-option-label { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 6px 12px; +} + +.settings-segmented-option:hover:not(.is-disabled) { + color: var(--text-strong); + background: color-mix(in srgb, var(--surface-card) 55%, transparent); +} + +.settings-segmented-option.is-active { + color: var(--text-strong); + background: var(--surface-card); + box-shadow: inset 0 0 0 1px var(--border-strong); +} + +.settings-segmented-option.is-disabled { + cursor: not-allowed; + color: var(--text-faint); +} + +.settings-segmented-input:focus-visible + .settings-segmented-option-label { + outline: 2px solid var(--focus-ring); + outline-offset: -2px; +} + .settings-section-title { font-size: 15px; font-weight: 600; diff --git a/src/types.ts b/src/types.ts index 13e08964e..63c689060 100644 --- a/src/types.ts +++ b/src/types.ts @@ -154,7 +154,11 @@ export type RemoteBackendTarget = { }; export type ThemePreference = "system" | "light" | "dark" | "dim"; export type PersonalityPreference = "friendly" | "pragmatic"; - +export type FollowUpMessageBehavior = "queue" | "steer"; +export type ComposerSendIntent = "default" | "queue" | "steer"; +export type SendMessageResult = { + status: "sent" | "blocked" | "steer_failed"; +}; export type ComposerEditorPreset = "default" | "helpful" | "smart"; @@ -228,6 +232,7 @@ export type AppSettings = { commitMessagePrompt: string; collaborationModesEnabled: boolean; steerEnabled: boolean; + followUpMessageBehavior: FollowUpMessageBehavior; pauseQueuedMessagesWhenResponseRequired: boolean; unifiedExecEnabled: boolean; experimentalAppsEnabled: boolean;