Elements

44 / 122 · Knowledge

Research report

An outline that fills in section by section, each carrying the sources behind it.

Draft ownership in 0.140/4 sections · 9 sources read
What changed in 0.14
Who is affected
Migration path
Open questions

Installation

npx shadcn@latest add "@assistant-ui/elements-research-report"

Usage

import { ResearchReport } from "@/components/elements/research-report";

<ResearchReport title="Draft ownership in 0.14" sections={sections} sourcesRead={12} />

Props

ResearchReport

title*stringWhat the report is about.
sections*ReportSection[]The outline. It exists from the start and fills in, so the shape is legible before the prose lands.
sourcesRead*numberRunning count of sources consumed.
classNamestringExtra classes merged onto the root.

ReportSection

id*stringStable identity for the row key.
heading*stringSection title, shown before any prose exists.
state*"pending" | "writing" | "done"Only one section is usually writing; pending sections stay visible but dim.
sources*numberHow many sources back this section. Hidden at zero.
previewstringFirst lines of the written section, shown once there is something to show.

Source

research-report.tsx
"use client";

import { CheckIcon, Loader2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
import { mono, paper } from "./surfaces";

export type SectionState = "pending" | "writing" | "done";

export interface ReportSection {
  id: string;
  heading: string;
  state: SectionState;
  sources: number;
  preview?: string;
}

export function ResearchReport({
  title,
  sections,
  sourcesRead,
  className,
}: {
  title: string;
  sections: readonly ReportSection[];
  sourcesRead: number;
  className?: string;
}) {
  const done = sections.filter((section) => section.state === "done").length;

  return (
    <div
      className={cn(
        paper,
        "flex w-full max-w-sm flex-col gap-3 rounded-2xl p-4",
        className,
      )}
    >
      <div className="flex flex-col gap-1">
        <span className="text-[13.5px] font-medium">{title}</span>
        <span className={cn(mono, "text-foreground/30 tabular-nums")}>
          {done}/{sections.length} sections · {sourcesRead} sources read
        </span>
      </div>

      <div className="flex flex-col">
        {sections.map((section) => (
          <div
            key={section.id}
            className="border-foreground/[0.06] flex flex-col gap-1 border-t py-2 first:border-t-0 first:pt-0"
          >
            <div className="flex items-center gap-2">
              <span className="flex size-3.5 shrink-0 items-center justify-center">
                {section.state === "done" ? (
                  <CheckIcon className="text-foreground/35 size-3" />
                ) : section.state === "writing" ? (
                  <Loader2Icon className="size-3 animate-spin text-blue-500 motion-reduce:animate-none dark:text-blue-400" />
                ) : (
                  <span
                    aria-hidden
                    className="bg-foreground/15 size-1.5 rounded-full"
                  />
                )}
              </span>
              <span
                className={cn(
                  "min-w-0 flex-1 truncate text-[13px]",
                  section.state === "pending"
                    ? "text-foreground/35"
                    : "text-foreground/85",
                )}
              >
                {section.heading}
              </span>
              {section.sources > 0 && (
                <span className={cn(mono, "text-foreground/25 shrink-0")}>
                  {section.sources} src
                </span>
              )}
            </div>
            {section.preview && (
              <p className="text-foreground/50 fade-in animate-in ps-5.5 text-xs leading-relaxed duration-300">
                {section.preview}
              </p>
            )}
          </div>
        ))}
      </div>
    </div>
  );
}