shadimage
retab
blockclassify-consensus-viewer-blocksource: ok · hit

Classify Consensus

A classification result with consensus visible in the sidebar: selected category, likelihood, and every vote beside the source document.

npx shadcn add "https://ui.retab.com/r/classify-consensus-viewer-block.json"
free

Where it goes

sidebardashboard-shell

Interaction

dropdown

Use cases

dashboard

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

@retab/file-viewer@retab/pdf-viewer

Files4 · source cached 10/9/2026, 12:42:22 PM

registry/new-york-v4/blocks/classify-consensus-viewer-block.tsx
"use client";

import { ClassifyConsensusBlock } from "@/components/viewers/classify/classify-consensus-block";
import type { ViewerSource } from "@/components/ui/file-viewer";
import type { ClassifyResult } from "@/components/viewers/lib/classify-types";

const LOAN_APPLICATION_SOURCE: ViewerSource = {
  kind: "url",
  url: "/samples/loan-application.pdf",
  fileName: "loan-application.pdf",
};

const CLASSIFY_RESULT: ClassifyResult = {
  category: "Loan Application",
  candidates: [
    {
      category: "Loan Application",
      description: "Uniform Residential Loan Application Form 1003.",
    },
    {
      category: "Tax Form",
      description: "Structured form, but no IRS tax identifiers.",
    },
    {
      category: "Bank Statement",
      description: "Financial fields are present, but no transactions.",
    },
  ],
  reasoning:
    "The document is a Uniform Residential Loan Application (Form 1003): it collects borrower, employment, and property details for a mortgage request.",
  consensus: {
    likelihoods: 0.67,
    choices: [
      {
        category: "Loan Application",
        reasoning:
          "The document is a Form 1003 loan application with borrower, employment, and property sections.",
      },
      {
        category: "Loan Application",
        reasoning:
          "The mortgage application fields and declarations match a loan application packet.",
      },
      {
        category: "Tax Form",
        reasoning:
          "The form is structured and financial, but it does not contain transaction rows or statement periods.",
      },
    ],
  },
};

export function ClassifyConsensusViewerBlock() {
  return (
    <ClassifyConsensusBlock
      source={LOAN_APPLICATION_SOURCE}
      result={CLASSIFY_RESULT}
      sidebarWidth="18rem"
      sidebarLabel="Classification consensus"
      minHeightClassName="min-h-[680px]"
    />
  );
}
components/viewers/classify/classify-consensus-block.tsx
"use client";

import { CheckCircle2 } from "lucide-react";

import { cn } from "@/lib/utils";
import {
  FileViewer,
  FileViewerContent,
  FileViewerControls,
  FileViewerHeader,
  FileViewerInset,
  FileViewerProvider,
  FileViewerSidebar,
  FileViewerSidebarContent,
  FileViewerSidebarTrigger,
  FileViewerTitle,
  FileViewerViewport,
  type ViewerSource,
} from "@/components/ui/file-viewer";
import { PdfViewerPages, PdfViewerProvider } from "@/components/ui/pdf-viewer";
import { ClassifierViewerProvider } from "@/components/viewers/classify/classifier-viewer";
import type {
  ClassifyCandidate,
  ClassifyResult,
} from "@/components/viewers/lib/classify-types";

export type ClassifyConsensusBlockProps = {
  source: ViewerSource;
  result: ClassifyResult;
  sidebarWidth?: string;
  sidebarLabel?: string;
  minHeightClassName?: string;
};

export function ClassifyConsensusBlock({
  source,
  result,
  sidebarWidth = "18rem",
  sidebarLabel = "Classification consensus",
  minHeightClassName = "min-h-[680px]",
}: ClassifyConsensusBlockProps) {
  return (
    <div
      className={cn("bg-background flex h-full flex-col", minHeightClassName)}
    >
      <ClassifierViewerProvider result={result}>
        <FileViewerProvider source={source} defaultSidebarOpen>
          <FileViewer className="bg-background">
            <PdfViewerProvider>
              <FileViewerHeader>
                <FileViewerSidebarTrigger className="-ms-1" />
                <FileViewerTitle />
                <FileViewerControls />
              </FileViewerHeader>
              <FileViewerContent>
                <ClassifyConsensusSidebar
                  result={result}
                  width={sidebarWidth}
                  label={sidebarLabel}
                />
                <FileViewerInset>
                  <FileViewerViewport>
                    <PdfViewerPages bare className="h-full" />
                  </FileViewerViewport>
                </FileViewerInset>
              </FileViewerContent>
            </PdfViewerProvider>
          </FileViewer>
        </FileViewerProvider>
      </ClassifierViewerProvider>
    </div>
  );
}

