import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Send, X, Sparkles, Image as ImageIcon, Film, Search, MessageSquare, Mic, Folder, Clock, Settings, Paperclip, Server, PlugZap, Trash2, Tv2, Workflow, Copy, RotateCw, PenLine, Phone, Users, EyeOff, PanelLeftClose, PanelLeft, } from 'lucide-react'; import SettingsPanel from './SettingsPanel'; import { resolveBackendUrl } from './lib/backendUrl'; import ProfileSettingsModal from './ProfileSettingsModal'; import UserAvatar from './components/UserAvatar'; import AccountMenu from './components/AccountMenu'; import { useAuth } from './components/AuthGate'; import VoiceMode, { stripMarkdownForSpeech } from './VoiceModeGrok'; import CallOverlay from './CallOverlay'; import { clog, speakOwned, isCallFullDuplexEnabled } from './call/log'; import PostCallCard from './phone/PostCallCard'; import CallEventRow from './phone/CallEventRow'; // Legacy voice mode available as: import VoiceModeLegacy from './VoiceModeLegacy' import ProjectsView from './ProjectsView'; import ImagineView from './Imagine'; import AnimateView from './Animate'; import InteractiveView from './Interactive'; import { InteractiveHost } from './InteractiveHost'; import InteractivePlayer from './InteractivePlayer'; import ModelsView from './Models'; import StudioView from './Studio'; import { CreatorStudioHost } from './CreatorStudioHost'; import { ChatSettingsPopover, DEFAULT_CHAT_SETTINGS, } from './components/ChatSettingsPopover'; import { MessageMarkdown } from './components/MessageMarkdown'; import { ChatEmptyState } from './components/ChatEmptyState'; import { INTENT_COPY } from './components/AgentIntentTiles'; import { AgentSettingsPanel } from './components/AgentSettingsPanel'; import { PersonaSettingsPanel } from './components/PersonaSettingsPanel'; import { detectAgenticIntent } from './agentic/intent'; import { ImageViewer } from './ImageViewer'; import { EditTab } from './edit'; import { AvatarStudio } from './avatar'; import { TeamsView, useTeamsMcpAvailable } from './teams'; import { SaveAsPersonaModal } from './avatar/SaveAsPersonaModal'; import { PersonaWizard } from './PersonaWizard'; import AboutDialog from './AboutDialog'; import SystemStatusDialog from './SystemStatusDialog'; import { PERSONALITY_CAPS } from './voice/personalityCaps'; import { getVoiceLinkedProjectId, setVoiceLinkedToProject, isPersonasEnabled, setPersonasEnabled as setPersonasEnabledGating, LS_PERSONA_CACHE, } from './voice/personalityGating'; // Companion-grade session management (additive) import { resolveSession, createSession, endSession } from './sessions'; import { SessionPanel, PersonaHubDrawer } from './sessions'; /** * Hydrate a persisted message's ``media`` field into the pieces the * Msg type carries. For call-memory rows (produced by the call-end * POST in the CallOverlay handler below) the payload lives under * ``media.call_memory`` — we lift it into the top-level ``callMemory`` * property so the existing PostCallCard render branch picks it up * transparently. * * For every other media shape (images, video_url, …) this returns * ``{ media: raw }`` unchanged, so regular messages are untouched. */ function hydratePersistedMessageMedia(raw) { if (!raw || typeof raw !== 'object') return { media: undefined }; const m = raw; if (m.type === 'call_memory' && m.call_memory && typeof m.call_memory === 'object') { const cm = m.call_memory; return { media: undefined, callMemory: { durationSec: Number(cm.durationSec ?? 0) || 0, endedAt: typeof cm.endedAt === 'number' ? cm.endedAt : undefined, personaName: typeof cm.personaName === 'string' ? cm.personaName : undefined, transcript: Array.isArray(cm.transcript) ? cm.transcript .filter((t) => (t.who === 'user' || t.who === 'assistant') && typeof t.text === 'string') .map((t) => ({ who: t.who, text: String(t.text) })) : undefined, }, }; } return { media: raw }; } // --------------------------------------------------------------------------- // ViewAngleChips — clickable angle selectors rendered under chat images // when the message has an interactive view_pack. // --------------------------------------------------------------------------- const VIEW_ANGLE_LABELS = { front: 'Front', left: 'Left', right: 'Right', back: 'Back' }; function ViewAngleChips({ viewPack, activeAngle, availableViews, onSelect, }) { // Preload all view_pack images on mount so angle switches are instant React.useEffect(() => { availableViews.forEach((angle) => { const url = viewPack[angle]; if (url) { const img = new Image(); img.src = url; } }); }, [viewPack, availableViews]); return (
{availableViews.map((angle) => { const url = viewPack[angle]; if (!url) return null; const isActive = angle === activeAngle; return (); })}
); } /** * Prevent duplicate transcript surfaces in chat. When a call-memory * card includes an inline transcript, those same turns have already * been rendered in the thread; collapse them so the transcript lives * only inside the card. */ function collapseCallTurns(msgs) { const out = []; for (let i = 0; i < msgs.length; i++) { const m = msgs[i]; const n = m.callMemory?.transcript?.length ?? 0; if (n > 0 && out.length >= n) { out.splice(out.length - n, n); } out.push(m); } return out; } /** * Feature flag for the enterprise inline call-event row. When true * (default), phone calls render as a thin centered divider with * hover-revealed actions and an inline expandable transcript. * When false, falls back to the legacy PostCallCard (boxy, big, * primary-colored Resume button). * * Priority: * 1. localStorage ``homepilot_call_card_legacy`` === 'true' → legacy * 2. build-time ``VITE_CALL_ENTERPRISE_ROW`` === 'false' → legacy * 3. default → enterprise */ function useEnterpriseCallRow() { try { if (typeof window !== 'undefined') { const legacy = window.localStorage.getItem('homepilot_call_card_legacy'); if (legacy === 'true') return false; } } catch { /* ignore */ } const envVal = import.meta.env?.VITE_CALL_ENTERPRISE_ROW; return String(envVal ?? 'true') !== 'false'; } // ----------------------------------------------------------------------------- // Components (consolidated) // ----------------------------------------------------------------------------- function Typewriter({ text, speed = 10, onDone, }) { const [displayedText, setDisplayedText] = useState(''); const indexRef = useRef(0); const doneRef = useRef(false); // Reset when text changes (e.g. if we switch messages or streaming updates) useEffect(() => { // If text is already fully displayed, don't reset (prevents flickering on re-renders) if (text.startsWith(displayedText) && displayedText.length > 0 && text.length > displayedText.length) { // Continue typing from current position } else if (text !== displayedText && !text.startsWith(displayedText)) { // New text content entirely setDisplayedText(''); indexRef.current = 0; doneRef.current = false; } else if (text === displayedText) { return; } }, [text, displayedText]); useEffect(() => { const timer = setInterval(() => { if (indexRef.current < text.length) { setDisplayedText((prev) => text.slice(0, indexRef.current + 1)); indexRef.current++; } else { clearInterval(timer); if (!doneRef.current) { doneRef.current = true; onDone?.(); } } }, speed); return () => clearInterval(timer); }, [text, speed]); return {displayedText}; } function NavItem({ icon: Icon, label, active, shortcut, onClick, collapsed, }) { return (); } function SettingsPopover({ value, onChange, onClose, }) { const [availableModels, setAvailableModels] = useState([]); const [loadingModels, setLoadingModels] = useState(false); const [modelsError, setModelsError] = useState(null); const fetchModels = async () => { setLoadingModels(true); setModelsError(null); try { const url = `${value.backendUrl}/models?provider=ollama&base_url=${encodeURIComponent(value.ollamaUrl)}`; const response = await fetch(url); const data = await response.json(); if (data.ok && Array.isArray(data.models)) { setAvailableModels(data.models); if (data.models.length === 0) { setModelsError('No models found. Run "ollama pull " to download a model.'); } } else { setModelsError(data.message || 'Failed to fetch models'); } } catch (err) { setModelsError(err.message || 'Failed to connect to backend'); } finally { setLoadingModels(false); } }; return (

