Compare commits

...

1 Commits

Author SHA1 Message Date
154ef78f7c v2.3.1-permission-questions: enrich ACP permission wire for interactive questions and elicitations
The permission_requested WS frame now carries kind ('tool'|'question'|'plan'|
'elicitation'), input (the tool's rawInput payload), and description fields.
PermissionCard detects question-type permissions (Claude Code's AskUserQuestion)
and renders an interactive radio/checkbox form instead of approve/deny buttons.
Submitting answers auto-selects the first allow option.

Also wires up ACP createElicitation (unstable/experimental) — JSON Schema-driven
forms for structured user input. The same PermissionCard renders elicitation
fields with type-appropriate inputs. Both flows use the existing permission-waiter
blocking pattern with 120s timeout.

The response path (POST /api/coder/tasks/:id/permission) now accepts optional
updated_input alongside option_id, forwarded to the ACP agent as the user's
answer payload. Elicitation responses map to accept/decline/cancel actions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 21:28:14 +00:00
10 changed files with 507 additions and 18 deletions

View File

@@ -85,7 +85,9 @@ async function main() {
type: 'permission_requested',
task_id: prompt.taskId,
session_id: prompt.sessionId,
kind: prompt.kind,
tool_title: prompt.toolTitle,
...(prompt.input ? { input: prompt.input } : {}),
options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })),
} as WsFrame);
},

View File

