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
167 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|