Skip to content

Documentation

neelam-ui

Search documentation

Getting Started
Forms
Overlays
Navigation
Data Display
Layout
AI & Chat
Blocks
GitHub repository
Blocks

Assistant panel

Jump to source

Revenue assistant

Reading churn-q2.csv

Beta
09:41
Here's last quarter's churn export. What stands out?
Revenue assistant09:41
Churn is concentrated in accounts that never invited a second seat — 71% of cancellations came from single-seat workspaces, against 12% of the base overall.
09:43
Is that a change from the quarter before?
Revenue assistant09:43
It is. The same cohort accounted for 44% of cancellations in Q1, so the concentration has roughly doubled while total churn stayed flat.

Answers are generated and can be wrong. Check anything you act on.

Source

A working chat surface: conversation history, attachments, a typing indicator, and a composer that sends. Built from Message, Bubble, Composer, TypingIndicator, Attachment, Avatar, Badge and Button.

import { useEffect, useRef, useState } from "react";
import { Plus, RotateCcw, Sparkles } from "lucide-react";
import {
  Attachment,
  Avatar,
  AvatarFallback,
  Badge,
  Bubble,
  Button,
  Composer,
  Message,
  TypingIndicator,
} from "neelam-ui";

interface Turn {
  id: number;
  from: "user" | "assistant";
  text: string;
  time: string;
}

const openingTurns: Turn[] = [
  {
    id: 1,
    from: "user",
    text: "Here's last quarter's churn export. What stands out?",
    time: "09:41",
  },
  {
    id: 2,
    from: "assistant",
    text: "Churn is concentrated in accounts that never invited a second seat — 71% of cancellations came from single-seat workspaces, against 12% of the base overall.",
    time: "09:41",
  },
  {
    id: 3,
    from: "user",
    text: "Is that a change from the quarter before?",
    time: "09:43",
  },
  {
    id: 4,
    from: "assistant",
    text: "It is. The same cohort accounted for 44% of cancellations in Q1, so the concentration has roughly doubled while total churn stayed flat.",
    time: "09:43",
  },
];

const suggestions = [
  "Break it down by plan",
  "Which accounts are at risk now?",
  "Draft a summary for the team",
];

export default function Chat01() {
  const [turns, setTurns] = useState(openingTurns);
  const [thinking, setThinking] = useState(false);
  const logRef = useRef<HTMLDivElement>(null);

  // Scroll the log itself rather than calling scrollIntoView on the last
  // turn: that walks up the tree and scrolls every ancestor with it, which
  // drags the whole page around when the chat is embedded in one.
  useEffect(() => {
    const log = logRef.current;
    if (!log) return;
    log.scrollTo({
      top: log.scrollHeight,
      behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
        ? "auto"
        : "smooth",
    });
  }, [turns, thinking]);

  function send(text: string) {
    const time = new Date().toLocaleTimeString([], {
      hour: "2-digit",
      minute: "2-digit",
    });

    setTurns((current) => [
      ...current,
      { id: Date.now(), from: "user", text, time },
    ]);
    setThinking(true);

    // Stands in for the streamed response a real assistant would send back.
    setTimeout(() => {
      setThinking(false);
      setTurns((current) => [
        ...current,
        {
          id: Date.now(),
          from: "assistant",
          text: "Working from the same export: the single-seat cohort also has the lowest activation rate, so the two are likely the same story rather than two separate ones.",
          time,
        },
      ]);
    }, 1600);
  }

  return (
    // The panel is capped at a readable measure, so it takes side borders
    // once the window is wider than it is — otherwise it floats in the middle
    // of the page with nothing marking where it ends.
    <div className="mx-auto flex h-full min-h-[36rem] w-full max-w-2xl flex-col overflow-hidden border-slate-200 sm:border-x dark:border-slate-800">
      <header className="flex shrink-0 items-center gap-3 border-b border-slate-200 px-4 py-3 dark:border-slate-800">
        <span className="flex h-9 w-9 items-center justify-center rounded-xl bg-slate-950 text-white dark:bg-white dark:text-slate-950">
          <Sparkles className="h-4 w-4" aria-hidden="true" />
        </span>
        <div className="min-w-0 flex-1">
          <h1 className="truncate text-sm font-semibold text-slate-950 dark:text-white">
            Revenue assistant
          </h1>
          <p className="truncate text-xs text-slate-500 dark:text-slate-400">
            Reading churn-q2.csv
          </p>
        </div>
        <Badge variant="secondary">Beta</Badge>
        <Button
          size="sm"
          variant="ghost"
          icon={<RotateCcw className="h-4 w-4" />}
          onClick={() => {
            setTurns(openingTurns);
            setThinking(false);
          }}
        >
          Reset
        </Button>
      </header>

      {/* The log is a live region so replies arriving after the fact are
          announced, rather than silently appearing below the composer. */}
      <div
        ref={logRef}
        className="flex flex-1 flex-col gap-5 overflow-y-auto px-4 py-6"
        role="log"
        aria-label="Conversation"
        aria-live="polite"
      >
        {turns.map((turn, index) =>
          turn.from === "user" ? (
            // No avatar on your own turns, as most chat UIs do.
            <Message key={turn.id} variant="outgoing" timestamp={turn.time}>
              <Bubble variant="outgoing">{turn.text}</Bubble>
              {index === 0 ? (
                <Attachment name="churn-q2.csv" size="86 KB" url="#" />
              ) : null}
            </Message>
          ) : (
            <Message
              key={turn.id}
              avatar={
                <Avatar size="sm">
                  <AvatarFallback>
                    <Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
                  </AvatarFallback>
                </Avatar>
              }
              sender="Revenue assistant"
              timestamp={turn.time}
            >
              <Bubble>{turn.text}</Bubble>
            </Message>
          ),
        )}

        {/* Sits exactly where the next incoming Bubble will land. */}
        {thinking ? <TypingIndicator label="Assistant is replying…" /> : null}
      </div>

      <div className="shrink-0 border-t border-slate-200 px-4 py-3 dark:border-slate-800">
        <div className="mb-2 flex flex-wrap gap-2">
          {suggestions.map((suggestion) => (
            <Button
              key={suggestion}
              size="sm"
              variant="outline"
              icon={<Plus className="h-3.5 w-3.5" />}
              onClick={() => send(suggestion)}
            >
              {suggestion}
            </Button>
          ))}
        </div>

        {/* Uncontrolled, so the field clears itself after each send. */}
        <Composer
          aria-label="Message the assistant"
          placeholder="Ask about the export… (Enter to send, Shift+Enter for a newline)"
          onSubmit={send}
        />
        <p className="mt-2 text-center text-xs text-slate-500 dark:text-slate-400">
          Answers are generated and can be wrong. Check anything you act on.
        </p>
      </div>
    </div>
  );
}