Work with your agent
@@ -182,6 +245,7 @@ export function McpInstallCard(props: { className?: string }) {
+ {advancedControls &&
{advancedControls}
}
{agentLogos}
diff --git a/packages/react/src/pages/resume-approval.tsx b/packages/react/src/pages/resume-approval.tsx
new file mode 100644
index 000000000..d4428b924
--- /dev/null
+++ b/packages/react/src/pages/resume-approval.tsx
@@ -0,0 +1,376 @@
+import { useAtomSet, useAtomValue } from "@effect/atom-react";
+import { Option, Schema } from "effect";
+import * as Exit from "effect/Exit";
+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
+import { Check, ExternalLink, Loader2, ShieldCheck, X } from "lucide-react";
+import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
+
+import { pausedExecutionAtom, resumeExecution } from "../api/atoms";
+import { Button } from "../components/button";
+import { CopyButton } from "../components/copy-button";
+import { type ElicitationAction, useElicitationApproval } from "../components/elicitation-approval";
+import { Skeleton } from "../components/skeleton";
+
+type PausedExecutionInfo = { readonly text: string; readonly structured: unknown };
+type ResumeExecutionResult =
+ | {
+ readonly status: "completed";
+ readonly text: string;
+ readonly structured: unknown;
+ readonly isError: boolean;
+ }
+ | {
+ readonly status: "paused";
+ readonly text: string;
+ readonly structured: unknown;
+ };
+
+type ResumeStatus =
+ | { readonly state: "idle" }
+ | { readonly state: "submitting"; readonly action: ElicitationAction }
+ | { readonly state: "done"; readonly action: ElicitationAction; readonly text: string }
+ | { readonly state: "failed"; readonly message: string };
+
+const actionLabel: Record
= {
+ accept: "Approve",
+ decline: "Decline",
+ cancel: "Cancel",
+};
+
+const returnPrompt: Record = {
+ accept: "I've approved it",
+ decline: "I've denied it",
+ cancel: "I've canceled it",
+};
+
+type PausedInteractionView = {
+ readonly kind: string | null;
+ readonly message: string;
+ readonly title: string;
+ readonly args: unknown;
+ readonly url: string | null;
+ readonly requestedSchema: unknown;
+ readonly toolId: string | null;
+};
+
+const encodeJsonPreview = Schema.encodeUnknownOption(Schema.UnknownFromJsonString);
+const decodeJsonPreview = Schema.decodeUnknownOption(Schema.UnknownFromJsonString);
+const PausedInteractionInfo = Schema.Struct({
+ kind: Schema.optional(Schema.String),
+ message: Schema.optional(Schema.String),
+ args: Schema.optional(Schema.Unknown),
+ url: Schema.optional(Schema.String),
+ requestedSchema: Schema.optional(Schema.Unknown),
+ toolId: Schema.optional(Schema.String),
+});
+const PausedStructured = Schema.Struct({
+ executionId: Schema.optional(Schema.String),
+ interaction: Schema.optional(PausedInteractionInfo),
+});
+const decodePausedStructured = Schema.decodeUnknownOption(PausedStructured);
+
+const failureMessage = (exit: Exit.Exit): string => {
+ if (Exit.isSuccess(exit)) return "Resume failed.";
+ return "The paused execution could not be resumed. It may have already completed or expired.";
+};
+
+const requestedSchemaFromPausedInfo = (paused: PausedExecutionInfo | null): unknown =>
+ paused ? interactionFromPausedInfo(paused)?.requestedSchema : undefined;
+
+const safeJson = (value: unknown): string | null => Option.getOrNull(encodeJsonPreview(value));
+
+const parseArgumentsFromMessage = (message: string): unknown => {
+ const marker = "\n\nArguments:\n";
+ const index = message.indexOf(marker);
+ if (index === -1) return undefined;
+ const raw = message.slice(index + marker.length).trim();
+ return Option.getOrUndefined(decodeJsonPreview(raw));
+};
+
+const messageTitle = (message: string): string => {
+ const marker = "\n\nArguments:\n";
+ const first = (message.includes(marker) ? message.slice(0, message.indexOf(marker)) : message)
+ .trim()
+ .split("\n")
+ .find((line) => line.trim().length > 0);
+ return first?.trim() || "Paused tool call";
+};
+
+const interactionFromPausedInfo = (paused: PausedExecutionInfo): PausedInteractionView | null => {
+ const structured = Option.getOrNull(decodePausedStructured(paused.structured));
+ const interaction = structured?.interaction;
+ if (!interaction) return null;
+ const message = interaction.message ?? paused.text;
+ const args = interaction.args ?? parseArgumentsFromMessage(message);
+ return {
+ kind: interaction.kind ?? null,
+ message,
+ title: messageTitle(message),
+ args,
+ url: interaction.url ?? null,
+ requestedSchema: interaction.requestedSchema,
+ toolId: interaction.toolId ?? null,
+ };
+};
+
+const executionIdFromStructured = (structured: unknown): string | null =>
+ Option.getOrNull(decodePausedStructured(structured))?.executionId ?? null;
+
+export function ResumeApprovalPage(props: { executionId: string }) {
+ const paused = useAtomValue(pausedExecutionAtom(props.executionId));
+ const doResume = useAtomSet(resumeExecution, { mode: "promiseExit" });
+
+ const resume = useCallback(
+ (executionId: string, action: ElicitationAction, content?: Record) =>
+ doResume({
+ params: { executionId },
+ payload: action === "accept" ? { action, content: content ?? {} } : { action },
+ }),
+ [doResume],
+ );
+
+ return ;
+}
+
+export function ResumeApprovalPageView(props: {
+ executionId: string;
+ paused: AsyncResult.AsyncResult;
+ resume: (
+ executionId: string,
+ action: ElicitationAction,
+ content?: Record,
+ ) => Promise>;
+ unavailableMessage?: string;
+}) {
+ const { executionId, paused, resume, unavailableMessage } = props;
+ const [status, setStatus] = useState({ state: "idle" });
+ const [currentExecutionId, setCurrentExecutionId] = useState(executionId);
+ const [nextPaused, setNextPaused] = useState(null);
+ const displayedPaused = nextPaused ?? (AsyncResult.isSuccess(paused) ? paused.value : null);
+ const approval = useElicitationApproval(requestedSchemaFromPausedInfo(displayedPaused));
+ const interaction = displayedPaused ? interactionFromPausedInfo(displayedPaused) : null;
+
+ useEffect(() => {
+ setCurrentExecutionId(executionId);
+ setNextPaused(null);
+ setStatus({ state: "idle" });
+ }, [executionId]);
+
+ const shortExecutionId = useMemo(
+ () =>
+ currentExecutionId.length > 24
+ ? `${currentExecutionId.slice(0, 12)}...${currentExecutionId.slice(-6)}`
+ : currentExecutionId,
+ [currentExecutionId],
+ );
+
+ const submit = useCallback(
+ async (action: ElicitationAction) => {
+ const content = action === "accept" ? approval.content() : undefined;
+ if (content === null) return;
+
+ setStatus({ state: "submitting", action });
+ const exit = await resume(currentExecutionId, action, content);
+
+ if (Exit.isFailure(exit)) {
+ setStatus({ state: "failed", message: failureMessage(exit) });
+ return;
+ }
+
+ if (exit.value.status === "paused") {
+ const nextExecutionId = executionIdFromStructured(exit.value.structured);
+ if (!nextExecutionId) {
+ setStatus({
+ state: "failed",
+ message: "The next paused execution did not include an id.",
+ });
+ return;
+ }
+
+ setCurrentExecutionId(nextExecutionId);
+ setNextPaused({ text: exit.value.text, structured: exit.value.structured });
+ setStatus({ state: "idle" });
+ return;
+ }
+
+ setStatus({
+ state: "done",
+ action,
+ text: exit.value.text || "The paused execution has been resumed.",
+ });
+ },
+ [approval, currentExecutionId, resume],
+ );
+
+ const busy = status.state === "submitting";
+ const done = status.state === "done";
+ const canSubmit = Boolean(displayedPaused) && !busy && !done;
+
+ return (
+
+
+
+
+
+
+
+
+ User approval required
+
+
Resume execution
+
+ A paused tool call is waiting for your decision before it can continue.
+
+
+
+
+
+ {nextPaused ? (
+
+ ) : (
+ AsyncResult.match(paused, {
+ onInitial: () => (
+
+
+
+
+
+ ),
+ onFailure: () => (
+
+ {unavailableMessage ??
+ "This paused execution is no longer available. It may have already been resumed, or the local daemon may have restarted."}
+
+ ),
+ onSuccess: () => (
+
+ ),
+ })
+ )}
+
+
+ {status.state === "failed" && (
+
+ {status.message}
+
+ )}
+
+ {done && (
+
+
+ {actionLabel[status.action]} sent
+
+
+ Return to your agent and let it continue.
+
+
+ )}
+
+
+
+ Execution
+ {shortExecutionId}
+
+ {done ? (
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+
+ );
+}
+
+function PendingRequestDetails({
+ interaction,
+ approvalFields,
+}: {
+ interaction: PausedInteractionView | null;
+ approvalFields: ReactNode;
+}) {
+ if (!interaction) {
+ return No pending request details found.
;
+ }
+
+ const argsJson = interaction.args === undefined ? null : safeJson(interaction.args);
+
+ return (
+
+
+
+ Pending request
+
+
+ {interaction.title}
+
+ {interaction.toolId && (
+
{interaction.toolId}
+ )}
+
+
+ {interaction.url && (
+
+ )}
+
+ {argsJson && (
+
+
+ Arguments
+
+
+ {argsJson}
+
+
+ )}
+
+ {approvalFields && (
+
{approvalFields}
+ )}
+
+ );
+}