feat(topbar): unread/queue badges + Approvals tab
App.tsx polls every 60s when authed + tenant known: - chatApi.listThreads + listMessages -> count threads with last message from someone else after our localStorage read mark - api.workItems filtered by tenant -> queue count Badge UI: - Chat tab shows amber unread pill (99+ cap) - Approvals tab (new in topbar) shows navy queue-count pill Also promotes Approvals to a first-class topbar tab (was landing-chip only). 60s poll is identical to existing live-tick cadence so this adds no extra polling load.
This commit is contained in:
+46
-1
@@ -1,5 +1,5 @@
|
|||||||
// App shell — scene switcher + top bar + command palette + console + toaster.
|
// App shell — scene switcher + top bar + command palette + console + toaster.
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useApp, scenarioById } from "./state/store";
|
import { useApp, scenarioById } from "./state/store";
|
||||||
import Landing from "./scenes/Landing";
|
import Landing from "./scenes/Landing";
|
||||||
import MissionControl from "./scenes/MissionControl";
|
import MissionControl from "./scenes/MissionControl";
|
||||||
@@ -76,6 +76,46 @@ export default function App() {
|
|||||||
|
|
||||||
const liveAge = liveFetchedAt ? `${Math.max(0, Math.floor((Date.now() - liveFetchedAt) / 1000))}s ago` : null;
|
const liveAge = liveFetchedAt ? `${Math.max(0, Math.floor((Date.now() - liveFetchedAt) / 1000))}s ago` : null;
|
||||||
|
|
||||||
|
const [chatUnread, setChatUnread] = useState(0);
|
||||||
|
const [queueCount, setQueueCount] = useState(0);
|
||||||
|
const tenantId = useApp((s) => s.tenantId);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthed || !tenantId || !userEmail) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const refresh = async () => {
|
||||||
|
try {
|
||||||
|
const { chatApi } = await import("./lib/chatApi");
|
||||||
|
const { api: apiMod } = await import("./lib/api");
|
||||||
|
const [threads, items] = await Promise.all([
|
||||||
|
chatApi.listThreads(userEmail).catch(() => []),
|
||||||
|
apiMod.workItems().catch(() => []),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
const readMap: Record<string, string> = (() => {
|
||||||
|
try { return JSON.parse(localStorage.getItem(`fm.canvas.chat.read.${userEmail}`) || "{}"); } catch { return {}; }
|
||||||
|
})();
|
||||||
|
let unread = 0;
|
||||||
|
for (const t of threads) {
|
||||||
|
try {
|
||||||
|
const msgs = await chatApi.listMessages(t._key);
|
||||||
|
const last = msgs[msgs.length - 1];
|
||||||
|
if (!last) continue;
|
||||||
|
if (last.author_email === userEmail) continue;
|
||||||
|
const readAt = readMap[t._key];
|
||||||
|
if (!readAt || last.created_at > readAt) unread++;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
setChatUnread(unread);
|
||||||
|
setQueueCount(items.filter((it) => (it as any).tenant_id === tenantId).length);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
void refresh();
|
||||||
|
const id = window.setInterval(refresh, 60_000);
|
||||||
|
return () => { cancelled = true; window.clearInterval(id); };
|
||||||
|
}, [isAuthed, tenantId, userEmail]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`shell shell-${scene}`}>
|
<div className={`shell shell-${scene}`}>
|
||||||
{scene !== "landing" && scene !== "login" && scene !== "sso-callback" && (
|
{scene !== "landing" && scene !== "login" && scene !== "sso-callback" && (
|
||||||
@@ -91,6 +131,10 @@ export default function App() {
|
|||||||
<button role="tab" aria-selected={scene === "mission"} className={`tab${scene === "mission" ? " tab-sel" : ""}`} onClick={() => setScene("mission")}>
|
<button role="tab" aria-selected={scene === "mission"} className={`tab${scene === "mission" ? " tab-sel" : ""}`} onClick={() => setScene("mission")}>
|
||||||
<Layers size={13} /> Mission
|
<Layers size={13} /> Mission
|
||||||
</button>
|
</button>
|
||||||
|
<button role="tab" aria-selected={scene === "approvals"} className={`tab${scene === "approvals" ? " tab-sel" : ""}`} onClick={() => setScene("approvals")}>
|
||||||
|
<Layers size={13} /> Approvals
|
||||||
|
{queueCount > 0 && <span className="tab-badge">{queueCount > 99 ? "99+" : queueCount}</span>}
|
||||||
|
</button>
|
||||||
<button role="tab" aria-selected={scene === "history"} className={`tab${scene === "history" ? " tab-sel" : ""}`} onClick={() => setScene("history")}>
|
<button role="tab" aria-selected={scene === "history"} className={`tab${scene === "history" ? " tab-sel" : ""}`} onClick={() => setScene("history")}>
|
||||||
<HistoryIcon size={13} /> Runs
|
<HistoryIcon size={13} /> Runs
|
||||||
</button>
|
</button>
|
||||||
@@ -99,6 +143,7 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
<button role="tab" aria-selected={scene === "chat"} className={`tab${scene === "chat" ? " tab-sel" : ""}`} onClick={() => setScene("chat")}>
|
<button role="tab" aria-selected={scene === "chat"} className={`tab${scene === "chat" ? " tab-sel" : ""}`} onClick={() => setScene("chat")}>
|
||||||
<Bot size={13} /> Chat
|
<Bot size={13} /> Chat
|
||||||
|
{chatUnread > 0 && <span className="tab-badge tab-badge-amber">{chatUnread > 99 ? "99+" : chatUnread}</span>}
|
||||||
</button>
|
</button>
|
||||||
<button role="tab" aria-selected={scene === "agent"} className={`tab${scene === "agent" ? " tab-sel" : ""}`} onClick={() => setScene("agent")}>
|
<button role="tab" aria-selected={scene === "agent"} className={`tab${scene === "agent" ? " tab-sel" : ""}`} onClick={() => setScene("agent")}>
|
||||||
<Bot size={13} /> Assistant
|
<Bot size={13} /> Assistant
|
||||||
|
|||||||
@@ -2225,3 +2225,15 @@ select.studio-input { background: var(--bp-paper); }
|
|||||||
.wizard-body { padding: 0 12px 12px; }
|
.wizard-body { padding: 0 12px 12px; }
|
||||||
.wizard-preview { position: static; max-height: none; }
|
.wizard-preview { position: static; max-height: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-badge {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
margin-left: 4px;
|
||||||
|
min-width: 18px; padding: 0 5px; height: 16px;
|
||||||
|
background: var(--bp-navy); color: var(--bp-paper);
|
||||||
|
font-family: var(--bp-mono); font-size: 9px; font-weight: 700; line-height: 1;
|
||||||
|
}
|
||||||
|
.tab-badge-amber { background: var(--bp-amber); color: var(--bp-paper); }
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.tab-badge { min-width: 14px; height: 12px; font-size: 8px; padding: 0 3px; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user