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

Queue

A task queue that separates work still to do from completed work.

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

A todo moves to Completed when it finishes. Only the active todo shimmers while the stream runs. A todo with a description has its own disclosure.

Usage

Complete example

Save as AiQueueExample.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";

interface Todo {
  content: string;
  status: "pending" | "in_progress" | "completed";
}

const props = withDefaults(defineProps<{ question?: string }>(), {
  question: "Add dark mode to the settings page.",
});
const QUESTION = props.question;
const ANSWER = "Dark mode is in. The toggle is under Settings, Appearance.";

const TODOS = [
  "Add a dark variant to the theme tokens",
  "Read the theme from a cookie",
  "Add a toggle to the settings page",
  "Check contrast on the charts",
];

function todosAt(current: number): Todo[] {
  return TODOS.map((content, index) => ({
    content,
    status:
      index < current
        ? "completed"
        : index === current
          ? "in_progress"
          : "pending",
  }));
}

const todos = ref<Todo[] | undefined>(todosAt(TODOS.length));
const text = ref(ANSWER);
const busy = ref(false);

const sections = computed(() => {
  const list = todos.value ?? [];
  const pending = list.filter((todo) => todo.status !== "completed");
  const completed = list.filter((todo) => todo.status === "completed");

  return [
    {
      id: "pending",
      icon: "ListTodo",
      title: `${pending.length} to do`,
      todos: pending,
    },
    {
      id: "completed",
      icon: "ListChecks",
      title: `${completed.length} completed`,
      todos: completed,
    },
  ].filter((section) => section.todos.length > 0);
});

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

async function replay() {
  const id = ++run;
  busy.value = true;
  todos.value = undefined;
  text.value = "";

  for (let index = 0; index < TODOS.length; index++) {
    todos.value = todosAt(index);
    if (!(await wait(1400, id))) return;
  }
  todos.value = todosAt(TODOS.length);

  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="todos || text">
      <MessageContent style="gap: 16px">
        <Trace v-if="todos">
          <TraceCollapsible
            v-for="section in sections"
            :key="section.id"
            default-open
          >
            <TraceCollapsibleTrigger>
              <TraceLine>
                <TraceIcon>
                  <Icon :name="section.icon" />
                </TraceIcon>
                <TraceTitle>{{ section.title }}</TraceTitle>
              </TraceLine>
            </TraceCollapsibleTrigger>
            <TraceCollapsibleContent>
              <Trace>
                <TraceLine
                  v-for="todo in section.todos"
                  :key="todo.content"
                  :status="
                    busy && todo.status === 'in_progress'
                      ? 'running'
                      : undefined
                  "
                >
                  <TraceIcon>
                    <Icon
                      name="CircleCheck"
                      v-if="todo.status === 'completed'"
                    />
                    <Icon
                      name="CircleDot"
                      v-else-if="todo.status === 'in_progress'"
                    />
                    <Icon name="Circle" v-else />
                  </TraceIcon>
                  <TraceTitle>{{ todo.content }}</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>

Descriptions

Save as AiQueueDescriptions.vue.

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

const todos: { title: string; description?: string; done: boolean }[] = [
  {
    title: "Write the migration guide",
    description: "Cover the renamed props and the new data attributes.",
    done: true,
  },
  { title: "Add a changeset", done: true },
  {
    title: "Fix the focus ring on the select trigger",
    description: "It is clipped by overflow-hidden on the field.",
    done: false,
  },
  { title: "Update the registry snapshots", done: false },
  {
    title: "Add tests for the combobox",
    description: "Typing, clearing and keyboard selection.",
    done: false,
  },
];

const sections = [
  {
    id: "pending",
    icon: "ListTodo",
    title: `${todos.filter((todo) => !todo.done).length} to do`,
    todos: todos.filter((todo) => !todo.done),
    defaultOpen: true,
  },
  {
    id: "completed",
    icon: "ListChecks",
    title: `${todos.filter((todo) => todo.done).length} completed`,
    todos: todos.filter((todo) => todo.done),
    defaultOpen: false,
  },
];
</script>

<template>
  <Trace
    style="
      width: 100%;
      max-width: 384px;
      margin-inline: auto;
      padding-block: 24px;
    "
  >
    <TraceCollapsible
      v-for="section in sections"
      :key="section.id"
      :unmount-on-hide="false"
      :default-open="section.defaultOpen"
    >
      <TraceCollapsibleTrigger>
        <TraceLine>
          <TraceIcon>
            <Icon :name="section.icon" />
          </TraceIcon>
          <TraceTitle>{{ section.title }}</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent>
        <Trace>
          <template v-for="todo in section.todos" :key="todo.title">
            <TraceCollapsible v-if="todo.description" :unmount-on-hide="false">
              <TraceCollapsibleTrigger>
                <TraceLine>
                  <TraceIcon>
                    <Icon v-if="todo.done" name="CircleCheck" />
                    <Icon v-else name="Circle" />
                  </TraceIcon>
                  <TraceTitle>{{ todo.title }}</TraceTitle>
                </TraceLine>
              </TraceCollapsibleTrigger>
              <TraceCollapsibleContent>
                {{ todo.description }}
              </TraceCollapsibleContent>
            </TraceCollapsible>
            <TraceLine v-else>
              <TraceIcon>
                <Icon v-if="todo.done" name="CircleCheck" />
                <Icon v-else name="Circle" />
              </TraceIcon>
              <TraceTitle>{{ todo.title }}</TraceTitle>
            </TraceLine>
          </template>
        </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 last tool-writeTodos call whose input is no longer streaming. Each call contains the complete todo list. Split it into pending and completed sections. Only show a running line while the chat streams.

Save as AiQueueAdapter.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,
  TraceIcon,
} from "@/components/ui/trace";
interface Todo {
  content: string;
  status: "pending" | "in_progress" | "completed";
}

