diff --git a/internal/gateway/methods/teams.go b/internal/gateway/methods/teams.go index 7ef4f5ad..8d7b47b1 100644 --- a/internal/gateway/methods/teams.go +++ b/internal/gateway/methods/teams.go @@ -31,6 +31,8 @@ func (m *TeamsMethods) Register(router *gateway.MethodRouter) { router.Register(protocol.MethodTeamsGet, m.handleGet) router.Register(protocol.MethodTeamsDelete, m.handleDelete) router.Register(protocol.MethodTeamsTaskList, m.handleTaskList) + router.Register(protocol.MethodTeamsMembersAdd, m.handleAddMember) + router.Register(protocol.MethodTeamsMembersRemove, m.handleRemoveMember) } // --- List --- @@ -294,6 +296,172 @@ func (m *TeamsMethods) handleTaskList(_ context.Context, client *gateway.Client, })) } +// --- Add Member --- + +type teamsAddMemberParams struct { + TeamID string `json:"teamId"` + Agent string `json:"agent"` // agent key or UUID +} + +func (m *TeamsMethods) handleAddMember(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.teamStore == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + return + } + + var params teamsAddMemberParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + if params.TeamID == "" || params.Agent == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId and agent are required")) + return + } + + teamID, err := uuid.Parse(params.TeamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) + return + } + + ctx := context.Background() + + // Validate team exists + team, err := m.teamStore.GetTeam(ctx, teamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "team not found: "+err.Error())) + return + } + + // Resolve agent + ag, err := resolveAgentInfo(m.agentStore, params.Agent) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent: "+err.Error())) + return + } + + // Prevent adding lead again + if ag.ID == team.LeadAgentID { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent is already the team lead")) + return + } + + // Add member + if err := m.teamStore.AddMember(ctx, teamID, ag.ID, store.TeamRoleMember); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to add member: "+err.Error())) + return + } + + // Auto-create bidirectional links between new member and existing members + if m.linkStore != nil { + existingMembers, _ := m.teamStore.ListMembers(ctx, teamID) + for _, member := range existingMembers { + if member.AgentID == ag.ID { + continue + } + memberAgent, err := m.agentStore.GetByID(ctx, member.AgentID) + if err != nil { + continue + } + m.autoCreateTeamLinks(ctx, teamID, []*store.AgentData{ag, memberAgent}, client.UserID()) + } + } + + // Invalidate caches for all team members + m.invalidateTeamCaches(ctx, teamID) + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{"ok": true})) +} + +// --- Remove Member --- + +type teamsRemoveMemberParams struct { + TeamID string `json:"teamId"` + AgentID string `json:"agentId"` // agent UUID +} + +func (m *TeamsMethods) handleRemoveMember(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.teamStore == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + return + } + + var params teamsRemoveMemberParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + if params.TeamID == "" || params.AgentID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId and agentId are required")) + return + } + + teamID, err := uuid.Parse(params.TeamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) + return + } + agentID, err := uuid.Parse(params.AgentID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid agentId")) + return + } + + ctx := context.Background() + + // Validate team exists and prevent removing the lead + team, err := m.teamStore.GetTeam(ctx, teamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "team not found: "+err.Error())) + return + } + if agentID == team.LeadAgentID { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot remove the team lead")) + return + } + + // Remove member + if err := m.teamStore.RemoveMember(ctx, teamID, agentID); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to remove member: "+err.Error())) + return + } + + // Clean up team-specific links + if m.linkStore != nil { + if err := m.linkStore.DeleteTeamLinksForAgent(ctx, teamID, agentID); err != nil { + slog.Warn("teams.members.remove: failed to clean up links", "error", err) + } + } + + // Invalidate caches for all remaining members + removed agent + m.invalidateTeamCaches(ctx, teamID) + if m.agentRouter != nil { + ag, err := m.agentStore.GetByID(ctx, agentID) + if err == nil { + m.agentRouter.InvalidateAgent(ag.AgentKey) + } + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{"ok": true})) +} + +// invalidateTeamCaches invalidates agent caches for all members of a team. +func (m *TeamsMethods) invalidateTeamCaches(ctx context.Context, teamID uuid.UUID) { + if m.agentRouter == nil { + return + } + members, err := m.teamStore.ListMembers(ctx, teamID) + if err != nil { + return + } + for _, member := range members { + if member.AgentKey != "" { + m.agentRouter.InvalidateAgent(member.AgentKey) + } + } +} + // --- helpers --- // autoCreateTeamLinks creates bidirectional agent_links between all team members. diff --git a/internal/store/agent_link_store.go b/internal/store/agent_link_store.go index 814695c8..7bc4cb73 100644 --- a/internal/store/agent_link_store.go +++ b/internal/store/agent_link_store.go @@ -71,4 +71,7 @@ type AgentLinkStore interface { // SearchDelegateTargetsByEmbedding performs vector similarity search over delegation targets. SearchDelegateTargetsByEmbedding(ctx context.Context, fromAgentID uuid.UUID, embedding []float32, limit int) ([]AgentLinkData, error) + + // DeleteTeamLinksForAgent removes all team-specific links involving an agent. + DeleteTeamLinksForAgent(ctx context.Context, teamID, agentID uuid.UUID) error } diff --git a/internal/store/pg/agent_links.go b/internal/store/pg/agent_links.go index 4710d116..44343d08 100644 --- a/internal/store/pg/agent_links.go +++ b/internal/store/pg/agent_links.go @@ -246,6 +246,14 @@ func (s *PGAgentLinkStore) SearchDelegateTargetsByEmbedding(ctx context.Context, return scanLinkRowsJoined(rows) } +func (s *PGAgentLinkStore) DeleteTeamLinksForAgent(ctx context.Context, teamID, agentID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM agent_links WHERE team_id = $1 AND (source_agent_id = $2 OR target_agent_id = $2)`, + teamID, agentID, + ) + return err +} + // --- scan helpers --- func scanLinkRow(row *sql.Row) (*store.AgentLinkData, error) { diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go index 03e59bcf..ce03c85d 100644 --- a/pkg/protocol/methods.go +++ b/pkg/protocol/methods.go @@ -103,7 +103,9 @@ const ( MethodTeamsCreate = "teams.create" MethodTeamsGet = "teams.get" MethodTeamsDelete = "teams.delete" - MethodTeamsTaskList = "teams.tasks.list" + MethodTeamsTaskList = "teams.tasks.list" + MethodTeamsMembersAdd = "teams.members.add" + MethodTeamsMembersRemove = "teams.members.remove" ) // Delegation history (managed mode) diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index f3fc9151..5e4a8305 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -129,6 +129,8 @@ export const Methods = { TEAMS_GET: "teams.get", TEAMS_DELETE: "teams.delete", TEAMS_TASK_LIST: "teams.tasks.list", + TEAMS_MEMBERS_ADD: "teams.members.add", + TEAMS_MEMBERS_REMOVE: "teams.members.remove", // Delegation history (managed mode) DELEGATIONS_LIST: "delegations.list", diff --git a/ui/web/src/components/ui/combobox.tsx b/ui/web/src/components/ui/combobox.tsx index 7f058967..d0ab6ad7 100644 --- a/ui/web/src/components/ui/combobox.tsx +++ b/ui/web/src/components/ui/combobox.tsx @@ -87,7 +87,7 @@ export function Combobox({ /> )} {open && filtered.length > 0 && ( -
+
{filtered.map((o) => ( + )} +
))} diff --git a/ui/web/src/pages/teams/team-detail-page.tsx b/ui/web/src/pages/teams/team-detail-page.tsx index 4388ded0..d99c4ffd 100644 --- a/ui/web/src/pages/teams/team-detail-page.tsx +++ b/ui/web/src/pages/teams/team-detail-page.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -16,11 +16,21 @@ interface TeamDetailPageProps { } export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) { - const { getTeam, getTeamTasks } = useTeams(); + const { getTeam, getTeamTasks, addMember, removeMember } = useTeams(); const [team, setTeam] = useState(null); const [members, setMembers] = useState([]); const [loading, setLoading] = useState(true); + const reload = useCallback(async () => { + try { + const res = await getTeam(teamId); + setTeam(res.team); + setMembers(res.members ?? []); + } catch { + // ignore + } + }, [teamId, getTeam]); + useEffect(() => { let cancelled = false; (async () => { @@ -40,6 +50,16 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) { return () => { cancelled = true; }; }, [teamId, getTeam]); + const handleAddMember = useCallback(async (agentId: string) => { + await addMember(teamId, agentId); + await reload(); + }, [teamId, addMember, reload]); + + const handleRemoveMember = useCallback(async (agentId: string) => { + await removeMember(teamId, agentId); + await reload(); + }, [teamId, removeMember, reload]); + if (loading || !team) { return (
@@ -93,7 +113,12 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) { - + diff --git a/ui/web/src/pages/teams/team-members-tab.tsx b/ui/web/src/pages/teams/team-members-tab.tsx index f0f92e11..3f120b14 100644 --- a/ui/web/src/pages/teams/team-members-tab.tsx +++ b/ui/web/src/pages/teams/team-members-tab.tsx @@ -1,14 +1,78 @@ +import { useState, useMemo, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Combobox } from "@/components/ui/combobox"; +import { Label } from "@/components/ui/label"; +import { UserPlus } from "lucide-react"; +import { useAgents } from "@/pages/agents/hooks/use-agents"; import type { TeamMemberData } from "@/types/team"; import { MemberList } from "./member-sections"; interface TeamMembersTabProps { + teamId: string; members: TeamMemberData[]; + onAddMember?: (agentId: string) => Promise; + onRemoveMember?: (agentId: string) => Promise; } -export function TeamMembersTab({ members }: TeamMembersTabProps) { +export function TeamMembersTab({ members, onAddMember, onRemoveMember }: TeamMembersTabProps) { + const { agents, refresh: refreshAgents } = useAgents(); + const [selectedAgent, setSelectedAgent] = useState(""); + const [adding, setAdding] = useState(false); + + useEffect(() => { + refreshAgents(); + }, [refreshAgents]); + + const memberIds = useMemo(() => new Set(members.map((m) => m.agent_id)), [members]); + + const availableAgents = useMemo( + () => + agents + .filter((a) => a.agent_type === "predefined" && a.status === "active" && !memberIds.has(a.id)) + .map((a) => ({ value: a.id, label: a.display_name || a.agent_key })), + [agents, memberIds], + ); + + const handleAdd = async () => { + if (!selectedAgent || !onAddMember) return; + setAdding(true); + try { + await onAddMember(selectedAgent); + setSelectedAgent(""); + } catch { + // error handled upstream + } finally { + setAdding(false); + } + }; + return (
- + {onAddMember && ( +
+ +
+
+ +
+ +
+
+ )} +
); } diff --git a/ui/web/src/pages/traces/hooks/use-traces.ts b/ui/web/src/pages/traces/hooks/use-traces.ts index 585e68d3..c170444e 100644 --- a/ui/web/src/pages/traces/hooks/use-traces.ts +++ b/ui/web/src/pages/traces/hooks/use-traces.ts @@ -20,7 +20,7 @@ export function useTraces(filters: TraceFilters = {}) { const queryKey = queryKeys.traces.list({ ...filters }); - const { data, isLoading: loading } = useQuery({ + const { data, isLoading: loading, isFetching } = useQuery({ queryKey, queryFn: async () => { const params: Record = {}; @@ -55,5 +55,5 @@ export function useTraces(filters: TraceFilters = {}) { [http], ); - return { traces, total, loading, refresh: invalidate, getTrace }; + return { traces, total, loading, fetching: isFetching, refresh: invalidate, getTrace }; } diff --git a/ui/web/src/pages/traces/traces-page.tsx b/ui/web/src/pages/traces/traces-page.tsx index b84c8e13..8544e945 100644 --- a/ui/web/src/pages/traces/traces-page.tsx +++ b/ui/web/src/pages/traces/traces-page.tsx @@ -20,12 +20,12 @@ export function TracesPage() { const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); - const { traces, total, loading, refresh, getTrace } = useTraces({ + const { traces, total, loading, fetching, refresh, getTrace } = useTraces({ agentId: appliedAgentFilter || undefined, limit: pageSize, offset: (page - 1) * pageSize, }); - const spinning = useMinLoading(loading); + const spinning = useMinLoading(fetching); const showSkeleton = useDeferredLoading(loading && traces.length === 0); const totalPages = Math.max(1, Math.ceil(total / pageSize));