diff --git a/components/chat-footer-message.tsx b/components/chat-footer-message.tsx new file mode 100644 index 000000000..51e6c2a06 --- /dev/null +++ b/components/chat-footer-message.tsx @@ -0,0 +1,58 @@ +'use client' + +import { useMemo } from 'react' + +import { DISCLAIMER_TEXT, getTips, shuffle } from '@/lib/footer-tips' + +import { useTypewriterCycle } from '@/hooks/use-typewriter-cycle' + +function FooterContent() { + const shuffledTips = useMemo(() => shuffle(getTips()), []) + + // Build items array: disclaimer first, then tip descriptions + const items = useMemo( + () => [DISCLAIMER_TEXT, ...shuffledTips.map(t => t.description)], + [shuffledTips] + ) + + const { currentIndex, charCount, displayText, isIdle } = + useTypewriterCycle(items) + + if (isIdle && charCount === 0) return null + + // Index 0 = disclaimer, 1+ = tips + const tipDataIndex = currentIndex - 1 + const isTip = tipDataIndex >= 0 && charCount > 0 + const tip = isTip ? shuffledTips[tipDataIndex] : null + + return ( +
+ {tip ? ( + + Tips: + + {tip.keys.map((key, i) => ( + + {key} + + ))} + + {displayText} + + ) : ( + + {displayText} + + )} +
+ ) +} + +export function ChatFooterMessage({ isLoading }: { isLoading: boolean }) { + if (isLoading) return null + + return +} diff --git a/components/chat-messages.tsx b/components/chat-messages.tsx index a5aa03ce3..c2b52e164 100644 --- a/components/chat-messages.tsx +++ b/components/chat-messages.tsx @@ -11,6 +11,7 @@ import { extractCitationMapsFromMessages } from '@/lib/utils/citation' import { AnimatedLogo } from './ui/animated-logo' import { ChatError } from './chat-error' +import { ChatFooterMessage } from './chat-footer-message' import { RenderMessage } from './render-message' // Import section structure interface @@ -228,10 +229,14 @@ export function ChatMessages({ ) })} - {/* Show assistant logo after assistant messages */} + {/* Show assistant logo and footer message after assistant messages */} {showAssistantLogo && sectionIndex === sections.length - 1 && ( -
- +
+ +
)} {sectionIndex === sections.length - 1 && ( diff --git a/hooks/use-typewriter-cycle.ts b/hooks/use-typewriter-cycle.ts new file mode 100644 index 000000000..02abf31f9 --- /dev/null +++ b/hooks/use-typewriter-cycle.ts @@ -0,0 +1,138 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' + +type Stage = + | 'init' + | 'typing-in' + | 'visible' + | 'typing-out' + | 'switching' + | 'idle' + +export interface TypewriterCycleOptions { + /** Duration to show the first item (ms) */ + firstDuration?: number + /** Duration to show each subsequent item (ms) */ + itemDuration?: number + /** Idle pause between items (ms) */ + idleDuration?: number + /** Typing speed per character (ms) */ + charInterval?: number + /** Delay before first item appears (ms) */ + initialDelay?: number +} + +const DEFAULTS: Required = { + firstDuration: 5000, + itemDuration: 15000, + idleDuration: 15000, + charInterval: 25, + initialDelay: 300 +} + +export function useTypewriterCycle( + items: string[], + options?: TypewriterCycleOptions +) { + const { + firstDuration, + itemDuration, + idleDuration, + charInterval, + initialDelay + } = { ...DEFAULTS, ...options } + + const [stage, setStage] = useState('init') + const [currentIndex, setCurrentIndex] = useState(0) + const [charCount, setCharCount] = useState(0) + const timerRef = useRef | null>(null) + const intervalRef = useRef | null>(null) + + const currentText = items[currentIndex] ?? '' + + const clearTimers = () => { + if (timerRef.current) clearTimeout(timerRef.current) + if (intervalRef.current) clearInterval(intervalRef.current) + } + + useEffect(() => { + clearTimers() + + switch (stage) { + case 'init': + timerRef.current = setTimeout(() => { + setCharCount(0) + setStage('typing-in') + }, initialDelay) + break + + case 'typing-in': + intervalRef.current = setInterval(() => { + setCharCount(prev => { + const next = prev + 1 + if (next >= currentText.length) { + if (intervalRef.current) clearInterval(intervalRef.current) + setStage('visible') + } + return next + }) + }, charInterval) + break + + case 'visible': { + const duration = currentIndex === 0 ? firstDuration : itemDuration + timerRef.current = setTimeout(() => { + setStage('typing-out') + }, duration) + break + } + + case 'typing-out': + intervalRef.current = setInterval(() => { + setCharCount(prev => { + const next = prev - 1 + if (next <= 0) { + if (intervalRef.current) clearInterval(intervalRef.current) + setStage('switching') + } + return Math.max(0, next) + }) + }, charInterval) + break + + case 'switching': + timerRef.current = setTimeout(() => { + setCurrentIndex(p => (p + 1) % items.length) + setCharCount(0) + setStage('idle') + }, 50) + break + + case 'idle': + timerRef.current = setTimeout(() => { + setStage('typing-in') + }, idleDuration) + break + } + + return clearTimers + }, [ + stage, + currentIndex, + items, + currentText.length, + initialDelay, + charInterval, + firstDuration, + itemDuration, + idleDuration + ]) + + return { + currentIndex, + charCount, + displayText: currentText.slice(0, charCount), + isIdle: stage === 'idle' || stage === 'init' || stage === 'switching' + } +} diff --git a/lib/footer-tips.ts b/lib/footer-tips.ts new file mode 100644 index 000000000..01bbfb105 --- /dev/null +++ b/lib/footer-tips.ts @@ -0,0 +1,36 @@ +import { formatShortcutKeys, SHORTCUTS } from '@/lib/keyboard-shortcuts' + +export const DISCLAIMER_TEXT = + 'Morphic can make mistakes. Please double-check responses.' + +const SHORTCUT_ENTRIES = [ + SHORTCUTS.newChat, + SHORTCUTS.copyMessage, + SHORTCUTS.toggleSidebar, + SHORTCUTS.toggleTheme, + SHORTCUTS.toggleSearchMode, + SHORTCUTS.showShortcuts +] as const + +export type Tip = { keys: string[]; description: string } + +// Lazily initialized to avoid hydration mismatch from navigator check +let cachedTips: Tip[] | null = null +export function getTips(): Tip[] { + if (!cachedTips) { + cachedTips = SHORTCUT_ENTRIES.map(s => ({ + keys: formatShortcutKeys(s), + description: s.description + })) + } + return cachedTips +} + +export function shuffle(arr: T[]): T[] { + const a = [...arr] + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[a[i], a[j]] = [a[j], a[i]] + } + return a +}