Skip to content
Open
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
20 changes: 19 additions & 1 deletion apps/server/src/codexAppServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ interface PendingUserInputRequest {
threadId: ThreadId;
turnId?: TurnId;
itemId?: ProviderItemId;
questionIds?: string[];
}

interface CodexUserInputAnswer {
Expand Down Expand Up @@ -864,7 +865,17 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
}

context.pendingUserInputs.delete(requestId);
const codexAnswers = toCodexUserInputAnswers(answers);
const isDismissed =
Object.keys(answers as Record<string, unknown>).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: {
Expand Down Expand Up @@ -1148,12 +1159,19 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve

if (request.method === "item/tool/requestUserInput") {
requestId = ApprovalRequestId.makeUnsafe(randomUUID());
const rawQuestions = Array.isArray((request.params as Record<string, unknown>)?.questions)
? ((request.params as Record<string, unknown>).questions as Array<Record<string, unknown>>)
: [];
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 } : {}),
});
}

Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).length === 0) {
const dismissedAnswers: Record<string, string> = {};
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 {
Expand Down
51 changes: 51 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -4065,6 +4115,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
questionIndex={activePendingQuestionIndex}
onSelectOption={onSelectActivePendingUserInputOption}
onAdvance={onAdvanceActivePendingUserInput}
onDismiss={onDismissUserInput}
/>
</div>
) : showPlanFollowUpPrompt && activeProposedPlan ? (
Expand Down
19 changes: 17 additions & 2 deletions apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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({
Expand All @@ -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];
Expand All @@ -38,6 +40,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
questionIndex={questionIndex}
onSelectOption={onSelectOption}
onAdvance={onAdvance}
onDismiss={onDismiss}
/>
);
});
Expand All @@ -49,13 +52,15 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
questionIndex,
onSelectOption,
onAdvance,
onDismiss,
}: {
prompt: PendingUserInput;
isResponding: boolean;
answers: Record<string, PendingUserInputDraftAnswer>;
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;
Expand Down Expand Up @@ -122,7 +127,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
return (
<div className="px-4 py-3 sm:px-5">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="flex flex-1 items-center gap-2">
{prompt.questions.length > 1 ? (
<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">
{questionIndex + 1}/{prompt.questions.length}
Expand All @@ -132,6 +137,16 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
{activeQuestion.header}
</span>
</div>
<button
type="button"
disabled={isResponding}
onClick={() => void onDismiss(prompt.requestId)}
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"
aria-label="Dismiss question"
title="Dismiss (Esc)"
>
<XIcon className="size-3.5" />
</button>
</div>
<p className="mt-1.5 text-sm text-foreground/90">{activeQuestion.question}</p>
<div className="mt-3 space-y-1">
Expand Down
Loading