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' && (
+
+
+ {agent.team_access === 'editor'
+ ? t('agents.teamBadge.editor')
+ : t('agents.teamBadge.viewer')}
+
+ )}
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() {
{initialOf(memberLabel(m))}
@@ -638,49 +765,6 @@ export default function Teams() {
))}
)}
-
- {isAdmin && (
-
-
- setNewMemberEmail(e.target.value)}
- onKeyDown={(e) => e.key === 'Enter' && handleAddMember()}
- />
-
-
-
-
- )}
@@ -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)}
+ />
+
+
+
+
+ {editError && (
+
+ {editError}
+
+ )}
+
+
+
+ open ? setAddMemberOpen(true) : closeAddMemberModal()
+ }
+ size="sm"
+ mobileVariant="sheet"
+ title={t('settings.teams.addMemberTitle')}
+ description={t('settings.teams.addMemberDescription')}
+ footer={
+ <>
+
+
+ >
+ }
+ >
+
+
setNewMemberEmail(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleAddMember()}
+ />
+
+
+
+
+
+ {addMemberError && (
+
+ {addMemberError}
+
+ )}
+
+