fix: notification bugs — human-agent-only counts, Svelte 5 lifecycle, API response parsing
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
- Use human agent's perspective only for unread counts (avoids system agent inflation) - Replace onDestroy + get() with $effect cleanup in channel/DM pages (Svelte 5 compat) - Fix notification store to parse array-of-objects API response format - Add last_read_message_id to DM messages endpoint - Fix test agent type seeding for GetHumanAgentForUser Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
eec06f5b5f
commit
5143e32f83
@@ -513,9 +513,13 @@ func (h *MessagesHandler) DMMessages(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Include last_read_message_id for the human agent's DM with the peer
|
||||
lastRead, _ := h.msgService.GetLastReadForDM(r.Context(), agentNames, peerAgent)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"messages": msgs,
|
||||
"total": len(msgs),
|
||||
"messages": msgs,
|
||||
"total": len(msgs),
|
||||
"last_read_message_id": lastRead,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -50,14 +50,10 @@ func (h *NotificationsHandler) UnreadCounts(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
|
||||
if err != nil {
|
||||
h.logger.Error("list agents failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents"))
|
||||
return
|
||||
}
|
||||
|
||||
if len(ownedAgents) == 0 {
|
||||
// Use only the human agent's perspective for notification counts.
|
||||
// This avoids system/AI agents inflating unread counts.
|
||||
humanAgent, err := h.agentService.GetHumanAgentForUser(r.Context(), ownerID)
|
||||
if err != nil || humanAgent == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"channels": []channelUnread{},
|
||||
"dms": []dmUnread{},
|
||||
@@ -66,63 +62,34 @@ func (h *NotificationsHandler) UnreadCounts(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
// Aggregate channel summaries across all owned agents
|
||||
channelMap := make(map[string]*channelUnread)
|
||||
for _, agent := range ownedAgents {
|
||||
if h.channelService == nil {
|
||||
break
|
||||
}
|
||||
summaries, err := h.channelService.GetChannelSummaries(r.Context(), agent.Name)
|
||||
// Channel summaries for the human agent
|
||||
channelsList := []channelUnread{}
|
||||
if h.channelService != nil {
|
||||
summaries, err := h.channelService.GetChannelSummaries(r.Context(), humanAgent.Name)
|
||||
if err != nil {
|
||||
h.logger.Error("get channel summaries failed", "agent", agent.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
for _, cs := range summaries {
|
||||
existing, ok := channelMap[cs.Name]
|
||||
if !ok {
|
||||
channelMap[cs.Name] = &channelUnread{
|
||||
h.logger.Error("get channel summaries failed", "agent", humanAgent.Name, "error", err)
|
||||
} else {
|
||||
for _, cs := range summaries {
|
||||
channelsList = append(channelsList, channelUnread{
|
||||
Name: cs.Name,
|
||||
UnreadCount: cs.UnreadCount,
|
||||
LastMessageID: cs.LastMessageID,
|
||||
}
|
||||
} else {
|
||||
// Take the max unread count (different agents may see different counts)
|
||||
if cs.UnreadCount > existing.UnreadCount {
|
||||
existing.UnreadCount = cs.UnreadCount
|
||||
}
|
||||
if cs.LastMessageID > existing.LastMessageID {
|
||||
existing.LastMessageID = cs.LastMessageID
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
channelsList := make([]channelUnread, 0, len(channelMap))
|
||||
for _, cu := range channelMap {
|
||||
channelsList = append(channelsList, *cu)
|
||||
}
|
||||
|
||||
// Aggregate DM unread counts across all owned agents
|
||||
// DM unread counts for the human agent
|
||||
dmMap := make(map[string]*dmUnread)
|
||||
for _, agent := range ownedAgents {
|
||||
counts, err := h.msgService.GetDMUnreadCounts(r.Context(), agent.Name)
|
||||
if err != nil {
|
||||
h.logger.Error("get dm unread counts failed", "agent", agent.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
counts, err := h.msgService.GetDMUnreadCounts(r.Context(), humanAgent.Name)
|
||||
if err != nil {
|
||||
h.logger.Error("get dm unread counts failed", "agent", humanAgent.Name, "error", err)
|
||||
} else {
|
||||
for _, dc := range counts {
|
||||
existing, ok := dmMap[dc.Agent]
|
||||
if !ok {
|
||||
dmMap[dc.Agent] = &dmUnread{
|
||||
Agent: dc.Agent,
|
||||
UnreadCount: dc.UnreadCount,
|
||||
LastMessageID: dc.LastMessageID,
|
||||
}
|
||||
} else {
|
||||
existing.UnreadCount += dc.UnreadCount
|
||||
if dc.LastMessageID > existing.LastMessageID {
|
||||
existing.LastMessageID = dc.LastMessageID
|
||||
}
|
||||
dmMap[dc.Agent] = &dmUnread{
|
||||
Agent: dc.Agent,
|
||||
UnreadCount: dc.UnreadCount,
|
||||
LastMessageID: dc.LastMessageID,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,22 +143,13 @@ func (h *NotificationsHandler) MarkRead(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
|
||||
if err != nil {
|
||||
h.logger.Error("list agents failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents"))
|
||||
humanAgent, err := h.agentService.GetHumanAgentForUser(r.Context(), ownerID)
|
||||
if err != nil || humanAgent == nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody("no_agents", "No human agent found"))
|
||||
return
|
||||
}
|
||||
|
||||
if len(ownedAgents) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody("no_agents", "No agents registered"))
|
||||
return
|
||||
}
|
||||
|
||||
agentNames := make([]string, len(ownedAgents))
|
||||
for i, a := range ownedAgents {
|
||||
agentNames[i] = a.Name
|
||||
}
|
||||
agentNames := []string{humanAgent.Name}
|
||||
|
||||
if req.Type == "channel" {
|
||||
if h.channelService == nil {
|
||||
@@ -205,7 +163,6 @@ func (h *NotificationsHandler) MarkRead(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// Get conversation IDs for messages in this channel up to the given message ID
|
||||
convIDs, err := h.msgService.GetConversationIDsForChannel(r.Context(), ch.ID, req.LastMessageID)
|
||||
if err != nil {
|
||||
h.logger.Error("get conversation ids failed", "error", err)
|
||||
@@ -213,16 +170,13 @@ func (h *NotificationsHandler) MarkRead(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// Update inbox state for all owned agents on all relevant conversations
|
||||
for _, agentName := range agentNames {
|
||||
for _, convID := range convIDs {
|
||||
if err := h.msgService.UpdateInboxState(r.Context(), agentName, convID, req.LastMessageID); err != nil {
|
||||
h.logger.Error("update inbox state failed",
|
||||
"agent", agentName,
|
||||
"conversation_id", convID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
for _, convID := range convIDs {
|
||||
if err := h.msgService.UpdateInboxState(r.Context(), humanAgent.Name, convID, req.LastMessageID); err != nil {
|
||||
h.logger.Error("update inbox state failed",
|
||||
"agent", humanAgent.Name,
|
||||
"conversation_id", convID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -234,15 +188,13 @@ func (h *NotificationsHandler) MarkRead(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
for _, agentName := range agentNames {
|
||||
for _, convID := range convIDs {
|
||||
if err := h.msgService.UpdateInboxState(r.Context(), agentName, convID, req.LastMessageID); err != nil {
|
||||
h.logger.Error("update inbox state failed",
|
||||
"agent", agentName,
|
||||
"conversation_id", convID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
for _, convID := range convIDs {
|
||||
if err := h.msgService.UpdateInboxState(r.Context(), humanAgent.Name, convID, req.LastMessageID); err != nil {
|
||||
h.logger.Error("update inbox state failed",
|
||||
"agent", humanAgent.Name,
|
||||
"conversation_id", convID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ func setupNotificationsRouter(t *testing.T) (chi.Router, *messaging.MessagingSer
|
||||
channelStore := channels.NewSQLiteChannelStore(db)
|
||||
channelService := channels.NewService(channelStore, msgService, nil)
|
||||
|
||||
// Seed agents
|
||||
seedTestAgent(t, db, "human-agent", 1)
|
||||
// Seed agents — human-agent must be type 'human' for GetHumanAgentForUser
|
||||
seedTestAgentWithType(t, db, "human-agent", "human", 1)
|
||||
seedTestAgent(t, db, "bot-alice", 2)
|
||||
seedTestAgent(t, db, "bot-bob", 2)
|
||||
|
||||
|
||||
Vendored
+9
-9
@@ -8,29 +8,29 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link href="/_app/immutable/entry/start.ClBvSxrP.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BOwfncGc.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DqlV_t7d.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/start.DpHKCwmv.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BRBotovi.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DBeLgT1-.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/SAcaBy3_.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DL-Ee-iM.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/VWBPw6n7.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BaoHVn2U.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.D00BGz8q.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BCvik_Lu.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BdrVqzRy.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.B_lhmyMs.js" rel="modulepreload">
|
||||
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">
|
||||
<script>
|
||||
{
|
||||
__sveltekit_ra3pf6 = {
|
||||
__sveltekit_vhg0t8 = {
|
||||
base: ""
|
||||
};
|
||||
|
||||
const element = document.currentScript.parentElement;
|
||||
|
||||
Promise.all([
|
||||
import("/_app/immutable/entry/start.ClBvSxrP.js"),
|
||||
import("/_app/immutable/entry/app.D00BGz8q.js")
|
||||
import("/_app/immutable/entry/start.DpHKCwmv.js"),
|
||||
import("/_app/immutable/entry/app.B_lhmyMs.js")
|
||||
]).then(([kit, app]) => {
|
||||
kit.start(app, element);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface UnreadCounts {
|
||||
channels: Map<string, number>;
|
||||
@@ -28,9 +28,19 @@ function createNotificationStore() {
|
||||
try {
|
||||
const res = await fetch('/api/notifications/unread', { credentials: 'same-origin' });
|
||||
if (!res.ok) return;
|
||||
const data: { channels?: Record<string, number>; dms?: Record<string, number> } = await res.json();
|
||||
const channels = new Map(Object.entries(data.channels ?? {}));
|
||||
const dms = new Map(Object.entries(data.dms ?? {}));
|
||||
const data = await res.json();
|
||||
const channels = new Map<string, number>();
|
||||
const dms = new Map<string, number>();
|
||||
if (Array.isArray(data.channels)) {
|
||||
for (const ch of data.channels) {
|
||||
if (ch.unread_count > 0) channels.set(ch.name, ch.unread_count);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(data.dms)) {
|
||||
for (const dm of data.dms) {
|
||||
if (dm.unread_count > 0) dms.set(dm.agent, dm.unread_count);
|
||||
}
|
||||
}
|
||||
const counts: UnreadCounts = { channels, dms, totalUnread: 0 };
|
||||
counts.totalUnread = recalcTotal(counts);
|
||||
set(counts);
|
||||
@@ -39,21 +49,6 @@ function createNotificationStore() {
|
||||
}
|
||||
},
|
||||
|
||||
/** Get unread count for a channel */
|
||||
channelUnread(name: string): number {
|
||||
return get({ subscribe }).channels.get(name) ?? 0;
|
||||
},
|
||||
|
||||
/** Get unread count for a DM agent */
|
||||
dmUnread(name: string): number {
|
||||
return get({ subscribe }).dms.get(name) ?? 0;
|
||||
},
|
||||
|
||||
/** Get total unread count */
|
||||
get totalUnread(): number {
|
||||
return get({ subscribe }).totalUnread;
|
||||
},
|
||||
|
||||
/** Increment unread count for a channel or DM */
|
||||
incrementUnread(type: 'channel' | 'dm', target: string) {
|
||||
update((counts) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { channels as channelsApi, messages as messagesApi, agents as agentsApi } from '$lib/api/client';
|
||||
import { openThread, closeThread } from '$lib/stores/thread';
|
||||
import { notifications } from '$lib/stores/notifications';
|
||||
@@ -61,12 +60,13 @@
|
||||
|
||||
function startMarkReadTimer() {
|
||||
clearMarkReadTimer();
|
||||
const unread = notifications.channelUnread(channelName);
|
||||
if (unread > 0 && messageList.length > 0) {
|
||||
if (lastReadMessageId !== null && messageList.length > 0) {
|
||||
const lastMsgId = messageList[messageList.length - 1]?.id;
|
||||
markReadTimer = setTimeout(() => {
|
||||
notifications.markAsRead('channel', channelName, lastMsgId);
|
||||
}, 2000);
|
||||
if (lastMsgId > lastReadMessageId) {
|
||||
markReadTimer = setTimeout(() => {
|
||||
notifications.markAsRead('channel', channelName, lastMsgId);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +77,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearMarkReadTimer();
|
||||
// Cleanup timer on component destroy
|
||||
$effect(() => {
|
||||
return () => clearMarkReadTimer();
|
||||
});
|
||||
|
||||
function scrollToBottom() {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { agents as agentsApi, messages as messagesApi } from '$lib/api/client';
|
||||
import { openThread, closeThread } from '$lib/stores/thread';
|
||||
import { notifications } from '$lib/stores/notifications';
|
||||
@@ -53,12 +52,13 @@
|
||||
|
||||
function startMarkReadTimer() {
|
||||
clearMarkReadTimer();
|
||||
const unread = notifications.dmUnread(peerAgent);
|
||||
if (unread > 0 && messageList.length > 0) {
|
||||
if (lastReadMessageId !== null && messageList.length > 0) {
|
||||
const lastMsgId = messageList[messageList.length - 1]?.id;
|
||||
markReadTimer = setTimeout(() => {
|
||||
notifications.markAsRead('dm', peerAgent, lastMsgId);
|
||||
}, 2000);
|
||||
if (lastMsgId > lastReadMessageId) {
|
||||
markReadTimer = setTimeout(() => {
|
||||
notifications.markAsRead('dm', peerAgent, lastMsgId);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,8 +69,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearMarkReadTimer();
|
||||
$effect(() => {
|
||||
return () => clearMarkReadTimer();
|
||||
});
|
||||
|
||||
function scrollToBottom() {
|
||||
|
||||
Reference in New Issue
Block a user