import React, { useState, useRef, useEffect, useCallback, useMemo } from "react" import { useEditor } from "tldraw" import { canvasAI, useCanvasAI } from "@/lib/canvasAI" import { useWebSpeechTranscription } from "@/hooks/useWebSpeechTranscription" import { ToolSchema } from "@/lib/toolSchema" import { spawnTools, spawnTool } from "@/utils/toolSpawner" import { TransformCommand } from "@/utils/selectionTransforms" // Copy icon component const CopyIcon = () => ( ) // Check icon for copy confirmation const CheckIcon = () => ( ) // Simple markdown-like renderer for code blocks and basic formatting function renderMessageContent(content: string): React.ReactNode { // Split by code blocks first const parts = content.split(/(```[\s\S]*?```)/g) return parts.map((part, i) => { // Code block if (part.startsWith('```') && part.endsWith('```')) { const lines = part.slice(3, -3).split('\n') const language = lines[0]?.trim() || '' const code = language ? lines.slice(1).join('\n') : lines.join('\n') return ( ) } // Regular text - handle inline code and basic formatting return ( {part.split(/(`[^`]+`)/g).map((segment, j) => { if (segment.startsWith('`') && segment.endsWith('`')) { return ( {segment.slice(1, -1)} ) } return segment })} ) }) } // Code block component with copy button function CodeBlock({ code, language }: { code: string; language: string }) { const [copied, setCopied] = useState(false) const handleCopy = async () => { try { await navigator.clipboard.writeText(code) setCopied(true) setTimeout(() => setCopied(false), 2000) } catch (err) { console.error('Failed to copy:', err) } } return (
{language && (
{language}
)}
        {code}
      