type QueueMessage = UIMessage<
  unknown,
  never,
  { writeTodos: { input: { todos: Todo[] }; output: { saved: boolean } } }
>;
const props = withDefaults(
  defineProps<{ message?: QueueMessage; streaming?: boolean }>(),
  {
    streaming: false,
    message: () => ({
      id: "answer",
      role: "assistant",
      parts: [
        {
          type: "tool-writeTodos",
          toolCallId: "todos-1",
          state: "input-available",
          input: {
            todos: [
              {
                content: "Add a dark variant to the theme tokens",
                status: "completed",
              },
              { content: "Read the theme from a cookie", status: "pending" },
              {
                content: "Add a toggle to the settings page",
                status: "pending",
              },
              { content: "Check contrast on the charts", status: "pending" },
            ],
          },
        },
      ],
    }),
  },
);
const todos = computed(
  () =>
    props.message.parts
      .filter((part) => part.type === "tool-writeTodos")
      .filter((part) => part.state !== "input-streaming")
      .slice(-1)[0]?.input?.todos,
);
const sections = computed(() => {
  const list = todos.value ?? [];
  const pending = list.filter((todo) => todo.status !== "completed");
  const completed = list.filter((todo) => todo.status === "completed");

  return [
    {
      id: "pending",
      icon: "ListTodo",
      title: `${pending.length} to do`,
      todos: pending,
    },
    {
      id: "completed",
      icon: "ListChecks",
      title: `${completed.length} completed`,
      todos: completed,
    },
  ].filter((section) => section.todos.length > 0);
});
</script>

<template>
  <div
    style="
      width: 100%;
      max-width: 384px;
      margin-inline: auto;
      padding-block: 24px;
    "
  >
    <Trace v-if="todos">
      <TraceCollapsible
        v-for="section in sections"
        :key="section.id"
        default-open
      >
        <TraceCollapsibleTrigger>
          <TraceLine>
            <TraceIcon>
              <Icon :name="section.icon" />
            </TraceIcon>
            <TraceTitle>{{ section.title }}</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <Trace>
            <TraceLine
              v-for="todo in section.todos"
              :key="todo.content"
              :status="
                props.streaming && todo.status === 'in_progress'
                  ? 'running'
                  : undefined
              "
            >
              <TraceIcon>
                <Icon name="CircleCheck" v-if="todo.status === 'completed'" />
                <Icon
                  name="CircleDot"
                  v-else-if="todo.status === 'in_progress'"
                />
                <Icon name="Circle" v-else />
              </TraceIcon>
              <TraceTitle>{{ todo.content }}</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