
Research
/Security News
Mini Shai-Hulud Campaign Hits Red Hat Cloud Services npm Packages
A mini Shai-Hulud campaign compromised Red Hat Cloud Services npm packages to steal developer and CI/CD secrets during installation.
@polpo-ai/chat
Advanced tools
Composable chat UI components for Polpo AI agents. Built on top of @polpo-ai/sdk and @polpo-ai/react.
Three levels of composition — from zero-config to full control.
npm install @polpo-ai/chat
Peer dependencies:
npm install @polpo-ai/sdk @polpo-ai/react react react-virtuoso lucide-react streamdown
Or install individual components via shadcn:
npx shadcn add @polpo-ai/chat
import { Chat } from "@polpo-ai/chat";
function ChatPage() {
return <Chat sessionId="session_abc" agent="coder" />;
}
That's it. Messages, streaming, input with file attachments, scroll-to-bottom, tool calls, typing dots, skeleton loading — all included.
import { Chat, useChatContext } from "@polpo-ai/chat";
function MyCustomInput() {
const { sendMessage, isStreaming, abort } = useChatContext();
// Build your own input UI...
}
function ChatPage() {
return (
<Chat sessionId="session_abc" agent="coder" avatar={<Avatar />} agentName="Coder">
<MyCustomInput />
</Chat>
);
}
Children replace the default ChatInput. Use useChatContext() to access chat state from any child.
Use a render function for conditional rendering (e.g. landing page → conversation transition):
<Chat agent="coder" onSessionCreated={(id) => router.push(`/chat/${id}`)}>
{({ hasMessages }) =>
hasMessages ? <ConversationInput /> : <LandingPage />
}
</Chat>
When hasMessages is false and children is a render function, the message list is hidden automatically.
import { useChat } from "@polpo-ai/react";
import { ChatMessage, ToolCallChip, ChatSkeleton } from "@polpo-ai/chat";
function MyChat() {
const { messages, sendMessage, isStreaming } = useChat({ agent: "coder" });
return (
<div>
{messages.map((msg, i) => (
<ChatMessage key={msg.id} msg={msg} isLast={i === messages.length - 1} isStreaming={isStreaming} />
))}
</div>
);
}
Use individual components and hooks to build completely custom layouts.
<Chat>Root compound component. Wraps ChatProvider + ChatMessages + default ChatInput.
<Chat
sessionId="session_abc" // Existing session ID (omit for new chats)
agent="coder" // Agent name
onSessionCreated={(id) => {}} // Called when server creates a new session
avatar={<Avatar />} // ReactNode for assistant messages
agentName="Coder" // Display name for assistant
streamdownComponents={...} // Custom code block renderer
skeletonCount={3} // Loading skeleton count
inputPlaceholder="Ask me..." // Default input placeholder
inputHint="Enter to send" // Hint text below default input
allowAttachments={true} // Enable file attachments on default input
className="flex-1" // Outer container class
ref={chatMessagesRef} // Ref to ChatMessagesHandle (scrollToBottom)
>
{children} {/* Replaces default ChatInput */}
</Chat>
The package does not handle routing — it's framework-agnostic. Use onSessionCreated to navigate when the first message creates a session:
// Next.js App Router
import { useRouter } from "next/navigation";
const router = useRouter();
<Chat agent="coder" onSessionCreated={(id) => router.push(`/chat/${id}`)} />
// React Router
import { useNavigate } from "react-router-dom";
const navigate = useNavigate();
<Chat agent="coder" onSessionCreated={(id) => navigate(`/chat/${id}`)} />
<ChatInput>Default input with textarea, submit/stop button, file attachments, and drag & drop. Reads from useChatContext() — must be inside a <ChatProvider> or <Chat>.
<ChatInput
placeholder="Type a message..."
hint="Enter to send, Shift+Enter for newline"
allowAttachments={true}
renderSubmit={({ isStreaming, onStop }) => <MyButton />}
className="..."
/>
Included automatically in <Chat> when no children are provided. Pass children to <Chat> to replace it.
<ChatLanding>Full "new chat" landing page with greeting, input, and suggestions. Wraps its own ChatProvider.
<ChatLanding
agent="coder"
onSessionCreated={(id) => router.push(`/chat/${id}`)}
greeting="How can I help you?"
subtitle="Ask anything about your codebase"
suggestions={[
{ icon: <Zap size={14} />, text: "Automate a task" },
{ icon: <UserPlus size={14} />, text: "Create an agent" },
]}
suggestionColumns={2}
inputPlaceholder="Ask me anything..."
inputHint="AI may make mistakes"
allowAttachments={true}
header={<Logo />}
/>
<ChatSuggestions>Configurable suggestion button grid.
<ChatSuggestions
suggestions={[
{ icon: <Zap size={14} />, text: "Automate a task" },
{ icon: <BarChart3 size={14} />, text: "Generate a report" },
]}
onSelect={(text) => sendMessage(text)}
columns={2} // 1, 2, or 3
className="mt-4"
/>
<ChatAgentSelector>Dropdown to pick an agent. Works standalone — no context required.
<ChatAgentSelector
agents={agents} // From useAgents().agents
selected={selectedAgent}
onSelect={setSelectedAgent}
fallbackLabel="Select agent"
renderAvatar={(agent, size) => <AgentAvatar agent={agent} size={size} />}
/>
<ChatSessionList>Flat list of chat sessions with select, delete, loading skeleton, and empty state.
<ChatSessionList
sessions={sessions} // From useSessions().sessions
agents={agents} // From useAgents().agents (for display names)
activeSessionId={currentId} // Highlighted session
onSelect={(id) => router.push(`/chat/${id}`)}
onDelete={deleteSession} // Omit to hide delete buttons
isLoading={isLoading}
emptyMessage="No conversations yet"
renderAvatar={(agent, name) => <AgentAvatar agent={agent} />}
/>
<ChatSessionsByAgent>Sessions grouped by agent, sorted by last activity, with session count badges.
<ChatSessionsByAgent
sessions={sessions}
agents={agents}
onSelect={(agentName) => router.push(`/chat/agent/${agentName}`)}
isLoading={isLoading}
renderAvatar={(agent, name) => <AgentAvatar agent={agent} />}
/>
<ChatMessage>Renders a single message. Dispatches to ChatUserMessage or ChatAssistantMessage based on role.
<ChatMessage
msg={message}
isLast={true}
isStreaming={false}
avatar={<Avatar />}
agentName="Coder"
streamdownComponents={...}
/>
<ChatMessages>Virtuoso-powered scrollable message list with auto-scroll, scroll-to-bottom button, and skeleton loading.
<ChatMessages
renderItem={(msg, index, isLast, isStreaming) => <ChatMessage msg={msg} ... />}
skeletonCount={3}
className="flex-1"
ref={ref} // ChatMessagesHandle — { scrollToBottom() }
/>
Must be inside a <ChatProvider>.
<ChatScrollButton>Scroll-to-bottom button with new message indicator.
<ChatScrollButton isAtBottom={false} showNewMessage={true} onClick={scrollToBottom} />
<ChatSkeleton>Loading skeleton matching the message layout.
<ChatSkeleton count={3} />
<ChatTyping>Animated typing dots.
<ChatTyping className="text-gray-400" />
<ChatProvider>Wraps useChat + useFiles from the SDK. All components that read chat state must be inside this provider (or inside <Chat>, which wraps it).
<ChatProvider
sessionId="session_abc"
agent="coder"
onSessionCreated={(id) => {}}
onUpdate={() => {}}
>
{children}
</ChatProvider>
useChatContext()Access chat state from any child of <ChatProvider>:
const {
messages, // ChatMessage[]
isStreaming, // boolean
status, // "idle" | "streaming" | "loading" | "error"
sendMessage, // (content: string | ContentPart[]) => Promise<void>
abort, // () => void
uploadFile, // (dest, file, name) => Promise<...>
isUploading, // boolean
pendingToolCall, // ToolCallEvent | null
sendToolResult, // (toolCallId, result) => void
} = useChatContext();
Built-in renderers for common Polpo tools:
| Tool | Renderer | What it shows |
|---|---|---|
read | ToolRead | File path + content with line numbers |
write / edit | ToolWrite | File path + content preview in green |
bash | ToolBash | Command with $ prompt + dark terminal output |
grep / glob | ToolSearch | Pattern + matched results list |
http_fetch / search_web | ToolHttp | URL + response preview |
email_send | ToolEmail | To, subject, body preview |
ask_user_question | ToolAskUser | Questions with answered state |
| (any other) | ToolCallShell | Generic with expand/collapse |
import { ToolCallShell } from "@polpo-ai/chat/tools";
import { Database } from "lucide-react";
function ToolDatabaseQuery({ tool }) {
const query = tool.arguments?.query;
return (
<ToolCallShell tool={tool} icon={Database} label="Query" summary={query}>
<pre>{tool.result}</pre>
</ToolCallShell>
);
}
useSubmitHandler(sendMessage, uploadFile)Handles file uploads via SDK and sends messages with ContentPart[].
import { useSubmitHandler } from "@polpo-ai/chat/hooks";
const handleSubmit = useSubmitHandler(sendMessage, uploadFile);
// handleSubmit({ text: "Analyze this", files: [{ url, filename }] })
useDocumentDrag()Tracks document-level drag state for drop overlay feedback.
import { useDocumentDrag } from "@polpo-ai/chat/hooks";
const dragging = useDocumentDrag();
// dragging: boolean — true when files are being dragged over the page
getTextContent(content)Extracts text from string | ContentPart[].
relativeTime(isoString)Formats timestamps as "Just now", "2m ago", "An hour ago", or full date.
createStreamdownComponents(CodeBlockComponent?)Creates a Streamdown components override for fenced code blocks. Pass your own CodeBlock component for syntax highlighting:
import { createStreamdownComponents } from "@polpo-ai/chat";
import { CodeBlock } from "@/components/ai-elements/code-block";
const streamdownComponents = createStreamdownComponents(CodeBlock);
<Chat streamdownComponents={streamdownComponents} ... />
Here's how to assemble a complete chat application using all the pieces. This mirrors a real production setup.
// layout.tsx — wrap your app with PolpoProvider
import { PolpoProvider } from "@polpo-ai/react";
export default function Layout({ children }) {
return (
<PolpoProvider
baseUrl="https://api.polpo.sh" // SDK appends /v1/ internally — do NOT add it
apiKey={process.env.NEXT_PUBLIC_POLPO_API_KEY}
>
{children}
</PolpoProvider>
);
}
Important:
baseUrlis the root URL without/v1/or/api/v1/. The SDK appends the API version path internally. For self-hosted:http://localhost:3000.
import { useSessions, useAgents } from "@polpo-ai/react";
import { ChatSessionList, ChatSessionsByAgent } from "@polpo-ai/chat";
function SessionsPage() {
const { sessions, isLoading, deleteSession } = useSessions();
const { agents } = useAgents();
const [view, setView] = useState<"all" | "byAgent">("all");
return (
<div>
<header>
<button onClick={() => setView("all")}>All</button>
<button onClick={() => setView("byAgent")}>By Agent</button>
<button onClick={() => router.push("/chat/new")}>New Chat</button>
</header>
{view === "all" ? (
<ChatSessionList
sessions={sessions}
agents={agents}
isLoading={isLoading}
onSelect={(id) => router.push(`/chat/${id}`)}
onDelete={deleteSession}
/>
) : (
<ChatSessionsByAgent
sessions={sessions}
agents={agents}
isLoading={isLoading}
onSelect={(agent) => router.push(`/chat/agent/${agent}`)}
/>
)}
</div>
);
}
import { useAgents } from "@polpo-ai/react";
import { Chat, ChatSuggestions, ChatAgentSelector, useChatContext } from "@polpo-ai/chat";
function NewChatPage() {
const { agents } = useAgents();
const [agent, setAgent] = useState(agents?.[0]?.name);
return (
<Chat agent={agent} onSessionCreated={(id) => router.replace(`/chat/${id}`)}>
{({ hasMessages }) =>
hasMessages ? (
<MyInput />
) : (
<Landing agents={agents} agent={agent} onAgentChange={setAgent} />
)
}
</Chat>
);
}
function Landing({ agents, agent, onAgentChange }) {
const { sendMessage } = useChatContext();
return (
<div>
<h1>How can I help?</h1>
<MyInput />
<ChatAgentSelector agents={agents} selected={agent} onSelect={onAgentChange} />
<ChatSuggestions
suggestions={[
{ icon: <Zap size={14} />, text: "Automate a workflow" },
{ icon: <BarChart3 size={14} />, text: "Generate a report" },
]}
onSelect={(text) => sendMessage(text)}
/>
</div>
);
}
import { Chat } from "@polpo-ai/chat";
// Level 1 — zero config
function ChatPage({ sessionId }) {
return <Chat sessionId={sessionId} agent="coder" />;
}
// Level 2 — custom input, ask-user-question handling
function ChatPage({ sessionId }) {
return (
<Chat sessionId={sessionId} agent="coder" avatar={<MyAvatar />} agentName="Coder">
<ChatInputWithAskUser />
</Chat>
);
}
function ChatInputWithAskUser() {
const { pendingToolCall, sendMessage } = useChatContext();
if (pendingToolCall?.toolName === "ask_user_question") {
return (
<ChatAskUser
questions={pendingToolCall.arguments.questions}
onSubmit={(answers) => sendMessage(JSON.stringify({ answers }))}
/>
);
}
return <ChatInput placeholder="Ask anything..." />;
}
Every component accepts className. For deeper customization, override Tailwind's gray palette in your CSS:
/* globals.css — map your brand to Tailwind grays */
@theme inline {
--color-gray-50: var(--bg);
--color-gray-100: var(--warm);
--color-gray-200: var(--line);
--color-gray-400: var(--ink-3);
--color-gray-600: var(--ink-2);
--color-gray-900: var(--ink);
--color-blue-500: var(--accent);
--color-green-600: var(--green);
}
This maps the package's neutral grays to your brand colors — zero component changes needed.
The package uses Tailwind utility classes with neutral gray colors by default. All components inherit fonts, colors, and sizing from the parent — the package ships zero CSS.
Components use inherit for all font properties. Set your fonts on the <body> or a parent wrapper:
/* globals.css */
body {
font-family: 'Inter', sans-serif;
}
/* Optional: heading/display font for agent names */
.font-display {
font-family: 'Bricolage Grotesque', sans-serif;
}
The agent name in ChatAssistantMessage uses text-[13px] font-semibold — it inherits your body font. To use a display font, pass a styled agentName via the avatar slot or use renderMessage (Level 2).
Override Tailwind's gray scale to match your brand:
@theme inline {
--color-gray-50: var(--bg); /* backgrounds */
--color-gray-100: var(--warm); /* hover, cards */
--color-gray-200: var(--line); /* borders */
--color-gray-400: var(--ink-3); /* muted text */
--color-gray-600: var(--ink-2); /* secondary text */
--color-gray-900: var(--ink); /* primary text */
--color-blue-500: var(--accent); /* accent, focus rings */
--color-green-600: var(--green); /* success states */
}
The package uses Tailwind's default scale (text-xs, text-sm, text-[13px]). To adjust message text size globally:
/* Make all chat message text 15px */
.chat-wrapper {
font-size: 15px;
}
Or use className on individual components for targeted overrides.
Components use fixed Tailwind spacing (px-6, py-3, rounded-2xl, etc.). Override via className prop:
<ChatInput className="px-4 py-2" />
<Chat className="max-w-4xl mx-auto" />
If you don't use Tailwind, the components still work — define the gray/blue/green colors as plain CSS:
:root {
/* The package references Tailwind classes like bg-gray-100, text-gray-900 etc.
With Tailwind installed, just override the palette as shown above.
Without Tailwind, you'll need to provide the utility classes yourself
or use Level 3 primitives with your own styling. */
}
Add to your globals.css for the typing indicator:
@keyframes typing-dot {
0%, 60%, 100% { opacity: .3; transform: translateY(0); }
30% { opacity: 1; transform: translateY(-3px); }
}
Built with React, React Virtuoso, Lucide, and Streamdown.
Inspired by shadcn/ui and the Vercel AI SDK.
MIT
FAQs
Composable chat UI components for Polpo AI agents
We found that @polpo-ai/chat demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Research
/Security News
A mini Shai-Hulud campaign compromised Red Hat Cloud Services npm packages to steal developer and CI/CD secrets during installation.

Research
/Security News
The North Korean malware loader hides in a Packagist-listed package and its GitHub branch to fetch and execute remote code in a likely Contagious Interview-style lure.

Security News
The Rust project is moving toward formal rules on LLM use in contributions after months of internal debate over maintainer burden, code quality, and contributor experience.