function ClassifyConsensusSidebar({
  result,
  width,
  label,
}: {
  result: ClassifyResult;
  width: string;
  label: string;
}) {
  return (
    <FileViewerSidebar aria-label={label} width={width} className="border-r">
      <FileViewerSidebarContent>
        <ClassifyConsensusLegend result={result} />
        <ClassifyConsensusDetails result={result} />
      </FileViewerSidebarContent>
    </FileViewerSidebar>
  );
}

function ClassifyConsensusLegend({ result }: { result: ClassifyResult }) {
  const candidates = normalizeClassifyCandidates({
    candidates: result.candidates ?? [],
    category: result.category,
  });
  const consensus = result.consensus;
  const likelihood =
    typeof consensus?.likelihoods === "number" ? consensus.likelihoods : null;

  if (candidates.length === 0) return null;

  return (
    <div className="bg-background flex shrink-0 flex-col gap-2 px-3 py-2">
      <div className="flex min-w-0 flex-wrap items-start gap-2">
        {candidates.map((candidate) => {
          const isActive = candidate.category === result.category;
          return (
            <div
              className={cn(
                "flex max-w-full min-w-0 items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs",
                isActive
                  ? "text-foreground border-emerald-500 bg-emerald-500/10"
                  : "border-border bg-muted/30 text-muted-foreground",
              )}
              key={candidate.category}
              title={candidate.description}
            >
              {isActive ? (
                <CheckCircle2
                  aria-hidden="true"
                  className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400"
                />
              ) : null}
              <span className="min-w-0 font-medium break-words">
                {candidate.category}
              </span>
              {isActive && likelihood !== null ? (
                <span
                  className={cn(
                    "shrink-0 rounded-sm px-1.5 py-0.5 text-[10px] font-medium tabular-nums",
                    getConfidenceClassName(likelihood),
                  )}
                >
                  {formatPercent(likelihood)}
                </span>
              ) : null}
            </div>
          );
        })}
      </div>
    </div>
  );
}

function ClassifyConsensusDetails({
  result,
}: {
  result: ClassifyResult;
}) {
  const consensus = result.consensus;
  const choices = consensus?.choices ?? [];

  return (
    <div className="space-y-4 px-3 pt-3 pb-3">
      {choices.length > 0 ? (
        <div className="space-y-2">
          <div className="text-muted-foreground flex items-center gap-2 text-xs font-medium">
            <span>Votes</span>
          </div>
          {choices.map((choice, index) => {
            const isWinner = choice.category === result.category;
            return (
              <div
                key={`${choice.category}-${index}`}
                className="border-border/70 rounded-md border px-2 py-1.5"
              >
                <div className="flex min-w-0 items-center justify-between gap-2">
                  <span className="min-w-0 text-xs font-medium break-words">
                    Vote {index + 1}
                  </span>
                  <span
                    className={cn(
                      "max-w-[9rem] shrink-0 rounded-sm px-1.5 py-0.5 text-right text-[10px] font-medium break-words",
                      isWinner
                        ? "bg-emerald-500/12 text-emerald-700"
                        : "bg-amber-500/12 text-amber-700",
                    )}
                  >
                    {choice.category}
                  </span>
                </div>
                {choice.reasoning ? (
                  <p className="text-muted-foreground mt-1 text-[11px] leading-4">
                    {choice.reasoning}
                  </p>
                ) : null}
              </div>
            );
          })}
        </div>
      ) : null}
    </div>
  );
}

function formatPercent(value: number) {
  return `${Math.round(value * 100)}%`;
}

function getConfidenceClassName(value: number) {
  if (value >= 0.9) return "bg-emerald-500/12 text-emerald-700";
  if (value >= 0.67) return "bg-amber-500/12 text-amber-700";
  return "bg-destructive/10 text-destructive";
}

function normalizeClassifyCandidates({
  candidates,
  category,
}: {
  candidates: readonly ClassifyCandidate[];
  category: string;
}) {
  if (candidates.length === 0) return [{ category }];
  if (candidates.some((candidate) => candidate.category === category)) {
    return candidates;
  }
  return [{ category }, ...candidates];
}
components/viewers/classify/classifier-viewer.tsx
"use client";

import * as React from "react";
import { CheckCircle2, Loader2, Tags } from "lucide-react";

