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 Reka UI. Each example runs with local data.

Installation

Complete project setup with vue/reka, then install these components:

pnpm exec frontfriend download trace collapsible message bubble button icon -f vue --primitive reka

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

Save as AiPlanExample.vue.

<script setup lang="ts">
import { Icon } from "@/components/ui/icon";
import { computed, reactive, ref, onScopeDispose } from "vue";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import { Message, MessageContent } from "@/components/ui/message";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceDescription,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace";

interface PlanData {
  title: string;
  steps: { title: string; status: "done" | "running" | "pending" }[];
}

interface PlanPart {
  type: "data-plan";
  id: string;
  data: PlanData;
}

interface TextPart {
  type: "text";
  text: string;
}

interface ChatMessage {
  id: string;
  role: "user" | "assistant";
  parts: (PlanPart | TextPart)[];
}

const STEPS = [
  "Install the design system components",
  "Replace the dialog imports",
  "Connect the dialog state",
  "Run the type checker",
];

const props = withDefaults(defineProps<{ question?: string }>(), {
  question: "Migrate the dialog components to the new design system.",
});
const QUESTION = props.question;
const ANSWER = "Migrated Dialog and AlertDialog. The type checker passes.";

function planAt(current: number): PlanData {
  return {
    title: "Plan: migrate the dialogs",
    steps: STEPS.map((title, index) => ({
      title,
      status:
        index < current ? "done" : index === current ? "running" : "pending",
    })),
  };
}

function transcript(): ChatMessage[] {
  return [
    { id: "user-0", role: "user", parts: [{ type: "text", text: QUESTION }] },
    {
      id: "assistant-0",
      role: "assistant",
      parts: [
        { type: "data-plan", id: "plan", data: planAt(STEPS.length) },
        { type: "text", text: ANSWER },
      ],
    },
  ];
}

const messages = ref<ChatMessage[]>(transcript());
const status = ref<"ready" | "submitted" | "streaming">("ready");
const busy = computed(() => status.value !== "ready");

let run = 0;
const timers = new Set<ReturnType<typeof setTimeout>>();
function pause(ms: number) {
  return new Promise<void>((resolve) => {
    const timer = setTimeout(() => {
      timers.delete(timer);
      resolve();
    }, ms);
    timers.add(timer);
  });
}
onScopeDispose(() => {
  run++;
  for (const timer of timers) clearTimeout(timer);
  timers.clear();
});

async function sleep(ms: number) {
  const id = run;
  await pause(ms);
  return id === run;
}

async function streamInto(
  target: { text: string },
  text: string,
  delayMs: number,
) {
  for (const chunk of text.match(/\S+\s*/g) ?? []) {
    target.text += chunk;
    if (!(await sleep(delayMs))) return false;
  }
  return true;
}

function getText(message: ChatMessage) {
  return message.parts
    .filter((part): part is TextPart => part.type === "text")
    .map((part) => part.text)
    .join("");
}

function getPlan(message: ChatMessage) {
  return message.parts.find(
    (part): part is PlanPart => part.type === "data-plan",
  )?.data;
}

function isStreaming(message: ChatMessage) {
  return busy.value && message === messages.value.slice(-1)[0];
}

function countDone(plan: PlanData) {
  return plan.steps.filter((step) => step.status === "done").length;
}

async function replay() {
  const id = ++run;
  messages.value = [
    {
      id: `user-${id}`,
      role: "user",
      parts: [{ type: "text", text: QUESTION }],
    },
  ];
  status.value = "submitted";
  if (!(await sleep(300))) return;

  const message = reactive<ChatMessage>({
    id: `assistant-${id}`,
    role: "assistant",
    parts: [],
  });
  messages.value.push(message);
  status.value = "streaming";
  const plan = reactive<PlanPart>({
    type: "data-plan",
    id: "plan",
    data: planAt(0),
  });
  message.parts.push(plan);
  for (let index = 1; index <= STEPS.length; index++) {
    if (!(await sleep(1200))) return;
    plan.data = planAt(index);
  }

  const text = reactive<TextPart>({ type: "text", text: "" });
  message.parts.push(text);
  if (!(await streamInto(text, ANSWER, 20))) return;
  status.value = "ready";
}
</script>

