feat: add Slack-like channel and DM messaging UX

- Channel pages now show message feed with compose bar instead of
  just member lists. Messages display with agent avatars, names,
  and timestamps. Collapsible info panel shows channel details.
- New /dm/[name] route for direct message conversations between
  agents with from→to indicators and status badges.
- Sidebar DM links now navigate to /dm/{name} instead of agent
  edit forms.
- Backend: add GetChannelMessages and GetDMMessages store/service
  methods with corresponding API handlers and routes.
- Makefile: build target now depends on web target so binary
  always embeds latest UI assets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Algis Dumbris
2026-03-14 06:46:07 +02:00
co-authored by Claude Opus 4.6
parent cad0138337
commit f5ae132ef4
13 changed files with 704 additions and 110 deletions
+36
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
@@ -198,6 +199,41 @@ func (h *ChannelsHandler) JoinChannel(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "joined"})
}
// ChannelMessages handles GET /api/channels/{name}/messages.
func (h *ChannelsHandler) ChannelMessages(w http.ResponseWriter, r *http.Request) {
_, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
name := chi.URLParam(r, "name")
ch, err := h.channelService.GetChannelByName(r.Context(), name)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Channel not found"))
return
}
limit := 100
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
msgs, err := h.msgService.GetChannelMessages(r.Context(), ch.ID, limit)
if err != nil {
h.logger.Error("get channel messages failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get messages"))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"messages": msgs,
"total": len(msgs),
})
}
// LeaveChannel handles POST /api/channels/{name}/leave.
func (h *ChannelsHandler) LeaveChannel(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
+45
View File
@@ -425,6 +425,51 @@ func (h *MessagesHandler) GetReplies(w http.ResponseWriter, r *http.Request) {
})
}
// DMMessages handles GET /api/agents/{name}/messages — returns DM messages with a specific agent.
func (h *MessagesHandler) DMMessages(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
peerAgent := chi.URLParam(r, "name")
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{"messages": []*messaging.Message{}, "total": 0})
return
}
agentNames := make([]string, len(ownedAgents))
for i, a := range ownedAgents {
agentNames[i] = a.Name
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 100
}
msgs, err := h.msgService.GetDMMessages(r.Context(), agentNames, peerAgent, limit)
if err != nil {
h.logger.Error("get dm messages failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get messages"))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"messages": msgs,
"total": len(msgs),
})
}
func (h *MessagesHandler) isAgentOwnedBy(r *http.Request, agentName string, ownerID int64) bool {
if agentName == "" {
return false
+2
View File
@@ -100,6 +100,7 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
r.Post("/api/agents", agentsHandler.RegisterAgent)
r.Delete("/api/agents/{name}", agentsHandler.DeleteAgent)
r.Post("/api/agents/{name}/revoke-key", agentsHandler.RevokeKey)
r.Get("/api/agents/{name}/messages", messagesHandler.DMMessages)
})
// API Keys
@@ -124,6 +125,7 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
r.Get("/api/channels", channelsHandler.ListChannels)
r.Get("/api/channels/{name}", channelsHandler.GetChannel)
r.Post("/api/channels", channelsHandler.CreateChannel)
r.Get("/api/channels/{name}/messages", channelsHandler.ChannelMessages)
r.Post("/api/channels/{name}/join", channelsHandler.JoinChannel)
r.Post("/api/channels/{name}/leave", channelsHandler.LeaveChannel)
})