Skip to content
Merged
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
58 changes: 58 additions & 0 deletions components/chat-footer-message.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-2 select-none">
{tip ? (
<span className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground/60">
<span>Tips:</span>
<span className="inline-flex items-center gap-0.5">
{tip.keys.map((key, i) => (
<kbd
key={i}
className="inline-flex items-center justify-center rounded border border-border/50 bg-muted/50 px-1 py-0.5 text-[10px] leading-none text-muted-foreground/70"
>
{key}
</kbd>
))}
</span>
<span>{displayText}</span>
</span>
) : (
<span className="font-mono text-xs text-muted-foreground/60">
{displayText}
</span>
)}
</div>
)
}

export function ChatFooterMessage({ isLoading }: { isLoading: boolean }) {
if (isLoading) return null

return <FooterContent />
}
11 changes: 8 additions & 3 deletions components/chat-messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -228,10 +229,14 @@ export function ChatMessages({
</div>
)
})}
{/* Show assistant logo after assistant messages */}
{/* Show assistant logo and footer message after assistant messages */}
{showAssistantLogo && sectionIndex === sections.length - 1 && (
<div className="flex justify-start py-1 md:py-4">
<AnimatedLogo className="size-10" animate={isLoading} />
<div className="flex items-center gap-3 py-1 md:py-4">
<AnimatedLogo
className="size-10 shrink-0"
animate={isLoading}
/>
<ChatFooterMessage isLoading={isLoading} />
</div>
)}
{sectionIndex === sections.length - 1 && (
Expand Down
138 changes: 138 additions & 0 deletions hooks/use-typewriter-cycle.ts
Original file line number Diff line number Diff line change
@@ -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<TypewriterCycleOptions> = {
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<Stage>('init')
const [currentIndex, setCurrentIndex] = useState(0)
const [charCount, setCharCount] = useState(0)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const intervalRef = useRef<ReturnType<typeof setInterval> | 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'
}
}
36 changes: 36 additions & 0 deletions lib/footer-tips.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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
}
Loading