{!language && ( )}
) } // Message bubble component with copy functionality interface MessageBubbleProps { content: string role: 'user' | 'assistant' colors: { userBubble: string assistantBubble: string border: string text: string textMuted: string } } function MessageBubble({ content, role, colors }: MessageBubbleProps) { const [copied, setCopied] = useState(false) const [showCopy, setShowCopy] = useState(false) const handleCopy = async () => { try { await navigator.clipboard.writeText(content) setCopied(true) setTimeout(() => setCopied(false), 2000) } catch (err) { console.error('Failed to copy:', err) } } const renderedContent = useMemo(() => renderMessageContent(content), [content]) return (
setShowCopy(true)} onMouseLeave={() => setShowCopy(false)} >
{renderedContent}
{/* Copy button on hover */} {showCopy && role === 'assistant' && content.length > 20 && ( )}
) } // Microphone icon component const MicrophoneIcon = ({ isListening }: { isListening: boolean }) => ( ) // Send icon component const SendIcon = () => ( ) // Expand/collapse icon const ExpandIcon = ({ isExpanded }: { isExpanded: boolean }) => ( ) // Tool suggestion card component interface ToolCardProps { tool: ToolSchema onSpawn: (tool: ToolSchema) => void isSpawned: boolean } const ToolCard = ({ tool, onSpawn, isSpawned }: ToolCardProps) => { const [isHovered, setIsHovered] = useState(false) return ( ) } // Prompt suggestion chip for selection transforms interface PromptSuggestionProps { label: string onClick: () => void } const PromptSuggestion = ({ label, onClick }: PromptSuggestionProps) => { const [isHovered, setIsHovered] = useState(false) return ( ) } // Follow-up suggestion chip with category-based styling interface FollowUpChipProps { suggestion: FollowUpSuggestion onClick: () => void } const CATEGORY_COLORS = { organize: { bg: 'rgba(59, 130, 246, 0.08)', border: 'rgba(59, 130, 246, 0.25)', text: '#3b82f6', hover: 'rgba(59, 130, 246, 0.15)' }, expand: { bg: 'rgba(16, 185, 129, 0.08)', border: 'rgba(16, 185, 129, 0.25)', text: '#10b981', hover: 'rgba(16, 185, 129, 0.15)' }, refine: { bg: 'rgba(245, 158, 11, 0.08)', border: 'rgba(245, 158, 11, 0.25)', text: '#f59e0b', hover: 'rgba(245, 158, 11, 0.15)' }, connect: { bg: 'rgba(139, 92, 246, 0.08)', border: 'rgba(139, 92, 246, 0.25)', text: '#8b5cf6', hover: 'rgba(139, 92, 246, 0.15)' }, create: { bg: 'rgba(236, 72, 153, 0.08)', border: 'rgba(236, 72, 153, 0.25)', text: '#ec4899', hover: 'rgba(236, 72, 153, 0.15)' }, } const FollowUpChip = ({ suggestion, onClick }: FollowUpChipProps) => { const [isHovered, setIsHovered] = useState(false) const colors = CATEGORY_COLORS[suggestion.category] return ( ) } // Selection transform suggestions that appear when shapes are selected const SELECTION_SUGGESTIONS = [ { label: 'arrange in a row', prompt: 'arrange in a row' }, { label: 'arrange in a column', prompt: 'arrange in a column' }, { label: 'arrange in a grid', prompt: 'arrange in a grid' }, { label: 'make same size', prompt: 'make these the same size' }, { label: 'align left', prompt: 'align left' }, { label: 'distribute evenly', prompt: 'distribute horizontally' }, { label: 'group by topic', prompt: 'cluster by semantic content' }, ] // Follow-up suggestions that appear after an action to guide the next step interface FollowUpSuggestion { label: string prompt: string icon?: string category: 'organize' | 'expand' | 'refine' | 'connect' | 'create' } // Context-aware follow-up suggestions based on what just happened type FollowUpContext = | { type: 'transform'; command: TransformCommand; shapeCount: number } | { type: 'tool_spawned'; toolId: string; toolName: string } | { type: 'ai_response'; hadSelection: boolean; topicKeywords: string[] } | { type: 'selection'; count: number; shapeTypes: Record } // Follow-up suggestions after transform commands const TRANSFORM_FOLLOWUPS: Record = { // After arranging in a row 'arrange-row': [ { label: 'make same size', prompt: 'make these the same size', icon: 'πŸ“', category: 'refine' }, { label: 'add labels', prompt: 'add a label above each shape', icon: '🏷️', category: 'expand' }, { label: 'connect with arrows', prompt: 'draw arrows connecting these in sequence', icon: 'β†’', category: 'connect' }, { label: 'group these', prompt: 'create a frame around these shapes', icon: 'πŸ“¦', category: 'organize' }, ], // After arranging in a column 'arrange-column': [ { label: 'make same width', prompt: 'make these the same width', icon: '↔️', category: 'refine' }, { label: 'number them', prompt: 'add numbers before each item', icon: 'πŸ”’', category: 'expand' }, { label: 'connect vertically', prompt: 'draw arrows connecting these from top to bottom', icon: '↓', category: 'connect' }, { label: 'add header', prompt: 'create a title above this column', icon: 'πŸ“', category: 'expand' }, ], // After arranging in a grid 'arrange-grid': [ { label: 'make uniform', prompt: 'make all shapes the same size', icon: '⊞', category: 'refine' }, { label: 'add row labels', prompt: 'label each row', icon: '🏷️', category: 'expand' }, { label: 'color by type', prompt: 'color code these by their content type', icon: '🎨', category: 'refine' }, { label: 'create matrix', prompt: 'add column and row headers to make a matrix', icon: 'πŸ“Š', category: 'expand' }, ], // After arranging in a circle 'arrange-circle': [ { label: 'add center node', prompt: 'add a central connecting node', icon: 'β­•', category: 'expand' }, { label: 'connect to center', prompt: 'draw lines from each to the center', icon: 'πŸ•ΈοΈ', category: 'connect' }, { label: 'label the cycle', prompt: 'add a title for this cycle diagram', icon: 'πŸ“', category: 'expand' }, ], // After aligning 'align-left': [ { label: 'distribute vertically', prompt: 'distribute these evenly vertically', icon: '↕️', category: 'refine' }, { label: 'make same width', prompt: 'make these the same width', icon: '↔️', category: 'refine' }, ], 'align-right': [ { label: 'distribute vertically', prompt: 'distribute these evenly vertically', icon: '↕️', category: 'refine' }, ], 'align-center': [ { label: 'stack vertically', prompt: 'arrange these in a column', icon: '⬇️', category: 'organize' }, ], 'align-top': [ { label: 'distribute horizontally', prompt: 'distribute these evenly horizontally', icon: '↔️', category: 'refine' }, ], 'align-bottom': [ { label: 'distribute horizontally', prompt: 'distribute these evenly horizontally', icon: '↔️', category: 'refine' }, ], // After distributing 'distribute-horizontal': [ { label: 'align tops', prompt: 'align these to the top', icon: '⬆️', category: 'refine' }, { label: 'make same size', prompt: 'make these the same size', icon: 'πŸ“', category: 'refine' }, { label: 'connect in sequence', prompt: 'draw arrows between these', icon: 'β†’', category: 'connect' }, ], 'distribute-vertical': [ { label: 'align left', prompt: 'align these to the left', icon: '⬅️', category: 'refine' }, { label: 'make same size', prompt: 'make these the same size', icon: 'πŸ“', category: 'refine' }, ], // After size normalization 'size-match-both': [ { label: 'arrange in grid', prompt: 'arrange in a grid', icon: '⊞', category: 'organize' }, { label: 'align centers', prompt: 'align horizontally centered', icon: '↔️', category: 'refine' }, ], 'size-match-width': [ { label: 'arrange in column', prompt: 'arrange in a column', icon: '⬇️', category: 'organize' }, ], 'size-match-height': [ { label: 'arrange in row', prompt: 'arrange in a row', icon: '➑️', category: 'organize' }, ], // After merging content 'merge-content': [ { label: 'summarize merged', prompt: 'summarize this combined content', icon: 'πŸ“', category: 'refine' }, { label: 'extract themes', prompt: 'identify the main themes in this content', icon: '🎯', category: 'expand' }, { label: 'create outline', prompt: 'organize this into an outline', icon: 'πŸ“‹', category: 'organize' }, ], // After semantic clustering 'cluster-semantic': [ { label: 'label clusters', prompt: 'add a label to each cluster', icon: '🏷️', category: 'expand' }, { label: 'connect related', prompt: 'draw connections between related clusters', icon: 'πŸ”—', category: 'connect' }, { label: 'create overview', prompt: 'create a summary of all clusters', icon: 'πŸ“Š', category: 'expand' }, ], } // Follow-up suggestions after spawning tools const TOOL_SPAWN_FOLLOWUPS: Record = { Prompt: [ { label: 'what should I ask?', prompt: 'suggest good prompts for my current canvas content', icon: 'πŸ’‘', category: 'expand' }, { label: 'use my notes', prompt: 'use content from my notes as context', icon: 'πŸ“', category: 'connect' }, ], ImageGen: [ { label: 'style suggestions', prompt: 'what visual styles would work well with my content?', icon: '🎨', category: 'expand' }, { label: 'from my notes', prompt: 'suggest an image based on my notes', icon: 'πŸ“', category: 'connect' }, ], VideoGen: [ { label: 'from image', prompt: 'which of my images would make a good video?', icon: 'πŸ–ΌοΈ', category: 'connect' }, { label: 'motion ideas', prompt: 'suggest motion effects for my content', icon: '✨', category: 'expand' }, ], Markdown: [ { label: 'summarize canvas', prompt: 'summarize what\'s on my canvas into this note', icon: 'πŸ“‹', category: 'connect' }, { label: 'create outline', prompt: 'create an outline from my current shapes', icon: 'πŸ“', category: 'organize' }, ], ChatBox: [ { label: 'discuss canvas', prompt: 'what would be good to discuss about my canvas content?', icon: 'πŸ’¬', category: 'expand' }, ], Transcription: [ { label: 'start recording', prompt: 'what should I talk about based on my canvas?', icon: '🎀', category: 'expand' }, ], } // Generic follow-ups based on canvas state const CANVAS_STATE_FOLLOWUPS = { manyShapes: [ { label: 'organize all', prompt: 'help me organize everything on this canvas', icon: 'πŸ—‚οΈ', category: 'organize' as const }, { label: 'find patterns', prompt: 'what patterns do you see in my content?', icon: 'πŸ”', category: 'expand' as const }, { label: 'identify themes', prompt: 'what are the main themes across my shapes?', icon: '🎯', category: 'expand' as const }, ], hasText: [ { label: 'summarize all', prompt: 'create a summary of all text content', icon: 'πŸ“', category: 'organize' as const }, { label: 'find connections', prompt: 'what connections exist between my notes?', icon: 'πŸ”—', category: 'connect' as const }, ], hasImages: [ { label: 'describe images', prompt: 'what themes are in my images?', icon: 'πŸ–ΌοΈ', category: 'expand' as const }, { label: 'animate one', prompt: 'which image should I animate?', icon: '🎬', category: 'create' as const }, ], hasAI: [ { label: 'continue creating', prompt: 'what should I create next?', icon: '✨', category: 'create' as const }, ], } // Tool-specific helpful prompts when a single tool is selected interface ToolPromptInfo { placeholder: string helpPrompt: string canDirectInput: boolean inputLabel?: string } const TOOL_PROMPTS: Record = { Prompt: { placeholder: 'Type here to send to your AI Prompt...', helpPrompt: 'What should I ask this AI prompt about?', canDirectInput: true, inputLabel: 'Send to Prompt', }, ImageGen: { placeholder: 'Describe the image you want to generate...', helpPrompt: 'Describe your desired image based on my canvas content', canDirectInput: true, inputLabel: 'Generate Image', }, VideoGen: { placeholder: 'Describe the motion/animation you want...', helpPrompt: 'What kind of motion should this video have?', canDirectInput: true, inputLabel: 'Generate Video', }, ChatBox: { placeholder: 'Send a message to this chat...', helpPrompt: 'What topic should we discuss in this chat?', canDirectInput: true, inputLabel: 'Send to Chat', }, Markdown: { placeholder: 'Add content to this note...', helpPrompt: 'What should I write in this note based on my canvas?', canDirectInput: true, inputLabel: 'Add to Note', }, ObsNote: { placeholder: 'Add to this observation...', helpPrompt: 'What observation should I add here?', canDirectInput: true, inputLabel: 'Add Note', }, Transcription: { placeholder: 'Ask about transcription...', helpPrompt: 'What should I record or transcribe?', canDirectInput: false, }, Embed: { placeholder: 'Enter a URL to embed...', helpPrompt: 'What should I embed here?', canDirectInput: true, inputLabel: 'Embed URL', }, Holon: { placeholder: 'Enter a Holon ID...', helpPrompt: 'Which Holon should I connect to?', canDirectInput: true, inputLabel: 'Connect Holon', }, } // Helper to get selected tool info function getSelectedToolInfo( selectionInfo: { count: number; types: Record } | null ): { toolType: string; promptInfo: ToolPromptInfo } | null { if (!selectionInfo || selectionInfo.count !== 1) return null const types = Object.keys(selectionInfo.types) if (types.length !== 1) return null const toolType = types[0] const promptInfo = TOOL_PROMPTS[toolType] if (!promptInfo) return null return { toolType, promptInfo } } // Generate follow-up suggestions based on context function getFollowUpSuggestions(context: FollowUpContext): FollowUpSuggestion[] { switch (context.type) { case 'transform': { const commandFollowups = TRANSFORM_FOLLOWUPS[context.command] || [] // Add generic suggestions if we have few specific ones if (commandFollowups.length < 2 && context.shapeCount > 1) { return [ ...commandFollowups, { label: 'create summary', prompt: 'summarize these shapes', icon: 'πŸ“', category: 'expand' }, { label: 'find similar', prompt: 'find other shapes related to these', icon: 'πŸ”', category: 'connect' }, ] } return commandFollowups } case 'tool_spawned': { return TOOL_SPAWN_FOLLOWUPS[context.toolId] || [ { label: 'how to use', prompt: `how do I best use ${context.toolName}?`, icon: '❓', category: 'expand' }, ] } case 'ai_response': { const suggestions: FollowUpSuggestion[] = [] // Context-aware follow-ups if (context.hadSelection) { suggestions.push( { label: 'do more with these', prompt: 'what else can I do with these selected shapes?', icon: '✨', category: 'expand' }, { label: 'find related', prompt: 'find shapes related to my selection', icon: 'πŸ”', category: 'connect' } ) } // Topic-based suggestions if (context.topicKeywords.length > 0) { suggestions.push( { label: 'expand on this', prompt: `tell me more about ${context.topicKeywords[0]}`, icon: 'πŸ“–', category: 'expand' }, { label: 'create visual', prompt: `create an image about ${context.topicKeywords[0]}`, icon: '🎨', category: 'create' } ) } // Default suggestions if (suggestions.length < 2) { suggestions.push( { label: 'what next?', prompt: 'what should I work on next?', icon: '➑️', category: 'expand' }, { label: 'organize ideas', prompt: 'help me organize my ideas', icon: 'πŸ—‚οΈ', category: 'organize' } ) } return suggestions.slice(0, 4) } case 'selection': { // For selections, show arrangement options based on count and types if (context.count >= 3) { return [ { label: 'arrange in grid', prompt: 'arrange in a grid', icon: '⊞', category: 'organize' }, { label: 'cluster by topic', prompt: 'cluster by semantic content', icon: '🎯', category: 'organize' }, ] } return [] } default: return [] } } // Extract likely topic keywords from AI response function extractTopicKeywords(response: string): string[] { // Simple extraction - look for quoted terms or capitalized phrases const keywords: string[] = [] // Find quoted phrases const quoted = response.match(/"([^"]+)"/g) if (quoted) { keywords.push(...quoted.map(q => q.replace(/"/g, '')).slice(0, 2)) } // Find terms after "about", "regarding", "for" const aboutMatch = response.match(/(?:about|regarding|for)\s+([A-Za-z][A-Za-z\s]{2,20}?)(?:[,.]|\s(?:and|or|in|on|to))/gi) if (aboutMatch) { keywords.push(...aboutMatch.map(m => m.replace(/^(?:about|regarding|for)\s+/i, '').replace(/[,.\s]+$/, '')).slice(0, 2)) } return [...new Set(keywords)].slice(0, 3) } // Hook to detect dark mode const useDarkMode = () => { const [isDark, setIsDark] = useState(() => document.documentElement.classList.contains('dark') ) useEffect(() => { const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.attributeName === 'class') { setIsDark(document.documentElement.classList.contains('dark')) } }) }) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) return () => observer.disconnect() }, []) return isDark } const ACCENT_COLOR = "#10b981" // Emerald green interface ConversationMessage { role: 'user' | 'assistant' content: string suggestedTools?: ToolSchema[] followUpSuggestions?: FollowUpSuggestion[] /** If this was a transform command result */ executedTransform?: TransformCommand } export function MycelialIntelligenceBar() { const editor = useEditor() const isDark = useDarkMode() const inputRef = useRef(null) const chatContainerRef = useRef(null) const containerRef = useRef(null) const [prompt, setPrompt] = useState("") const [isExpanded, setIsExpanded] = useState(false) const [isLoading, setIsLoading] = useState(false) const [isListening, setIsListening] = useState(false) const [conversationHistory, setConversationHistory] = useState([]) const [streamingResponse, setStreamingResponse] = useState("") const [indexingProgress, setIndexingProgress] = useState(0) const [isIndexing, setIsIndexing] = useState(false) const [isHovering, setIsHovering] = useState(false) const [suggestedTools, setSuggestedTools] = useState([]) const [spawnedToolIds, setSpawnedToolIds] = useState>(new Set()) const [selectionInfo, setSelectionInfo] = useState<{ count: number types: Record } | null>(null) const [followUpSuggestions, setFollowUpSuggestions] = useState([]) const [lastTransform, setLastTransform] = useState(null) const [toolInputMode, setToolInputMode] = useState<{ toolType: string; shapeId: string } | null>(null) const [isModalOpen, setIsModalOpen] = useState(false) // Detect when modals/dialogs are open to fade the bar useEffect(() => { const checkForModals = () => { // Check for common modal/dialog overlays const hasSettingsModal = document.querySelector('.settings-modal-overlay') !== null const hasTldrawDialog = document.querySelector('[data-state="open"][role="dialog"]') !== null const hasAuthModal = document.querySelector('.auth-modal-overlay') !== null const hasPopup = document.querySelector('.profile-popup') !== null setIsModalOpen(hasSettingsModal || hasTldrawDialog || hasAuthModal || hasPopup) } // Initial check checkForModals() // Use MutationObserver to detect DOM changes const observer = new MutationObserver(checkForModals) observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'style', 'data-state'] }) return () => observer.disconnect() }, []) // Derived state: get selected tool info const selectedToolInfo = getSelectedToolInfo(selectionInfo) // Initialize canvas AI with editor useCanvasAI(editor) // Track selection changes - use direct selection query for reliability useEffect(() => { if (!editor) return const updateSelection = () => { const selectedShapes = editor.getSelectedShapes() if (selectedShapes.length > 0) { // Count shape types const types: Record = {} for (const shape of selectedShapes) { types[shape.type] = (types[shape.type] || 0) + 1 } setSelectionInfo({ count: selectedShapes.length, types, }) // If single tool selected, track it for direct input mode if (selectedShapes.length === 1) { const shape = selectedShapes[0] if (TOOL_PROMPTS[shape.type]) { setToolInputMode({ toolType: shape.type, shapeId: shape.id as string }) } else { setToolInputMode(null) } } else { setToolInputMode(null) } } else { setSelectionInfo(null) setToolInputMode(null) } } // Initial check updateSelection() // Subscribe to all store changes and filter for selection const unsubscribe = editor.store.listen(updateSelection, { scope: 'all' }) return () => { unsubscribe() } }, [editor]) // Handle prompt suggestion click - fills in the prompt and submits const handleSuggestionClick = useCallback((suggestionPrompt: string) => { setPrompt(suggestionPrompt) // Use setTimeout to allow state to update, then submit setTimeout(() => { // Trigger submit with the suggestion const submitPrompt = async () => { if (!suggestionPrompt.trim()) return const newHistory: ConversationMessage[] = [ ...conversationHistory, { role: 'user', content: suggestionPrompt } ] setConversationHistory(newHistory) setIsLoading(true) setIsExpanded(true) setStreamingResponse("") setPrompt('') setFollowUpSuggestions([]) // Clear previous follow-ups try { const { isIndexing: currentlyIndexing } = canvasAI.getIndexingStatus() if (!currentlyIndexing) { setIsIndexing(true) await canvasAI.indexCanvas((progress) => { setIndexingProgress(progress) }) setIsIndexing(false) setIndexingProgress(100) } let fullResponse = '' let tools: ToolSchema[] = [] const result = await canvasAI.query( suggestionPrompt, (partial, done) => { fullResponse = partial setStreamingResponse(partial) if (done) { setIsLoading(false) } } ) tools = result.suggestedTools || [] setSuggestedTools(tools) setSpawnedToolIds(new Set()) // Generate follow-up suggestions based on result let newFollowUps: FollowUpSuggestion[] = [] if (result.executedTransform) { // Transform was executed - suggest next steps setLastTransform(result.executedTransform) newFollowUps = getFollowUpSuggestions({ type: 'transform', command: result.executedTransform, shapeCount: result.selectionCount, }) } else { // Regular AI response - suggest based on content const keywords = extractTopicKeywords(fullResponse) newFollowUps = getFollowUpSuggestions({ type: 'ai_response', hadSelection: result.hadSelection, topicKeywords: keywords, }) } setFollowUpSuggestions(newFollowUps) const updatedHistory: ConversationMessage[] = [ ...newHistory, { role: 'assistant', content: fullResponse, suggestedTools: tools, followUpSuggestions: newFollowUps, executedTransform: result.executedTransform, } ] setConversationHistory(updatedHistory) setStreamingResponse("") setIsLoading(false) } catch (error) { console.error('Mycelial Intelligence query error:', error) const errorMessage = error instanceof Error ? error.message : 'An error occurred' setConversationHistory([ ...newHistory, { role: 'assistant', content: `Error: ${errorMessage}` } ]) setStreamingResponse("") setIsLoading(false) setFollowUpSuggestions([]) } } submitPrompt() }, 0) }, [conversationHistory]) // Theme-aware colors const colors = isDark ? { background: 'rgba(30, 30, 30, 0.98)', backgroundHover: 'rgba(40, 40, 40, 1)', border: 'rgba(70, 70, 70, 0.8)', borderHover: 'rgba(90, 90, 90, 1)', text: '#e4e4e4', textMuted: '#a1a1aa', inputBg: 'rgba(50, 50, 50, 0.8)', inputBorder: 'rgba(70, 70, 70, 1)', inputText: '#e4e4e4', shadow: '0 8px 32px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.3)', shadowHover: '0 12px 40px rgba(0, 0, 0, 0.5), 0 6px 20px rgba(0, 0, 0, 0.4)', userBubble: 'rgba(16, 185, 129, 0.2)', assistantBubble: 'rgba(50, 50, 50, 0.9)', } : { background: 'rgba(255, 255, 255, 0.98)', backgroundHover: 'rgba(255, 255, 255, 1)', border: 'rgba(229, 231, 235, 0.8)', borderHover: 'rgba(209, 213, 219, 1)', text: '#18181b', textMuted: '#71717a', inputBg: 'rgba(244, 244, 245, 0.8)', inputBorder: 'rgba(228, 228, 231, 1)', inputText: '#18181b', shadow: '0 8px 32px rgba(0, 0, 0, 0.12), 0 4px 16px rgba(0, 0, 0, 0.08)', shadowHover: '0 12px 40px rgba(0, 0, 0, 0.15), 0 6px 20px rgba(0, 0, 0, 0.1)', userBubble: 'rgba(16, 185, 129, 0.1)', assistantBubble: 'rgba(244, 244, 245, 0.8)', } // Voice transcription const handleTranscriptUpdate = useCallback((text: string) => { setPrompt(prev => (prev + text).trim()) }, []) const { isRecording, isSupported: isVoiceSupported, startRecording, stopRecording, } = useWebSpeechTranscription({ onTranscriptUpdate: handleTranscriptUpdate, continuous: false, interimResults: true, }) // Update isListening state when recording changes useEffect(() => { setIsListening(isRecording) }, [isRecording]) // Scroll to bottom when conversation updates useEffect(() => { if (chatContainerRef.current) { chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight } }, [conversationHistory, streamingResponse]) // Click outside to collapse - detects clicks on canvas or outside the MI bar useEffect(() => { if (!isExpanded) return const handleClickOutside = (event: MouseEvent | PointerEvent) => { // Check if click is outside the MI bar container if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setIsExpanded(false) } } // Use pointerdown to catch clicks before they reach canvas document.addEventListener('pointerdown', handleClickOutside, true) return () => { document.removeEventListener('pointerdown', handleClickOutside, true) } }, [isExpanded]) // Handle voice toggle const toggleVoice = useCallback(() => { if (isRecording) { stopRecording() } else { startRecording() } }, [isRecording, startRecording, stopRecording]) // Handle submit const handleSubmit = useCallback(async () => { const trimmedPrompt = prompt.trim() if (!trimmedPrompt || isLoading) return // Clear prompt immediately setPrompt('') setFollowUpSuggestions([]) // Clear previous follow-ups const newHistory: ConversationMessage[] = [ ...conversationHistory, { role: 'user', content: trimmedPrompt } ] setConversationHistory(newHistory) setIsLoading(true) setIsExpanded(true) setStreamingResponse("") try { const { isIndexing: currentlyIndexing } = canvasAI.getIndexingStatus() if (!currentlyIndexing) { setIsIndexing(true) await canvasAI.indexCanvas((progress) => { setIndexingProgress(progress) }) setIsIndexing(false) setIndexingProgress(100) } let fullResponse = '' let tools: ToolSchema[] = [] const result = await canvasAI.query( trimmedPrompt, (partial, done) => { fullResponse = partial setStreamingResponse(partial) if (done) { setIsLoading(false) } } ) // Capture suggested tools from the result tools = result.suggestedTools || [] setSuggestedTools(tools) // Reset spawned tracking for new suggestions setSpawnedToolIds(new Set()) // Generate follow-up suggestions based on result let newFollowUps: FollowUpSuggestion[] = [] if (result.executedTransform) { // Transform was executed - suggest next steps setLastTransform(result.executedTransform) newFollowUps = getFollowUpSuggestions({ type: 'transform', command: result.executedTransform, shapeCount: result.selectionCount, }) } else { // Regular AI response - suggest based on content const keywords = extractTopicKeywords(fullResponse) newFollowUps = getFollowUpSuggestions({ type: 'ai_response', hadSelection: result.hadSelection, topicKeywords: keywords, }) } setFollowUpSuggestions(newFollowUps) const updatedHistory: ConversationMessage[] = [ ...newHistory, { role: 'assistant', content: fullResponse, suggestedTools: tools, followUpSuggestions: newFollowUps, executedTransform: result.executedTransform, } ] setConversationHistory(updatedHistory) setStreamingResponse("") setIsLoading(false) } catch (error) { console.error('Mycelial Intelligence query error:', error) const errorMessage = error instanceof Error ? error.message : 'An error occurred' const errorHistory: ConversationMessage[] = [ ...newHistory, { role: 'assistant', content: `Error: ${errorMessage}` } ] setConversationHistory(errorHistory) setStreamingResponse("") setIsLoading(false) setFollowUpSuggestions([]) } }, [prompt, isLoading, conversationHistory]) // Toggle expanded state const toggleExpand = useCallback(() => { setIsExpanded(prev => !prev) }, []) // Handle sending input directly to a selected tool const handleDirectToolInput = useCallback((input: string) => { if (!editor || !toolInputMode) return const shape = editor.getShape(toolInputMode.shapeId as any) if (!shape) return const toolType = toolInputMode.toolType // Update the shape's content based on tool type switch (toolType) { case 'Prompt': case 'ChatBox': // For AI tools, set the prompt/message editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props as any, prompt: input }, }) break case 'ImageGen': // For image generation, set the prompt editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props as any, prompt: input }, }) break case 'VideoGen': // For video generation, set the motion prompt editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props as any, motionPrompt: input }, }) break case 'Markdown': case 'ObsNote': // For notes, append to or set content const currentContent = (shape.props as any).content || '' const newContent = currentContent ? `${currentContent}\n\n${input}` : input editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props as any, content: newContent }, }) break case 'Embed': // For embed, set the URL editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props as any, url: input }, }) break case 'Holon': // For Holon, set the holon ID editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props as any, holonId: input }, }) break default: console.log(`Direct input not implemented for ${toolType}`) } // Clear the prompt setPrompt('') // Show confirmation in conversation setConversationHistory(prev => [ ...prev, { role: 'user', content: `[Sent to ${TOOL_PROMPTS[toolType]?.inputLabel || toolType}]: ${input}` }, { role: 'assistant', content: `I've added that to your ${toolType}. You can see it updated on the canvas.` } ]) }, [editor, toolInputMode]) // Handle spawning a single tool const handleSpawnTool = useCallback((tool: ToolSchema) => { if (!editor) return // Get viewport center for spawn position const viewportBounds = editor.getViewportPageBounds() const centerX = viewportBounds.x + viewportBounds.w / 2 const centerY = viewportBounds.y + viewportBounds.h / 2 + 50 // Offset below MI bar const shapeId = spawnTool(editor, tool.id, { x: centerX, y: centerY }, { selectAfterSpawn: true, }) if (shapeId) { // Track that this tool was spawned setSpawnedToolIds(prev => new Set([...prev, tool.id])) // Generate follow-up suggestions for the spawned tool const newFollowUps = getFollowUpSuggestions({ type: 'tool_spawned', toolId: tool.id, toolName: tool.displayName, }) setFollowUpSuggestions(newFollowUps) console.log(`Spawned ${tool.displayName} on canvas`) } }, [editor]) // Handle spawning all suggested tools const handleSpawnAllTools = useCallback(() => { if (!editor || suggestedTools.length === 0) return const toolsToSpawn = suggestedTools.filter(t => !spawnedToolIds.has(t.id)) if (toolsToSpawn.length === 0) return const ids = spawnTools(editor, toolsToSpawn, { arrangement: toolsToSpawn.length <= 2 ? 'horizontal' : 'grid', selectAfterSpawn: true, zoomToFit: toolsToSpawn.length > 2, }) if (ids.length > 0) { setSpawnedToolIds(prev => new Set([...prev, ...toolsToSpawn.map(t => t.id)])) console.log(`Spawned ${ids.length} tools on canvas`) } }, [editor, suggestedTools, spawnedToolIds]) // Height: taller when showing suggestion chips (single tool or 2+ selected) const showSuggestions = selectedToolInfo || (selectionInfo && selectionInfo.count > 1) const collapsedHeight = showSuggestions ? 76 : 48 const maxExpandedHeight = 400 const barWidth = 520 // Consistent width // Calculate dynamic height when expanded based on content // Header: ~45px, Input area: ~56px, padding: ~24px = ~125px fixed // Each message is roughly 50-80px, we'll let CSS handle the actual sizing const hasContent = conversationHistory.length > 0 || streamingResponse // Minimum expanded height when there's no content (just empty state) const minExpandedHeight = 180 // Use auto height with max constraint when expanded const height = isExpanded ? 'auto' : collapsedHeight return (
setIsHovering(true)} onPointerLeave={() => setIsHovering(false)} >
{/* Collapsed: Single-line prompt bar with optional suggestions */} {!isExpanded && (
{/* Main input row */}
{/* Mushroom + Brain icon with selection count badge */}
πŸ„πŸ§  {selectionInfo && ( {selectionInfo.count} )}
{/* Input field - context-aware placeholder */} setPrompt(e.target.value)} onKeyDown={(e) => { e.stopPropagation() if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() // Direct tool input mode if single tool selected if (selectedToolInfo?.promptInfo.canDirectInput && prompt.trim()) { handleDirectToolInput(prompt.trim()) } else { handleSubmit() } } }} onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onFocus={(e) => e.stopPropagation()} placeholder={ selectedToolInfo ? selectedToolInfo.promptInfo.placeholder : selectionInfo && selectionInfo.count > 1 ? `${selectionInfo.count} selected β€” try a suggestion below...` : "Ask mi anything about this workspace..." } style={{ flex: 1, background: 'transparent', border: 'none', padding: '8px 4px', fontSize: '14px', color: colors.inputText, outline: 'none', }} /> {/* Indexing indicator */} {isIndexing && ( {Math.round(indexingProgress)}% )} {/* Voice button (compact) */} {isVoiceSupported && ( )} {/* Send button - shows tool-specific label when tool selected */} {/* Expand button if there's history */} {conversationHistory.length > 0 && ( )}
{/* Prompt suggestions row - context-aware */} {/* Show tool-specific help when single tool selected */} {selectedToolInfo && (
e.stopPropagation()} > handleSuggestionClick(selectedToolInfo.promptInfo.helpPrompt)} /> handleSuggestionClick(`Use content from my canvas to help fill this ${selectedToolInfo.toolType}`)} />
)} {/* Show transform suggestions when multiple shapes selected */} {!selectedToolInfo && selectionInfo && selectionInfo.count > 1 && (
e.stopPropagation()} > {SELECTION_SUGGESTIONS.slice(0, 5).map((suggestion) => ( handleSuggestionClick(suggestion.prompt)} /> ))}
)}
)} {/* Expanded: Header + Conversation + Input */} {isExpanded && ( <> {/* Header */}
πŸ„πŸ§  ask your mycelial intelligence anything about this workspace {isIndexing && ( Indexing... {Math.round(indexingProgress)}% )}
{/* Conversation area */}
e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} > {conversationHistory.length === 0 && !streamingResponse && (
I'm your Mycelial Intelligence β€” the awareness connecting all shapes and ideas in your workspace.
Ask me about what's on your canvas, how to use the tools, or what connections I perceive between your ideas.
)} {conversationHistory.map((msg, idx) => ( {/* Tool suggestions for assistant messages */} {msg.role === 'assistant' && msg.suggestedTools && msg.suggestedTools.length > 0 && (
Suggested Tools {msg.suggestedTools.length > 1 && ( )}
{msg.suggestedTools.map((tool) => ( ))}
)}
))} {/* Streaming response */} {streamingResponse && ( <>
{renderMessageContent(streamingResponse)} {isLoading && ( )}
)} {/* Loading indicator */} {isLoading && !streamingResponse && (
)} {/* Combined "Try next" section - tools + follow-up suggestions in one scrollable row */} {!isLoading && (followUpSuggestions.length > 0 || suggestedTools.length > 0) && (
✨ Try next
{suggestedTools.length > 1 && ( )}
{/* Suggested tools first */} {suggestedTools.map((tool) => ( ))} {/* Then follow-up prompts */} {followUpSuggestions.map((suggestion, i) => ( handleSuggestionClick(suggestion.prompt)} /> ))}
)}
{/* Input area (expanded) */}
setPrompt(e.target.value)} onKeyDown={(e) => { e.stopPropagation() if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSubmit() } }} onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onFocus={(e) => e.stopPropagation()} placeholder="Ask a follow-up..." style={{ flex: 1, background: colors.inputBg, border: `1px solid ${colors.inputBorder}`, borderRadius: '18px', padding: '8px 14px', fontSize: '13px', color: colors.inputText, outline: 'none', transition: 'all 0.2s ease', }} /> {/* Voice input button */} {isVoiceSupported && ( )} {/* Send button */}
)}
{/* CSS animations */}
) }