feat(topbar): unread/queue badges + Approvals tab
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled

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:
2026-06-14 18:07:33 +04:00
parent d89660659f
commit 5a65408612
2 changed files with 58 additions and 1 deletions
+46 -1
View File
@@ -1,5 +1,5 @@
// 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 Landing from "./scenes/Landing";
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 [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 (
<div className={`shell shell-${scene}`}>
{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")}>
<Layers size={13} /> Mission
</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")}>
<HistoryIcon size={13} /> Runs
</button>
@@ -99,6 +143,7 @@ export default function App() {
</button>
<button role="tab" aria-selected={scene === "chat"} className={`tab${scene === "chat" ? " tab-sel" : ""}`} onClick={() => setScene("chat")}>
<Bot size={13} /> Chat
{chatUnread > 0 && <span className="tab-badge tab-badge-amber">{chatUnread > 99 ? "99+" : chatUnread}</span>}
</button>
<button role="tab" aria-selected={scene === "agent"} className={`tab${scene === "agent" ? " tab-sel" : ""}`} onClick={() => setScene("agent")}>
<Bot size={13} /> Assistant
+12
View File
@@ -2225,3 +2225,15 @@ select.studio-input { background: var(--bp-paper); }
.wizard-body { padding: 0 12px 12px; }
.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; }
}