Files
Keep/keep-notes/components/memory-echo-notification.tsx
Sepehr Ramezani 402e88b788 feat(ux): epic UX design improvements across agents, chat, notes, and i18n
Comprehensive UI/UX updates including agent card redesign, chat container
improvements, note editor enhancements, memory echo notifications, and
updated translations for all 15 locales.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-19 23:01:04 +02:00

338 lines
12 KiB
TypeScript

'use client'
import { useState, useEffect, useRef } from 'react'
import { useLanguage } from '@/lib/i18n/LanguageProvider'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Lightbulb, ThumbsUp, ThumbsDown, X, Sparkles, ArrowRight, GitMerge } from 'lucide-react'
import { toast } from 'sonner'
import { ComparisonModal } from './comparison-modal'
import { FusionModal } from './fusion-modal'
import { createNote, updateNote } from '@/app/actions/notes'
import { Note } from '@/lib/types'
interface MemoryEchoInsight {
id: string
note1Id: string
note2Id: string
note1: {
id: string
title: string | null
content: string
}
note2: {
id: string
title: string | null
content: string
}
similarityScore: number
insight: string
insightDate: Date
viewed: boolean
feedback: string | null
}
interface MemoryEchoNotificationProps {
onOpenNote?: (noteId: string) => void
}
export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationProps) {
const { t } = useLanguage()
const [insight, setInsight] = useState<MemoryEchoInsight | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isDismissed, setIsDismissed] = useState(false)
const [showComparison, setShowComparison] = useState(false)
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
const [demoMode, setDemoMode] = useState(false)
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Fetch insight on mount
useEffect(() => {
fetchInsight()
return () => {
if (pollingRef.current) clearInterval(pollingRef.current)
}
}, [])
const fetchInsight = async () => {
setIsLoading(true)
try {
const res = await fetch('/api/ai/echo')
const data = await res.json()
if (data.insight) {
setInsight(data.insight)
// If we got an insight, check if user is in demo mode
setDemoMode(true)
}
} catch (error) {
console.error('[MemoryEcho] Failed to fetch insight:', error)
} finally {
setIsLoading(false)
}
}
// Start polling in demo mode after first dismiss
useEffect(() => {
if (isDismissed && demoMode && !pollingRef.current) {
pollingRef.current = setInterval(async () => {
try {
const res = await fetch('/api/ai/echo')
const data = await res.json()
if (data.insight) {
setInsight(data.insight)
setIsDismissed(false)
}
} catch {
// silent
}
}, 15000) // Poll every 15s
}
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current)
pollingRef.current = null
}
}
}, [isDismissed, demoMode])
const handleView = async () => {
if (!insight) return
try {
await fetch('/api/ai/echo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'view', insightId: insight.id })
})
toast.success(t('toast.openingConnection'))
setShowComparison(true)
} catch (error) {
console.error('[MemoryEcho] Failed to view connection:', error)
toast.error(t('toast.openConnectionFailed'))
}
}
const handleFeedback = async (feedback: 'thumbs_up' | 'thumbs_down') => {
if (!insight) return
try {
await fetch('/api/ai/echo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'feedback', insightId: insight.id, feedback })
})
if (feedback === 'thumbs_up') {
toast.success(t('toast.thanksFeedback'))
} else {
toast.success(t('toast.thanksFeedbackImproving'))
}
setIsDismissed(true)
// Stop polling after explicit feedback
if (pollingRef.current) {
clearInterval(pollingRef.current)
pollingRef.current = null
}
} catch (error) {
console.error('[MemoryEcho] Failed to submit feedback:', error)
toast.error(t('toast.feedbackFailed'))
}
}
const handleMergeNotes = async (noteIds: string[]) => {
if (!insight) return
const fetched = await Promise.all(noteIds.map(async (id) => {
try {
const res = await fetch(`/api/notes/${id}`)
if (!res.ok) return null
const data = await res.json()
return data.success && data.data ? data.data : null
} catch { return null }
}))
setFusionNotes(fetched.filter((n: any) => n !== null) as Array<Partial<Note>>)
}
const handleDismiss = () => {
setIsDismissed(true)
}
if (isLoading || !insight) {
return null
}
const note1Title = insight.note1.title || t('notification.untitled')
const note2Title = insight.note2.title || t('notification.untitled')
const similarityPercentage = Math.round(insight.similarityScore * 100)
const comparisonNotes: Array<Partial<Note>> = [
{ id: insight.note1.id, title: insight.note1.title, content: insight.note1.content },
{ id: insight.note2.id, title: insight.note2.title, content: insight.note2.content }
]
return (
<>
{/* Comparison Modal */}
<ComparisonModal
isOpen={showComparison}
onClose={() => {
setShowComparison(false)
setIsDismissed(true)
}}
notes={comparisonNotes}
similarity={insight.similarityScore}
onOpenNote={(noteId) => {
setShowComparison(false)
setIsDismissed(true)
onOpenNote?.(noteId)
}}
onMergeNotes={handleMergeNotes}
/>
{/* Fusion Modal */}
{fusionNotes.length > 0 && (
<FusionModal
isOpen={fusionNotes.length > 0}
onClose={() => setFusionNotes([])}
notes={fusionNotes}
onConfirmFusion={async ({ title, content }, options) => {
await createNote({
title,
content,
labels: options.keepAllTags
? [...new Set(fusionNotes.flatMap(n => n.labels || []))]
: fusionNotes[0].labels || [],
color: fusionNotes[0].color,
type: 'text',
isMarkdown: true,
autoGenerated: true,
aiProvider: 'fusion',
notebookId: fusionNotes[0].notebookId ?? undefined
})
if (options.archiveOriginals) {
for (const n of fusionNotes) {
if (n.id) await updateNote(n.id, { isArchived: true })
}
}
toast.success(t('toast.notesFusionSuccess'))
setFusionNotes([])
setIsDismissed(true)
}}
/>
)}
{/* Notification Card — hidden when dismissed or modal open */}
{!isDismissed && (
<div className="fixed bottom-4 right-4 z-50 max-w-md w-full animate-in slide-in-from-bottom-4 fade-in duration-500">
<Card className="border-amber-200 dark:border-amber-900 shadow-lg bg-gradient-to-br from-amber-50 to-white dark:from-amber-950/20 dark:to-background">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-full">
<Lightbulb className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<CardTitle className="text-base flex items-center gap-2">
{t('memoryEcho.title')}
<Sparkles className="h-4 w-4 text-amber-500" />
</CardTitle>
<CardDescription className="text-xs mt-1">
{t('memoryEcho.description')}
</CardDescription>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 -mr-2 -mt-2"
onClick={handleDismiss}
>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* AI-generated insight */}
<div className="bg-white dark:bg-zinc-900 rounded-lg p-3 border border-amber-100 dark:border-amber-900/30">
<p className="text-sm text-gray-700 dark:text-gray-300">
{insight.insight}
</p>
</div>
{/* Connected notes */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Badge variant="outline" className="border-primary/20 text-primary dark:border-primary/30 dark:text-primary-foreground">
{note1Title}
</Badge>
<ArrowRight className="h-3 w-3 text-gray-400" />
<Badge variant="outline" className="border-purple-200 text-purple-700 dark:border-purple-900 dark:text-purple-300">
{note2Title}
</Badge>
<Badge variant="secondary" className="ml-auto text-xs">
{t('memoryEcho.match', { percentage: similarityPercentage })}
</Badge>
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 pt-2">
<Button
size="sm"
className="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
onClick={handleView}
>
{t('memoryEcho.viewConnection')}
</Button>
<Button
size="sm"
variant="outline"
className="flex-1 border-purple-200 text-purple-700 hover:bg-purple-50 dark:border-purple-800 dark:text-purple-400 dark:hover:bg-purple-950/20"
onClick={() => handleMergeNotes([insight.note1.id, insight.note2.id])}
>
<GitMerge className="h-4 w-4 mr-1" />
{t('memoryEcho.editorSection.merge')}
</Button>
<div className="flex items-center gap-1 border-l pl-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950/20"
onClick={() => handleFeedback('thumbs_up')}
title={t('memoryEcho.helpful')}
>
<ThumbsUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20"
onClick={() => handleFeedback('thumbs_down')}
title={t('memoryEcho.notHelpful')}
>
<ThumbsDown className="h-4 w-4" />
</Button>
</div>
</div>
{/* Dismiss link */}
<button
className="w-full text-center text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 py-1"
onClick={handleDismiss}
>
{t('memoryEcho.dismiss')}
</button>
</CardContent>
</Card>
</div>
)}
</>
)
}