import { cn } from "@/lib/utils";
import {
  ViewerBody,
  ViewerHeader,
  ViewerRoot,
  ViewerSurface,
} from "@/components/ui/viewer";
import type {
  ClassifyCandidate,
  ClassifyResult,
} from "@/components/viewers/lib/classify-types";

type ClassifierViewerContextValue = {
  category: string | null;
  candidates: readonly ClassifyCandidate[];
  emptyDescription: string;
  emptyTitle: string;
  isProcessing: boolean;
  reasoning: string | null;
  result: ClassifyResult | null;
};

type ClassifierViewerHeaderState = {
  category: string | null;
};

type ClassifierViewerLegendState = {
  candidates: readonly ClassifyCandidate[];
  category: string | null;
  reasoning: string | null;
};

type ClassifierViewerEmptyStatusState = {
  emptyDescription: string;
  emptyTitle: string;
  isProcessing: boolean;
};

type ClassifierViewerDocumentState = {
  hasOutput: boolean;
};

const ClassifierViewerContext =
  React.createContext<ClassifierViewerContextValue | null>(null);
const EMPTY_CLASSIFY_CANDIDATES: readonly ClassifyCandidate[] = [];

export interface ClassifierViewerProviderProps {
  result: ClassifyResult | null;
  isProcessing?: boolean;
  emptyTitle?: string;
  emptyDescription?: string;
  children: React.ReactNode;
}

export interface ClassifierViewerProps {
  result: ClassifyResult | null;
  isProcessing?: boolean;
  emptyTitle?: string;
  emptyDescription?: string;
  document?: React.ReactNode;
}

function useClassifierViewerContext(): ClassifierViewerContextValue {
  const context = React.useContext(ClassifierViewerContext);
  if (!context) {
    throw new Error(
      "useClassifierViewer must be used within ClassifierViewerProvider.",
    );
  }
  return context;
}

function useClassifierViewerHeader(): ClassifierViewerHeaderState {
  const { category } = useClassifierViewerContext();

  return {
    category,
  };
}

function useClassifierViewerLegend(): ClassifierViewerLegendState {
  const { candidates, category, reasoning } = useClassifierViewerContext();

  return {
    candidates,
    category,
    reasoning,
  };
}

function useClassifierViewerEmpty(): ClassifierViewerEmptyStatusState {
  const { emptyDescription, emptyTitle, isProcessing } =
    useClassifierViewerContext();
  return {
    emptyDescription,
    emptyTitle,
    isProcessing,
  };
}

function useClassifierViewerDocument(): ClassifierViewerDocumentState {
  return {
    hasOutput: useClassifierViewerContext().category !== null,
  };
}

export function ClassifierViewerProvider({
  result,
  isProcessing = false,
  emptyTitle = "Run classify to see output",
  emptyDescription = "Provide input, define categories, and click Run Classify",
  children,
}: ClassifierViewerProviderProps) {
  const category = result?.category ?? null;
  const candidates = result?.candidates ?? EMPTY_CLASSIFY_CANDIDATES;
  const reasoning = result?.reasoning?.trim() || null;
  const value = React.useMemo<ClassifierViewerContextValue>(
    () => ({
      category,
      candidates,
      emptyDescription,
      emptyTitle,
      isProcessing,
      reasoning,
      result,
    }),
    [
      category,
      candidates,
      emptyDescription,
      emptyTitle,
      isProcessing,
      reasoning,
      result,
    ],
  );

  return (
    <ClassifierViewerContext.Provider value={value}>
      {children}
    </ClassifierViewerContext.Provider>
  );
}

export function ClassifierViewerHeader({ className }: { className?: string }) {
  const { category } = useClassifierViewerHeader();

  if (!category) return null;

  const categoryNode = (
    <span className="bg-background text-foreground inline-flex min-h-7 min-w-0 items-center rounded-md border px-2.5 text-sm font-medium">
      <span className="truncate">{category}</span>
    </span>
  );

  return (
    <ViewerHeader
      className={cn(
        "bg-background flex min-h-10 items-center gap-3 px-3 py-2",
        className,
      )}
    >
      <div className="flex min-w-0 flex-1 items-center gap-2">
        <Tags className="text-muted-foreground size-4 shrink-0" />
        <span className="shrink-0 text-sm font-medium">Classification</span>
        <span className="min-w-0">{categoryNode}</span>
      </div>
    </ViewerHeader>
  );
}

