Files
maskanx_cm_frontend/src/components/ConsoleCronBubble/index.tsx
T
AFFAANhandClaude Opus 5 b42b7406dc Initial commit: MaskanX frontend
Independent React, TypeScript and Vite web application for MaskanX.

Includes the chat console, agent and persona configuration, MCP client
management, model and provider settings, cron jobs, sessions, and
diagnostics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:28:39 +05:30

132 lines
3.9 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { MessageCircle, X } from "lucide-react";
import { consoleApi, type PushMessage } from "../../api/modules/console";
import styles from "./index.module.less";
const POLL_INTERVAL_MS = 2500;
const AUTO_DISMISS_MS = 8000;
const MAX_SEEN_IDS = 500;
const MAX_VISIBLE_BUBBLES = 4;
const MAX_NEW_PER_POLL = 2;
const TITLE_BLINK_PREFIX = "\u2022 ";
interface BubbleItem extends PushMessage {
dismissAt: number;
}
export default function ConsoleCronBubble() {
const [items, setItems] = useState<BubbleItem[]>([]);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const seenIdsRef = useRef<Set<string>>(new Set());
const originalTitleRef = useRef(document.title);
const blinkRef = useRef<ReturnType<typeof setInterval> | null>(null);
const dismiss = (id: string) => {
setItems((prev) => prev.filter((i) => i.id !== id));
};
useEffect(() => {
originalTitleRef.current = document.title;
}, []);
useEffect(() => {
const tick = () => {
consoleApi
.getPushMessages()
.then((res) => {
if (!res?.messages?.length) return;
const seen = seenIdsRef.current;
if (seen.size > MAX_SEEN_IDS) seen.clear();
const newItems: BubbleItem[] = [];
const now = Date.now();
for (const m of res.messages) {
if (seen.has(m.id)) continue;
seen.add(m.id);
newItems.push({ ...m, dismissAt: now + AUTO_DISMISS_MS });
}
if (newItems.length === 0) return;
const toAdd = newItems.slice(-MAX_NEW_PER_POLL);
setItems((prev) => {
const merged = [...prev, ...toAdd];
return merged.slice(-MAX_VISIBLE_BUBBLES);
});
})
.catch(() => {});
};
tick();
pollRef.current = setInterval(tick, POLL_INTERVAL_MS);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, []);
useEffect(() => {
if (items.length === 0) return;
const t = setInterval(() => {
const now = Date.now();
setItems((prev) => {
const next = prev.filter((i) => i.dismissAt > now);
return next.length === prev.length ? prev : next;
});
}, 500);
return () => clearInterval(t);
}, [items.length]);
useEffect(() => {
if (items.length === 0 || !document.hidden || blinkRef.current) return;
const original = originalTitleRef.current;
let showPrefix = true;
blinkRef.current = setInterval(() => {
document.title = showPrefix
? `${TITLE_BLINK_PREFIX}${original}`
: original;
showPrefix = !showPrefix;
}, 800);
return () => {
if (blinkRef.current) {
clearInterval(blinkRef.current);
blinkRef.current = null;
}
document.title = original;
};
}, [items.length]);
useEffect(() => {
const onVisibility = () => {
if (document.visibilityState === "visible") {
if (blinkRef.current) {
clearInterval(blinkRef.current);
blinkRef.current = null;
}
document.title = originalTitleRef.current;
}
};
document.addEventListener("visibilitychange", onVisibility);
return () => document.removeEventListener("visibilitychange", onVisibility);
}, []);
if (items.length === 0) return null;
return (
<div className={styles.wrap} role="region" aria-label="Cron messages">
{items.map((item) => (
<div key={item.id} className={styles.bubble}>
<MessageCircle size={18} className={styles.icon} aria-hidden />
<p className={styles.text} title={item.text}>
{item.text}
</p>
<button
type="button"
className={styles.close}
onClick={() => dismiss(item.id)}
aria-label="Close"
>
<X size={14} />
</button>
</div>
))}
</div>
);
}