diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 3145038647..30c4d2e4dc 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -64,6 +64,7 @@ interface PendingUserInputRequest { threadId: ThreadId; turnId?: TurnId; itemId?: ProviderItemId; + questionIds?: string[]; } interface CodexUserInputAnswer { @@ -864,7 +865,17 @@ export class CodexAppServerManager extends EventEmitter).length === 0 && + pendingRequest.questionIds; + const codexAnswers = isDismissed + ? Object.fromEntries( + pendingRequest.questionIds!.map((id) => [ + id, + { answers: ["[User dismissed this question without answering]"] }, + ]), + ) + : toCodexUserInputAnswers(answers); this.writeMessage(context, { id: pendingRequest.jsonRpcId, result: { @@ -1148,12 +1159,19 @@ export class CodexAppServerManager extends EventEmitter)?.questions) + ? ((request.params as Record).questions as Array>) + : []; + const questionIds = rawQuestions + .map((q) => (typeof q.id === "string" ? q.id : null)) + .filter((id): id is string => id !== null); context.pendingUserInputs.set(requestId, { requestId, jsonRpcId: request.id, threadId: context.session.threadId, ...(effectiveTurnId ? { turnId: effectiveTurnId } : {}), ...(rawRoute.itemId ? { itemId: rawRoute.itemId } : {}), + ...(questionIds.length > 0 ? { questionIds } : {}), }); } diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 9f2eeb014e..357064bf49 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2501,6 +2501,22 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } satisfies PermissionResult; } + // Empty answers means the user dismissed the question without answering. + if (Object.keys(answers as Record).length === 0) { + const dismissedAnswers: Record = {}; + for (const q of questions) { + dismissedAnswers[q.question || q.header] = + "[User dismissed this question without answering]"; + } + return { + behavior: "allow", + updatedInput: { + questions: toolInput.questions, + answers: dismissedAnswers, + }, + } satisfies PermissionResult; + } + // Return the answers to the SDK in the expected format: // { questions: [...], answers: { questionText: selectedLabel } } return { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index aeab2d083a..f96abef32c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1081,6 +1081,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const activePendingIsResponding = activePendingUserInput ? respondingUserInputRequestIds.includes(activePendingUserInput.requestId) : false; + const activeProposedPlan = useMemo(() => { if (!latestTurnSettled) { return null; @@ -3194,6 +3195,55 @@ export default function ChatView({ threadId }: ChatViewProps) { [activeThreadId, setThreadError], ); + const onDismissUserInput = useCallback( + async (requestId: ApprovalRequestId) => { + const api = readNativeApi(); + if (!api || !activeThreadId) return; + + setRespondingUserInputRequestIds((existing) => + existing.includes(requestId) ? existing : [...existing, requestId], + ); + await api.orchestration + .dispatchCommand({ + type: "thread.user-input.respond", + commandId: newCommandId(), + threadId: activeThreadId, + requestId, + answers: {}, + createdAt: new Date().toISOString(), + }) + .catch((err: unknown) => { + setThreadError( + activeThreadId, + err instanceof Error ? err.message : "Failed to dismiss user input.", + ); + }); + setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); + setPendingUserInputAnswersByRequestId((existing) => { + const { [requestId]: _, ...rest } = existing; + return rest; + }); + setPendingUserInputQuestionIndexByRequestId((existing) => { + const { [requestId]: _, ...rest } = existing; + return rest; + }); + }, + [activeThreadId, setThreadError], + ); + + // Dismiss pending user input on Escape key press. + useEffect(() => { + if (!activePendingUserInput || activePendingIsResponding) return; + const handler = (event: globalThis.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + void onDismissUserInput(activePendingUserInput.requestId); + } + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [activePendingUserInput, activePendingIsResponding, onDismissUserInput]); + const setActivePendingUserInputQuestionIndex = useCallback( (nextQuestionIndex: number) => { if (!activePendingUserInput) { @@ -4065,6 +4115,7 @@ export default function ChatView({ threadId }: ChatViewProps) { questionIndex={activePendingQuestionIndex} onSelectOption={onSelectActivePendingUserInputOption} onAdvance={onAdvanceActivePendingUserInput} + onDismiss={onDismissUserInput} /> ) : showPlanFollowUpPrompt && activeProposedPlan ? ( diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index c8cad7bf36..6a7756104b 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -5,7 +5,7 @@ import { derivePendingUserInputProgress, type PendingUserInputDraftAnswer, } from "../../pendingUserInput"; -import { CheckIcon } from "lucide-react"; +import { CheckIcon, XIcon } from "lucide-react"; import { cn } from "~/lib/utils"; interface PendingUserInputPanelProps { @@ -15,6 +15,7 @@ interface PendingUserInputPanelProps { questionIndex: number; onSelectOption: (questionId: string, optionLabel: string) => void; onAdvance: () => void; + onDismiss: (requestId: ApprovalRequestId) => void; } export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ @@ -24,6 +25,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn questionIndex, onSelectOption, onAdvance, + onDismiss, }: PendingUserInputPanelProps) { if (pendingUserInputs.length === 0) return null; const activePrompt = pendingUserInputs[0]; @@ -38,6 +40,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn questionIndex={questionIndex} onSelectOption={onSelectOption} onAdvance={onAdvance} + onDismiss={onDismiss} /> ); }); @@ -49,6 +52,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( questionIndex, onSelectOption, onAdvance, + onDismiss, }: { prompt: PendingUserInput; isResponding: boolean; @@ -56,6 +60,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( questionIndex: number; onSelectOption: (questionId: string, optionLabel: string) => void; onAdvance: () => void; + onDismiss: (requestId: ApprovalRequestId) => void; }) { const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); const activeQuestion = progress.activeQuestion; @@ -122,7 +127,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( return (
-
+
{prompt.questions.length > 1 ? ( {questionIndex + 1}/{prompt.questions.length} @@ -132,6 +137,16 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( {activeQuestion.header}
+

{activeQuestion.question}