Skip to content
21 changes: 7 additions & 14 deletions components/frontend/src/components/session/ask-user-question.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { HelpCircle, CheckCircle2, Send, ChevronRight } from "lucide-react";
import { formatTimestamp } from "@/lib/format-timestamp";
import type {
ToolUseBlock,
ToolResultBlock,
AskUserQuestionItem,
AskUserQuestionInput,
import {
hasToolResult,
type ToolUseBlock,
type ToolResultBlock,
type AskUserQuestionItem,
type AskUserQuestionInput,
} from "@/types/agentic-session";

export type AskUserQuestionMessageProps = {
Expand Down Expand Up @@ -41,14 +42,6 @@ function parseQuestions(input: Record<string, unknown>): AskUserQuestionItem[] {
return [];
}

function hasResult(resultBlock?: ToolResultBlock): boolean {
if (!resultBlock) return false;
const content = resultBlock.content;
if (!content) return false;
if (typeof content === "string" && content.trim() === "") return false;
return true;
}

export const AskUserQuestionMessage: React.FC<AskUserQuestionMessageProps> = ({
toolUseBlock,
resultBlock,
Expand All @@ -57,7 +50,7 @@ export const AskUserQuestionMessage: React.FC<AskUserQuestionMessageProps> = ({
isNewest = false,
}) => {
const questions = parseQuestions(toolUseBlock.input);
const alreadyAnswered = hasResult(resultBlock);
const alreadyAnswered = hasToolResult(resultBlock);
const formattedTime = formatTimestamp(timestamp);
const isMultiQuestion = questions.length > 1;

Expand Down
171 changes: 171 additions & 0 deletions components/frontend/src/components/session/permission-request.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
"use client";

import React, { useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ShieldCheck, ShieldX, ShieldAlert } from "lucide-react";
import { formatTimestamp } from "@/lib/format-timestamp";
import {
hasToolResult,
type ToolUseBlock,
type ToolResultBlock,
type PermissionRequestInput,
} from "@/types/agentic-session";

export type PermissionRequestMessageProps = {
toolUseBlock: ToolUseBlock;
resultBlock?: ToolResultBlock;
timestamp?: string;
onSubmitAnswer?: (formattedAnswer: string) => Promise<void>;
isNewest?: boolean;
};

function isPermissionRequestInput(
input: Record<string, unknown>
): input is PermissionRequestInput {
return "tool_name" in input && "key" in input;
}

type PermissionStatus = "pending" | "approved" | "denied";

function deriveStatus(resultBlock?: ToolResultBlock): PermissionStatus {
if (!hasToolResult(resultBlock)) return "pending";
const content = resultBlock?.content;
if (typeof content !== "string") return "denied";
try {
return JSON.parse(content).approved === true ? "approved" : "denied";
} catch {
return "denied";
}
}

const STATUS_CONFIG: Record<PermissionStatus, {
icon: typeof ShieldCheck;
avatarClass: string;
borderClass: string;
}> = {
pending: {
icon: ShieldAlert,
avatarClass: "bg-amber-500",
borderClass: "border-l-amber-500 bg-amber-50/30 dark:bg-amber-950/10",
},
approved: {
icon: ShieldCheck,
avatarClass: "bg-green-600",
borderClass: "border-l-green-500 bg-green-50/30 dark:bg-green-950/10",
},
denied: {
icon: ShieldX,
avatarClass: "bg-red-600",
borderClass: "border-l-red-500 bg-red-50/30 dark:bg-red-950/10",
},
};

export const PermissionRequestMessage: React.FC<
PermissionRequestMessageProps
> = ({ toolUseBlock, resultBlock, timestamp, onSubmitAnswer, isNewest = false }) => {
const input = toolUseBlock.input;
const status = deriveStatus(resultBlock);
const formattedTime = formatTimestamp(timestamp);

const [submitted, setSubmitted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const disabled = status !== "pending" || submitted || isSubmitting || !isNewest;

if (!isPermissionRequestInput(input)) return null;

const handleResponse = async (allow: boolean) => {
if (!onSubmitAnswer || disabled) return;

const response = JSON.stringify({
approved: allow,
tool_name: input.tool_name,
key: input.key,
});

try {
setIsSubmitting(true);
await onSubmitAnswer(response);
setSubmitted(true);
} finally {
setIsSubmitting(false);
}
};

const activeConfig = STATUS_CONFIG[disabled && status !== "pending" ? status : "pending"];
const Icon = activeConfig.icon;

return (
<div className="mb-3">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<div
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center",
activeConfig.avatarClass
)}
>
<Icon className="w-4 h-4 text-white" />
</div>
</div>

<div className="flex-1 min-w-0">
{formattedTime && (
<div className="text-[10px] text-muted-foreground/60 mb-0.5">
{formattedTime}
</div>
)}

<div
className={cn("rounded-lg border-l-3 pl-3 pr-3 py-2.5", activeConfig.borderClass)}
>
<p className="text-sm font-medium text-foreground mb-1">
Permission Required
</p>
<p className="text-sm text-foreground/80 mb-2">
{input.description}
</p>

{(input.file_path || input.command) && (
<div className="text-xs text-muted-foreground font-mono bg-muted/50 rounded px-2 py-1 mb-2 break-all">
{input.file_path || input.command}
</div>
)}

{disabled && status !== "pending" && (
<p className="text-xs text-muted-foreground">
{status === "approved" ? "Approved" : "Denied"}
</p>
)}

{!disabled && (
<div className="flex items-center gap-2 mt-2 pt-1.5 border-t border-border/40">
<Button
size="sm"
className="h-7 text-xs gap-1 px-3 bg-green-600 hover:bg-green-700 text-white"
onClick={() => handleResponse(true)}
disabled={isSubmitting}
>
<ShieldCheck className="w-3 h-3" />
Allow
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1 px-3 text-red-600 border-red-200 hover:bg-red-50 dark:border-red-800 dark:hover:bg-red-950/30"
onClick={() => handleResponse(false)}
disabled={isSubmitting}
>
<ShieldX className="w-3 h-3" />
Deny
</Button>
</div>
)}
</div>
</div>
</div>
</div>
);
};

PermissionRequestMessage.displayName = "PermissionRequestMessage";
25 changes: 23 additions & 2 deletions components/frontend/src/components/ui/stream-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { MessageObject, ToolUseMessages, HierarchicalToolMessage } from "@/types
import { LoadingDots, Message } from "@/components/ui/message";
import { ToolMessage } from "@/components/ui/tool-message";
import { AskUserQuestionMessage } from "@/components/session/ask-user-question";
import { PermissionRequestMessage } from "@/components/session/permission-request";
import { ThinkingMessage } from "@/components/ui/thinking-message";
import { SystemMessage } from "@/components/ui/system-message";
import { Button } from "@/components/ui/button";
Expand All @@ -20,9 +21,16 @@ export type StreamMessageProps = {
currentUserId?: string;
};

function normalizeToolName(name: string): string {
return name.toLowerCase().replace(/[^a-z]/g, "");
}

function isAskUserQuestionTool(name: string): boolean {
const normalized = name.toLowerCase().replace(/[^a-z]/g, "");
return normalized === "askuserquestion";
return normalizeToolName(name) === "askuserquestion";
}

function isPermissionRequestTool(name: string): boolean {
return normalizeToolName(name) === "permissionrequest";
}

const getRandomAgentMessage = () => {
Expand Down Expand Up @@ -59,6 +67,19 @@ export const StreamMessage: React.FC<StreamMessageProps> = ({ message, onGoToRes
);
}

// Render PermissionRequest with Allow/Deny buttons
if (isPermissionRequestTool(message.toolUseBlock.name)) {
return (
<PermissionRequestMessage
toolUseBlock={message.toolUseBlock}
resultBlock={message.resultBlock}
timestamp={message.timestamp}
onSubmitAnswer={onSubmitAnswer}
isNewest={isNewest}
/>
);
}

// Check if this is a hierarchical message with children
const hierarchical = message as HierarchicalToolMessage;
return (
Expand Down
12 changes: 8 additions & 4 deletions components/frontend/src/hooks/use-agent-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import type {
} from "@/types/agentic-session";
import type { PlatformMessage } from "@/types/agui";

function isAskUserQuestionTool(name: string): boolean {
const normalized = name.toLowerCase().replace(/[^a-z]/g, "");
return normalized === "askuserquestion";
function normalizeToolName(name: string): string {
return name.toLowerCase().replace(/[^a-z]/g, "");
}

function isHumanInTheLoopTool(name: string): boolean {
const normalized = normalizeToolName(name);
return normalized === "askuserquestion" || normalized === "permissionrequest";
}

/**
Expand Down Expand Up @@ -38,7 +42,7 @@ export function useAgentStatus(

// Check the last tool call on this message
const lastTc = msg.toolCalls[msg.toolCalls.length - 1];
if (lastTc.function?.name && isAskUserQuestionTool(lastTc.function.name)) {
if (lastTc.function?.name && isHumanInTheLoopTool(lastTc.function.name)) {
const hasResult =
lastTc.result !== undefined &&
lastTc.result !== null &&
Expand Down
18 changes: 18 additions & 0 deletions components/frontend/src/types/agentic-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ export type AskUserQuestionInput = {
questions: AskUserQuestionItem[];
};

// PermissionRequest tool types (synthetic tool emitted by can_use_tool callback)
export type PermissionRequestInput = {
tool_name: string;
file_path?: string;
command?: string;
description: string;
key: string;
};

export type LLMSettings = {
model: string;
temperature: number;
Expand Down Expand Up @@ -142,6 +151,15 @@ export type ToolResultBlock = {

export type ContentBlock = TextBlock | ReasoningBlock | ToolUseBlock | ToolResultBlock;

/** Check whether a ToolResultBlock contains a non-empty result. */
export function hasToolResult(resultBlock?: ToolResultBlock): boolean {
if (!resultBlock) return false;
const content = resultBlock.content;
if (!content) return false;
if (typeof content === "string" && content.trim() === "") return false;
return true;
}

export type ToolUseMessages = {
type: "tool_use_messages";
toolUseBlock: ToolUseBlock;
Expand Down
Loading
Loading