branch:
ApprovalDemo.tsx
15765 bytesRaw
import { useAgent } from "agents/react";
import { useState } from "react";
import {
  ClockIcon,
  CheckCircleIcon,
  XCircleIcon,
  PaperPlaneTiltIcon,
  TrashIcon,
  WarningCircleIcon,
  ArrowsClockwiseIcon
} from "@phosphor-icons/react";
import {
  Button,
  Input,
  InputArea,
  Surface,
  Empty,
  Text
} from "@cloudflare/kumo";
import { DemoWrapper } from "../../layout";
import {
  LogPanel,
  ConnectionStatus,
  CodeExplanation,
  type CodeSection
} from "../../components";
import { useLogs, useUserId } from "../../hooks";
import type {
  ApprovalAgent,
  ApprovalAgentState,
  ApprovalRequest
} from "./approval-agent";

const codeSections: CodeSection[] = [
  {
    title: "Wait for human approval in a workflow",
    description:
      "AgentWorkflow provides a built-in waitForApproval() helper. The workflow suspends durably — it can wait for minutes, hours, or days — and resumes when the agent calls approveWorkflow() or rejectWorkflow(). If rejected, it throws a WorkflowRejectedError.",
    code: `import { AgentWorkflow } from "agents/workflows";
import type { AgentWorkflowEvent, AgentWorkflowStep } from "agents/workflows";

class ApprovalWorkflow extends AgentWorkflow<MyAgent, RequestParams> {
  async run(event: AgentWorkflowEvent<RequestParams>, step: AgentWorkflowStep) {
    const request = await step.do("prepare", async () => {
      return { ...event.payload, preparedAt: Date.now() };
    });

    await this.reportProgress({
      step: "approval",
      status: "pending",
      message: "Awaiting approval",
    });

    // Suspends until approved — throws WorkflowRejectedError if rejected
    const approvalData = await this.waitForApproval<{ approvedBy: string }>(
      step,
      { timeout: "7 days" }
    );

    const result = await step.do("execute", async () => {
      return executeRequest(request);
    });

    await step.reportComplete(result);
    return result;
  }
}`
  },
  {
    title: "Approve or reject from the agent",
    description:
      "The agent has built-in approveWorkflow() and rejectWorkflow() convenience methods. Both accept optional metadata — for approvals, this data is returned to the waiting workflow.",
    code: `class MyAgent extends Agent {
  @callable()
  async approve(instanceId: string, userId: string) {
    await this.approveWorkflow(instanceId, {
      reason: "Approved by admin",
      metadata: { approvedBy: userId },
    });
  }

  @callable()
  async reject(instanceId: string, reason: string) {
    await this.rejectWorkflow(instanceId, { reason });
  }
}`
  }
];

function ApprovalCard({
  request,
  onApprove,
  onReject
}: {
  request: ApprovalRequest;
  onApprove: (id: string) => void;
  onReject: (id: string, reason?: string) => void;
}) {
  const [rejectReason, setRejectReason] = useState("");
  const [showRejectForm, setShowRejectForm] = useState(false);

  const statusIcons = {
    pending: <ClockIcon size={20} className="text-kumo-warning" />,
    approved: <CheckCircleIcon size={20} className="text-kumo-success" />,
    rejected: <XCircleIcon size={20} className="text-kumo-danger" />
  };

  const statusBorder = {
    pending: "border-l-4 border-l-kumo-warning",
    approved: "border-l-4 border-l-green-500",
    rejected: "border-l-4 border-l-kumo-danger"
  };

  const timeAgo = (date: string) => {
    const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
    if (seconds < 60) return `${seconds}s ago`;
    const minutes = Math.floor(seconds / 60);
    if (minutes < 60) return `${minutes}m ago`;
    const hours = Math.floor(minutes / 60);
    return `${hours}h ago`;
  };

  return (
    <Surface
      className={`p-4 rounded-lg ring ring-kumo-line ${statusBorder[request.status]}`}
    >
      <div className="flex items-start justify-between mb-2">
        <div className="flex items-center gap-2">
          {statusIcons[request.status]}
          <Text bold>{request.title}</Text>
        </div>
        <span className="text-xs text-kumo-subtle">
          {timeAgo(request.createdAt)}
        </span>
      </div>

      <p className="text-sm text-kumo-subtle mb-3">{request.description}</p>

      {request.status === "pending" && (
        <div className="space-y-2">
          {!showRejectForm ? (
            <div className="flex gap-2">
              <Button
                variant="primary"
                onClick={() => onApprove(request.id)}
                icon={<CheckCircleIcon size={16} />}
              >
                Approve
              </Button>
              <Button
                variant="destructive"
                onClick={() => setShowRejectForm(true)}
                icon={<XCircleIcon size={16} />}
              >
                Reject
              </Button>
            </div>
          ) : (
            <div className="space-y-2">
              <Input
                aria-label="Rejection reason"
                type="text"
                value={rejectReason}
                onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
                  setRejectReason(e.target.value)
                }
                placeholder="Reason for rejection (optional)"
                className="w-full"
              />
              <div className="flex gap-2">
                <Button
                  variant="destructive"
                  onClick={() => {
                    onReject(request.id, rejectReason || undefined);
                    setShowRejectForm(false);
                    setRejectReason("");
                  }}
                >
                  Confirm Reject
                </Button>
                <Button
                  variant="secondary"
                  onClick={() => {
                    setShowRejectForm(false);
                    setRejectReason("");
                  }}
                >
                  Cancel
                </Button>
              </div>
            </div>
          )}
        </div>
      )}

      {request.status !== "pending" && request.resolvedAt && (
        <div className="text-xs text-kumo-subtle border-t border-kumo-fill pt-2 mt-2">
          <div>
            {request.status === "approved" ? "Approved" : "Rejected"} at{" "}
            {new Date(request.resolvedAt).toLocaleTimeString()}
          </div>
          {request.reason && <div>Reason: {request.reason}</div>}
        </div>
      )}
    </Surface>
  );
}