@@ -19,6 +19,7 @@ const CreateBody = z.object({
const PermissionBody = z.object({
option_id: z.string().max(200).nullable(),
updated_input: z.record(z.unknown()).optional(),
});
const ListQuery = z.object({
@@ -164,7 +165,7 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In
return { error: 'invalid body', details: parsed.error.flatten() };
}
const ok = respondToPermission(req.params.id, parsed.data.option_id);
const ok = respondToPermission(req.params.id, parsed.data.option_id, parsed.data.updated_input as Record<string, unknown> | undefined);
if (!ok) {
reply.code(404);
return { error: 'no pending permission' };

View File

@@ -17,6 +17,8 @@ import {
type WriteTextFileResponse,
type CreateTerminalRequest,
type CreateTerminalResponse,
type CreateElicitationRequest,
type CreateElicitationResponse,
type SessionConfigOption,
type ClientSideConnection as ConnectionType,
} from '@agentclientprotocol/sdk';
@@ -26,7 +28,7 @@ import { spawn } from 'node:child_process';
import { findThoughtLevelConfigId } from './acp-derive.js';
import { resolveAcpSpawnArgs } from './acp-spawn.js';
import { createAcpNdJsonStream } from './acp-stream.js';
import { waitForPermissionResponse, cancelPendingPermission } from './permission-waiter.js';
import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js';
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
import { readWorktreeTextFile, writeWorktreeTextFile } from './acp-client-fs.js';
import {
@@ -254,6 +256,12 @@ class AcpStreamContext {
createTerminal: async (_params: CreateTerminalRequest): Promise<CreateTerminalResponse> => {
return { terminalId: 'noop' };
},
unstable_createElicitation: async (params: CreateElicitationRequest): Promise<CreateElicitationResponse> => {
if (taskId && sessionId) {
return waitForElicitationResponse(taskId, sessionId, agent, modeId, params);
}
return { action: 'decline' };
},
};
}
}

View File

@@ -1,12 +1,13 @@
/**
* Blocks ACP dispatch on permission prompts until the user responds via API.
* Blocks ACP dispatch on permission/elicitation prompts until the user responds via API.
*/
import type { RequestPermissionRequest, RequestPermissionResponse } from '@agentclientprotocol/sdk';
import type { RequestPermissionRequest, RequestPermissionResponse, CreateElicitationRequest, CreateElicitationResponse } from '@agentclientprotocol/sdk';
import { isUnattendedMode } from './provider-manifest.js';
const DEFAULT_TIMEOUT_MS = 120_000;
interface PendingPermission {
type: 'permission';
request: RequestPermissionRequest;
sessionId: string;
resolve: (response: RequestPermissionResponse) => void;
@@ -14,11 +15,27 @@ interface PendingPermission {
timer: ReturnType<typeof setTimeout>;
}
const pendingByTask = new Map<string, PendingPermission>();
interface PendingElicitation {
type: 'elicitation';
request: CreateElicitationRequest;
sessionId: string;
resolve: (response: CreateElicitationResponse) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
type PendingEntry = PendingPermission | PendingElicitation;
const pendingByTask = new Map<string, PendingEntry>();
export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation';
export interface PermissionPrompt {
taskId: string;
kind: PermissionKind;
toolTitle?: string;
description?: string;
input?: Record<string, unknown>;
options: Array<{ optionId: string; label: string }>;
}
@@ -33,10 +50,25 @@ export function setPermissionHooks(next: PermissionHooks): void {
hooks = next;
}
function resolveKind(params: RequestPermissionRequest): PermissionKind {
const input = params.toolCall?.rawInput;
if (input && typeof input === 'object' && !Array.isArray(input) && 'questions' in input && Array.isArray((input as Record<string, unknown>).questions)) {
return 'question';
}
return 'tool';
}
function toPrompt(taskId: string, params: RequestPermissionRequest): PermissionPrompt {
const kind = resolveKind(params);
const rawInput = params.toolCall?.rawInput;
const input = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
? rawInput as Record<string, unknown>
: undefined;
return {
taskId,
kind,
toolTitle: params.toolCall?.title ?? undefined,
...(input ? { input } : {}),
options: params.options.map((o) => ({
optionId: o.optionId,
label: o.name,
@@ -73,24 +105,33 @@ export function waitForPermissionResponse(
resolve({ outcome: { outcome: 'cancelled' } });
}, timeoutMs);
pendingByTask.set(taskId, { request: params, sessionId, resolve, reject, timer });
pendingByTask.set(taskId, { type: 'permission', request: params, sessionId, resolve, reject, timer });
const prompt = toPrompt(taskId, params);
void hooks.onPrompt?.({ ...prompt, sessionId });
});
}
export function respondToPermission(taskId: string, optionId: string | null): boolean {
export function respondToPermission(taskId: string, optionId: string | null, updatedInput?: Record<string, unknown>): boolean {
const pending = pendingByTask.get(taskId);
if (!pending) return false;
clearTimeout(pending.timer);
pendingByTask.delete(taskId);
if (optionId) {
pending.resolve({ outcome: { outcome: 'selected', optionId } });
if (pending.type === 'elicitation') {
if (updatedInput) {
const content = updatedInput as { [key: string]: string | number | boolean | string[] };
pending.resolve({ action: 'accept', content });
} else {
pending.resolve({ action: 'decline' });
}
} else {
pending.resolve({ outcome: { outcome: 'cancelled' } });
if (optionId) {
pending.resolve({ outcome: { outcome: 'selected', optionId } });
} else {
pending.resolve({ outcome: { outcome: 'cancelled' } });
}
}
void hooks.onResolved?.(taskId, pending.sessionId);
@@ -100,14 +141,67 @@ export function respondToPermission(taskId: string, optionId: string | null): bo
export function getPendingPermission(taskId: string): PermissionPrompt | null {
const pending = pendingByTask.get(taskId);
if (!pending) return null;
if (pending.type === 'elicitation') {
return elicitationToPrompt(taskId, pending.request);
}
return toPrompt(taskId, pending.request);
}
function elicitationToPrompt(taskId: string, params: CreateElicitationRequest): PermissionPrompt {
const input: Record<string, unknown> = { message: params.message };
if ('requestedSchema' in params) {
input.requestedSchema = params.requestedSchema;
}
return {
taskId,
kind: 'elicitation',
toolTitle: params.message,
input,
options: [],
};
}
export function waitForElicitationResponse(
taskId: string,
sessionId: string,
provider: string,
modeId: string | undefined,
params: CreateElicitationRequest,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<CreateElicitationResponse> {
if (isUnattendedMode(provider, modeId)) {
return Promise.resolve({ action: 'decline' });
}
return new Promise((resolve, reject) => {
const existing = pendingByTask.get(taskId);
if (existing) {
clearTimeout(existing.timer);
existing.reject(new Error('superseded by newer elicitation request'));
}
const timer = setTimeout(() => {
pendingByTask.delete(taskId);
void hooks.onResolved?.(taskId, sessionId);
resolve({ action: 'cancel' });
}, timeoutMs);
pendingByTask.set(taskId, { type: 'elicitation', request: params, sessionId, resolve, reject, timer });
const prompt = elicitationToPrompt(taskId, params);
void hooks.onPrompt?.({ ...prompt, sessionId });
});
}
export function cancelPendingPermission(taskId: string): void {
const pending = pendingByTask.get(taskId);
if (!pending) return;
clearTimeout(pending.timer);
pendingByTask.delete(taskId);
pending.resolve({ outcome: { outcome: 'cancelled' } });
if (pending.type === 'elicitation') {
pending.resolve({ action: 'cancel' });
} else {
pending.resolve({ outcome: { outcome: 'cancelled' } });
}
void hooks.onResolved?.(taskId, pending.sessionId);
}

View File

@@ -272,7 +272,9 @@ export const PermissionRequestedFrame = z.object({
type: z.literal('permission_requested'),
task_id: Uuid,
session_id: Uuid,
kind: z.enum(['tool', 'question', 'plan', 'elicitation']).optional(),
tool_title: z.string().optional(),
input: z.record(z.unknown()).optional(),
options: z.array(PermissionOptionShape),
});

View File

@@ -319,10 +319,10 @@ export const api = {
}),
getTaskPermission: (taskId: string) =>
request<PermissionPrompt>(`/api/coder/tasks/${taskId}/permission`),
respondTaskPermission: (taskId: string, optionId: string | null) =>
respondTaskPermission: (taskId: string, optionId: string | null, updatedInput?: Record<string, unknown>) =>
request<{ ok: boolean }>(`/api/coder/tasks/${taskId}/permission`, {
method: 'POST',
body: JSON.stringify({ option_id: optionId }),
body: JSON.stringify({ option_id: optionId, ...(updatedInput ? { updated_input: updatedInput } : {}) }),
}),
getTaskCommands: (taskId: string) =>
request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),

View File

@@ -250,9 +250,13 @@ export interface AgentSessionConfig {
thinkingOptionId: string | null;
}
export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation';
export interface PermissionPrompt {
taskId: string;
kind?: PermissionKind;
toolTitle?: string;
input?: Record<string, unknown>;
options: Array<{ optionId: string; label: string }>;
}

View File

@@ -272,7 +272,9 @@ export const PermissionRequestedFrame = z.object({
type: z.literal('permission_requested'),
task_id: Uuid,
session_id: Uuid,
kind: z.enum(['tool', 'question', 'plan', 'elicitation']).optional(),
tool_title: z.string().optional(),
input: z.record(z.unknown()).optional(),
options: z.array(PermissionOptionShape),
});

View File

@@ -1,14 +1,105 @@
import { ShieldAlert } from 'lucide-react';
import { useState } from 'react';
import { ShieldAlert, MessageCircleQuestion } from 'lucide-react';
import type { PermissionPrompt } from '@/api/types';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface Props {
prompt: PermissionPrompt;
onRespond: (optionId: string | null) => void;
onRespond: (optionId: string | null, updatedInput?: Record<string, unknown>) => void;
busy?: boolean;
}
// ---------------------------------------------------------------------------
// Question detection — ACP's RequestPermissionRequest carries the tool input
// in `input`. Claude Code's AskUserQuestion puts { questions: [...] } there.
// ---------------------------------------------------------------------------
interface Question {
question: string;
header?: string;
options: string[];
multiSelect: boolean;
}
function parseQuestions(input: Record<string, unknown> | undefined): Question[] | null {
if (!input) return null;
const raw = input.questions;
if (!Array.isArray(raw)) return null;
const out: Question[] = [];
for (const item of raw) {
if (!item || typeof item !== 'object') continue;
const q = item as { question?: unknown; header?: unknown; options?: unknown; multiSelect?: unknown };
if (typeof q.question !== 'string') continue;
const opts = Array.isArray(q.options)
? q.options.filter((o): o is string => typeof o === 'string')
: [];
out.push({
question: q.question,
header: typeof q.header === 'string' ? q.header : undefined,
options: opts,
multiSelect: q.multiSelect === true,
});
}
return out.length > 0 ? out : null;
}
// ---------------------------------------------------------------------------
// Elicitation detection — ACP's createElicitation carries a JSON Schema in
// `input.requestedSchema`. For now, render each property as a text input.
// ---------------------------------------------------------------------------
interface ElicitationField {
key: string;
title: string;
description?: string;
type: string;
enumValues?: string[];
}
function parseElicitation(input: Record<string, unknown> | undefined): { message: string; fields: ElicitationField[] } | null {
if (!input) return null;
const schema = input.requestedSchema;
if (!schema || typeof schema !== 'object') return null;
const s = schema as Record<string, unknown>;
const props = s.properties;
if (!props || typeof props !== 'object') return null;
const fields: ElicitationField[] = [];
for (const [key, val] of Object.entries(props as Record<string, unknown>)) {
if (!val || typeof val !== 'object') continue;
const p = val as Record<string, unknown>;
fields.push({
key,
title: typeof p.title === 'string' ? p.title : key,
description: typeof p.description === 'string' ? p.description : undefined,
type: typeof p.type === 'string' ? p.type : 'string',
enumValues: Array.isArray(p.enum) ? p.enum.filter((e): e is string => typeof e === 'string') : undefined,
});
}
if (fields.length === 0) return null;
return { message: typeof input.message === 'string' ? input.message : '', fields };
}
export function PermissionCard({ prompt, onRespond, busy }: Props) {
const isQuestion = prompt.kind === 'question';
const isElicitation = prompt.kind === 'elicitation';
if (isQuestion) {
const questions = parseQuestions(prompt.input);
if (questions) {
return <QuestionView questions={questions} prompt={prompt} onRespond={onRespond} busy={busy} />;
}
}
if (isElicitation) {
const elicitation = parseElicitation(prompt.input);
if (elicitation) {
return <ElicitationView elicitation={elicitation} prompt={prompt} onRespond={onRespond} busy={busy} />;
}
}
// Standard tool permission — approve/deny buttons
return (
<div className="mx-3 my-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm">
<div className="flex items-start gap-2">
@@ -47,3 +138,286 @@ export function PermissionCard({ prompt, onRespond, busy }: Props) {
</div>
);
}
// ---------------------------------------------------------------------------
// QuestionView — renders Claude's AskUserQuestion as interactive radio/checkbox
// ---------------------------------------------------------------------------
function QuestionView({
questions,
prompt,
onRespond,
busy,
}: {
questions: Question[];
prompt: PermissionPrompt;
onRespond: Props['onRespond'];
busy?: boolean;
}) {
const [selections, setSelections] = useState<string[][]>(() => questions.map(() => []));
const [freeTexts, setFreeTexts] = useState<string[]>(() => questions.map(() => ''));
const [submitting, setSubmitting] = useState(false);
const disabled = busy || submitting;
const allComplete = questions.every((_, i) =>
selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0,
);
function buildAnswers(): Record<string, string> {
const answers: Record<string, string> = {};
for (let i = 0; i < questions.length; i++) {
const q = questions[i]!;
const key = q.question;
const selected = selections[i]!;
const free = freeTexts[i]!.trim();
if (free) {
answers[key] = free;
} else if (selected.length > 0) {
answers[key] = selected.join(', ');
}
}
return answers;
}
function handleSubmit() {
if (!allComplete || submitting) return;
setSubmitting(true);
const answers = buildAnswers();
const firstAllow = prompt.options.find((o) =>
o.label.toLowerCase().includes('allow') || o.label.toLowerCase().includes('yes'),
);
onRespond(firstAllow?.optionId ?? prompt.options[0]?.optionId ?? null, {
...prompt.input,
answers,
});
}
function pickSingle(qIdx: number, option: string) {
setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr)));
if (questions.length === 1 && !freeTexts[0]!.trim()) {
setSubmitting(true);
const firstAllow = prompt.options.find((o) =>
o.label.toLowerCase().includes('allow') || o.label.toLowerCase().includes('yes'),
);
onRespond(firstAllow?.optionId ?? prompt.options[0]?.optionId ?? null, {
...prompt.input,
answers: { [questions[0]!.question]: option },
});
}
}
function toggleMulti(qIdx: number, option: string) {
setSelections((prev) =>
prev.map((arr, i) => {
if (i !== qIdx) return arr;
return arr.includes(option) ? arr.filter((o) => o !== option) : [...arr, option];
}),
);
}
return (
<div className="mx-3 my-2 rounded-lg border bg-muted/20 text-sm">
<div className="px-4 py-3 space-y-4">
{questions.map((q, i) => (
<div key={i} className="space-y-2">
{questions.length > 1 && (
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
{q.header ?? `Question ${i + 1}`}
</div>
)}
<div className="font-medium leading-snug">{q.question}</div>
{q.options.length > 0 && !q.multiSelect && (
<RadioGroup
value={selections[i]![0] ?? ''}
onValueChange={(v) => pickSingle(i, v)}
disabled={disabled}
className="gap-1.5"
>
{q.options.map((opt, j) => {
const id = `q${i}-opt${j}`;
return (
<label
key={j}
htmlFor={id}
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
>
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
<span>{opt}</span>
</label>
);
})}
</RadioGroup>
)}
{q.options.length > 0 && q.multiSelect && (
<div className="grid gap-1.5">
{q.options.map((opt, j) => {
const id = `q${i}-opt${j}`;
const checked = selections[i]!.includes(opt);
return (
<label
key={j}
htmlFor={id}
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
>
<input
id={id}
type="checkbox"
checked={checked}
disabled={disabled}
onChange={() => toggleMulti(i, opt)}
className="mt-1 size-3.5 rounded border-input accent-primary"
/>
<span>{opt}</span>
</label>
);
})}
</div>
)}
<div className="pt-1 space-y-1">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Or type a custom answer
</div>
<input
type="text"
value={freeTexts[i]}
disabled={disabled}
placeholder="Free text…"
onChange={(e) =>
setFreeTexts((prev) => prev.map((t, idx) => (idx === i ? e.target.value : t)))
}
className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60"
/>
</div>
</div>
))}
</div>
{(questions.length > 1 || freeTexts.some((t) => t.trim())) && (
<div className="flex justify-between items-center border-t px-4 py-2">
<button
type="button"
disabled={disabled}
onClick={() => onRespond(null)}
className="text-xs text-destructive hover:underline disabled:opacity-40"
>
Dismiss
</button>
<Button
type="button"
size="sm"
disabled={!allComplete || disabled}
onClick={handleSubmit}
>
{submitting ? 'Submitting…' : 'Submit'}
</Button>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// ElicitationView — renders ACP elicitation forms (JSON Schema-driven)
// ---------------------------------------------------------------------------
function ElicitationView({
elicitation,
prompt,
onRespond,
busy,
}: {
elicitation: { message: string; fields: ElicitationField[] };
prompt: PermissionPrompt;
onRespond: Props['onRespond'];
busy?: boolean;
}) {
const [values, setValues] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {};
for (const f of elicitation.fields) init[f.key] = '';
return init;
});
const [submitting, setSubmitting] = useState(false);
const disabled = busy || submitting;
const allFilled = elicitation.fields.every((f) => (values[f.key] ?? '').trim().length > 0);
function handleSubmit() {
if (!allFilled || submitting) return;
setSubmitting(true);
const content: Record<string, unknown> = {};
for (const f of elicitation.fields) {
const raw = values[f.key]!.trim();
if (f.type === 'number' || f.type === 'integer') {
content[f.key] = Number(raw);
} else if (f.type === 'boolean') {
content[f.key] = raw === 'true' || raw === 'yes' || raw === '1';
} else {
content[f.key] = raw;
}
}
const firstAllow = prompt.options[0];
onRespond(firstAllow?.optionId ?? null, content);
}
return (
<div className="mx-3 my-2 rounded-lg border bg-muted/20 text-sm">
<div className="px-4 py-3 space-y-3">
<div className="flex items-start gap-2">
<MessageCircleQuestion className="size-4 text-blue-500 shrink-0 mt-0.5" />
<p className="font-medium leading-snug">{elicitation.message}</p>
</div>
{elicitation.fields.map((f) => (
<div key={f.key} className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">{f.title}</label>
{f.description && (
<p className="text-[11px] text-muted-foreground/70">{f.description}</p>
)}
{f.enumValues ? (
<RadioGroup
value={values[f.key] ?? ''}
onValueChange={(v) => setValues((prev) => ({ ...prev, [f.key]: v }))}
disabled={disabled}
className="gap-1.5"
>
{f.enumValues.map((opt, j) => {
const id = `e-${f.key}-${j}`;
return (
<label key={j} htmlFor={id} className="flex items-start gap-2 text-sm cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40">
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
<span>{opt}</span>
</label>
);
})}
</RadioGroup>
) : (
<input
type={f.type === 'number' || f.type === 'integer' ? 'number' : 'text'}
value={values[f.key] ?? ''}
disabled={disabled}
onChange={(e) => setValues((prev) => ({ ...prev, [f.key]: e.target.value }))}
className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60"
/>
)}
</div>
))}
</div>
<div className="flex justify-between items-center border-t px-4 py-2">
<button
type="button"
disabled={disabled}
onClick={() => onRespond(null)}
className="text-xs text-destructive hover:underline disabled:opacity-40"
>
Dismiss
</button>
<Button
type="button"
size="sm"
disabled={!allFilled || disabled}
onClick={handleSubmit}
>
{submitting ? 'Submitting…' : 'Submit'}
</Button>
</div>
</div>
);
}

View File

@@ -290,7 +290,9 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler
} else if (frame.type === 'permission_requested') {
handlersRef.current.onPermissionRequested?.({
taskId: frame.task_id,
kind: frame.kind,
toolTitle: frame.tool_title,
...(frame.input ? { input: frame.input as Record<string, unknown> } : {}),
options: (frame.options ?? []).map((o: { option_id: string; label: string }) => ({
optionId: o.option_id,
label: o.label,
@@ -565,11 +567,11 @@ export function CoderPane({
setProviderCommands(commands);
}, []);
const handlePermissionRespond = useCallback(async (optionId: string | null) => {
const handlePermissionRespond = useCallback(async (optionId: string | null, updatedInput?: Record<string, unknown>) => {
if (!permissionPrompt) return;
setPermissionBusy(true);
try {
await api.coder.respondTaskPermission(permissionPrompt.taskId, optionId);
await api.coder.respondTaskPermission(permissionPrompt.taskId, optionId, updatedInput);
setPermissionPrompt(null);
} finally {
setPermissionBusy(false);
@@ -716,7 +718,7 @@ export function CoderPane({
{permissionPrompt && (
<PermissionCard
prompt={permissionPrompt}
onRespond={(id) => void handlePermissionRespond(id)}
onRespond={(id, input) => void handlePermissionRespond(id, input)}
busy={permissionBusy}
/>
)}