canvas-website/src/ui/MycelialIntelligenceBar.tsx

2079 lines
73 KiB
TypeScript

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 = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
)
// Check icon for copy confirmation
const CheckIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
)
// 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 (
<CodeBlock key={i} code={code.trim()} language={language} />
)
}
// Regular text - handle inline code and basic formatting
return (
<span key={i}>
{part.split(/(`[^`]+`)/g).map((segment, j) => {
if (segment.startsWith('`') && segment.endsWith('`')) {
return (
<code
key={j}
className="mi-inline-code"
style={{
padding: '1px 4px',
borderRadius: '3px',
fontSize: '0.9em',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
}}
>
{segment.slice(1, -1)}
</code>
)
}
return segment
})}
</span>
)
})
}
// 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 (
<div
style={{
position: 'relative',
margin: '8px 0',
borderRadius: '6px',
overflow: 'hidden',
background: 'rgba(0, 0, 0, 0.04)',
border: '1px solid rgba(0, 0, 0, 0.08)',
}}
>
{language && (
<div
style={{
padding: '4px 10px',
fontSize: '10px',
fontWeight: 500,
color: '#666',
background: 'rgba(0, 0, 0, 0.03)',
borderBottom: '1px solid rgba(0, 0, 0, 0.06)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span>{language}</span>
<button
onClick={handleCopy}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px 6px',
borderRadius: '4px',
color: copied ? '#10b981' : '#666',
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '10px',
transition: 'all 0.2s',
}}
title="Copy code"
>
{copied ? <CheckIcon /> : <CopyIcon />}
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
)}
<pre
style={{
margin: 0,
padding: '10px 12px',
overflow: 'auto',
fontSize: '12px',
lineHeight: '1.5',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
maxHeight: '200px',
}}
>
<code>{code}</code>
</pre>
{!language && (
<button
onClick={handleCopy}
style={{
position: 'absolute',
top: '6px',
right: '6px',
background: 'rgba(255,255,255,0.9)',
border: '1px solid rgba(0,0,0,0.1)',
cursor: 'pointer',
padding: '4px 8px',
borderRadius: '4px',
color: copied ? '#10b981' : '#666',
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '10px',
transition: 'all 0.2s',
}}
title="Copy code"
>
{copied ? <CheckIcon /> : <CopyIcon />}
</button>
)}
</div>
)
}
// 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 (
<div
style={{
position: 'relative',
alignSelf: role === 'user' ? 'flex-end' : 'flex-start',
maxWidth: '85%',
}}
onMouseEnter={() => setShowCopy(true)}
onMouseLeave={() => setShowCopy(false)}
>
<div
style={{
padding: '8px 12px',
borderRadius: role === 'user' ? '14px 14px 4px 14px' : '14px 14px 14px 4px',
backgroundColor: role === 'user' ? colors.userBubble : colors.assistantBubble,
border: `1px solid ${role === 'user' ? 'rgba(16, 185, 129, 0.2)' : colors.border}`,
color: colors.text,
fontSize: '13px',
lineHeight: '1.5',
wordBreak: 'break-word',
userSelect: 'text',
cursor: 'text',
}}
>
{renderedContent}
</div>
{/* Copy button on hover */}
{showCopy && role === 'assistant' && content.length > 20 && (
<button
onClick={handleCopy}
style={{
position: 'absolute',
top: '4px',
right: '-28px',
background: 'rgba(255,255,255,0.95)',
border: '1px solid rgba(0,0,0,0.1)',
borderRadius: '4px',
padding: '4px',
cursor: 'pointer',
color: copied ? '#10b981' : colors.textMuted,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.15s',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}}
title={copied ? 'Copied!' : 'Copy message'}
>
{copied ? <CheckIcon /> : <CopyIcon />}
</button>
)}
</div>
)
}
// Microphone icon component
const MicrophoneIcon = ({ isListening }: { isListening: boolean }) => (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill={isListening ? "#10b981" : "currentColor"}
style={{
filter: isListening ? 'drop-shadow(0 0 8px #10b981)' : 'none',
transition: 'all 0.3s ease'
}}
>
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5z"/>
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
)
// Send icon component
const SendIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
</svg>
)
// Expand/collapse icon
const ExpandIcon = ({ isExpanded }: { isExpanded: boolean }) => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
style={{
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.3s ease'
}}
>
<path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/>
</svg>
)
// 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 (
<button
onClick={(e) => {
e.stopPropagation()
if (!isSpawned) onSpawn(tool)
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
disabled={isSpawned}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
borderRadius: '12px',
border: `1px solid ${isSpawned ? 'rgba(16, 185, 129, 0.3)' : isHovered ? tool.primaryColor : 'rgba(229, 231, 235, 0.8)'}`,
background: isSpawned
? 'rgba(16, 185, 129, 0.1)'
: isHovered
? `${tool.primaryColor}15`
: 'rgba(255, 255, 255, 0.8)',
cursor: isSpawned ? 'default' : 'pointer',
transition: 'all 0.2s ease',
transform: isHovered && !isSpawned ? 'translateY(-1px)' : 'none',
boxShadow: isHovered && !isSpawned
? `0 4px 12px ${tool.primaryColor}25`
: '0 1px 3px rgba(0,0,0,0.08)',
opacity: isSpawned ? 0.7 : 1,
}}
title={isSpawned ? `${tool.displayName} already spawned` : `Spawn ${tool.displayName} on canvas`}
>
<span style={{ fontSize: '16px' }}>{tool.icon}</span>
<span style={{
fontSize: '12px',
fontWeight: 500,
color: isSpawned ? '#10b981' : isHovered ? tool.primaryColor : '#374151',
whiteSpace: 'nowrap',
}}>
{tool.displayName}
</span>
{isSpawned && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="#10b981">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
)}
</button>
)
}
// Prompt suggestion chip for selection transforms
interface PromptSuggestionProps {
label: string
onClick: () => void
}
const PromptSuggestion = ({ label, onClick }: PromptSuggestionProps) => {
const [isHovered, setIsHovered] = useState(false)
return (
<button
onClick={(e) => {
e.stopPropagation()
onClick()
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
display: 'inline-flex',
alignItems: 'center',
padding: '4px 10px',
borderRadius: '14px',
border: '1px solid rgba(139, 92, 246, 0.25)',
background: isHovered
? 'rgba(139, 92, 246, 0.12)'
: 'rgba(139, 92, 246, 0.06)',
cursor: 'pointer',
transition: 'all 0.15s ease',
fontSize: '11px',
fontWeight: 500,
color: isHovered ? '#7c3aed' : '#8b5cf6',
whiteSpace: 'nowrap',
}}
>
{label}
</button>
)
}
// 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 (
<button
onClick={(e) => {
e.stopPropagation()
onClick()
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '5px 10px',
borderRadius: '14px',
border: `1px solid ${colors.border}`,
background: isHovered ? colors.hover : colors.bg,
cursor: 'pointer',
transition: 'all 0.15s ease',
fontSize: '11px',
fontWeight: 500,
color: colors.text,
whiteSpace: 'nowrap',
transform: isHovered ? 'translateY(-1px)' : 'none',
boxShadow: isHovered ? `0 2px 8px ${colors.border}` : 'none',
}}
title={suggestion.prompt}
>
{suggestion.icon && <span style={{ fontSize: '12px' }}>{suggestion.icon}</span>}
{suggestion.label}
</button>
)
}
// 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<string, number> }
// Follow-up suggestions after transform commands
const TRANSFORM_FOLLOWUPS: Record<string, FollowUpSuggestion[]> = {
// 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<string, FollowUpSuggestion[]> = {
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<string, ToolPromptInfo> = {
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<string, number> } | 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<HTMLInputElement>(null)
const chatContainerRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [prompt, setPrompt] = useState("")
const [isExpanded, setIsExpanded] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [isListening, setIsListening] = useState(false)
const [conversationHistory, setConversationHistory] = useState<ConversationMessage[]>([])
const [streamingResponse, setStreamingResponse] = useState("")
const [indexingProgress, setIndexingProgress] = useState(0)
const [isIndexing, setIsIndexing] = useState(false)
const [isHovering, setIsHovering] = useState(false)
const [suggestedTools, setSuggestedTools] = useState<ToolSchema[]>([])
const [spawnedToolIds, setSpawnedToolIds] = useState<Set<string>>(new Set())
const [selectionInfo, setSelectionInfo] = useState<{
count: number
types: Record<string, number>
} | null>(null)
const [followUpSuggestions, setFollowUpSuggestions] = useState<FollowUpSuggestion[]>([])
const [lastTransform, setLastTransform] = useState<TransformCommand | null>(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<string, number> = {}
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 (
<div
ref={containerRef}
className="mycelial-intelligence-bar"
style={{
position: 'fixed',
top: '10px',
left: '50%',
transform: 'translateX(-50%)',
width: barWidth,
height: isExpanded ? 'auto' : collapsedHeight,
minHeight: isExpanded ? minExpandedHeight : collapsedHeight,
maxHeight: isExpanded ? maxExpandedHeight : collapsedHeight,
zIndex: isModalOpen ? 1 : 99999, // Lower z-index when modals are open
pointerEvents: isModalOpen ? 'none' : 'auto', // Disable interactions when modal is open
opacity: isModalOpen ? 0.3 : 1, // Fade when modal is open
transition: 'opacity 0.2s ease, z-index 0s',
}}
onPointerEnter={() => setIsHovering(true)}
onPointerLeave={() => setIsHovering(false)}
>
<div
style={{
width: '100%',
height: '100%',
minHeight: isExpanded ? minExpandedHeight : collapsedHeight,
maxHeight: isExpanded ? maxExpandedHeight : collapsedHeight,
background: isHovering ? colors.backgroundHover : colors.background,
borderRadius: isExpanded ? '20px' : '24px',
border: `1px solid ${isHovering ? colors.borderHover : colors.border}`,
boxShadow: isHovering ? colors.shadowHover : colors.shadow,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
fontFamily: "'Inter', 'SF Pro Display', -apple-system, sans-serif",
transition: 'all 0.3s ease',
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
}}
>
{/* Collapsed: Single-line prompt bar with optional suggestions */}
{!isExpanded && (
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '4px',
padding: '6px 10px 6px 14px',
height: '100%',
justifyContent: 'center',
}}>
{/* Main input row */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
{/* Mushroom + Brain icon with selection count badge */}
<div style={{
position: 'relative',
flexShrink: 0,
}}>
<span style={{
fontSize: '16px',
opacity: 0.9,
}}>
🍄🧠
</span>
{selectionInfo && (
<span style={{
position: 'absolute',
top: '-4px',
right: '-8px',
background: '#8b5cf6',
color: 'white',
fontSize: '9px',
fontWeight: 600,
padding: '1px 4px',
borderRadius: '8px',
minWidth: '14px',
textAlign: 'center',
}}>
{selectionInfo.count}
</span>
)}
</div>
{/* Input field - context-aware placeholder */}
<input
ref={inputRef}
type="text"
value={prompt}
onChange={(e) => 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 && (
<span style={{
color: ACCENT_COLOR,
fontSize: '11px',
whiteSpace: 'nowrap',
opacity: 0.8,
}}>
{Math.round(indexingProgress)}%
</span>
)}
{/* Voice button (compact) */}
{isVoiceSupported && (
<button
onClick={(e) => {
e.stopPropagation()
toggleVoice()
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
width: '34px',
height: '34px',
borderRadius: '50%',
border: 'none',
background: isRecording
? `rgba(16, 185, 129, 0.15)`
: 'transparent',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: isRecording ? ACCENT_COLOR : colors.textMuted,
transition: 'all 0.2s ease',
flexShrink: 0,
}}
title={isRecording ? "Stop recording" : "Voice input"}
>
<MicrophoneIcon isListening={isRecording} />
</button>
)}
{/* Send button - shows tool-specific label when tool selected */}
<button
onClick={(e) => {
e.stopPropagation()
if (selectedToolInfo?.promptInfo.canDirectInput && prompt.trim()) {
handleDirectToolInput(prompt.trim())
} else {
handleSubmit()
}
}}
onPointerDown={(e) => e.stopPropagation()}
disabled={!prompt.trim() || isLoading}
style={{
height: '34px',
padding: selectedToolInfo ? '0 12px' : '0 14px',
borderRadius: '17px',
border: 'none',
background: prompt.trim() && !isLoading
? selectedToolInfo ? '#6366f1' : ACCENT_COLOR
: colors.inputBg,
cursor: prompt.trim() && !isLoading ? 'pointer' : 'default',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
color: prompt.trim() && !isLoading ? 'white' : colors.textMuted,
transition: 'all 0.2s ease',
flexShrink: 0,
opacity: prompt.trim() && !isLoading ? 1 : 0.5,
fontSize: '11px',
fontWeight: 500,
}}
title={selectedToolInfo?.promptInfo.inputLabel || "Send"}
>
{selectedToolInfo && prompt.trim() ? (
<>
<span style={{ fontSize: '12px' }}></span>
{selectedToolInfo.promptInfo.inputLabel}
</>
) : (
<SendIcon />
)}
</button>
{/* Expand button if there's history */}
{conversationHistory.length > 0 && (
<button
onClick={(e) => {
e.stopPropagation()
toggleExpand()
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
width: '34px',
height: '34px',
borderRadius: '50%',
border: 'none',
background: 'transparent',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: ACCENT_COLOR,
transition: 'all 0.2s',
flexShrink: 0,
}}
title="View conversation"
>
<ExpandIcon isExpanded={false} />
</button>
)}
</div>
{/* Prompt suggestions row - context-aware */}
{/* Show tool-specific help when single tool selected */}
{selectedToolInfo && (
<div
style={{
display: 'flex',
gap: '6px',
paddingTop: '2px',
paddingLeft: '28px',
overflowX: 'auto',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
onPointerDown={(e) => e.stopPropagation()}
>
<PromptSuggestion
label={`💡 ${selectedToolInfo.toolType} ideas`}
onClick={() => handleSuggestionClick(selectedToolInfo.promptInfo.helpPrompt)}
/>
<PromptSuggestion
label="use canvas context"
onClick={() => handleSuggestionClick(`Use content from my canvas to help fill this ${selectedToolInfo.toolType}`)}
/>
</div>
)}
{/* Show transform suggestions when multiple shapes selected */}
{!selectedToolInfo && selectionInfo && selectionInfo.count > 1 && (
<div
style={{
display: 'flex',
gap: '6px',
paddingTop: '2px',
paddingLeft: '28px', // Align with input field
overflowX: 'auto',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
onPointerDown={(e) => e.stopPropagation()}
>
{SELECTION_SUGGESTIONS.slice(0, 5).map((suggestion) => (
<PromptSuggestion
key={suggestion.label}
label={suggestion.label}
onClick={() => handleSuggestionClick(suggestion.prompt)}
/>
))}
</div>
)}
</div>
)}
{/* Expanded: Header + Conversation + Input */}
{isExpanded && (
<>
{/* Header */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 14px',
borderBottom: `1px solid ${colors.border}`,
flexShrink: 0,
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
}}>
<span style={{ fontSize: '16px' }}>🍄🧠</span>
<span style={{
color: colors.text,
fontSize: '13px',
fontWeight: 500,
letterSpacing: '-0.01em',
}}>
<span style={{ fontStyle: 'italic', opacity: 0.85 }}>ask your mycelial intelligence anything about this workspace</span>
</span>
{isIndexing && (
<span style={{
color: colors.textMuted,
fontSize: '11px',
marginLeft: '4px',
}}>
Indexing... {Math.round(indexingProgress)}%
</span>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation()
toggleExpand()
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
color: colors.textMuted,
padding: '4px',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
transition: 'all 0.2s',
}}
title="Collapse"
>
<ExpandIcon isExpanded={true} />
</button>
</div>
{/* Conversation area */}
<div
ref={chatContainerRef}
style={{
flex: '1 1 auto',
minHeight: 0, // Allow flex shrinking below content size
overflowY: 'auto',
padding: '12px',
display: 'flex',
flexDirection: 'column',
gap: '10px',
}}
onWheel={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{conversationHistory.length === 0 && !streamingResponse && (
<div style={{
color: colors.textMuted,
fontSize: '13px',
textAlign: 'center',
padding: '20px 16px',
lineHeight: '1.6',
}}>
<div style={{ marginBottom: '8px' }}>
I'm your Mycelial Intelligence — the awareness connecting all shapes and ideas in your workspace.
</div>
<div style={{ opacity: 0.8, fontSize: '12px' }}>
Ask me about what's on your canvas, how to use the tools, or what connections I perceive between your ideas.
</div>
</div>
)}
{conversationHistory.map((msg, idx) => (
<React.Fragment key={idx}>
<MessageBubble
content={msg.content}
role={msg.role}
colors={colors}
/>
{/* Tool suggestions for assistant messages */}
{msg.role === 'assistant' && msg.suggestedTools && msg.suggestedTools.length > 0 && (
<div
style={{
alignSelf: 'flex-start',
maxWidth: '100%',
padding: '10px',
marginTop: '6px',
background: 'linear-gradient(135deg, rgba(16, 185, 129, 0.05) 0%, rgba(99, 102, 241, 0.05) 100%)',
borderRadius: '12px',
border: '1px solid rgba(16, 185, 129, 0.15)',
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '8px',
}}>
<span style={{
fontSize: '11px',
fontWeight: 600,
color: ACCENT_COLOR,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>
Suggested Tools
</span>
{msg.suggestedTools.length > 1 && (
<button
onClick={(e) => {
e.stopPropagation()
handleSpawnAllTools()
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
fontSize: '11px',
padding: '4px 10px',
borderRadius: '8px',
border: `1px solid ${ACCENT_COLOR}`,
background: 'transparent',
color: ACCENT_COLOR,
cursor: 'pointer',
fontWeight: 500,
transition: 'all 0.2s ease',
}}
title="Spawn all suggested tools on canvas"
>
Spawn All
</button>
)}
</div>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
}}>
{msg.suggestedTools.map((tool) => (
<ToolCard
key={tool.id}
tool={tool}
onSpawn={handleSpawnTool}
isSpawned={spawnedToolIds.has(tool.id)}
/>
))}
</div>
</div>
)}
</React.Fragment>
))}
{/* Streaming response */}
{streamingResponse && (
<>
<div style={{
alignSelf: 'flex-start',
maxWidth: '85%',
padding: '8px 12px',
borderRadius: '14px 14px 14px 4px',
backgroundColor: colors.assistantBubble,
border: `1px solid ${colors.border}`,
color: colors.text,
fontSize: '13px',
lineHeight: '1.5',
wordBreak: 'break-word',
userSelect: 'text',
cursor: 'text',
}}>
{renderMessageContent(streamingResponse)}
{isLoading && (
<span style={{
display: 'inline-block',
width: '2px',
height: '14px',
backgroundColor: ACCENT_COLOR,
marginLeft: '2px',
animation: 'blink 1s infinite',
}} />
)}
</div>
</>
)}
{/* Loading indicator */}
{isLoading && !streamingResponse && (
<div style={{
alignSelf: 'flex-start',
display: 'flex',
gap: '5px',
padding: '8px 12px',
}}>
<span className="loading-dot" style={{ backgroundColor: ACCENT_COLOR }} />
<span className="loading-dot" style={{ backgroundColor: ACCENT_COLOR, animationDelay: '0.2s' }} />
<span className="loading-dot" style={{ backgroundColor: ACCENT_COLOR, animationDelay: '0.4s' }} />
</div>
)}
{/* Combined "Try next" section - tools + follow-up suggestions in one scrollable row */}
{!isLoading && (followUpSuggestions.length > 0 || suggestedTools.length > 0) && (
<div
style={{
alignSelf: 'flex-start',
maxWidth: '100%',
padding: '10px',
marginTop: '4px',
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.04) 0%, rgba(16, 185, 129, 0.04) 100%)',
borderRadius: '12px',
border: '1px solid rgba(99, 102, 241, 0.1)',
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '8px',
}}>
<div style={{
fontSize: '10px',
fontWeight: 600,
color: '#6366f1',
textTransform: 'uppercase',
letterSpacing: '0.05em',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}>
<span></span>
Try next
</div>
{suggestedTools.length > 1 && (
<button
onClick={(e) => {
e.stopPropagation()
handleSpawnAllTools()
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
fontSize: '10px',
padding: '3px 8px',
borderRadius: '6px',
border: `1px solid ${ACCENT_COLOR}`,
background: 'transparent',
color: ACCENT_COLOR,
cursor: 'pointer',
fontWeight: 500,
transition: 'all 0.2s ease',
}}
title="Spawn all suggested tools on canvas"
>
Spawn All
</button>
)}
</div>
<div style={{
display: 'flex',
gap: '6px',
overflowX: 'auto',
overflowY: 'hidden',
paddingBottom: '4px',
scrollbarWidth: 'thin',
scrollbarColor: 'rgba(99, 102, 241, 0.3) transparent',
}}>
{/* Suggested tools first */}
{suggestedTools.map((tool) => (
<ToolCard
key={tool.id}
tool={tool}
onSpawn={handleSpawnTool}
isSpawned={spawnedToolIds.has(tool.id)}
/>
))}
{/* Then follow-up prompts */}
{followUpSuggestions.map((suggestion, i) => (
<FollowUpChip
key={`current-${suggestion.label}-${i}`}
suggestion={suggestion}
onClick={() => handleSuggestionClick(suggestion.prompt)}
/>
))}
</div>
</div>
)}
</div>
{/* Input area (expanded) */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 12px',
borderTop: `1px solid ${colors.border}`,
flexShrink: 0,
}}>
<input
ref={inputRef}
type="text"
value={prompt}
onChange={(e) => 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 && (
<button
onClick={(e) => {
e.stopPropagation()
toggleVoice()
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
width: '36px',
height: '36px',
borderRadius: '50%',
border: `1px solid ${isRecording ? ACCENT_COLOR : colors.inputBorder}`,
background: isRecording
? `rgba(16, 185, 129, 0.1)`
: colors.inputBg,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: isRecording ? ACCENT_COLOR : colors.textMuted,
transition: 'all 0.2s ease',
boxShadow: isRecording ? `0 0 12px rgba(16, 185, 129, 0.3)` : 'none',
flexShrink: 0,
}}
title={isRecording ? "Stop recording" : "Start voice input"}
>
<MicrophoneIcon isListening={isRecording} />
</button>
)}
{/* Send button */}
<button
onClick={(e) => {
e.stopPropagation()
handleSubmit()
}}
onPointerDown={(e) => e.stopPropagation()}
disabled={!prompt.trim() || isLoading}
style={{
width: '36px',
height: '36px',
borderRadius: '50%',
border: 'none',
background: prompt.trim() && !isLoading
? ACCENT_COLOR
: colors.inputBg,
cursor: prompt.trim() && !isLoading ? 'pointer' : 'not-allowed',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: prompt.trim() && !isLoading ? 'white' : colors.textMuted,
transition: 'all 0.2s ease',
boxShadow: prompt.trim() && !isLoading
? '0 2px 8px rgba(16, 185, 129, 0.3)'
: 'none',
flexShrink: 0,
}}
title="Send message"
>
<SendIcon />
</button>
</div>
</>
)}
</div>
{/* CSS animations */}
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.loading-dot {
width: 6px;
height: 6px;
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out;
}
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
`}</style>
</div>
)
}