<template>
  <div
    style="
      width: 100%;
      max-width: 384px;
      min-width: 0;
      min-height: 288px;
      margin-inline: auto;
      display: flex;
      flex-direction: column;
      gap: 16px;
      padding-block: 24px;
      font-size: var(--text-sm);
      line-height: 1.5;
    "
  >
    <template v-for="message in messages" :key="message.id">
      <Message v-if="message.role === 'user'" align="end">
        <MessageContent>
          <Bubble align="end" variant="muted">
            <BubbleContent>{{ props.question }}</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message v-else>
        <MessageContent style="gap: 16px">
          <Trace v-if="getPlan(message)">
            <TraceCollapsible default-open>
              <TraceCollapsibleTrigger>
                <TraceLine
                  :status="
                    isStreaming(message) &&
                    countDone(getPlan(message)!) <
                      getPlan(message)!.steps.length
                      ? 'running'
                      : undefined
                  "
                >
                  <TraceIcon>
                    <Icon name="Route" />
                  </TraceIcon>
                  <TraceTitle>{{ getPlan(message)!.title }}</TraceTitle>
                  <TraceDescription>
                    {{ countDone(getPlan(message)!) }} of
                    {{ getPlan(message)!.steps.length }} done
                  </TraceDescription>
                </TraceLine>
              </TraceCollapsibleTrigger>
              <TraceCollapsibleContent>
                <Trace>
                  <TraceLine
                    v-for="step in getPlan(message)!.steps"
                    :key="step.title"
                    :status="
                      step.status === 'pending'
                        ? 'pending'
                        : isStreaming(message) && step.status === 'running'
                          ? 'running'
                          : undefined
                    "
                  >
                    <TraceIcon>
                      <Icon name="CircleCheck" v-if="step.status === 'done'" />
                      <Icon
                        name="CircleDot"
                        v-else-if="step.status === 'running'"
                      />
                      <Icon name="Circle" v-else />
                    </TraceIcon>
                    <TraceTitle>{{ step.title }}</TraceTitle>
                  </TraceLine>
                </Trace>
              </TraceCollapsibleContent>
            </TraceCollapsible>
          </Trace>
          <p v-if="getText(message)">
            {{ getText(message) }}
          </p>
        </MessageContent>
      </Message>
    </template>
    <Button
      :disabled="busy"
      variant="outline"
      size="sm"
      style="margin-top: auto; align-self: flex-start"
      @click="replay"
    >
      Replay
    </Button>
  </div>
</template>

Details

Save as AiPlanDetails.vue.

<script setup lang="ts">
import { Icon } from "@/components/ui/icon";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceDescription,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace";

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",
];
</script>

<template>
  <Trace
    style="
      width: 100%;
      max-width: 384px;
      margin-inline: auto;
      padding-block: 24px;
    "
  >
    <TraceCollapsible default-open>
      <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="margin-bottom: 12px">
          Store the theme in a cookie so the server renders the right one and
          the page does not flash on load.
        </p>
        <Trace>
          <TraceLine
            v-for="(step, index) in steps"
            :key="step"
            :status="index > 0 ? 'pending' : undefined"
          >
            <TraceIcon>
              <Icon name="CircleCheck" v-if="index === 0" />
              <Icon name="Circle" v-else />
            </TraceIcon>
            <TraceTitle>{{ step }}</TraceTitle>
          </TraceLine>
        </Trace>
      </TraceCollapsibleContent>
    </TraceCollapsible>
  </Trace>
</template>

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 (server/api/chat.post.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.

Save as AiPlanAdapter.vue.

<script setup lang="ts">
import type { UIMessage } from "ai";
import { computed } from "vue";
import { Icon } from "@/components/ui/icon";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceCollapsibleContent,
  TraceLine,
  TraceTitle,
  TraceDescription,
  TraceIcon,
} from "@/components/ui/trace";
interface PlanData {
  title: string;
  steps: { title: string; status: "done" | "running" | "pending" }[];
}

type PlanMessage = UIMessage<unknown, { plan: PlanData }>;
const props = withDefaults(
  defineProps<{ message?: PlanMessage; streaming?: boolean }>(),
  {
    streaming: false,
    message: () => ({
      id: "answer",
      role: "assistant",
      parts: [
        {
          type: "data-plan",
          id: "plan",
          data: {
            title: "Plan: migrate the dialogs",
            steps: [
              { title: "Install the design system components", status: "done" },
              { title: "Replace the dialog imports", status: "pending" },
              { title: "Connect the dialog state", status: "pending" },
              { title: "Run the type checker", status: "pending" },
            ],
          },
        },
      ],
    }),
  },
);
const plan = computed(
  () => props.message.parts.find((part) => part.type === "data-plan")?.data,
);
const done = computed(
  () => plan.value?.steps.filter((step) => step.status === "done").length ?? 0,
);
</script>

<template>
  <div
    style="
      width: 100%;
      max-width: 384px;
      margin-inline: auto;
      padding-block: 24px;
    "
  >
    <Trace v-if="plan">
      <TraceCollapsible default-open>
        <TraceCollapsibleTrigger>
          <TraceLine
            :status="
              props.streaming && done < plan.steps.length
                ? 'running'
                : undefined
            "
          >
            <TraceIcon>
              <Icon name="Route" />
            </TraceIcon>
            <TraceTitle>{{ plan.title }}</TraceTitle>
            <TraceDescription>
              {{ done }} of {{ plan.steps.length }} done
            </TraceDescription>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <Trace>
            <TraceLine
              v-for="step in plan.steps"
              :key="step.title"
              :status="
                step.status === 'pending'
                  ? 'pending'
                  : props.streaming && step.status === 'running'
                    ? 'running'
                    : undefined
              "
            >
              <TraceIcon>
                <Icon name="CircleCheck" v-if="step.status === 'done'" />
                <Icon name="CircleDot" v-else-if="step.status === 'running'" />
                <Icon name="Circle" v-else />
              </TraceIcon>
              <TraceTitle>{{ step.title }}</TraceTitle>
            </TraceLine>
          </Trace>
        </TraceCollapsibleContent>
      </TraceCollapsible>
    </Trace>
  </div>
</template>

Components and API

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

ComponentTarget reference
trace Reka UI API
collapsible Reka UI API
message Reka UI API
bubble Reka UI API
button Reka UI API
icon Reka UI API