feat: brainstorm sessions, PDF document Q&A, embedding fixes, and UI improvements
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
- Add brainstorm feature with collaborative canvas, AI idea generation, live cursors, playback, and export - Add PDF upload/extraction/ingestion pipeline with pgvector document search (RAG) - Add document Q&A overlay with streaming chat and PDF preview - Add note attachments UI with status polling, grid layout, and auto-scroll - Add task extraction AI tool and agent executor improvements - Fix NoteEmbedding missing updatedAt column, re-index 66 notes with 1536-dim embeddings - Fix brainstorm 'Create Note' button: add success toast and redirect to created note - Fix memory echo notification infinite polling - Fix chat route to always include document_search tool - Add brainstorm i18n keys across all 14 locales - Add socket server for real-time brainstorm collaboration - Add hierarchical notebook selector and organize notebook dialog improvements - Add sidebar brainstorm section with session management - Update prisma schema with brainstorm tables, attachments, and document chunks
This commit is contained in:
@@ -27,6 +27,7 @@ import {
|
||||
Sun,
|
||||
Pin,
|
||||
PinOff,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -48,8 +49,9 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
|
||||
|
||||
type NavigationView = 'notebooks' | 'agents' | 'reminders'
|
||||
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms'
|
||||
type SortOrder = 'newest' | 'oldest' | 'alpha'
|
||||
|
||||
function NoteLink({
|
||||
@@ -61,13 +63,15 @@ function NoteLink({
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const { language } = useLanguage()
|
||||
const slideX = language === 'fa' || language === 'ar' ? 10 : -10
|
||||
return (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
initial={{ opacity: 0, x: slideX }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg text-left',
|
||||
'w-full flex items-center gap-2 ps-12 pe-4 py-2 text-[12px] transition-colors rounded-lg text-start',
|
||||
isActive ? 'bg-white/50 text-foreground font-medium' : 'text-muted-foreground hover:text-foreground hover:bg-white/30'
|
||||
)}
|
||||
>
|
||||
@@ -80,6 +84,81 @@ function NoteLink({
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarBrainstorms() {
|
||||
const { data: sessions, isLoading } = useBrainstormSessions()
|
||||
const deleteBrainstorm = useDeleteBrainstorm()
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-4 space-y-2">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-12 rounded-xl bg-paper/50 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!sessions || sessions.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-6 text-center">
|
||||
<Sparkles size={20} className="mx-auto text-orange-400/40 mb-2" />
|
||||
<p className="text-[11px] text-muted-foreground">{t('brainstorm.noSessions')}</p>
|
||||
<button
|
||||
onClick={() => router.push('/brainstorm')}
|
||||
className="mt-2 text-[11px] text-orange-500 hover:text-orange-400 font-medium"
|
||||
>
|
||||
{t('brainstorm.startOne')} →
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{sessions.slice(0, 10).map(s => (
|
||||
<div key={s.id} className="relative group/item">
|
||||
<button
|
||||
onClick={() => router.replace(`/brainstorm?session=${s.id}`)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all duration-200 text-start hover:bg-memento-blue/5 group ${
|
||||
(s as any)._owned === false ? 'border-s-2 border-memento-blue/30 dark:border-memento-blue/70' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 ${
|
||||
(s as any)._owned === false
|
||||
? 'border border-memento-blue/20 dark:border-memento-blue/80 bg-memento-blue/5 dark:bg-memento-blue/20'
|
||||
: 'border border-orange-200 dark:border-orange-800/40 bg-orange-50 dark:bg-orange-900/20'
|
||||
}`}>
|
||||
<Sparkles size={12} className={(s as any)._owned === false ? 'text-memento-blue' : 'text-orange-500'} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[12px] font-medium truncate">
|
||||
{s.seedIdea}
|
||||
{(s as any)._owned === false && <span className="text-[9px] ms-1.5 text-memento-blue/70 font-normal">· partagé</span>}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{s.activeIdeas} {t('brainstorm.ideas')} · {new Date(s.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{(s as any)._owned !== false && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
deleteBrainstorm.mutate(s.id)
|
||||
}}
|
||||
className="absolute end-2 top-1/2 -translate-y-1/2 p-1.5 rounded-lg opacity-0 group-hover/item:opacity-100 hover:bg-rose-500/10 text-muted-foreground hover:text-rose-500 transition-all"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarCarnetItem({
|
||||
carnet,
|
||||
isActive,
|
||||
@@ -117,7 +196,8 @@ function SidebarCarnetItem({
|
||||
isExpanded: boolean
|
||||
toggleExpand: () => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const { t, language } = useLanguage()
|
||||
const isRtl = language === 'fa' || language === 'ar'
|
||||
const hasChildren = React.Children.count(children) > 0
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
@@ -133,7 +213,7 @@ function SidebarCarnetItem({
|
||||
<div className={cn('transition-opacity', isDragging && 'opacity-40')}>
|
||||
<div
|
||||
className="flex items-center group relative h-10"
|
||||
style={{ paddingLeft: `${level * 16}px` }}
|
||||
style={{ paddingInlineStart: `${level * 16}px` }}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@@ -141,15 +221,15 @@ function SidebarCarnetItem({
|
||||
}}
|
||||
>
|
||||
{level > 0 && (
|
||||
<div className="absolute left-[8px] top-[-10px] bottom-1/2 w-px bg-border/40" />
|
||||
<div className="absolute start-[8px] top-[-10px] bottom-1/2 w-px bg-border/40" />
|
||||
)}
|
||||
{level > 0 && (
|
||||
<div className="absolute left-[8px] top-1/2 w-[8px] h-px bg-border/40" />
|
||||
<div className="absolute start-[8px] top-1/2 w-[8px] h-px bg-border/40" />
|
||||
)}
|
||||
|
||||
<div
|
||||
{...dragHandleProps}
|
||||
className="absolute left-1 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground/30 hover:text-muted-foreground cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
||||
className="absolute start-1 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground/30 hover:text-muted-foreground cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
||||
>
|
||||
<GripVertical size={12} />
|
||||
</div>
|
||||
@@ -161,7 +241,7 @@ function SidebarCarnetItem({
|
||||
className="p-1 hover:bg-foreground/5 rounded-md transition-colors text-muted-foreground"
|
||||
>
|
||||
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
|
||||
<ChevronRight size={14} />
|
||||
<ChevronRight size={14} className="rtl:scale-x-[-1]" />
|
||||
</motion.div>
|
||||
</button>
|
||||
) : (
|
||||
@@ -169,7 +249,7 @@ function SidebarCarnetItem({
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
whileHover={{ x: 2 }}
|
||||
whileHover={{ x: isRtl ? -2 : 2 }}
|
||||
onClick={onCarnetClick}
|
||||
onDoubleClick={(e) => { e.stopPropagation(); onRename() }}
|
||||
className={cn(
|
||||
@@ -180,7 +260,7 @@ function SidebarCarnetItem({
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="active-indicator"
|
||||
className="absolute -left-1 w-1 h-4 bg-blueprint rounded-full"
|
||||
className="absolute -start-1 w-1 h-4 bg-blueprint rounded-full"
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
@@ -193,7 +273,7 @@ function SidebarCarnetItem({
|
||||
{carnet.initial}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 text-left flex items-center gap-2 min-w-0">
|
||||
<div className="flex-1 text-start flex items-center gap-2 min-w-0">
|
||||
<span className={cn(
|
||||
'text-[12px] font-medium transition-colors truncate',
|
||||
isActive ? 'text-ink' : 'text-muted-ink group-hover/item:text-ink'
|
||||
@@ -205,7 +285,7 @@ function SidebarCarnetItem({
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity shrink-0">
|
||||
{isPinned && (
|
||||
<span className="text-blueprint" title="Carnet figé">
|
||||
<span className="text-blueprint" title={t('notebook.pinnedFrozenTooltip')}>
|
||||
<Pin size={9} className="opacity-70" />
|
||||
</span>
|
||||
)}
|
||||
@@ -258,8 +338,8 @@ function SidebarCarnetItem({
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-ink hover:bg-foreground/5 transition-colors"
|
||||
>
|
||||
{isPinned
|
||||
? <><PinOff size={13} className="text-blueprint" /><span>Défiger l'état du carnet</span></>
|
||||
: <><Pin size={13} className="text-blueprint" /><span>Figer l'état du carnet</span></>
|
||||
? <><PinOff size={13} className="text-blueprint" /><span>{t('sidebar.unfreezePinnedNotebook')}</span></>
|
||||
: <><Pin size={13} className="text-blueprint" /><span>{t('sidebar.freezePinnedNotebook')}</span></>
|
||||
}
|
||||
</button>
|
||||
<div className="mx-3 my-1 border-t border-border/50" />
|
||||
@@ -268,14 +348,14 @@ function SidebarCarnetItem({
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-ink hover:bg-foreground/5 transition-colors"
|
||||
>
|
||||
<Plus size={13} className="text-concrete" />
|
||||
<span>Nouveau sous-carnet</span>
|
||||
<span>{t('sidebar.newSubNotebook')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onRename(); setContextMenu(null) }}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-ink hover:bg-foreground/5 transition-colors"
|
||||
>
|
||||
<Pencil size={13} className="text-concrete" />
|
||||
<span>Renommer</span>
|
||||
<span>{t('sidebar.renameNotebook')}</span>
|
||||
</button>
|
||||
<div className="mx-3 my-1 border-t border-border/50" />
|
||||
<button
|
||||
@@ -283,7 +363,7 @@ function SidebarCarnetItem({
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30 transition-colors"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
<span>Supprimer</span>
|
||||
<span>{t('common.delete')}</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -298,8 +378,8 @@ function SidebarCarnetItem({
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="relative" style={{ marginLeft: `${(level + 1) * 16 + 10}px` }}>
|
||||
<div className="absolute left-[-6px] top-0 bottom-4 w-px bg-border/30" />
|
||||
<div className="relative" style={{ marginInlineStart: `${(level + 1) * 16 + 10}px` }}>
|
||||
<div className="absolute start-[-6px] top-0 bottom-4 w-px bg-border/30" />
|
||||
|
||||
<div className="space-y-0.5 py-1">
|
||||
{children}
|
||||
@@ -312,7 +392,7 @@ function SidebarCarnetItem({
|
||||
/>
|
||||
))}
|
||||
{isActive && notes.length === 0 && !hasChildren && (
|
||||
<p className="pl-8 py-2 text-[10px] italic text-muted-foreground/40 font-light">
|
||||
<p className="ps-8 py-2 text-[10px] italic text-muted-foreground/40 font-light">
|
||||
{t('common.noResults') || 'No notes found'}
|
||||
</p>
|
||||
)}
|
||||
@@ -329,7 +409,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
const { t, language } = useLanguage()
|
||||
const isRtl = language === 'fa' || language === 'ar'
|
||||
const { notebooks, trashNotebook, updateNotebookOrderOptimistic } = useNotebooks()
|
||||
const { refreshKey } = useNoteRefresh()
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
@@ -733,7 +814,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
<LogOut className="h-4 w-4 me-2" />
|
||||
{t('sidebar.signOut') || 'Se déconnecter'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -770,6 +851,13 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
>
|
||||
<Bot size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveView('brainstorms'); router.push('/brainstorm') }}
|
||||
className={cn('p-1.5 rounded-full transition-all', activeView === 'brainstorms' ? 'bg-orange-500 text-white shadow-sm' : 'text-muted-ink hover:text-ink')}
|
||||
title={t('brainstorm.sessions')}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -781,9 +869,9 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
{activeView === 'notebooks' ? (
|
||||
<motion.div
|
||||
key="notebooks"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 10 }}
|
||||
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{/* Section header with sort button */}
|
||||
@@ -795,7 +883,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<button
|
||||
onClick={() => { setCreateParentId(null); setIsCreateDialogOpen(true) }}
|
||||
className="p-1 text-muted-foreground hover:text-foreground hover:bg-white/40 transition-all rounded"
|
||||
title={t('notebook.create') || 'Nouveau carnet'}
|
||||
title={t('notebook.create')}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
@@ -813,14 +901,14 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
initial={{ opacity: 0, scale: 0.9, y: -4 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: -4 }}
|
||||
className="absolute right-0 top-full mt-1 bg-card border border-border rounded-xl shadow-lg z-50 py-1 min-w-[140px]"
|
||||
className="absolute end-0 top-full mt-1 bg-card border border-border rounded-xl shadow-lg z-50 py-1 min-w-[140px]"
|
||||
>
|
||||
{(['newest', 'oldest', 'alpha'] as SortOrder[]).map(order => (
|
||||
<button
|
||||
key={order}
|
||||
onClick={() => { setSortOrder(order); setShowSortMenu(false) }}
|
||||
className={cn(
|
||||
'w-full text-left px-4 py-2 text-[12px] transition-colors',
|
||||
'w-full text-start px-4 py-2 text-[12px] transition-colors',
|
||||
sortOrder === order
|
||||
? 'font-bold text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
@@ -872,9 +960,9 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
) : activeView === 'reminders' ? (
|
||||
<motion.div
|
||||
key="reminders"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 10 }}
|
||||
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<p className="text-[10px] font-bold text-muted-ink tracking-[0.2em] uppercase mb-4 px-4">
|
||||
@@ -885,12 +973,12 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<p className="text-[11px] text-concrete italic">{t('sidebar.noReminders') || 'No active reminders.'}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
) : activeView === 'agents' ? (
|
||||
<motion.div
|
||||
key="agents"
|
||||
initial={{ opacity: 0, x: 10 }}
|
||||
initial={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
exit={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-4">
|
||||
@@ -900,6 +988,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
{[
|
||||
{ id: 'agents', href: '/agents', label: t('agents.myAgents'), icon: Bot },
|
||||
{ id: 'lab', href: '/lab', label: t('nav.lab'), icon: FlaskConical },
|
||||
{ id: 'brainstorm', href: '/brainstorm', label: t('brainstorm.sessions'), icon: Sparkles },
|
||||
].map(item => {
|
||||
const isActive = pathname.startsWith(item.href)
|
||||
return (
|
||||
@@ -925,6 +1014,28 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="brainstorms"
|
||||
initial={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 mb-3">
|
||||
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase">
|
||||
{t('brainstorm.sessions')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push('/brainstorm')}
|
||||
className="p-1 text-muted-foreground hover:text-orange-500 transition-colors rounded"
|
||||
title={t('brainstorm.newBrainstorm')}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<SidebarBrainstorms />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
@@ -965,7 +1076,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<Trash2 size={14} className={pathname === '/trash' ? 'text-rose-500' : 'text-muted-ink group-hover:text-rose-500'} />
|
||||
<span>{t('sidebar.trash')}</span>
|
||||
{trashCount > 0 && (
|
||||
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-rose-400" />
|
||||
<span className="ms-auto w-1.5 h-1.5 rounded-full bg-rose-400" />
|
||||
)}
|
||||
</Link>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user