feat: integrate HITL phase management across tool components

- Refactored ApprovalCard in various tools (Gmail, Google Calendar, Google Drive) to utilize the new useHitlPhase hook for improved state management.
- Updated logic to handle tool action phases (pending, processing, complete, rejected) consistently across components, enhancing user feedback during interactions.
- Simplified decision handling by removing direct state management for approval decisions, streamlining the approval process.
- Enhanced UI feedback to reflect the current phase of tool actions, improving user experience during tool interactions.
This commit is contained in:
Anish Sarkar 2026-03-21 11:18:35 +05:30
parent ff6514a99f
commit 9cd2c1f712
19 changed files with 706 additions and 590 deletions

View file

@ -107,6 +107,25 @@ import {
trackChatResponseReceived, trackChatResponseReceived,
} from "@/lib/posthog/events"; } from "@/lib/posthog/events";
/**
* After a tool produces output, mark any previously-decided interrupt tool
* calls as completed so the ApprovalCard can transition from shimmer to done.
*/
function markInterruptsCompleted(contentParts: Array<{ type: string; result?: unknown }>): void {
for (const part of contentParts) {
if (
part.type === "tool-call" &&
typeof part.result === "object" &&
part.result !== null &&
(part.result as Record<string, unknown>).__interrupt__ === true &&
(part.result as Record<string, unknown>).__decided__ &&
!(part.result as Record<string, unknown>).__completed__
) {
part.result = { ...(part.result as Record<string, unknown>), __completed__: true };
}
}
}
/** /**
* Extract thinking steps from message content * Extract thinking steps from message content
*/ */
@ -729,6 +748,7 @@ export default function NewChatPage() {
case "tool-output-available": { case "tool-output-available": {
// Update the tool call with its result // Update the tool call with its result
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output }); updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
markInterruptsCompleted(contentParts);
// Handle podcast-specific logic // Handle podcast-specific logic
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) { if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
// Check if this is a podcast tool by looking at the content part // Check if this is a podcast tool by looking at the content part
@ -1107,6 +1127,7 @@ export default function NewChatPage() {
updateToolCall(contentPartsState, parsed.toolCallId, { updateToolCall(contentPartsState, parsed.toolCallId, {
result: parsed.output, result: parsed.output,
}); });
markInterruptsCompleted(contentParts);
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === assistantMsgId m.id === assistantMsgId
@ -1458,6 +1479,7 @@ export default function NewChatPage() {
case "tool-output-available": case "tool-output-available":
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output }); updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
markInterruptsCompleted(contentParts);
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) { if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
const idx = toolCallIndices.get(parsed.toolCallId); const idx = toolCallIndices.get(parsed.toolCallId);
if (idx !== undefined) { if (idx !== undefined) {

View file

@ -21,6 +21,7 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom"; import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom"; import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GmailAccount { interface GmailAccount {
id: number; id: number;
@ -32,6 +33,7 @@ interface GmailAccount {
interface InterruptResult { interface InterruptResult {
__interrupt__: true; __interrupt__: true;
__decided__?: "approve" | "reject" | "edit"; __decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{ action_requests: Array<{
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
@ -126,10 +128,7 @@ function ApprovalCard({
edited_action?: { name: string; args: Record<string, unknown> }; edited_action?: { name: string; args: Record<string, unknown> };
}) => void; }) => void;
}) { }) {
const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>( const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
interruptData.__decided__ ?? null
);
const [wasAlreadyDecided] = useState(() => interruptData.__decided__ != null);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom); const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const [pendingEdits, setPendingEdits] = useState<{ const [pendingEdits, setPendingEdits] = useState<{
@ -149,15 +148,16 @@ function ApprovalCard({
const canApprove = !!selectedAccountId; const canApprove = !!selectedAccountId;
const reviewConfig = interruptData.review_configs[0]; const reviewConfig = interruptData.review_configs?.[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"]; const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
const canEdit = allowedDecisions.includes("edit"); const canEdit = allowedDecisions.includes("edit");
const handleApprove = useCallback(() => { const handleApprove = useCallback(() => {
if (decided || isPanelOpen || !canApprove) return; if (phase !== "pending") return;
if (isPanelOpen || !canApprove) return;
if (!allowedDecisions.includes("approve")) return; if (!allowedDecisions.includes("approve")) return;
const isEdited = pendingEdits !== null; const isEdited = pendingEdits !== null;
setDecided(isEdited ? "edit" : "approve"); setProcessing();
onDecision({ onDecision({
type: isEdited ? "edit" : "approve", type: isEdited ? "edit" : "approve",
edited_action: { edited_action: {
@ -175,7 +175,7 @@ function ApprovalCard({
}, },
}, },
}); });
}, [decided, isPanelOpen, canApprove, allowedDecisions, onDecision, interruptData, args, selectedAccountId, pendingEdits]); }, [phase, isPanelOpen, canApprove, allowedDecisions, setProcessing, onDecision, interruptData, args, selectedAccountId, pendingEdits]);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -194,30 +194,30 @@ function ApprovalCard({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
{decided === "reject" {phase === "rejected"
? "Gmail Draft Rejected" ? "Gmail Draft Rejected"
: decided === "approve" || decided === "edit" : phase === "processing" || phase === "complete"
? "Gmail Draft Approved" ? "Gmail Draft Approved"
: "Create Gmail Draft"} : "Create Gmail Draft"}
</p> </p>
{decided === "approve" || decided === "edit" ? ( {phase === "processing" ? (
wasAlreadyDecided ? ( <TextShimmerLoader text={pendingEdits ? "Creating draft with your changes" : "Creating draft"} size="sm" />
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "edit" ? "Draft created with your changes" : "Draft created"} {pendingEdits ? "Draft created with your changes" : "Draft created"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Draft creation was cancelled
</p> </p>
) : (
<TextShimmerLoader text={decided === "edit" ? "Creating draft with your changes" : "Creating draft"} size="sm" />
)
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject" Requires your approval to proceed
? "Draft creation was cancelled"
: "Requires your approval to proceed"}
</p> </p>
)} )}
</div> </div>
</div> </div>
{!decided && canEdit && ( {phase === "pending" && canEdit && (
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@ -255,8 +255,8 @@ function ApprovalCard({
)} )}
</div> </div>
{/* Account selector */} {/* Account selector — real dropdown in pending */}
{!decided && interruptData.context && ( {phase === "pending" && interruptData.context && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none"> <div className="px-5 py-4 space-y-4 select-none">
@ -297,7 +297,7 @@ function ApprovalCard({
</> </>
)} )}
{/* Email headers + body preview */} {/* Email headers + body preview — visible in ALL phases */}
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3 pb-2 space-y-1.5 select-none"> <div className="px-5 pt-3 pb-2 space-y-1.5 select-none">
{(pendingEdits?.to ?? args.to) && ( {(pendingEdits?.to ?? args.to) && (
@ -343,8 +343,8 @@ function ApprovalCard({
)} )}
</div> </div>
{/* Action buttons */} {/* Action buttons — only in pending */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none"> <div className="px-5 py-4 flex items-center gap-2 select-none">
@ -366,7 +366,7 @@ function ApprovalCard({
className="rounded-lg text-muted-foreground" className="rounded-lg text-muted-foreground"
disabled={isPanelOpen} disabled={isPanelOpen}
onClick={() => { onClick={() => {
setDecided("reject"); setRejected();
onDecision({ type: "reject", message: "User rejected the action." }); onDecision({ type: "reject", message: "User rejected the action." });
}} }}
> >
@ -445,7 +445,7 @@ export const CreateGmailDraftToolUI = makeAssistantToolUI<
CreateGmailDraftResult CreateGmailDraftResult
>({ >({
toolName: "create_gmail_draft", toolName: "create_gmail_draft",
render: function CreateGmailDraftUI({ args, result, status: _status }) { render: function CreateGmailDraftUI({ args, result }) {
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {

View file

@ -22,6 +22,7 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom"; import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom"; import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GmailAccount { interface GmailAccount {
id: number; id: number;
@ -33,6 +34,7 @@ interface GmailAccount {
interface InterruptResult { interface InterruptResult {
__interrupt__: true; __interrupt__: true;
__decided__?: "approve" | "reject" | "edit"; __decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{ action_requests: Array<{
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
@ -126,10 +128,7 @@ function ApprovalCard({
edited_action?: { name: string; args: Record<string, unknown> }; edited_action?: { name: string; args: Record<string, unknown> };
}) => void; }) => void;
}) { }) {
const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>( const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
interruptData.__decided__ ?? null
);
const [wasAlreadyDecided] = useState(() => interruptData.__decided__ != null);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom); const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const [pendingEdits, setPendingEdits] = useState<{ const [pendingEdits, setPendingEdits] = useState<{
@ -149,15 +148,16 @@ function ApprovalCard({
const canApprove = !!selectedAccountId; const canApprove = !!selectedAccountId;
const reviewConfig = interruptData.review_configs[0]; const reviewConfig = interruptData.review_configs?.[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"]; const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
const canEdit = allowedDecisions.includes("edit"); const canEdit = allowedDecisions.includes("edit");
const handleApprove = useCallback(() => { const handleApprove = useCallback(() => {
if (decided || isPanelOpen || !canApprove) return; if (phase !== "pending") return;
if (isPanelOpen || !canApprove) return;
if (!allowedDecisions.includes("approve")) return; if (!allowedDecisions.includes("approve")) return;
const isEdited = pendingEdits !== null; const isEdited = pendingEdits !== null;
setDecided(isEdited ? "edit" : "approve"); setProcessing();
onDecision({ onDecision({
type: isEdited ? "edit" : "approve", type: isEdited ? "edit" : "approve",
edited_action: { edited_action: {
@ -175,7 +175,7 @@ function ApprovalCard({
}, },
}, },
}); });
}, [decided, isPanelOpen, canApprove, allowedDecisions, onDecision, interruptData, args, selectedAccountId, pendingEdits]); }, [phase, isPanelOpen, canApprove, allowedDecisions, setProcessing, onDecision, interruptData, args, selectedAccountId, pendingEdits]);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -194,30 +194,30 @@ function ApprovalCard({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
{decided === "reject" {phase === "rejected"
? "Email Sending Rejected" ? "Email Sending Rejected"
: decided === "approve" || decided === "edit" : phase === "processing" || phase === "complete"
? "Email Sending Approved" ? "Email Sending Approved"
: "Send Email"} : "Send Email"}
</p> </p>
{decided === "approve" || decided === "edit" ? ( {phase === "processing" ? (
wasAlreadyDecided ? ( <TextShimmerLoader text={pendingEdits ? "Sending email with your changes" : "Sending email"} size="sm" />
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "edit" ? "Email sent with your changes" : "Email sent"} {pendingEdits ? "Email sent with your changes" : "Email sent"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Email sending was cancelled
</p> </p>
) : (
<TextShimmerLoader text={decided === "edit" ? "Sending email with your changes" : "Sending email"} size="sm" />
)
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject" Requires your approval to proceed
? "Email sending was cancelled"
: "Requires your approval to proceed"}
</p> </p>
)} )}
</div> </div>
</div> </div>
{!decided && canEdit && ( {phase === "pending" && canEdit && (
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@ -255,8 +255,8 @@ function ApprovalCard({
)} )}
</div> </div>
{/* Account selector */} {/* Account selector — real dropdown in pending */}
{!decided && interruptData.context && ( {phase === "pending" && interruptData.context && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none"> <div className="px-5 py-4 space-y-4 select-none">
@ -295,7 +295,7 @@ function ApprovalCard({
</> </>
)} )}
{/* Email headers + body preview */} {/* Email headers + body preview — visible in ALL phases */}
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3 pb-2 space-y-1.5 select-none"> <div className="px-5 pt-3 pb-2 space-y-1.5 select-none">
{(pendingEdits?.to ?? args.to) && ( {(pendingEdits?.to ?? args.to) && (
@ -341,8 +341,8 @@ function ApprovalCard({
)} )}
</div> </div>
{/* Action buttons */} {/* Action buttons — only in pending */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none"> <div className="px-5 py-4 flex items-center gap-2 select-none">
@ -364,7 +364,7 @@ function ApprovalCard({
className="rounded-lg text-muted-foreground" className="rounded-lg text-muted-foreground"
disabled={isPanelOpen} disabled={isPanelOpen}
onClick={() => { onClick={() => {
setDecided("reject"); setRejected();
onDecision({ type: "reject", message: "User rejected the action." }); onDecision({ type: "reject", message: "User rejected the action." });
}} }}
> >
@ -444,7 +444,7 @@ export const SendGmailEmailToolUI = makeAssistantToolUI<
SendGmailEmailResult SendGmailEmailResult
>({ >({
toolName: "send_gmail_email", toolName: "send_gmail_email",
render: function SendGmailEmailUI({ args, result, status: _status }) { render: function SendGmailEmailUI({ args, result }) {
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {

View file

@ -11,6 +11,7 @@ import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GmailAccount { interface GmailAccount {
id: number; id: number;
@ -32,6 +33,7 @@ interface GmailMessage {
interface InterruptResult { interface InterruptResult {
__interrupt__: true; __interrupt__: true;
__decided__?: "approve" | "reject"; __decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{ action_requests: Array<{
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
@ -144,18 +146,16 @@ function ApprovalCard({
edited_action?: { name: string; args: Record<string, unknown> }; edited_action?: { name: string; args: Record<string, unknown> };
}) => void; }) => void;
}) { }) {
const [decided, setDecided] = useState<"approve" | "reject" | null>( const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
interruptData.__decided__ ?? null
);
const [wasAlreadyDecided] = useState(() => interruptData.__decided__ != null);
const [deleteFromKb, setDeleteFromKb] = useState(false); const [deleteFromKb, setDeleteFromKb] = useState(false);
const account = interruptData.context?.account; const context = interruptData.context;
const email = interruptData.context?.email; const account = context?.account;
const email = context?.email;
const handleApprove = useCallback(() => { const handleApprove = useCallback(() => {
if (decided) return; if (phase !== "pending") return;
setDecided("approve"); setProcessing();
onDecision({ onDecision({
type: "approve", type: "approve",
edited_action: { edited_action: {
@ -167,7 +167,7 @@ function ApprovalCard({
}, },
}, },
}); });
}, [decided, onDecision, interruptData, email, account?.id, deleteFromKb]); }, [phase, setProcessing, onDecision, interruptData, email, account?.id, deleteFromKb]);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -186,23 +186,23 @@ function ApprovalCard({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
{decided === "reject" {phase === "rejected"
? "Email Trash Rejected" ? "Email Trash Rejected"
: decided === "approve" : phase === "processing" || phase === "complete"
? "Email Trash Approved" ? "Email Trash Approved"
: "Trash Email"} : "Trash Email"}
</p> </p>
{decided === "approve" ? ( {phase === "processing" ? (
wasAlreadyDecided ? (
<p className="text-xs text-muted-foreground mt-0.5">Email trashed</p>
) : (
<TextShimmerLoader text="Trashing email" size="sm" /> <TextShimmerLoader text="Trashing email" size="sm" />
) ) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">Email trashed</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Email trash was cancelled
</p>
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject" Requires your approval to proceed
? "Email trash was cancelled"
: "Requires your approval to proceed"}
</p> </p>
)} )}
</div> </div>
@ -210,12 +210,12 @@ function ApprovalCard({
</div> </div>
{/* Context — read-only account and email info */} {/* Context — read-only account and email info */}
{!decided && interruptData.context && ( {phase !== "rejected" && context && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none"> <div className="px-5 py-4 space-y-4 select-none">
{interruptData.context.error ? ( {context.error ? (
<p className="text-sm text-destructive">{interruptData.context.error}</p> <p className="text-sm text-destructive">{context.error}</p>
) : ( ) : (
<> <>
{account && ( {account && (
@ -253,7 +253,7 @@ function ApprovalCard({
)} )}
{/* delete_from_kb toggle */} {/* delete_from_kb toggle */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 select-none"> <div className="px-5 py-4 select-none">
@ -276,7 +276,7 @@ function ApprovalCard({
)} )}
{/* Action buttons */} {/* Action buttons */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none"> <div className="px-5 py-4 flex items-center gap-2 select-none">
@ -293,7 +293,7 @@ function ApprovalCard({
variant="ghost" variant="ghost"
className="rounded-lg text-muted-foreground" className="rounded-lg text-muted-foreground"
onClick={() => { onClick={() => {
setDecided("reject"); setRejected();
onDecision({ type: "reject", message: "User rejected the action." }); onDecision({ type: "reject", message: "User rejected the action." });
}} }}
> >
@ -397,7 +397,7 @@ export const TrashGmailEmailToolUI = makeAssistantToolUI<
TrashGmailEmailResult TrashGmailEmailResult
>({ >({
toolName: "trash_gmail_email", toolName: "trash_gmail_email",
render: function TrashGmailEmailUI({ result, status: _status }) { render: function TrashGmailEmailUI({ result }) {
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
@ -405,9 +405,10 @@ export const TrashGmailEmailToolUI = makeAssistantToolUI<
<ApprovalCard <ApprovalCard
interruptData={result} interruptData={result}
onDecision={(decision) => { onDecision={(decision) => {
window.dispatchEvent( const event = new CustomEvent("hitl-decision", {
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } }) detail: { decisions: [decision] },
); });
window.dispatchEvent(event);
}} }}
/> />
); );

View file

@ -15,6 +15,7 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom"; import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom"; import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GmailAccount { interface GmailAccount {
id: number; id: number;
@ -36,6 +37,7 @@ interface GmailMessage {
interface InterruptResult { interface InterruptResult {
__interrupt__: true; __interrupt__: true;
__decided__?: "approve" | "reject" | "edit"; __decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{ action_requests: Array<{
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
@ -157,12 +159,7 @@ function ApprovalCard({
edited_action?: { name: string; args: Record<string, unknown> }; edited_action?: { name: string; args: Record<string, unknown> };
}) => void; }) => void;
}) { }) {
const [decided, setDecided] = useState< const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
"approve" | "reject" | "edit" | null
>(interruptData.__decided__ ?? null);
const [wasAlreadyDecided] = useState(
() => interruptData.__decided__ != null,
);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom); const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const [pendingEdits, setPendingEdits] = useState<{ const [pendingEdits, setPendingEdits] = useState<{
@ -173,12 +170,13 @@ function ApprovalCard({
bcc: string; bcc: string;
} | null>(null); } | null>(null);
const account = interruptData.context?.account; const context = interruptData.context;
const email = interruptData.context?.email; const account = context?.account;
const draftId = interruptData.context?.draft_id; const email = context?.email;
const existingBody = interruptData.context?.existing_body; const draftId = context?.draft_id;
const existingBody = context?.existing_body;
const reviewConfig = interruptData.review_configs[0]; const reviewConfig = interruptData.review_configs?.[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? [ const allowedDecisions = reviewConfig?.allowed_decisions ?? [
"approve", "approve",
"reject", "reject",
@ -197,10 +195,11 @@ function ApprovalCard({
const editableBody = currentBody || existingBody || ""; const editableBody = currentBody || existingBody || "";
const handleApprove = useCallback(() => { const handleApprove = useCallback(() => {
if (decided || isPanelOpen) return; if (phase !== "pending") return;
if (isPanelOpen) return;
if (!allowedDecisions.includes("approve")) return; if (!allowedDecisions.includes("approve")) return;
const isEdited = pendingEdits !== null; const isEdited = pendingEdits !== null;
setDecided(isEdited ? "edit" : "approve"); setProcessing();
onDecision({ onDecision({
type: isEdited ? "edit" : "approve", type: isEdited ? "edit" : "approve",
edited_action: { edited_action: {
@ -218,9 +217,10 @@ function ApprovalCard({
}, },
}); });
}, [ }, [
decided, phase,
isPanelOpen, isPanelOpen,
allowedDecisions, allowedDecisions,
setProcessing,
onDecision, onDecision,
interruptData, interruptData,
email, email,
@ -251,39 +251,39 @@ function ApprovalCard({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
{decided === "reject" {phase === "rejected"
? "Draft Update Rejected" ? "Draft Update Rejected"
: decided === "approve" || decided === "edit" : phase === "processing" || phase === "complete"
? "Draft Update Approved" ? "Draft Update Approved"
: "Update Gmail Draft"} : "Update Gmail Draft"}
</p> </p>
{decided === "approve" || decided === "edit" ? ( {phase === "processing" ? (
wasAlreadyDecided ? (
<p className="text-xs text-muted-foreground mt-0.5">
{decided === "edit"
? "Draft updated with your changes"
: "Draft updated"}
</p>
) : (
<TextShimmerLoader <TextShimmerLoader
text={ text={
decided === "edit" pendingEdits
? "Updating draft with your changes" ? "Updating draft with your changes"
: "Updating draft" : "Updating draft"
} }
size="sm" size="sm"
/> />
) ) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">
{pendingEdits
? "Draft updated with your changes"
: "Draft updated"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Draft update was cancelled
</p>
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject" Requires your approval to proceed
? "Draft update was cancelled"
: "Requires your approval to proceed"}
</p> </p>
)} )}
</div> </div>
</div> </div>
{!decided && canEdit && ( {phase === "pending" && canEdit && (
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@ -340,14 +340,14 @@ function ApprovalCard({
)} )}
</div> </div>
{/* Context — account and current draft info */} {/* Context — account and draft info in pending/processing/complete */}
{!decided && interruptData.context && ( {phase !== "rejected" && context && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none"> <div className="px-5 py-4 space-y-4 select-none">
{interruptData.context.error ? ( {context.error ? (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
{interruptData.context.error} {context.error}
</p> </p>
) : ( ) : (
<> <>
@ -383,7 +383,7 @@ function ApprovalCard({
</> </>
)} )}
{/* Email headers + body preview */} {/* Email headers + body preview — visible in ALL phases */}
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3 pb-2 space-y-1.5 select-none"> <div className="px-5 pt-3 pb-2 space-y-1.5 select-none">
{currentTo && ( {currentTo && (
@ -433,8 +433,8 @@ function ApprovalCard({
) : null} ) : null}
</div> </div>
{/* Action buttons */} {/* Action buttons — only in pending */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none"> <div className="px-5 py-4 flex items-center gap-2 select-none">
@ -456,7 +456,7 @@ function ApprovalCard({
className="rounded-lg text-muted-foreground" className="rounded-lg text-muted-foreground"
disabled={isPanelOpen} disabled={isPanelOpen}
onClick={() => { onClick={() => {
setDecided("reject"); setRejected();
onDecision({ onDecision({
type: "reject", type: "reject",
message: "User rejected the action.", message: "User rejected the action.",

View file

@ -21,6 +21,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { PlateEditor } from "@/components/editor/plate-editor"; import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom"; import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom"; import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
@ -39,6 +40,7 @@ interface CalendarEntry {
interface InterruptResult { interface InterruptResult {
__interrupt__: true; __interrupt__: true;
__decided__?: "approve" | "reject" | "edit"; __decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{ action_requests: Array<{
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
@ -153,11 +155,9 @@ function ApprovalCard({
edited_action?: { name: string; args: Record<string, unknown> }; edited_action?: { name: string; args: Record<string, unknown> };
}) => void; }) => void;
}) { }) {
const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>( const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
interruptData.__decided__ ?? null
);
const [wasAlreadyDecided] = useState(() => interruptData.__decided__ != null);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
const [wasEdited, setWasEdited] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom); const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const [pendingEdits, setPendingEdits] = useState<{ const [pendingEdits, setPendingEdits] = useState<{
summary: string; description: string; start_datetime: string; summary: string; description: string; start_datetime: string;
@ -185,6 +185,14 @@ function ApprovalCard({
const [selectedAccountId, setSelectedAccountId] = useState<string>(defaultAccountId); const [selectedAccountId, setSelectedAccountId] = useState<string>(defaultAccountId);
const [selectedCalendarId, setSelectedCalendarId] = useState<string>(defaultCalendarId); const [selectedCalendarId, setSelectedCalendarId] = useState<string>(defaultCalendarId);
useEffect(() => {
if (defaultAccountId && !selectedAccountId) setSelectedAccountId(defaultAccountId);
}, [defaultAccountId, selectedAccountId]);
useEffect(() => {
if (defaultCalendarId && !selectedCalendarId) setSelectedCalendarId(defaultCalendarId);
}, [defaultCalendarId, selectedCalendarId]);
const reviewConfig = interruptData.review_configs[0]; const reviewConfig = interruptData.review_configs[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"]; const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
const canEdit = allowedDecisions.includes("edit"); const canEdit = allowedDecisions.includes("edit");
@ -195,10 +203,11 @@ function ApprovalCard({
!!(pendingEdits?.summary ?? args.summary)?.trim(); !!(pendingEdits?.summary ?? args.summary)?.trim();
const handleApprove = useCallback(() => { const handleApprove = useCallback(() => {
if (decided || isPanelOpen || !canApprove) return; if (phase !== "pending" || isPanelOpen || !canApprove) return;
if (!allowedDecisions.includes("approve")) return; if (!allowedDecisions.includes("approve")) return;
const isEdited = pendingEdits !== null; const isEdited = pendingEdits !== null;
setDecided(isEdited ? "edit" : "approve"); setWasEdited(isEdited);
setProcessing();
const finalArgs: Record<string, unknown> = { const finalArgs: Record<string, unknown> = {
...args, ...args,
@ -227,7 +236,7 @@ function ApprovalCard({
args: finalArgs, args: finalArgs,
}, },
}); });
}, [decided, isPanelOpen, canApprove, allowedDecisions, onDecision, interruptData, args, selectedAccountId, selectedCalendarId, pendingEdits]); }, [phase, isPanelOpen, canApprove, allowedDecisions, setProcessing, onDecision, interruptData, args, selectedAccountId, selectedCalendarId, pendingEdits]);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -251,30 +260,30 @@ function ApprovalCard({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
{decided === "reject" {phase === "rejected"
? "Calendar Event Rejected" ? "Calendar Event Rejected"
: decided === "approve" || decided === "edit" : phase === "processing" || phase === "complete"
? "Calendar Event Approved" ? "Calendar Event Approved"
: "Create Calendar Event"} : "Create Calendar Event"}
</p> </p>
{decided === "approve" || decided === "edit" ? ( {phase === "processing" ? (
wasAlreadyDecided ? ( <TextShimmerLoader text={wasEdited ? "Creating event with your changes" : "Creating event"} size="sm" />
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "edit" ? "Event created with your changes" : "Event created"} {wasEdited ? "Event created with your changes" : "Event created"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Event creation was cancelled
</p> </p>
) : (
<TextShimmerLoader text={decided === "edit" ? "Creating event with your changes" : "Creating event"} size="sm" />
)
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject" Requires your approval to proceed
? "Event creation was cancelled"
: "Requires your approval to proceed"}
</p> </p>
)} )}
</div> </div>
</div> </div>
{!decided && canEdit && ( {phase === "pending" && canEdit && (
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@ -314,8 +323,8 @@ function ApprovalCard({
)} )}
</div> </div>
{/* Context section */} {/* Context section - pending with real dropdowns */}
{!decided && interruptData.context && ( {phase === "pending" && interruptData.context && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none"> <div className="px-5 py-4 space-y-4 select-none">
@ -386,7 +395,7 @@ function ApprovalCard({
</> </>
)} )}
{/* Content preview */} {/* Content preview - visible in ALL phases */}
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3 pb-3 space-y-2"> <div className="px-5 pt-3 pb-3 space-y-2">
{(pendingEdits?.summary ?? args.summary) && ( {(pendingEdits?.summary ?? args.summary) && (
@ -437,8 +446,8 @@ function ApprovalCard({
)} )}
</div> </div>
{/* Action buttons */} {/* Action buttons - pending only */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none"> <div className="px-5 py-4 flex items-center gap-2 select-none">
@ -460,7 +469,7 @@ function ApprovalCard({
className="rounded-lg text-muted-foreground" className="rounded-lg text-muted-foreground"
disabled={isPanelOpen} disabled={isPanelOpen}
onClick={() => { onClick={() => {
setDecided("reject"); setRejected();
onDecision({ type: "reject", message: "User rejected the action." }); onDecision({ type: "reject", message: "User rejected the action." });
}} }}
> >

View file

@ -11,6 +11,7 @@ import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GoogleCalendarAccount { interface GoogleCalendarAccount {
id: number; id: number;
@ -34,6 +35,7 @@ interface CalendarEvent {
interface InterruptResult { interface InterruptResult {
__interrupt__: true; __interrupt__: true;
__decided__?: "approve" | "reject"; __decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{ action_requests: Array<{
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
@ -172,10 +174,7 @@ function ApprovalCard({
edited_action?: { name: string; args: Record<string, unknown> }; edited_action?: { name: string; args: Record<string, unknown> };
}) => void; }) => void;
}) { }) {
const [decided, setDecided] = useState<"approve" | "reject" | null>( const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
interruptData.__decided__ ?? null
);
const [wasAlreadyDecided] = useState(() => interruptData.__decided__ != null);
const [deleteFromKb, setDeleteFromKb] = useState(false); const [deleteFromKb, setDeleteFromKb] = useState(false);
const context = interruptData.context; const context = interruptData.context;
@ -183,8 +182,8 @@ function ApprovalCard({
const event = context?.event; const event = context?.event;
const handleApprove = useCallback(() => { const handleApprove = useCallback(() => {
if (decided) return; if (phase !== "pending") return;
setDecided("approve"); setProcessing();
onDecision({ onDecision({
type: "approve", type: "approve",
edited_action: { edited_action: {
@ -196,7 +195,7 @@ function ApprovalCard({
}, },
}, },
}); });
}, [decided, onDecision, interruptData, event?.event_id, account?.id, deleteFromKb]); }, [phase, setProcessing, onDecision, interruptData, event?.event_id, account?.id, deleteFromKb]);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -215,31 +214,30 @@ function ApprovalCard({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
{decided === "reject" {phase === "rejected"
? "Calendar Event Deletion Rejected" ? "Calendar Event Deletion Rejected"
: decided === "approve" : phase === "processing" || phase === "complete"
? "Calendar Event Deletion Approved" ? "Calendar Event Deletion Approved"
: "Delete Calendar Event"} : "Delete Calendar Event"}
</p> </p>
{decided === "approve" ? ( {phase === "processing" ? (
wasAlreadyDecided ? (
<p className="text-xs text-muted-foreground mt-0.5">Event deleted</p>
) : (
<TextShimmerLoader text="Deleting event" size="sm" /> <TextShimmerLoader text="Deleting event" size="sm" />
) ) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">Event deleted</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Event deletion was cancelled
</p>
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject" Requires your approval to proceed
? "Event deletion was cancelled"
: "Requires your approval to proceed"}
</p> </p>
)} )}
</div> </div>
</div> </div>
</div> </div>
{/* Context section */} {phase !== "rejected" && context && (
{!decided && context && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none"> <div className="px-5 py-4 space-y-4 select-none">
@ -290,7 +288,7 @@ function ApprovalCard({
)} )}
{/* delete_from_kb toggle */} {/* delete_from_kb toggle */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 select-none"> <div className="px-5 py-4 select-none">
@ -313,7 +311,7 @@ function ApprovalCard({
)} )}
{/* Action buttons */} {/* Action buttons */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none"> <div className="px-5 py-4 flex items-center gap-2 select-none">
@ -330,7 +328,7 @@ function ApprovalCard({
variant="ghost" variant="ghost"
className="rounded-lg text-muted-foreground" className="rounded-lg text-muted-foreground"
onClick={() => { onClick={() => {
setDecided("reject"); setRejected();
onDecision({ type: "reject", message: "User rejected the action." }); onDecision({ type: "reject", message: "User rejected the action." });
}} }}
> >
@ -443,7 +441,7 @@ function SuccessCard({ result }: { result: SuccessResult }) {
} }
export const DeleteCalendarEventToolUI = makeAssistantToolUI< export const DeleteCalendarEventToolUI = makeAssistantToolUI<
{ event_ref: string; delete_from_kb?: boolean }, { event_title_or_id: string; delete_from_kb?: boolean },
DeleteCalendarEventResult DeleteCalendarEventResult
>({ >({
toolName: "delete_calendar_event", toolName: "delete_calendar_event",

View file

@ -14,6 +14,7 @@ import { useSetAtom } from "jotai";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PlateEditor } from "@/components/editor/plate-editor"; import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom"; import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom"; import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
@ -39,6 +40,7 @@ interface CalendarEvent {
interface InterruptResult { interface InterruptResult {
__interrupt__: true; __interrupt__: true;
__decided__?: "approve" | "reject" | "edit"; __decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{ action_requests: Array<{
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
@ -148,9 +150,19 @@ function formatDateTime(iso: string): string {
} }
function ApprovalCard({ function ApprovalCard({
args,
interruptData, interruptData,
onDecision, onDecision,
}: { }: {
args: {
event_ref: string;
new_summary?: string;
new_description?: string;
new_start_datetime?: string;
new_end_datetime?: string;
new_location?: string;
new_attendees?: string[];
};
interruptData: InterruptResult; interruptData: InterruptResult;
onDecision: (decision: { onDecision: (decision: {
type: "approve" | "reject" | "edit"; type: "approve" | "reject" | "edit";
@ -158,16 +170,14 @@ function ApprovalCard({
edited_action?: { name: string; args: Record<string, unknown> }; edited_action?: { name: string; args: Record<string, unknown> };
}) => void; }) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const actionArgs = interruptData.action_requests[0]?.args ?? {}; const actionArgs = interruptData.action_requests[0]?.args ?? {};
const context = interruptData.context; const context = interruptData.context;
const account = context?.account; const account = context?.account;
const event = context?.event; const event = context?.event;
const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>(
interruptData.__decided__ ?? null
);
const [wasAlreadyDecided] = useState(() => interruptData.__decided__ != null);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
const [wasEdited, setWasEdited] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom); const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const [pendingEdits, setPendingEdits] = useState<{ const [pendingEdits, setPendingEdits] = useState<{
summary: string; description: string; start_datetime: string; summary: string; description: string; start_datetime: string;
@ -183,39 +193,51 @@ function ApprovalCard({
? (actionArgs.new_attendees as string[]) ? (actionArgs.new_attendees as string[])
: null; : null;
const effectiveNewSummary = actionArgs.new_summary ?? args.new_summary;
const effectiveNewStartDatetime = actionArgs.new_start_datetime ?? args.new_start_datetime;
const effectiveNewEndDatetime = actionArgs.new_end_datetime ?? args.new_end_datetime;
const effectiveNewLocation = actionArgs.new_location !== undefined
? actionArgs.new_location
: args.new_location;
const effectiveNewAttendees = proposedAttendees
?? (Array.isArray(args.new_attendees) ? args.new_attendees : null);
const effectiveNewDescription = actionArgs.new_description !== undefined
? actionArgs.new_description
: args.new_description;
const changes: Array<{ label: string; oldVal: string; newVal: string }> = []; const changes: Array<{ label: string; oldVal: string; newVal: string }> = [];
if (actionArgs.new_summary && String(actionArgs.new_summary) !== event?.summary) { if (effectiveNewSummary && String(effectiveNewSummary) !== (event?.summary ?? "")) {
changes.push({ label: "Summary", oldVal: event?.summary ?? "", newVal: String(actionArgs.new_summary) }); changes.push({ label: "Summary", oldVal: event?.summary ?? "", newVal: String(effectiveNewSummary) });
} }
if (actionArgs.new_start_datetime && String(actionArgs.new_start_datetime) !== event?.start) { if (effectiveNewStartDatetime && String(effectiveNewStartDatetime) !== (event?.start ?? "")) {
changes.push({ changes.push({
label: "Start", label: "Start",
oldVal: event?.start ? formatDateTime(event.start) : "", oldVal: event?.start ? formatDateTime(event.start) : "",
newVal: formatDateTime(String(actionArgs.new_start_datetime)), newVal: formatDateTime(String(effectiveNewStartDatetime)),
}); });
} }
if (actionArgs.new_end_datetime && String(actionArgs.new_end_datetime) !== event?.end) { if (effectiveNewEndDatetime && String(effectiveNewEndDatetime) !== (event?.end ?? "")) {
changes.push({ changes.push({
label: "End", label: "End",
oldVal: event?.end ? formatDateTime(event.end) : "", oldVal: event?.end ? formatDateTime(event.end) : "",
newVal: formatDateTime(String(actionArgs.new_end_datetime)), newVal: formatDateTime(String(effectiveNewEndDatetime)),
}); });
} }
if (actionArgs.new_location !== undefined && String(actionArgs.new_location ?? "") !== (event?.location ?? "")) { if (effectiveNewLocation !== undefined && String(effectiveNewLocation ?? "") !== (event?.location ?? "")) {
changes.push({ label: "Location", oldVal: event?.location ?? "", newVal: String(actionArgs.new_location ?? "") }); changes.push({ label: "Location", oldVal: event?.location ?? "", newVal: String(effectiveNewLocation ?? "") });
} }
if (proposedAttendees) { if (effectiveNewAttendees) {
const oldStr = currentAttendees.join(", "); const oldStr = currentAttendees.join(", ");
const newStr = proposedAttendees.join(", "); const newStr = effectiveNewAttendees.join(", ");
if (oldStr !== newStr) { if (oldStr !== newStr) {
changes.push({ label: "Attendees", oldVal: oldStr, newVal: newStr }); changes.push({ label: "Attendees", oldVal: oldStr, newVal: newStr });
} }
} }
const hasDescriptionChange = const hasDescriptionChange =
actionArgs.new_description !== undefined && effectiveNewDescription !== undefined &&
String(actionArgs.new_description ?? "") !== (event?.description ?? ""); String(effectiveNewDescription ?? "") !== (event?.description ?? "");
const buildFinalArgs = useCallback(() => { const buildFinalArgs = useCallback(() => {
if (pendingEdits) { if (pendingEdits) {
@ -248,10 +270,11 @@ function ApprovalCard({
}, [event, account, actionArgs, proposedAttendees, pendingEdits]); }, [event, account, actionArgs, proposedAttendees, pendingEdits]);
const handleApprove = useCallback(() => { const handleApprove = useCallback(() => {
if (decided || isPanelOpen) return; if (phase !== "pending" || isPanelOpen) return;
if (!allowedDecisions.includes("approve")) return; if (!allowedDecisions.includes("approve")) return;
const isEdited = pendingEdits !== null; const isEdited = pendingEdits !== null;
setDecided(isEdited ? "edit" : "approve"); setWasEdited(isEdited);
setProcessing();
onDecision({ onDecision({
type: isEdited ? "edit" : "approve", type: isEdited ? "edit" : "approve",
edited_action: { edited_action: {
@ -259,7 +282,7 @@ function ApprovalCard({
args: buildFinalArgs(), args: buildFinalArgs(),
}, },
}); });
}, [decided, isPanelOpen, allowedDecisions, onDecision, interruptData, buildFinalArgs, pendingEdits]); }, [phase, isPanelOpen, allowedDecisions, setProcessing, onDecision, interruptData, buildFinalArgs, pendingEdits]);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -278,30 +301,30 @@ function ApprovalCard({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
{decided === "reject" {phase === "rejected"
? "Calendar Event Update Rejected" ? "Calendar Event Update Rejected"
: decided === "approve" || decided === "edit" : phase === "processing" || phase === "complete"
? "Calendar Event Update Approved" ? "Calendar Event Update Approved"
: "Update Calendar Event"} : "Update Calendar Event"}
</p> </p>
{decided === "approve" || decided === "edit" ? ( {phase === "processing" ? (
wasAlreadyDecided ? ( <TextShimmerLoader text={wasEdited ? "Updating event with your changes" : "Updating event"} size="sm" />
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "edit" ? "Event updated with your changes" : "Event updated"} {wasEdited ? "Event updated with your changes" : "Event updated"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Event update was cancelled
</p> </p>
) : (
<TextShimmerLoader text={decided === "edit" ? "Updating event with your changes" : "Updating event"} size="sm" />
)
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject" Requires your approval to proceed
? "Event update was cancelled"
: "Requires your approval to proceed"}
</p> </p>
)} )}
</div> </div>
</div> </div>
{!decided && canEdit && ( {phase === "pending" && canEdit && (
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@ -354,16 +377,14 @@ function ApprovalCard({
)} )}
</div> </div>
{/* Context section */} {/* Content section */}
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none"> <div className="px-5 py-4 space-y-4 select-none">
{context?.error ? ( {context?.error ? (
<p className="text-sm text-destructive">{context.error}</p> <p className="text-sm text-destructive">{context.error}</p>
) : ( ) : (
<> <>
{account && ( {phase === "pending" && account && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Google Calendar Account</p> <p className="text-xs font-medium text-muted-foreground">Google Calendar Account</p>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm"> <div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
@ -403,6 +424,7 @@ function ApprovalCard({
</div> </div>
)} )}
{/* Proposed Changes - visible in all phases */}
{(changes.length > 0 || hasDescriptionChange) && ( {(changes.length > 0 || hasDescriptionChange) && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Proposed Changes</p> <p className="text-xs font-medium text-muted-foreground">Proposed Changes</p>
@ -428,7 +450,7 @@ function ApprovalCard({
}} }}
> >
<PlateEditor <PlateEditor
markdown={String(actionArgs.new_description ?? "")} markdown={String(effectiveNewDescription ?? "")}
readOnly readOnly
preset="readonly" preset="readonly"
editorVariant="none" editorVariant="none"
@ -441,17 +463,15 @@ function ApprovalCard({
</div> </div>
)} )}
{changes.length === 0 && !hasDescriptionChange && ( {event && changes.length === 0 && !hasDescriptionChange && (
<p className="text-sm text-muted-foreground italic">No changes proposed</p> <p className="text-sm text-muted-foreground italic">No changes proposed</p>
)} )}
</> </>
)} )}
</div> </div>
</>
)}
{/* Action buttons */} {/* Action buttons - pending only */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none"> <div className="px-5 py-4 flex items-center gap-2 select-none">
@ -473,7 +493,7 @@ function ApprovalCard({
className="rounded-lg text-muted-foreground" className="rounded-lg text-muted-foreground"
disabled={isPanelOpen} disabled={isPanelOpen}
onClick={() => { onClick={() => {
setDecided("reject"); setRejected();
onDecision({ type: "reject", message: "User rejected the action." }); onDecision({ type: "reject", message: "User rejected the action." });
}} }}
> >
@ -591,12 +611,13 @@ export const UpdateCalendarEventToolUI = makeAssistantToolUI<
UpdateCalendarEventResult UpdateCalendarEventResult
>({ >({
toolName: "update_calendar_event", toolName: "update_calendar_event",
render: function UpdateCalendarEventUI({ result }) { render: function UpdateCalendarEventUI({ args, result }) {
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
args={args}
interruptData={result} interruptData={result}
onDecision={(decision) => { onDecision={(decision) => {
window.dispatchEvent( window.dispatchEvent(

View file

@ -17,6 +17,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { PlateEditor } from "@/components/editor/plate-editor"; import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom"; import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
@ -29,6 +30,7 @@ interface GoogleDriveAccount {
interface InterruptResult { interface InterruptResult {
__interrupt__: true; __interrupt__: true;
__decided__?: "approve" | "reject" | "edit"; __decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{ action_requests: Array<{
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
@ -131,10 +133,7 @@ function ApprovalCard({
edited_action?: { name: string; args: Record<string, unknown> }; edited_action?: { name: string; args: Record<string, unknown> };
}) => void; }) => void;
}) { }) {
const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>( const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
interruptData.__decided__ ?? null
);
const [wasAlreadyDecided] = useState(() => interruptData.__decided__ != null);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom); const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const [pendingEdits, setPendingEdits] = useState<{ name: string; content: string } | null>(null); const [pendingEdits, setPendingEdits] = useState<{ name: string; content: string } | null>(null);
@ -172,15 +171,16 @@ function ApprovalCard({
const canApprove = !!selectedAccountId && isNameValid; const canApprove = !!selectedAccountId && isNameValid;
const reviewConfig = interruptData.review_configs[0]; const reviewConfig = interruptData.review_configs?.[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"]; const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
const canEdit = allowedDecisions.includes("edit"); const canEdit = allowedDecisions.includes("edit");
const handleApprove = useCallback(() => { const handleApprove = useCallback(() => {
if (decided || isPanelOpen || !canApprove) return; if (phase !== "pending") return;
if (isPanelOpen || !canApprove) return;
if (!allowedDecisions.includes("approve")) return; if (!allowedDecisions.includes("approve")) return;
const isEdited = pendingEdits !== null; const isEdited = pendingEdits !== null;
setDecided(isEdited ? "edit" : "approve"); setProcessing();
onDecision({ onDecision({
type: isEdited ? "edit" : "approve", type: isEdited ? "edit" : "approve",
edited_action: { edited_action: {
@ -194,7 +194,7 @@ function ApprovalCard({
}, },
}, },
}); });
}, [decided, isPanelOpen, canApprove, allowedDecisions, onDecision, interruptData, args, selectedFileType, selectedAccountId, parentFolderId, pendingEdits]); }, [phase, setProcessing, isPanelOpen, canApprove, allowedDecisions, onDecision, interruptData, args, selectedFileType, selectedAccountId, parentFolderId, pendingEdits]);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -212,29 +212,29 @@ function ApprovalCard({
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
{decided === "reject" {phase === "rejected"
? `${fileTypeLabel} Rejected` ? `${fileTypeLabel} Rejected`
: decided === "approve" || decided === "edit" : phase === "processing" || phase === "complete"
? `${fileTypeLabel} Approved` ? `${fileTypeLabel} Approved`
: `Create ${fileTypeLabel}`} : `Create ${fileTypeLabel}`}
</p> </p>
{decided === "approve" || decided === "edit" ? ( {phase === "processing" ? (
wasAlreadyDecided ? ( <TextShimmerLoader text={pendingEdits ? "Creating file with your changes" : "Creating file"} size="sm" />
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "edit" ? "File created with your changes" : "File created"} {pendingEdits ? "File created with your changes" : "File created"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
File creation was cancelled
</p> </p>
) : (
<TextShimmerLoader text={decided === "edit" ? "Creating file with your changes" : "Creating file"} size="sm" />
)
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject" Requires your approval to proceed
? "File creation was cancelled"
: "Requires your approval to proceed"}
</p> </p>
)} )}
</div> </div>
{!decided && canEdit && ( {phase === "pending" && canEdit && (
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@ -259,8 +259,8 @@ function ApprovalCard({
)} )}
</div> </div>
{/* Context section */} {/* Context section — real pickers in pending */}
{!decided && interruptData.context && ( {phase === "pending" && interruptData.context && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none"> <div className="px-5 py-4 space-y-4 select-none">
@ -368,7 +368,7 @@ function ApprovalCard({
</div> </div>
{/* Action buttons - only shown when pending */} {/* Action buttons - only shown when pending */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none"> <div className="px-5 py-4 flex items-center gap-2 select-none">
@ -390,7 +390,7 @@ function ApprovalCard({
className="rounded-lg text-muted-foreground" className="rounded-lg text-muted-foreground"
disabled={isPanelOpen} disabled={isPanelOpen}
onClick={() => { onClick={() => {
setDecided("reject"); setRejected();
onDecision({ type: "reject", message: "User rejected the action." }); onDecision({ type: "reject", message: "User rejected the action." });
}} }}
> >
@ -486,7 +486,7 @@ export const CreateGoogleDriveFileToolUI = makeAssistantToolUI<
CreateGoogleDriveFileResult CreateGoogleDriveFileResult
>({ >({
toolName: "create_google_drive_file", toolName: "create_google_drive_file",
render: function CreateGoogleDriveFileUI({ args, result, status: _status }) { render: function CreateGoogleDriveFileUI({ args, result }) {
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {

View file

@ -9,6 +9,7 @@ import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GoogleDriveAccount { interface GoogleDriveAccount {
id: number; id: number;
@ -26,6 +27,7 @@ interface GoogleDriveFile {
interface InterruptResult { interface InterruptResult {
__interrupt__: true; __interrupt__: true;
__decided__?: "approve" | "reject"; __decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{ action_requests: Array<{
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
@ -159,19 +161,17 @@ function ApprovalCard({
edited_action?: { name: string; args: Record<string, unknown> }; edited_action?: { name: string; args: Record<string, unknown> };
}) => void; }) => void;
}) { }) {
const [decided, setDecided] = useState<"approve" | "reject" | null>( const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
interruptData.__decided__ ?? null
);
const [wasAlreadyDecided] = useState(() => interruptData.__decided__ != null);
const [deleteFromKb, setDeleteFromKb] = useState(false); const [deleteFromKb, setDeleteFromKb] = useState(false);
const account = interruptData.context?.account; const context = interruptData.context;
const file = interruptData.context?.file; const account = context?.account;
const file = context?.file;
const fileLabel = file?.mime_type ? (MIME_TYPE_LABELS[file.mime_type] ?? "File") : "File"; const fileLabel = file?.mime_type ? (MIME_TYPE_LABELS[file.mime_type] ?? "File") : "File";
const handleApprove = useCallback(() => { const handleApprove = useCallback(() => {
if (decided) return; if (phase !== "pending") return;
setDecided("approve"); setProcessing();
onDecision({ onDecision({
type: "approve", type: "approve",
edited_action: { edited_action: {
@ -183,7 +183,7 @@ function ApprovalCard({
}, },
}, },
}); });
}, [decided, onDecision, interruptData, file?.file_id, account?.id, deleteFromKb]); }, [phase, setProcessing, onDecision, interruptData, file?.file_id, account?.id, deleteFromKb]);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -201,35 +201,35 @@ function ApprovalCard({
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
{decided === "reject" {phase === "rejected"
? "Google Drive File Deletion Rejected" ? "Google Drive File Deletion Rejected"
: decided === "approve" : phase === "processing" || phase === "complete"
? "Google Drive File Deletion Approved" ? "Google Drive File Deletion Approved"
: "Delete Google Drive File"} : "Delete Google Drive File"}
</p> </p>
{decided === "approve" ? ( {phase === "processing" ? (
wasAlreadyDecided ? (
<p className="text-xs text-muted-foreground mt-0.5">File trashed</p>
) : (
<TextShimmerLoader text="Trashing file" size="sm" /> <TextShimmerLoader text="Trashing file" size="sm" />
) ) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">File trashed</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
File deletion was cancelled
</p>
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject" Requires your approval to proceed
? "File deletion was cancelled"
: "Requires your approval to proceed"}
</p> </p>
)} )}
</div> </div>
</div> </div>
{/* Context — read-only file details */} {/* Context — read-only file details (visible in pending, processing, complete) */}
{!decided && interruptData.context && ( {phase !== "rejected" && context && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none"> <div className="px-5 py-4 space-y-4 select-none">
{interruptData.context.error ? ( {context.error ? (
<p className="text-sm text-destructive">{interruptData.context.error}</p> <p className="text-sm text-destructive">{context.error}</p>
) : ( ) : (
<> <>
{account && ( {account && (
@ -269,7 +269,7 @@ function ApprovalCard({
)} )}
{/* Trash warning + delete_from_kb toggle */} {/* Trash warning + delete_from_kb toggle */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-3 select-none"> <div className="px-5 py-4 space-y-3 select-none">
@ -295,7 +295,7 @@ function ApprovalCard({
)} )}
{/* Action buttons - only shown when pending */} {/* Action buttons - only shown when pending */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none"> <div className="px-5 py-4 flex items-center gap-2 select-none">
@ -312,7 +312,7 @@ function ApprovalCard({
variant="ghost" variant="ghost"
className="rounded-lg text-muted-foreground" className="rounded-lg text-muted-foreground"
onClick={() => { onClick={() => {
setDecided("reject"); setRejected();
onDecision({ type: "reject", message: "User rejected the action." }); onDecision({ type: "reject", message: "User rejected the action." });
}} }}
> >
@ -423,7 +423,7 @@ export const DeleteGoogleDriveFileToolUI = makeAssistantToolUI<
DeleteGoogleDriveFileResult DeleteGoogleDriveFileResult
>({ >({
toolName: "delete_google_drive_file", toolName: "delete_google_drive_file",
render: function DeleteGoogleDriveFileUI({ result, status: _status }) { render: function DeleteGoogleDriveFileUI({ result }) {
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
@ -431,9 +431,10 @@ export const DeleteGoogleDriveFileToolUI = makeAssistantToolUI<
<ApprovalCard <ApprovalCard
interruptData={result} interruptData={result}
onDecision={(decision) => { onDecision={(decision) => {
window.dispatchEvent( const event = new CustomEvent("hitl-decision", {
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } }) detail: { decisions: [decision] },
); });
window.dispatchEvent(event);
}} }}
/> />
); );

View file

@ -15,6 +15,7 @@ import {
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { PlateEditor } from "@/components/editor/plate-editor"; import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom"; import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
@ -66,6 +67,7 @@ interface LinearWorkspace {
interface InterruptResult { interface InterruptResult {
__interrupt__: true; __interrupt__: true;
__decided__?: "approve" | "reject" | "edit"; __decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{ action_requests: Array<{
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
@ -143,10 +145,7 @@ function ApprovalCard({
edited_action?: { name: string; args: Record<string, unknown> }; edited_action?: { name: string; args: Record<string, unknown> };
}) => void; }) => void;
}) { }) {
const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>( const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
interruptData.__decided__ ?? null
);
const [wasAlreadyDecided] = useState(() => interruptData.__decided__ != null);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom); const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const [pendingEdits, setPendingEdits] = useState<{ title: string; description: string } | null>(null); const [pendingEdits, setPendingEdits] = useState<{ title: string; description: string } | null>(null);
@ -193,10 +192,11 @@ function ApprovalCard({
}, [args.title, args.description, selectedWorkspaceId, selectedTeamId, selectedStateId, selectedAssigneeId, selectedPriority, selectedLabelIds, pendingEdits]); }, [args.title, args.description, selectedWorkspaceId, selectedTeamId, selectedStateId, selectedAssigneeId, selectedPriority, selectedLabelIds, pendingEdits]);
const handleApprove = useCallback(() => { const handleApprove = useCallback(() => {
if (decided || isPanelOpen || !canApprove) return; if (phase !== "pending") return;
if (isPanelOpen || !canApprove) return;
if (!allowedDecisions.includes("approve")) return; if (!allowedDecisions.includes("approve")) return;
const isEdited = pendingEdits !== null; const isEdited = pendingEdits !== null;
setDecided(isEdited ? "edit" : "approve"); setProcessing();
onDecision({ onDecision({
type: isEdited ? "edit" : "approve", type: isEdited ? "edit" : "approve",
edited_action: { edited_action: {
@ -204,7 +204,7 @@ function ApprovalCard({
args: buildFinalArgs(), args: buildFinalArgs(),
}, },
}); });
}, [decided, isPanelOpen, canApprove, allowedDecisions, onDecision, interruptData, buildFinalArgs, pendingEdits]); }, [phase, setProcessing, isPanelOpen, canApprove, allowedDecisions, onDecision, interruptData, buildFinalArgs, pendingEdits]);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -222,29 +222,29 @@ function ApprovalCard({
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
{decided === "reject" {phase === "rejected"
? "Linear Issue Rejected" ? "Linear Issue Rejected"
: decided === "approve" || decided === "edit" : phase === "processing" || phase === "complete"
? "Linear Issue Approved" ? "Linear Issue Approved"
: "Create Linear Issue"} : "Create Linear Issue"}
</p> </p>
{decided === "approve" || decided === "edit" ? ( {phase === "processing" ? (
wasAlreadyDecided ? ( <TextShimmerLoader text={pendingEdits ? "Creating issue with your changes" : "Creating issue"} size="sm" />
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "edit" ? "Issue created with your changes" : "Issue created"} {pendingEdits ? "Issue created with your changes" : "Issue created"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Issue creation was cancelled
</p> </p>
) : (
<TextShimmerLoader text={decided === "edit" ? "Creating issue with your changes" : "Creating issue"} size="sm" />
)
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject" Requires your approval to proceed
? "Issue creation was cancelled"
: "Requires your approval to proceed"}
</p> </p>
)} )}
</div> </div>
{!decided && canEdit && ( {phase === "pending" && canEdit && (
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@ -269,8 +269,8 @@ function ApprovalCard({
)} )}
</div> </div>
{/* Context section */} {/* Context section — real pickers in pending */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-3 space-y-3 select-none"> <div className="px-5 py-3 space-y-3 select-none">
@ -480,7 +480,7 @@ function ApprovalCard({
</div> </div>
{/* Action buttons - only shown when pending */} {/* Action buttons - only shown when pending */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-3 flex items-center gap-2 select-none"> <div className="px-5 py-3 flex items-center gap-2 select-none">
@ -502,7 +502,7 @@ function ApprovalCard({
className="rounded-lg text-muted-foreground" className="rounded-lg text-muted-foreground"
disabled={isPanelOpen} disabled={isPanelOpen}
onClick={() => { onClick={() => {
setDecided("reject"); setRejected();
onDecision({ type: "reject", message: "User rejected the action." }); onDecision({ type: "reject", message: "User rejected the action." });
}} }}
> >
@ -582,7 +582,7 @@ export const CreateLinearIssueToolUI = makeAssistantToolUI<
CreateLinearIssueResult CreateLinearIssueResult
>({ >({
toolName: "create_linear_issue", toolName: "create_linear_issue",
render: function CreateLinearIssueUI({ args, result, status: _status }) { render: function CreateLinearIssueUI({ args, result }) {
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {

View file

@ -6,10 +6,12 @@ import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface InterruptResult { interface InterruptResult {
__interrupt__: true; __interrupt__: true;
__decided__?: "approve" | "reject"; __decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{ action_requests: Array<{
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
@ -128,21 +130,15 @@ function ApprovalCard({
edited_action?: { name: string; args: Record<string, unknown> }; edited_action?: { name: string; args: Record<string, unknown> };
}) => void; }) => void;
}) { }) {
const actionArgs = interruptData.action_requests[0]?.args ?? {}; const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false);
const context = interruptData.context; const context = interruptData.context;
const issue = context?.issue; const issue = context?.issue;
const [decided, setDecided] = useState<"approve" | "reject" | null>(
interruptData.__decided__ ?? null
);
const [wasAlreadyDecided] = useState(() => interruptData.__decided__ != null);
const [deleteFromKb, setDeleteFromKb] = useState(
typeof actionArgs.delete_from_kb === "boolean" ? actionArgs.delete_from_kb : false
);
const handleApprove = useCallback(() => { const handleApprove = useCallback(() => {
if (decided) return; if (phase !== "pending") return;
setDecided("approve"); setProcessing();
onDecision({ onDecision({
type: "approve", type: "approve",
edited_action: { edited_action: {
@ -154,7 +150,7 @@ function ApprovalCard({
}, },
}, },
}); });
}, [decided, onDecision, interruptData, issue?.id, context?.workspace?.id, deleteFromKb]); }, [phase, setProcessing, onDecision, interruptData, issue?.id, context?.workspace?.id, deleteFromKb]);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -172,38 +168,38 @@ function ApprovalCard({
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
{decided === "reject" {phase === "rejected"
? "Linear Issue Deletion Rejected" ? "Linear Issue Deletion Rejected"
: decided === "approve" : phase === "processing" || phase === "complete"
? "Linear Issue Deletion Approved" ? "Linear Issue Deletion Approved"
: "Delete Linear Issue"} : "Delete Linear Issue"}
</p> </p>
{decided === "approve" ? ( {phase === "processing" ? (
wasAlreadyDecided ? (
<p className="text-xs text-muted-foreground mt-0.5">Issue deleted</p>
) : (
<TextShimmerLoader text="Deleting issue" size="sm" /> <TextShimmerLoader text="Deleting issue" size="sm" />
) ) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">Issue deleted</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Issue deletion was cancelled
</p>
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject" Requires your approval to proceed
? "Issue deletion was cancelled"
: "Requires your approval to proceed"}
</p> </p>
)} )}
</div> </div>
</div> </div>
{/* Context section — workspace + issue info (read-only) */} {/* Context section — workspace + issue info (visible in pending, processing, complete) */}
{!decided && ( {phase !== "rejected" && context && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none"> <div className="px-5 py-4 space-y-4 select-none">
{context?.error ? ( {context.error ? (
<p className="text-sm text-destructive">{context.error}</p> <p className="text-sm text-destructive">{context.error}</p>
) : ( ) : (
<> <>
{context?.workspace && ( {context.workspace && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Linear Account</p> <p className="text-xs font-medium text-muted-foreground">Linear Account</p>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm"> <div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
@ -232,7 +228,7 @@ function ApprovalCard({
)} )}
{/* delete_from_kb toggle */} {/* delete_from_kb toggle */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 select-none"> <div className="px-5 py-4 select-none">
@ -255,7 +251,7 @@ function ApprovalCard({
)} )}
{/* Action buttons - only shown when pending */} {/* Action buttons - only shown when pending */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none"> <div className="px-5 py-4 flex items-center gap-2 select-none">
@ -272,7 +268,7 @@ function ApprovalCard({
variant="ghost" variant="ghost"
className="rounded-lg text-muted-foreground" className="rounded-lg text-muted-foreground"
onClick={() => { onClick={() => {
setDecided("reject"); setRejected();
onDecision({ type: "reject", message: "User rejected the action." }); onDecision({ type: "reject", message: "User rejected the action." });
}} }}
> >
@ -371,7 +367,7 @@ export const DeleteLinearIssueToolUI = makeAssistantToolUI<
DeleteLinearIssueResult DeleteLinearIssueResult
>({ >({
toolName: "delete_linear_issue", toolName: "delete_linear_issue",
render: function DeleteLinearIssueUI({ result, status: _status }) { render: function DeleteLinearIssueUI({ result }) {
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
@ -379,9 +375,10 @@ export const DeleteLinearIssueToolUI = makeAssistantToolUI<
<ApprovalCard <ApprovalCard
interruptData={result} interruptData={result}
onDecision={(decision) => { onDecision={(decision) => {
window.dispatchEvent( const event = new CustomEvent("hitl-decision", {
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } }) detail: { decisions: [decision] },
); });
window.dispatchEvent(event);
}} }}
/> />
); );

View file

@ -16,6 +16,7 @@ import {
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { PlateEditor } from "@/components/editor/plate-editor"; import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom"; import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
interface LinearLabel { interface LinearLabel {
@ -47,6 +48,7 @@ interface LinearPriority {
interface InterruptResult { interface InterruptResult {
__interrupt__: true; __interrupt__: true;
__decided__?: "approve" | "reject" | "edit"; __decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{ action_requests: Array<{
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
@ -147,9 +149,19 @@ function isAuthErrorResult(result: unknown): result is AuthErrorResult {
} }
function ApprovalCard({ function ApprovalCard({
args,
interruptData, interruptData,
onDecision, onDecision,
}: { }: {
args: {
issue_ref: string;
new_title?: string;
new_description?: string;
new_state_name?: string;
new_assignee_email?: string;
new_priority?: number;
new_label_names?: string[];
};
interruptData: InterruptResult; interruptData: InterruptResult;
onDecision: (decision: { onDecision: (decision: {
type: "approve" | "reject" | "edit"; type: "approve" | "reject" | "edit";
@ -157,6 +169,8 @@ function ApprovalCard({
edited_action?: { name: string; args: Record<string, unknown> }; edited_action?: { name: string; args: Record<string, unknown> };
}) => void; }) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const actionArgs = interruptData.action_requests[0]?.args ?? {}; const actionArgs = interruptData.action_requests[0]?.args ?? {};
const context = interruptData.context; const context = interruptData.context;
const team = context?.team; const team = context?.team;
@ -164,10 +178,10 @@ function ApprovalCard({
const issue = context?.issue; const issue = context?.issue;
const initialEditState = { const initialEditState = {
title: actionArgs.new_title ? String(actionArgs.new_title) : (issue?.title ?? ""), title: actionArgs.new_title ? String(actionArgs.new_title) : (issue?.title ?? args.new_title ?? ""),
description: actionArgs.new_description description: actionArgs.new_description
? String(actionArgs.new_description) ? String(actionArgs.new_description)
: (issue?.description ?? ""), : (issue?.description ?? args.new_description ?? ""),
stateId: actionArgs.new_state_id stateId: actionArgs.new_state_id
? String(actionArgs.new_state_id) ? String(actionArgs.new_state_id)
: (issue?.current_state?.id ?? "__none__"), : (issue?.current_state?.id ?? "__none__"),
@ -183,10 +197,6 @@ function ApprovalCard({
: (issue?.current_labels?.map((l) => l.id) ?? []), : (issue?.current_labels?.map((l) => l.id) ?? []),
}; };
const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>(
interruptData.__decided__ ?? null
);
const [wasAlreadyDecided] = useState(() => interruptData.__decided__ != null);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
const [editedArgs, setEditedArgs] = useState(initialEditState); const [editedArgs, setEditedArgs] = useState(initialEditState);
const [hasPanelEdits, setHasPanelEdits] = useState(false); const [hasPanelEdits, setHasPanelEdits] = useState(false);
@ -246,18 +256,19 @@ function ApprovalCard({
); );
const hasProposedChanges = const hasProposedChanges =
actionArgs.new_title || actionArgs.new_title || args.new_title ||
actionArgs.new_description || actionArgs.new_description || args.new_description ||
proposedStateName || proposedStateName ||
proposedAssigneeName || proposedAssigneeName ||
proposedPriorityLabel || proposedPriorityLabel ||
proposedLabelObjects.length > 0; proposedLabelObjects.length > 0;
const handleApprove = useCallback(() => { const handleApprove = useCallback(() => {
if (decided || isPanelOpen) return; if (phase !== "pending") return;
if (isPanelOpen) return;
if (!allowedDecisions.includes("approve")) return; if (!allowedDecisions.includes("approve")) return;
const isEdited = hasPanelEdits; const isEdited = hasPanelEdits;
setDecided(isEdited ? "edit" : "approve"); setProcessing();
onDecision({ onDecision({
type: isEdited ? "edit" : "approve", type: isEdited ? "edit" : "approve",
edited_action: { edited_action: {
@ -265,7 +276,7 @@ function ApprovalCard({
args: buildFinalArgs(), args: buildFinalArgs(),
}, },
}); });
}, [decided, isPanelOpen, allowedDecisions, onDecision, interruptData, buildFinalArgs, hasPanelEdits]); }, [phase, setProcessing, isPanelOpen, allowedDecisions, onDecision, interruptData, buildFinalArgs, hasPanelEdits]);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -283,29 +294,29 @@ function ApprovalCard({
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
{decided === "reject" {phase === "rejected"
? "Linear Issue Update Rejected" ? "Linear Issue Update Rejected"
: decided === "approve" || decided === "edit" : phase === "processing" || phase === "complete"
? "Linear Issue Update Approved" ? "Linear Issue Update Approved"
: "Update Linear Issue"} : "Update Linear Issue"}
</p> </p>
{decided === "approve" || decided === "edit" ? ( {phase === "processing" ? (
wasAlreadyDecided ? ( <TextShimmerLoader text={hasPanelEdits ? "Updating issue with your changes" : "Updating issue"} size="sm" />
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "edit" ? "Issue updated with your changes" : "Issue updated"} {hasPanelEdits ? "Issue updated with your changes" : "Issue updated"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Issue update was cancelled
</p> </p>
) : (
<TextShimmerLoader text={decided === "edit" ? "Updating issue with your changes" : "Updating issue"} size="sm" />
)
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject" Requires your approval to proceed
? "Issue update was cancelled"
: "Requires your approval to proceed"}
</p> </p>
)} )}
</div> </div>
{!decided && canEdit && ( {phase === "pending" && canEdit && (
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@ -335,8 +346,8 @@ function ApprovalCard({
)} )}
</div> </div>
{/* Context section — workspace + current issue (read-only) */} {/* Context section — workspace + current issue + pickers in pending */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none"> <div className="px-5 py-4 space-y-4 select-none">
@ -407,7 +418,6 @@ function ApprovalCard({
</div> </div>
)} )}
{/* Editable context selects for state, assignee, priority, labels */}
{team && ( {team && (
<> <>
<div className="space-y-2"> <div className="space-y-2">
@ -530,10 +540,10 @@ function ApprovalCard({
<div className="px-5 pt-3"> <div className="px-5 pt-3">
{(hasProposedChanges || hasPanelEdits) ? ( {(hasProposedChanges || hasPanelEdits) ? (
<> <>
{(hasPanelEdits ? editedArgs.title : actionArgs.new_title) && ( {(hasPanelEdits ? editedArgs.title : (actionArgs.new_title ?? args.new_title)) && (
<p className="text-sm font-medium text-foreground">{String(hasPanelEdits ? editedArgs.title : actionArgs.new_title)}</p> <p className="text-sm font-medium text-foreground">{String(hasPanelEdits ? editedArgs.title : (actionArgs.new_title ?? args.new_title))}</p>
)} )}
{(hasPanelEdits ? editedArgs.description : actionArgs.new_description) && ( {(hasPanelEdits ? editedArgs.description : (actionArgs.new_description ?? args.new_description)) && (
<div <div
className="max-h-[7rem] overflow-hidden text-sm" className="max-h-[7rem] overflow-hidden text-sm"
style={{ style={{
@ -542,7 +552,7 @@ function ApprovalCard({
}} }}
> >
<PlateEditor <PlateEditor
markdown={String(hasPanelEdits ? editedArgs.description : actionArgs.new_description)} markdown={String(hasPanelEdits ? editedArgs.description : (actionArgs.new_description ?? args.new_description))}
readOnly readOnly
preset="readonly" preset="readonly"
editorVariant="none" editorVariant="none"
@ -591,7 +601,7 @@ function ApprovalCard({
</div> </div>
{/* Action buttons - only shown when pending */} {/* Action buttons - only shown when pending */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none"> <div className="px-5 py-4 flex items-center gap-2 select-none">
@ -613,7 +623,7 @@ function ApprovalCard({
className="rounded-lg text-muted-foreground" className="rounded-lg text-muted-foreground"
disabled={isPanelOpen} disabled={isPanelOpen}
onClick={() => { onClick={() => {
setDecided("reject"); setRejected();
onDecision({ type: "reject", message: "User rejected the action." }); onDecision({ type: "reject", message: "User rejected the action." });
}} }}
> >
@ -717,12 +727,13 @@ export const UpdateLinearIssueToolUI = makeAssistantToolUI<
UpdateLinearIssueResult UpdateLinearIssueResult
>({ >({
toolName: "update_linear_issue", toolName: "update_linear_issue",
render: function UpdateLinearIssueUI({ result, status: _status }) { render: function UpdateLinearIssueUI({ args, result }) {
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
args={args}
interruptData={result} interruptData={result}
onDecision={(decision) => { onDecision={(decision) => {
window.dispatchEvent( window.dispatchEvent(

View file

@ -15,10 +15,12 @@ import {
import { PlateEditor } from "@/components/editor/plate-editor"; import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom"; import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface InterruptResult { interface InterruptResult {
__interrupt__: true; __interrupt__: true;
__decided__?: "approve" | "reject" | "edit"; __decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{ action_requests: Array<{
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
@ -115,10 +117,7 @@ function ApprovalCard({
edited_action?: { name: string; args: Record<string, unknown> }; edited_action?: { name: string; args: Record<string, unknown> };
}) => void; }) => void;
}) { }) {
const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>( const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
interruptData.__decided__ ?? null
);
const [wasAlreadyDecided] = useState(() => interruptData.__decided__ != null);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom); const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const [pendingEdits, setPendingEdits] = useState<{ title: string; content: string } | null>(null); const [pendingEdits, setPendingEdits] = useState<{ title: string; content: string } | null>(null);
@ -154,10 +153,11 @@ function ApprovalCard({
const canEdit = allowedDecisions.includes("edit"); const canEdit = allowedDecisions.includes("edit");
const handleApprove = useCallback(() => { const handleApprove = useCallback(() => {
if (decided || isPanelOpen || !selectedAccountId || !isTitleValid) return; if (phase !== "pending") return;
if (isPanelOpen || !selectedAccountId || !isTitleValid) return;
if (!allowedDecisions.includes("approve")) return; if (!allowedDecisions.includes("approve")) return;
const isEdited = pendingEdits !== null; const isEdited = pendingEdits !== null;
setDecided(isEdited ? "edit" : "approve"); setProcessing();
onDecision({ onDecision({
type: isEdited ? "edit" : "approve", type: isEdited ? "edit" : "approve",
edited_action: { edited_action: {
@ -171,7 +171,7 @@ function ApprovalCard({
}, },
}, },
}); });
}, [decided, isPanelOpen, selectedAccountId, isTitleValid, allowedDecisions, onDecision, interruptData, args, selectedParentPageId, pendingEdits]); }, [phase, isPanelOpen, selectedAccountId, isTitleValid, allowedDecisions, setProcessing, onDecision, interruptData, args, selectedParentPageId, pendingEdits]);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -191,29 +191,29 @@ function ApprovalCard({
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
{decided === "reject" {phase === "rejected"
? "Notion Page Rejected" ? "Notion Page Rejected"
: decided === "approve" || decided === "edit" : phase === "processing" || phase === "complete"
? "Notion Page Approved" ? "Notion Page Approved"
: "Create Notion Page"} : "Create Notion Page"}
</p> </p>
{decided === "approve" || decided === "edit" ? ( {phase === "processing" ? (
wasAlreadyDecided ? ( <TextShimmerLoader text={pendingEdits ? "Creating page with your changes" : "Creating page"} size="sm" />
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "edit" ? "Page created with your changes" : "Page created"} {pendingEdits ? "Page created with your changes" : "Page created"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Page creation was cancelled
</p> </p>
) : (
<TextShimmerLoader text={decided === "edit" ? "Creating page with your changes" : "Creating page"} size="sm" />
)
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject" Requires your approval to proceed
? "Page creation was cancelled"
: "Requires your approval to proceed"}
</p> </p>
)} )}
</div> </div>
{!decided && canEdit && ( {phase === "pending" && canEdit && (
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@ -238,8 +238,8 @@ function ApprovalCard({
)} )}
</div> </div>
{/* Context section */} {/* Account/workspace picker — real UI in pending */}
{!decided && interruptData.context && ( {phase === "pending" && interruptData.context && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none"> <div className="px-5 py-4 space-y-4 select-none">
@ -338,7 +338,7 @@ function ApprovalCard({
</div> </div>
{/* Action buttons - only shown when pending */} {/* Action buttons - only shown when pending */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none"> <div className="px-5 py-4 flex items-center gap-2 select-none">
@ -360,7 +360,7 @@ function ApprovalCard({
className="rounded-lg text-muted-foreground" className="rounded-lg text-muted-foreground"
disabled={isPanelOpen} disabled={isPanelOpen}
onClick={() => { onClick={() => {
setDecided("reject"); setRejected();
onDecision({ type: "reject", message: "User rejected the action." }); onDecision({ type: "reject", message: "User rejected the action." });
}} }}
> >
@ -441,9 +441,7 @@ export const CreateNotionPageToolUI = makeAssistantToolUI<
>({ >({
toolName: "create_notion_page", toolName: "create_notion_page",
render: function CreateNotionPageUI({ args, result }) { render: function CreateNotionPageUI({ args, result }) {
if (!result) { if (!result) return null;
return null;
}
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (

View file

@ -6,10 +6,12 @@ import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface InterruptResult { interface InterruptResult {
__interrupt__: true; __interrupt__: true;
__decided__?: "approve" | "reject"; __decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{ action_requests: Array<{
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
@ -136,18 +138,16 @@ function ApprovalCard({
edited_action?: { name: string; args: Record<string, unknown> }; edited_action?: { name: string; args: Record<string, unknown> };
}) => void; }) => void;
}) { }) {
const [decided, setDecided] = useState<"approve" | "reject" | null>( const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
interruptData.__decided__ ?? null
);
const [wasAlreadyDecided] = useState(() => interruptData.__decided__ != null);
const [deleteFromKb, setDeleteFromKb] = useState(false); const [deleteFromKb, setDeleteFromKb] = useState(false);
const account = interruptData.context?.account; const context = interruptData.context;
const currentTitle = interruptData.context?.current_title; const account = context?.account;
const currentTitle = context?.current_title;
const handleApprove = useCallback(() => { const handleApprove = useCallback(() => {
if (decided) return; if (phase !== "pending") return;
setDecided("approve"); setProcessing();
onDecision({ onDecision({
type: "approve", type: "approve",
edited_action: { edited_action: {
@ -159,7 +159,7 @@ function ApprovalCard({
}, },
}, },
}); });
}, [decided, onDecision, interruptData, account?.id, deleteFromKb]); }, [phase, setProcessing, onDecision, interruptData, account?.id, deleteFromKb]);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -177,35 +177,35 @@ function ApprovalCard({
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
{decided === "reject" {phase === "rejected"
? "Notion Page Deletion Rejected" ? "Notion Page Deletion Rejected"
: decided === "approve" : phase === "processing" || phase === "complete"
? "Notion Page Deletion Approved" ? "Notion Page Deletion Approved"
: "Delete Notion Page"} : "Delete Notion Page"}
</p> </p>
{decided === "approve" ? ( {phase === "processing" ? (
wasAlreadyDecided ? (
<p className="text-xs text-muted-foreground mt-0.5">Page deleted</p>
) : (
<TextShimmerLoader text="Deleting page" size="sm" /> <TextShimmerLoader text="Deleting page" size="sm" />
) ) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">Page deleted</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Page deletion was cancelled
</p>
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject" Requires your approval to proceed
? "Page deletion was cancelled"
: "Requires your approval to proceed"}
</p> </p>
)} )}
</div> </div>
</div> </div>
{/* Context section — read-only account and page info */} {/* Context section — read-only account and page info */}
{!decided && interruptData.context && ( {phase !== "rejected" && context && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none"> <div className="px-5 py-4 space-y-4 select-none">
{interruptData.context.error ? ( {context.error ? (
<p className="text-sm text-destructive">{interruptData.context.error}</p> <p className="text-sm text-destructive">{context.error}</p>
) : ( ) : (
<> <>
{account && ( {account && (
@ -232,7 +232,7 @@ function ApprovalCard({
)} )}
{/* delete_from_kb toggle */} {/* delete_from_kb toggle */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 select-none"> <div className="px-5 py-4 select-none">
@ -254,8 +254,8 @@ function ApprovalCard({
</> </>
)} )}
{/* Action buttons - only shown when pending */} {/* Action buttons */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none"> <div className="px-5 py-4 flex items-center gap-2 select-none">
@ -272,7 +272,7 @@ function ApprovalCard({
variant="ghost" variant="ghost"
className="rounded-lg text-muted-foreground" className="rounded-lg text-muted-foreground"
onClick={() => { onClick={() => {
setDecided("reject"); setRejected();
onDecision({ type: "reject", message: "User rejected the action." }); onDecision({ type: "reject", message: "User rejected the action." });
}} }}
> >
@ -388,9 +388,7 @@ export const DeleteNotionPageToolUI = makeAssistantToolUI<
>({ >({
toolName: "delete_notion_page", toolName: "delete_notion_page",
render: function DeleteNotionPageUI({ result }) { render: function DeleteNotionPageUI({ result }) {
if (!result) { if (!result) return null;
return null;
}
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (

View file

@ -8,10 +8,12 @@ import { Button } from "@/components/ui/button";
import { PlateEditor } from "@/components/editor/plate-editor"; import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom"; import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface InterruptResult { interface InterruptResult {
__interrupt__: true; __interrupt__: true;
__decided__?: "approve" | "reject" | "edit"; __decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{ action_requests: Array<{
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
@ -117,10 +119,7 @@ function ApprovalCard({
edited_action?: { name: string; args: Record<string, unknown> }; edited_action?: { name: string; args: Record<string, unknown> };
}) => void; }) => void;
}) { }) {
const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>( const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
interruptData.__decided__ ?? null
);
const [wasAlreadyDecided] = useState(() => interruptData.__decided__ != null);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom); const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const [pendingEdits, setPendingEdits] = useState<{ content: string } | null>(null); const [pendingEdits, setPendingEdits] = useState<{ content: string } | null>(null);
@ -133,10 +132,11 @@ function ApprovalCard({
const canEdit = allowedDecisions.includes("edit"); const canEdit = allowedDecisions.includes("edit");
const handleApprove = useCallback(() => { const handleApprove = useCallback(() => {
if (decided || isPanelOpen) return; if (phase !== "pending") return;
if (isPanelOpen) return;
if (!allowedDecisions.includes("approve")) return; if (!allowedDecisions.includes("approve")) return;
const isEdited = pendingEdits !== null; const isEdited = pendingEdits !== null;
setDecided(isEdited ? "edit" : "approve"); setProcessing();
onDecision({ onDecision({
type: isEdited ? "edit" : "approve", type: isEdited ? "edit" : "approve",
edited_action: { edited_action: {
@ -148,7 +148,7 @@ function ApprovalCard({
}, },
}, },
}); });
}, [decided, isPanelOpen, allowedDecisions, onDecision, interruptData, args, account?.id, pendingEdits]); }, [phase, isPanelOpen, allowedDecisions, setProcessing, onDecision, interruptData, args, account?.id, pendingEdits]);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -166,29 +166,29 @@ function ApprovalCard({
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
{decided === "reject" {phase === "rejected"
? "Notion Page Update Rejected" ? "Notion Page Update Rejected"
: decided === "approve" || decided === "edit" : phase === "processing" || phase === "complete"
? "Notion Page Update Approved" ? "Notion Page Update Approved"
: "Update Notion Page"} : "Update Notion Page"}
</p> </p>
{decided === "approve" || decided === "edit" ? ( {phase === "processing" ? (
wasAlreadyDecided ? ( <TextShimmerLoader text={pendingEdits ? "Updating page with your changes" : "Updating page"} size="sm" />
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "edit" ? "Page updated with your changes" : "Page updated"} {pendingEdits ? "Page updated with your changes" : "Page updated"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Page update was cancelled
</p> </p>
) : (
<TextShimmerLoader text={decided === "edit" ? "Updating page with your changes" : "Updating page"} size="sm" />
)
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject" Requires your approval to proceed
? "Page update was cancelled"
: "Requires your approval to proceed"}
</p> </p>
)} )}
</div> </div>
{!decided && canEdit && ( {phase === "pending" && canEdit && (
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@ -213,8 +213,8 @@ function ApprovalCard({
)} )}
</div> </div>
{/* Context section — read-only account and page info */} {/* Context section — real UI in pending/processing/complete */}
{!decided && interruptData.context && ( {phase !== "rejected" && interruptData.context && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none"> <div className="px-5 py-4 space-y-4 select-none">
@ -270,7 +270,7 @@ function ApprovalCard({
</div> </div>
{/* Action buttons - only shown when pending */} {/* Action buttons - only shown when pending */}
{!decided && ( {phase === "pending" && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none"> <div className="px-5 py-4 flex items-center gap-2 select-none">
@ -292,7 +292,7 @@ function ApprovalCard({
className="rounded-lg text-muted-foreground" className="rounded-lg text-muted-foreground"
disabled={isPanelOpen} disabled={isPanelOpen}
onClick={() => { onClick={() => {
setDecided("reject"); setRejected();
onDecision({ type: "reject", message: "User rejected the action." }); onDecision({ type: "reject", message: "User rejected the action." });
}} }}
> >
@ -389,9 +389,7 @@ export const UpdateNotionPageToolUI = makeAssistantToolUI<
>({ >({
toolName: "update_notion_page", toolName: "update_notion_page",
render: function UpdateNotionPageUI({ args, result }) { render: function UpdateNotionPageUI({ args, result }) {
if (!result) { if (!result) return null;
return null;
}
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (

View file

@ -15,7 +15,7 @@ const buttonVariants = cva(
outline: outline:
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground focus-visible:ring-0 focus-visible:ring-offset-0",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {

View file

@ -27,7 +27,7 @@ function SelectTrigger({
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
data-slot="select-trigger" data-slot="select-trigger"
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex h-9 w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex h-9 w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}

View file

@ -0,0 +1,62 @@
import { useEffect, useRef, useState } from "react";
export type HitlPhase = "pending" | "processing" | "complete" | "rejected";
interface HitlInterruptLike {
__decided__?: string | null;
__completed__?: boolean;
}
const MINIMUM_SHIMMER_MS = 500;
const FALLBACK_TIMEOUT_MS = 30_000;
/**
* State machine for HITL approval card phases.
*
* Phases:
* pending waiting for user decision (show buttons)
* processing user approved/edited, waiting for backend (shimmer)
* complete backend responded with __completed__ (done text)
* rejected user rejected (cancelled text)
*/
export function useHitlPhase(interruptData: HitlInterruptLike): {
phase: HitlPhase;
setProcessing: () => void;
setRejected: () => void;
} {
const [phase, setPhase] = useState<HitlPhase>(() => {
if (interruptData.__decided__ === "reject") return "rejected";
if (interruptData.__decided__) return "complete";
return "pending";
});
const shimmerStartRef = useRef<number | null>(null);
// processing → complete when __completed__ arrives (with min shimmer duration)
useEffect(() => {
if (phase !== "processing") return;
if (!interruptData.__completed__) return;
const elapsed = shimmerStartRef.current ? Date.now() - shimmerStartRef.current : Infinity;
const remaining = Math.max(0, MINIMUM_SHIMMER_MS - elapsed);
const timer = setTimeout(() => setPhase("complete"), remaining);
return () => clearTimeout(timer);
}, [phase, interruptData.__completed__]);
// Fallback: processing → complete after 30s even if __completed__ never arrives
useEffect(() => {
if (phase !== "processing") return;
const fallback = setTimeout(() => setPhase("complete"), FALLBACK_TIMEOUT_MS);
return () => clearTimeout(fallback);
}, [phase]);
return {
phase,
setProcessing: () => {
shimmerStartRef.current = Date.now();
setPhase("processing");
},
setRejected: () => setPhase("rejected"),
};
}