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
12 changes: 12 additions & 0 deletions src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,11 @@ pub(crate) struct AppSettings {
alias = "experimentalSteerEnabled"
)]
pub(crate) steer_enabled: bool,
#[serde(
default = "default_pause_queued_messages_when_response_required",
rename = "pauseQueuedMessagesWhenResponseRequired"
)]
pub(crate) pause_queued_messages_when_response_required: bool,
#[serde(
default = "default_unified_exec_enabled",
rename = "unifiedExecEnabled",
Expand Down Expand Up @@ -970,6 +975,10 @@ fn default_steer_enabled() -> bool {
true
}

fn default_pause_queued_messages_when_response_required() -> bool {
true
}

fn default_unified_exec_enabled() -> bool {
true
}
Expand Down Expand Up @@ -1210,6 +1219,8 @@ impl Default for AppSettings {
experimental_collab_enabled: false,
collaboration_modes_enabled: true,
steer_enabled: true,
pause_queued_messages_when_response_required:
default_pause_queued_messages_when_response_required(),
unified_exec_enabled: true,
experimental_apps_enabled: false,
personality: default_personality(),
Expand Down Expand Up @@ -1373,6 +1384,7 @@ mod tests {
assert!(settings.commit_message_prompt.contains("{diff}"));
assert!(settings.collaboration_modes_enabled);
assert!(settings.steer_enabled);
assert!(settings.pause_queued_messages_when_response_required);
assert!(settings.unified_exec_enabled);
assert!(!settings.experimental_apps_enabled);
assert_eq!(settings.personality, "friendly");
Expand Down
39 changes: 39 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import type {
ComposerEditorSettings,
WorkspaceInfo,
} from "@/types";
import { computePlanFollowupState } from "@/features/messages/utils/messageRenderUtils";
import { OPEN_APP_STORAGE_KEY } from "@app/constants";
import { useOpenAppIcons } from "@app/hooks/useOpenAppIcons";
import { useAccountSwitching } from "@app/hooks/useAccountSwitching";
Expand Down Expand Up @@ -1183,6 +1184,42 @@ function MainApp() {
const activeTurnId = activeThreadId
? activeTurnIdByThread[activeThreadId] ?? null
: null;
const hasUserInputRequestForActiveThread = Boolean(
activeThreadId &&
userInputRequests.some(
(request) =>
request.params.thread_id === activeThreadId &&
(!activeWorkspaceId || request.workspace_id === activeWorkspaceId),
),
);

const isPlanReadyAwaitingResponse = useMemo(() => {
return computePlanFollowupState({
threadId: activeThreadId,
items: activeItems,
isThinking: isProcessing,
hasVisibleUserInputRequest: hasUserInputRequestForActiveThread,
}).shouldShow;
}, [
activeItems,
activeThreadId,
hasUserInputRequestForActiveThread,
isProcessing,
]);

const queueFlushPaused = Boolean(
appSettings.pauseQueuedMessagesWhenResponseRequired &&
activeThreadId &&
(hasUserInputRequestForActiveThread || isPlanReadyAwaitingResponse),
);

const queuePausedReason =
queueFlushPaused && hasUserInputRequestForActiveThread
? "Paused — waiting for your answers."
: queueFlushPaused && isPlanReadyAwaitingResponse
? "Paused — waiting for plan accept/changes."
: null;

const {
activeImages,
attachImages,
Expand Down Expand Up @@ -1210,6 +1247,7 @@ function MainApp() {
activeWorkspace,
isProcessing,
isReviewing,
queueFlushPaused,
steerEnabled: appSettings.steerEnabled,
appsEnabled: appSettings.experimentalAppsEnabled,
connectWorkspace,
Expand Down Expand Up @@ -2078,6 +2116,7 @@ function MainApp() {
onReviewPromptConfirmCustom: confirmCustom,
activeTokenUsage,
activeQueue,
queuePausedReason,
draftText: activeDraft,
onDraftChange: handleDraftChange,
activeImages,
Expand Down
3 changes: 3 additions & 0 deletions src/features/app/hooks/useComposerController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function useComposerController({
activeWorkspace,
isProcessing,
isReviewing,
queueFlushPaused = false,
steerEnabled,
appsEnabled,
connectWorkspace,
Expand All @@ -30,6 +31,7 @@ export function useComposerController({
activeWorkspace: WorkspaceInfo | null;
isProcessing: boolean;
isReviewing: boolean;
queueFlushPaused?: boolean;
steerEnabled: boolean;
appsEnabled: boolean;
connectWorkspace: (workspace: WorkspaceInfo) => Promise<void>;
Expand Down Expand Up @@ -84,6 +86,7 @@ export function useComposerController({
activeTurnId,
isProcessing,
isReviewing,
queueFlushPaused,
steerEnabled,
appsEnabled,
activeWorkspace,
Expand Down
3 changes: 3 additions & 0 deletions src/features/composer/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ type ComposerProps = {
files: string[];
contextUsage?: ThreadTokenUsage | null;
queuedMessages?: QueuedMessage[];
queuePausedReason?: string | null;
onEditQueued?: (item: QueuedMessage) => void;
onDeleteQueued?: (id: string) => void;
sendLabel?: string;
Expand Down Expand Up @@ -174,6 +175,7 @@ export const Composer = memo(function Composer({
files,
contextUsage = null,
queuedMessages = [],
queuePausedReason = null,
onEditQueued,
onDeleteQueued,
sendLabel = "Send",
Expand Down Expand Up @@ -635,6 +637,7 @@ export const Composer = memo(function Composer({
<footer className={`composer${disabled ? " is-disabled" : ""}`}>
<ComposerQueue
queuedMessages={queuedMessages}
pausedReason={queuePausedReason}
onEditQueued={onEditQueued}
onDeleteQueued={onDeleteQueued}
/>
Expand Down
5 changes: 5 additions & 0 deletions src/features/composer/components/ComposerQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import type { QueuedMessage } from "../../../types";

type ComposerQueueProps = {
queuedMessages: QueuedMessage[];
pausedReason?: string | null;
onEditQueued?: (item: QueuedMessage) => void;
onDeleteQueued?: (id: string) => void;
};

export function ComposerQueue({
queuedMessages,
pausedReason = null,
onEditQueued,
onDeleteQueued,
}: ComposerQueueProps) {
Expand Down Expand Up @@ -43,6 +45,9 @@ export function ComposerQueue({
return (
<div className="composer-queue">
<div className="composer-queue-title">Queued</div>
{pausedReason ? (
<div className="composer-queue-hint">{pausedReason}</div>
) : null}
<div className="composer-queue-list">
{queuedMessages.map((item) => (
<div key={item.id} className="composer-queue-item">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export function buildPrimaryNodes(options: LayoutNodesOptions): PrimaryLayoutNod
onFileAutocompleteActiveChange={options.onFileAutocompleteActiveChange}
contextUsage={options.activeTokenUsage}
queuedMessages={options.activeQueue}
queuePausedReason={options.queuePausedReason}
sendLabel={
options.composerSendLabel ??
(options.isProcessing && !options.steerEnabled ? "Queue" : "Send")
Expand Down
1 change: 1 addition & 0 deletions src/features/layout/hooks/layoutNodes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ export type LayoutNodesOptions = {
onReviewPromptConfirmCustom: () => Promise<void>;
activeTokenUsage: ThreadTokenUsage | null;
activeQueue: QueuedMessage[];
queuePausedReason: string | null;
draftText: string;
onDraftChange: (next: string) => void;
activeImages: string[];
Expand Down
57 changes: 15 additions & 42 deletions src/features/messages/components/Messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ import { useFileLinkOpener } from "../hooks/useFileLinkOpener";
import {
SCROLL_THRESHOLD_PX,
buildToolGroups,
computePlanFollowupState,
formatCount,
parseReasoning,
scrollKeyForItems,
toolStatusTone,
} from "../utils/messageRenderUtils";
import {
DiffRow,
Expand Down Expand Up @@ -288,51 +288,24 @@ export const Messages = memo(function Messages({
useState<Record<string, string>>({});

const planFollowup = useMemo(() => {
if (!threadId) {
return { shouldShow: false, planItemId: null as string | null };
}
if (!onPlanAccept || !onPlanSubmitChanges) {
return { shouldShow: false, planItemId: null as string | null };
}
if (hasVisibleUserInputRequest) {
return { shouldShow: false, planItemId: null as string | null };
}
let planIndex = -1;
let planItem: Extract<ConversationItem, { kind: "tool" }> | null = null;
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (item.kind === "tool" && item.toolType === "plan") {
planIndex = index;
planItem = item;
break;
}
}
if (!planItem) {
return { shouldShow: false, planItemId: null as string | null };
}
const planItemId = planItem.id;
if (dismissedPlanFollowupByThread[threadId] === planItemId) {
return { shouldShow: false, planItemId };
return { shouldShow: false, planItemId: null };
}
if (!(planItem.output ?? "").trim()) {
return { shouldShow: false, planItemId };
}
const planTone = toolStatusTone(planItem, false);
if (planTone === "failed") {
return { shouldShow: false, planItemId };
}
// Some backends stream plan output deltas without a final status update. As
// soon as the turn stops thinking, treat the latest plan output as ready.
if (isThinking && planTone !== "completed") {
return { shouldShow: false, planItemId };
}
for (let index = planIndex + 1; index < items.length; index += 1) {
const item = items[index];
if (item.kind === "message" && item.role === "user") {
return { shouldShow: false, planItemId };

const candidate = computePlanFollowupState({
threadId,
items,
isThinking,
hasVisibleUserInputRequest,
});

if (threadId && candidate.planItemId) {
if (dismissedPlanFollowupByThread[threadId] === candidate.planItemId) {
return { ...candidate, shouldShow: false };
}
}
return { shouldShow: true, planItemId };

return candidate;
}, [
dismissedPlanFollowupByThread,
hasVisibleUserInputRequest,
Expand Down
66 changes: 66 additions & 0 deletions src/features/messages/utils/messageRenderUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,72 @@ export function toolStatusTone(
return "processing";
}


export type PlanFollowupState = {
shouldShow: boolean;
planItemId: string | null;
};

export function computePlanFollowupState({
threadId,
items,
isThinking,
hasVisibleUserInputRequest,
}: {
threadId: string | null;
items: ConversationItem[];
isThinking: boolean;
hasVisibleUserInputRequest: boolean;
}): PlanFollowupState {
if (!threadId) {
return { shouldShow: false, planItemId: null };
}
if (hasVisibleUserInputRequest) {
return { shouldShow: false, planItemId: null };
}

let planIndex = -1;
let planItem: Extract<ConversationItem, { kind: "tool" }> | null = null;
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (item.kind === "tool" && item.toolType === "plan") {
planIndex = index;
planItem = item;
break;
}
}

if (!planItem) {
return { shouldShow: false, planItemId: null };
}

const planItemId = planItem.id;

if (!(planItem.output ?? "").trim()) {
return { shouldShow: false, planItemId };
}

const planTone = toolStatusTone(planItem, false);
if (planTone === "failed") {
return { shouldShow: false, planItemId };
}

// Some backends stream plan output deltas without a final status update. As
// soon as the turn stops thinking, treat the latest plan output as ready.
if (isThinking && planTone !== "completed") {
return { shouldShow: false, planItemId };
}

for (let index = planIndex + 1; index < items.length; index += 1) {
const item = items[index];
if (item.kind === "message" && item.role === "user") {
return { shouldShow: false, planItemId };
}
}

return { shouldShow: true, planItemId };
}

export function scrollKeyForItems(items: ConversationItem[]) {
if (!items.length) {
return "empty";
Expand Down
1 change: 1 addition & 0 deletions src/features/settings/components/SettingsView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ const baseSettings: AppSettings = {
experimentalCollabEnabled: false,
collaborationModesEnabled: true,
steerEnabled: true,
pauseQueuedMessagesWhenResponseRequired: true,
unifiedExecEnabled: true,
experimentalAppsEnabled: false,
personality: "friendly",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,31 @@ export function SettingsFeaturesSection({
<span className="settings-toggle-knob" />
</button>
</div>
<div className="settings-toggle-row">
<div>
<div className="settings-toggle-title">
Pause queued messages when a response is required
</div>
<div className="settings-toggle-subtitle">
Keep queued messages paused while Codex is waiting for plan accept/changes
or your answers.
</div>
</div>
<button
type="button"
className={`settings-toggle ${appSettings.pauseQueuedMessagesWhenResponseRequired ? "on" : ""}`}
onClick={() =>
void onUpdateAppSettings({
...appSettings,
pauseQueuedMessagesWhenResponseRequired:
!appSettings.pauseQueuedMessagesWhenResponseRequired,
})
}
aria-pressed={appSettings.pauseQueuedMessagesWhenResponseRequired}
>
<span className="settings-toggle-knob" />
</button>
</div>
<div className="settings-toggle-row">
<div>
<div className="settings-toggle-title">Background terminal</div>
Expand Down
1 change: 1 addition & 0 deletions src/features/settings/hooks/useAppSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ function buildDefaultSettings(): AppSettings {
experimentalCollabEnabled: false,
collaborationModesEnabled: true,
steerEnabled: true,
pauseQueuedMessagesWhenResponseRequired: true,
unifiedExecEnabled: true,
experimentalAppsEnabled: false,
personality: "friendly",
Expand Down
Loading
Loading