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:
viettranxandClaude Opus 4.6 committed 2026-02-28 23:23:48 +07:00
1 parent 2623c65321
commit 56827aee11
13 files changed
+317 -17

No files matched your search

+168
View File
@@ -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, &params); 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, &params); 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.
+3
View File
@@ -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
}
+8
View File
@@ -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) {
+3 -1
View File
@@ -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)
+2
View File
@@ -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",
+1 -1
View File
@@ -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>
+15 -1
View File
@@ -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>
+28 -3
View File
@@ -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">
+66 -2
View File
@@ -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>
);
}
+2 -2
View File
@@ -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 };
}
+2 -2
View File
@@ -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));