branch:
app.tsx
18913 bytesRaw
import { useAgent } from "agents/react";
import { useState, useEffect, useCallback, useRef } from "react";
import { Button, Input, Badge, Text, Empty } from "@cloudflare/kumo";
import {
  ConnectionIndicator,
  ModeToggle,
  PoweredByAgents
} from "@cloudflare/agents-ui";
import type { ConnectionStatus } from "@cloudflare/agents-ui";
import type {
  TaskAgent,
  WorkflowItem,
  WorkflowPage,
  WorkflowUpdate
} from "./server";

// Local progress type without index signature for type-safe JSX rendering
type ProgressInfo = {
  step?: string;
  status?: string;
  message?: string;
  percent?: number;
};

// UI-safe workflow type with explicit result type for rendering
type WorkflowCardData = Omit<WorkflowItem, "result" | "progress"> & {
  result?: Record<string, unknown>;
  progress: ProgressInfo | null;
};

// Client-side pagination state
type PaginationState = {
  workflows: WorkflowItem[];
  total: number;
  nextCursor: string | null;
};

const initialPagination: PaginationState = {
  workflows: [],
  total: 0,
  nextCursor: null
};

type Toast = {
  message: string;
  type: "error" | "info";
};

/** Format the "not supported in local dev" message, or fall back to a generic one. */
function localDevMessage(err: unknown, fallback: string): string {
  if (
    err instanceof Error &&
    err.message.includes("not supported in local development")
  ) {
    return `${fallback.replace("Failed to ", "")} is not supported in local dev. Deploy to Cloudflare to use this feature.`;
  }
  return fallback;
}

