Files
nexu-io--open-design/apps/web/src/components/MemoryToast.tsx
T
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

167 lines
5.5 KiB
TypeScript

// Floats above everything else and surfaces a transient "Memory updated"
// pill whenever the daemon emits a `kind: 'extract'` change event. We
// only fire on extraction events so a manual edit in the settings panel
// doesn't bounce a redundant toast back at the user (their click was the
// confirmation). The pill is clickable: tapping it opens Settings →
// Memory so the user can immediately see (and edit) the freshly
// extracted entries. The component owns its own EventSource so it can
// be dropped into App.tsx with no other plumbing.
import { useEffect, useRef, useState } from 'react';
import type { CSSProperties } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import type { MemoryChangeEvent } from '@open-design/contracts';
import { useT } from '../i18n';
import { toastSlideUp } from '../motion';
interface ActiveToast {
key: number;
count: number;
source?: MemoryChangeEvent['source'];
}
interface Props {
// Optional click handler. When provided, the pill becomes a button
// and clicking it should jump the user into Settings → Memory.
onOpenMemory?: () => void;
}
const VISIBLE_MS = 4500;
export function MemoryToast({ onOpenMemory }: Props) {
const t = useT();
const [toast, setToast] = useState<ActiveToast | null>(null);
// We keep the dismiss timer in a ref so a second event mid-flight
// resets the countdown instead of double-dismissing.
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
// Guard for environments without EventSource (jsdom in tests, SSR).
// The toast is purely a UX nicety; no SSE just means no auto-pop-up.
if (typeof EventSource === 'undefined') return;
const es = new EventSource('/api/memory/events');
es.addEventListener('change', (raw) => {
try {
const event = JSON.parse((raw as MessageEvent).data) as MemoryChangeEvent;
if (event.kind !== 'extract') return;
if ((event.count ?? 0) <= 0) return;
// Source defaults to heuristic but a manual extract via curl
// would still be useful to surface. Only suppress when source is
// 'manual' (won't currently fire, reserved for future settings
// bulk-import hook).
if (event.source === 'manual') return;
setToast({
key: Date.now(),
count: event.count ?? 1,
source: event.source,
});
} catch {
// Malformed payload — ignore.
}
});
es.addEventListener('error', () => {
// The browser will auto-reconnect. We don't surface connection
// failures because the SSE channel is purely a UX nicety; missing
// a notification still lets the user see updates next time they
// open Settings → Memory.
});
return () => {
es.close();
};
}, []);
useEffect(() => {
if (!toast) return;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setToast(null), VISIBLE_MS);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [toast]);
const label = toast ? t('settings.memoryToastChanged') : '';
const detail = toast
? toast.source === 'llm'
? `(${toast.count} · LLM)`
: toast.source === 'annotation'
? `(${toast.count} · annotations)`
: `(${toast.count})`
: '';
const clickHint = toast ? t('settings.memoryToastClickHint') : '';
const pillStyle: CSSProperties = {
position: 'fixed',
bottom: 20,
right: 20,
zIndex: 1000,
padding: '8px 14px',
borderRadius: 999,
background: 'rgba(20, 20, 20, 0.92)',
color: '#fff',
fontSize: 13,
boxShadow: '0 6px 24px rgba(0,0,0,0.18)',
display: 'flex',
alignItems: 'center',
gap: 8,
backdropFilter: 'blur(8px)',
border: 'none',
font: 'inherit',
cursor: onOpenMemory ? 'pointer' : 'default',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
};
return (
<AnimatePresence>
{toast ? (
!onOpenMemory ? (
<motion.div
key={toast.key}
role="status"
aria-live="polite"
style={pillStyle}
variants={toastSlideUp}
initial="hidden"
animate="visible"
exit="exit"
>
<span aria-hidden style={{ fontSize: 14 }}></span>
<span>{label}</span>
<span style={{ opacity: 0.65 }}>{detail}</span>
</motion.div>
) : (
<motion.button
key={toast.key}
type="button"
aria-live="polite"
aria-label={`${label} ${detail}${clickHint}`}
title={clickHint}
onClick={onOpenMemory}
whileHover={{ y: -1, boxShadow: '0 10px 28px rgba(0,0,0,0.24)' }}
style={pillStyle}
variants={toastSlideUp}
initial="hidden"
animate="visible"
exit="exit"
>
<span aria-hidden style={{ fontSize: 14 }}></span>
<span>{label}</span>
<span style={{ opacity: 0.65 }}>{detail}</span>
<span
aria-hidden
style={{
marginLeft: 4,
paddingLeft: 8,
borderLeft: '1px solid rgba(255,255,255,0.18)',
opacity: 0.85,
fontSize: 12,
fontWeight: 500,
}}
>
{clickHint}
</span>
</motion.button>
)
) : null}
</AnimatePresence>
);
}