Settings

{/* Backend URL */}
onChange({ ...value, backendUrl: e.target.value })} placeholder="http://localhost:8000" inputMode="url"/>
Used for /chat and /upload. Example: http://localhost:8000
{/* Provider */}
If you choose Ollama, your browser must reach Ollama and CORS must allow it (or use a reverse proxy).
{/* Ollama options */} {value.provider === 'ollama' ? (
onChange({ ...value, ollamaUrl: e.target.value })} placeholder="http://localhost:11434" inputMode="url"/>
onChange({ ...value, ollamaModel: e.target.value })} placeholder="llama3:8b"/>
{modelsError && (
{modelsError}
)} {availableModels.length > 0 && (
Available models ({availableModels.length}):
{availableModels.map((model) => ())}
)}
) : null} {/* API Key */}
onChange({ ...value, apiKey: e.target.value })} placeholder="x-api-key value"/>
{/* Divider */}

Hardware Preset

{['4060', '4080', 'a100', 'custom'].map((preset) => ())}
{value.preset === '4060' && '✓ RTX 4060: 1024x1024, 20 steps, good for quick iterations'} {value.preset === '4080' && '✓ RTX 4080: Higher res, 25 steps, balanced quality'} {value.preset === 'a100' && '✓ A100: Max quality, 1536x1536, 40 steps'} {value.preset === 'custom' && '✓ Custom: Manual settings below'}
{/* Text Generation */}

