Meet Frontfriend.Your brand. Your rules. Your agents, connected.Explore what’s new

Task

A collapsible record of the steps an agent takes as it performs a task.

Interactive preview

The shared preview uses React Base. The source below uses React Aria. Each example runs with local data.

Installation

Complete project setup with react/aria, then install these components:

pnpm exec frontfriend download trace collapsible message bubble button icon -f react --primitive aria

Use Task when each step becomes known during the work. A running task opens, then closes when complete, unless the user chooses another state. A failed or interrupted step makes its task an error. Plan is for steps known in advance.

Usage

Complete example

import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceLine,
  TraceIcon,
  TraceTitle,
  TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import * as React from "react";
const TASKS = [
  {
    id: "find",
    title: ["Finding the project files", "Found the project files"],
    steps: [
      ["Searching for generateMetadata", "Searched for generateMetadata"],
      ["Reading app/layout.tsx", "Read app/layout.tsx"],
      ["Reading app/page.tsx", "Read app/page.tsx"],
    ],
  },
  {
    id: "update",
    title: ["Updating the page metadata", "Updated the page metadata"],
    steps: [
      ["Editing app/layout.tsx", "Edited app/layout.tsx"],
      ["Editing app/page.tsx", "Edited app/page.tsx"],
    ],
  },
];
type TaskData = {
  id: string;
  title: string;
  steps: {
    title: string;
    status: "done" | "running" | "error";
  }[];
};
function taskAt(task: (typeof TASKS)[number], done: number): TaskData {
  return {
    id: task.id,
    title: task.title[done < task.steps.length ? 0 : 1],
    steps: task.steps.slice(0, done + 1).map(([running, finished], index) => ({
      title: index < done ? finished : running,
      status: index < done ? "done" : "running",
    })),
  };
}
const canvasStyle: React.CSSProperties = {
  width: "100%",
  maxWidth: 384,
  minWidth: 0,
  minHeight: 288,
  marginInline: "auto",
  display: "flex",
  flexDirection: "column",
  gap: 16,
  paddingBlock: 24,
  fontSize: "var(--text-sm)",
  lineHeight: 1.5,
};
function TaskRow({ task, streaming }: { task: TaskData; streaming: boolean }) {
  const [open, setOpen] = React.useState<boolean>();
  const steps = task.steps.map((step) => ({
    title: step.title,
    status:
      step.status === "done"
        ? undefined
        : step.status === "running" && streaming
          ? ("running" as const)
          : ("error" as const),
  }));
  const status = steps.some((step) => step.status === "error")
    ? "error"
    : steps.some((step) => step.status === "running")
      ? "running"
      : undefined;
  return (
    <TraceCollapsible
      isExpanded={open ?? status === "running"}
      onExpandedChange={setOpen}
    >
      <TraceCollapsibleTrigger>
        <TraceLine status={status}>
          <TraceIcon>
            <Icon
              name={
                status === "running"
                  ? "CircleDot"
                  : status === "error"
                    ? "CircleAlert"
                    : "CircleCheck"
              }
            />
          </TraceIcon>
          <TraceTitle>{task.title}</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent>
        <Trace>
          {steps.map((step) => (
            <TraceLine key={step.title} status={step.status}>
              <TraceTitle>
                {step.status === "error"
                  ? `Interrupted: ${step.title}`
                  : step.title}
              </TraceTitle>
            </TraceLine>
          ))}
        </Trace>
      </TraceCollapsibleContent>
    </TraceCollapsible>
  );
}
export default function AiTaskExample() {
  const [run, setRun] = React.useState(0);
  const [current, setCurrent] = React.useState(7);
  const running = current < 7;
  React.useEffect(() => {
    if (!running) return;
    const timer = setTimeout(() => setCurrent((value) => value + 1), 900);
    return () => clearTimeout(timer);
  }, [current, running]);
  const tasks =
    current <= 3
      ? [taskAt(TASKS[0], current)]
      : [taskAt(TASKS[0], 3), taskAt(TASKS[1], current - 4)];
  return (
    <div style={canvasStyle}>
      <Message align="end">
        <MessageContent>
          <Bubble align="end" variant="muted">
            <BubbleContent>Add Open Graph metadata to the home page.</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message>
        <MessageContent style={{ gap: 16 }}>
          <Trace>
            {tasks.map((task) => (
              <TaskRow
                key={`${run}-${task.id}`}
                task={task}
                streaming={running}
              />
            ))}
          </Trace>
          {!running && (
            <p>
              Added Open Graph defaults to app/layout.tsx and a title,
              description and image for the home page in app/page.tsx.
            </p>
          )}
        </MessageContent>
      </Message>
      <Button
        variant="outline"
        size="sm"
        style={{ marginTop: "auto", alignSelf: "flex-start" }}
        isDisabled={running}
        onPress={() => {
          setRun((value) => value + 1);
          setCurrent(0);
        }}
      >
        Replay
      </Button>
    </div>
  );
}

Interrupted

import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceLine,
  TraceIcon,
  TraceTitle,
  TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import * as React from "react";
