Agent Chat
Full-featured AI agent chat with state management, tools, and widgets.
components/infsh/agent/agent.tsx
import React, { memo, useMemo, type ReactNode, type Ref } from 'react';
import { Bot, Clock, X } from 'lucide-react';
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import {
Inference,
ChatMessageStatusReady,
ChatMessageStatusFailed,
ChatMessageStatusCancelled,
ChatMessageStatusQueued,
ChatMessageRoleUser,
ChatMessageRoleAssistant,
ChatMessageContentTypeReasoning,
ChatMessageContentTypeText,
type ChatMessageDTO,
} from '@inferencesh/sdk';
import {
AgentChatProvider,
useAgentChat,
useAgentActions,
isAdHocConfig,
type AgentOptions,
} from '@inferencesh/sdk/agent';
import { ChatContainer } from '@/components/infsh/agent/chat-container';
import { VirtualizedChatMessages } from '@/components/infsh/agent/virtualized-chat-messages';
import { ChatInput, type ChatInputHandle } from '@/components/infsh/agent/chat-input';
import { MessageBubble } from '@/components/infsh/agent/message-bubble';
import { MessageContent } from '@/components/infsh/agent/message-content';
import { MessageReasoning } from '@/components/infsh/agent/message-reasoning';
import { MessageStatusIndicator } from '@/components/infsh/agent/message-status-indicator';
import { ToolInvocations } from '@/components/infsh/agent/tool-invocations';
// Component props
interface AgentProps {
proxyUrl?: string;
apiKey?: string;
baseUrl?: string;
config: AgentOptions;
name?: string;
chatId?: string;
className?: string;
compact?: boolean;
allowFiles?: boolean;
allowImages?: boolean;
onChatCreated?: (chatId: string) => void;
description?: string;
examplePrompts?: string[];
}
// Check if message status is terminal (generation complete)
function isTerminalStatus(status: string | undefined): boolean {
return status === ChatMessageStatusReady ||
status === ChatMessageStatusFailed ||
status === ChatMessageStatusCancelled;
}
function getReasoningContent(message: ChatMessageDTO): string | undefined {
const reasoningContent = message.content.find((c) => c.type === ChatMessageContentTypeReasoning);
return reasoningContent?.text;
}
function hasTextContent(message: ChatMessageDTO): boolean {
return message.content.some((c) => c.type === ChatMessageContentTypeText && c.text?.trim());
}
// =============================================================================
// Internal Components
// =============================================================================
const DefaultHeader = memo(function DefaultHeader() {
return (
<div className="flex items-center gap-2 px-4 py-3 border-b">
<Bot className="h-4 w-4 text-primary" />
<span className="font-medium text-sm">agent</span>
</div>
);
});
const ExamplePrompts = memo(function ExamplePrompts({
prompts,
onSelect,
}: {
prompts: string[];
onSelect: (prompt: string) => void;
}) {
if (prompts.length === 0) return null;
return (
<div className="mt-4 space-y-2 w-full max-w-md">
{prompts.map((prompt, idx) => (
<Button
key={idx}
variant="outline"
onClick={() => onSelect(prompt)}
className="w-full text-left justify-start h-auto py-2 px-3 text-sm whitespace-normal"
>
{prompt}
</Button>
))}
</div>
);
});
const EmptyState = memo(function EmptyState({
description,
examplePrompts = [],
icon,
title,
}: {
description?: string;
examplePrompts?: string[];
/** Defaults to the bot glyph. Pass null for no icon. */
icon?: ReactNode;
title?: string;
}) {
const { sendMessage } = useAgentActions();
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6 text-muted-foreground">
{icon === undefined ? <Bot className="h-8 w-8 mb-3 opacity-50" /> : icon}
<p className="text-sm font-medium">{title ?? 'how can I help?'}</p>
<p className="text-xs mt-1 opacity-70">
{description || 'ask me anything'}
</p>
{examplePrompts.length > 0 && (
<ExamplePrompts prompts={examplePrompts} onSelect={sendMessage} />
)}
</div>
);
});
const MessageRow = memo(function MessageRow({ message }: { message: ChatMessageDTO }) {
const isUser = message.role === ChatMessageRoleUser;
const isAssistant = message.role === ChatMessageRoleAssistant;
const reasoningContent = getReasoningContent(message);
const isGenerating = !isTerminalStatus(message.status);
const hasTools = message.tool_invocations && message.tool_invocations.length > 0;
const hasContent = hasTextContent(message) || !!reasoningContent || !!hasTools;
const { cancelMessage } = useAgentActions();
const isQueued = isUser && message.status === ChatMessageStatusQueued;
if (message.role === 'tool') {
return null;
}
// Empty terminal messages are invisible, but a generating assistant
// message with no content yet still renders its status indicator.
if (!hasContent && !(isAssistant && isGenerating)) {
return null;
}
return (
<MessageBubble message={message} onCancel={isQueued ? () => cancelMessage(message.id) : undefined}>
{isAssistant && reasoningContent && (
<MessageReasoning
reasoning={reasoningContent}
isReasoning={isGenerating && !hasTextContent(message)}
/>
)}
{(isUser || hasTextContent(message)) && <MessageContent message={message} truncate={isUser} />}
{isAssistant && <ToolInvocations message={message} />}
{isAssistant && isGenerating && <MessageStatusIndicator />}
</MessageBubble>
);
});
/** Render a single message — passed to VirtualizedChatMessages. */
function renderMessage(message: ChatMessageDTO) {
return <MessageRow message={message} />
}
const QueuedMessageStrip = memo(function QueuedMessageStrip() {
const { messages } = useAgentChat();
const { cancelMessage } = useAgentActions();
const queued = useMemo(
() => messages.filter(m => m.role === ChatMessageRoleUser && m.status === ChatMessageStatusQueued),
[messages]
);
if (queued.length === 0) return null;
return (
<div className="flex justify-center px-4 py-2 animate-in fade-in slide-in-from-bottom-1 duration-150">
<div className="w-full max-w-[600px] flex flex-col gap-1.5">
{queued.map(msg => {
const text = msg.content?.find(c => c.type === 'text')?.text || '';
return (
<div key={msg.id} className="flex items-start gap-2 text-xs text-muted-foreground/50">
<Clock className="shrink-0 h-3 w-3 mt-0.5" />
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="flex-1 text-left truncate cursor-default">{text}</TooltipTrigger>
<TooltipContent side="top" className="max-w-sm whitespace-pre-wrap">{text}</TooltipContent>
</Tooltip>
</TooltipProvider>
<button
onClick={() => cancelMessage(msg.id)}
className="shrink-0 mt-0.5 hover:text-destructive transition-colors"
>
<X className="h-3 w-3" />
</button>
</div>
);
})}
</div>
</div>
);
});
/**
* The chat body: header, message list or empty state, and the input.
*
* Exported because it was not, and every panel that needed a chat surface
* copied it instead — seven near-identical versions across the app, already
* drifted on padding, prompt sizing and which message kinds render at all.
* It depends only on provider context, so any caller inside its own
* AgentChatProvider can use it directly.
*/
export const AgentContent = memo(function AgentContent({
/**
* Sizing and padding are the caller's, not this component's. `h-full p-2`
* used to be baked in here, so panels that wanted a different box passed
* `h-auto p-0` to undo it — and that only worked because cn is twMerge.
*/
className,
compact,
allowFiles = true,
allowImages = true,
description,
examplePrompts,
header,
emptyState,
emptyIcon,
emptyTitle,
chatInputRef,
}: {
className?: string;
compact?: boolean;
allowFiles?: boolean;
allowImages?: boolean;
description?: string;
examplePrompts?: string[];
/** Replaces the default header. Ignored when `compact` hides the header entirely. */
header?: ReactNode;
/** Replaces the default empty state, for panels that need their own copy. */
emptyState?: ReactNode;
/** Empty-state glyph. Defaults to the bot; pass null for none. */
emptyIcon?: ReactNode;
/** Empty-state headline. Defaults to "how can I help?". */
emptyTitle?: string;
/**
* Handle on the composer. Panels that prefill the input rather than sending
* outright — an embed wants the visitor to read and edit first — need this to
* drive it from their own empty state.
*/
chatInputRef?: Ref<ChatInputHandle>;
}) {
const { chat, messages, agentInfo } = useAgentChat();
const hasMessages = messages.length > 0;
const resolvedDescription = description ?? agentInfo?.description;
const resolvedExamplePrompts = examplePrompts ?? agentInfo?.example_prompts;
const hasGeneratingAssistant = messages.some(
m => m.role === ChatMessageRoleAssistant && !isTerminalStatus(m.status)
);
const showTyping = chat?.status === 'busy' && !hasGeneratingAssistant;
return (
<ChatContainer className={className}>
{!compact && (header ?? <DefaultHeader />)}
{hasMessages ? (
<VirtualizedChatMessages
className="flex-1"
renderMessage={renderMessage}
typingIndicator={showTyping ? <MessageStatusIndicator /> : undefined}
/>
) : (
emptyState ?? (
<EmptyState
description={resolvedDescription}
examplePrompts={resolvedExamplePrompts}
icon={emptyIcon}
title={emptyTitle}
/>
)
)}
<QueuedMessageStrip />
<ChatInput
ref={chatInputRef}
examplePrompts={examplePrompts}
allowFiles={allowFiles}
allowImages={allowImages}
/>
</ChatContainer>
);
});
// =============================================================================
// Main Component
// =============================================================================
export function Agent({
proxyUrl,
apiKey,
baseUrl,
config,
name,
chatId,
className,
compact = false,
allowFiles = true,
allowImages = true,
onChatCreated,
description,
examplePrompts,
}: AgentProps) {
// Create client internally - memoized to prevent re-creation
const client = useMemo(() => {
if (!proxyUrl && !apiKey) {
console.error('[Agent] Either proxyUrl or apiKey is required');
return null;
}
return new Inference({ proxyUrl, apiKey, baseUrl });
}, [proxyUrl, apiKey, baseUrl]);
if (!client) {
return null;
}
// Extract description and example prompts from ad-hoc config, or use props
const effectiveDescription = description ?? (isAdHocConfig(config) ? config.description : undefined);
const effectiveExamplePrompts = examplePrompts ?? (isAdHocConfig(config) ? config.example_prompts : undefined);
return (
<AgentChatProvider
client={client}
agentConfig={config}
chatId={chatId}
onChatCreated={onChatCreated}
>
<AgentContent
className={className}
compact={compact}
allowFiles={allowFiles}
allowImages={allowImages}
description={effectiveDescription}
examplePrompts={effectiveExamplePrompts}
/>
</AgentChatProvider>
);
}
Agent.displayName = 'Agent';
- Where it goes
- statschatfeature-grid
- Interaction
- animated-counter
DependenciesBundle weight · light
@inferencesh/sdk