Skip to content

Commit 0257d73

Browse files
mrincclaude
andcommitted
feat: allow dismissing pending user-input questions
Add dismiss/close functionality to the pending user-input question UI. Empty answers ({}) are treated as a dismissal -- both adapters detect this and inform the model with explicit "[User dismissed this question without answering]" text. No contract or pipeline changes needed. Closes #479, Closes #856 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1234708 commit 0257d73

4 files changed

Lines changed: 103 additions & 3 deletions

File tree

apps/server/src/codexAppServerManager.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ interface PendingUserInputRequest {
6464
threadId: ThreadId;
6565
turnId?: TurnId;
6666
itemId?: ProviderItemId;
67+
questionIds?: string[];
6768
}
6869

6970
interface CodexUserInputAnswer {
@@ -864,7 +865,17 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
864865
}
865866

866867
context.pendingUserInputs.delete(requestId);
867-
const codexAnswers = toCodexUserInputAnswers(answers);
868+
const isDismissed =
869+
Object.keys(answers as Record<string, unknown>).length === 0 &&
870+
pendingRequest.questionIds;
871+
const codexAnswers = isDismissed
872+
? Object.fromEntries(
873+
pendingRequest.questionIds!.map((id) => [
874+
id,
875+
{ answers: ["[User dismissed this question without answering]"] },
876+
]),
877+
)
878+
: toCodexUserInputAnswers(answers);
868879
this.writeMessage(context, {
869880
id: pendingRequest.jsonRpcId,
870881
result: {
@@ -1148,12 +1159,19 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
11481159

11491160
if (request.method === "item/tool/requestUserInput") {
11501161
requestId = ApprovalRequestId.makeUnsafe(randomUUID());
1162+
const rawQuestions = Array.isArray((request.params as Record<string, unknown>)?.questions)
1163+
? ((request.params as Record<string, unknown>).questions as Array<Record<string, unknown>>)
1164+
: [];
1165+
const questionIds = rawQuestions
1166+
.map((q) => (typeof q.id === "string" ? q.id : null))
1167+
.filter((id): id is string => id !== null);
11511168
context.pendingUserInputs.set(requestId, {
11521169
requestId,
11531170
jsonRpcId: request.id,
11541171
threadId: context.session.threadId,
11551172
...(effectiveTurnId ? { turnId: effectiveTurnId } : {}),
11561173
...(rawRoute.itemId ? { itemId: rawRoute.itemId } : {}),
1174+
...(questionIds.length > 0 ? { questionIds } : {}),
11571175
});
11581176
}
11591177

apps/server/src/provider/Layers/ClaudeAdapter.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2501,6 +2501,22 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
25012501
} satisfies PermissionResult;
25022502
}
25032503

2504+
// Empty answers means the user dismissed the question without answering.
2505+
if (Object.keys(answers as Record<string, unknown>).length === 0) {
2506+
const dismissedAnswers: Record<string, string> = {};
2507+
for (const q of questions) {
2508+
dismissedAnswers[q.question || q.header] =
2509+
"[User dismissed this question without answering]";
2510+
}
2511+
return {
2512+
behavior: "allow",
2513+
updatedInput: {
2514+
questions: toolInput.questions,
2515+
answers: dismissedAnswers,
2516+
},
2517+
} satisfies PermissionResult;
2518+
}
2519+
25042520
// Return the answers to the SDK in the expected format:
25052521
// { questions: [...], answers: { questionText: selectedLabel } }
25062522
return {

apps/web/src/components/ChatView.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,6 +1081,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
10811081
const activePendingIsResponding = activePendingUserInput
10821082
? respondingUserInputRequestIds.includes(activePendingUserInput.requestId)
10831083
: false;
1084+
10841085
const activeProposedPlan = useMemo(() => {
10851086
if (!latestTurnSettled) {
10861087
return null;
@@ -3193,6 +3194,55 @@ export default function ChatView({ threadId }: ChatViewProps) {
31933194
[activeThreadId, setThreadError],
31943195
);
31953196

3197+
const onDismissUserInput = useCallback(
3198+
async (requestId: ApprovalRequestId) => {
3199+
const api = readNativeApi();
3200+
if (!api || !activeThreadId) return;
3201+
3202+
setRespondingUserInputRequestIds((existing) =>
3203+
existing.includes(requestId) ? existing : [...existing, requestId],
3204+
);
3205+
await api.orchestration
3206+
.dispatchCommand({
3207+
type: "thread.user-input.respond",
3208+
commandId: newCommandId(),
3209+
threadId: activeThreadId,
3210+
requestId,
3211+
answers: {},
3212+
createdAt: new Date().toISOString(),
3213+
})
3214+
.catch((err: unknown) => {
3215+
setThreadError(
3216+
activeThreadId,
3217+
err instanceof Error ? err.message : "Failed to dismiss user input.",
3218+
);
3219+
});
3220+
setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
3221+
setPendingUserInputAnswersByRequestId((existing) => {
3222+
const { [requestId]: _, ...rest } = existing;
3223+
return rest;
3224+
});
3225+
setPendingUserInputQuestionIndexByRequestId((existing) => {
3226+
const { [requestId]: _, ...rest } = existing;
3227+
return rest;
3228+
});
3229+
},
3230+
[activeThreadId, setThreadError],
3231+
);
3232+
3233+
// Dismiss pending user input on Escape key press.
3234+
useEffect(() => {
3235+
if (!activePendingUserInput || activePendingIsResponding) return;
3236+
const handler = (event: globalThis.KeyboardEvent) => {
3237+
if (event.key === "Escape") {
3238+
event.preventDefault();
3239+
void onDismissUserInput(activePendingUserInput.requestId);
3240+
}
3241+
};
3242+
document.addEventListener("keydown", handler);
3243+
return () => document.removeEventListener("keydown", handler);
3244+
}, [activePendingUserInput, activePendingIsResponding, onDismissUserInput]);
3245+
31963246
const setActivePendingUserInputQuestionIndex = useCallback(
31973247
(nextQuestionIndex: number) => {
31983248
if (!activePendingUserInput) {
@@ -4064,6 +4114,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
40644114
questionIndex={activePendingQuestionIndex}
40654115
onSelectOption={onSelectActivePendingUserInputOption}
40664116
onAdvance={onAdvanceActivePendingUserInput}
4117+
onDismiss={onDismissUserInput}
40674118
/>
40684119
</div>
40694120
) : showPlanFollowUpPrompt && activeProposedPlan ? (

apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
derivePendingUserInputProgress,
66
type PendingUserInputDraftAnswer,
77
} from "../../pendingUserInput";
8-
import { CheckIcon } from "lucide-react";
8+
import { CheckIcon, XIcon } from "lucide-react";
99
import { cn } from "~/lib/utils";
1010

1111
interface PendingUserInputPanelProps {
@@ -15,6 +15,7 @@ interface PendingUserInputPanelProps {
1515
questionIndex: number;
1616
onSelectOption: (questionId: string, optionLabel: string) => void;
1717
onAdvance: () => void;
18+
onDismiss: (requestId: ApprovalRequestId) => void;
1819
}
1920

2021
export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({
@@ -24,6 +25,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
2425
questionIndex,
2526
onSelectOption,
2627
onAdvance,
28+
onDismiss,
2729
}: PendingUserInputPanelProps) {
2830
if (pendingUserInputs.length === 0) return null;
2931
const activePrompt = pendingUserInputs[0];
@@ -38,6 +40,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
3840
questionIndex={questionIndex}
3941
onSelectOption={onSelectOption}
4042
onAdvance={onAdvance}
43+
onDismiss={onDismiss}
4144
/>
4245
);
4346
});
@@ -49,13 +52,15 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
4952
questionIndex,
5053
onSelectOption,
5154
onAdvance,
55+
onDismiss,
5256
}: {
5357
prompt: PendingUserInput;
5458
isResponding: boolean;
5559
answers: Record<string, PendingUserInputDraftAnswer>;
5660
questionIndex: number;
5761
onSelectOption: (questionId: string, optionLabel: string) => void;
5862
onAdvance: () => void;
63+
onDismiss: (requestId: ApprovalRequestId) => void;
5964
}) {
6065
const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
6166
const activeQuestion = progress.activeQuestion;
@@ -122,7 +127,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
122127
return (
123128
<div className="px-4 py-3 sm:px-5">
124129
<div className="flex items-center gap-3">
125-
<div className="flex items-center gap-2">
130+
<div className="flex flex-1 items-center gap-2">
126131
{prompt.questions.length > 1 ? (
127132
<span className="flex h-5 items-center rounded-md bg-muted/60 px-1.5 text-[10px] font-medium tabular-nums text-muted-foreground/60">
128133
{questionIndex + 1}/{prompt.questions.length}
@@ -132,6 +137,16 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
132137
{activeQuestion.header}
133138
</span>
134139
</div>
140+
<button
141+
type="button"
142+
disabled={isResponding}
143+
onClick={() => void onDismiss(prompt.requestId)}
144+
className="flex size-5 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground/40 transition-colors hover:text-muted-foreground/70 hover:bg-muted/40 disabled:opacity-50 disabled:cursor-not-allowed"
145+
aria-label="Dismiss question"
146+
title="Dismiss (Esc)"
147+
>
148+
<XIcon className="size-3.5" />
149+
</button>
135150
</div>
136151
<p className="mt-1.5 text-sm text-foreground/90">{activeQuestion.question}</p>
137152
<div className="mt-3 space-y-1">

0 commit comments

Comments
 (0)