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 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 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

Save as AiTaskExample.vue.

<script setup lang="ts">
import { Icon } from "@/components/ui/icon";
import { computed, 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,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace";

type Status = "running" | "error" | undefined;

interface TaskData {
  title: string;
  steps: { title: string; status: "done" | "running" | "error" }[];
}

const props = withDefaults(defineProps<{ question?: string }>(), {
  question: "Add Open Graph metadata to the home page.",
});
const QUESTION = props.question;
const ANSWER =
  "Added Open Graph defaults to app/app.vue and a title, description and image for the home page in app/pages/index.vue.";

const TASKS = [
  {
    id: "find",
    title: ["Finding the project files", "Found the project files"],
    steps: [
      ["Searching for useSeoMeta", "Searched for useSeoMeta"],
      ["Reading app/app.vue", "Read app/app.vue"],
      ["Reading app/pages/index.vue", "Read app/pages/index.vue"],
    ],
  },
  {
    id: "update",
    title: ["Updating the page metadata", "Updated the page metadata"],
    steps: [
      ["Editing app/app.vue", "Edited app/app.vue"],
      ["Editing app/pages/index.vue", "Edited app/pages/index.vue"],
    ],
  },
];

function taskAt(task: (typeof TASKS)[number], done: number): TaskData {
  return {
    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",
    })),
  };
}

function finalTasks() {
  return TASKS.map((task) => ({
    id: task.id,
    data: taskAt(task, task.steps.length),
  }));
}

const tasks = ref<{ id: string; data: TaskData }[]>(finalTasks());
const text = ref(ANSWER);
const busy = ref(false);
const openState = ref<Record<string, boolean>>({});

function getStepStatus(
  status: TaskData["steps"][number]["status"],
  streaming: boolean,
): Status {
  if (status === "done") return undefined;

  return status === "running" && streaming ? "running" : "error";
}

const rows = computed(() =>
  tasks.value.map((task) => {
    const steps = task.data.steps.map((step) => ({
      title: step.title,
      status: getStepStatus(step.status, busy.value),
    }));
    const status: Status = steps.some((step) => step.status === "error")
      ? "error"
      : steps.some((step) => step.status === "running")
        ? "running"
        : undefined;

    return { id: task.id, title: task.data.title, steps, status };
  }),
);

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 wait(ms: number, id: number) {
  await pause(ms);
  return id === run;
}

function writeTask(id: string, data: TaskData) {
  const index = tasks.value.findIndex((task) => task.id === id);
  if (index === -1) tasks.value.push({ id, data });
  else tasks.value[index] = { id, data };
}

async function replay() {
  const id = ++run;
  busy.value = true;
  tasks.value = [];
  text.value = "";
  openState.value = {};

  for (const task of TASKS) {
    for (let done = 0; done <= task.steps.length; done++) {
      writeTask(task.id, taskAt(task, done));
      if (!(await wait(900, id))) return;
    }
  }

  for (const word of ANSWER.split(/(?<= )/)) {
    if (!(await wait(20, id))) return;
    text.value += word;
  }

  busy.value = false;
}
</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;
    "
  >
    <Message align="end">
      <MessageContent>
        <Bubble align="end" variant="muted">
          <BubbleContent>{{ props.question }}</BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
    <Message v-if="rows.length > 0 || text">
      <MessageContent style="gap: 16px">
        <Trace v-if="rows.length > 0">
          <TraceCollapsible
            v-for="task in rows"
            :key="task.id"
            :unmount-on-hide="false"
            :open="openState[task.id] ?? task.status === 'running'"
            @update:open="openState[task.id] = $event"
          >
            <TraceCollapsibleTrigger>
              <TraceLine :status="task.status">
                <TraceIcon>
                  <Icon v-if="task.status === 'running'" name="CircleDot" />
                  <Icon
                    v-else-if="task.status === 'error'"
                    name="CircleAlert"
                  />
                  <Icon v-else name="CircleCheck" />
                </TraceIcon>
                <TraceTitle>{{ task.title }}</TraceTitle>
              </TraceLine>
            </TraceCollapsibleTrigger>
            <TraceCollapsibleContent>
              <Trace>
                <TraceLine
                  v-for="step in task.steps"
                  :key="step.title"
                  :status="step.status"
                >
                  <TraceTitle>{{
                    step.status === "error"
                      ? "Interrupted: " + step.title
                      : step.title
                  }}</TraceTitle>
                </TraceLine>
              </Trace>
            </TraceCollapsibleContent>
          </TraceCollapsible>
        </Trace>
        <p v-if="text">
          {{ text }}
        </p>
      </MessageContent>
    </Message>
    <Button
      :disabled="busy"
      variant="outline"
      size="sm"
      style="margin-top: auto; align-self: flex-start"
      @click="replay"
    >
      Replay
    </Button>
  </div>