export default function App() {
  const [taskName, setTaskName] = useState("");
  const [pagination, setPagination] =
    useState<PaginationState>(initialPagination);
  const [connectionStatus, setConnectionStatus] =
    useState<ConnectionStatus>("connecting");
  const [toast, setToast] = useState<Toast | null>(null);
  const [loading, setLoading] = useState(false);
  const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

  const connected = connectionStatus === "connected";

  const showToast = useCallback(
    (message: string, type: Toast["type"] = "error") => {
      if (toastTimer.current) clearTimeout(toastTimer.current);
      setToast({ message, type });
      toastTimer.current = setTimeout(() => setToast(null), 5000);
    },
    []
  );

  // Handle real-time updates from server
  const handleMessage = useCallback(
    (message: MessageEvent) => {
      try {
        const data = JSON.parse(message.data);

        if (data?.type === "warning" && data?.message) {
          showToast(data.message, "error");
          return;
        }

        if (data?.type === "workflows:cleared") {
          setPagination((prev) => ({
            ...prev,
            workflows: prev.workflows.filter(
              (w) => w.status !== "complete" && w.status !== "errored"
            ),
            total: prev.total - (data.count || 0)
          }));
          return;
        }

        const update = data as WorkflowUpdate;
        if (update?.type === "workflow:added") {
          setPagination((prev) => {
            const exists = prev.workflows.some(
              (w) => w.workflowId === update.workflow.workflowId
            );
            if (exists) return prev;
            return {
              ...prev,
              workflows: [update.workflow, ...prev.workflows],
              total: prev.total + 1
            };
          });
        } else if (update?.type === "workflow:updated") {
          setPagination((prev) => ({
            ...prev,
            workflows: prev.workflows.map((w) =>
              w.workflowId === update.workflowId
                ? { ...w, ...update.updates }
                : w
            )
          }));
        } else if (update?.type === "workflow:removed") {
          setPagination((prev) => ({
            ...prev,
            workflows: prev.workflows.filter(
              (w) => w.workflowId !== update.workflowId
            ),
            total: Math.max(0, prev.total - 1)
          }));
        }
      } catch {
        // Ignore non-JSON messages
      }
    },
    [showToast]
  );

  const agent = useAgent<TaskAgent, Record<string, never>>({
    agent: "TaskAgent",
    onMessage: handleMessage,
    onOpen: () => setConnectionStatus("connected"),
    onClose: () => setConnectionStatus("disconnected")
  });

  /** Wrapper around agent.call with try/catch and local-dev error handling. */
  const callAgent = useCallback(
    async (method: string, args: unknown[], errorLabel?: string) => {
      try {
        // @ts-expect-error - callable method typing
        return await agent.call(method, args);
      } catch (err) {
        if (errorLabel) {
          showToast(localDevMessage(err, errorLabel));
        } else {
          console.error(`Failed to call ${method}:`, err);
        }
      }
    },
    [agent, showToast]
  );

  // Fetch initial page on connect
  useEffect(() => {
    if (!connected) return;

    const fetchInitial = async () => {
      const page = (await callAgent("listWorkflows", [])) as
        | WorkflowPage
        | undefined;
      if (page) {
        setPagination({
          workflows: page.workflows,
          total: page.total,
          nextCursor: page.nextCursor
        });
      }
    };

    fetchInitial();
  }, [connected, callAgent]);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!taskName.trim()) return;
    await callAgent("submitTask", [taskName]);
    setTaskName("");
  };

  const handleLoadMore = async () => {
    if (!pagination.nextCursor || loading) return;
    setLoading(true);
    try {
      const page = (await callAgent("listWorkflows", [
        pagination.nextCursor,
        5
      ])) as WorkflowPage | undefined;
      if (page) {
        // Silently deduplicate in case of pagination overlap
        const existingIds = new Set(
          pagination.workflows.map((w) => w.workflowId)
        );
        const newWorkflows = page.workflows.filter(
          (w) => !existingIds.has(w.workflowId)
        );
        setPagination((prev) => ({
          workflows: [...prev.workflows, ...newWorkflows],
          total: page.total,
          nextCursor: page.nextCursor
        }));
      }
    } finally {
      setLoading(false);
    }
  };

  const handleClearCompleted = () => callAgent("clearCompleted", []);

  const hasCompletedWorkflows = pagination.workflows.some(
    (w) => w.status === "complete" || w.status === "errored"
  );

  return (
    <div className="min-h-screen bg-kumo-elevated">
      <div className="mx-auto max-w-2xl px-5 py-10">
        {/* Header */}
        <header className="mb-10 flex items-start justify-between">
          <div>
            <Text variant="heading1">Workflow Demo</Text>
            <p className="mt-1 text-kumo-inactive">
              Multiple Concurrent Workflows with Human-in-the-Loop Approval
            </p>
          </div>
          <div className="flex items-center gap-3">
            <ConnectionIndicator status={connectionStatus} />
            <ModeToggle />
          </div>
        </header>

        {/* Task submission form */}
        <form onSubmit={handleSubmit} className="mb-8 flex items-center gap-4">
          <Input
            value={taskName}
            onChange={(e) => setTaskName(e.target.value)}
            placeholder="Enter task name (e.g., 'Generate Report')"
            aria-label="Task name"
            className="flex-1"
          />
          <Button
            type="submit"
            variant="primary"
            disabled={!taskName.trim()}
            className="shrink-0"
          >
            Start Task
          </Button>
        </form>

        {/* Workflow list header */}
        {pagination.workflows.length > 0 && (
          <div className="mb-4 flex items-center justify-between">
            <Text variant="heading3">
              Workflows ({pagination.workflows.length}
              {pagination.total > pagination.workflows.length &&
                ` of ${pagination.total}`}
              )
            </Text>
            {hasCompletedWorkflows && (
              <Button
                variant="secondary"
                size="sm"
                onClick={handleClearCompleted}
              >
                Clear Completed
              </Button>
            )}
          </div>
        )}

        {/* Workflow list */}
        <div className="flex flex-col gap-3">
          {pagination.workflows.map((workflow) => (
            <WorkflowCard
              key={workflow.workflowId}
              workflow={workflow}
              callAgent={callAgent}
            />
          ))}
        </div>

        {/* Load more button */}
        {pagination.nextCursor && (
          <div className="mt-4 border-t border-kumo-line pt-5 text-center">
            <Button
              variant="secondary"
              onClick={handleLoadMore}
              loading={loading}
            >
              Load More ({pagination.total - pagination.workflows.length}{" "}
              remaining)
            </Button>
          </div>
        )}

        {/* Empty state */}
        {pagination.workflows.length === 0 && (
          <Empty
            title="No workflows yet"
            description="Start a task above to begin!"
          />
        )}

        {/* Feature list */}
        <footer className="mt-12 border-t border-kumo-line pt-6">
          <h4 className="mb-3 font-medium text-kumo-inactive">
            This demo shows:
          </h4>
          <ul className="list-inside list-disc space-y-1 text-kumo-inactive">
            <li>
              Multiple concurrent workflows with{" "}
              <code className="rounded bg-kumo-tint px-1.5 py-0.5 text-kumo-brand">
                runWorkflow()
              </code>
            </li>
            <li>
              Paginated workflow list via{" "}
              <code className="rounded bg-kumo-tint px-1.5 py-0.5 text-kumo-brand">
                getWorkflows()
              </code>
            </li>
            <li>
              Typed progress reporting with{" "}
              <code className="rounded bg-kumo-tint px-1.5 py-0.5 text-kumo-brand">
                reportProgress()
              </code>
            </li>
            <li>
              Human-in-the-loop with{" "}
              <code className="rounded bg-kumo-tint px-1.5 py-0.5 text-kumo-brand">
                waitForApproval()
              </code>
            </li>
            <li>
              Per-workflow approve/reject via{" "}
              <code className="rounded bg-kumo-tint px-1.5 py-0.5 text-kumo-brand">
                approveWorkflow()
              </code>
            </li>
            <li>
              Workflow termination via{" "}
              <code className="rounded bg-kumo-tint px-1.5 py-0.5 text-kumo-brand">
                terminateWorkflow()
              </code>
            </li>
          </ul>

          <div className="mt-8 flex justify-center">
            <PoweredByAgents />
          </div>
        </footer>

        {/* Toast notification */}
        {toast && (
          <div
            className={`animate-toast-in fixed bottom-6 right-6 z-50 flex max-w-sm items-center gap-3 rounded-lg border bg-kumo-base p-4 shadow-lg ${
              toast.type === "error"
                ? "border-l-4 border-l-kumo-brand border-kumo-line"
                : "border-l-4 border-l-kumo-secondary border-kumo-line"
            }`}
          >
            <span className="flex-1 text-kumo-default">{toast.message}</span>
            <button
              type="button"
              className="text-kumo-inactive transition-colors hover:text-kumo-default"
              onClick={() => setToast(null)}
              aria-label="Dismiss notification"
            >
              ×
            </button>
          </div>
        )}
      </div>
    </div>
  );
}