Text Generation

onChange({ ...value, textTemperature: parseFloat(e.target.value) })} className="w-full"/>
onChange({ ...value, textMaxTokens: parseInt(e.target.value) })} className="w-full"/>
{/* Image Generation */}

Image Generation

onChange({ ...value, imgWidth: parseInt(e.target.value) || 1024 })} className="w-full bg-black border border-white/10 rounded-lg px-2 py-1 text-xs text-white"/>
onChange({ ...value, imgHeight: parseInt(e.target.value) || 1024 })} className="w-full bg-black border border-white/10 rounded-lg px-2 py-1 text-xs text-white"/>
onChange({ ...value, imgSteps: parseInt(e.target.value) })} className="w-full"/>
onChange({ ...value, imgCfg: parseFloat(e.target.value) })} className="w-full"/>
onChange({ ...value, imgSeed: parseInt(e.target.value) || -1 })} className="w-full bg-black border border-white/10 rounded-lg px-2 py-1 text-xs text-white"/>
{/* Video Generation */}

Video Generation

onChange({ ...value, vidSeconds: parseInt(e.target.value) })} className="w-full"/>
onChange({ ...value, vidFps: parseInt(e.target.value) })} className="w-full"/>
{/* Fun mode */}
Fun Mode
); } function HistoryPanel({ conversations, searchQuery, setSearchQuery, onLoadConversation, onDeleteConversation, onClose, }) { const filteredConversations = conversations.filter((conv) => { if (!searchQuery) return true; const query = searchQuery.toLowerCase(); return (conv.conversation_id.toLowerCase().includes(query) || conv.last_content.toLowerCase().includes(query)); }); return (

Conversation History

setSearchQuery(e.target.value)} placeholder="Search conversations..." className="w-full bg-black border border-white/10 rounded-xl pl-9 pr-3 py-2 text-xs text-white focus:outline-none focus:border-white/30"/>
{filteredConversations.length === 0 ? (
{searchQuery ? 'No conversations found' : 'No conversation history yet'}
) : (filteredConversations.map((conv) => (
)))}
); } /** * Turn the conversation's ``last_content`` into a single-line * sidebar title similar to ChatGPT's. Tool-response payloads are * often JSON blobs (``{"type":"final","text":"…"}``) so we peek * into the common shape keys and pull the readable field when we * can. Everything is whitespace-collapsed and the CSS handles * the ellipsis at overflow. */ function cleanConversationTitle(raw) { const input = (raw || '').trim(); if (!input) return 'New conversation'; let text = input; const first = text[0]; if (first === '{' || first === '[') { try { const parsed = JSON.parse(text); const pick = (v) => { if (typeof v === 'string') return v; if (v && typeof v === 'object') { const o = v; return ((typeof o.text === 'string' && o.text) || (typeof o.content === 'string' && o.content) || (typeof o.message === 'string' && o.message) || (typeof o.reply_text === 'string' && o.reply_text) || ''); } return ''; }; const candidate = Array.isArray(parsed) ? pick(parsed[0]) : pick(parsed); if (candidate) text = candidate; } catch { // Not valid JSON — use the raw string. } } return text.replace(/\s+/g, ' ').trim() || 'Conversation'; } function SidebarRecents({ conversations, activeConversationId, onLoadConversation, onViewAll, }) { const buckets = useMemo(() => { const now = new Date(); const startOfDay = (d) => { const x = new Date(d); x.setHours(0, 0, 0, 0); return x; }; const daysBetween = (a, b) => Math.floor((startOfDay(a).getTime() - startOfDay(b).getTime()) / 86_400_000); const sorted = [...conversations].sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); const today = []; const yesterday = []; const last7 = []; const older = []; for (const c of sorted) { const diff = daysBetween(now, new Date(c.updated_at)); if (diff === 0) today.push(c); else if (diff === 1) yesterday.push(c); else if (diff <= 7) last7.push(c); else older.push(c); } return { today, yesterday, last7, older }; }, [conversations]); const renderRow = (c) => { const isActive = c.conversation_id === activeConversationId; // Clean the raw last_content for display: tool payloads often // arrive as JSON blobs and we don't want those spilling into // the sidebar. The title attribute keeps the full text for // hover tooltips. const label = cleanConversationTitle(c.last_content); return (); }; const hasBuckets = buckets.today.length > 0 || buckets.yesterday.length > 0 || buckets.last7.length > 0 || buckets.older.length > 0; if (!hasBuckets) return null; return (
{buckets.today.length > 0 ? (
Today
{buckets.today.slice(0, 6).map(renderRow)}
) : null} {buckets.yesterday.length > 0 ? (
Yesterday
{buckets.yesterday.slice(0, 6).map(renderRow)}
) : null} {buckets.last7.length > 0 ? (
Last 7 days
{buckets.last7.slice(0, 8).map(renderRow)}
) : null} {buckets.older.length > 0 ? (
Older
{buckets.older.slice(0, 6).map(renderRow)}
) : null}
); } function Sidebar({ mode, setMode, messages, conversations, activeConversationId, onLoadConversation, onNewConversation, onScrollToBottom, showSettings, setShowSettings, settingsDraft, setSettingsDraft, onSaveSettings, showHistory, setShowHistory, collapsed, onToggleCollapse, }) { const [showAccountMenu, setShowAccountMenu] = useState(false); const [showProfileModal, setShowProfileModal] = useState(false); const [showAboutDialog, setShowAboutDialog] = useState(false); const [showSystemStatus, setShowSystemStatus] = useState(false); const { user: authUser, logout } = useAuth(); // Build AccountMenuUser from auth context (fallback for pre-auth setups) const currentUser = useMemo(() => { if (authUser) { // Prefer display_name, but fall back to username if display_name is // empty or still the generic default "User" const effectiveName = authUser.display_name && authUser.display_name !== 'User' ? authUser.display_name : authUser.username || authUser.display_name || 'User'; return { id: authUser.id, username: authUser.username, display_name: effectiveName, email: authUser.email, avatar_url: authUser.avatar_url, }; } // Fallback: try localStorage (backward compat with non-auth setups) try { const raw = localStorage.getItem('homepilot_auth_user'); if (raw) { const parsed = JSON.parse(raw); // Same logic: prefer real display_name over generic "User" if (parsed.display_name === 'User' && parsed.username) { parsed.display_name = parsed.username; } return parsed; } } catch { /* ignore */ } return { id: '', username: 'User', display_name: 'User', email: '', avatar_url: '' }; }, [authUser]); // Smooth logout via AuthContext (no page reload) const handleLogout = useCallback(async () => { setShowAccountMenu(false); await logout(); }, [logout]); return (); } function QueryBar({ centered, input, setInput, mode, fileInputRef, canSend, onSend, onUpload, placeholderOverride, pendingPreviewUrl, onRemoveAttachment, }) { // ---- Drag-and-drop image support ---- const [isDragging, setIsDragging] = useState(false); const dragCounterRef = useRef(0); const handleDragEnter = useCallback((e) => { e.preventDefault(); e.stopPropagation(); dragCounterRef.current++; if (e.dataTransfer.types.includes('Files')) setIsDragging(true); }, []); const handleDragLeave = useCallback((e) => { e.preventDefault(); e.stopPropagation(); dragCounterRef.current--; if (dragCounterRef.current === 0) setIsDragging(false); }, []); const handleDragOver = useCallback((e) => { e.preventDefault(); e.stopPropagation(); }, []); const handleDrop = useCallback((e) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); dragCounterRef.current = 0; const file = e.dataTransfer.files?.[0]; if (file && file.type.startsWith('image/')) onUpload(file); }, [onUpload]); // ---- Paste image from clipboard ---- const handlePaste = useCallback((e) => { const items = e.clipboardData?.items; if (!items) return; for (const item of Array.from(items)) { if (item.type.startsWith('image/')) { e.preventDefault(); const blob = item.getAsFile(); if (blob) onUpload(new File([blob], 'pasted-image.png', { type: blob.type })); return; } } }, [onUpload]); // ---- Speech-to-text for the mic button ---- const [isListening, setIsListening] = useState(false); const recognitionRef = useRef(null); const toggleListening = useCallback(() => { // Stop if already listening if (isListening && recognitionRef.current) { recognitionRef.current.stop(); return; } const SR = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SR) return; const recognition = new SR(); recognition.continuous = false; recognition.interimResults = true; recognition.lang = 'en-US'; recognitionRef.current = recognition; recognition.onstart = () => setIsListening(true); recognition.onend = () => { setIsListening(false); recognitionRef.current = null; }; recognition.onerror = () => { setIsListening(false); recognitionRef.current = null; }; recognition.onresult = (event) => { let finalTranscript = ''; let interimTranscript = ''; for (let i = event.resultIndex; i < event.results.length; i++) { const t = event.results[i][0].transcript; if (event.results[i].isFinal) finalTranscript += t + ' '; else interimTranscript += t; } // Show interim text while speaking, final text when done setInput(finalTranscript.trim() || interimTranscript); }; try { recognition.start(); } catch { /* already started */ } }, [isListening, setInput]); return (
{isDragging && (
Drop image to attach
)} {/* Left: attach */}
{ const f = e.target.files?.[0]; if (f) onUpload(f); e.currentTarget.value = ''; }}/>
{/* Right: submit or mic */}
{isListening ? () : canSend ? () : ()}
{/* Pending image attachment preview */} {pendingPreviewUrl && (
Attached Image attached
)} {/* Textarea */}