Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m7s
Replaced ~100+ hardcoded French and English text strings across 30+ components with proper i18n t() calls. Added 57 new translation keys to all 15 locale files (ar, de, en, es, fa, fr, hi, it, ja, ko, nl, pl, pt, ru, zh). Key changes: - contextual-ai-chat.tsx: 30 French strings → t() (actions, toasts, labels, placeholders) - ai-chat.tsx: 15 French/English strings → t() (header, tabs, welcome, insights, history) - note-inline-editor.tsx: 20 French fallbacks removed (toolbar, save status, checklist) - lab-skeleton.tsx: French loading text → t() - admin-header.tsx, header.tsx, editor-connections-section.tsx: French fallbacks removed - New AI chat component, agent cards, sidebar, settings panel i18n cleanup Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
521 lines
24 KiB
TypeScript
521 lines
24 KiB
TypeScript
'use client'
|
|
|
|
import { useRef, useEffect, useState } from 'react'
|
|
import { useChat } from '@ai-sdk/react'
|
|
import { DefaultChatTransport } from 'ai'
|
|
import type { UIMessage } from 'ai'
|
|
import { cn } from '@/lib/utils'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
X, Bot, Sparkles, Send, Loader2,
|
|
Briefcase, Palette, GraduationCap, Coffee,
|
|
Lightbulb, Minimize2, AlignLeft, Wand2,
|
|
Globe, BookOpen, FileText, RotateCcw, Check,
|
|
Maximize2,
|
|
} from 'lucide-react'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { MarkdownContent } from '@/components/markdown-content'
|
|
import { toast } from 'sonner'
|
|
import { useWebSearchAvailable } from '@/hooks/use-web-search-available'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { getNotebookIcon } from '@/lib/notebook-icon'
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function getMessageContent(msg: UIMessage): string {
|
|
if (typeof (msg as any).content === 'string') return (msg as any).content
|
|
if (msg.parts && Array.isArray(msg.parts)) {
|
|
return msg.parts
|
|
.filter((p: any) => p.type === 'text')
|
|
.map((p: any) => p.text)
|
|
.join('')
|
|
}
|
|
return ''
|
|
}
|
|
|
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
|
|
const TONES = [
|
|
{ id: 'professional', label: 'Pro', full: 'Professional', icon: Briefcase },
|
|
{ id: 'creative', label: 'Create', full: 'Creative', icon: Palette },
|
|
{ id: 'academic', label: 'Acad.', full: 'Academic', icon: GraduationCap },
|
|
{ id: 'casual', label: 'Casual', full: 'Casual', icon: Coffee },
|
|
]
|
|
|
|
interface ActionDef {
|
|
id: string
|
|
icon: any
|
|
apiPath: string
|
|
body: (content: string) => object
|
|
resultKey: string
|
|
i18nKey: string
|
|
}
|
|
|
|
const ACTION_IDS = [
|
|
{ id: 'clarify', icon: Lightbulb, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'clarify' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.clarify' },
|
|
{ id: 'shorten', icon: Minimize2, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'shorten' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.shorten' },
|
|
{ id: 'improve', icon: AlignLeft, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'improve' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.improve' },
|
|
{ id: 'markdown', icon: Wand2, apiPath: '/api/ai/transform-markdown', body: (content: string) => ({ text: content }), resultKey: 'transformedText', i18nKey: 'ai.action.toMarkdown' },
|
|
]
|
|
|
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
|
|
interface ContextualAIChatProps {
|
|
onClose: () => void
|
|
noteTitle?: string
|
|
noteContent?: string
|
|
/** Called when an action result should be injected into the note */
|
|
onApplyToNote?: (newContent: string) => void
|
|
/** Called when the user wants to undo the last injected action */
|
|
onUndoLastAction?: () => void
|
|
/** Whether the last action has been applied (so we can show undo) */
|
|
lastActionApplied?: boolean
|
|
/** Notebooks available for scope selection */
|
|
notebooks?: Array<{ id: string; name: string }>
|
|
/** Extra classes forwarded to the aside root element */
|
|
className?: string
|
|
}
|
|
|
|
// ── Component ─────────────────────────────────────────────────────────────────
|
|
|
|
export function ContextualAIChat({
|
|
onClose,
|
|
noteTitle,
|
|
noteContent,
|
|
onApplyToNote,
|
|
onUndoLastAction,
|
|
lastActionApplied = false,
|
|
notebooks = [],
|
|
className,
|
|
}: ContextualAIChatProps) {
|
|
const { t, language } = useLanguage()
|
|
const webSearchAvailable = useWebSearchAvailable()
|
|
|
|
const [activeTab, setActiveTab] = useState<'chat' | 'actions'>('chat')
|
|
const [selectedTone, setSelectedTone] = useState('professional')
|
|
const [input, setInput] = useState('')
|
|
const [chatScope, setChatScope] = useState<'note' | 'all' | string>('note') // 'note', 'all', or notebook ID
|
|
const [webSearch, setWebSearch] = useState(false)
|
|
const [expanded, setExpanded] = useState(false)
|
|
|
|
// Action state
|
|
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
|
const [actionPreview, setActionPreview] = useState<{ label: string; text: string } | null>(null)
|
|
|
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
|
|
const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current
|
|
|
|
const buildChatBody = () => {
|
|
const body: Record<string, any> = { language, webSearch }
|
|
if (chatScope === 'note') {
|
|
body.noteContext = { title: noteTitle || '', content: noteContent || '', tone: selectedTone }
|
|
} else if (chatScope !== 'all') {
|
|
// scope is a notebook ID
|
|
body.notebookId = chatScope
|
|
}
|
|
return body
|
|
}
|
|
|
|
const { messages, sendMessage, status } = useChat({ transport })
|
|
|
|
const isLoading = status === 'submitted' || status === 'streaming'
|
|
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}, [messages])
|
|
|
|
useEffect(() => {
|
|
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: true }))
|
|
return () => {
|
|
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: false }))
|
|
}
|
|
}, [])
|
|
|
|
// ── Chat send ───────────────────────────────────────────────────────────────
|
|
const handleSend = async () => {
|
|
const text = input.trim()
|
|
if (!text || isLoading) return
|
|
setInput('')
|
|
await sendMessage({ text }, { body: buildChatBody() })
|
|
}
|
|
|
|
// ── Action execution ────────────────────────────────────────────────────────
|
|
const handleAction = async (action: ActionDef) => {
|
|
const wc = (noteContent || '').split(/\s+/).filter(Boolean).length
|
|
if (!noteContent || wc < 5) {
|
|
toast.error(t('ai.minWordsError'))
|
|
return
|
|
}
|
|
setActionLoading(action.id)
|
|
setActionPreview(null)
|
|
try {
|
|
const res = await fetch(action.apiPath, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(action.body(noteContent)),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
|
|
const result = data[action.resultKey] || ''
|
|
setActionPreview({ label: t(action.i18nKey), text: result })
|
|
} catch (e: any) {
|
|
toast.error(e.message || t('ai.actionError'))
|
|
} finally {
|
|
setActionLoading(null)
|
|
}
|
|
}
|
|
|
|
const handleApplyPreview = () => {
|
|
if (!actionPreview || !onApplyToNote) return
|
|
onApplyToNote(actionPreview.text)
|
|
setActionPreview(null)
|
|
toast.success(t('ai.appliedToNote'))
|
|
}
|
|
|
|
const handleDiscardPreview = () => setActionPreview(null)
|
|
|
|
// ── Scope label ─────────────────────────────────────────────────────────────
|
|
const scopeLabel =
|
|
chatScope === 'note' ? t('ai.thisNote')
|
|
: chatScope === 'all' ? t('ai.allMyNotes')
|
|
: notebooks.find(n => n.id === chatScope)?.name ?? t('ai.notebookGeneric')
|
|
|
|
return (
|
|
<aside className={cn(
|
|
'border-l border-border/40 bg-card flex flex-col self-stretch flex-shrink-0 z-10 transition-all duration-300',
|
|
expanded ? 'w-[560px]' : 'w-[360px]',
|
|
className,
|
|
)}>
|
|
|
|
{/* ── Header ───────────────────────────────────────────────── */}
|
|
<div className="px-4 py-3 border-b border-border/40 flex items-center justify-between shrink-0">
|
|
<div className="min-w-0">
|
|
<h2 className="text-base font-semibold text-foreground flex items-center gap-2">
|
|
<Sparkles className="h-4 w-4 text-primary shrink-0" />
|
|
{t('ai.assistantTitle')}
|
|
</h2>
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
{noteTitle ? `"${noteTitle}"` : t('ai.currentNote')}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-0.5 shrink-0">
|
|
{lastActionApplied && onUndoLastAction && (
|
|
<Button
|
|
variant="ghost" size="icon"
|
|
className="h-7 w-7 text-amber-500 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-950/30"
|
|
onClick={onUndoLastAction}
|
|
title={t('ai.undoLastAction')}
|
|
>
|
|
<RotateCcw className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
{/* Expand / Shrink */}
|
|
<Button
|
|
variant="ghost" size="icon"
|
|
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
|
onClick={() => setExpanded(e => !e)}
|
|
title={expanded ? t('ai.shrinkPanel') : t('ai.expandPanel')}
|
|
>
|
|
{expanded
|
|
? <Minimize2 className="h-3.5 w-3.5" />
|
|
: <Maximize2 className="h-3.5 w-3.5" />}
|
|
</Button>
|
|
<Button variant="ghost" size="icon" onClick={onClose} className="h-7 w-7 text-muted-foreground hover:text-foreground">
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Tabs ─────────────────────────────────────────────────── */}
|
|
<div className="flex border-b border-border/40 shrink-0">
|
|
{(['chat', 'actions'] as const).map(tab => (
|
|
<button
|
|
key={tab}
|
|
onClick={() => setActiveTab(tab)}
|
|
className={cn(
|
|
'flex-1 py-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all capitalize',
|
|
activeTab === tab
|
|
? 'border-primary text-primary'
|
|
: 'border-transparent text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
{tab === 'chat' && <Bot className="h-3.5 w-3.5" />}
|
|
{tab === 'actions' && <Wand2 className="h-3.5 w-3.5" />}
|
|
{tab === 'chat' ? t('ai.chatTab') : t('ai.noteActions')}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* ══════════════════════════════════════════════════════════ */}
|
|
{/* ── TAB: CHAT ─────────────────────────────────────────── */}
|
|
{/* ══════════════════════════════════════════════════════════ */}
|
|
{activeTab === 'chat' && (
|
|
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
|
{/* Messages */}
|
|
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3 flex flex-col">
|
|
{messages.length === 0 && (
|
|
<div className="flex-1 flex flex-col items-center justify-center text-center opacity-60">
|
|
<div className="w-14 h-14 rounded-full bg-primary/5 flex items-center justify-center border border-primary/10 mb-4">
|
|
<Sparkles className="h-6 w-6 text-primary/50" />
|
|
</div>
|
|
<p className="text-sm text-muted-foreground max-w-[220px]">
|
|
{t('ai.askToStart')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{messages.map((msg: UIMessage) => {
|
|
const content = getMessageContent(msg)
|
|
return (
|
|
<div key={msg.id} className={cn('flex gap-2', msg.role === 'user' && 'flex-row-reverse')}>
|
|
<div className={cn(
|
|
'w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 border text-[10px] font-bold',
|
|
msg.role === 'user'
|
|
? 'bg-slate-100 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300'
|
|
: 'bg-primary/10 text-primary border-primary/20',
|
|
)}>
|
|
{msg.role === 'user' ? 'U' : <Bot className="h-3 w-3" />}
|
|
</div>
|
|
<div className={cn(
|
|
'max-w-[88%] p-3 rounded-2xl text-sm leading-relaxed',
|
|
msg.role === 'user'
|
|
? 'bg-primary text-primary-foreground rounded-tr-sm'
|
|
: 'bg-muted/40 border border-border/40 rounded-tl-sm text-foreground',
|
|
)}>
|
|
{msg.role === 'assistant'
|
|
? <MarkdownContent content={content} />
|
|
: <p>{content}</p>}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{isLoading && (
|
|
<div className="flex gap-2">
|
|
<div className="w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center flex-shrink-0 border border-primary/20">
|
|
<Bot className="h-3 w-3" />
|
|
</div>
|
|
<div className="bg-muted/40 border border-border/40 p-3 rounded-2xl rounded-tl-sm">
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Scope & Tone Control Area */}
|
|
<div className="border-t border-border/20 shrink-0 bg-muted/10">
|
|
{/* Scope bar */}
|
|
<div className="px-3 py-2 border-b border-border/10 flex flex-col gap-1.5">
|
|
<label className="text-xs font-bold tracking-wider text-muted-foreground uppercase">
|
|
{t('ai.contextLabel')}
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<Select value={chatScope} onValueChange={setChatScope}>
|
|
<SelectTrigger className="h-8 flex-1 text-sm bg-card">
|
|
<SelectValue placeholder={t('ai.selectContext')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="note" className="text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<FileText className="h-3 w-3 text-muted-foreground" /> {t('ai.thisNote')}
|
|
</div>
|
|
</SelectItem>
|
|
<SelectItem value="all" className="text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<Bot className="h-3 w-3 text-muted-foreground" /> {t('ai.allMyNotes')}
|
|
</div>
|
|
</SelectItem>
|
|
{notebooks.map(nb => (
|
|
<SelectItem key={nb.id} value={nb.id} className="text-xs">
|
|
<div className="flex items-center gap-2">
|
|
{(() => {
|
|
const Icon = getNotebookIcon((nb as any).icon)
|
|
return <Icon className="w-3 h-3 text-muted-foreground" />
|
|
})()}
|
|
{nb.name.length > 25 ? nb.name.slice(0, 25) + '…' : nb.name}
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tone selector */}
|
|
<div className="px-3 py-2 flex flex-col gap-1.5">
|
|
<label className="text-xs font-bold tracking-wider text-muted-foreground uppercase">
|
|
{t('ai.writingTone')}
|
|
</label>
|
|
<div className="grid grid-cols-4 gap-1">
|
|
{TONES.map(tone => {
|
|
const Icon = tone.icon
|
|
const sel = selectedTone === tone.id
|
|
return (
|
|
<button
|
|
key={tone.id}
|
|
onClick={() => setSelectedTone(tone.id)}
|
|
title={tone.full}
|
|
className={cn(
|
|
'py-1.5 rounded border text-xs font-medium transition-all flex flex-col items-center gap-1',
|
|
sel
|
|
? 'border-primary bg-primary/10 text-primary'
|
|
: 'border-border/40 text-muted-foreground hover:bg-muted bg-card',
|
|
)}
|
|
>
|
|
<Icon className="h-3 w-3" />
|
|
{tone.label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<div className="p-3 border-t border-border/40 shrink-0 bg-card">
|
|
<div className="relative bg-card border border-border/60 rounded-xl p-1 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/20 transition-all">
|
|
<textarea
|
|
className="w-full bg-transparent border-none focus:ring-0 resize-none text-sm text-foreground placeholder:text-muted-foreground/60 p-2 min-h-[64px] max-h-[120px]"
|
|
placeholder={
|
|
chatScope === 'note'
|
|
? t('ai.askAboutThisNote')
|
|
: t('ai.askAboutYourNotes')
|
|
}
|
|
value={input}
|
|
onChange={e => setInput(e.target.value)}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
|
}}
|
|
disabled={isLoading}
|
|
/>
|
|
<div className="flex justify-between items-center px-1 pb-1 pt-1">
|
|
<div className="flex items-center gap-2">
|
|
{webSearchAvailable && (
|
|
<button
|
|
onClick={() => setWebSearch(w => !w)}
|
|
title={t('ai.webSearchLabel')}
|
|
className={cn(
|
|
'flex items-center justify-center gap-1.5 h-8 px-3 rounded-md border transition-all text-xs font-medium',
|
|
webSearch
|
|
? 'border-emerald-500 bg-emerald-50 text-emerald-600 dark:bg-emerald-950/30'
|
|
: 'border-border/50 text-muted-foreground hover:bg-muted bg-card',
|
|
)}
|
|
>
|
|
<Globe className="h-3 w-3" />
|
|
Web
|
|
</button>
|
|
)}
|
|
</div>
|
|
<Button
|
|
size="icon"
|
|
className="h-7 w-7 rounded-lg bg-primary text-primary-foreground shadow-sm disabled:opacity-50"
|
|
onClick={handleSend}
|
|
disabled={isLoading || !input.trim()}
|
|
>
|
|
{isLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5 ml-0.5" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<p className="text-[9px] text-muted-foreground/40 text-center mt-1">{t('ai.newLineHint')}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ══════════════════════════════════════════════════════════ */}
|
|
{/* ── TAB: ACTIONS ─────────────────────────────────────── */}
|
|
{/* ══════════════════════════════════════════════════════════ */}
|
|
{activeTab === 'actions' && (
|
|
<div className="flex flex-col flex-1 overflow-hidden">
|
|
{/* Preview panel — result to apply */}
|
|
{actionPreview ? (
|
|
<div className="flex flex-col flex-1 overflow-hidden">
|
|
<div className="px-4 py-2.5 border-b border-border/40 flex items-center justify-between shrink-0">
|
|
<p className="text-xs font-semibold text-foreground">{t('ai.resultLabel')} — {actionPreview.label}</p>
|
|
<button onClick={handleDiscardPreview} className="text-muted-foreground hover:text-foreground">
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
<div className="text-xs leading-relaxed text-foreground bg-muted/30 border border-border/40 rounded-xl p-3 whitespace-pre-wrap">
|
|
{actionPreview.text}
|
|
</div>
|
|
</div>
|
|
<div className="p-3 border-t border-border/40 flex gap-2 shrink-0">
|
|
<Button
|
|
variant="ghost" size="sm"
|
|
className="flex-1 text-xs gap-1.5"
|
|
onClick={handleDiscardPreview}
|
|
>
|
|
<X className="h-3.5 w-3.5" /> {t('ai.discardAction')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
className="flex-1 text-xs gap-1.5 bg-primary"
|
|
onClick={handleApplyPreview}
|
|
disabled={!onApplyToNote}
|
|
>
|
|
<Check className="h-3.5 w-3.5" /> {t('ai.applyToNote')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-2.5">
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-3">
|
|
{t('ai.transformationsDesc')}
|
|
</p>
|
|
|
|
{!noteContent || noteContent.trim().split(/\s+/).filter(Boolean).length < 5 ? (
|
|
<div className="flex items-start gap-2 p-3 rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30">
|
|
<Lightbulb className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
|
|
<p className="text-xs text-amber-700 dark:text-amber-400">
|
|
{t('ai.writeMinWordsAction')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
ACTION_IDS.map(action => {
|
|
const Icon = action.icon
|
|
const loading = actionLoading === action.id
|
|
return (
|
|
<button
|
|
key={action.id}
|
|
onClick={() => handleAction(action)}
|
|
disabled={!!actionLoading}
|
|
className="w-full flex items-center gap-3 rounded-xl border border-border/60 bg-card px-4 py-3 text-sm font-medium text-foreground hover:bg-muted hover:border-primary/40 transition-all text-left disabled:opacity-60"
|
|
>
|
|
{loading
|
|
? <Loader2 className="h-4 w-4 text-primary animate-spin shrink-0" />
|
|
: <Icon className="h-4 w-4 text-primary shrink-0" />
|
|
}
|
|
<span>{t(action.i18nKey)}</span>
|
|
{loading && <span className="ml-auto text-[10px] text-muted-foreground">{t('ai.processingAction')}</span>}
|
|
</button>
|
|
)
|
|
})
|
|
)}
|
|
|
|
{/* Undo last action shortcut */}
|
|
{lastActionApplied && onUndoLastAction && (
|
|
<button
|
|
onClick={onUndoLastAction}
|
|
className="w-full mt-2 flex items-center gap-3 rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30 px-4 py-2.5 text-sm font-medium text-amber-700 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-950/50 transition-all text-left"
|
|
>
|
|
<RotateCcw className="h-4 w-4 shrink-0" />
|
|
{t('ai.undoLastAction')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</aside>
|
|
)
|
|
}
|