chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
const AGENT_URL =
process.env.AGENT_URL ||
process.env.LANGGRAPH_DEPLOYMENT_URL ||
"http://localhost:8123";
if (!process.env.AGENT_URL && !process.env.LANGGRAPH_DEPLOYMENT_URL) {
console.warn(
"[copilotkit/route] WARNING: No AGENT_URL or LANGGRAPH_DEPLOYMENT_URL set, falling back to localhost:8123",
);
}
console.log("[copilotkit/route] Initializing CopilotKit runtime");
console.log(`[copilotkit/route] AGENT_URL: ${AGENT_URL}`);
function createAgent(graphId: string = "sample_agent") {
return new LangGraphAgent({
deploymentUrl: AGENT_URL,
graphId,
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
});
}
const agentNames = [
"sample_agent",
"agentic_chat",
"human_in_the_loop",
"tool-rendering",
"gen-ui-tool-based",
"gen-ui-agent",
"shared-state-read",
"shared-state-write",
"shared-state-streaming",
"subagents",
"default",
];
const agents: Record<string, LangGraphAgent> = {};
for (const name of agentNames) {
agents[name] = createAgent();
}
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-expect-error -- type wrapping mismatch, fixed in source pending release
agents,
}),
});
return await handleRequest(req);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error("[copilotkit/route] ERROR:", error);
return NextResponse.json({ error: message }, { status: 500 });
}
};
export const GET = async () => {
let agentStatus = "unknown";
try {
const res = await fetch(`${AGENT_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
agentStatus = res.ok ? "reachable" : `error (${res.status})`;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
agentStatus = `unreachable (${msg})`;
}
const topStatus = agentStatus.startsWith("unreachable") ? "degraded" : "ok";
return NextResponse.json({
status: topStatus,
agent_url: AGENT_URL,
agent_status: agentStatus,
env: {
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
NODE_ENV: process.env.NODE_ENV,
},
});
};
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
status: "ok",
integration: "{{SLUG}}",
timestamp: new Date().toISOString(),
});
}
@@ -0,0 +1,6 @@
/* CopilotKit overrides — EXACTLY matches Dojo, nothing more */
.copilotKitInput {
border-radius: 0.75rem;
border: 1px solid var(--copilot-kit-separator-color) !important;
}
@@ -0,0 +1,23 @@
@import "tailwindcss";
:root {
--copilot-kit-background-color: #fafaf9;
--copilot-kit-primary-color: #0d6e3f;
--copilot-kit-response-button-background-color: #f5f5f3;
--copilot-kit-response-button-color: #1a1a18;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
width: 100%;
overflow: auto;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #fafaf9;
color: #1a1a18;
}
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import "@copilotkit/react-core/v2/styles.css";
import "./globals.css";
import "./copilotkit-overrides.css";
export const metadata: Metadata = {
title: "CopilotKit Showcase — {{NAME}}",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<script
dangerouslySetInnerHTML={{
__html: `
console.log('[showcase] {{NAME}} demo loaded');
console.log('[showcase] URL:', window.location.href);
console.log('[showcase] In iframe:', window.self !== window.top);
window.addEventListener('error', function(e) {
console.error('[showcase] Uncaught error:', e.message, e.filename, e.lineno);
});
window.addEventListener('unhandledrejection', function(e) {
console.error('[showcase] Unhandled rejection:', e.reason);
});
`,
}}
/>
{children}
</body>
</html>
);
}
@@ -0,0 +1,96 @@
"use client";
import React from "react";
import { CopilotKit } from "@copilotkit/react-core";
import { CopilotSidebar } from "@copilotkit/react-core/v2";
import { SalesDashboard } from "../components/sales-dashboard";
import { RendererSelector } from "../components/renderers/renderer-selector";
import { useRenderMode } from "../components/renderers/use-render-mode";
import { useShowcaseHooks } from "../hooks/use-showcase-hooks";
import { useShowcaseSuggestions } from "../hooks/use-showcase-suggestions";
import { ToolBasedDashboard } from "../components/renderers/tool-based";
import { A2UIDashboard } from "../components/renderers/a2ui";
import {
HashBrownDashboard,
useHashBrownMessageRenderer,
} from "../components/renderers/hashbrown";
const AGENT_ID = "sample_agent";
function ToolBasedPage() {
return <ToolBasedDashboard agentId={AGENT_ID} />;
}
function A2UIPage() {
return <A2UIDashboard agentId={AGENT_ID} />;
}
function JsonRenderPage() {
// json-render falls back to tool-based with a note
return (
<div>
<div className="bg-yellow-50 border border-yellow-200 text-yellow-800 text-sm px-4 py-2 text-center">
json-render is not yet available as a standalone starter. Showing
tool-based rendering instead.
</div>
<ToolBasedDashboard agentId={AGENT_ID} />
</div>
);
}
function HashBrownInner() {
const RenderMessage = useHashBrownMessageRenderer();
useShowcaseHooks();
useShowcaseSuggestions();
return (
<CopilotKit runtimeUrl="/api/copilotkit" agent={AGENT_ID}>
<div className="min-h-screen w-full flex items-center justify-center">
<SalesDashboard agentId={AGENT_ID} />
<CopilotSidebar
defaultOpen={true}
labels={{ modalHeaderTitle: "Sales Dashboard Assistant" }}
RenderMessage={RenderMessage}
/>
</div>
</CopilotKit>
);
}
function HashBrownPage() {
return (
<HashBrownDashboard>
<HashBrownInner />
</HashBrownDashboard>
);
}
export default function Home() {
const { mode, setMode } = useRenderMode();
const renderDashboard = () => {
switch (mode) {
case "tool-based":
return <ToolBasedPage />;
case "a2ui":
return <A2UIPage />;
case "json-render":
return <JsonRenderPage />;
case "hashbrown":
return <HashBrownPage />;
default:
return <ToolBasedPage />;
}
};
return (
<div className="h-screen flex flex-col">
<header className="sticky top-0 z-[60] border-b border-[var(--border)] bg-[var(--card)] px-6 py-3 flex items-center justify-between shrink-0">
<h1 className="text-sm font-bold text-[var(--foreground)]">
CopilotKit Sales Dashboard
</h1>
<RendererSelector mode={mode} onModeChange={setMode} />
</header>
<main className="flex-1 overflow-hidden">{renderDashboard()}</main>
</div>
);
}
@@ -0,0 +1,180 @@
import { useRef } from "react";
import {
BarChart as RechartsBarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Cell,
ResponsiveContainer,
Rectangle,
} from "recharts";
import { z } from "zod";
import { CHART_COLORS, CHART_CONFIG } from "./chart-config";
export const BarChartProps = z.object({
title: z.string().describe("Chart title"),
description: z.string().describe("Brief description or subtitle"),
data: z.array(
z.object({
label: z.string(),
value: z.number(),
}),
),
});
type BarChartPropsType = z.infer<typeof BarChartProps>;
/** Tracks seen indices so only NEW bars get the fade-in animation. */
function useSeenIndices() {
const seen = useRef(new Set<number>());
return {
isNew(index: number) {
if (seen.current.has(index)) return false;
seen.current.add(index);
return true;
},
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function AnimatedBar(props: any) {
const { isNew, ...rest } = props;
return (
<g
style={
isNew
? {
animation: "barSlideIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both",
}
: undefined
}
>
<Rectangle {...rest} />
</g>
);
}
export function BarChart({ title, description, data }: BarChartPropsType) {
const { isNew } = useSeenIndices();
if (!data || !Array.isArray(data) || data.length === 0) {
return (
<div className="max-w-2xl mx-auto my-4 rounded-lg border border-[var(--border)] bg-[var(--card)]">
<div className="p-6">
<div className="flex items-center gap-2">
<svg
className="h-4 w-4 text-[var(--muted-foreground)]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
{title}
</h3>
</div>
<p className="text-sm text-[var(--muted-foreground)]">
{description}
</p>
</div>
<div className="p-6 pt-0">
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
No data available
</p>
</div>
</div>
);
}
return (
<div className="max-w-2xl mx-auto my-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]">
{/* Scoped keyframe -- no globals.css needed */}
<style>{`
@keyframes barSlideIn {
from { transform: translateY(40px); opacity: 0; }
20% { opacity: 1; }
to { transform: translateY(0); opacity: 1; }
}
`}</style>
<div className="p-6 pb-2">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center h-6 w-6 rounded-md bg-[var(--secondary)]">
<svg
className="h-3.5 w-3.5 text-[var(--muted-foreground)]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
</div>
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
{title}
</h3>
</div>
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
</div>
<div className="p-6 pt-2">
<ResponsiveContainer width="100%" height={280}>
<RechartsBarChart
data={data}
margin={{ top: 12, right: 12, bottom: 4, left: -8 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
vertical={false}
/>
<XAxis
dataKey="label"
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
tickLine={false}
axisLine={false}
/>
<Tooltip
contentStyle={CHART_CONFIG.tooltipStyle}
cursor={{ fill: "var(--secondary)", opacity: 0.5 }}
/>
<Bar
isAnimationActive={false}
dataKey="value"
radius={[6, 6, 0, 0]}
maxBarSize={48}
shape={(props: unknown) => {
const p = props as Record<string, unknown>;
return <AnimatedBar {...p} isNew={isNew(p.index as number)} />;
}}
>
{data.map((_, index) => (
<Cell
key={index}
fill={CHART_COLORS[index % CHART_COLORS.length]}
/>
))}
</Bar>
</RechartsBarChart>
</ResponsiveContainer>
</div>
</div>
);
}
@@ -0,0 +1,25 @@
/**
* CopilotKit brand chart palette -- Plus Jakarta Sans / brand color system.
*/
export const CHART_COLORS = [
"#BEC2FF", // lilac-400
"#85ECCE", // mint-400
"#FFAC4D", // orange-400
"#FFF388", // yellow-400
"#189370", // mint-800
"#EEE6FE", // primary-100
"#FA5F67", // red-400
] as const;
export const CHART_CONFIG = {
tooltipStyle: {
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "10px",
padding: "10px 14px",
color: "var(--foreground)",
fontSize: "13px",
fontFamily: "var(--font-body)",
boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
},
};
@@ -0,0 +1,154 @@
import { z } from "zod";
import { CHART_COLORS } from "./chart-config";
export const PieChartProps = z.object({
title: z.string().describe("Chart title"),
description: z.string().describe("Brief description or subtitle"),
data: z.array(
z.object({
label: z.string(),
value: z.number(),
}),
),
});
type PieChartPropsType = z.infer<typeof PieChartProps>;
/** Custom SVG donut chart built with <circle> + stroke-dasharray. */
function DonutChart({
data,
size = 240,
strokeWidth = 40,
}: {
data: { label: string; value: number }[];
size?: number;
strokeWidth?: number;
}) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const center = size / 2;
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
// Calculate each slice's arc length and starting position
let accumulated = 0;
const slices = data.map((item, index) => {
const val = Number(item.value) || 0;
const ratio = total > 0 ? val / total : 0;
const arc = ratio * circumference;
const startAt = accumulated;
accumulated += arc;
return {
...item,
arc,
gap: circumference - arc,
// Negative dashoffset shifts the dash forward (clockwise) to the correct position
dashoffset: -startAt,
color: CHART_COLORS[index % CHART_COLORS.length],
};
});
return (
<svg
width="100%"
viewBox={`0 0 ${size} ${size}`}
className="block mx-auto"
style={{ maxWidth: size, transform: "scaleX(-1)" }}
>
{/* Background ring */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="var(--secondary)"
strokeWidth={strokeWidth}
/>
{/* Data slices */}
{slices.map((slice, i) => (
<circle
key={i}
cx={center}
cy={center}
r={radius}
fill="none"
stroke={slice.color}
strokeWidth={strokeWidth}
strokeDasharray={`${slice.arc} ${slice.gap}`}
strokeDashoffset={slice.dashoffset}
strokeLinecap="butt"
transform={`rotate(-90 ${center} ${center})`}
/>
))}
</svg>
);
}
export function PieChart({ title, description, data }: PieChartPropsType) {
if (!data || !Array.isArray(data) || data.length === 0) {
return (
<div className="max-w-lg mx-auto my-4 rounded-lg border border-[var(--border)] bg-[var(--card)]">
<div className="p-6 pb-0">
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
{title}
</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{description}
</p>
</div>
<div className="p-6">
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
No data available
</p>
</div>
</div>
);
}
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
return (
<div className="max-w-lg mx-auto my-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]">
<div className="p-6 pb-0">
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
{title}
</h3>
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
</div>
<div className="p-6 pt-4">
<DonutChart data={data} />
{/* Legend */}
<div className="space-y-2 pt-4">
{data.map((item, index) => {
const val = Number(item.value) || 0;
const pct = total > 0 ? ((val / total) * 100).toFixed(0) : 0;
return (
<div
key={index}
className="flex items-center gap-3 text-sm transition-opacity duration-300 ease-out"
style={{ opacity: 1 }}
>
<span
className="inline-block h-3 w-3 rounded-full shrink-0"
style={{
backgroundColor: CHART_COLORS[index % CHART_COLORS.length],
}}
/>
<span className="flex-1 text-[var(--foreground)] truncate">
{item.label}
</span>
<span className="text-[var(--muted-foreground)] tabular-nums">
{val.toLocaleString()}
</span>
<span className="text-[var(--muted-foreground)] text-sm w-10 text-right tabular-nums">
{pct}%
</span>
</div>
);
})}
</div>
</div>
</div>
);
}
@@ -0,0 +1,220 @@
import { useState } from "react";
export interface TimeSlot {
date: string;
time: string;
duration?: string;
}
export interface MeetingTimePickerProps {
status: "inProgress" | "executing" | "complete";
respond?: (response: string) => void;
reasonForScheduling?: string;
meetingDuration?: number;
title?: string;
timeSlots?: TimeSlot[];
}
export function MeetingTimePicker({
status,
respond,
reasonForScheduling,
meetingDuration,
title = "Schedule a Meeting",
timeSlots = [
{ date: "Tomorrow", time: "2:00 PM", duration: "30 min" },
{ date: "Friday", time: "10:00 AM", duration: "30 min" },
{ date: "Next Monday", time: "3:00 PM", duration: "30 min" },
],
}: MeetingTimePickerProps) {
const displayTitle = reasonForScheduling || title;
const slots = meetingDuration
? timeSlots.map((slot) => ({ ...slot, duration: `${meetingDuration} min` }))
: timeSlots;
const [selectedSlot, setSelectedSlot] = useState<TimeSlot | null>(null);
const [declined, setDeclined] = useState(false);
const handleSelectSlot = (slot: TimeSlot) => {
setSelectedSlot(slot);
respond?.(
`Meeting scheduled for ${slot.date} at ${slot.time}${slot.duration ? ` (${slot.duration})` : ""}.`,
);
};
const handleDecline = () => {
setDeclined(true);
respond?.(
"The user declined all proposed meeting times. Please suggest alternative times or ask for their availability.",
);
};
// Confirmed state
if (selectedSlot) {
return (
<div className="max-w-md w-full mx-auto mb-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]">
<div className="p-6">
<div className="flex flex-col items-center text-center gap-3">
<div className="flex items-center justify-center h-10 w-10 rounded-full bg-[#189370]">
<svg
className="h-5 w-5 text-white"
strokeWidth={3}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<div>
<h3 className="text-lg font-bold text-[var(--foreground)]">
Meeting Scheduled
</h3>
<p className="text-sm text-[var(--muted-foreground)] mt-1">
{selectedSlot.date} at {selectedSlot.time}
</p>
</div>
{selectedSlot.duration && (
<span className="inline-flex items-center rounded-full border border-[var(--border)] bg-[var(--secondary)] px-2.5 py-0.5 text-xs font-semibold text-[var(--secondary-foreground)]">
<svg
className="h-3 w-3 mr-1"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
{selectedSlot.duration}
</span>
)}
</div>
</div>
</div>
);
}
// Declined state
if (declined) {
return (
<div className="max-w-md w-full mx-auto mb-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]">
<div className="p-6">
<div className="flex flex-col items-center text-center gap-3">
<div className="flex items-center justify-center h-12 w-12 rounded-full bg-[var(--secondary)]">
<svg
className="h-6 w-6 text-[var(--muted-foreground)]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</div>
<div>
<h3 className="text-lg font-bold text-[var(--foreground)]">
No Time Selected
</h3>
<p className="text-sm text-[var(--muted-foreground)] mt-1">
Looking for a better time that works for you
</p>
</div>
</div>
</div>
</div>
);
}
// Selection state
return (
<div className="max-w-md w-full mx-auto mb-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]">
<div className="p-6">
<div className="flex flex-col items-center text-center mb-5">
<div className="flex items-center justify-center h-12 w-12 rounded-full bg-[var(--accent)] mb-3">
<svg
className="h-6 w-6 text-[#BEC2FF]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</div>
<h3 className="text-lg font-bold text-[var(--foreground)]">
{displayTitle}
</h3>
<p className="text-sm text-[var(--muted-foreground)] mt-1">
{status === "inProgress"
? "Finding available times..."
: "Pick a time that works for you"}
</p>
</div>
{status === "inProgress" && (
<div className="flex justify-center py-6">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-[var(--muted)] border-t-[var(--foreground)]" />
</div>
)}
{status === "executing" && (
<div className="space-y-3">
{slots.map((slot, index) => (
<button
key={index}
onClick={() => handleSelectSlot(slot)}
className="group w-full px-6 py-5 rounded-[var(--radius)]
border border-[var(--border)]
hover:border-[var(--ring)] hover:bg-[var(--accent)]
transition-all duration-150 cursor-pointer
flex items-center gap-4"
>
<div className="flex-1 text-left">
<div className="font-semibold text-base text-[var(--foreground)]">
{slot.date}
</div>
<div className="text-sm text-[var(--muted-foreground)] mt-0.5">
{slot.time}
</div>
</div>
{slot.duration && (
<span className="shrink-0 text-sm px-3 py-1 inline-flex items-center rounded-full border border-[var(--border)] bg-[var(--secondary)] font-semibold text-[var(--secondary-foreground)]">
{slot.duration}
</span>
)}
<svg
className="h-4 w-4 text-[var(--muted-foreground)] opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
))}
<button
className="w-full mt-1 text-xs text-[var(--muted-foreground)] py-2 hover:bg-[var(--accent)] rounded-md transition-colors"
onClick={handleDecline}
>
None of these work
</button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,180 @@
/**
* Demonstration Catalog -- Component Definitions
*
* Platform-agnostic definitions: component names, props (Zod), descriptions.
* This is the contract between the app and the AI agent. Agents receive these
* definitions as context so they know what components are available.
*
* Renderers (React, React Native, etc.) import these definitions and provide
* platform-specific implementations, type-checked against the Zod schemas.
*/
import { z } from "zod";
/**
* Dynamic string: accepts either a literal string or a data-model path binding
* like `{ path: "airline" }`. The GenericBinder resolves path bindings to the
* actual value at render time.
*/
const DynString = z.union([z.string(), z.object({ path: z.string() })]);
/**
* Shared action schema used by interactive components (Button, FlightCard).
* The GenericBinder resolves `{ event }` objects to callable `() => void` at
* render time.
*/
const ActionSchema = z
.union([
z.object({
event: z.object({
name: z.string(),
context: z.record(z.any()).optional(),
}),
}),
z.null(),
])
.optional();
export const demonstrationCatalogDefinitions = {
Title: {
description: "A heading. Use for section titles and page headers.",
props: z.object({
text: z.string(),
level: z.string().optional(),
}),
},
// Text: removed -- the basic catalog's Text uses DynamicStringSchema
// which supports path bindings (e.g. { path: "flights[*].airline" }).
// Overriding it with z.string() breaks fixed-schema data binding.
Row: {
description: "Horizontal layout container.",
props: z.object({
gap: z.number().optional(),
align: z.string().optional(),
justify: z.string().optional(),
// Union with { componentId, path } so GenericBinder treats this as
// STRUCTURAL and resolves template children from the data model.
children: z.union([
z.array(z.string()),
z.object({ componentId: z.string(), path: z.string() }),
]),
}),
},
Column: {
description: "Vertical layout container.",
props: z.object({
gap: z.number().optional(),
align: z.string().optional(),
// Same union as Row -- required for template children support.
children: z.union([
z.array(z.string()),
z.object({ componentId: z.string(), path: z.string() }),
]),
}),
},
DashboardCard: {
description:
"A card container with title and optional subtitle. Has a 'child' slot for content (chart, metrics, etc). Use 'child' with a single component ID.",
props: z.object({
title: z.string(),
subtitle: z.string().optional(),
child: z.string().optional(),
}),
},
Metric: {
description:
"A key metric display with label, value, and optional trend indicator. Great for KPIs and stats.",
props: z.object({
label: z.string(),
value: z.string(),
trend: z.enum(["up", "down", "neutral"]).optional(),
trendValue: z.string().optional(),
}),
},
PieChart: {
description:
"A pie/donut chart. Provide data as array of {label, value, color} objects.",
props: z.object({
data: z.array(
z.object({
label: z.string(),
value: z.number(),
color: z.string().optional(),
}),
),
innerRadius: z.number().optional(),
}),
},
BarChart: {
description:
"A bar chart. Provide data as array of {label, value} objects.",
props: z.object({
data: z.array(z.object({ label: z.string(), value: z.number() })),
color: z.string().optional(),
}),
},
Badge: {
description:
"A small status badge/tag. Use for labels, statuses, categories.",
props: z.object({
text: z.string(),
variant: z
.enum(["success", "warning", "error", "info", "neutral"])
.optional(),
}),
},
DataTable: {
description: "A data table with columns and rows.",
props: z.object({
columns: z.array(z.object({ key: z.string(), label: z.string() })),
rows: z.array(z.record(z.any())),
}),
},
Button: {
description:
"An interactive button with an action event. Use 'child' with a Text component ID for the label. 'action' is dispatched on click.",
props: z.object({
child: z
.string()
.describe(
"The ID of the child component (e.g. a Text component for the label).",
),
variant: z.enum(["primary", "secondary", "ghost"]).optional(),
action: ActionSchema,
}),
},
FlightCard: {
description:
"A rich flight result card. Displays airline, flight number, route, times, duration, status, and price. Use inside a Row for side-by-side layout.",
props: z.object({
airline: DynString,
airlineLogo: DynString,
flightNumber: DynString,
origin: DynString,
destination: DynString,
date: DynString,
departureTime: DynString,
arrivalTime: DynString,
duration: DynString,
status: DynString,
statusColor: DynString.optional(),
price: DynString,
action: ActionSchema,
}),
},
};
/** Type helper for renderers */
export type DemonstrationCatalogDefinitions =
typeof demonstrationCatalogDefinitions;
@@ -0,0 +1,42 @@
"use client";
import React from "react";
import { CopilotKit } from "@copilotkit/react-core";
import { CopilotSidebar } from "@copilotkit/react-core/v2";
import { useShowcaseHooks } from "../../../hooks/use-showcase-hooks";
import { useShowcaseSuggestions } from "../../../hooks/use-showcase-suggestions";
import { SalesDashboard } from "../../sales-dashboard";
import { demonstrationCatalog } from "./renderers";
interface A2UIDashboardProps {
agentId: string;
}
function DashboardContent({ agentId }: A2UIDashboardProps) {
useShowcaseHooks();
useShowcaseSuggestions();
return (
<div className="min-h-screen w-full flex items-center justify-center">
<SalesDashboard agentId={agentId} />
<CopilotSidebar
defaultOpen={true}
labels={{
modalHeaderTitle: "Sales Dashboard Assistant",
}}
/>
</div>
);
}
export function A2UIDashboard({ agentId }: A2UIDashboardProps) {
return (
<CopilotKit
runtimeUrl="/api/copilotkit"
agent={agentId}
a2ui={{ catalog: demonstrationCatalog }}
>
<DashboardContent agentId={agentId} />
</CopilotKit>
);
}
@@ -0,0 +1,623 @@
/**
* A2UI Catalog -- React Renderers
*
* Each renderer maps a component name from definitions.ts to a React
* implementation. Props are type-checked against the Zod schemas.
*/
import React, { useState } from "react";
import {
PieChart as RechartsPie,
Pie,
Cell,
ResponsiveContainer,
BarChart as RechartsBar,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
} from "recharts";
import {
createCatalog,
type CatalogRenderers,
} from "@copilotkit/a2ui-renderer";
import {
demonstrationCatalogDefinitions,
type DemonstrationCatalogDefinitions,
} from "./definitions";
// --- Theme-aware colors ---
const c = {
card: "var(--card)",
cardFg: "var(--card-foreground)",
border: "var(--border)",
muted: "var(--muted-foreground)",
divider: "color-mix(in srgb, var(--border) 50%, var(--card))",
shadow: "0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04)",
btnBg: "color-mix(in srgb, var(--muted) 40%, var(--card))",
btnDoneBg: "color-mix(in srgb, #22c55e 10%, var(--card))",
};
function ActionButton({
label,
doneLabel,
action,
children: child,
}: {
label: string;
doneLabel: string;
action: unknown;
children?: React.ReactNode;
}) {
const [done, setDone] = useState(false);
return (
<button
disabled={done}
style={{
width: "100%",
padding: "10px 16px",
borderRadius: "10px",
border: done ? "1px solid #bbf7d0" : `1px solid ${c.border}`,
background: done ? c.btnDoneBg : c.btnBg,
color: done ? "#059669" : c.cardFg,
fontSize: "0.85rem",
fontWeight: 500,
cursor: done ? "default" : "pointer",
transition: "all 0.2s ease",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "6px",
}}
onClick={() => {
if (!done) {
(action as (() => void) | undefined)?.();
setDone(true);
}
}}
>
{done && (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="#059669"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
{done ? doneLabel : (child ?? label)}
</button>
);
}
// --- Renderers (type-checked against schema definitions) ---
const demonstrationCatalogRenderers: CatalogRenderers<DemonstrationCatalogDefinitions> =
{
Title: ({ props }) => {
const Tag = (
props.level === "h1" ? "h1" : props.level === "h3" ? "h3" : "h2"
) as keyof React.JSX.IntrinsicElements;
const sizes: Record<string, string> = {
h1: "1.75rem",
h2: "1.25rem",
h3: "1rem",
};
return (
<Tag
style={{
margin: 0,
fontWeight: 600,
fontSize: sizes[props.level ?? "h2"],
color: c.cardFg,
letterSpacing: "-0.01em",
}}
>
{props.text}
</Tag>
);
},
Row: ({ props, children }) => {
const justifyMap: Record<string, string> = {
start: "flex-start",
center: "center",
end: "flex-end",
spaceBetween: "space-between",
};
const items = Array.isArray(props.children) ? props.children : [];
return (
<div
style={{
display: "flex",
flexDirection: "row",
gap: `${props.gap ?? 16}px`,
alignItems: props.align ?? "stretch",
justifyContent:
justifyMap[props.justify ?? "start"] ?? "flex-start",
flexWrap: "wrap",
width: "100%",
}}
>
{items.map((item: unknown, i: number) => {
if (typeof item === "string")
return (
<div
key={`${item}-${i}`}
style={{ flex: "1 1 0", minWidth: 0 }}
>
{children(item)}
</div>
);
if (
item &&
typeof item === "object" &&
"id" in (item as Record<string, unknown>)
)
return (
<div
key={`${(item as Record<string, unknown>).id}-${i}`}
style={{ flex: "1 1 0", minWidth: 0 }}
>
{(
children as (
id: string,
basePath?: string,
) => React.ReactNode
)(
(item as Record<string, unknown>).id,
(item as Record<string, unknown>).basePath,
)}
</div>
);
return null;
})}
</div>
);
},
Column: ({ props, children }) => {
const items = Array.isArray(props.children) ? props.children : [];
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${props.gap ?? 12}px`,
width: "100%",
}}
>
{items.map((item: unknown, i: number) => {
if (typeof item === "string")
return (
<React.Fragment key={`${item}-${i}`}>
{children(item)}
</React.Fragment>
);
if (
item &&
typeof item === "object" &&
"id" in (item as Record<string, unknown>)
)
return (
<React.Fragment
key={`${(item as Record<string, unknown>).id}-${i}`}
>
{(
children as (
id: string,
basePath?: string,
) => React.ReactNode
)(
(item as Record<string, unknown>).id,
(item as Record<string, unknown>).basePath,
)}
</React.Fragment>
);
return null;
})}
</div>
);
},
DashboardCard: ({ props, children }) => (
<div
style={{
background: c.card,
borderRadius: "12px",
border: `1px solid ${c.border}`,
padding: "20px",
boxShadow: c.shadow,
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
<div>
<div style={{ fontWeight: 600, fontSize: "0.9rem", color: c.cardFg }}>
{props.title}
</div>
{props.subtitle && (
<div
style={{
fontSize: "0.75rem",
color: c.muted,
marginTop: "2px",
}}
>
{props.subtitle}
</div>
)}
</div>
{props.child && children(props.child)}
</div>
),
Metric: ({ props }) => {
const trendColors: Record<string, string> = {
up: "#059669",
down: "#dc2626",
neutral: c.muted,
};
const trendIcons: Record<string, string> = {
up: "\u2191",
down: "\u2193",
neutral: "\u2192",
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<span
style={{
fontSize: "0.75rem",
color: c.muted,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{props.label}
</span>
<div style={{ display: "flex", alignItems: "baseline", gap: "8px" }}>
<span
style={{
fontSize: "1.5rem",
fontWeight: 700,
color: c.cardFg,
letterSpacing: "-0.02em",
}}
>
{props.value}
</span>
{props.trend && props.trendValue && (
<span
style={{
fontSize: "0.8rem",
fontWeight: 500,
color: trendColors[props.trend] ?? c.muted,
}}
>
{trendIcons[props.trend]} {props.trendValue}
</span>
)}
</div>
</div>
);
},
PieChart: ({ props }) => {
const COLORS = [
"#3b82f6",
"#8b5cf6",
"#ec4899",
"#f59e0b",
"#10b981",
"#6366f1",
];
const data = props.data ?? [];
return (
<div style={{ width: "100%", height: 200 }}>
<ResponsiveContainer>
<RechartsPie>
<Pie
data={data}
dataKey="value"
nameKey="label"
cx="50%"
cy="50%"
innerRadius={props.innerRadius ?? 40}
outerRadius={80}
paddingAngle={2}
>
{data.map((entry: Record<string, unknown>, i: number) => (
<Cell
key={i}
fill={(entry.color as string) ?? COLORS[i % COLORS.length]}
/>
))}
</Pie>
<Tooltip />
</RechartsPie>
</ResponsiveContainer>
</div>
);
},
BarChart: ({ props }) => {
const data = props.data ?? [];
return (
<div style={{ width: "100%", height: 200 }}>
<ResponsiveContainer>
<RechartsBar data={data}>
<CartesianGrid strokeDasharray="3 3" stroke={c.divider} />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: c.muted }} />
<YAxis tick={{ fontSize: 11, fill: c.muted }} />
<Tooltip />
<Bar
dataKey="value"
fill={props.color ?? "#3b82f6"}
radius={[4, 4, 0, 0]}
/>
</RechartsBar>
</ResponsiveContainer>
</div>
);
},
Badge: ({ props }) => {
const variants: Record<string, { bg: string; color: string }> = {
success: { bg: "#dcfce7", color: "#166534" },
warning: { bg: "#fef3c7", color: "#92400e" },
error: { bg: "#fee2e2", color: "#991b1b" },
info: { bg: "#dbeafe", color: "#1e40af" },
neutral: { bg: "var(--muted)", color: c.cardFg },
};
const v = variants[props.variant ?? "neutral"] ?? variants.neutral;
return (
<span
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: "9999px",
fontSize: "0.7rem",
fontWeight: 500,
background: v.bg,
color: v.color,
}}
>
{props.text}
</span>
);
},
DataTable: ({ props }) => {
const cols = props.columns ?? [];
const rows = props.rows ?? [];
return (
<div style={{ overflowX: "auto", width: "100%" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "0.8rem",
}}
>
<thead>
<tr>
{cols.map((col: Record<string, unknown>) => (
<th
key={col.key as string}
style={{
textAlign: "left",
padding: "8px 12px",
borderBottom: `2px solid ${c.border}`,
color: c.muted,
fontWeight: 600,
fontSize: "0.7rem",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{col.label as string}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row: Record<string, unknown>, i: number) => (
<tr key={i} style={{ borderBottom: `1px solid ${c.divider}` }}>
{cols.map((col: Record<string, unknown>) => (
<td
key={col.key as string}
style={{ padding: "8px 12px", color: c.cardFg }}
>
{String(row[col.key as string] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
},
Button: ({ props, children }) => {
return (
<ActionButton label="Click" doneLabel="Done" action={props.action}>
{props.child ? children(props.child) : null}
</ActionButton>
);
},
FlightCard: ({ props: rawProps }) => {
// The binder resolves path bindings to strings at runtime.
const props = rawProps as Record<string, unknown>;
const statusColors: Record<string, string> = {
"On Time": "#22c55e",
Delayed: "#eab308",
Cancelled: "#ef4444",
};
const dotColor =
(props.statusColor as string) ??
statusColors[props.status as string] ??
"#22c55e";
return (
<div
style={{
border: `1px solid ${c.border}`,
borderRadius: "16px",
padding: "20px",
background: c.card,
color: c.cardFg,
minWidth: 260,
maxWidth: 340,
flex: "1 1 260px",
display: "flex",
flexDirection: "column",
gap: "12px",
boxShadow: c.shadow,
}}
>
{/* Header: airline + price */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<img
src={props.airlineLogo as string}
alt={props.airline as string}
style={{
width: 28,
height: 28,
borderRadius: "50%",
objectFit: "contain",
}}
/>
<span style={{ fontWeight: 600, fontSize: "0.95rem" }}>
{props.airline as string}
</span>
</div>
<span style={{ fontWeight: 700, fontSize: "1.15rem" }}>
{props.price as string}
</span>
</div>
{/* Meta */}
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "0.8rem",
color: c.muted,
}}
>
<span>{props.flightNumber as string}</span>
<span>{props.date as string}</span>
</div>
<hr
style={{
border: "none",
borderTop: `1px solid ${c.divider}`,
margin: 0,
}}
/>
{/* Times */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span style={{ fontWeight: 700, fontSize: "1.1rem" }}>
{props.departureTime as string}
</span>
<span style={{ fontSize: "0.75rem", color: c.muted }}>
{props.duration as string}
</span>
<span style={{ fontWeight: 700, fontSize: "1.1rem" }}>
{props.arrivalTime as string}
</span>
</div>
{/* Route */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "0.95rem",
fontWeight: 600,
}}
>
<span>{props.origin as string}</span>
<span style={{ color: c.muted }}>{"\u2192"}</span>
<span>{props.destination as string}</span>
</div>
<div
style={{
marginTop: "auto",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
<hr
style={{
border: "none",
borderTop: `1px solid ${c.divider}`,
margin: 0,
}}
/>
{/* Status */}
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: dotColor,
display: "inline-block",
}}
/>
<span style={{ fontSize: "0.8rem", color: c.muted }}>
{props.status as string}
</span>
</div>
<ActionButton
label="Select"
doneLabel="Selected"
action={props.action}
/>
</div>
</div>
);
},
};
// --- Assembled Catalog ---
export const demonstrationCatalog = createCatalog(
demonstrationCatalogDefinitions,
demonstrationCatalogRenderers,
{
catalogId: "copilotkit://app-dashboard-catalog",
},
);
@@ -0,0 +1,285 @@
"use client";
import React, { memo } from "react";
import { s, prompt } from "@hashbrownai/core";
import {
exposeComponent,
exposeMarkdown,
useUiKit,
useJsonParser,
} from "@hashbrownai/react";
import type { RenderMessageProps } from "@copilotkit/react-ui";
import type { AssistantMessage } from "@ag-ui/core";
import { PieChart } from "../../charts/pie-chart";
import { BarChart } from "../../charts/bar-chart";
import type { SalesStage } from "../../../types";
// ---------------------------------------------------------------------------
// Simple MetricCard with flat string props (optimized for structured output)
// ---------------------------------------------------------------------------
interface MetricCardProps {
label: string;
value: string;
trend?: string;
}
function MetricCard({ label, value, trend }: MetricCardProps) {
const isPositive =
trend?.startsWith("+") || trend?.toLowerCase().includes("up");
const isNegative =
trend?.startsWith("-") || trend?.toLowerCase().includes("down");
const trendColor = isPositive
? "text-green-600 dark:text-green-400"
: isNegative
? "text-red-600 dark:text-red-400"
: "text-[var(--muted-foreground)]";
return (
<div
data-testid="metric-card"
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4"
>
<p className="text-xs text-[var(--muted-foreground)] uppercase tracking-wider font-medium">
{label}
</p>
<p className="text-2xl font-bold text-[var(--foreground)] mt-1">
{value}
</p>
{trend && (
<p data-testid="metric-trend" className={`text-sm mt-1 ${trendColor}`}>
{trend}
</p>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Standalone DealCard for the kit (flat props, no SalesTodo dependency)
// ---------------------------------------------------------------------------
interface HashBrownDealCardProps {
title: string;
stage: SalesStage;
value: number;
assignee?: string;
dueDate?: string;
}
const STAGE_COLORS: Record<SalesStage, string> = {
prospect: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
qualified:
"bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
proposal: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200",
negotiation:
"bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200",
"closed-won":
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
"closed-lost": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
};
function DealCardComponent({
title,
stage,
value,
assignee,
dueDate,
}: HashBrownDealCardProps) {
const badgeClass =
STAGE_COLORS[stage] ??
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200";
return (
<div
data-testid="hashbrown-deal-card"
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4"
>
<h3 className="text-sm font-semibold leading-snug text-[var(--foreground)]">
{title}
</h3>
<div className="mt-2 flex items-center gap-2 flex-wrap">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass}`}
>
{stage}
</span>
<span className="text-sm font-semibold text-[var(--foreground)]">
${(value ?? 0).toLocaleString()}
</span>
</div>
<div className="mt-2 flex items-center gap-3 text-xs text-[var(--muted-foreground)]">
{assignee && <span>{assignee}</span>}
{dueDate && <span>Due {dueDate}</span>}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Kit definition
// ---------------------------------------------------------------------------
export function useSalesDashboardKit() {
return useUiKit({
examples: prompt`
# Mixing components and Markdown:
<ui>
<Markdown children="## Q4 Sales Summary" />
<metric label="Total Revenue" value="$1.2M" trend="+12% vs Q3" />
<Markdown children="Revenue breakdown by segment:" />
<pieChart title="Revenue by Segment" data='[{"label":"Enterprise","value":600000},{"label":"SMB","value":400000},{"label":"Startup","value":200000}]' />
<barChart title="Monthly Trend" data='[{"label":"Oct","value":350000},{"label":"Nov","value":400000},{"label":"Dec","value":450000}]' />
<dealCard title="Acme Corp Renewal" stage="negotiation" value="250000" assignee="Jane" dueDate="2024-12-31" />
</ui>
Hint: use Markdown for explanatory text between visual components.
Hint: always include title and data for charts.
`,
components: [
exposeMarkdown(),
exposeComponent(MetricCard, {
name: "metric",
description: "A KPI metric card with label, value, and optional trend",
props: {
label: s.string("The metric label/name"),
value: s.string("The metric value (formatted)"),
trend: s.string("Optional trend indicator, e.g. +12%").optional(),
},
}),
exposeComponent(PieChart, {
name: "pieChart",
description: "A donut/pie chart with title and data segments",
props: {
title: s.string("Chart title"),
description: s.string("Brief description or subtitle").optional(),
data: s.streaming.array(
s.object({
label: s.string("Segment label"),
value: s.number("Segment value"),
}),
),
},
}),
exposeComponent(BarChart, {
name: "barChart",
description: "A vertical bar chart with title and data bars",
props: {
title: s.string("Chart title"),
description: s.string("Brief description or subtitle").optional(),
data: s.streaming.array(
s.object({
label: s.string("Bar label"),
value: s.number("Bar value"),
}),
),
},
}),
exposeComponent(DealCardComponent, {
name: "dealCard",
description: "A sales deal card showing pipeline stage and value",
props: {
title: s.string("Deal title"),
stage: s.string(
"Pipeline stage: prospect | qualified | proposal | negotiation | closed-won | closed-lost",
),
value: s.number("Deal value in dollars"),
assignee: s.string("Who owns this deal").optional(),
dueDate: s.string("Expected close date").optional(),
},
}),
],
});
}
// ---------------------------------------------------------------------------
// Custom message renderer
// ---------------------------------------------------------------------------
const AssistantMessageRenderer = memo(function AssistantMessageRenderer({
message,
kit,
}: {
message: AssistantMessage;
kit: ReturnType<typeof useSalesDashboardKit>;
}) {
const { value } = useJsonParser(message.content ?? "", kit.schema);
if (!value) return null;
return (
<div className="mt-2 flex w-full justify-start">
<div className="w-full px-1 py-1">{kit.render(value)}</div>
</div>
);
});
// ---------------------------------------------------------------------------
// Exported dashboard component
// ---------------------------------------------------------------------------
export interface HashBrownDashboardProps {
/**
* Optional custom wrapper for the assistant message area.
* Defaults to rendering messages inline.
*/
children?: React.ReactNode;
}
/**
* Provider that instantiates the HashBrown kit ONCE and shares it via context.
* Both the dashboard layout and message renderer consume the same kit instance.
*
* The kit registers MetricCard, PieChart, BarChart, DealCard, and Markdown
* components. Agent context forwarding for output_schema is omitted because
* the npm-published react-core may not export useAgentContext yet.
*/
const HashBrownKitContext = React.createContext<ReturnType<
typeof useSalesDashboardKit
> | null>(null);
function useHashBrownKit() {
const kit = React.useContext(HashBrownKitContext);
if (!kit)
throw new Error("useHashBrownKit must be used within HashBrownDashboard");
return kit;
}
export function HashBrownDashboard({ children }: HashBrownDashboardProps) {
const kit = useSalesDashboardKit();
// Note: Agent context forwarding (useAgentContext) for output_schema is
// omitted because the npm-published react-core may not export it yet.
return (
<HashBrownKitContext.Provider value={kit}>
{children}
</HashBrownKitContext.Provider>
);
}
/**
* Stable message renderer component that consumes the kit from context.
* Defined at module level to avoid unstable function identity.
*/
function HashBrownRenderMessage({ message }: RenderMessageProps) {
const kit = useHashBrownKit();
if (message.role === "assistant") {
return (
<AssistantMessageRenderer
message={message as AssistantMessage}
kit={kit}
/>
);
}
return null;
}
/**
* Returns the stable HashBrownRenderMessage component.
* Must be used within a HashBrownDashboard provider.
*/
export function useHashBrownMessageRenderer() {
return HashBrownRenderMessage;
}
@@ -0,0 +1,53 @@
"use client";
import React from "react";
import { RenderMode, RENDER_STRATEGIES } from "./types";
export interface RendererSelectorProps {
/** The currently active render mode. */
mode: RenderMode;
/** Called when the user selects a different render mode. */
onModeChange: (mode: RenderMode) => void;
}
/**
* Horizontal pill-toggle for selecting a render strategy.
*
* Each pill shows the strategy icon and name. Hovering reveals a tooltip with
* the one-line description. The active pill is visually highlighted.
*/
export function RendererSelector({
mode,
onModeChange,
}: RendererSelectorProps) {
return (
<div
className="flex flex-wrap gap-2"
role="radiogroup"
aria-label="Render mode"
>
{RENDER_STRATEGIES.map((strategy) => {
const isActive = strategy.mode === mode;
return (
<button
key={strategy.mode}
role="radio"
aria-checked={isActive}
title={strategy.description}
onClick={() => onModeChange(strategy.mode)}
className={[
"inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500",
isActive
? "bg-blue-600 text-white shadow-sm"
: "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700",
].join(" ")}
>
<span aria-hidden="true">{strategy.icon}</span>
{strategy.name}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,37 @@
"use client";
import React from "react";
import { CopilotKit } from "@copilotkit/react-core";
import { CopilotSidebar } from "@copilotkit/react-core/v2";
import { useShowcaseHooks } from "../../../hooks/use-showcase-hooks";
import { useShowcaseSuggestions } from "../../../hooks/use-showcase-suggestions";
import { SalesDashboard } from "../../sales-dashboard";
interface ToolBasedDashboardProps {
agentId: string;
}
function DashboardContent({ agentId }: ToolBasedDashboardProps) {
useShowcaseHooks();
useShowcaseSuggestions();
return (
<div className="min-h-screen w-full flex items-center justify-center">
<SalesDashboard agentId={agentId} />
<CopilotSidebar
defaultOpen={true}
labels={{
modalHeaderTitle: "Sales Dashboard Assistant",
}}
/>
</div>
);
}
export function ToolBasedDashboard({ agentId }: ToolBasedDashboardProps) {
return (
<CopilotKit runtimeUrl="/api/copilotkit" agent={agentId}>
<DashboardContent agentId={agentId} />
</CopilotKit>
);
}
@@ -0,0 +1,71 @@
export const RENDER_MODES = [
"tool-based",
"a2ui",
"json-render",
"hashbrown",
] as const;
export type RenderMode = (typeof RENDER_MODES)[number];
export interface RenderStrategyInfo {
mode: RenderMode;
name: string;
description: string;
icon: string;
features: {
streaming: boolean;
interactivity: boolean;
sandbox: boolean;
constraintLevel: "high" | "medium" | "low" | "none";
};
}
export const RENDER_STRATEGIES: RenderStrategyInfo[] = [
{
mode: "tool-based",
name: "Tool-Based",
description: "Agent calls typed tool functions",
icon: "\u{1F527}",
features: {
streaming: false,
interactivity: true,
sandbox: false,
constraintLevel: "high",
},
},
{
mode: "a2ui",
name: "A2UI Catalog",
description: "Component tree from predefined catalog",
icon: "\u{1F4CB}",
features: {
streaming: false,
interactivity: true,
sandbox: false,
constraintLevel: "medium",
},
},
{
mode: "json-render",
name: "json-render",
description: "JSONL patches with built-in state",
icon: "\u{1F4C4}",
features: {
streaming: true,
interactivity: true,
sandbox: false,
constraintLevel: "medium",
},
},
{
mode: "hashbrown",
name: "HashBrown",
description: "Streaming structured output",
icon: "\u{1F954}",
features: {
streaming: true,
interactivity: false,
sandbox: false,
constraintLevel: "high",
},
},
];
@@ -0,0 +1,35 @@
"use client";
import { useState } from "react";
import { RENDER_MODES, type RenderMode } from "./types";
const STORAGE_KEY = "showcase-render-mode";
const VALID_MODES: Set<string> = new Set(RENDER_MODES);
/**
* Manages the active render mode with localStorage persistence.
*
* Note: Agent context forwarding (useAgentContext) is omitted here because
* the npm-published @copilotkit/react-core may not export it yet, which
* causes prerender failures during Docker builds. The backend render_mode
* middleware falls back to "tool-based" when no context is present.
*/
export function useRenderMode() {
const [mode, setMode] = useState<RenderMode>(() => {
if (typeof window !== "undefined") {
const stored = localStorage.getItem(STORAGE_KEY);
return VALID_MODES.has(stored ?? "")
? (stored as RenderMode)
: "tool-based";
}
return "tool-based";
});
const setAndPersist = (newMode: RenderMode) => {
setMode(newMode);
localStorage.setItem(STORAGE_KEY, newMode);
};
return { mode, setMode: setAndPersist };
}
@@ -0,0 +1,68 @@
import type { SalesTodo } from "../../types";
const STAGE_COLORS: Record<SalesTodo["stage"], string> = {
prospect: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
qualified:
"bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
proposal: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200",
negotiation:
"bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200",
"closed-won":
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
"closed-lost": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
};
export interface DealCardProps {
deal: SalesTodo;
}
export function DealCard({ deal }: DealCardProps) {
return (
<div
data-testid="deal-card"
className={`rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 transition-all duration-150 ${
deal.completed ? "opacity-60" : ""
}`}
>
<div className="flex items-start justify-between gap-2">
{/* Title */}
<h3
className={`text-sm font-semibold leading-snug break-words ${
deal.completed
? "text-[var(--muted-foreground)] line-through"
: "text-[var(--foreground)]"
}`}
>
{deal.title}
</h3>
{/* Completion indicator */}
<span
data-testid="completion-indicator"
className={`mt-0.5 h-2.5 w-2.5 shrink-0 rounded-full ${
deal.completed ? "bg-[var(--muted-foreground)]" : "bg-green-500"
}`}
/>
</div>
{/* Stage badge + value */}
<div className="mt-2 flex items-center gap-2 flex-wrap">
<span
data-testid="stage-badge"
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STAGE_COLORS[deal.stage]}`}
>
{deal.stage}
</span>
<span className="text-sm font-semibold text-[var(--foreground)]">
${deal.value.toLocaleString()}
</span>
</div>
{/* Meta: assignee + due date */}
<div className="mt-2 flex items-center gap-3 text-xs text-[var(--muted-foreground)]">
{deal.assignee && <span>{deal.assignee}</span>}
{deal.dueDate && <span>Due {deal.dueDate}</span>}
</div>
</div>
);
}
@@ -0,0 +1,80 @@
import { useAgent } from "@copilotkit/react-core/v2";
import { TodoList } from "./todo-list";
import type { SalesTodo } from "../../types";
import { SALES_STAGES } from "../../types";
interface SalesDashboardProps {
agentId?: string;
}
export function SalesDashboard({ agentId }: SalesDashboardProps) {
const { agent } = useAgent(agentId ? { agentId } : undefined);
const todos: SalesTodo[] = agent.state?.todos || [];
const onUpdate = (updatedTodos: SalesTodo[]) => {
agent.setState({ todos: updatedTodos });
};
// Pipeline summary
const totalValue = todos.reduce((sum, t) => sum + t.value, 0);
const byStage = SALES_STAGES.reduce(
(acc, stage) => {
const stageTodos = todos.filter((t) => t.stage === stage);
acc[stage] = {
count: stageTodos.length,
value: stageTodos.reduce((sum, t) => sum + t.value, 0),
};
return acc;
},
{} as Record<string, { count: number; value: number }>,
);
return (
<div className="h-full overflow-y-auto bg-[var(--background)]">
<div className="max-w-5xl mx-auto px-8 py-10 h-full">
{/* Pipeline Summary */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-[var(--foreground)] mb-4">
Sales Pipeline
</h1>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
<p className="text-xs text-[var(--muted-foreground)] uppercase tracking-wider font-medium">
Total Pipeline
</p>
<p className="text-2xl font-bold text-[var(--foreground)] mt-1">
${totalValue.toLocaleString()}
</p>
</div>
{SALES_STAGES.filter((s) => !s.startsWith("closed")).map(
(stage) => (
<div
key={stage}
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4"
>
<p className="text-xs text-[var(--muted-foreground)] uppercase tracking-wider font-medium">
{stage.charAt(0).toUpperCase() + stage.slice(1)}
</p>
<p className="text-lg font-semibold text-[var(--foreground)] mt-1">
{byStage[stage]?.count || 0} deals
</p>
<p className="text-sm text-[var(--muted-foreground)]">
${(byStage[stage]?.value || 0).toLocaleString()}
</p>
</div>
),
)}
</div>
</div>
{/* Todo List */}
<TodoList
todos={todos}
onUpdate={onUpdate}
isAgentRunning={agent.isRunning}
/>
</div>
</div>
);
}
@@ -0,0 +1,44 @@
export interface MetricCardProps {
label: string;
value: string;
trend?: {
direction: "up" | "down" | "neutral";
percentage: number;
};
}
const TREND_STYLES: Record<
"up" | "down" | "neutral",
{ color: string; arrow: string }
> = {
up: { color: "text-green-600 dark:text-green-400", arrow: "\u2191" },
down: { color: "text-red-600 dark:text-red-400", arrow: "\u2193" },
neutral: {
color: "text-[var(--muted-foreground)]",
arrow: "\u2192",
},
};
export function MetricCard({ label, value, trend }: MetricCardProps) {
return (
<div
data-testid="metric-card"
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4"
>
<p className="text-xs text-[var(--muted-foreground)] uppercase tracking-wider font-medium">
{label}
</p>
<p className="text-2xl font-bold text-[var(--foreground)] mt-1">
{value}
</p>
{trend && (
<p
data-testid="trend-indicator"
className={`text-sm font-medium mt-1 ${TREND_STYLES[trend.direction].color}`}
>
{TREND_STYLES[trend.direction].arrow} {trend.percentage}%
</p>
)}
</div>
);
}
@@ -0,0 +1,72 @@
import { DealCard } from "./deal-card";
import type { SalesTodo } from "../../types";
import { SALES_STAGES } from "../../types";
const STAGE_LABELS: Record<SalesTodo["stage"], string> = {
prospect: "Prospect",
qualified: "Qualified",
proposal: "Proposal",
negotiation: "Negotiation",
"closed-won": "Closed Won",
"closed-lost": "Closed Lost",
};
export interface PipelineProps {
deals: SalesTodo[];
}
export function Pipeline({ deals }: PipelineProps) {
const dealsByStage = SALES_STAGES.reduce(
(acc, stage) => {
acc[stage] = deals.filter((d) => d.stage === stage);
return acc;
},
{} as Record<SalesTodo["stage"], SalesTodo[]>,
);
return (
<div
data-testid="pipeline-board"
className="flex gap-4 overflow-x-auto pb-4"
>
{SALES_STAGES.map((stage) => {
const stageDeals = dealsByStage[stage];
const totalValue = stageDeals.reduce((sum, d) => sum + d.value, 0);
return (
<section
key={stage}
aria-label={`${STAGE_LABELS[stage]} column`}
className="flex-shrink-0 w-64 min-w-[16rem]"
>
{/* Column header */}
<div className="mb-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold text-[var(--foreground)]">
{STAGE_LABELS[stage]}
</h3>
<span className="inline-flex items-center rounded-full border border-[var(--border)] bg-[var(--secondary)] px-2 py-0.5 text-xs font-semibold text-[var(--secondary-foreground)]">
{stageDeals.length}
</span>
</div>
<p className="text-xs text-[var(--muted-foreground)] mt-0.5">
${totalValue.toLocaleString()}
</p>
</div>
{/* Cards */}
<div className="space-y-3">
{stageDeals.length === 0 ? (
<div className="text-center text-xs rounded-lg border-2 border-dashed border-[var(--border)] p-4 text-[var(--muted-foreground)]">
No deals
</div>
) : (
stageDeals.map((deal) => <DealCard key={deal.id} deal={deal} />)
)}
</div>
</section>
);
})}
</div>
);
}
@@ -0,0 +1,151 @@
import { useState } from "react";
import type { SalesTodo } from "../../types";
const STAGE_COLORS: Record<SalesTodo["stage"], string> = {
prospect: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
qualified:
"bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
proposal: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200",
negotiation:
"bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200",
"closed-won":
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
"closed-lost": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
};
interface TodoCardProps {
todo: SalesTodo;
onToggleCompleted: (todo: SalesTodo) => void;
onDelete: (todo: SalesTodo) => void;
onUpdateTitle: (todoId: string, title: string) => void;
onUpdateStage: (todoId: string, stage: SalesTodo["stage"]) => void;
onUpdateValue: (todoId: string, value: number) => void;
}
export function TodoCard({
todo,
onToggleCompleted,
onDelete,
onUpdateTitle,
onUpdateStage,
onUpdateValue,
}: TodoCardProps) {
const [editingTitle, setEditingTitle] = useState(false);
const [titleValue, setTitleValue] = useState(todo.title);
const saveTitle = () => {
if (titleValue.trim()) {
onUpdateTitle(todo.id, titleValue.trim());
}
setEditingTitle(false);
};
return (
<div
data-testid="todo-card"
className={`group relative rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 transition-all duration-150 ${
todo.completed ? "opacity-60" : ""
}`}
>
{/* Delete button - hover reveal */}
<button
onClick={() => onDelete(todo)}
className="absolute top-3 right-3 h-7 w-7 flex items-center justify-center rounded-md opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[var(--secondary)]"
aria-label="Delete deal"
>
<svg
className="h-3.5 w-3.5 text-[var(--muted-foreground)]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<div className="flex items-start gap-3">
{/* Completion checkbox */}
<button
data-testid="toggle-completed"
onClick={() => onToggleCompleted(todo)}
className={`mt-0.5 h-5 w-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors ${
todo.completed
? "bg-[var(--primary)] border-[var(--primary)]"
: "border-[var(--border)] hover:border-[var(--primary)]"
}`}
>
{todo.completed && (
<svg
className="h-3 w-3 text-[var(--primary-foreground)]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</button>
<div className="flex-1 min-w-0">
{/* Title */}
{editingTitle ? (
<input
type="text"
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
onBlur={saveTitle}
onKeyDown={(e) => {
if (e.key === "Enter") saveTitle();
if (e.key === "Escape") {
setTitleValue(todo.title);
setEditingTitle(false);
}
}}
className="w-full text-sm font-semibold focus:outline-none bg-transparent text-[var(--foreground)] border-b-2 border-[var(--primary)] pb-[2px]"
autoFocus
/>
) : (
<div
onClick={() => {
setTitleValue(todo.title);
setEditingTitle(true);
}}
className={`text-sm font-semibold cursor-text break-words leading-snug ${
todo.completed
? "text-[var(--muted-foreground)] line-through"
: "text-[var(--foreground)]"
}`}
>
{todo.title}
</div>
)}
{/* Stage badge */}
<div className="mt-2 flex items-center gap-2 flex-wrap">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STAGE_COLORS[todo.stage]}`}
>
{todo.stage}
</span>
<span className="text-sm font-semibold text-[var(--foreground)]">
${todo.value.toLocaleString()}
</span>
</div>
{/* Meta: assignee + due date */}
<div className="mt-2 flex items-center gap-3 text-xs text-[var(--muted-foreground)]">
{todo.assignee && <span>{todo.assignee}</span>}
{todo.dueDate && <span>Due {todo.dueDate}</span>}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,88 @@
import { TodoCard } from "./todo-card";
import type { SalesTodo } from "../../types";
interface TodoColumnProps {
title: string;
todos: SalesTodo[];
emptyMessage: string;
showAddButton?: boolean;
onAddTodo?: () => void;
onToggleCompleted: (todo: SalesTodo) => void;
onDelete: (todo: SalesTodo) => void;
onUpdateTitle: (todoId: string, title: string) => void;
onUpdateStage: (todoId: string, stage: SalesTodo["stage"]) => void;
onUpdateValue: (todoId: string, value: number) => void;
isAgentRunning: boolean;
}
export function TodoColumn({
title,
todos,
emptyMessage,
showAddButton = false,
onAddTodo,
onToggleCompleted,
onDelete,
onUpdateTitle,
onUpdateStage,
onUpdateValue,
isAgentRunning,
}: TodoColumnProps) {
return (
<section aria-label={`${title} column`} className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<h2 className="text-lg font-bold tracking-tight text-[var(--foreground)]">
{title}
</h2>
<span className="inline-flex items-center rounded-full border border-[var(--border)] bg-[var(--secondary)] px-2.5 py-0.5 text-xs font-semibold text-[var(--secondary-foreground)]">
{todos.length}
</span>
</div>
{showAddButton && onAddTodo && (
<button
onClick={onAddTodo}
disabled={isAgentRunning}
aria-label="Add new deal"
className="h-8 w-8 flex items-center justify-center rounded-md hover:bg-[var(--secondary)] transition-colors disabled:opacity-50"
>
<svg
className="h-4 w-4 text-[var(--foreground)]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
)}
</div>
{/* Cards */}
<div className="space-y-3">
{todos.length === 0 ? (
<div className="text-center text-sm rounded-lg border-2 border-dashed border-[var(--border)] p-5 min-h-[151px] flex items-center justify-center text-[var(--muted-foreground)]">
{emptyMessage}
</div>
) : (
todos.map((todo) => (
<TodoCard
key={todo.id}
todo={todo}
onToggleCompleted={onToggleCompleted}
onDelete={onDelete}
onUpdateTitle={onUpdateTitle}
onUpdateStage={onUpdateStage}
onUpdateValue={onUpdateValue}
/>
))
)}
</div>
</section>
);
}
@@ -0,0 +1,102 @@
import { TodoColumn } from "./todo-column";
import type { SalesTodo } from "../../types";
interface TodoListProps {
todos: SalesTodo[];
onUpdate: (todos: SalesTodo[]) => void;
isAgentRunning: boolean;
}
export function TodoList({ todos, onUpdate, isAgentRunning }: TodoListProps) {
const activeTodos = todos.filter((t) => !t.completed);
const completedTodos = todos.filter((t) => t.completed);
const toggleCompleted = (todo: SalesTodo) => {
const updated = todos.map((t) =>
t.id === todo.id ? { ...t, completed: !t.completed } : t,
);
onUpdate(updated);
};
const deleteTodo = (todo: SalesTodo) => {
onUpdate(todos.filter((t) => t.id !== todo.id));
};
const updateTitle = (todoId: string, title: string) => {
const updated = todos.map((t) => (t.id === todoId ? { ...t, title } : t));
onUpdate(updated);
};
const updateStage = (todoId: string, stage: SalesTodo["stage"]) => {
const updated = todos.map((t) => (t.id === todoId ? { ...t, stage } : t));
onUpdate(updated);
};
const updateValue = (todoId: string, value: number) => {
const updated = todos.map((t) => (t.id === todoId ? { ...t, value } : t));
onUpdate(updated);
};
const addTodo = () => {
const newTodo: SalesTodo = {
id: crypto.randomUUID(),
title: "New Deal",
stage: "prospect",
value: 0,
dueDate: new Date().toISOString().split("T")[0],
assignee: "",
completed: false,
};
onUpdate([...todos, newTodo]);
};
if (!todos || todos.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-64 gap-4">
<div className="text-5xl">{"\uD83D\uDCBC"}</div>
<p className="text-base font-semibold text-[var(--foreground)]">
No deals yet
</p>
<p className="text-sm text-[var(--muted-foreground)]">
Create your first deal to get started
</p>
<button
onClick={addTodo}
disabled={isAgentRunning}
className="mt-2 px-4 py-2 rounded-md bg-[var(--primary)] text-[var(--primary-foreground)] text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
Add a deal
</button>
</div>
);
}
return (
<div className="flex gap-8 h-full">
<TodoColumn
title="Active Deals"
todos={activeTodos}
emptyMessage="No active deals"
showAddButton
onAddTodo={addTodo}
onToggleCompleted={toggleCompleted}
onDelete={deleteTodo}
onUpdateTitle={updateTitle}
onUpdateStage={updateStage}
onUpdateValue={updateValue}
isAgentRunning={isAgentRunning}
/>
<TodoColumn
title="Closed"
todos={completedTodos}
emptyMessage="No closed deals yet"
onToggleCompleted={toggleCompleted}
onDelete={deleteTodo}
onUpdateTitle={updateTitle}
onUpdateStage={updateStage}
onUpdateValue={updateValue}
isAgentRunning={isAgentRunning}
/>
</div>
);
}
@@ -0,0 +1,120 @@
import { useEffect, useRef } from "react";
interface ToolReasoningProps {
name: string;
args?: object | unknown;
status: string;
}
function formatValue(value: unknown): string {
if (Array.isArray(value)) return `[${value.length} items]`;
if (typeof value === "object" && value !== null)
return `{${Object.keys(value).length} keys}`;
if (typeof value === "string") return `"${value}"`;
return String(value);
}
export function ToolReasoning({ name, args, status }: ToolReasoningProps) {
const entries = args ? Object.entries(args as Record<string, unknown>) : [];
const detailsRef = useRef<HTMLDetailsElement>(null);
const isRunning = status === "executing" || status === "inProgress";
// Auto-open while executing, auto-close when complete
useEffect(() => {
if (!detailsRef.current) return;
detailsRef.current.open = isRunning;
}, [isRunning]);
const statusIcon = isRunning ? (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-[var(--muted)] border-t-[var(--foreground)]" />
) : (
<svg
className="h-3 w-3 text-emerald-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
);
return (
<div className="my-1.5">
{entries.length > 0 ? (
<details ref={detailsRef} open className="group">
<summary className="flex items-center gap-2 cursor-pointer list-none text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors">
{statusIcon}
<svg
className="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
<span
className="font-medium"
style={{ fontFamily: "var(--font-code)" }}
>
{name}
</span>
<svg
className="h-3 w-3 ml-auto transition-transform group-open:rotate-180"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</summary>
<div className="ml-5 mt-1.5 rounded-md bg-[var(--secondary)] px-3 py-2 space-y-1">
{entries.map(([key, value]) => (
<div
key={key}
className="flex gap-2 min-w-0 text-xs"
style={{ fontFamily: "var(--font-code)" }}
>
<span className="text-[var(--muted-foreground)] shrink-0">
{key}:
</span>
<span className="text-[var(--foreground)] truncate">
{formatValue(value)}
</span>
</div>
))}
</div>
</details>
) : (
<div className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
{statusIcon}
<svg
className="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
<span
className="font-medium"
style={{ fontFamily: "var(--font-code)" }}
>
{name}
</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,128 @@
import React from "react";
export function getWeatherGradient(conditions: string): string {
const c = conditions.toLowerCase();
if (c.includes("clear") || c.includes("sunny"))
return "linear-gradient(135deg, #667eea 0%, #764ba2 100%)";
if (c.includes("rain") || c.includes("storm"))
return "linear-gradient(135deg, #4A5568 0%, #2D3748 100%)";
if (c.includes("cloud") || c.includes("overcast"))
return "linear-gradient(135deg, #718096 0%, #4A5568 100%)";
if (c.includes("snow"))
return "linear-gradient(135deg, #63B3ED 0%, #4299E1 100%)";
return "linear-gradient(135deg, #667eea 0%, #764ba2 100%)";
}
export function getWeatherIcon(conditions: string): string {
const c = conditions.toLowerCase();
if (c.includes("clear") || c.includes("sunny")) return "\u2600\uFE0F";
if (c.includes("rain") || c.includes("drizzle")) return "\uD83C\uDF27\uFE0F";
if (c.includes("snow")) return "\u2744\uFE0F";
if (c.includes("thunderstorm")) return "\u26C8\uFE0F";
if (c.includes("cloud") || c.includes("overcast")) return "\u2601\uFE0F";
if (c.includes("fog")) return "\uD83C\uDF2B\uFE0F";
return "\uD83C\uDF24\uFE0F";
}
export interface WeatherCardProps {
location: string;
temperature?: number;
conditions?: string;
humidity?: number;
windSpeed?: number;
feelsLike?: number;
city?: string;
loading?: boolean;
}
export function WeatherCard({
location,
temperature = 22,
conditions = "Clear",
humidity = 55,
windSpeed = 12,
feelsLike,
city,
loading = false,
}: WeatherCardProps) {
if (loading) {
return (
<div
className="flex items-center gap-3 px-5 py-4 rounded-2xl max-w-sm"
style={{
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
}}
>
<div className="animate-pulse text-2xl">{"\uD83C\uDF24\uFE0F"}</div>
<div>
<p className="text-white font-medium text-sm">Checking weather...</p>
<p className="text-white/60 text-xs">{location}</p>
</div>
</div>
);
}
const temp = temperature;
const cond = conditions;
const hum = humidity;
const wind = windSpeed;
const feels = feelsLike ?? temp;
return (
<div
data-testid="weather-card"
className="rounded-2xl overflow-hidden shadow-xl my-3"
style={{ background: getWeatherGradient(cond), width: "320px" }}
>
<div className="px-5 pt-4 pb-3">
<div className="flex items-start justify-between">
<div>
<h3 className="text-base font-bold text-white capitalize tracking-tight">
{city || location}
</h3>
<p className="text-white/50 text-[10px] font-medium uppercase tracking-wider">
Current Weather
</p>
</div>
<span className="text-4xl leading-none">{getWeatherIcon(cond)}</span>
</div>
<div className="mt-3 flex items-baseline gap-1.5">
<span className="text-4xl font-extralight text-white tracking-tighter">
{temp}&deg;
</span>
<span className="text-white/40 text-xs">
{((temp * 9) / 5 + 32).toFixed(0)}&deg;F
</span>
</div>
<p className="text-white/70 text-xs font-medium capitalize mt-0.5">
{cond}
</p>
</div>
<div
className="grid grid-cols-3 text-center py-2.5 px-5"
style={{ background: "rgba(0,0,0,0.15)" }}
>
<div>
<p className="text-white/40 text-[9px] font-medium uppercase tracking-wider">
Humidity
</p>
<p className="text-white text-xs font-semibold mt-0.5">{hum}%</p>
</div>
<div className="border-x border-white/10">
<p className="text-white/40 text-[9px] font-medium uppercase tracking-wider">
Wind
</p>
<p className="text-white text-xs font-semibold mt-0.5">{wind} mph</p>
</div>
<div>
<p className="text-white/40 text-[9px] font-medium uppercase tracking-wider">
Feels Like
</p>
<p className="text-white text-xs font-semibold mt-0.5">
{feels}&deg;
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,80 @@
import { z } from "zod";
import {
useComponent,
useFrontendTool,
useHumanInTheLoop,
useDefaultRenderTool,
} from "@copilotkit/react-core/v2";
import { PieChart, PieChartProps } from "../components/charts/pie-chart";
import { BarChart, BarChartProps } from "../components/charts/bar-chart";
import { MeetingTimePicker } from "../components/meeting-time-picker";
import { ToolReasoning } from "../components/tool-reasoning";
export const useShowcaseHooks = () => {
// Human-in-the-Loop (frontend tool requiring user decision)
useHumanInTheLoop({
name: "scheduleTime",
description: "Use human-in-the-loop to schedule a meeting with the user.",
parameters: z.object({
reasonForScheduling: z
.string()
.describe("Reason for scheduling, very brief - 5 words."),
meetingDuration: z
.number()
.describe("Duration of the meeting in minutes"),
}),
render: ({ respond, status, args }) => {
return <MeetingTimePicker status={status} respond={respond} {...args} />;
},
});
// Controlled Generative UI (frontend-defined chart components)
useComponent({
name: "pieChart",
description: "Controlled Generative UI that displays data as a pie chart.",
parameters: PieChartProps,
render: PieChart,
});
useComponent({
name: "barChart",
description: "Controlled Generative UI that displays data as a bar chart.",
parameters: BarChartProps,
render: BarChart,
});
// Default Tool Rendering (backend tool UI)
const ignoredTools = [
"render_a2ui", // Rendered by A2UI streaming, not as a tool card
"generate_a2ui", // Rendered by A2UI, not as a tool card
"log_a2ui_event", // Internal A2UI event tracker
];
useDefaultRenderTool({
render: ({ name, status, parameters }) => {
if (ignoredTools.includes(name)) return <></>;
return <ToolReasoning name={name} status={status} args={parameters} />;
},
});
// Frontend Tools (direct frontend state manipulation)
useFrontendTool(
{
name: "toggleTheme",
description: "Frontend tool for toggling the theme of the app.",
parameters: z.object({}),
handler: async () => {
const isDark = document.documentElement.classList.contains("dark");
if (isDark) {
document.documentElement.classList.remove("dark");
document.documentElement.classList.add("light");
} else {
document.documentElement.classList.remove("light");
document.documentElement.classList.add("dark");
}
},
},
[],
);
};
@@ -0,0 +1,57 @@
/**
* Suggestion pills shown in the chat UI. Each suggestion triggers a specific
* demo feature when clicked.
*
* Ordered from most constrained (fixed UI) to most open (freeform UI).
*/
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
export const useShowcaseSuggestions = (options?: { showcaseMode?: string }) => {
const showcase = options?.showcaseMode;
useConfigureSuggestions({
suggestions: [
{
title: "Pie Chart (Controlled Generative UI)",
message:
"Show me a pie chart of our revenue distribution by category. Use the query_data tool to fetch the data first, then render it with the pieChart component.",
},
{
title: "Bar Chart (Controlled Generative UI)",
message:
"Show me a bar chart of our expenses by category. Use the query_data tool to fetch the data first, then render it with the barChart component.",
},
{
title: "Schedule Meeting (Human In The Loop)",
message:
"I'd like to schedule a 30-minute meeting to learn about CopilotKit. Please use the scheduleTime tool to let me pick a time.",
},
{
title: "Search Flights (A2UI Fixed Schema)",
message: "Find flights from SFO to JFK for next Tuesday.",
className: showcase === "a2ui" ? "a2ui-highlight" : undefined,
},
{
title: "Sales Dashboard (A2UI Dynamic)",
message:
"First use the query_data tool to fetch the financial sales data, then using A2UI, show me a sales dashboard with total revenue, new customers, and conversion rate metrics. Include a pie chart of revenue by category and a bar chart of monthly sales.",
className: showcase === "a2ui" ? "a2ui-highlight" : undefined,
},
{
title: "Excalidraw Diagram (MCP App)",
message:
"Use Excalidraw to create a simple network diagram showing a router connected to two switches, each connected to two computers.",
},
{
title: "Toggle Theme (Frontend Tools)",
message: "Toggle the app theme using the toggleTheme tool.",
},
{
title: "Task Manager (Shared State)",
message:
"Enable app mode and add three todos about learning CopilotKit: one about reading the docs, one about building a prototype, and one about exploring agent state.",
},
],
available: "always",
});
};
+50
View File
@@ -0,0 +1,50 @@
export const SALES_STAGES = [
"prospect",
"qualified",
"proposal",
"negotiation",
"closed-won",
"closed-lost",
] as const;
export type SalesStage = (typeof SALES_STAGES)[number];
export interface SalesTodo {
id: string;
title: string;
stage: SalesStage;
value: number;
dueDate: string;
assignee: string;
completed: boolean;
}
export const INITIAL_SALES_TODOS: SalesTodo[] = [
{
id: "1",
title: "Follow up with Acme Corp",
stage: "qualified",
value: 50000,
dueDate: "2026-04-20",
assignee: "Alice",
completed: false,
},
{
id: "2",
title: "Send proposal to TechStart",
stage: "proposal",
value: 120000,
dueDate: "2026-04-18",
assignee: "Bob",
completed: false,
},
{
id: "3",
title: "Schedule demo for GlobalInc",
stage: "prospect",
value: 75000,
dueDate: "2026-04-22",
assignee: "Alice",
completed: false,
},
];