OSAGI

Reasoning Thread

AI chats 8

About

AI chat thread that shows its work: streaming reasoning that folds when done, tool steps with state, timing and results, then the answer with a caret. A live region, springs off under reduced motion. From the AI Assistant Kit → ai.osagi.finance/kits/ai-assistant

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Reasoning Thread to your project and fits it to your theme.

Code

How Reasoning Thread is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import {
  ReasoningThread,
  type ThreadMessage,
} from "@/components/ui/reasoning-thread";

const settings = {
  autoplay: true,
  question: "Which requests are waiting for my approval?",
  toolMs: 700,
  wordMs: 45,
};

const REASONING =
  "The question is about approvals. I will search the approvals that wait for a decision, check which one is risky, then answer with the list.";
const STEPS = [
  { detail: "3 approvals pending", id: "t1", title: "Searching the approvals" },
  {
    detail: "1 with a high risk: production data access",
    id: "t2",
    title: "Checking the risk notes",
  },
];
const ANSWER =
  "**3** requests wait for your decision.\n\n- Laptop for the new analyst, $1,840 (Finance)\n- Design licence renewal, $620 (Team lead)\n- Production database access for contractors (Security)\n\nThe database access is the risky one: ask for a masked replica first.";

type Timer = ReturnType<typeof setTimeout>;
const NO_MESSAGES: ThreadMessage[] = [];
const newTimers = (): Timer[] => [];

/** A scripted turn: the reasoning streams, two tool steps run, then the answer streams. */
export default function Demo(props: Partial<typeof settings>) {
  const s = { ...settings, ...props };
  const [messages, setMessages] = useState(NO_MESSAGES);
  const timers = useRef(newTimers());
  /** Each replay is a new turn with new ids, so it starts from a folded, fresh state. */
  const turn = useRef(0);

  const clear = useCallback(() => {
    for (const timer of timers.current) {
      clearTimeout(timer);
    }
    timers.current = [];
  }, []);

  const play = useCallback(() => {
    clear();
    turn.current += 1;
    const at = (
      ms: number,
      update: (reply: ThreadMessage) => ThreadMessage
    ) => {
      timers.current.push(
        setTimeout(
          () =>
            setMessages(([asked, last]) =>
              asked && last ? [asked, update(last)] : []
            ),
          ms
        )
      );
    };
    setMessages([
      { id: `q${turn.current}`, role: "user", text: s.question },
      {
        id: `a${turn.current}`,
        reasoning: { text: "" },
        role: "assistant",
        steps: [],
        streaming: true,
        text: "",
      },
    ]);
    const words = REASONING.split(" ");
    for (let i = 0; i < words.length; i += 1) {
      const text = words.slice(0, i + 1).join(" ");
      at((i + 1) * s.wordMs, (m) =>
        m.role === "assistant" ? { ...m, reasoning: { text } } : m
      );
    }
    let t = (words.length + 1) * s.wordMs;
    at(t, (m) =>
      m.role === "assistant"
        ? { ...m, reasoning: { seconds: 2, text: REASONING } }
        : m
    );
    for (const step of STEPS) {
      at(t, (m) =>
        m.role === "assistant"
          ? { ...m, steps: [...(m.steps ?? []), { ...step, state: "running" }] }
          : m
      );
      t += s.toolMs;
      at(t, (m) =>
        m.role === "assistant"
          ? {
              ...m,
              steps: (m.steps ?? []).map((x) =>
                x.id === step.id
                  ? { ...x, durationMs: s.toolMs, state: "done" }
                  : x
              ),
            }
          : m
      );
    }
    const reply = ANSWER.split(" ");
    for (let i = 0; i < reply.length; i += 1) {
      const text = reply.slice(0, i + 1).join(" ");
      const streaming = i < reply.length - 1;
      at(t + (i + 1) * s.wordMs, (m) =>
        m.role === "assistant" ? { ...m, streaming, text } : m
      );
    }
  }, [clear, s.question, s.toolMs, s.wordMs]);

  useEffect(() => {
    if (s.autoplay) {
      play();
    }
    return clear;
  }, [clear, play, s.autoplay]);

  return (
    <div className="flex min-h-[560px] w-full flex-col items-center gap-4 bg-background p-6">
      <ReasoningThread messages={messages} />
      <button
        className="min-h-11 rounded-lg border border-border px-4 py-2 font-medium text-sm hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
        onClick={play}
        type="button"
      >
        {messages.length === 0 ? "Ask" : "Ask again"}
      </button>
    </div>
  );
}

Dependencies

More by OSAGI

View all
  • AI Orb

    AI Orb

    83
  • KPI Tile

    KPI Tile

    28
  • OTP Input

    OTP Input

    12
  • Assistant Composer

    Assistant Composer

    8
  • Release Notes

    Release Notes

  • Docs TOC

    Docs TOC

  • Docs Search

    Docs Search

  • Workload Timeline

    Workload Timeline

More in AI chats

View all
  • AI Actions

    AI Actions

    319
  • Color Picker

    Color Picker

    81
  • Agent Chat

    Agent Chat

    17
  • Prompt with Model Picker

    Prompt with Model Picker

    2
  • Message Thread

    Message Thread

    35
  • System Message

    System Message

    54
  • Suggestions

    Suggestions

    28
  • Message With Header And Delivery Status

    Message With Header And Delivery Status

    38