/** * ChatPanel — reusable AI chat component with preset modes. * * Presets: * - simple: Chat only (messages + composer). No header, no sidebar. * - standard: Chat + header. Sidebar toggle available. * - developer: Full experience — header + sidebar (open by default, all sections). * * Embeddable anywhere. Owns its own useChatEngine instance. * Parent can override individual section visibility via props. */ import React, { useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import { Bot } from 'lucide-react'; import { useChatEngine } from '@/modules/ai/useChatEngine'; import ChatHeader from '@/modules/ai/components/ChatHeader'; import ChatSidebar from '@/modules/ai/components/ChatSidebar'; import ChatComposer from '@/modules/ai/components/ChatComposer'; import MessageBubble from '@/modules/ai/components/MessageBubble'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'; import { Sheet, SheetContent } from '@/components/ui/sheet'; import { useIsMobile } from '@/hooks/use-mobile'; import { buildSupportPrompt, SupportContextKey } from '@/modules/ai/defaults'; // ── Types ──────────────────────────────────────────────────────────────── export type ChatPreset = 'simple' | 'standard' | 'developer' | 'support'; export interface ChatPanelProps { /** Preset controls the default layout. Default: 'developer' */ preset?: ChatPreset; /** Override: show/hide the header bar */ showHeader?: boolean; /** Override: allow sidebar (settings toggle visible in header) */ showSidebar?: boolean; /** Override: sidebar initially open */ sidebarOpen?: boolean; /** CSS class on the outer container */ className?: string; /** Unique layout persistence ID (defaults to 'chat-layout') */ layoutId?: string; /** * Dynamic context provider — called just before each send. * Return a string to inject into the system prompt, or null to skip. * Use this to inject page context, selection state, etc. */ getContext?: () => string | null; /** * Extra tools provider — called when assembling tools for each send. * Return an array of OpenAI-compatible tool definitions (RunnableToolFunctionWithParse). */ extraTools?: () => any[]; /** * Pre-wired engine instance. When provided, ChatPanel uses this engine * instead of creating its own via useChatEngine(). The caller is * responsible for wiring context/tools into the engine. * getContext and extraTools props are ignored when engine is supplied. */ engine?: ReturnType; /** * Extra toggles to render in the sidebar (for edit mode specific tools) */ extraToggles?: React.ReactNode; /** * Seed the engine with a fixed provider on first mount. * Useful for support / embedded chat where the provider must not be changed by the user. */ initialProvider?: string; /** Seed the engine with a fixed model on first mount. */ initialModel?: string; /** Seed the engine with a system prompt on first mount. */ initialSystemPrompt?: string; /** * Named context additions from defaults.ts to append to the base support prompt. * Used when initialSystemPrompt is NOT provided — the prompt is assembled via * buildSupportPrompt() using the current UI language + these context keys. * e.g. context={['shipping-rates']} */ context?: SupportContextKey[]; /** Seed search-tools enabled on first mount. */ initialSearchToolsEnabled?: boolean; /** Seed page-tools enabled on first mount. */ initialPageToolsEnabled?: boolean; /** Seed image-tools enabled on first mount. */ initialImageToolsEnabled?: boolean; /** Seed VFS-tools enabled on first mount. */ initialVfsToolsEnabled?: boolean; /** * Portal target for the composer. If provided, the composer is rendered * into this DOM element via createPortal instead of inside the messages panel. * Use this to mount the chat input in a completely different part of the page. * * @example * const composerTarget = useRef(null); * *
// composer renders here */ composerPortal?: React.RefObject; /** Initial user input to populate in the text area when opened */ initialPrompt?: string; /** If true, the initialPrompt will be submitted immediately */ initialAutoSend?: boolean; } // ── Preset defaults ────────────────────────────────────────────────────── const PRESET_DEFAULTS: Record = { simple: { showHeader: false, showSidebar: false, sidebarOpen: false, }, standard: { showHeader: true, showSidebar: true, sidebarOpen: false, }, developer: { showHeader: true, showSidebar: true, sidebarOpen: true, }, support: { showHeader: false, showSidebar: false, sidebarOpen: false, }, }; // ── Messages + Composer sub-component ───────────────────────────────────── const ChatMessages: React.FC<{ engine: ReturnType; composerPortal?: React.RefObject; layoutId: string; }> = ({ engine, composerPortal, layoutId }) => { const composer = ( engine.fileInputRef.current?.click()} onOpenGallery={() => engine.setShowImagePicker(true)} onCloseGallery={() => engine.setShowImagePicker(false)} onPickerSelect={engine.handlePickerSelect} onPickerSelectSingle={(pic: any) => { engine.handlePickerSelect([pic]); }} fileContexts={engine.fileContexts} onRemoveFileContext={engine.removeFileContext} /> ); const bottomRef = useRef(null); const scrollContainerRef = engine.scrollRef; // Interrupt auto-scroll if the user scrolls up manually useEffect(() => { const el = scrollContainerRef.current; if (!el) return; const handleScroll = () => { const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50; engine.isUserScrolledUpRef.current = !isAtBottom; }; el.addEventListener('scroll', handleScroll, { passive: true }); return () => el.removeEventListener('scroll', handleScroll); }, [scrollContainerRef, engine.isUserScrolledUpRef]); // Scroll to bottom when messages change or stream updates useEffect(() => { if (!engine.isUserScrolledUpRef.current) { bottomRef.current?.scrollIntoView({ block: 'end', behavior: 'smooth' }); } }, [engine.messages, engine.isUserScrolledUpRef]); return (
{/* Messages */}
{engine.messages.length === 0 ? (

Send a message to start chatting

Using {engine.provider}/{engine.model} • Enter to send

Drop images or use 📎 to attach

) : ( engine.messages .filter(msg => msg.role !== 'tool') .map(msg => ( )) )}
{/* Mini-log: last 2 info-level entries for at-a-glance activity */} {(() => { const recent = engine.chatLogs .filter(l => l.level === 'info') .slice(-2); if (!recent.length) return null; return (
{recent.map(l => (

{l.message}

))}
); })()} {/* Composer — portaled if target exists, inline as a resizable panel otherwise */} {composerPortal?.current ? ( createPortal(composer, composerPortal.current) ) : ( <> {composer} )}
); }; // ── Sidebar props helper ───────────────────────────────────────────────── export const sidebarProps = (engine: ReturnType) => ({ provider: engine.provider, model: engine.model, onProviderChange: engine.setProvider, onModelChange: engine.setModel, systemPrompt: engine.systemPrompt, onSystemPromptChange: engine.setSystemPrompt, toolsEnabled: engine.toolsEnabled, onToolsEnabledChange: engine.setToolsEnabled, pageToolsEnabled: engine.pageToolsEnabled, onPageToolsEnabledChange: engine.setPageToolsEnabled, imageToolsEnabled: engine.imageToolsEnabled, onImageToolsEnabledChange: engine.setImageToolsEnabled, imageModel: engine.imageModel, onImageModelChange: engine.setImageModel, vfsToolsEnabled: engine.vfsToolsEnabled, onVfsToolsEnabledChange: engine.setVfsToolsEnabled, webSearchEnabled: engine.webSearchEnabled, onWebSearchEnabledChange: engine.setWebSearchEnabled, isGenerating: engine.isGenerating, messages: engine.messages, user: engine.user, chatLogs: engine.chatLogs, onClearLogs: () => engine.setChatLogs([]), lastApiMessages: engine.lastApiMessages, sessionId: engine.sessionId, sessions: engine.sessions, onNewSession: engine.handleNewSession, onLoadSession: engine.handleLoadSession, onDeleteSession: engine.handleDeleteSession, fileContexts: engine.fileContexts, addFileContext: engine.addFileContext, removeFileContext: engine.removeFileContext, }); // ── Main Component ─────────────────────────────────────────────────────── const ChatPanel: React.FC = ({ preset = 'developer', showHeader: showHeaderProp, showSidebar: showSidebarProp, sidebarOpen: sidebarOpenProp, className = '', layoutId = 'chat-layout', getContext, extraTools, extraToggles, engine: externalEngine, composerPortal, initialProvider, initialModel, initialSystemPrompt, context, initialSearchToolsEnabled, initialPageToolsEnabled, initialImageToolsEnabled, initialVfsToolsEnabled, initialPrompt, initialAutoSend, }) => { const defaults = PRESET_DEFAULTS[preset]; const hasHeader = showHeaderProp ?? defaults.showHeader; const hasSidebar = showSidebarProp ?? defaults.showSidebar; const ownEngine = useChatEngine(preset); const engine = externalEngine || ownEngine; const isMobile = useIsMobile(); // Wire external context and tools providers into the engine synchronously // (skipped when an external engine is provided — caller handles wiring) if (!externalEngine) { engine.contextProviderRef.current = getContext ?? null; engine.extraToolsRef.current = extraTools ?? null; } // One-shot: seed initial provider/model/systemPrompt/tools on first mount useEffect(() => { if (externalEngine) return; if (initialProvider !== undefined) engine.setProvider(initialProvider); if (initialModel !== undefined) engine.setModel(initialModel); // System prompt: explicit prop wins; otherwise assemble from defaults + context const resolvedPrompt = initialSystemPrompt ?? buildSupportPrompt(context); engine.setSystemPrompt(resolvedPrompt); if (initialSearchToolsEnabled !== undefined) engine.setToolsEnabled(initialSearchToolsEnabled); if (initialPageToolsEnabled !== undefined) engine.setPageToolsEnabled(initialPageToolsEnabled); if (initialImageToolsEnabled !== undefined) engine.setImageToolsEnabled(initialImageToolsEnabled); if (initialVfsToolsEnabled !== undefined) engine.setVfsToolsEnabled(initialVfsToolsEnabled); // Pre-fill the composer if a prompt is provided if (initialPrompt) { engine.setInput(initialPrompt); if (initialAutoSend) { setTimeout(() => { engine.sendMessage(initialPrompt); }, 100); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Override sidebar initial state based on preset (only on mount) const sidebarInitial = sidebarOpenProp ?? defaults.sidebarOpen; // The engine already persists showSettings — but for 'simple' we force it off const showSettings = hasSidebar ? engine.showSettings : false; return (
{/* Header */} {hasHeader && (
engine.setShowSettings(v => !v) : () => { } } onExportJson={engine.handleExportJson} onExportMarkdown={engine.handleExportMarkdown} onClear={engine.handleClear} onNewSession={engine.handleNewSession} />
)} {/* Main area */}
{/* Mobile sidebar (Sheet) */} {hasSidebar && isMobile && (
)} {/* Desktop: resizable panel layout */}
{hasSidebar && showSettings && !isMobile && ( <> )}
{/* Mobile: full-width chat */}
); }; export { ChatPanel, ChatMessages }; export default ChatPanel;