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

Plan

A collapsible plan that shows all proposed steps and the number of completed steps.

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 Plan when the steps are known before work starts. Completed, running, and pending steps remain in order. A task adds steps as work occurs.

Usage

Complete example

import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceLine,
  TraceIcon,
  TraceTitle,
  TraceDescription,
  TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import * as React from "react";
const STEPS = [
  "Install the design system components",
  "Replace the dialog imports",
  "Connect the dialog state",
  "Run the type checker",
];
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,
};
export default function AiPlanExample() {
  const [current, setCurrent] = React.useState(STEPS.length);
  const running = current < STEPS.length;
  React.useEffect(() => {
    if (!running) return;
    const timer = setTimeout(() => setCurrent((value) => value + 1), 1200);
    return () => clearTimeout(timer);
  }, [current, running]);
  const part = {
    type: "data-plan",
    id: "plan",
    data: {
      title: "Plan: migrate the dialogs",
      steps: STEPS.map((title, index) => ({
        title,
        status:
          index < current ? "done" : index === current ? "running" : "pending",
      })),
    },
  };
  const done = part.data.steps.filter((step) => step.status === "done").length;
  return (
    <div style={canvasStyle}>
      <Message align="end">
        <MessageContent>
          <Bubble align="end" variant="muted">
            <BubbleContent>Migrate the dialog components to the new design system.</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message>
        <MessageContent style={{ gap: 16 }}>
          <Trace>
            <TraceCollapsible defaultExpanded>
              <TraceCollapsibleTrigger>
                <TraceLine status={running ? "running" : undefined}>
                  <TraceIcon>
                    <Icon name="Route" />
                  </TraceIcon>
                  <TraceTitle>{part.data.title}</TraceTitle>
                  <TraceDescription>
                    {done} of {part.data.steps.length} done
                  </TraceDescription>
                </TraceLine>
              </TraceCollapsibleTrigger>
              <TraceCollapsibleContent>
                <Trace>
                  {part.data.steps.map((step) => (
                    <TraceLine
                      key={step.title}
                      status={
                        step.status === "pending"
                          ? "pending"
                          : running && step.status === "running"
                            ? "running"
                            : undefined
                      }
                    >
                      <TraceIcon>
                        <Icon
                          name={
                            step.status === "done"
                              ? "CircleCheck"
                              : step.status === "running"
                                ? "CircleDot"
                                : "Circle"
                          }
                        />
                      </TraceIcon>
                      <TraceTitle>{step.title}</TraceTitle>
                    </TraceLine>
                  ))}
                </Trace>
              </TraceCollapsibleContent>
            </TraceCollapsible>
          </Trace>
          {!running && (
            <p>Migrated Dialog and AlertDialog. The type checker passes.</p>
          )}
        </MessageContent>
      </Message>
      <Button
        variant="outline"
        size="sm"
        style={{ marginTop: "auto", alignSelf: "flex-start" }}
        isDisabled={running}
        onPress={() => setCurrent(0)}
      >
        Replay
      </Button>
    </div>
  );
}

Details

import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceLine,
  TraceIcon,
  TraceTitle,
  TraceDescription,
  TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
export default function AiPlanExample() {
  const steps = [
    "Add a dark variant to the theme tokens",
    "Read the theme from a cookie on the server",
    "Add a toggle to the settings page",
    "Check contrast on the charts",
  ];
  return (
    <div
      style={{
        width: "100%",
        maxWidth: 384,
        marginInline: "auto",
        paddingBlock: 24,
      }}
    >
      <Trace>
        <TraceCollapsible defaultExpanded>
          <TraceCollapsibleTrigger>
            <TraceLine>
              <TraceIcon>
                <Icon name="Route" />
              </TraceIcon>
              <TraceTitle>Plan: add dark mode</TraceTitle>
              <TraceDescription>1 of {steps.length} done</TraceDescription>
            </TraceLine>
          </TraceCollapsibleTrigger>
          <TraceCollapsibleContent>
            <p style={{ marginBottom: 12 }}>
              Store the theme in a cookie so the server renders the right one
              and the page does not flash on load.
            </p>
            <Trace>
              {steps.map((step, index) => (
                <TraceLine
                  key={step}
                  status={index > 0 ? "pending" : undefined}
                >
                  <TraceIcon>
                    <Icon name={index === 0 ? "CircleCheck" : "Circle"} />
                  </TraceIcon>
                  <TraceTitle>{step}</TraceTitle>
                </TraceLine>
              ))}
            </Trace>
          </TraceCollapsibleContent>
        </TraceCollapsible>
      </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 the data-plan part. On the server, write each update with the same part id, such as plan. The AI SDK replaces that data part in place. The adapter renders its title, steps, and completed count.

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

import { createUIMessageStream } from "ai";

const title = "Plan: migrate the dialogs";
const steps = [{ title: "Update the dialog imports", status: "pending" }];

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

Pass the resulting message to the client adapter below.

import type { UIMessage } from "ai";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceLine,
  TraceIcon,
  TraceTitle,
  TraceDescription,
  TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
type PlanData = {
  title: string;
  steps: {
    title: string;
    status: "done" | "running" | "pending";
  }[];
};
type PlanMessage = UIMessage<
  unknown,
  {
    plan: PlanData;
  }
>;
function PlanAdapter({
  message,
  streaming,
}: {
  message: PlanMessage;
  streaming: boolean;
}) {
  const part = message.parts.find((part) => part.type === "data-plan");
  if (!part) return null;
  const done = part.data.steps.filter((step) => step.status === "done").length;
  const running = streaming && done < part.data.steps.length;
  return (
    <Trace>
      <TraceCollapsible defaultExpanded>
        <TraceCollapsibleTrigger>
          <TraceLine status={running ? "running" : undefined}>
            <TraceIcon>
              <Icon name="Route" />
            </TraceIcon>
            <TraceTitle>{part.data.title}</TraceTitle>
            <TraceDescription>
              {done} of {part.data.steps.length} done
            </TraceDescription>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <Trace>
            {part.data.steps.map((step) => (
              <TraceLine
                key={step.title}
                status={
                  step.status === "pending"
                    ? "pending"
                    : running && step.status === "running"
                      ? "running"
                      : undefined
                }
              >
                <TraceIcon>
                  <Icon
                    name={
                      step.status === "done"
                        ? "CircleCheck"
                        : step.status === "running"
                          ? "CircleDot"
                          : "Circle"
                    }
                  />
                </TraceIcon>
                <TraceTitle>{step.title}</TraceTitle>
              </TraceLine>
            ))}
          </Trace>
        </TraceCollapsibleContent>
      </TraceCollapsible>
    </Trace>
  );
}
const STEPS = [
  "Install the design system components",
  "Replace the dialog imports",
  "Connect the dialog state",
  "Run the type checker",
];
export default function AiPlanExample() {
  return (
    <div
      style={{
        width: "100%",
        maxWidth: 384,
        marginInline: "auto",
        paddingBlock: 24,
      }}
    >
      <PlanAdapter
        message={{
          id: "answer",
          role: "assistant",
          parts: [
            {
              type: "data-plan",
              id: "plan",
              data: {
                title: "Plan: migrate the dialogs",
                steps: STEPS.map((title, index) => ({
                  title,
                  status: index === 0 ? "done" : "pending",
                })),
              },
            },
          ],
        }}
        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