fix: Memory Echo sans vol de scroll, badge IA sidebar, wizard langue

- Ne plus auto-scroller vers Memory Echo : pastille bas de page + reset scroll au titre
- Badges notes/carnets générés par l’IA dans la sidebar
- Wizard : langue du contenu = langue de la requête ; refresh carnet après création
- Voix : erreurs not-allowed/no-speech moins bruyantes
This commit is contained in:
Antigravity
2026-07-16 17:25:57 +00:00
parent 45297da333
commit 704fed1191
11 changed files with 672 additions and 210 deletions

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback, type ReactNode } from 'react'
import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
import { ChevronDown, ChevronUp, Sparkles, Link2, X, Loader2, HelpCircle } from 'lucide-react'
import { LinkedNotePreviewDialog } from '@/components/linked-note-preview-dialog'
import { Button } from '@/components/ui/button'
@@ -12,7 +12,6 @@ import { toast } from 'sonner'
import { useNoteEditorContext } from '@/components/note-editor/note-editor-context'
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
import { detectTextDirection } from '@/lib/clip/rtl-content'
import { SEMANTIC_SIMILARITY_FLOOR_CLIP } from '@/lib/ai/semantic-proximity'
interface ConnectionData {
noteId: string
@@ -106,6 +105,9 @@ export function MemoryEchoSection({
const [embeddingId, setEmbeddingId] = useState<string | null>(null)
const [helpOpen, setHelpOpen] = useState(false)
const [previewTarget, setPreviewTarget] = useState<PreviewTarget | null>(null)
const sectionRef = useRef<HTMLDivElement>(null)
const [sectionInView, setSectionInView] = useState(false)
const [cueDismissed, setCueDismissed] = useState(false)
useEffect(() => {
let cancelled = false
@@ -113,6 +115,8 @@ export function MemoryEchoSection({
setConsentRequired(false)
setConnections([])
setRetroRefs([])
setCueDismissed(false)
setSectionInView(false)
const load = async () => {
try {
@@ -154,18 +158,24 @@ export function MemoryEchoSection({
}
}, [noteId])
// Scroll doux vers la section quand une forte connexion apparaît (une fois par note)
// Observe section visibility — never auto-scroll; cue only when off-screen
useEffect(() => {
if (isLoading || connections.length === 0) return
const top = connections[0]
if (!top || top.similarity < SEMANTIC_SIMILARITY_FLOOR_CLIP) return
const key = `memory-echo-scroll-${noteId}`
if (sessionStorage.getItem(key)) return
sessionStorage.setItem(key, '1')
requestAnimationFrame(() => {
document.getElementById('memory-echo-section')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
}, [isLoading, connections, noteId])
const el = sectionRef.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
setSectionInView(entry.isIntersecting && entry.intersectionRatio > 0.08)
},
{ root: null, threshold: [0, 0.08, 0.2, 0.5] },
)
observer.observe(el)
return () => observer.disconnect()
}, [noteId, isLoading, connections.length, retroRefs.length, consentRequired, isVisible])
const scrollToSection = useCallback(() => {
sectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, [])
const handleEmbed = useCallback(async (conn: ConnectionData) => {
setEmbeddingId(conn.noteId)
@@ -251,8 +261,29 @@ export function MemoryEchoSection({
const topConnection = connections[0]
const restConnections = connections.slice(1)
// Floating cue: signal activity below without stealing the title viewport
const showBottomCue =
!cueDismissed &&
!sectionInView &&
(isLoading || hasConnections || hasRetro || consentRequired)
const cueLabel = isLoading
? t('memoryEcho.editorSection.bottomCueLoading')
: hasConnections
? connections.length === 1
? t('memoryEcho.editorSection.bottomCueFoundOne')
: t('memoryEcho.editorSection.bottomCueFound', { count: connections.length })
: hasRetro
? t('memoryEcho.editorSection.bottomCueRetro', { count: retroRefs.length })
: t('memoryEcho.editorSection.bottomCueConsent')
return (
<div id="memory-echo-section" className="mt-10 space-y-6 scroll-mt-24">
<>
<div
id="memory-echo-section"
ref={sectionRef}
className="mt-10 space-y-6 scroll-mt-24"
>
{isLoading && (
<div
className="rounded-2xl border border-indigo-500/10 bg-gradient-to-br from-indigo-500/[0.03] to-transparent p-5 animate-pulse space-y-3"
@@ -551,6 +582,47 @@ export function MemoryEchoSection({
/>
)}
</div>
{showBottomCue && (
<div
className={cn(
'fixed bottom-6 left-1/2 z-40 -translate-x-1/2',
'flex items-center gap-1 rounded-full border border-indigo-500/25',
'bg-background/95 shadow-lg shadow-indigo-500/10 backdrop-blur-md',
'pl-3 pr-1.5 py-1.5 max-w-[min(92vw,22rem)]',
'animate-in fade-in slide-in-from-bottom-2 duration-300',
)}
role="status"
aria-live="polite"
>
<button
type="button"
onClick={scrollToSection}
className="flex min-w-0 flex-1 items-center gap-2 rounded-full px-1 py-0.5 text-left transition-colors hover:bg-indigo-500/5"
title={t('memoryEcho.editorSection.bottomCueScroll')}
>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-indigo-500" />
) : (
<Sparkles className="h-3.5 w-3.5 shrink-0 text-indigo-500" />
)}
<span className="truncate text-xs font-medium text-foreground/90">
{cueLabel}
</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-indigo-500/80 animate-bounce" />
</button>
<button
type="button"
onClick={() => setCueDismissed(true)}
className="shrink-0 rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={t('memoryEcho.editorSection.bottomCueDismiss')}
title={t('memoryEcho.editorSection.bottomCueDismiss')}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</>
)
}