fix: chat memory lost between messages + per-note history
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m11s

Chat (AIChat floating widget): conversationId was never captured from
the API response, so every message created a new conversation with no
context. Now creates the conversation upfront before streaming (same
pattern as ChatContainer) so the ID persists across messages.

Note history: was stored globally in UserAISettings, so enabling
history on one note enabled it for ALL notes. Now each Note has its
own historyEnabled boolean field. The "Enable history" action only
affects the specific note. A migration adds the column with default
false.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 22:18:46 +02:00
parent 0bccc41ccc
commit b92f6384a4
11 changed files with 80 additions and 54 deletions

View File

@@ -436,14 +436,12 @@ export async function commitNoteHistory(noteId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const enabled = await isNoteHistoryEnabledForUser(session.user.id)
if (!enabled) throw new Error('History is disabled')
const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true },
select: { id: true, historyEnabled: true },
})
if (!note) throw new Error('Note not found')
if (!note.historyEnabled) throw new Error('History is disabled for this note')
await createNoteHistorySnapshot({
noteId: note.id,
@@ -466,6 +464,22 @@ export async function deleteNoteHistoryEntry(noteId: string, historyEntryId: str
})
}
export async function enableNoteHistory(noteId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true },
})
if (!note) throw new Error('Note not found')
await prisma.note.update({
where: { id: noteId },
data: { historyEnabled: true },
})
}
// Search notes - DB-side filtering (fast) with optional semantic search
// Supports contextual search within notebook (IA5)
export async function searchNotes(query: string, useSemantic: boolean = false, notebookId?: string) {
@@ -641,14 +655,9 @@ export async function createNote(data: {
}
try {
const historyEnabled = await isNoteHistoryEnabledForUser(session.user.id)
if (historyEnabled) {
await createNoteHistorySnapshot({
noteId: note.id,
userId: session.user.id,
reason: 'create',
})
}
// New notes start with historyEnabled=false (schema default),
// so no initial snapshot is needed here.
// History is enabled per-note via enableNoteHistory() action.
} catch (snapshotError) {
console.error('[HISTORY] Failed to create initial snapshot:', snapshotError)
}
@@ -783,7 +792,7 @@ export async function updateNote(id: string, data: {
try {
const oldNote = await prisma.note.findUnique({
where: { id, userId: session.user.id },
select: { labels: true, notebookId: true, reminder: true, content: true, title: true }
select: { labels: true, notebookId: true, reminder: true, content: true, title: true, historyEnabled: true }
})
const oldLabels: string[] = oldNote?.labels ? JSON.parse(oldNote.labels) : []
const oldNotebookId = oldNote?.notebookId
@@ -854,7 +863,7 @@ export async function updateNote(id: string, data: {
}
try {
const historyEnabled = await isNoteHistoryEnabledForUser(session.user.id)
const historyEnabled = oldNote?.historyEnabled === true
if (historyEnabled && shouldCaptureHistorySnapshot(data as Record<string, unknown>)) {
const mode = await getNoteHistoryMode(session.user.id)
if (mode === 'manual') {

View File

@@ -142,7 +142,7 @@ export async function PUT(
})
try {
const historyEnabled = await isNoteHistoryEnabledForUser(session.user.id)
const historyEnabled = existingNote.historyEnabled === true
if (historyEnabled && shouldCaptureHistorySnapshot(updateData)) {
const mode = await getNoteHistoryMode(session.user.id)
if (mode === 'auto') {

View File

@@ -13,6 +13,7 @@ import { useWebSearchAvailable } from '@/hooks/use-web-search-available'
import { useNotebooks } from '@/context/notebooks-context'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { toast } from 'sonner'
import { createConversation } from '@/app/actions/chat-actions'
function getTextContent(msg: UIMessage): string {
if (msg.parts && Array.isArray(msg.parts)) {
@@ -68,6 +69,20 @@ export function AIChat() {
const text = input.trim()
if (!text || isLoading) return
setInput('')
// Create conversation upfront so we have the ID for continuity
let convId = conversationId
if (!convId) {
try {
const result = await createConversation(text, chatScope !== 'all' ? chatScope : undefined)
convId = result.id
setConversationId(convId)
} catch {
toast.error(t('chat.createError'))
return
}
}
await sendMessage(
{ text },
{
@@ -76,7 +91,7 @@ export function AIChat() {
chatScope,
notebookId: chatScope !== 'all' ? chatScope : undefined,
webSearch: webSearch && webSearchAvailable,
conversationId,
conversationId: convId,
language,
}
}

View File

@@ -50,6 +50,13 @@ export function ChatContainer({ initialConversations, notebooks, webSearchAvaila
const isLoading = status === 'submitted' || status === 'streaming'
const refreshConversations = useCallback(async () => {
try {
const updated = await getConversations()
setConversations(updated)
} catch {}
}, [])
// Timeout warning: show toast if response takes > 30s
useEffect(() => {
if (!isLoading) return
@@ -105,13 +112,6 @@ export function ChatContainer({ initialConversations, notebooks, webSearchAvaila
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentId])
const refreshConversations = useCallback(async () => {
try {
const updated = await getConversations()
setConversations(updated)
} catch {}
}, [])
const handleSendMessage = async (content: string, notebookId?: string) => {
if (notebookId) {
setSelectedNotebook(notebookId)

View File

@@ -4,8 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { Note } from '@/lib/types'
import { updateAISettings } from '@/app/actions/ai-settings'
import { getAllNotes, searchNotes } from '@/app/actions/notes'
import { getAllNotes, searchNotes, enableNoteHistory } from '@/app/actions/notes'
import { NoteInput } from '@/components/note-input'
import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
import { NotesViewToggle } from '@/components/notes-view-toggle'
@@ -62,7 +61,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
initialNotes.filter(n => n.isPinned)
)
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>(initialSettings.notesViewMode)
const [noteHistoryEnabled, setNoteHistoryEnabled] = useState(initialSettings.noteHistory)
const [noteHistoryMode] = useState<'manual' | 'auto'>(initialSettings.noteHistoryMode)
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
const [isLoading, setIsLoading] = useState(false) // false by default — data is pre-loaded
@@ -145,9 +143,12 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
setHistoryOpen(true)
}, [])
const handleEnableHistory = useCallback(async () => {
await updateAISettings({ noteHistory: true })
setNoteHistoryEnabled(true)
const handleEnableHistory = useCallback(async (noteId: string) => {
await enableNoteHistory(noteId)
// Update the specific note in state
setNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, historyEnabled: true } : n)))
setPinnedNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, historyEnabled: true } : n)))
setEditingNote((prev) => (prev?.note.id === noteId ? { ...prev, note: { ...prev.note, historyEnabled: true } } : prev))
}, [])
const handleHistoryRestored = useCallback((restored: Note) => {
@@ -411,7 +412,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onSizeChange={handleSizeChange}
currentNotebookId={searchParams.get('notebook')}
noteHistoryEnabled={noteHistoryEnabled}
noteHistoryMode={noteHistoryMode}
onOpenHistory={handleOpenHistory}
onEnableHistory={handleEnableHistory}
@@ -469,8 +469,8 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
open={historyOpen}
onOpenChange={setHistoryOpen}
note={historyNote}
enabled={noteHistoryEnabled}
onEnableHistory={handleEnableHistory}
enabled={!!historyNote?.historyEnabled}
onEnableHistory={async () => { if (historyNote) await handleEnableHistory(historyNote.id) }}
onRestored={handleHistoryRestored}
/>
</div>

View File

@@ -67,7 +67,7 @@ interface NoteInlineEditorProps {
onArchive?: (noteId: string) => void
onChange?: (noteId: string, fields: Partial<Note>) => void
onOpenHistory?: (note: Note) => void
noteHistoryEnabled?: boolean
onEnableHistory?: (noteId: string) => Promise<void>
noteHistoryMode?: 'manual' | 'auto'
colorKey: NoteColor
/** If true and the note is a Markdown note, open directly in preview mode */
@@ -98,7 +98,7 @@ export function NoteInlineEditor({
onArchive,
onChange,
onOpenHistory,
noteHistoryEnabled = false,
onEnableHistory,
noteHistoryMode = 'manual',
colorKey,
defaultPreviewMode = false,
@@ -532,7 +532,7 @@ export function NoteInlineEditor({
{/* Right group: meta actions + save indicator */}
<div className="flex items-center gap-1">
{noteHistoryEnabled && noteHistoryMode === 'manual' && (
{note.historyEnabled && noteHistoryMode === 'manual' && (
<Button
variant="ghost"
size="sm"
@@ -596,10 +596,16 @@ export function NoteInlineEditor({
</DropdownMenuItem>
{onOpenHistory && (
<DropdownMenuItem
onClick={() => onOpenHistory(note)}
onClick={() => {
if (note.historyEnabled) {
onOpenHistory(note)
} else if (onEnableHistory) {
onEnableHistory(note.id).then(() => onOpenHistory(note))
}
}}
>
<History className="h-4 w-4 mr-2" />
{noteHistoryEnabled
{note.historyEnabled
? (t('notes.history') || 'Historique')
: (t('notes.enableHistory') || "Activer l'historique")}
</DropdownMenuItem>

View File

@@ -25,10 +25,9 @@ interface NotesMainSectionProps {
onEdit?: (note: Note, readOnly?: boolean) => void
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void
currentNotebookId?: string | null
noteHistoryEnabled?: boolean
noteHistoryMode?: 'manual' | 'auto'
onOpenHistory?: (note: Note) => void
onEnableHistory?: () => Promise<void>
onEnableHistory?: (noteId: string) => Promise<void>
}
export function NotesMainSection({
@@ -37,7 +36,6 @@ export function NotesMainSection({
onEdit,
onSizeChange,
currentNotebookId,
noteHistoryEnabled = false,
noteHistoryMode = 'manual',
onOpenHistory,
onEnableHistory,
@@ -49,7 +47,6 @@ export function NotesMainSection({
notes={notes}
onEdit={onEdit}
currentNotebookId={currentNotebookId}
noteHistoryEnabled={noteHistoryEnabled}
noteHistoryMode={noteHistoryMode}
onOpenHistory={onOpenHistory}
onEnableHistory={onEnableHistory}
@@ -64,7 +61,6 @@ export function NotesMainSection({
notes={notes}
onEdit={onEdit}
onSizeChange={onSizeChange}
noteHistoryEnabled={noteHistoryEnabled}
noteHistoryMode={noteHistoryMode}
onOpenHistory={onOpenHistory}
/>

View File

@@ -83,10 +83,9 @@ interface NotesTabsViewProps {
notes: Note[]
onEdit?: (note: Note, readOnly?: boolean) => void
currentNotebookId?: string | null
noteHistoryEnabled?: boolean
noteHistoryMode?: 'manual' | 'auto'
onOpenHistory?: (note: Note) => void
onEnableHistory?: () => Promise<void>
onEnableHistory?: (noteId: string) => Promise<void>
}
type SortOrder = 'date-desc' | 'date-asc' | 'title-asc' | 'title-desc'
@@ -382,16 +381,14 @@ function NoteMetaSidebar({
note,
onPinToggle,
onArchive,
noteHistoryEnabled = false,
onOpenHistory,
onEnableHistory,
}: {
note: Note
onPinToggle: (note: Note) => void
onArchive: (note: Note) => void
noteHistoryEnabled?: boolean
onOpenHistory?: (note: Note) => void
onEnableHistory?: () => Promise<void>
onEnableHistory?: (noteId: string) => Promise<void>
}) {
const { t } = useLanguage()
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
@@ -427,13 +424,13 @@ function NoteMetaSidebar({
}
const handleHistory = async () => {
if (!noteHistoryEnabled) {
if (!note.historyEnabled) {
if (!onEnableHistory) {
toast.info(ts('notes.historyDisabledDesc', "L'historique est désactivé pour votre compte."))
toast.info(ts('notes.historyDisabledDesc', "L'historique est désactivé pour cette note."))
return
}
try {
await onEnableHistory()
await onEnableHistory(note.id)
toast.success(ts('notes.historyEnabled', 'Historique activé'))
onOpenHistory?.(note)
} catch {
@@ -577,7 +574,7 @@ function NoteMetaSidebar({
<SidebarActionBtn
icon={<History className="h-3.5 w-3.5" />}
label={
noteHistoryEnabled
note.historyEnabled
? ts('notes.history', 'Historique')
: ts('notes.enableHistory', "Activer l'historique")
}
@@ -596,7 +593,7 @@ export function NotesTabsView({
notes,
onEdit,
currentNotebookId,
noteHistoryEnabled = false,
noteHistoryMode = 'manual',
onOpenHistory,
onEnableHistory,
@@ -893,9 +890,9 @@ export function NotesTabsView({
<NoteInlineEditor
key={selected.id}
note={selected}
noteHistoryEnabled={noteHistoryEnabled}
noteHistoryMode={noteHistoryMode}
onOpenHistory={onOpenHistory}
onEnableHistory={onEnableHistory}
colorKey={colorKey}
defaultPreviewMode={true}
onChange={(noteId, fields) => {
@@ -932,7 +929,6 @@ export function NotesTabsView({
note={selected}
onPinToggle={handlePinToggle}
onArchive={handleArchive}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
onEnableHistory={onEnableHistory}
/>

View File

@@ -75,6 +75,7 @@ export interface Note {
notebook?: Notebook | null;
autoGenerated?: boolean | null;
aiProvider?: string | null;
historyEnabled?: boolean;
// Search result metadata (optional)
matchType?: 'exact' | 'related' | null;
searchScore?: number | null;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Note" ADD COLUMN "historyEnabled" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -154,6 +154,7 @@ model Note {
shares NoteShare[]
labelRelations Label[] @relation("LabelToNote")
historyEntries NoteHistory[]
historyEnabled Boolean @default(false)
@@index([isPinned])
@@index([isArchived])