Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions libs/remix-ui/remix-ai-assistant/src/components/prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ export interface PromptAreaProps {
aiAssistantGroupList: groupListType[]
textareaRef?: React.RefObject<HTMLTextAreaElement>
maximizePanel: () => Promise<void>
aiMode: 'ask' | 'edit'
setAiMode: React.Dispatch<React.SetStateAction<'ask' | 'edit'>>
aiMode: 'ask' | 'edit' | 'learn'
setAiMode: React.Dispatch<React.SetStateAction<'ask' | 'edit' | 'learn'>>
isMaximized: boolean
setIsMaximized: React.Dispatch<React.SetStateAction<boolean>>
}
Expand Down Expand Up @@ -126,7 +126,7 @@ export const PromptArea: React.FC<PromptAreaProps> = ({
</button>

<div className="d-flex justify-content-center align-items-center gap-2">
{/* Ask/Edit Mode Toggle */}
{/* Ask/Edit/Learn Mode Toggle */}
<div className="btn-group btn-group-sm" role="group">
<button
type="button"
Expand All @@ -150,6 +150,18 @@ export const PromptArea: React.FC<PromptAreaProps> = ({
>
Edit
</button>
<button
type="button"
className={`btn btn-sm ${aiMode === 'learn' ? 'btn-primary' : 'btn-outline-secondary'} px-2`}
onClick={() => {
setAiMode('learn')
trackMatomoEvent({ category: 'ai', action: 'ModeSwitch', name: 'learn', isClick: true })
}}
title="Learn mode - Interactive tutorials"
>
<i className="fas fa-graduation-cap me-1"></i>
Learn
</button>
</div>
<span
className="badge align-self-center text-bg-info fw-light rounded"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import GroupListMenu from './contextOptMenu'
import { useOnClickOutside } from './onClickOutsideHook'
import { useAudioTranscription } from '../hooks/useAudioTranscription'
import { QueryParams } from '@remix-project/remix-lib'
import { TutorialMode } from './tutorial/TutorialMode'

export interface RemixUiRemixAiAssistantProps {
plugin: Plugin
Expand Down Expand Up @@ -63,7 +64,7 @@ export const RemixUiRemixAiAssistant = React.forwardRef<
const [availableModels, setAvailableModels] = useState<string[]>([])
const [selectedModel, setSelectedModel] = useState<string | null>(null)
const [isOllamaFailureFallback, setIsOllamaFailureFallback] = useState(false)
const [aiMode, setAiMode] = useState<'ask' | 'edit'>('ask')
const [aiMode, setAiMode] = useState<'ask' | 'edit' | 'learn'>('ask')
const [themeTracker, setThemeTracker] = useState(null)
const [isMaximized, setIsMaximized] = useState(false)
const historyRef = useRef<HTMLDivElement | null>(null)
Expand Down Expand Up @@ -806,25 +807,39 @@ export const RemixUiRemixAiAssistant = React.forwardRef<
className="d-flex flex-column h-100 w-100 overflow-x-hidden"
ref={aiChatRef}
>
<section id="remix-ai-chat-history" className="h-83 d-flex flex-column p-2 overflow-x-hidden" style={{ flex: 7, overflowY: 'scroll' }} ref={chatHistoryRef}>
<div data-id="remix-ai-assistant-ready"></div>
{/* hidden hook for E2E tests: data-streaming="true|false" */}
<div
data-id="remix-ai-streaming"
className='d-none'
data-streaming={isStreaming ? 'true' : 'false'}
></div>
<ChatHistoryComponent
messages={messages}
isStreaming={isStreaming}
sendPrompt={sendPrompt}
recordFeedback={recordFeedback}
historyRef={historyRef}
theme={themeTracker?.name}
{aiMode === 'learn' ? (
// Tutorial Mode
<TutorialMode
plugin={props.plugin}
onAskAI={(question, context) => {
// Switch to ask mode and send the question with tutorial context
setAiMode('ask')
const contextualQuestion = `[Tutorial Context: ${context.roadmapTitle} - Step ${context.stepIndex + 1}: ${context.stepTitle}]\n\n${question}`
sendPrompt(contextualQuestion)
}}
onExit={() => setAiMode('ask')}
/>
</section>
<section id="remix-ai-prompt-area" className="mt-1" style={{ flex: 1 }}
>
) : (
<>
<section id="remix-ai-chat-history" className="h-83 d-flex flex-column p-2 overflow-x-hidden" style={{ flex: 7, overflowY: 'scroll' }} ref={chatHistoryRef}>
<div data-id="remix-ai-assistant-ready"></div>
{/* hidden hook for E2E tests: data-streaming="true|false" */}
<div
data-id="remix-ai-streaming"
className='d-none'
data-streaming={isStreaming ? 'true' : 'false'}
></div>
<ChatHistoryComponent
messages={messages}
isStreaming={isStreaming}
sendPrompt={sendPrompt}
recordFeedback={recordFeedback}
historyRef={historyRef}
theme={themeTracker?.name}
/>
</section>
<section id="remix-ai-prompt-area" className="mt-1" style={{ flex: 1 }}
>
{showAssistantOptions && (
<div
className="pt-2 mb-2 z-3 bg-light border border-text w-75"
Expand Down Expand Up @@ -919,6 +934,8 @@ export const RemixUiRemixAiAssistant = React.forwardRef<
setIsMaximized={setIsMaximized}
/>
</section>
</>
)}
</div>
)
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import React, { useEffect, useState, useRef } from 'react'
import { PointingHandPosition } from '../../lib/tutorials/types'
import './tutorial-styles.css'

export interface PointingHandProps {
targetElement?: string | HTMLElement | null
position?: PointingHandPosition
visible: boolean
animationSpeed?: 'slow' | 'normal' | 'fast'
onAnimationComplete?: () => void
}

/**
* PointingHand Component
* Displays an animated hand icon that points to tutorial target elements
*/
export const PointingHand: React.FC<PointingHandProps> = ({
targetElement,
position,
visible,
animationSpeed = 'normal',
onAnimationComplete
}) => {
const [handPosition, setHandPosition] = useState<PointingHandPosition>({
top: 0,
left: 0,
rotation: 0,
visible: false
})
const handRef = useRef<HTMLDivElement>(null)
const animationTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const highlightedElementRef = useRef<HTMLElement | null>(null)

// Calculate position based on target element
useEffect(() => {
// Remove previous highlight
if (highlightedElementRef.current) {
highlightedElementRef.current.classList.remove('tutorial-spotlight')
highlightedElementRef.current = null
}

if (!visible) {
setHandPosition(prev => ({ ...prev, visible: false }))
return
}

// If explicit position is provided, use it
if (position) {
setHandPosition({ ...position, visible: true })
return
}

// Otherwise, calculate position from target element
if (!targetElement) {
setHandPosition(prev => ({ ...prev, visible: false }))
return
}

const element = typeof targetElement === 'string'
? document.querySelector(targetElement) as HTMLElement
: targetElement

if (!element) {
console.warn(`[PointingHand] Target element not found: ${targetElement}`)
setHandPosition(prev => ({ ...prev, visible: false }))
return
}

// Calculate position relative to the element
const calculatePosition = () => {
const rect = element.getBoundingClientRect()

// Center the hand on the target element
const handSize = 48 // emoji size in pixels
const top = rect.top + (rect.height / 2) - (handSize / 2) - 30
const left = rect.left + (rect.width / 2) - (handSize / 2) + 20
const rotation = 0

setHandPosition({
top,
left,
rotation,
visible: true
})

// Add highlight to target element
element.classList.add('tutorial-spotlight')
highlightedElementRef.current = element
}

calculatePosition()

// Recalculate on scroll or resize
const handleUpdate = () => calculatePosition()
window.addEventListener('scroll', handleUpdate, true)
window.addEventListener('resize', handleUpdate)

return () => {
window.removeEventListener('scroll', handleUpdate, true)
window.removeEventListener('resize', handleUpdate)

// Remove highlight on cleanup
if (highlightedElementRef.current) {
highlightedElementRef.current.classList.remove('tutorial-spotlight')
highlightedElementRef.current = null
}
}
}, [targetElement, position, visible])

// Handle animation complete callback
useEffect(() => {
if (!handPosition.visible || !onAnimationComplete) return

const duration = animationSpeed === 'slow' ? 3000 : animationSpeed === 'fast' ? 1000 : 2000

animationTimeoutRef.current = setTimeout(() => {
onAnimationComplete()
}, duration)

return () => {
if (animationTimeoutRef.current) {
clearTimeout(animationTimeoutRef.current)
}
}
}, [handPosition.visible, animationSpeed, onAnimationComplete])

if (!handPosition.visible) return null

const animationClass = `tutorial-hand-animation-${animationSpeed}`

return (
<div
ref={handRef}
className={`tutorial-pointing-hand ${animationClass}`}
style={{
top: `${handPosition.top}px`,
left: `${handPosition.left}px`,
transform: `rotate(${handPosition.rotation}deg)`,
position: 'fixed',
zIndex: 10000,
pointerEvents: 'none'
}}
data-id="tutorial-pointing-hand"
>
{/* Pointing hand emoji with enhanced visibility */}
<div style={{
fontSize: '48px',
filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.5))',
transform: 'scaleX(-1)', // Mirror the hand to point left-to-right
userSelect: 'none'
}}>
👉
</div>
<div className="tutorial-hand-pulse"></div>
</div>
)
}

/**
* Hook to get element position
*/
export function useElementPosition(elementSelector: string | null): PointingHandPosition | null {
const [position, setPosition] = useState<PointingHandPosition | null>(null)

useEffect(() => {
if (!elementSelector) {
setPosition(null)
return
}

const element = document.querySelector(elementSelector) as HTMLElement
if (!element) {
setPosition(null)
return
}

const updatePosition = () => {
const rect = element.getBoundingClientRect()
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft

setPosition({
top: rect.top + scrollTop - 40,
left: rect.left + scrollLeft - 60,
rotation: -45,
visible: true
})
}

updatePosition()

window.addEventListener('scroll', updatePosition, true)
window.addEventListener('resize', updatePosition)

return () => {
window.removeEventListener('scroll', updatePosition, true)
window.removeEventListener('resize', updatePosition)
}
}, [elementSelector])

return position
}
Loading