type TaskData = {
  id: string;
  title: string;
  steps: {
    title: string;
    status: "done" | "running" | "error";
  }[];
};
function TaskRow({ task, streaming }: { task: TaskData; streaming: boolean }) {
  const [open, setOpen] = React.useState<boolean>();
  const steps = task.steps.map((step) => ({
    title: step.title,
    status:
      step.status === "done"
        ? undefined
        : step.status === "running" && streaming
          ? ("running" as const)
          : ("error" as const),
  }));
  const status = steps.some((step) => step.status === "error")
    ? "error"
    : steps.some((step) => step.status === "running")
      ? "running"
      : undefined;
  return (
    <TraceCollapsible
      isExpanded={open ?? status === "running"}
      onExpandedChange={setOpen}
    >
      <TraceCollapsibleTrigger>
        <TraceLine status={status}>
          <TraceIcon>
            <Icon
              name={
                status === "running"
                  ? "CircleDot"
                  : status === "error"
                    ? "CircleAlert"
                    : "CircleCheck"
              }
            />
          </TraceIcon>
          <TraceTitle>{task.title}</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent>
        <Trace>
          {steps.map((step) => (
            <TraceLine key={step.title} status={step.status}>
              <TraceTitle>
                {step.status === "error"
                  ? `Interrupted: ${step.title}`
                  : step.title}
              </TraceTitle>
            </TraceLine>
          ))}
        </Trace>
      </TraceCollapsibleContent>
    </TraceCollapsible>
  );
}
export default function AiTaskExample() {
  const task: TaskData = {
    id: "metadata",
    title: "Failed to update the page metadata",
    steps: [
      { title: "Read app/page.tsx", status: "done" },
      { title: "Save app/page.tsx", status: "running" },
    ],
  };
  return (
    <div
      style={{
        width: "100%",
        maxWidth: 384,
        marginInline: "auto",
        paddingBlock: 24,
      }}
    >
      <Trace>
        <TaskRow task={task} streaming={false} />
      </Trace>
    </div>
  );
}

AI SDK

This optional adapter accepts a UIMessage from your chat client. The complete example needs no AI SDK or provider. Install this extra package only for the adapter:

pnpm add ai

Read data-task parts and render each task by its part id. On the server, write updates with the same task id to replace the data part in place. Titles and step errors come from the task data. A running step becomes an error when the chat stops before it completes.

Server (app/api/chat/route.ts):

import { createUIMessageStream } from "ai";

const title = "Updated the page metadata";
const steps = [{ title: "Saved app/page.tsx", status: "done" }];

const stream = createUIMessageStream({
  execute: async ({ writer }) => {
    writer.write({ type: "data-task", id: "update", data: { title, steps } });
  },
});

Pass the resulting message to the client adapter below.

import type { UIMessage } from "ai";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceLine,
  TraceIcon,
  TraceTitle,
  TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import * as React from "react";
type TaskData = {
  id: string;
  title: string;
  steps: {
    title: string;
    status: "done" | "running" | "error";
  }[];
};
type TaskMessage = UIMessage<
  unknown,
  {
    task: Omit<TaskData, "id">;
  }
>;
function TaskRow({ task, streaming }: { task: TaskData; streaming: boolean }) {
  const [open, setOpen] = React.useState<boolean>();
  const steps = task.steps.map((step) => ({
    title: step.title,
    status:
      step.status === "done"
        ? undefined
        : step.status === "running" && streaming
          ? ("running" as const)
          : ("error" as const),
  }));
  const status = steps.some((step) => step.status === "error")
    ? "error"
    : steps.some((step) => step.status === "running")
      ? "running"
      : undefined;
  return (
    <TraceCollapsible
      isExpanded={open ?? status === "running"}
      onExpandedChange={setOpen}
    >
      <TraceCollapsibleTrigger>
        <TraceLine status={status}>
          <TraceIcon>
            <Icon
              name={
                status === "running"
                  ? "CircleDot"
                  : status === "error"
                    ? "CircleAlert"
                    : "CircleCheck"
              }
            />
          </TraceIcon>
          <TraceTitle>{task.title}</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent>
        <Trace>
          {steps.map((step) => (
            <TraceLine key={step.title} status={step.status}>
              <TraceTitle>
                {step.status === "error"
                  ? `Interrupted: ${step.title}`
                  : step.title}
              </TraceTitle>
            </TraceLine>
          ))}
        </Trace>
      </TraceCollapsibleContent>
    </TraceCollapsible>
  );
}
function TaskAdapter({
  message,
  streaming,
}: {
  message: TaskMessage;
  streaming: boolean;
}) {
  return (
    <Trace>
      {message.parts
        .filter((part) => part.type === "data-task")
        .map((part, index) => (
          <TaskRow
            key={part.id ?? index}
            task={{ ...part.data, id: part.id ?? String(index) }}
            streaming={streaming}
          />
        ))}
    </Trace>
  );
}
export default function AiTaskExample() {
  return (
    <div
      style={{
        width: "100%",
        maxWidth: 384,
        marginInline: "auto",
        paddingBlock: 24,
      }}
    >
      <TaskAdapter
        message={{
          id: "answer",
          role: "assistant",
          parts: [
            {
              type: "data-task",
              id: "find",
              data: {
                title: "Could not find the project files",
                steps: [
                  { title: "Search the project directory", status: "error" },
                ],
              },
            },
            {
              type: "data-task",
              id: "update",
              data: {
                title: "Updated the page metadata",
                steps: [{ title: "Saved app/page.tsx", status: "done" }],
              },
            },
          ],
        }}
        streaming={false}
      />
    </div>
  );
}

Components and API

This composition has no separate component API. Use the APIs for its installed components.

ComponentTarget reference
trace React Aria API
collapsible React Aria API
message React Aria API
bubble React Aria API
button React Aria API
icon React Aria API