diff --git a/application/storage/db/repositories/teams.py b/application/storage/db/repositories/teams.py index ac3a7f5b..136d9bcc 100644 --- a/application/storage/db/repositories/teams.py +++ b/application/storage/db/repositories/teams.py @@ -62,7 +62,12 @@ class TeamsRepository: """ SELECT t.*, CASE WHEN bool_or(m.role = 'team_admin') THEN 'team_admin' - ELSE 'team_member' END AS member_role + ELSE 'team_member' END AS member_role, + (SELECT count(DISTINCT mm.user_id) FROM team_members mm + WHERE mm.team_id = t.id) AS member_count, + (SELECT count(DISTINCT g.resource_id) + FROM team_resource_grants g + WHERE g.team_id = t.id) AS shared_count FROM teams t JOIN team_members m ON m.team_id = t.id WHERE m.user_id = :user_id diff --git a/frontend/src/agents/AgentCard.tsx b/frontend/src/agents/AgentCard.tsx index f1a9a0ae..cc12413b 100644 --- a/frontend/src/agents/AgentCard.tsx +++ b/frontend/src/agents/AgentCard.tsx @@ -175,6 +175,42 @@ export default function AgentCard({ iconHeight: 13, }, ], + // Agents shared with the user via a team. They don't own it, so only + // non-destructive, non-owner actions are offered: open the config + // (editors can save, viewers see it read-only) and pin for quick access. + // Logs / Export / Share / Move-to-folder / Delete stay owner-only. + team: [ + { + icon: Edit, + label: 'Edit', + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + if (agent.agent_type === 'workflow') { + navigate(`/agents/workflow/edit/${agent.id}`); + } else { + navigate(`/agents/edit/${agent.id}`); + } + }, + variant: 'default', + iconWidth: 14, + iconHeight: 14, + }, + ...(agent.status === 'published' + ? [ + { + icon: agent.pinned ? UnPin : Pin, + label: agent.pinned ? 'Unpin' : 'Pin agent', + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + togglePin(); + }, + variant: 'default' as const, + iconWidth: 18, + iconHeight: 18, + }, + ] + : []), + ], shared: [ { icon: Link, @@ -214,7 +250,9 @@ export default function AgentCard({ const menuOptions = menuOptionsConfig[section] || []; const handleClick = () => { - if (section === 'user') { + // Team-shared agents open/run exactly like the user's own published + // agents (the run + GetAgent routes authorize team grantees server-side). + if (section === 'user' || section === 'team') { if (agent.status === 'published') { dispatch(setSelectedAgent(agent)); navigate(agent.id ? `/agents/${agent.id}/c/new` : '/c/new'); @@ -378,6 +416,16 @@ export default function AgentCard({ ))} + {/* Team access badge — pinned to the top row, left of the ⋯ menu + (right-11 clears the 19px trigger at right-4) so the two align. */} + {agent.ownership === 'team' && ( + + + )}
userService.getAgents(token), + selectData: selectAgents, + updateAction: setAgents, + }, + { + id: 'shared' as const, + title: 'Discovered', + description: "Public agents you've opened via a link", + showNewAgentButton: false, + emptyStateDescription: "You haven't opened any public agents yet.", fetchAgents: (token: string | null) => userService.getSharedAgents(token), selectData: selectSharedAgents, updateAction: setSharedAgents, diff --git a/frontend/src/agents/hooks/useAgentSearch.ts b/frontend/src/agents/hooks/useAgentSearch.ts index 7a5edb9e..39819fd0 100644 --- a/frontend/src/agents/hooks/useAgentSearch.ts +++ b/frontend/src/agents/hooks/useAgentSearch.ts @@ -45,9 +45,28 @@ export function useAgentSearch(): UseAgentSearchResult { const [activeFilter, setActiveFilter] = useState('all'); const templateAgents = useSelector(selectTemplateAgents); - const userAgents = useSelector(selectAgents); + const allUserAgents = useSelector(selectAgents); const sharedAgents = useSelector(selectSharedAgents); + // /api/get_agents returns both the caller's own agents and agents shared + // with their teams (tagged ownership:'team'). Split them so "By me" shows + // only owned agents and "Shared with my team" gets its own section. + // Preserve the null (still-loading) state for both buckets. + const userAgents = useMemo( + () => + allUserAgents === null + ? null + : allUserAgents.filter((a) => a.ownership !== 'team'), + [allUserAgents], + ); + const teamAgents = useMemo( + () => + allUserAgents === null + ? null + : allUserAgents.filter((a) => a.ownership === 'team'), + [allUserAgents], + ); + const handleSearchChange = useCallback((query: string) => { setSearchQuery(query); }, []); @@ -60,24 +79,27 @@ export function useAgentSearch(): UseAgentSearchResult { (): Record => ({ template: templateAgents !== null, user: userAgents !== null, + team: teamAgents !== null, shared: sharedAgents !== null, }), - [templateAgents, userAgents, sharedAgents], + [templateAgents, userAgents, teamAgents, sharedAgents], ); const totalAgentsBySection = useMemo( (): Record => ({ template: templateAgents?.length ?? 0, user: userAgents?.length ?? 0, + team: teamAgents?.length ?? 0, shared: sharedAgents?.length ?? 0, }), - [templateAgents, userAgents, sharedAgents], + [templateAgents, userAgents, teamAgents, sharedAgents], ); const filteredAgentsBySection = useMemo((): AgentsBySection => { const filtered = { template: filterAgentsByQuery(templateAgents, searchQuery), user: filterAgentsByQuery(userAgents, searchQuery), + team: filterAgentsByQuery(teamAgents, searchQuery), shared: filterAgentsByQuery(sharedAgents, searchQuery), }; @@ -88,14 +110,23 @@ export function useAgentSearch(): UseAgentSearchResult { return { template: activeFilter === 'template' ? filtered.template : [], user: activeFilter === 'user' ? filtered.user : [], + team: activeFilter === 'team' ? filtered.team : [], shared: activeFilter === 'shared' ? filtered.shared : [], }; - }, [templateAgents, userAgents, sharedAgents, searchQuery, activeFilter]); + }, [ + templateAgents, + userAgents, + teamAgents, + sharedAgents, + searchQuery, + activeFilter, + ]); const hasAnyAgents = useMemo(() => { return ( totalAgentsBySection.template > 0 || totalAgentsBySection.user > 0 || + totalAgentsBySection.team > 0 || totalAgentsBySection.shared > 0 ); }, [totalAgentsBySection]); @@ -104,6 +135,7 @@ export function useAgentSearch(): UseAgentSearchResult { return ( filteredAgentsBySection.template.length > 0 || filteredAgentsBySection.user.length > 0 || + filteredAgentsBySection.team.length > 0 || filteredAgentsBySection.shared.length > 0 ); }, [filteredAgentsBySection]); diff --git a/frontend/src/agents/hooks/useAgentsFetch.ts b/frontend/src/agents/hooks/useAgentsFetch.ts index eee4719d..66be5b6e 100644 --- a/frontend/src/agents/hooks/useAgentsFetch.ts +++ b/frontend/src/agents/hooks/useAgentsFetch.ts @@ -25,6 +25,9 @@ export function useAgentsFetch(): UseAgentsFetchResult { const [isLoading, setIsLoading] = useState>({ template: true, user: true, + // 'team' agents are split out of the same /api/get_agents payload, so + // their loading state tracks the user fetch. + team: true, shared: true, }); @@ -50,7 +53,7 @@ export function useAgentsFetch(): UseAgentsFetchResult { } catch (error) { dispatch(setAgents([])); } finally { - setIsLoading((prev) => ({ ...prev, user: false })); + setIsLoading((prev) => ({ ...prev, user: false, team: false })); } }, [token, dispatch]); @@ -79,7 +82,7 @@ export function useAgentsFetch(): UseAgentsFetchResult { }, [token, dispatch]); useEffect(() => { - setIsLoading({ template: true, user: true, shared: true }); + setIsLoading({ template: true, user: true, team: true, shared: true }); Promise.all([ fetchTemplateAgents(), fetchUserAgents(), @@ -89,7 +92,10 @@ export function useAgentsFetch(): UseAgentsFetchResult { }, [fetchTemplateAgents, fetchUserAgents, fetchSharedAgents, fetchFolders]); const isAllLoaded = - !isLoading.template && !isLoading.user && !isLoading.shared; + !isLoading.template && + !isLoading.user && + !isLoading.team && + !isLoading.shared; return { isLoading, diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 1919d41f..cb598b07 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -224,10 +224,18 @@ "backToTeams": "Back to teams", "createTeam": "Create a team", "createTeamDescription": "Give your team a name. You can add members and share resources once it's created.", + "editTeam": "Edit", "teamNamePlaceholder": "Team name", + "descriptionLabel": "Description", + "descriptionPlaceholder": "What's this team for? (optional)", "create": "Create", + "save": "Save", "createTeamError": "Could not create team.", "noTeams": "No teams yet.", + "noDescription": "No description", + "memberCountOne": "{{count}} member", + "memberCountOther": "{{count}} members", + "sharedCount": "{{count}} shared", "loadError": "Failed to load teams.", "roleAdmin": "admin", "roleMember": "member", @@ -241,6 +249,10 @@ "deleteTeam": "Delete team", "deleteTeamConfirmation": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.", "members": "Members", + "addMember": "Add member", + "addMemberTitle": "Add a member", + "addMemberDescription": "Invite someone by email. They must have signed in at least once.", + "teamActions": "Team actions", "memberEmailLabel": "Member email", "memberEmailPlaceholder": "name@example.com", "memberRoleLabel": "Role", @@ -891,25 +903,35 @@ "tryDifferentSearch": "Try a different search term", "filters": { "all": "All", - "byDocsGPT": "By DocsGPT", - "byMe": "By Me", - "shared": "Shared With Me" + "byDocsGPT": "Templates", + "byMe": "My agents", + "team": "Team", + "shared": "Discovered" + }, + "teamBadge": { + "editor": "Editor", + "viewer": "Viewer" }, "sections": { "template": { - "title": "By DocsGPT", + "title": "Templates", "description": "Agents provided by DocsGPT", "emptyState": "No template agents found." }, "user": { - "title": "By me", + "title": "My agents", "description": "Agents created or published by you", "emptyState": "You don't have any created agents yet." }, + "team": { + "title": "Team", + "description": "Agents your teammates shared with you", + "emptyState": "No agents have been shared with your team yet." + }, "shared": { - "title": "Shared with me", - "description": "Agents imported by using a public link", - "emptyState": "No shared agents found." + "title": "Discovered", + "description": "Public agents you've opened via a link", + "emptyState": "You haven't opened any public agents yet." } }, "form": { diff --git a/frontend/src/settings/Teams.tsx b/frontend/src/settings/Teams.tsx index 34a4d96c..2962f08a 100644 --- a/frontend/src/settings/Teams.tsx +++ b/frontend/src/settings/Teams.tsx @@ -4,8 +4,11 @@ import { ChevronRight, FileText, MessageSquare, + MoreVertical, + Pencil, Plus, Trash2, + Users, Wrench, } from 'lucide-react'; import { type ReactNode, useEffect, useRef, useState } from 'react'; @@ -23,6 +26,12 @@ import NoFilesIcon from '../assets/no-files.svg'; import SkeletonLoader from '../components/SkeletonLoader'; import { Avatar } from '../components/ui/avatar'; import { Button } from '../components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../components/ui/dropdown-menu'; import { Input } from '../components/ui/input'; import { Modal } from '../components/ui/modal'; import { @@ -109,8 +118,14 @@ export default function Teams() { const [newTeamName, setNewTeamName] = useState(''); const [createOpen, setCreateOpen] = useState(false); const [createError, setCreateError] = useState(null); + const [editOpen, setEditOpen] = useState(false); + const [editName, setEditName] = useState(''); + const [editDescription, setEditDescription] = useState(''); + const [editError, setEditError] = useState(null); const [newMemberEmail, setNewMemberEmail] = useState(''); const [newMemberRole, setNewMemberRole] = useState('team_member'); + const [addMemberOpen, setAddMemberOpen] = useState(false); + const [addMemberError, setAddMemberError] = useState(null); const [error, setError] = useState(null); const [deleteTeamModalState, setDeleteTeamModalState] = @@ -284,6 +299,42 @@ export default function Teams() { setCreateError(null); }; + const openEditModal = () => { + if (!selected) return; + setEditName(selected.name); + setEditDescription(selected.description ?? ''); + setEditError(null); + setEditOpen(true); + }; + + const closeEditModal = () => { + setEditOpen(false); + setEditError(null); + }; + + const handleEditSave = async () => { + if (!selected || !editName.trim()) return; + const name = editName.trim(); + const description = editDescription.trim(); + try { + const res = await teamsService.update( + selected.id, + { name, description }, + token, + ); + if (!res || res.success === false) { + setEditError(t('settings.teams.updateFailed')); + return; + } + // Reflect locally and refresh the list so the card/switcher update too. + setSelected({ ...selected, name, description }); + dispatch(loadTeams({ token })); + setEditOpen(false); + } catch { + setEditError(t('settings.teams.updateFailed')); + } + }; + // Consume navigation intent from the team switcher: "Manage team" passes // openTeamId (open that team's detail directly); "Create team" passes // create:true (pop the create modal). Consumed once, then the history state @@ -321,9 +372,21 @@ export default function Teams() { } }, [teams]); + const openAddMemberModal = () => { + setNewMemberEmail(''); + setNewMemberRole('team_member'); + setAddMemberError(null); + setAddMemberOpen(true); + }; + + const closeAddMemberModal = () => { + setAddMemberOpen(false); + setAddMemberError(null); + }; + const handleAddMember = async () => { if (!selected || !newMemberEmail.trim()) return; - setError(null); + setAddMemberError(null); try { const res = await teamsService.addMember( selected.id, @@ -333,13 +396,15 @@ export default function Teams() { if (res?.success === false) { // The backend returns 404 with a message when the email maps to no // known user; surface its message (e.g. "they must sign in first"). - setError(res.message ?? t('settings.teams.memberNotFound')); + setAddMemberError(res.message ?? t('settings.teams.memberNotFound')); return; } setNewMemberEmail(''); + setNewMemberRole('team_member'); + setAddMemberOpen(false); openTeam(selected); } catch { - setError(t('settings.teams.addMemberError')); + setAddMemberError(t('settings.teams.addMemberError')); } }; @@ -419,7 +484,7 @@ export default function Teams() { @@ -446,7 +511,7 @@ export default function Teams() { return (
-
+

{t('settings.teams.label')} @@ -462,7 +527,7 @@ export default function Teams() {

{!selected ? ( -
+
{teamsLoading ? ( ) : teamsError ? ( @@ -482,36 +547,66 @@ export default function Teams() { , ) ) : ( - teams.map((team) => ( - - )) +
+ {teams.map((team) => ( + + ))} +
)}
) : ( -
+
{isAdmin && ( - + + + + + + + + {t('settings.teams.editTeam')} + + requestDeleteTeam(selected)} + > + + {t('settings.teams.deleteTeam')} + + + )}
@@ -565,9 +679,22 @@ export default function Teams() { )}
-

- {t('settings.teams.members')} · {members.length} -

+
+

+ {t('settings.teams.members')} · {members.length} +

+ {isAdmin && ( + + )} +
{members.length === 0 ? ( emptyState(t('settings.teams.noMembers')) ) : ( @@ -580,7 +707,7 @@ export default function Teams() {
@@ -768,6 +852,122 @@ export default function Teams() { )} + (open ? setEditOpen(true) : closeEditModal())} + size="sm" + mobileVariant="sheet" + title={t('settings.teams.editTeam')} + footer={ + <> + + + + } + > +
+ setEditName(e.target.value)} + /> +
+ +