mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 03:03:56 +02:00
Replace the floating chat FAB/window with a first-class Chat tab under Inbox, laid out as an IM-style two-pane surface (thread list + conversation). Highlights: - New Chat page (packages/views/chat/chat-page.tsx) with URL-addressable session selection; web + desktop routing wired up. Removes the old chat-fab / chat-window / resize-handles / context-items paths. - IM thread list: agent avatar + last-message preview + IM timestamp, red unread *count* badge (read-cursor model), presence-gated typing vs waiting. Rename lives only in the conversation header ⋯ menu (not the list hover). - Per-session conversation header (rename / view agent / delete), agent-aware empty state (avatar + name + description + starter prompts), and a deterministic clean-title derivation from the first message. - Server: read-cursor unread model (migration 145) and per-user pinned agents (migration 146, dedicated chat_pinned_agent table + handler/queries). New-agent welcome chat auto-enqueues a real agent run (LLM intro, no static template). - Design: fade the global --border token; borderless list headers on Chat/Inbox, kept (faded) on the conversation header. Verified: pnpm typecheck (all packages), go build ./..., go vet, gofmt. Co-authored-by: multica-agent <github@multica.ai>
170 lines
5.0 KiB
Go
170 lines
5.0 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// maxChatPinnedAgents caps the quick-agent bar so it stays compact.
|
|
const maxChatPinnedAgents = 5
|
|
|
|
// ChatPinnedAgentResponse is one entry in a user's "quick agent" bar.
|
|
type ChatPinnedAgentResponse struct {
|
|
AgentID string `json:"agent_id"`
|
|
Position float64 `json:"position"`
|
|
}
|
|
|
|
// resolveChatAgentAccess returns the set of agent ids the caller may use, or
|
|
// writes an error and returns ok=false. Mirrors ListChatSessions so pins can't
|
|
// surface or target agents the caller has lost access to.
|
|
func (h *Handler) resolveChatAgentAccess(w http.ResponseWriter, r *http.Request, userID, workspaceID string) (map[string]struct{}, bool) {
|
|
member, ok := h.workspaceMember(w, r, workspaceID)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
|
allowed, ok := h.accessibleAgentIDs(r.Context(), workspaceID, actorType, actorID, member.Role)
|
|
if !ok {
|
|
writeError(w, http.StatusInternalServerError, "failed to resolve agent access")
|
|
return nil, false
|
|
}
|
|
return allowed, true
|
|
}
|
|
|
|
// ListChatPinnedAgents returns the caller's pinned quick-chat agents, ordered.
|
|
// Pins for agents the caller can no longer access (archived / permissions
|
|
// changed) are dropped from the response.
|
|
func (h *Handler) ListChatPinnedAgents(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
workspaceID := ctxWorkspaceID(r.Context())
|
|
allowed, ok := h.resolveChatAgentAccess(w, r, userID, workspaceID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
rows, err := h.Queries.ListChatPinnedAgents(r.Context(), db.ListChatPinnedAgentsParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
UserID: parseUUID(userID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list pinned agents")
|
|
return
|
|
}
|
|
|
|
resp := make([]ChatPinnedAgentResponse, 0, len(rows))
|
|
for _, row := range rows {
|
|
agentID := uuidToString(row.AgentID)
|
|
if _, ok := allowed[agentID]; !ok {
|
|
continue
|
|
}
|
|
resp = append(resp, ChatPinnedAgentResponse{AgentID: agentID, Position: row.Position})
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// PinChatAgent adds an agent to the caller's quick-chat bar (append at end).
|
|
// Idempotent — pinning an already-pinned agent returns 200.
|
|
func (h *Handler) PinChatAgent(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
workspaceID := ctxWorkspaceID(r.Context())
|
|
|
|
var req struct {
|
|
AgentID string `json:"agent_id"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
agentUUID, ok := parseUUIDOrBadRequest(w, req.AgentID, "agent_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
allowed, ok := h.resolveChatAgentAccess(w, r, userID, workspaceID)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, ok := allowed[req.AgentID]; !ok {
|
|
writeError(w, http.StatusNotFound, "agent not found")
|
|
return
|
|
}
|
|
|
|
// Cap the quick-agent bar at a small, curated set. Re-pinning an already
|
|
// pinned agent stays idempotent and is allowed even at the cap.
|
|
existing, err := h.Queries.ListChatPinnedAgents(r.Context(), db.ListChatPinnedAgentsParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
UserID: parseUUID(userID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list pinned agents")
|
|
return
|
|
}
|
|
alreadyPinned := false
|
|
for _, e := range existing {
|
|
if uuidToString(e.AgentID) == req.AgentID {
|
|
alreadyPinned = true
|
|
break
|
|
}
|
|
}
|
|
if !alreadyPinned && len(existing) >= maxChatPinnedAgents {
|
|
writeError(w, http.StatusBadRequest, "pinned agent limit reached")
|
|
return
|
|
}
|
|
|
|
maxPos, err := h.Queries.GetMaxChatPinnedAgentPosition(r.Context(), db.GetMaxChatPinnedAgentPositionParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
UserID: parseUUID(userID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to get position")
|
|
return
|
|
}
|
|
|
|
row, err := h.Queries.CreateChatPinnedAgent(r.Context(), db.CreateChatPinnedAgentParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
UserID: parseUUID(userID),
|
|
AgentID: agentUUID,
|
|
Position: maxPos + 1,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to pin agent")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, ChatPinnedAgentResponse{
|
|
AgentID: uuidToString(row.AgentID),
|
|
Position: row.Position,
|
|
})
|
|
}
|
|
|
|
// UnpinChatAgent removes an agent from the caller's quick-chat bar.
|
|
func (h *Handler) UnpinChatAgent(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
workspaceID := ctxWorkspaceID(r.Context())
|
|
agentUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "agentId"), "agentId")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if err := h.Queries.DeleteChatPinnedAgent(r.Context(), db.DeleteChatPinnedAgentParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
UserID: parseUUID(userID),
|
|
AgentID: agentUUID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to unpin agent")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|