export function WorkflowApprovalDemo() {
  const userId = useUserId();
  const { logs, addLog, clearLogs } = useLogs();
  const [title, setTitle] = useState("");
  const [description, setDescription] = useState("");
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [requests, setRequests] = useState<ApprovalRequest[]>([]);

  const agent = useAgent<ApprovalAgent, ApprovalAgentState>({
    agent: "approval-agent",
    name: `workflow-approval-${userId}`,
    onStateUpdate: () => {
      refreshRequests();
    },
    onOpen: () => {
      addLog("info", "connected");
      refreshRequests();
    },
    onClose: () => addLog("info", "disconnected"),
    onError: () => addLog("error", "error", "Connection error"),
    onMessage: (message) => {
      try {
        const data = JSON.parse(message.data as string);
        if (data.type) {
          addLog("in", data.type, data);
          if (data.type.startsWith("approval_")) {
            refreshRequests();
          }
        }
      } catch {
        // ignore
      }
    }
  });

  const refreshRequests = async () => {
    try {
      const list = await (
        agent.call as (m: string) => Promise<ApprovalRequest[]>
      )("listRequests");
      setRequests(list);
    } catch {
      // ignore - might not be connected yet
    }
  };

  const handleSubmitRequest = async () => {
    if (!title.trim() || !description.trim()) return;

    setIsSubmitting(true);
    addLog("out", "requestApproval", { title, description });

    try {
      await agent.call("requestApproval", [title, description]);
      setTitle("");
      setDescription("");
      await refreshRequests();
    } catch (e) {
      addLog("error", "error", e instanceof Error ? e.message : String(e));
    } finally {
      setIsSubmitting(false);
    }
  };

  const handleApprove = async (requestId: string) => {
    addLog("out", "approve", { requestId });
    try {
      await agent.call("approve", [requestId]);
      await refreshRequests();
    } catch (e) {
      addLog("error", "error", e instanceof Error ? e.message : String(e));
    }
  };

  const handleReject = async (requestId: string, reason?: string) => {
    addLog("out", "reject", { requestId, reason });
    try {
      await agent.call("reject", [requestId, reason]);
      await refreshRequests();
    } catch (e) {
      addLog("error", "error", e instanceof Error ? e.message : String(e));
    }
  };

  const handleClearApprovals = async () => {
    addLog("out", "clearApprovals");
    try {
      const result = await agent.call("clearApprovals");
      addLog("in", "cleared", { count: result });
      await refreshRequests();
    } catch (e) {
      addLog("error", "error", e instanceof Error ? e.message : String(e));
    }
  };

  const pendingRequests = requests.filter((r) => r.status === "pending");
  const resolvedRequests = requests.filter((r) => r.status !== "pending");

  const presetRequests = [
    {
      title: "Deploy to Production",
      description: "Release v2.3.0 with new features"
    },
    {
      title: "Access Request - Admin Panel",
      description: "Need admin access for debugging"
    },
    {
      title: "Expense Report - $450",
      description: "Team offsite dinner and supplies"
    }
  ];

  return (
    <DemoWrapper
      title="Approval Workflow"
      description={
        <>
          Workflows can pause and wait for human input using{" "}
          <code className="text-xs bg-kumo-fill px-1 py-0.5 rounded">
            waitForApproval()
          </code>
          . The workflow suspends durably — it can wait for minutes, hours, or
          days — and resumes when someone calls{" "}
          <code className="text-xs bg-kumo-fill px-1 py-0.5 rounded">
            approveWorkflow()
          </code>{" "}
          or{" "}
          <code className="text-xs bg-kumo-fill px-1 py-0.5 rounded">
            rejectWorkflow()
          </code>
          . Submit a request below and then approve or reject it.
        </>
      }
      statusIndicator={
        <ConnectionStatus
          status={
            agent.readyState === WebSocket.OPEN ? "connected" : "connecting"
          }
        />
      }
    >
      <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
        {/* Left Panel - Create Request */}
        <div className="space-y-6">
          <Surface className="p-4 rounded-lg ring ring-kumo-line">
            <div className="mb-4">
              <Text variant="heading3">Submit Request</Text>
            </div>
            <div className="space-y-3">
              <Input
                label="Title"
                type="text"
                value={title}
                onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
                  setTitle(e.target.value)
                }
                className="w-full"
                placeholder="What needs approval?"
              />
              <InputArea
                label="Description"
                value={description}
                onChange={(e) => setDescription(e.target.value)}
                className="w-full"
                rows={3}
                placeholder="Provide details..."
              />
              <Button
                variant="primary"
                onClick={handleSubmitRequest}
                disabled={isSubmitting || !title.trim() || !description.trim()}
                className="w-full"
                icon={<PaperPlaneTiltIcon size={16} />}
              >
                {isSubmitting ? "Submitting..." : "Submit Request"}
              </Button>
            </div>
          </Surface>

          {/* Quick Presets */}
          <Surface className="p-4 rounded-lg ring ring-kumo-line">
            <div className="mb-3">
              <Text bold size="sm">
                Quick Presets
              </Text>
            </div>
            <div className="space-y-2">
              {presetRequests.map((preset) => (
                <button
                  key={preset.title}
                  type="button"
                  onClick={() => {
                    setTitle(preset.title);
                    setDescription(preset.description);
                  }}
                  className="w-full text-left p-2 text-xs bg-kumo-elevated hover:bg-kumo-tint rounded transition-colors text-kumo-default"
                >
                  {preset.title}
                </button>
              ))}
            </div>
          </Surface>
        </div>

        {/* Center Panel - Approval Queue */}
        <div className="space-y-6">
          {/* Pending Requests */}
          <div>
            <div className="flex items-center justify-between mb-3">
              <div className="flex items-center gap-2">
                <WarningCircleIcon size={16} className="text-kumo-warning" />
                <Text variant="heading3">
                  Pending Approval ({pendingRequests.length})
                </Text>
              </div>
              <Button
                variant="ghost"
                size="xs"
                onClick={refreshRequests}
                icon={<ArrowsClockwiseIcon size={12} />}
              >
                Refresh
              </Button>
            </div>
            {pendingRequests.length > 0 ? (
              <div className="space-y-3">
                {pendingRequests.map((request) => (
                  <ApprovalCard
                    key={request.id}
                    request={request}
                    onApprove={handleApprove}
                    onReject={handleReject}
                  />
                ))}
              </div>
            ) : (
              <Surface className="p-6 rounded-lg ring ring-kumo-line">
                <Empty title="No pending approvals" size="sm" />
              </Surface>
            )}
          </div>

          {/* History */}
          <div>
            <div className="flex items-center justify-between mb-3">
              <Text variant="heading3">
                History ({resolvedRequests.length})
              </Text>
              {resolvedRequests.length > 0 && (
                <Button
                  variant="ghost"
                  size="xs"
                  onClick={handleClearApprovals}
                  icon={<TrashIcon size={12} />}
                  className="text-kumo-danger"
                >
                  Clear
                </Button>
              )}
            </div>
            {resolvedRequests.length > 0 ? (
              <div className="space-y-3 max-h-64 overflow-y-auto">
                {resolvedRequests.map((request) => (
                  <ApprovalCard
                    key={request.id}
                    request={request}
                    onApprove={handleApprove}
                    onReject={handleReject}
                  />
                ))}
              </div>
            ) : (
              <Surface className="p-6 rounded-lg ring ring-kumo-line">
                <Empty title="No resolved requests" size="sm" />
              </Surface>
            )}
          </div>
        </div>

        {/* Right Panel - Logs */}
        <div className="space-y-6">
          <LogPanel logs={logs} onClear={clearLogs} maxHeight="500px" />
        </div>
      </div>

      <CodeExplanation sections={codeSections} />
    </DemoWrapper>
  );
}