feat: web UI notifications — unread badges, new message line, auto mark-as-read

Backend:
- GET /api/notifications/unread returns channel + DM unread counts
- POST /api/notifications/mark-read updates inbox_state for channels/DMs
- SSE broadcaster wired into message send for real-time push
- last_read_message_id added to channel/DM message responses

Frontend:
- Notification store tracks unread counts per channel/DM
- SSE listener for new_message and unread_update events
- Red circular badges in sidebar (Slack-style)
- "New messages" separator line in channel/DM views
- Auto mark-as-read after 2 seconds of viewing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Algis Dumbris
2026-03-15 09:18:36 +02:00
co-authored by Claude Opus 4.6
parent 68b7d946b3
commit eec06f5b5f
19 changed files with 1255 additions and 23 deletions
+109
View File
@@ -0,0 +1,109 @@
package api
import (
"context"
"log/slog"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/channels"
)
// NewMessageEvent is broadcast when a new message is sent.
type NewMessageEvent struct {
Channel string `json:"channel,omitempty"` // set for channel messages
FromAgent string `json:"from_agent,omitempty"` // set for DMs
ToAgent string `json:"to_agent,omitempty"` // set for DMs
MessageID int64 `json:"message_id"`
}
// UnreadUpdateEvent is broadcast when unread counts change (e.g. mark-read).
type UnreadUpdateEvent struct {
Channel string `json:"channel,omitempty"`
Agent string `json:"agent,omitempty"`
UnreadCount int `json:"unread_count"`
}
// EventBroadcaster broadcasts real-time events to connected SSE clients.
type EventBroadcaster interface {
BroadcastNewMessage(ctx context.Context, ownerID int64, event NewMessageEvent)
BroadcastUnreadUpdate(ctx context.Context, ownerID int64, event UnreadUpdateEvent)
}
// SSEBroadcaster implements EventBroadcaster using the SSEHub.
type SSEBroadcaster struct {
hub *SSEHub
agentService *agents.AgentService
channelService *channels.Service
logger *slog.Logger
}
// NewSSEBroadcaster creates a broadcaster that sends events via SSE.
func NewSSEBroadcaster(hub *SSEHub, agentService *agents.AgentService, channelService *channels.Service) *SSEBroadcaster {
return &SSEBroadcaster{
hub: hub,
agentService: agentService,
channelService: channelService,
logger: slog.Default().With("component", "api.broadcaster"),
}
}
// BroadcastNewMessage sends a new_message event to the given owner.
func (b *SSEBroadcaster) BroadcastNewMessage(_ context.Context, ownerID int64, event NewMessageEvent) {
b.hub.Broadcast(ownerID, SSEEvent{
Type: "new_message",
Data: event,
})
}
// BroadcastUnreadUpdate sends an unread_update event to the given owner.
func (b *SSEBroadcaster) BroadcastUnreadUpdate(_ context.Context, ownerID int64, event UnreadUpdateEvent) {
b.hub.Broadcast(ownerID, SSEEvent{
Type: "unread_update",
Data: event,
})
}
// BroadcastDM broadcasts a new_message event for a direct message.
// It resolves the recipient agent's owner and sends the event to them.
func (b *SSEBroadcaster) BroadcastDM(ctx context.Context, msg NewMessageEvent) {
if msg.ToAgent == "" {
return
}
agent, err := b.agentService.GetAgent(ctx, msg.ToAgent)
if err != nil {
b.logger.Debug("could not resolve recipient owner for SSE broadcast",
"to_agent", msg.ToAgent, "error", err)
return
}
b.BroadcastNewMessage(ctx, agent.OwnerID, msg)
}
// BroadcastChannelMessage broadcasts a new_message event for a channel message.
// It resolves all channel members' owners and sends the event to each unique owner.
func (b *SSEBroadcaster) BroadcastChannelMessage(ctx context.Context, channelID int64, msg NewMessageEvent) {
if b.channelService == nil {
return
}
members, err := b.channelService.GetMembers(ctx, channelID)
if err != nil {
b.logger.Debug("could not get channel members for SSE broadcast",
"channel_id", channelID, "error", err)
return
}
// Collect unique owner IDs to avoid duplicate broadcasts.
seen := make(map[int64]bool)
for _, m := range members {
agent, err := b.agentService.GetAgent(ctx, m.AgentName)
if err != nil {
continue
}
if !seen[agent.OwnerID] {
seen[agent.OwnerID] = true
b.BroadcastNewMessage(ctx, agent.OwnerID, msg)
}
}
}
+16 -3
View File
@@ -201,7 +201,7 @@ func (h *ChannelsHandler) JoinChannel(w http.ResponseWriter, r *http.Request) {
// ChannelMessages handles GET /api/channels/{name}/messages.
func (h *ChannelsHandler) ChannelMessages(w http.ResponseWriter, r *http.Request) {
_, ok := OwnerIDFromContext(r.Context())
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
@@ -228,9 +228,22 @@ func (h *ChannelsHandler) ChannelMessages(w http.ResponseWriter, r *http.Request
return
}
// Compute last_read_message_id across owned agents
var lastReadMessageID int64
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
if err == nil {
for _, agent := range ownedAgents {
lr, err := h.msgService.GetLastReadForChannel(r.Context(), agent.Name, ch.ID)
if err == nil && lr > lastReadMessageID {
lastReadMessageID = lr
}
}
}
writeJSON(w, http.StatusOK, map[string]any{
"messages": paginated.Messages,
"total": paginated.Total,
"messages": paginated.Messages,
"total": paginated.Total,
"last_read_message_id": lastReadMessageID,
})
}
+24
View File
@@ -18,9 +18,15 @@ import (
type MessagesHandler struct {
msgService *messaging.MessagingService
agentService *agents.AgentService
broadcaster *SSEBroadcaster
logger *slog.Logger
}
// SetBroadcaster sets the event broadcaster for real-time SSE notifications.
func (h *MessagesHandler) SetBroadcaster(b *SSEBroadcaster) {
h.broadcaster = b
}
// NewMessagesHandler creates a new messages handler.
func NewMessagesHandler(msgService *messaging.MessagingService, agentService *agents.AgentService) *MessagesHandler {
return &MessagesHandler{
@@ -309,6 +315,24 @@ func (h *MessagesHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
return
}
// Broadcast real-time event to connected SSE clients
if h.broadcaster != nil {
event := NewMessageEvent{
MessageID: msg.ID,
FromAgent: msg.FromAgent,
ToAgent: msg.ToAgent,
}
if msg.ChannelID != nil && h.broadcaster.channelService != nil {
ch, chErr := h.broadcaster.channelService.GetChannel(r.Context(), *msg.ChannelID)
if chErr == nil {
event.Channel = ch.Name
}
h.broadcaster.BroadcastChannelMessage(r.Context(), *msg.ChannelID, event)
} else {
h.broadcaster.BroadcastDM(r.Context(), event)
}
}
writeJSON(w, http.StatusCreated, msg)
}
+251
View File
@@ -0,0 +1,251 @@
package api
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/messaging"
)
// NotificationsHandler handles REST API requests for notification badges.
type NotificationsHandler struct {
msgService *messaging.MessagingService
agentService *agents.AgentService
channelService *channels.Service
logger *slog.Logger
}
// NewNotificationsHandler creates a new notifications handler.
func NewNotificationsHandler(msgService *messaging.MessagingService, agentService *agents.AgentService, channelService *channels.Service) *NotificationsHandler {
return &NotificationsHandler{
msgService: msgService,
agentService: agentService,
channelService: channelService,
logger: slog.Default().With("component", "api.notifications"),
}
}
// channelUnread is the JSON shape for a channel's unread info.
type channelUnread struct {
Name string `json:"name"`
UnreadCount int `json:"unread_count"`
LastMessageID int64 `json:"last_message_id"`
}
// dmUnread is the JSON shape for a DM peer's unread info.
type dmUnread struct {
Agent string `json:"agent"`
UnreadCount int `json:"unread_count"`
LastMessageID int64 `json:"last_message_id"`
}
// UnreadCounts handles GET /api/notifications/unread.
func (h *NotificationsHandler) UnreadCounts(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
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 {
writeJSON(w, http.StatusOK, map[string]any{
"channels": []channelUnread{},
"dms": []dmUnread{},
"total_unread": 0,
})
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)
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{
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
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
}
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
}
}
}
}
dmsList := make([]dmUnread, 0, len(dmMap))
for _, du := range dmMap {
dmsList = append(dmsList, *du)
}
totalUnread := 0
for _, cu := range channelsList {
totalUnread += cu.UnreadCount
}
for _, du := range dmsList {
totalUnread += du.UnreadCount
}
writeJSON(w, http.StatusOK, map[string]any{
"channels": channelsList,
"dms": dmsList,
"total_unread": totalUnread,
})
}
// MarkRead handles POST /api/notifications/mark-read.
func (h *NotificationsHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
var req struct {
Type string `json:"type"`
Target string `json:"target"`
LastMessageID int64 `json:"last_message_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_request", "Invalid JSON body"))
return
}
if req.Type == "" || req.Target == "" || req.LastMessageID <= 0 {
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "type, target, and last_message_id are required"))
return
}
if req.Type != "channel" && req.Type != "dm" {
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "type must be 'channel' or 'dm'"))
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 {
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
}
if req.Type == "channel" {
if h.channelService == nil {
writeJSON(w, http.StatusBadRequest, errorBody("not_available", "Channel service not available"))
return
}
ch, err := h.channelService.GetChannelByName(r.Context(), req.Target)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Channel not found"))
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)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get conversations"))
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,
)
}
}
}
} else {
// DM mark-read
convIDs, err := h.msgService.GetConversationIDsForDM(r.Context(), agentNames, req.Target, req.LastMessageID)
if err != nil {
h.logger.Error("get dm conversation ids failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get conversations"))
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,
)
}
}
}
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+373
View File
@@ -0,0 +1,373 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
_ "modernc.org/sqlite"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/messaging"
)
func setupNotificationsRouter(t *testing.T) (chi.Router, *messaging.MessagingService, *agents.AgentService, *channels.Service) {
t.Helper()
db := newTestDBFull(t)
msgStore := messaging.NewSQLiteMessageStore(db)
msgService := messaging.NewMessagingService(msgStore, nil)
agentStore := agents.NewSQLiteAgentStore(db)
agentService := agents.NewAgentService(agentStore, nil)
channelStore := channels.NewSQLiteChannelStore(db)
channelService := channels.NewService(channelStore, msgService, nil)
// Seed agents
seedTestAgent(t, db, "human-agent", 1)
seedTestAgent(t, db, "bot-alice", 2)
seedTestAgent(t, db, "bot-bob", 2)
handler := NewNotificationsHandler(msgService, agentService, channelService)
messagesHandler := NewMessagesHandler(msgService, agentService)
channelsHandler := NewChannelsHandler(channelService, agentService, msgService)
router := chi.NewRouter()
router.Group(func(r chi.Router) {
r.Use(OwnerAuthMiddleware)
r.Get("/api/notifications/unread", handler.UnreadCounts)
r.Post("/api/notifications/mark-read", handler.MarkRead)
r.Get("/api/channels/{name}/messages", channelsHandler.ChannelMessages)
r.Get("/api/agents/{name}/messages", messagesHandler.DMMessages)
})
return router, msgService, agentService, channelService
}
func TestUnreadCounts_Unauthenticated(t *testing.T) {
router, _, _, _ := setupNotificationsRouter(t)
req := httptest.NewRequest("GET", "/api/notifications/unread", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", rr.Code, http.StatusUnauthorized)
}
}
func TestUnreadCounts_NoAgents(t *testing.T) {
router, _, _, _ := setupNotificationsRouter(t)
// Owner 99 has no agents
req := httptest.NewRequest("GET", "/api/notifications/unread", nil)
req.Header.Set("X-Owner-ID", "99")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusOK, rr.Body.String())
}
var resp struct {
Channels []channelUnread `json:"channels"`
DMs []dmUnread `json:"dms"`
TotalUnread int `json:"total_unread"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Channels) != 0 {
t.Errorf("channels = %d, want 0", len(resp.Channels))
}
if len(resp.DMs) != 0 {
t.Errorf("dms = %d, want 0", len(resp.DMs))
}
if resp.TotalUnread != 0 {
t.Errorf("total_unread = %d, want 0", resp.TotalUnread)
}
}
func TestUnreadCounts_WithDMs(t *testing.T) {
router, msgService, _, _ := setupNotificationsRouter(t)
ctx := t.Context()
// bot-alice sends DMs to human-agent (owner 1)
_, err := msgService.SendMessage(ctx, "bot-alice", "human-agent", "Hello from Alice", messaging.SendOptions{Subject: "dm"})
if err != nil {
t.Fatalf("send message: %v", err)
}
_, err = msgService.SendMessage(ctx, "bot-alice", "human-agent", "Second message from Alice", messaging.SendOptions{Subject: "dm"})
if err != nil {
t.Fatalf("send message: %v", err)
}
// bot-bob sends a DM to human-agent
_, err = msgService.SendMessage(ctx, "bot-bob", "human-agent", "Hello from Bob", messaging.SendOptions{Subject: "dm"})
if err != nil {
t.Fatalf("send message: %v", err)
}
req := httptest.NewRequest("GET", "/api/notifications/unread", nil)
req.Header.Set("X-Owner-ID", "1")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusOK, rr.Body.String())
}
var resp struct {
Channels []channelUnread `json:"channels"`
DMs []dmUnread `json:"dms"`
TotalUnread int `json:"total_unread"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.DMs) != 2 {
t.Fatalf("dms = %d, want 2; body: %s", len(resp.DMs), rr.Body.String())
}
// Find alice and bob counts
dmsByAgent := make(map[string]dmUnread)
for _, dm := range resp.DMs {
dmsByAgent[dm.Agent] = dm
}
if alice, ok := dmsByAgent["bot-alice"]; !ok {
t.Error("expected DM from bot-alice")
} else if alice.UnreadCount != 2 {
t.Errorf("bot-alice unread = %d, want 2", alice.UnreadCount)
}
if bob, ok := dmsByAgent["bot-bob"]; !ok {
t.Error("expected DM from bot-bob")
} else if bob.UnreadCount != 1 {
t.Errorf("bot-bob unread = %d, want 1", bob.UnreadCount)
}
if resp.TotalUnread != 3 {
t.Errorf("total_unread = %d, want 3", resp.TotalUnread)
}
}
func TestMarkRead_DM(t *testing.T) {
router, msgService, _, _ := setupNotificationsRouter(t)
ctx := t.Context()
// bot-alice sends 3 DMs to human-agent
msg1, _ := msgService.SendMessage(ctx, "bot-alice", "human-agent", "Message 1", messaging.SendOptions{Subject: "dm"})
_, _ = msgService.SendMessage(ctx, "bot-alice", "human-agent", "Message 2", messaging.SendOptions{Subject: "dm"})
msg3, _ := msgService.SendMessage(ctx, "bot-alice", "human-agent", "Message 3", messaging.SendOptions{Subject: "dm"})
// Mark read up to msg1
body, _ := json.Marshal(map[string]any{
"type": "dm",
"target": "bot-alice",
"last_message_id": msg1.ID,
})
req := httptest.NewRequest("POST", "/api/notifications/mark-read", bytes.NewReader(body))
req.Header.Set("X-Owner-ID", "1")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("mark-read status = %d, want %d, body: %s", rr.Code, http.StatusOK, rr.Body.String())
}
// Check unread — should have 2 unread still
req = httptest.NewRequest("GET", "/api/notifications/unread", nil)
req.Header.Set("X-Owner-ID", "1")
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
var resp struct {
DMs []dmUnread `json:"dms"`
TotalUnread int `json:"total_unread"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.DMs) != 1 {
t.Fatalf("dms = %d, want 1; body: %s", len(resp.DMs), rr.Body.String())
}
if resp.DMs[0].UnreadCount != 2 {
t.Errorf("unread after mark-read = %d, want 2", resp.DMs[0].UnreadCount)
}
// Mark all read up to msg3
body, _ = json.Marshal(map[string]any{
"type": "dm",
"target": "bot-alice",
"last_message_id": msg3.ID,
})
req = httptest.NewRequest("POST", "/api/notifications/mark-read", bytes.NewReader(body))
req.Header.Set("X-Owner-ID", "1")
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("mark-read status = %d, want %d", rr.Code, http.StatusOK)
}
// Check unread — should be 0
req = httptest.NewRequest("GET", "/api/notifications/unread", nil)
req.Header.Set("X-Owner-ID", "1")
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
json.Unmarshal(rr.Body.Bytes(), &resp)
if resp.TotalUnread != 0 {
t.Errorf("total_unread after marking all read = %d, want 0; body: %s", resp.TotalUnread, rr.Body.String())
}
}
func TestMarkRead_Validation(t *testing.T) {
router, _, _, _ := setupNotificationsRouter(t)
tests := []struct {
name string
body map[string]any
want int
}{
{
name: "missing type",
body: map[string]any{"target": "foo", "last_message_id": 1},
want: http.StatusBadRequest,
},
{
name: "missing target",
body: map[string]any{"type": "dm", "last_message_id": 1},
want: http.StatusBadRequest,
},
{
name: "missing last_message_id",
body: map[string]any{"type": "dm", "target": "foo"},
want: http.StatusBadRequest,
},
{
name: "invalid type",
body: map[string]any{"type": "invalid", "target": "foo", "last_message_id": 1},
want: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.body)
req := httptest.NewRequest("POST", "/api/notifications/mark-read", bytes.NewReader(body))
req.Header.Set("X-Owner-ID", "1")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != tt.want {
t.Errorf("status = %d, want %d, body: %s", rr.Code, tt.want, rr.Body.String())
}
})
}
}
func TestDMMessages_IncludesLastRead(t *testing.T) {
router, msgService, _, _ := setupNotificationsRouter(t)
ctx := t.Context()
// Send DMs from bot-alice to human-agent
msg1, _ := msgService.SendMessage(ctx, "bot-alice", "human-agent", "Hello", messaging.SendOptions{Subject: "dm"})
_, _ = msgService.SendMessage(ctx, "bot-alice", "human-agent", "World", messaging.SendOptions{Subject: "dm"})
// Mark read up to msg1
body, _ := json.Marshal(map[string]any{
"type": "dm",
"target": "bot-alice",
"last_message_id": msg1.ID,
})
req := httptest.NewRequest("POST", "/api/notifications/mark-read", bytes.NewReader(body))
req.Header.Set("X-Owner-ID", "1")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("mark-read status = %d", rr.Code)
}
// GET DM messages should include last_read_message_id
req = httptest.NewRequest("GET", "/api/agents/bot-alice/messages", nil)
req.Header.Set("X-Owner-ID", "1")
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("dm messages status = %d, body: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
json.Unmarshal(rr.Body.Bytes(), &resp)
lastRead, ok := resp["last_read_message_id"]
if !ok {
t.Fatal("response missing last_read_message_id")
}
if int64(lastRead.(float64)) != msg1.ID {
t.Errorf("last_read_message_id = %v, want %d", lastRead, msg1.ID)
}
}
func TestChannelMessages_IncludesLastRead(t *testing.T) {
router, _, _, channelService := setupNotificationsRouter(t)
ctx := t.Context()
// Create a channel and have human-agent join
ch, err := channelService.CreateChannel(ctx, channels.CreateChannelRequest{
Name: "test-channel",
CreatedBy: "human-agent",
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
// Broadcast messages
msgs, err := channelService.BroadcastMessage(ctx, ch.ID, "human-agent", "Hello channel", 5, "")
if err != nil {
t.Fatalf("broadcast: %v", err)
}
// Mark read up to the first channel message
body, _ := json.Marshal(map[string]any{
"type": "channel",
"target": "test-channel",
"last_message_id": msgs[0].ID,
})
req := httptest.NewRequest("POST", "/api/notifications/mark-read", bytes.NewReader(body))
req.Header.Set("X-Owner-ID", "1")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("mark-read status = %d, body: %s", rr.Code, rr.Body.String())
}
// GET channel messages should include last_read_message_id
req = httptest.NewRequest("GET", "/api/channels/test-channel/messages", nil)
req.Header.Set("X-Owner-ID", "1")
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("channel messages status = %d, body: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
json.Unmarshal(rr.Body.Bytes(), &resp)
_, ok := resp["last_read_message_id"]
if !ok {
t.Fatal("response missing last_read_message_id")
}
}
+11
View File
@@ -85,6 +85,13 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
if cfg.MsgService != nil && cfg.AgentService != nil {
messagesHandler := NewMessagesHandler(cfg.MsgService, cfg.AgentService)
agentsHandler := NewAgentsHandler(cfg.AgentService, cfg.TraceStore, cfg.ChannelService)
notificationsHandler := NewNotificationsHandler(cfg.MsgService, cfg.AgentService, cfg.ChannelService)
// Wire up SSE broadcaster for real-time events
if cfg.SSEHub != nil {
broadcaster := NewSSEBroadcaster(cfg.SSEHub, cfg.AgentService, cfg.ChannelService)
messagesHandler.SetBroadcaster(broadcaster)
}
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
@@ -109,6 +116,10 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
r.Delete("/api/agents/{name}", agentsHandler.DeleteAgent)
r.Post("/api/agents/{name}/revoke-key", agentsHandler.RevokeKey)
r.Get("/api/agents/{name}/messages", messagesHandler.DMMessages)
// Notifications
r.Get("/api/notifications/unread", notificationsHandler.UnreadCounts)
r.Post("/api/notifications/mark-read", notificationsHandler.MarkRead)
})
// API Keys
+1
View File
@@ -16,6 +16,7 @@ type ChannelSummary struct {
ID int64 `json:"id"`
Name string `json:"name"`
UnreadCount int `json:"unread"`
LastMessageID int64 `json:"last_message_id"`
LastMessageAt *time.Time `json:"last_message_at"`
}
+2 -1
View File
@@ -381,6 +381,7 @@ func (s *SQLiteChannelStore) GetChannelSummaries(ctx context.Context, agentName
WHERE ist.agent_name = ? AND ist.conversation_id = m.conversation_id), 0)
AND m.from_agent != ?
) AS unread_count,
COALESCE((SELECT MAX(m3.id) FROM messages m3 WHERE m3.channel_id = c.id), 0) AS last_message_id,
(SELECT MAX(m2.created_at) FROM messages m2 WHERE m2.channel_id = c.id) AS last_message_at
FROM channels c
JOIN channel_members cm ON cm.channel_id = c.id AND cm.agent_name = ?
@@ -396,7 +397,7 @@ func (s *SQLiteChannelStore) GetChannelSummaries(ctx context.Context, agentName
for rows.Next() {
var cs ChannelSummary
var lastMsg sql.NullString
if err := rows.Scan(&cs.ID, &cs.Name, &cs.UnreadCount, &lastMsg); err != nil {
if err := rows.Scan(&cs.ID, &cs.Name, &cs.UnreadCount, &cs.LastMessageID, &lastMsg); err != nil {
return nil, fmt.Errorf("scan channel summary: %w", err)
}
if lastMsg.Valid {
+30
View File
@@ -459,6 +459,36 @@ func (s *MessagingService) GetDMMessages(ctx context.Context, ownedAgents []stri
return messages, nil
}
// GetDMUnreadCounts returns unread DM counts grouped by peer agent.
func (s *MessagingService) GetDMUnreadCounts(ctx context.Context, agentName string) ([]DMUnreadCount, error) {
return s.store.GetDMUnreadCounts(ctx, agentName)
}
// GetLastReadForChannel returns the last_read_message_id for an agent in a channel.
func (s *MessagingService) GetLastReadForChannel(ctx context.Context, agentName string, channelID int64) (int64, error) {
return s.store.GetLastReadForChannel(ctx, agentName, channelID)
}
// GetLastReadForDM returns the last_read_message_id for owned agents in a DM with a peer.
func (s *MessagingService) GetLastReadForDM(ctx context.Context, agentNames []string, peerAgent string) (int64, error) {
return s.store.GetLastReadForDM(ctx, agentNames, peerAgent)
}
// UpdateInboxState updates the read position for an agent in a conversation.
func (s *MessagingService) UpdateInboxState(ctx context.Context, agentName string, conversationID int64, lastReadMsgID int64) error {
return s.store.UpdateInboxState(ctx, agentName, conversationID, lastReadMsgID)
}
// GetConversationIDsForChannel returns conversation IDs in a channel with messages up to lastMessageID.
func (s *MessagingService) GetConversationIDsForChannel(ctx context.Context, channelID int64, lastMessageID int64) ([]int64, error) {
return s.store.GetConversationIDsForChannel(ctx, channelID, lastMessageID)
}
// GetConversationIDsForDM returns conversation IDs for DMs between owned agents and a peer.
func (s *MessagingService) GetConversationIDsForDM(ctx context.Context, agentNames []string, peerAgent string, lastMessageID int64) ([]int64, error) {
return s.store.GetConversationIDsForDM(ctx, agentNames, peerAgent, lastMessageID)
}
// GetConversation returns a conversation and its messages.
func (s *MessagingService) GetConversation(ctx context.Context, id int64) (*Conversation, []*Message, error) {
conv, err := s.store.GetConversation(ctx, id)
+183
View File
@@ -34,6 +34,11 @@ type MessageStore interface {
GetPendingDMs(ctx context.Context, agentName string, limit int) ([]*Message, error)
GetRecentMentions(ctx context.Context, agentName string, limit int) ([]*Message, error)
GetSystemNotifications(ctx context.Context, agentName string, limit int) ([]*Message, error)
GetDMUnreadCounts(ctx context.Context, agentName string) ([]DMUnreadCount, error)
GetLastReadForChannel(ctx context.Context, agentName string, channelID int64) (int64, error)
GetLastReadForDM(ctx context.Context, agentNames []string, peerAgent string) (int64, error)
GetConversationIDsForChannel(ctx context.Context, channelID int64, lastMessageID int64) ([]int64, error)
GetConversationIDsForDM(ctx context.Context, agentNames []string, peerAgent string, lastMessageID int64) ([]int64, error)
}
// SQLiteMessageStore implements MessageStore using SQLite.
@@ -773,6 +778,184 @@ func scanMessageFromRows(rows *sql.Rows) (*Message, error) {
return &msg, nil
}
// GetDMUnreadCounts returns unread DM counts grouped by peer agent.
// For each unique from_agent that has sent DMs to agentName, it computes
// how many messages have id > last_read_message_id (from inbox_state).
func (s *SQLiteMessageStore) GetDMUnreadCounts(ctx context.Context, agentName string) ([]DMUnreadCount, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT
m.from_agent,
COUNT(CASE WHEN m.id > COALESCE(
(SELECT ist.last_read_message_id FROM inbox_state ist
WHERE ist.agent_name = ? AND ist.conversation_id = m.conversation_id), 0)
THEN 1 END) AS unread_count,
MAX(m.id) AS last_message_id
FROM messages m
WHERE m.to_agent = ?
AND m.channel_id IS NULL
AND m.from_agent != 'system'
GROUP BY m.from_agent
ORDER BY m.from_agent`,
agentName, agentName,
)
if err != nil {
return nil, fmt.Errorf("get dm unread counts: %w", err)
}
defer rows.Close()
var results []DMUnreadCount
for rows.Next() {
var d DMUnreadCount
if err := rows.Scan(&d.Agent, &d.UnreadCount, &d.LastMessageID); err != nil {
return nil, fmt.Errorf("scan dm unread count: %w", err)
}
results = append(results, d)
}
if results == nil {
results = []DMUnreadCount{}
}
return results, rows.Err()
}
// GetLastReadForChannel returns the effective last_read_message_id for an agent
// across all conversations in a given channel.
func (s *SQLiteMessageStore) GetLastReadForChannel(ctx context.Context, agentName string, channelID int64) (int64, error) {
var lastRead sql.NullInt64
err := s.db.QueryRowContext(ctx,
`SELECT MIN(COALESCE(ist.last_read_message_id, 0))
FROM (SELECT DISTINCT conversation_id FROM messages WHERE channel_id = ?) conv
LEFT JOIN inbox_state ist ON ist.conversation_id = conv.conversation_id AND ist.agent_name = ?`,
channelID, agentName,
).Scan(&lastRead)
if err != nil {
return 0, fmt.Errorf("get last read for channel: %w", err)
}
if lastRead.Valid {
return lastRead.Int64, nil
}
return 0, nil
}
// GetLastReadForDM returns the effective last_read_message_id for an owned agent
// in DM conversations with a specific peer agent.
func (s *SQLiteMessageStore) GetLastReadForDM(ctx context.Context, agentNames []string, peerAgent string) (int64, error) {
if len(agentNames) == 0 {
return 0, nil
}
placeholders := make([]string, len(agentNames))
args := make([]any, 0, len(agentNames)*2+1)
for i, a := range agentNames {
placeholders[i] = "?"
args = append(args, a)
}
inClause := strings.Join(placeholders, ",")
// Find conversations between owned agents and peer agent (DMs only)
// and get the max last_read_message_id
query := fmt.Sprintf(
`SELECT COALESCE(MAX(ist.last_read_message_id), 0)
FROM inbox_state ist
WHERE ist.agent_name IN (%s)
AND ist.conversation_id IN (
SELECT DISTINCT m.conversation_id FROM messages m
WHERE m.channel_id IS NULL
AND ((m.from_agent IN (%s) AND m.to_agent = ?)
OR (m.from_agent = ? AND m.to_agent IN (%s)))
)`,
inClause, inClause, inClause,
)
for _, a := range agentNames {
args = append(args, a)
}
args = append(args, peerAgent, peerAgent)
for _, a := range agentNames {
args = append(args, a)
}
var lastRead int64
err := s.db.QueryRowContext(ctx, query, args...).Scan(&lastRead)
if err != nil {
return 0, fmt.Errorf("get last read for dm: %w", err)
}
return lastRead, nil
}
// GetConversationIDsForChannel returns conversation IDs in a channel with messages up to lastMessageID.
func (s *SQLiteMessageStore) GetConversationIDsForChannel(ctx context.Context, channelID int64, lastMessageID int64) ([]int64, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT DISTINCT conversation_id FROM messages
WHERE channel_id = ? AND id <= ?`,
channelID, lastMessageID,
)
if err != nil {
return nil, fmt.Errorf("get conversation ids for channel: %w", err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan conversation id: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// GetConversationIDsForDM returns conversation IDs for DMs between owned agents and a peer agent,
// with messages up to lastMessageID.
func (s *SQLiteMessageStore) GetConversationIDsForDM(ctx context.Context, agentNames []string, peerAgent string, lastMessageID int64) ([]int64, error) {
if len(agentNames) == 0 {
return nil, nil
}
placeholders := make([]string, len(agentNames))
args := make([]any, 0, len(agentNames)*2+2)
for i, a := range agentNames {
placeholders[i] = "?"
args = append(args, a)
}
inClause := strings.Join(placeholders, ",")
query := fmt.Sprintf(
`SELECT DISTINCT conversation_id FROM messages
WHERE channel_id IS NULL
AND id <= ?
AND ((from_agent IN (%s) AND to_agent = ?)
OR (from_agent = ? AND to_agent IN (%s)))`,
inClause, inClause,
)
// Reorder args: agentNames for first IN, lastMessageID, agentNames for second IN...
finalArgs := make([]any, 0, len(agentNames)*2+3)
finalArgs = append(finalArgs, lastMessageID)
for _, a := range agentNames {
finalArgs = append(finalArgs, a)
}
finalArgs = append(finalArgs, peerAgent, peerAgent)
for _, a := range agentNames {
finalArgs = append(finalArgs, a)
}
rows, err := s.db.QueryContext(ctx, query, finalArgs...)
if err != nil {
return nil, fmt.Errorf("get conversation ids for dm: %w", err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan conversation id: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// scanMessage scans a single message from sql.Row.
func scanMessage(row *sql.Row) (*Message, error) {
var msg Message
+7
View File
@@ -72,3 +72,10 @@ type InboxState struct {
LastReadMessageID int64 `json:"last_read_message_id"`
UpdatedAt time.Time `json:"updated_at"`
}
// DMUnreadCount holds the unread DM count for a specific peer agent.
type DMUnreadCount struct {
Agent string `json:"agent"`
UnreadCount int `json:"unread_count"`
LastMessageID int64 `json:"last_message_id"`
}
+9 -9
View File
@@ -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.B4zIRc6l.js" rel="modulepreload">
<link href="/_app/immutable/chunks/k7nCSttu.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DBeLgT1-.js" rel="modulepreload">
<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/chunks/SAcaBy3_.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DL-Ee-iM.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.UyBC-CXX.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">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_ymf88 = {
__sveltekit_ra3pf6 = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.B4zIRc6l.js"),
import("/_app/immutable/entry/app.UyBC-CXX.js")
import("/_app/immutable/entry/start.ClBvSxrP.js"),
import("/_app/immutable/entry/app.D00BGz8q.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
+8
View File
@@ -188,4 +188,12 @@ export const apiKeys = {
get: (id: number) => request<any>('GET', `/api/keys/${id}`)
};
// Notifications
export const notificationsApi = {
unread: () =>
request<{ channels: Record<string, number>; dms: Record<string, number> }>('GET', '/api/notifications/unread'),
markRead: (type: 'channel' | 'dm', target: string, lastMessageId?: number) =>
request<{ status: string }>('POST', '/api/notifications/mark-read', { type, target, last_message_id: lastMessageId })
};
export { ApiError };
+1 -1
View File
@@ -26,7 +26,7 @@ export class SSEClient {
};
// Listen for typed events
const eventTypes = ['connected', 'new_message', 'message_updated', 'agent_connected', 'agent_disconnected', 'heartbeat'];
const eventTypes = ['connected', 'new_message', 'message_updated', 'agent_connected', 'agent_disconnected', 'heartbeat', 'unread_update'];
for (const type of eventTypes) {
this.eventSource.addEventListener(type, (e: MessageEvent) => {
try {
+17 -5
View File
@@ -2,6 +2,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { user, logout } from '$lib/stores/auth';
import { notifications } from '$lib/stores/notifications';
import { channels as channelsApi, agents as agentsApi, deadLetters as deadLettersApi } from '$lib/api/client';
let channelList = $state<any[]>([]);
@@ -44,6 +45,10 @@
goto('/login');
}
function badgeText(count: number): string {
return count > 99 ? '99+' : String(count);
}
const adminLinks = [
{ href: '/agents', label: 'Agents' },
{ href: '/settings', label: 'Settings' }
@@ -152,6 +157,7 @@
<p class="px-3 py-1 text-xs text-text-secondary italic">No channels</p>
{:else}
{#each channelList as ch}
{@const chUnread = $notifications.channels.get(ch.name) ?? 0}
<a
href="/channels/{ch.name}"
class="sidebar-item {isActive('/channels/' + ch.name) ? 'sidebar-item-active' : ''}"
@@ -160,16 +166,19 @@
<svg class="w-4 h-4 flex-shrink-0 text-accent-purple" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span class="truncate">My Agents</span>
<span class="truncate {chUnread > 0 ? 'font-bold text-text-primary' : ''}">My Agents</span>
{:else}
<span class="text-text-secondary font-mono text-xs">#</span>
<span class="truncate">{ch.name}</span>
<span class="truncate {chUnread > 0 ? 'font-bold text-text-primary' : ''}">{ch.name}</span>
{/if}
{#if ch.is_private && !ch.name.startsWith('my-agents-')}
<svg class="w-3 h-3 text-text-secondary ml-auto flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<svg class="w-3 h-3 text-text-secondary {chUnread > 0 ? '' : 'ml-auto'} flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
</svg>
{/if}
{#if chUnread > 0}
<span class="ml-auto text-[10px] font-bold text-white bg-accent-red px-1.5 py-0.5 rounded-full min-w-[18px] text-center flex-shrink-0">{badgeText(chUnread)}</span>
{/if}
</a>
{/each}
{/if}
@@ -196,6 +205,7 @@
<p class="px-3 py-1 text-xs text-text-secondary italic">No agents</p>
{:else}
{#each agentList as agent}
{@const dmUnread = $notifications.dms.get(agent.name) ?? 0}
<a
href="/dm/{agent.name}"
class="sidebar-item {isActive('/dm/' + agent.name) ? 'sidebar-item-active' : ''}"
@@ -208,9 +218,11 @@
class="absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full border border-bg-secondary {agent.status === 'active' ? 'bg-accent-green' : 'bg-text-secondary'}"
></span>
</span>
<span class="truncate">{agent.display_name || agent.name}</span>
<span class="truncate {dmUnread > 0 ? 'font-bold text-text-primary' : ''}">{agent.display_name || agent.name}</span>
<span class="text-[9px] text-text-secondary flex-shrink-0">(you)</span>
{#if agent.type === 'ai'}
{#if dmUnread > 0}
<span class="ml-auto text-[10px] font-bold text-white bg-accent-red px-1.5 py-0.5 rounded-full min-w-[18px] text-center flex-shrink-0">{badgeText(dmUnread)}</span>
{:else if agent.type === 'ai'}
<span class="ml-auto text-[9px] font-mono text-accent-purple bg-accent-purple/10 px-1 rounded flex-shrink-0">AI</span>
{/if}
</a>
+108
View File
@@ -0,0 +1,108 @@
import { writable, get } from 'svelte/store';
export interface UnreadCounts {
channels: Map<string, number>;
dms: Map<string, number>;
totalUnread: number;
}
function createNotificationStore() {
const { subscribe, set, update } = writable<UnreadCounts>({
channels: new Map(),
dms: new Map(),
totalUnread: 0
});
function recalcTotal(counts: UnreadCounts): number {
let total = 0;
for (const v of counts.channels.values()) total += v;
for (const v of counts.dms.values()) total += v;
return total;
}
return {
subscribe,
/** Initialize from the backend GET /api/notifications/unread */
async initialize() {
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 counts: UnreadCounts = { channels, dms, totalUnread: 0 };
counts.totalUnread = recalcTotal(counts);
set(counts);
} catch {
// API may not be available yet — silently ignore
}
},
/** 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) => {
const map = type === 'channel' ? counts.channels : counts.dms;
map.set(target, (map.get(target) ?? 0) + 1);
counts.totalUnread = recalcTotal(counts);
return counts;
});
},
/** Set exact unread count for a channel or DM */
setUnread(type: 'channel' | 'dm', target: string, count: number) {
update((counts) => {
const map = type === 'channel' ? counts.channels : counts.dms;
if (count > 0) {
map.set(target, count);
} else {
map.delete(target);
}
counts.totalUnread = recalcTotal(counts);
return counts;
});
},
/** Mark a channel or DM as read — POST to backend and clear local count */
async markAsRead(type: 'channel' | 'dm', target: string, lastMessageId?: number) {
try {
await fetch('/api/notifications/mark-read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ type, target, last_message_id: lastMessageId })
});
} catch {
// Best effort — clear locally regardless
}
update((counts) => {
const map = type === 'channel' ? counts.channels : counts.dms;
map.delete(target);
counts.totalUnread = recalcTotal(counts);
return counts;
});
},
/** Reset all counts (e.g. on logout) */
reset() {
set({ channels: new Map(), dms: new Map(), totalUnread: 0 });
}
};
}
export const notifications = createNotificationStore();
+23
View File
@@ -4,16 +4,36 @@
import { goto } from '$app/navigation';
import { checkAuth, user, loading } from '$lib/stores/auth';
import { SSEClient } from '$lib/api/sse';
import { notifications } from '$lib/stores/notifications';
import Sidebar from '$lib/components/Sidebar.svelte';
import Header from '$lib/components/Header.svelte';
import ThreadPanel from '$lib/components/ThreadPanel.svelte';
let { children } = $props();
let sseClient: SSEClient | null = $state(null);
let sseUnsubscribe: (() => void) | null = $state(null);
let initialized = $state(false);
let isLoginPage = $derived($page.url.pathname === '/login');
function setupNotifications(client: SSEClient) {
notifications.initialize();
return client.onEvent((event) => {
if (event.type === 'new_message') {
const d = event.data;
if (d.channel_name) {
notifications.incrementUnread('channel', d.channel_name);
} else if (d.from_agent) {
notifications.incrementUnread('dm', d.from_agent);
}
} else if (event.type === 'unread_update') {
const d = event.data;
const type = d.type === 'channel' ? 'channel' : 'dm';
notifications.setUnread(type as 'channel' | 'dm', d.target, d.count ?? 0);
}
});
}
$effect(() => {
if (!initialized) {
initialized = true;
@@ -23,6 +43,7 @@
} else if (authenticated) {
sseClient = new SSEClient();
sseClient.connect();
sseUnsubscribe = setupNotifications(sseClient);
}
});
}
@@ -36,7 +57,9 @@
$effect(() => {
return () => {
sseUnsubscribe?.();
sseClient?.disconnect();
notifications.reset();
};
});
</script>
+41 -2
View File
@@ -1,7 +1,9 @@
<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';
let channel = $state<any>(null);
let members = $state<any[]>([]);
@@ -12,12 +14,16 @@
let joining = $state(false);
let showInfo = $state(false);
let leaveError = $state('');
let lastReadMessageId = $state<number | null>(null);
// Compose state
let body = $state('');
let sending = $state(false);
let sendError = $state('');
// Mark-as-read timer
let markReadTimer: ReturnType<typeof setTimeout> | null = null;
let channelName = $derived($page.params.name);
let messagesContainer: HTMLDivElement;
@@ -43,14 +49,38 @@
async function loadMessages() {
if (!channel) return;
try {
const res = await channelsApi.messages(channelName);
const res = await channelsApi.messages(channelName) as any;
messageList = res.messages;
lastReadMessageId = res.last_read_message_id ?? null;
scrollToBottom();
startMarkReadTimer();
} catch {
// handled
}
}
function startMarkReadTimer() {
clearMarkReadTimer();
const unread = notifications.channelUnread(channelName);
if (unread > 0 && messageList.length > 0) {
const lastMsgId = messageList[messageList.length - 1]?.id;
markReadTimer = setTimeout(() => {
notifications.markAsRead('channel', channelName, lastMsgId);
}, 2000);
}
}
function clearMarkReadTimer() {
if (markReadTimer !== null) {
clearTimeout(markReadTimer);
markReadTimer = null;
}
}
onDestroy(() => {
clearMarkReadTimer();
});
function scrollToBottom() {
requestAnimationFrame(() => {
if (messagesContainer) {
@@ -62,7 +92,9 @@
let _prevChannel = $state('');
$effect(() => {
if (channelName !== _prevChannel) {
clearMarkReadTimer();
_prevChannel = channelName;
lastReadMessageId = null;
closeThread();
loadChannel();
}
@@ -212,7 +244,14 @@
</div>
{:else}
<div class="py-2">
{#each messageList as msg (msg.id)}
{#each messageList as msg, i (msg.id)}
{#if lastReadMessageId !== null && msg.id > lastReadMessageId && (i === 0 || messageList[i - 1].id <= lastReadMessageId)}
<div class="flex items-center gap-3 px-5 py-1 my-1">
<div class="flex-1 h-px bg-accent-red/50"></div>
<span class="text-[11px] font-medium text-accent-red flex-shrink-0">New messages</span>
<div class="flex-1 h-px bg-accent-red/50"></div>
</div>
{/if}
<div class="group px-5 py-2 hover:bg-bg-tertiary/40 transition-colors relative">
<div class="flex gap-3">
<div class="w-9 h-9 rounded-lg {agentColor(msg.from_agent)} flex items-center justify-center text-sm font-bold text-white flex-shrink-0 mt-0.5">
+41 -2
View File
@@ -1,19 +1,25 @@
<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';
let peerAgent = $derived($page.params.name);
let peer = $state<any>(null);
let messageList = $state<any[]>([]);
let ownAgents = $state<any[]>([]);
let loadingData = $state(true);
let lastReadMessageId = $state<number | null>(null);
// Compose state
let body = $state('');
let sending = $state(false);
let sendError = $state('');
// Mark-as-read timer
let markReadTimer: ReturnType<typeof setTimeout> | null = null;
let messagesContainer: HTMLDivElement;
async function loadData() {
@@ -35,14 +41,38 @@
async function loadMessages() {
try {
const res = await agentsApi.messages(peerAgent);
const res = await agentsApi.messages(peerAgent) as any;
messageList = res.messages;
lastReadMessageId = res.last_read_message_id ?? null;
scrollToBottom();
startMarkReadTimer();
} catch {
// handled
}
}
function startMarkReadTimer() {
clearMarkReadTimer();
const unread = notifications.dmUnread(peerAgent);
if (unread > 0 && messageList.length > 0) {
const lastMsgId = messageList[messageList.length - 1]?.id;
markReadTimer = setTimeout(() => {
notifications.markAsRead('dm', peerAgent, lastMsgId);
}, 2000);
}
}
function clearMarkReadTimer() {
if (markReadTimer !== null) {
clearTimeout(markReadTimer);
markReadTimer = null;
}
}
onDestroy(() => {
clearMarkReadTimer();
});
function scrollToBottom() {
requestAnimationFrame(() => {
if (messagesContainer) {
@@ -54,7 +84,9 @@
let _prevPeer = $state('');
$effect(() => {
if (peerAgent !== _prevPeer) {
clearMarkReadTimer();
_prevPeer = peerAgent;
lastReadMessageId = null;
closeThread();
loadData();
}
@@ -180,7 +212,14 @@
</div>
{:else}
<div class="py-2">
{#each messageList as msg (msg.id)}
{#each messageList as msg, i (msg.id)}
{#if lastReadMessageId !== null && msg.id > lastReadMessageId && (i === 0 || messageList[i - 1].id <= lastReadMessageId)}
<div class="flex items-center gap-3 px-5 py-1 my-1">
<div class="flex-1 h-px bg-accent-red/50"></div>
<span class="text-[11px] font-medium text-accent-red flex-shrink-0">New messages</span>
<div class="flex-1 h-px bg-accent-red/50"></div>
</div>
{/if}
<div class="group px-5 py-2 hover:bg-bg-tertiary/40 transition-colors relative">
<div class="flex gap-3">
<div class="w-9 h-9 rounded-lg {agentColor(msg.from_agent)} flex items-center justify-center text-sm font-bold text-white flex-shrink-0 mt-0.5">