shadimage

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