// Status badge variant mapping
const statusBadgeConfig: Record<
  WorkflowItem["status"],
  { label: string; variant: React.ComponentProps<typeof Badge>["variant"] }
> = {
  queued: { label: "Queued", variant: "secondary" },
  running: { label: "Running", variant: "primary" },
  waiting: { label: "Awaiting Approval", variant: "beta" },
  complete: { label: "Complete", variant: "outline" },
  errored: { label: "Error", variant: "destructive" },
  paused: { label: "Paused", variant: "secondary" }
};

function StatusBadge({ status }: { status: WorkflowItem["status"] }) {
  const { label, variant } = statusBadgeConfig[status];
  return <Badge variant={variant}>{label}</Badge>;
}

/** Restart + Dismiss buttons used for both completed and errored workflows. */
function WorkflowEndActions({
  workflowId,
  callAgent
}: {
  workflowId: string;
  callAgent: (
    method: string,
    args: unknown[],
    errorLabel?: string
  ) => Promise<unknown>;
}) {
  return (
    <div className="flex gap-2">
      <Button
        size="sm"
        variant="primary"
        onClick={() =>
          callAgent("restart", [workflowId], "Failed to restart workflow")
        }
      >
        Restart
      </Button>
      <Button
        size="sm"
        variant="secondary"
        onClick={() => callAgent("dismissWorkflow", [workflowId])}
      >
        Dismiss
      </Button>
    </div>
  );
}

