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 · lighticonslucide-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;
}