mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 08:17:09 +00:00
feat: add team member management (add/remove) and fix UI issues
- Add teams.members.add and teams.members.remove RPC methods with bidirectional link management and cache invalidation - Add DeleteTeamLinksForAgent to AgentLinkStore for link cleanup - Add member add/remove UI in team detail Members tab - Widen agent create dialog (sm:max-w-4xl) to fix cramped layout - Fix combobox dropdown truncation (min-w-full instead of w-full) - Fix traces refresh button animation using isFetching instead of isLoading Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
2623c65321
commit
56827aee11
13 files changed
+317
-17
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -87,7 +87,7 @@ export function Combobox({
|
||||
/>
|
||||
)}
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-y-auto rounded-md border p-1 shadow-md">
|
||||
<div className="bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 max-h-60 min-w-full overflow-y-auto rounded-md border p-1 shadow-md">
|
||||
{filtered.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="sm:max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Agent</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -66,5 +66,19 @@ export function useTeams() {
|
||||
[ws],
|
||||
);
|
||||
|
||||
return { teams, loading, load, createTeam, deleteTeam, getTeam, getTeamTasks };
|
||||
const addMember = useCallback(
|
||||
async (teamId: string, agent: string) => {
|
||||
await ws.call(Methods.TEAMS_MEMBERS_ADD, { teamId, agent });
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
const removeMember = useCallback(
|
||||
async (teamId: string, agentId: string) => {
|
||||
await ws.call(Methods.TEAMS_MEMBERS_REMOVE, { teamId, agentId });
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
return { teams, loading, load, createTeam, deleteTeam, getTeam, getTeamTasks, addMember, removeMember };
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Users } from "lucide-react";
|
||||
import { Users, X } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { TeamMemberData } from "@/types/team";
|
||||
import { roleBadgeVariant } from "./member-utils";
|
||||
|
||||
interface MemberListProps {
|
||||
members: TeamMemberData[];
|
||||
onRemove?: (agentId: string) => void;
|
||||
}
|
||||
|
||||
export function MemberList({ members }: MemberListProps) {
|
||||
export function MemberList({ members, onRemove }: MemberListProps) {
|
||||
if (members.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
@@ -19,15 +20,16 @@ export function MemberList({ members }: MemberListProps) {
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border">
|
||||
<div className="grid grid-cols-[1fr_1fr_80px] items-center gap-2 border-b bg-muted/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<div className="grid grid-cols-[1fr_1fr_80px_40px] items-center gap-2 border-b bg-muted/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<span>Agent</span>
|
||||
<span>Frontmatter</span>
|
||||
<span>Role</span>
|
||||
<span />
|
||||
</div>
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.agent_id}
|
||||
className="grid grid-cols-[1fr_1fr_80px] items-center gap-2 border-b px-4 py-3 last:border-0"
|
||||
className="grid grid-cols-[1fr_1fr_80px_40px] items-center gap-2 border-b px-4 py-3 last:border-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="truncate text-sm font-medium">
|
||||
@@ -51,6 +53,18 @@ export function MemberList({ members }: MemberListProps) {
|
||||
<Badge variant={roleBadgeVariant(member.role)}>
|
||||
{member.role}
|
||||
</Badge>
|
||||
<div className="flex justify-center">
|
||||
{member.role !== "lead" && onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(member.agent_id)}
|
||||
className="rounded p-1 text-muted-foreground/50 transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
title="Remove member"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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<TeamData | null>(null);
|
||||
const [members, setMembers] = useState<TeamMemberData[]>([]);
|
||||
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 (
|
||||
<div className="p-6">
|
||||
@@ -93,7 +113,12 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="members" className="mt-4">
|
||||
<TeamMembersTab members={members} />
|
||||
<TeamMembersTab
|
||||
teamId={teamId}
|
||||
members={members}
|
||||
onAddMember={handleAddMember}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tasks" className="mt-4">
|
||||
|
||||
@@ -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<void>;
|
||||
onRemoveMember?: (agentId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<MemberList members={members} />
|
||||
{onAddMember && (
|
||||
<div className="space-y-2">
|
||||
<Label>Add Member</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Combobox
|
||||
value={selectedAgent}
|
||||
onChange={setSelectedAgent}
|
||||
options={availableAgents}
|
||||
placeholder={availableAgents.length === 0 ? "No available agents" : "Search agents..."}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9 gap-1"
|
||||
disabled={!availableAgents.some((a) => a.value === selectedAgent) || adding}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
{adding ? "Adding..." : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<MemberList members={members} onRemove={onRemoveMember} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user