export function ClassifierViewerLegend({ className }: { className?: string }) {
  const { candidates, category, reasoning } = useClassifierViewerLegend();

  if (!category) return null;

  const normalizedCandidates = normalizeClassifierCandidates({
    candidates,
    category,
  });

  if (normalizedCandidates.length === 0 && !reasoning) return null;

  return (
    <div
      className={cn(
        "bg-background flex flex-shrink-0 flex-col gap-2 border-b px-3 py-2",
        className,
      )}
    >
      {normalizedCandidates.length > 0 ? (
        <div className="flex min-w-0 flex-wrap items-center gap-2">
          {normalizedCandidates.map((candidate) => {
            const isActive = candidate.category === category;
            return (
              <div
                className={cn(
                  "flex min-w-0 items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs",
                  isActive
                    ? "border-emerald-300 bg-emerald-500/10 text-emerald-950"
                    : "border-border bg-muted/30 text-muted-foreground",
                )}
                key={candidate.category}
                title={candidate.description}
              >
                {isActive ? (
                  <CheckCircle2
                    aria-hidden="true"
                    className="size-3.5 shrink-0"
                  />
                ) : null}
                <span className="min-w-0 truncate font-medium">
                  {candidate.category}
                </span>
              </div>
            );
          })}
        </div>
      ) : null}

      {reasoning ? (
        <p className="text-muted-foreground max-h-10 overflow-hidden text-xs leading-5">
          {reasoning}
        </p>
      ) : null}
    </div>
  );
}

export function ClassifierViewerEmptyState() {
  const { emptyDescription, emptyTitle, isProcessing } =
    useClassifierViewerEmpty();

  return (
    <div className="bg-background text-muted-foreground flex h-full flex-1 flex-col items-center justify-center gap-4 px-8">
      {isProcessing ? (
        <>
          <Loader2 className="text-primary h-12 w-12 animate-spin" />
          <p className="text-muted-foreground text-center text-base">
            Classifying...
          </p>
        </>
      ) : (
        <>
          <Tags className="text-muted-foreground h-16 w-16" />
          <p className="text-muted-foreground text-center text-base">
            {emptyTitle}
          </p>
          <p className="text-muted-foreground max-w-sm text-center text-sm">
            {emptyDescription}
          </p>
        </>
      )}
    </div>
  );
}

export function ClassifierViewerDocument({
  document,
}: {
  document?: React.ReactNode;
}) {
  const { hasOutput } = useClassifierViewerDocument();

  if (!hasOutput) return <ClassifierViewerEmptyState />;

  return document ? (
    <div className="flex min-h-0 min-w-0 flex-1 flex-col">{document}</div>
  ) : (
    <div className="flex h-full flex-1 items-center justify-center">
      <span className="text-muted-foreground text-sm">
        No document available
      </span>
    </div>
  );
}

export function ClassifierViewer({
  result,
  isProcessing = false,
  emptyTitle,
  emptyDescription,
  document,
}: ClassifierViewerProps) {
  return (
    <ClassifierViewerProvider
      result={result}
      isProcessing={isProcessing}
      emptyTitle={emptyTitle}
      emptyDescription={emptyDescription}
    >
      <ViewerRoot className="bg-background h-full flex-1">
        <ViewerBody>
          <ViewerSurface>
            <ClassifierViewerDocument document={document} />
          </ViewerSurface>
        </ViewerBody>
      </ViewerRoot>
    </ClassifierViewerProvider>
  );
}

function normalizeClassifierCandidates({
  candidates,
  category,
}: {
  candidates: readonly ClassifyCandidate[];
  category: string;
}): readonly ClassifyCandidate[] {
  if (candidates.length === 0) {
    return [{ category }];
  }

  if (candidates.some((candidate) => candidate.category === category)) {
    return candidates;
  }

  return [{ category }, ...candidates];
}
components/viewers/lib/classify-types.ts
// Classification result shape for the classifier viewer.

export interface ClassifyResult {
  category: string;
  reasoning?: string;
  candidates?: readonly ClassifyCandidate[];
  consensus?: ClassifyConsensus | null;
}

export interface ClassifyCandidate {
  category: string;
  description?: string;
}

export interface ClassifyConsensus {
  choices?: readonly ClassifyConsensusChoice[];
  /** Consensus likelihood score (0.0-1.0) of the winning classification. */
  likelihoods?: number | null;
}

export interface ClassifyConsensusChoice {
  category: string;
  reasoning?: string;
}

More from retab

Browse all components in this library →