diff --git a/internal/api/messages_handler.go b/internal/api/messages_handler.go index 827e41f..db6b8ae 100644 --- a/internal/api/messages_handler.go +++ b/internal/api/messages_handler.go @@ -4,7 +4,6 @@ import ( "encoding/json" "log/slog" "net/http" - "sort" "strconv" "strings" "time" @@ -583,8 +582,8 @@ func (h *MessagesHandler) DMMessages(w http.ResponseWriter, r *http.Request) { } // DMPartners returns a list of agents the user has DM conversations with, -// ordered by most recent message. Each entry includes the peer agent name, -// last message preview, timestamp, and unread count. +// ordered by most recent message. Queries ALL messages (not just inbox) +// so historical conversations always appear. func (h *MessagesHandler) DMPartners(w http.ResponseWriter, r *http.Request) { ownerID, ok := OwnerIDFromContext(r.Context()) if !ok { @@ -603,86 +602,41 @@ func (h *MessagesHandler) DMPartners(w http.ResponseWriter, r *http.Request) { return } - ownedNames := make(map[string]bool, len(ownedAgents)) - for _, a := range ownedAgents { - ownedNames[a.Name] = true + agentNames := make([]string, len(ownedAgents)) + for i, a := range ownedAgents { + agentNames[i] = a.Name } - // Get recent DMs for all owned agents - type partner struct { + partners, err := h.msgService.GetDMPartners(r.Context(), agentNames) + if err != nil { + h.logger.Error("get dm partners failed", "error", err) + writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get DM partners")) + return + } + + // Resolve display names + type partnerWithDisplay struct { Name string `json:"name"` DisplayName string `json:"display_name"` LastMessage string `json:"last_message"` LastTime string `json:"last_time"` Unread int `json:"unread"` } - partnerMap := make(map[string]*partner) - - for _, agent := range ownedAgents { - opts := messaging.ReadOptions{ - Limit: 200, - IncludeRead: true, + result := make([]partnerWithDisplay, len(partners)) + for i, p := range partners { + result[i] = partnerWithDisplay{ + Name: p.Name, + DisplayName: p.Name, + LastMessage: p.LastMessage, + LastTime: p.LastTime, + Unread: p.Unread, } - result, err := h.msgService.ReadInbox(r.Context(), agent.Name, opts) - if err != nil { - continue - } - for _, msg := range result.Messages { - if msg.ChannelID != nil { - continue // skip channel messages - } - // Determine the peer (the other party in the DM) - peer := msg.FromAgent - if ownedNames[peer] { - peer = msg.ToAgent - } - if peer == "" || ownedNames[peer] { - continue // skip self-to-self - } - - existing, exists := partnerMap[peer] - if !exists { - partnerMap[peer] = &partner{ - Name: peer, - DisplayName: peer, - LastMessage: truncateStr(msg.Body, 80), - LastTime: msg.CreatedAt.Format(time.RFC3339), - Unread: 0, - } - existing = partnerMap[peer] - } - - // Track newest message - lt, _ := time.Parse(time.RFC3339, existing.LastTime) - if msg.CreatedAt.After(lt) { - existing.LastMessage = truncateStr(msg.Body, 80) - existing.LastTime = msg.CreatedAt.Format(time.RFC3339) - } - - // Count unread (pending messages TO owned agents) - if ownedNames[msg.ToAgent] && (msg.Status == "pending" || msg.Status == "processing") { - existing.Unread++ - } + if a, err := h.agentService.GetAgent(r.Context(), p.Name); err == nil { + result[i].DisplayName = a.DisplayName } } - // Resolve display names - for peer, p := range partnerMap { - if a, err := h.agentService.GetAgent(r.Context(), peer); err == nil { - p.DisplayName = a.DisplayName - } - } - - // Sort by last_time descending - partners := make([]*partner, 0, len(partnerMap)) - for _, p := range partnerMap { - partners = append(partners, p) - } - sort.Slice(partners, func(i, j int) bool { - return partners[i].LastTime > partners[j].LastTime - }) - - writeJSON(w, http.StatusOK, map[string]any{"partners": partners}) + writeJSON(w, http.StatusOK, map[string]any{"partners": result}) } func (h *MessagesHandler) isAgentOwnedBy(r *http.Request, agentName string, ownerID int64) bool { diff --git a/internal/messaging/service.go b/internal/messaging/service.go index baeccd4..6add5ec 100644 --- a/internal/messaging/service.go +++ b/internal/messaging/service.go @@ -531,6 +531,11 @@ func (s *MessagingService) GetDMMessages(ctx context.Context, ownedAgents []stri return messages, nil } +// GetDMPartners returns all DM conversation partners for the owned agents. +func (s *MessagingService) GetDMPartners(ctx context.Context, ownedAgents []string) ([]DMPartner, error) { + return s.store.GetDMPartners(ctx, ownedAgents) +} + // 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) diff --git a/internal/messaging/store.go b/internal/messaging/store.go index 90c3de0..9b3d81e 100644 --- a/internal/messaging/store.go +++ b/internal/messaging/store.go @@ -29,6 +29,7 @@ type MessageStore interface { GetChannelMessages(ctx context.Context, channelID int64, limit, offset int) ([]*Message, error) CountChannelMessages(ctx context.Context, channelID int64) (int, error) GetDMMessages(ctx context.Context, agents []string, peerAgent string, limit int) ([]*Message, error) + GetDMPartners(ctx context.Context, agents []string) ([]DMPartner, error) AgentExists(ctx context.Context, agentName string) (bool, error) CountPendingDMs(ctx context.Context, agentName string) (int64, error) GetPendingDMs(ctx context.Context, agentName string, limit int) ([]*Message, error) @@ -687,6 +688,94 @@ func (s *SQLiteMessageStore) GetDMMessages(ctx context.Context, agents []string, return scanMessages(rows) } +// GetDMPartners returns all unique DM conversation partners for the owned agents, +// with the most recent message preview and unread count. This queries ALL messages +// (not just inbox) to show historical conversations. +func (s *SQLiteMessageStore) GetDMPartners(ctx context.Context, agents []string) ([]DMPartner, error) { + if len(agents) == 0 { + return []DMPartner{}, nil + } + + placeholders := make([]string, len(agents)) + args := make([]any, 0, len(agents)*2) + for i, a := range agents { + placeholders[i] = "?" + args = append(args, a) + } + inClause := strings.Join(placeholders, ",") + // Duplicate args for the second IN clause + for _, a := range agents { + args = append(args, a) + } + + // Find all DM partners with latest message and unread count + query := fmt.Sprintf(` + SELECT + peer, + last_body, + last_time, + COALESCE(SUM(is_unread), 0) as unread + FROM ( + SELECT + CASE + WHEN from_agent IN (%s) THEN to_agent + ELSE from_agent + END as peer, + body as last_body, + created_at as last_time, + CASE + WHEN to_agent IN (%s) AND status IN ('pending', 'processing') THEN 1 + ELSE 0 + END as is_unread, + ROW_NUMBER() OVER ( + PARTITION BY CASE WHEN from_agent IN (%s) THEN to_agent ELSE from_agent END + ORDER BY created_at DESC + ) as rn + FROM messages + WHERE channel_id IS NULL + AND (from_agent IN (%s) OR to_agent IN (%s)) + ) sub + WHERE rn = 1 AND peer != '' AND peer NOT IN (%s) + GROUP BY peer + ORDER BY last_time DESC + LIMIT 50`, + inClause, inClause, inClause, inClause, inClause, inClause, + ) + + // Need 6 copies of the args for the 6 IN clauses + fullArgs := make([]any, 0, len(agents)*6) + for i := 0; i < 6; i++ { + for _, a := range agents { + fullArgs = append(fullArgs, a) + } + } + + rows, err := s.db.QueryContext(ctx, query, fullArgs...) + if err != nil { + return nil, fmt.Errorf("get dm partners: %w", err) + } + defer rows.Close() + + var partners []DMPartner + for rows.Next() { + var p DMPartner + var body sql.NullString + if err := rows.Scan(&p.Name, &body, &p.LastTime, &p.Unread); err != nil { + return nil, err + } + if body.Valid && len(body.String) > 80 { + p.LastMessage = body.String[:80] + "..." + } else if body.Valid { + p.LastMessage = body.String + } + partners = append(partners, p) + } + if partners == nil { + partners = []DMPartner{} + } + return partners, rows.Err() +} + func (s *SQLiteMessageStore) CountPendingDMs(ctx context.Context, agentName string) (int64, error) { var count int64 err := s.db.QueryRowContext(ctx, diff --git a/internal/messaging/types.go b/internal/messaging/types.go index 036a697..dc74a9c 100644 --- a/internal/messaging/types.go +++ b/internal/messaging/types.go @@ -101,3 +101,11 @@ type DMUnreadCount struct { UnreadCount int `json:"unread_count"` LastMessageID int64 `json:"last_message_id"` } + +// DMPartner represents a DM conversation partner with summary info. +type DMPartner struct { + Name string `json:"name"` + LastMessage string `json:"last_message"` + LastTime string `json:"last_time"` + Unread int `json:"unread"` +}