From 0ce9c6ec275da7d40bb7e235571cdfeeb02eb791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yohan=20Gon=C3=A7alves?= Date: Thu, 30 Jul 2026 18:36:32 +0200 Subject: [PATCH] fix(hitl): approve/reject continues conversation, multi-tool-call panel, last-message gating The HITL approve/reject flow was broken in the UI: clicking approve or reject did not continue the conversation. Root causes on the frontend: 1. useSendMessage invalidated the React Query key ["messages", threadId] but the message list is driven by useThreadHistory whose key is ["history", threadId]. After a successful approve/reject nothing was refetched: the UI stayed frozen on the awaiting_hitl message. 2. HITLReviewPanel only handled toolCalls[0]; multi-tool-call interrupts were not supported (the backend now requires one decision per interrupted tool call). 3. The HITL panel rendered on ANY awaiting_hitl message, not only the last one, so a stale panel reappeared on older messages after continuation. 4. Mutation errors were silently swallowed (no sendMessage.error display). Changes: - useSendMessage: invalidate ["history", threadId] (the correct key). - ChatRequest: add HitlDecisionInput + decisions field (legacy tool_call_id+action kept for backward compat). - HITLReviewPanel: render one decision row per tool call (Approve/Reject per call + optional reject reason), a single Submit button sends one mutation with { decisions: [...] }. Display sendMessage.error when the mutation fails. Extract ToolDecisionRow subcomponent to keep functions shallow (SonarQube S2004). - ChatMessage: add isLast prop; render the HITL panel only on the last message. Render discreet HITL_DECISION badges in the timeline (Approved / Rejected: reason / Edited). Update memo comparator. - MessageList: pass isLast={idx === entries.length - 1} to each entry. - traceEvent: add HITL_DECISION to the TraceEventType enum (flows into chatApi VALID_EVENT_TYPES automatically). Tests: frontend suite green (801 passed). 0 new SonarQube issues, 0 new Trivy vulnerabilities. --- .../components/chat/ChatMessage.tsx | 39 ++- .../components/chat/HITLReviewPanel.tsx | 269 +++++++++++------- .../components/chat/MessageList.tsx | 1 + src/application/hooks/chat/useSendMessage.ts | 2 +- src/domain/entities/chat/chatRequest.ts | 8 + src/domain/entities/chat/traceEvent.ts | 1 + .../unit/components/chat/ChatMessage.test.tsx | 109 +++++++ .../components/chat/HITLReviewPanel.test.tsx | 153 ++++++---- .../unit/components/chat/MessageList.test.tsx | 69 ++++- .../domain/entities/chat/traceEvent.test.ts | 15 +- tests/unit/hooks/chat/useSendMessage.test.tsx | 66 ++++- 11 files changed, 570 insertions(+), 162 deletions(-) diff --git a/src/application/components/chat/ChatMessage.tsx b/src/application/components/chat/ChatMessage.tsx index 679c237..2c233fb 100644 --- a/src/application/components/chat/ChatMessage.tsx +++ b/src/application/components/chat/ChatMessage.tsx @@ -20,6 +20,7 @@ interface ChatMessageProps { agentName: string; threadId?: string; events?: TraceEvent[]; + isLast?: boolean; } interface SubagentTimeline { @@ -89,7 +90,13 @@ function formatTimestamp(ts: string): string { }).format(date); } -function ChatMessageImpl({ message, agentName, threadId, events }: Readonly) { +function ChatMessageImpl({ + message, + agentName, + threadId, + events, + isLast, +}: Readonly) { const isHuman = message.role === MessageRole.HUMAN; const isAi = message.role === MessageRole.AI; const isAwaitingHitl = message.status === MessageStatus.AWAITING_HITL; @@ -103,6 +110,10 @@ function ChatMessageImpl({ message, agentName, threadId, events }: Readonly (events ? parentToolEvents(events) : { toolCalls: [], toolResults: [] }), [events], ); + const hitlDecisionEvents = useMemo( + () => (events ? events.filter((e) => e.type === TraceEventType.HITL_DECISION) : []), + [events], + ); if (isHuman) { return ( @@ -160,6 +171,27 @@ function ChatMessageImpl({ message, agentName, threadId, events }: Readonly + {hitlDecisionEvents.map((ev) => { + const action = ev.name ?? ""; + let label = "✎ Edited"; + let className = "border-accent text-accent"; + if (action === "approve") { + label = "✓ Approved"; + className = "border-success text-success"; + } else if (action === "reject") { + label = ev.content ? `✗ Rejected: ${ev.content}` : "✗ Rejected"; + className = "border-danger text-danger"; + } + return ( + + {label} + + ); + })} + {parentTools.toolCalls.map((call) => ( - {isAwaitingHitl && message.tool_calls?.length && threadId && ( + {isAwaitingHitl && !!message.tool_calls?.length && !!threadId && isLast && ( )} @@ -213,7 +245,8 @@ const ChatMessage = memo(ChatMessageImpl, (prev, next) => { prev.agentName === next.agentName && prev.threadId === next.threadId && prev.message === next.message && - prev.events === next.events + prev.events === next.events && + prev.isLast === next.isLast ); }); diff --git a/src/application/components/chat/HITLReviewPanel.tsx b/src/application/components/chat/HITLReviewPanel.tsx index da12d92..26db721 100644 --- a/src/application/components/chat/HITLReviewPanel.tsx +++ b/src/application/components/chat/HITLReviewPanel.tsx @@ -4,133 +4,202 @@ import { Button } from "@/application/components/ui/button"; import { Input } from "@/application/components/ui/input"; import { useSendMessage } from "@/application/hooks/chat/useSendMessage"; import type { ToolCall } from "@/domain/entities/chat/message"; +import type { HitlDecisionInput } from "@/domain/entities/chat/chatRequest"; interface HITLReviewPanelProps { toolCalls: ToolCall[]; threadId: string; } -type ReviewState = "idle" | "reviewing" | "rejecting"; +type DecisionAction = "approve" | "reject"; -export default function HITLReviewPanel({ toolCalls, threadId }: Readonly) { - const [reviewState, setReviewState] = useState("idle"); - const [rejectReason, setRejectReason] = useState(""); - const sendMessage = useSendMessage(threadId); - - const toolName = toolCalls[0]?.name ?? "Unknown tool"; - const toolCallId = toolCalls[0]?.id ?? ""; - - function handleApprove() { - sendMessage.mutate({ - tool_call_id: toolCallId, - action: "approve", - }); - } - - function handleReject() { - if (reviewState !== "rejecting") { - setReviewState("rejecting"); - return; - } - - sendMessage.mutate( - { - tool_call_id: toolCallId, - action: "reject", - reason: rejectReason || "Rejected by user", - }, - { - onSuccess: () => { - setReviewState("idle"); - setRejectReason(""); - }, - }, - ); - } - - if (reviewState === "idle") { - return ( -
-
-
-
- -
-
- ); - } +interface ToolDecisionRowProps { + toolCall: ToolCall; + isRejecting: boolean; + rejectReason: string; + isPending: boolean; + onApprove: (id: string) => void; + onReject: (id: string) => void; + onReasonChange: (id: string, reason: string) => void; + onConfirmReject: () => void; +} +function ToolDecisionRow({ + toolCall, + isRejecting, + rejectReason, + isPending, + onApprove, + onReject, + onReasonChange, + onConfirmReject, +}: Readonly) { return ( -
-
-