</template>

Interrupted

Save as AiTaskInterrupted.vue.

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

const steps = [
  { title: "Read app/pages/index.vue", status: undefined },
  { title: "Interrupted: Save app/pages/index.vue", status: "error" as const },
];
</script>

<template>
  <div
    style="
      width: 100%;
      max-width: 384px;
      margin-inline: auto;
      padding-block: 24px;
    "
  >
    <Trace>
      <TraceCollapsible :unmount-on-hide="false">
        <TraceCollapsibleTrigger>
          <TraceLine status="error">
            <TraceIcon><Icon name="CircleAlert" /></TraceIcon>
            <TraceTitle>Failed to update the page metadata</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <Trace>
            <TraceLine
              v-for="step in steps"
              :key="step.title"
              :status="step.status"
            >
              <TraceTitle>{{ step.title }}</TraceTitle>
            </TraceLine>
          </Trace>
        </TraceCollapsibleContent>
      </TraceCollapsible>
    </Trace>
  </div>
</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 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 (server/api/chat.post.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.

Save as AiTaskAdapter.vue.

<script setup lang="ts">
import type { UIMessage } from "ai";
import { computed, ref } from "vue";
import { Icon } from "@/components/ui/icon";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceCollapsibleContent,
  TraceLine,
  TraceTitle,
  TraceIcon,
} from "@/components/ui/trace";
type Status = "running" | "error" | undefined;

interface TaskData {
  title: string;
  steps: { title: string; status: "done" | "running" | "error" }[];
}

type TaskMessage = UIMessage<unknown, { task: TaskData }>;
const props = withDefaults(
  defineProps<{ message?: TaskMessage; streaming?: boolean }>(),
  {
    streaming: false,
    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/pages/index.vue", status: "done" }],
          },
        },
      ],
    }),
  },
);
const openState = ref<Record<string, boolean>>({});
function getStepStatus(
  status: TaskData["steps"][number]["status"],
  streaming: boolean,
): Status {
  if (status === "done") return undefined;

  return status === "running" && streaming ? "running" : "error";
}

const rows = computed(() =>
  props.message.parts
    .filter((part) => part.type === "data-task")
    .map((task, index) => {
      const steps = task.data.steps.map((step) => ({
        title: step.title,
        status: getStepStatus(step.status, props.streaming),
      }));
      const status: Status = steps.some((step) => step.status === "error")
        ? "error"
        : steps.some((step) => step.status === "running")
          ? "running"
          : undefined;

      return {
        id: task.id ?? String(index),
        title: task.data.title,
        steps,
        status,
      };
    }),
);
</script>

<template>
  <div
    style="
      width: 100%;
      max-width: 384px;
      margin-inline: auto;
      padding-block: 24px;
    "
  >
    <Trace v-if="rows.length > 0">
      <TraceCollapsible
        v-for="task in rows"
        :key="task.id"
        :unmount-on-hide="false"
        :open="openState[task.id] ?? task.status === 'running'"
        @update:open="openState[task.id] = $event"
      >
        <TraceCollapsibleTrigger>
          <TraceLine :status="task.status">
            <TraceIcon>
              <Icon v-if="task.status === 'running'" name="CircleDot" />
              <Icon v-else-if="task.status === 'error'" name="CircleAlert" />
              <Icon v-else name="CircleCheck" />
            </TraceIcon>
            <TraceTitle>{{ task.title }}</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <Trace>
            <TraceLine
              v-for="step in task.steps"
              :key="step.title"
              :status="step.status"
            >
              <TraceTitle>{{
                step.status === "error"
                  ? "Interrupted: " + step.title
                  : 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