diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index a3a9d4260..90f4fb889 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -107,6 +107,25 @@ import { trackChatResponseReceived, } 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).__interrupt__ === true && + (part.result as Record).__decided__ && + !(part.result as Record).__completed__ + ) { + part.result = { ...(part.result as Record), __completed__: true }; + } + } +} + /** * Extract thinking steps from message content */ @@ -729,6 +748,7 @@ export default function NewChatPage() { case "tool-output-available": { // Update the tool call with its result updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output }); + markInterruptsCompleted(contentParts); // Handle podcast-specific logic if (parsed.output?.status === "pending" && parsed.output?.podcast_id) { // 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, { result: parsed.output, }); + markInterruptsCompleted(contentParts); setMessages((prev) => prev.map((m) => m.id === assistantMsgId @@ -1458,6 +1479,7 @@ export default function NewChatPage() { case "tool-output-available": updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output }); + markInterruptsCompleted(contentParts); if (parsed.output?.status === "pending" && parsed.output?.podcast_id) { const idx = toolCallIndices.get(parsed.toolCallId); if (idx !== undefined) { diff --git a/surfsense_web/components/tool-ui/gmail/create-draft.tsx b/surfsense_web/components/tool-ui/gmail/create-draft.tsx index 611824672..64ac14789 100644 --- a/surfsense_web/components/tool-ui/gmail/create-draft.tsx +++ b/surfsense_web/components/tool-ui/gmail/create-draft.tsx @@ -21,6 +21,7 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { useSetAtom } from "jotai"; import { openHitlEditPanelAtom } 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 { id: number; @@ -32,6 +33,7 @@ interface GmailAccount { interface InterruptResult { __interrupt__: true; __decided__?: "approve" | "reject" | "edit"; + __completed__?: boolean; action_requests: Array<{ name: string; args: Record; @@ -126,10 +128,7 @@ function ApprovalCard({ edited_action?: { name: string; args: Record }; }) => void; }) { - const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>( - interruptData.__decided__ ?? null - ); - const [wasAlreadyDecided] = useState(() => interruptData.__decided__ != null); + const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const [isPanelOpen, setIsPanelOpen] = useState(false); const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom); const [pendingEdits, setPendingEdits] = useState<{ @@ -149,15 +148,16 @@ function ApprovalCard({ const canApprove = !!selectedAccountId; - const reviewConfig = interruptData.review_configs[0]; + const reviewConfig = interruptData.review_configs?.[0]; const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"]; const canEdit = allowedDecisions.includes("edit"); const handleApprove = useCallback(() => { - if (decided || isPanelOpen || !canApprove) return; + if (phase !== "pending") return; + if (isPanelOpen || !canApprove) return; if (!allowedDecisions.includes("approve")) return; const isEdited = pendingEdits !== null; - setDecided(isEdited ? "edit" : "approve"); + setProcessing(); onDecision({ type: isEdited ? "edit" : "approve", 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(() => { const handler = (e: KeyboardEvent) => { @@ -194,30 +194,30 @@ function ApprovalCard({

- {decided === "reject" + {phase === "rejected" ? "Gmail Draft Rejected" - : decided === "approve" || decided === "edit" + : phase === "processing" || phase === "complete" ? "Gmail Draft Approved" : "Create Gmail Draft"}

- {decided === "approve" || decided === "edit" ? ( - wasAlreadyDecided ? ( -

- {decided === "edit" ? "Draft created with your changes" : "Draft created"} -

- ) : ( - - ) + {phase === "processing" ? ( + + ) : phase === "complete" ? ( +

+ {pendingEdits ? "Draft created with your changes" : "Draft created"} +

+ ) : phase === "rejected" ? ( +

+ Draft creation was cancelled +

) : (

- {decided === "reject" - ? "Draft creation was cancelled" - : "Requires your approval to proceed"} + Requires your approval to proceed

)}
- {!decided && canEdit && ( + {phase === "pending" && canEdit && ( - - +
+ + +
)} @@ -388,9 +388,7 @@ export const DeleteNotionPageToolUI = makeAssistantToolUI< >({ toolName: "delete_notion_page", render: function DeleteNotionPageUI({ result }) { - if (!result) { - return null; - } + if (!result) return null; if (isInterruptResult(result)) { return ( diff --git a/surfsense_web/components/tool-ui/notion/update-notion-page.tsx b/surfsense_web/components/tool-ui/notion/update-notion-page.tsx index 4e12c6953..c5a5581a1 100644 --- a/surfsense_web/components/tool-ui/notion/update-notion-page.tsx +++ b/surfsense_web/components/tool-ui/notion/update-notion-page.tsx @@ -8,10 +8,12 @@ import { Button } from "@/components/ui/button"; import { PlateEditor } from "@/components/editor/plate-editor"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom"; +import { useHitlPhase } from "@/hooks/use-hitl-phase"; interface InterruptResult { __interrupt__: true; __decided__?: "approve" | "reject" | "edit"; + __completed__?: boolean; action_requests: Array<{ name: string; args: Record; @@ -117,10 +119,7 @@ function ApprovalCard({ edited_action?: { name: string; args: Record }; }) => void; }) { - const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>( - interruptData.__decided__ ?? null - ); - const [wasAlreadyDecided] = useState(() => interruptData.__decided__ != null); + const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const [isPanelOpen, setIsPanelOpen] = useState(false); const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom); const [pendingEdits, setPendingEdits] = useState<{ content: string } | null>(null); @@ -133,10 +132,11 @@ function ApprovalCard({ const canEdit = allowedDecisions.includes("edit"); const handleApprove = useCallback(() => { - if (decided || isPanelOpen) return; + if (phase !== "pending") return; + if (isPanelOpen) return; if (!allowedDecisions.includes("approve")) return; const isEdited = pendingEdits !== null; - setDecided(isEdited ? "edit" : "approve"); + setProcessing(); onDecision({ type: isEdited ? "edit" : "approve", 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(() => { const handler = (e: KeyboardEvent) => { @@ -166,29 +166,29 @@ function ApprovalCard({

- {decided === "reject" + {phase === "rejected" ? "Notion Page Update Rejected" - : decided === "approve" || decided === "edit" + : phase === "processing" || phase === "complete" ? "Notion Page Update Approved" : "Update Notion Page"}

- {decided === "approve" || decided === "edit" ? ( - wasAlreadyDecided ? ( -

- {decided === "edit" ? "Page updated with your changes" : "Page updated"} -

- ) : ( - - ) + {phase === "processing" ? ( + + ) : phase === "complete" ? ( +

+ {pendingEdits ? "Page updated with your changes" : "Page updated"} +

+ ) : phase === "rejected" ? ( +

+ Page update was cancelled +

) : (

- {decided === "reject" - ? "Page update was cancelled" - : "Requires your approval to proceed"} + Requires your approval to proceed

)}
- {!decided && canEdit && ( + {phase === "pending" && canEdit && (