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

Reasoning

A collapsible view of the agent’s thinking that follows the reasoning stream and can show its duration.

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

Reasoning opens while thinking and closes when the stream ends. A user’s open or closed choice takes priority. An empty reasoning part is a plain line until text arrives. Supply the optional duration from message metadata.

Usage

Complete example

Save as AiReasoningExample.vue.

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

interface ReasoningPart {
  type: "reasoning";
  text: string;
  state: "streaming" | "done";
}

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

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

const props = withDefaults(
  defineProps<{ question?: string; duration?: number }>(),
  { question: "Why is the checkout deploy so slow?", duration: 5 },
);
const QUESTION = props.question;
const REASONING =
  "The deploy log has three phases. Install takes 38s and next build takes 52s, both normal for this project.\n\nImage optimization takes 3m 50s. It reruns on every deploy because its cache key includes the commit hash, so the cache never hits.";
const ANSWER =
  "Image optimization is the bottleneck. Key its cache on the lockfile and the image directory instead of the commit hash, and the deploy should take under two minutes.";

function transcript(): ChatMessage[] {
  return [
    { id: "user-0", role: "user", parts: [{ type: "text", text: QUESTION }] },
    {
      id: "assistant-0",
      role: "assistant",
      parts: [
        { type: "reasoning", text: REASONING, state: "done" },
        { type: "text", text: ANSWER },
      ],
    },
  ];
}

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

const openState = ref<Record<string, boolean | undefined>>({
  "assistant-0-0": true,
});

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 getReasoning(message: ChatMessage) {
  return message.parts.filter(
    (part): part is ReasoningPart => part.type === "reasoning",
  );
}

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

async function replay() {
  const id = ++run;
  messages.value = [
    {
      id: `user-${id}`,
      role: "user",
      parts: [{ type: "text", text: QUESTION }],
    },
  ];
  openState.value = {};
  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 reasoning = reactive<ReasoningPart>({
    type: "reasoning",
    text: "",
    state: "streaming",
  });
  message.parts.push(reasoning);
  if (!(await streamInto(reasoning, REASONING, 40))) return;
  reasoning.state = "done";
  if (!(await sleep(400))) return;

  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="getReasoning(message).length > 0">
            <template
              v-for="(part, index) in getReasoning(message)"
              :key="index"
            >
              <TraceLine
                v-if="part.text.length === 0"
                :status="
                  isStreaming(message) && part.state === 'streaming'
                    ? 'running'
                    : undefined
                "
              >
                <TraceTitle>
                  {{
                    isStreaming(message) && part.state === "streaming"
                      ? "Thinking"
                      : "Thought"
                  }}
                </TraceTitle>
                <TraceDescription
                  v-if="part.state === 'done' && props.duration !== undefined"
                  >{{ props.duration }}s</TraceDescription
                >
              </TraceLine>
              <TraceCollapsible
                v-else
                :open="
                  openState[`${message.id}-${index}`] ??
                  (isStreaming(message) && part.state === 'streaming')
                "
                @update:open="openState[`${message.id}-${index}`] = $event"
              >
                <TraceCollapsibleTrigger>
                  <TraceLine
                    :status="
                      isStreaming(message) && part.state === 'streaming'
                        ? 'running'
                        : undefined
                    "
                  >
                    <TraceTitle>
                      {{
                        isStreaming(message) && part.state === "streaming"
                          ? "Thinking"
                          : "Thought"
                      }}
                    </TraceTitle>
                    <TraceDescription
                      v-if="
                        part.state === 'done' && props.duration !== undefined
                      "
                      >{{ props.duration }}s</TraceDescription
                    >
                  </TraceLine>
                </TraceCollapsibleTrigger>
                <TraceCollapsibleContent style="white-space: pre-wrap">
                  {{ part.text }}
                </TraceCollapsibleContent>
              </TraceCollapsible>
            </template>
          </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>

Duration

Save as AiReasoningDuration.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 text =
  "The build log shows install and compile finish in under a minute. Image optimization takes almost four, and it reruns on every deploy because the cache key includes the commit hash.";
</script>

<template>
  <Trace
    style="
      width: 100%;
      max-width: 384px;
      margin-inline: auto;
      padding-block: 24px;
    "
  >
    <TraceCollapsible>
      <TraceCollapsibleTrigger>
        <TraceLine>
          <TraceIcon>
            <Icon name="CircleCheck" />
          </TraceIcon>
          <TraceTitle>Thought</TraceTitle>
          <TraceDescription>12s</TraceDescription>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent style="white-space: pre-wrap">
        {{ text }}
      </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

Render every reasoning part. A line runs only when both the chat and the part are streaming. Empty text stays a plain line. Each line follows the stream until the user changes its open state.

Save as AiReasoningAdapter.vue.

<script setup lang="ts">
import type { UIMessage } from "ai";
import { computed, ref } from "vue";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceCollapsibleContent,
  TraceLine,
  TraceTitle,
  TraceIcon,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
const props = withDefaults(
  defineProps<{ message?: UIMessage; streaming?: boolean }>(),
  {
    streaming: false,
    message: () => ({
      id: "answer",
      role: "assistant",
      parts: [
        {
          type: "reasoning",
          text: "Image optimization runs on each deploy because the cache key includes the commit hash.",
          state: "done",
        },
      ],
    }),
  },
);
const openState = ref<Record<string, boolean>>({});
const rows = computed(() =>
  props.message.parts
    .filter((part) => part.type === "reasoning")
    .map((part, index) => ({
      ...part,
      key: `${props.message.id}-${index}`,
      running: props.streaming && part.state === "streaming",
    })),
);
</script>

<template>
  <div
    style="
      width: 100%;
      max-width: 384px;
      margin-inline: auto;
      padding-block: 24px;
    "
  >
    <Trace>
      <template v-for="part in rows" :key="part.key">
        <TraceLine
          v-if="!part.text.length"
          :status="part.running ? 'running' : undefined"
        >
          <TraceIcon>
            <Icon :name="part.running ? 'LoaderCircle' : 'CircleCheck'" />
          </TraceIcon>
          <TraceTitle>{{ part.running ? "Thinking" : "Thought" }}</TraceTitle>
        </TraceLine>
        <TraceCollapsible
          v-else
          :open="openState[part.key] ?? part.running"
          @update:open="openState[part.key] = $event"
        >
          <TraceCollapsibleTrigger>
            <TraceLine :status="part.running ? 'running' : undefined">
              <TraceIcon>
                <Icon :name="part.running ? 'LoaderCircle' : 'CircleCheck'" />
              </TraceIcon>
              <TraceTitle>
                {{ part.running ? "Thinking" : "Thought" }}
              </TraceTitle>
            </TraceLine>
          </TraceCollapsibleTrigger>
          <TraceCollapsibleContent>
            <div style="white-space: pre-wrap">{{ part.text }}</div>
          </TraceCollapsibleContent>
        </TraceCollapsible>
      </template>
    </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