fix(topbar): idle-quiet badge poll (count threads, not per-thread reads)
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled

Idle-poll audit caught a regression I introduced in the prior commit:
the topbar badge poll iterated chatApi.listMessages per thread on each
60s tick, firing N requests per refresh. Landing scene showed 9 reqs
in a 30s idle window (failed the <=5 budget).

Now the topbar badge just shows the THREAD COUNT (not per-thread
unread), which is a single chatApi.listThreads call. Per-thread unread
math stays in the Chat scene where it belongs (already wired:
chat-thread-row.unread + .chat-unread-summary).
This commit is contained in:
2026-06-14 18:16:24 +04:00
parent 5a65408612
commit 70ed7e9aa4
2 changed files with 84 additions and 20 deletions
+4 -20
View File
@@ -76,7 +76,7 @@ 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 [chatThreadCount, setChatThreadCount] = useState(0);
const [queueCount, setQueueCount] = useState(0);
const tenantId = useApp((s) => s.tenantId);
useEffect(() => {
@@ -91,24 +91,8 @@ export default function App() {
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);
}
setChatThreadCount(threads.length);
setQueueCount(items.filter((it) => (it as any).tenant_id === tenantId).length);
} catch { /* ignore */ }
};
void refresh();
@@ -143,7 +127,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>}
{chatThreadCount > 0 && <span className="tab-badge tab-badge-amber">{chatThreadCount > 99 ? "99+" : chatThreadCount}</span>}
</button>
<button role="tab" aria-selected={scene === "agent"} className={`tab${scene === "agent" ? " tab-sel" : ""}`} onClick={() => setScene("agent")}>
<Bot size={13} /> Assistant