feat(story-62): add permission request prompts to web UI
When Claude Code requires user approval before executing a tool (file writes, commits, etc.) the agent sends a permission_request message over the WebSocket. The web UI now intercepts that message, surfaces a modal dialog showing the tool name and input, and lets the user approve or deny. The decision is sent back as a permission_response, allowing the agent to continue or adjust its approach. Backend changes: - claude_code.rs: parse "permission_request" NDJSON events from the PTY, block the PTY thread via a sync channel, and write the user's decision back to the PTY stdin as a JSON permission_response. - chat.rs: thread an optional UnboundedSender<PermissionReqMsg> through to the provider. - ws.rs: create a permission-request channel, forward requests to the client, collect responses via a pending-perms map, and interleave all of this with the active chat session using tokio::select!. Frontend changes: - client.ts: add permission_request to WsResponse, permission_response to WsRequest, onPermissionRequest handler to ChatWebSocket.connect(), and sendPermissionResponse() method. - types.ts: mirror the same type additions. - Chat.tsx: add permissionRequest state, wire onPermissionRequest callback, and render an approval modal with tool name, input context, Approve and Deny buttons. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,11 @@ export type WsRequest =
|
||||
}
|
||||
| {
|
||||
type: "cancel";
|
||||
}
|
||||
| {
|
||||
type: "permission_response";
|
||||
request_id: string;
|
||||
approved: boolean;
|
||||
};
|
||||
|
||||
export interface AgentAssignment {
|
||||
@@ -39,6 +44,12 @@ export type WsResponse =
|
||||
current: PipelineStageItem[];
|
||||
qa: PipelineStageItem[];
|
||||
merge: PipelineStageItem[];
|
||||
}
|
||||
| {
|
||||
type: "permission_request";
|
||||
request_id: string;
|
||||
tool_name: string;
|
||||
tool_input: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface ProviderConfig {
|
||||
@@ -244,6 +255,11 @@ export class ChatWebSocket {
|
||||
private onSessionId?: (sessionId: string) => void;
|
||||
private onError?: (message: string) => void;
|
||||
private onPipelineState?: (state: PipelineState) => void;
|
||||
private onPermissionRequest?: (
|
||||
requestId: string,
|
||||
toolName: string,
|
||||
toolInput: Record<string, unknown>,
|
||||
) => void;
|
||||
private connected = false;
|
||||
private closeTimer?: number;
|
||||
private wsPath = DEFAULT_WS_PATH;
|
||||
@@ -280,6 +296,12 @@ export class ChatWebSocket {
|
||||
qa: data.qa,
|
||||
merge: data.merge,
|
||||
});
|
||||
if (data.type === "permission_request")
|
||||
this.onPermissionRequest?.(
|
||||
data.request_id,
|
||||
data.tool_name,
|
||||
data.tool_input,
|
||||
);
|
||||
} catch (err) {
|
||||
this.onError?.(String(err));
|
||||
}
|
||||
@@ -314,6 +336,11 @@ export class ChatWebSocket {
|
||||
onSessionId?: (sessionId: string) => void;
|
||||
onError?: (message: string) => void;
|
||||
onPipelineState?: (state: PipelineState) => void;
|
||||
onPermissionRequest?: (
|
||||
requestId: string,
|
||||
toolName: string,
|
||||
toolInput: Record<string, unknown>,
|
||||
) => void;
|
||||
},
|
||||
wsPath = DEFAULT_WS_PATH,
|
||||
) {
|
||||
@@ -322,6 +349,7 @@ export class ChatWebSocket {
|
||||
this.onSessionId = handlers.onSessionId;
|
||||
this.onError = handlers.onError;
|
||||
this.onPipelineState = handlers.onPipelineState;
|
||||
this.onPermissionRequest = handlers.onPermissionRequest;
|
||||
this.wsPath = wsPath;
|
||||
this.shouldReconnect = true;
|
||||
|
||||
@@ -351,6 +379,10 @@ export class ChatWebSocket {
|
||||
this.send({ type: "cancel" });
|
||||
}
|
||||
|
||||
sendPermissionResponse(requestId: string, approved: boolean) {
|
||||
this.send({ type: "permission_response", request_id: requestId, approved });
|
||||
}
|
||||
|
||||
close() {
|
||||
this.shouldReconnect = false;
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
|
||||
@@ -38,6 +38,11 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
merge: [],
|
||||
});
|
||||
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(null);
|
||||
const [permissionRequest, setPermissionRequest] = useState<{
|
||||
requestId: string;
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
} | null>(null);
|
||||
const [isNarrowScreen, setIsNarrowScreen] = useState(
|
||||
window.innerWidth < NARROW_BREAKPOINT,
|
||||
);
|
||||
@@ -166,6 +171,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
onPipelineState: (state) => {
|
||||
setPipeline(state);
|
||||
},
|
||||
onPermissionRequest: (requestId, toolName, toolInput) => {
|
||||
setPermissionRequest({ requestId, toolName, toolInput });
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -319,6 +327,15 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePermissionResponse = (approved: boolean) => {
|
||||
if (!permissionRequest) return;
|
||||
wsRef.current?.sendPermissionResponse(
|
||||
permissionRequest.requestId,
|
||||
approved,
|
||||
);
|
||||
setPermissionRequest(null);
|
||||
};
|
||||
|
||||
const clearSession = async () => {
|
||||
const confirmed = window.confirm(
|
||||
"Are you sure? This will clear all messages and reset the conversation context.",
|
||||
@@ -823,6 +840,107 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{permissionRequest && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#2f2f2f",
|
||||
padding: "32px",
|
||||
borderRadius: "12px",
|
||||
maxWidth: "520px",
|
||||
width: "90%",
|
||||
border: "1px solid #444",
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginTop: 0, color: "#ececec" }}>
|
||||
Permission Request
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
color: "#aaa",
|
||||
fontSize: "0.9em",
|
||||
marginBottom: "12px",
|
||||
}}
|
||||
>
|
||||
The agent wants to use the{" "}
|
||||
<strong style={{ color: "#ececec" }}>
|
||||
{permissionRequest.toolName}
|
||||
</strong>{" "}
|
||||
tool. Do you approve?
|
||||
</p>
|
||||
{Object.keys(permissionRequest.toolInput).length > 0 && (
|
||||
<pre
|
||||
style={{
|
||||
background: "#1a1a1a",
|
||||
border: "1px solid #333",
|
||||
borderRadius: "6px",
|
||||
padding: "12px",
|
||||
fontSize: "0.8em",
|
||||
color: "#ccc",
|
||||
overflowX: "auto",
|
||||
maxHeight: "200px",
|
||||
marginBottom: "20px",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(permissionRequest.toolInput, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "12px",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePermissionResponse(false)}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #555",
|
||||
backgroundColor: "transparent",
|
||||
color: "#aaa",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9em",
|
||||
}}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePermissionResponse(true)}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
borderRadius: "8px",
|
||||
border: "none",
|
||||
backgroundColor: "#ececec",
|
||||
color: "#000",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9em",
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,13 +48,24 @@ export type WsRequest =
|
||||
}
|
||||
| {
|
||||
type: "cancel";
|
||||
}
|
||||
| {
|
||||
type: "permission_response";
|
||||
request_id: string;
|
||||
approved: boolean;
|
||||
};
|
||||
|
||||
export type WsResponse =
|
||||
| { type: "token"; content: string }
|
||||
| { type: "update"; messages: Message[] }
|
||||
| { type: "session_id"; session_id: string }
|
||||
| { type: "error"; message: string };
|
||||
| { type: "error"; message: string }
|
||||
| {
|
||||
type: "permission_request";
|
||||
request_id: string;
|
||||
tool_name: string;
|
||||
tool_input: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// Re-export API client types for convenience
|
||||
export type {
|
||||
|
||||
Reference in New Issue
Block a user