// Workflow card component
function WorkflowCard({
  workflow: rawWorkflow,
  callAgent
}: {
  workflow: WorkflowItem;
  callAgent: (
    method: string,
    args: unknown[],
    errorLabel?: string
  ) => Promise<unknown>;
}) {
  const [rejectReason, setRejectReason] = useState("");

  // Transform WorkflowItem to UI-safe types for rendering
  const workflow: WorkflowCardData = {
    ...rawWorkflow,
    result:
      rawWorkflow.result != null &&
      typeof rawWorkflow.result === "object" &&
      !Array.isArray(rawWorkflow.result)
        ? (rawWorkflow.result as Record<string, unknown>)
        : undefined,
    progress: rawWorkflow.progress as ProgressInfo | null
  };

  const percent = workflow.progress?.percent ?? 0;
  const message = workflow.progress?.message ?? "Processing...";
  const id = workflow.workflowId;

  const borderClass =
    workflow.status === "waiting"
      ? "border-kumo-brand/40"
      : workflow.status === "complete"
        ? "border-green-500/30"
        : workflow.status === "errored"
          ? "border-red-500/30"
          : "border-kumo-line";

  return (
    <div
      className={`rounded-xl border bg-kumo-base p-5 transition-colors hover:border-kumo-interact ${borderClass}`}
    >
      {/* Header with task name and status */}
      <div className="mb-3 flex items-start justify-between">
        <div className="flex flex-col gap-0.5">
          <span className="font-semibold text-kumo-default">
            {workflow.taskName}
          </span>
          <span className="font-mono text-xs text-kumo-inactive">
            {id.slice(0, 8)}
          </span>
        </div>
        <StatusBadge status={workflow.status} />
      </div>

      {/* Progress bar for running/waiting/queued workflows */}
      {(workflow.status === "running" ||
        workflow.status === "waiting" ||
        workflow.status === "queued") && (
        <div className="mb-3">
          <div className="mb-2 h-1 overflow-hidden rounded-full bg-kumo-tint">
            <div
              className={`h-full rounded-full bg-kumo-brand transition-all duration-300 ease-out ${
                workflow.waitingForApproval ? "animate-pulse-glow" : ""
              }`}
              style={{ width: `${percent * 100}%` }}
            />
          </div>
          <span className="text-sm text-kumo-inactive">
            {Math.round(percent * 100)}% – {message}
          </span>
        </div>
      )}

      {/* Action buttons for running/queued workflows */}
      {(workflow.status === "running" || workflow.status === "queued") &&
        !workflow.waitingForApproval && (
          <div className="flex gap-2">
            <Button
              size="sm"
              variant="secondary"
              onClick={() =>
                callAgent("pause", [id], "Failed to pause workflow")
              }
            >
              Pause
            </Button>
            <Button
              size="sm"
              variant="destructive"
              onClick={() =>
                callAgent("terminate", [id], "Failed to terminate workflow")
              }
            >
              Terminate
            </Button>
          </div>
        )}

      {/* Resume button for paused workflows */}
      {workflow.status === "paused" && (
        <div className="flex gap-2">
          <Button
            size="sm"
            variant="primary"
            onClick={() =>
              callAgent("resume", [id], "Failed to resume workflow")
            }
          >
            Resume
          </Button>
          <Button
            size="sm"
            variant="destructive"
            onClick={() =>
              callAgent("terminate", [id], "Failed to terminate workflow")
            }
          >
            Terminate
          </Button>
        </div>
      )}

      {/* Approval buttons */}
      {workflow.waitingForApproval && (
        <div className="flex flex-wrap items-stretch gap-3 border-t border-kumo-line pt-3">
          <Button
            variant="primary"
            onClick={() => callAgent("approve", [id, "Approved via UI"])}
          >
            Approve
          </Button>
          <div className="flex flex-1 items-stretch gap-2">
            <Input
              value={rejectReason}
              onChange={(e) => setRejectReason(e.target.value)}
              placeholder="Reason (optional)"
              aria-label="Rejection reason"
              className="min-w-[120px] flex-1"
            />
            <Button
              variant="destructive"
              onClick={() => {
                callAgent("reject", [id, rejectReason || "Rejected via UI"]);
                setRejectReason("");
              }}
            >
              Reject
            </Button>
          </div>
        </div>
      )}

      {/* Completed result */}
      {workflow.status === "complete" && workflow.result && (
        <div className="border-t border-kumo-line pt-3">
          <pre className="mb-3 max-h-[150px] overflow-auto rounded-lg border border-kumo-line bg-kumo-elevated p-3 font-mono text-sm text-kumo-inactive">
            {JSON.stringify(workflow.result, null, 2)}
          </pre>
          <WorkflowEndActions workflowId={id} callAgent={callAgent} />
        </div>
      )}

      {/* Error display */}
      {workflow.status === "errored" && (
        <div className="border-t border-kumo-line pt-3">
          <p className="mb-3 text-kumo-danger">{workflow.error?.message}</p>
          <WorkflowEndActions workflowId={id} callAgent={callAgent} />
        </div>
      )}
    </div>
  );
}