feat(11-04): extend AgentSkillsTab with groups section and dialogs
- Add imports: skillGroupsApi, SkillGroupRow, GroupBadge, Dialog, Separator, ScrollArea, Textarea - Add agentGroups + allGroups + agentEffectiveSkills queries in AgentSkillsTab - Add assignGroup + removeGroup + createGroup mutations - Add state for add/create/remove dialogs, search, new group fields - Insert Assigned Groups section with loading/empty/populated states - Insert Combined Effective Skills collapsible section with ScrollArea - Insert Additional Individual Skills label above existing skill list - Add Add Skill Group, Create Skill Group, Remove group from agent dialogs - Add Skill Groups section to DesignGuide with all GroupBadge variants
This commit is contained in:
parent
cb8423c400
commit
e495d98e48
2 changed files with 399 additions and 0 deletions
|
|
@ -9,6 +9,7 @@ import {
|
|||
type AgentPermissionUpdate,
|
||||
} from "../api/agents";
|
||||
import { companySkillsApi } from "../api/companySkills";
|
||||
import { skillGroupsApi, type SkillGroupRow } from "../api/skillGroups";
|
||||
import { budgetsApi } from "../api/budgets";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
|
|
@ -75,6 +76,17 @@ import {
|
|||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { GroupBadge } from "../components/GroupBadge";
|
||||
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
||||
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
|
||||
import {
|
||||
|
|
@ -2400,6 +2412,63 @@ function AgentSkillsTab({
|
|||
},
|
||||
});
|
||||
|
||||
// Skill groups queries
|
||||
const agentGroupsQuery = useQuery({
|
||||
queryKey: queryKeys.skillGroups.agentGroups(agent.id),
|
||||
queryFn: () => skillGroupsApi.listAgentGroups(agent.id),
|
||||
});
|
||||
|
||||
const allGroupsQuery = useQuery({
|
||||
queryKey: queryKeys.skillGroups.list,
|
||||
queryFn: () => skillGroupsApi.listGroups(),
|
||||
});
|
||||
|
||||
const agentEffectiveSkillsQuery = useQuery({
|
||||
queryKey: queryKeys.skillGroups.agentSkills(agent.id),
|
||||
queryFn: () => skillGroupsApi.listAgentSkills(agent.id),
|
||||
});
|
||||
|
||||
// Group dialog state
|
||||
const [addGroupOpen, setAddGroupOpen] = useState(false);
|
||||
const [createGroupOpen, setCreateGroupOpen] = useState(false);
|
||||
const [removeGroupConfirm, setRemoveGroupConfirm] = useState<SkillGroupRow | null>(null);
|
||||
const [groupSearch, setGroupSearch] = useState("");
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [newGroupDesc, setNewGroupDesc] = useState("");
|
||||
const [effectiveOpen, setEffectiveOpen] = useState(false);
|
||||
|
||||
// Group mutations
|
||||
const assignGroupMut = useMutation({
|
||||
mutationFn: ({ groupId }: { groupId: string }) =>
|
||||
skillGroupsApi.assignGroup(agent.id, groupId, ""),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.skillGroups.agentGroups(agent.id) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.skillGroups.agentSkills(agent.id) });
|
||||
setAddGroupOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const removeGroupMut = useMutation({
|
||||
mutationFn: ({ groupId }: { groupId: string }) =>
|
||||
skillGroupsApi.removeGroup(agent.id, groupId, ""),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.skillGroups.agentGroups(agent.id) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.skillGroups.agentSkills(agent.id) });
|
||||
setRemoveGroupConfirm(null);
|
||||
},
|
||||
});
|
||||
|
||||
const createGroupMut = useMutation({
|
||||
mutationFn: () =>
|
||||
skillGroupsApi.createGroup({ name: newGroupName, description: newGroupDesc || undefined }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.skillGroups.list });
|
||||
setCreateGroupOpen(false);
|
||||
setNewGroupName("");
|
||||
setNewGroupDesc("");
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setSkillDraft([]);
|
||||
setLastSavedSkills([]);
|
||||
|
|
@ -2573,6 +2642,289 @@ function AgentSkillsTab({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ---- Assigned Groups Section ---- */}
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Assigned Groups
|
||||
</h3>
|
||||
|
||||
{agentGroupsQuery.isLoading ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Skeleton className="h-6 w-24 rounded-full" />
|
||||
<Skeleton className="h-6 w-28 rounded-full" />
|
||||
<Skeleton className="h-6 w-20 rounded-full" />
|
||||
</div>
|
||||
) : agentGroupsQuery.data?.length === 0 ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">No groups assigned</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add a skill group to install a bundle of skills for this agent.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TooltipProvider>
|
||||
{(agentGroupsQuery.data ?? []).map((group) => (
|
||||
<GroupBadge
|
||||
key={group.id}
|
||||
name={group.name}
|
||||
isBuiltin={group.isBuiltin === 1}
|
||||
onRemove={
|
||||
group.isBuiltin === 1
|
||||
? undefined
|
||||
: () => setRemoveGroupConfirm(group)
|
||||
}
|
||||
removing={
|
||||
removeGroupMut.isPending && removeGroupConfirm?.id === group.id
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setAddGroupOpen(true)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
Add Group
|
||||
</Button>
|
||||
|
||||
{agentGroupsQuery.isError && (
|
||||
<p className="text-sm text-destructive">Failed to load groups. Try again.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ---- Combined Effective Skills Section ---- */}
|
||||
<div className="space-y-3">
|
||||
<Collapsible open={effectiveOpen} onOpenChange={setEffectiveOpen}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Combined Effective Skills
|
||||
</h3>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs">
|
||||
{effectiveOpen
|
||||
? "Hide skills"
|
||||
: `Show ${agentEffectiveSkillsQuery.data?.length ?? 0} skills`}
|
||||
<ChevronDown
|
||||
className={cn("ml-1 h-3.5 w-3.5 transition-transform", effectiveOpen && "rotate-180")}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
{agentEffectiveSkillsQuery.isLoading ? (
|
||||
<div className="space-y-1.5 pt-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-36" />
|
||||
</div>
|
||||
) : agentEffectiveSkillsQuery.data?.length === 0 ? (
|
||||
<p className="pt-2 text-sm text-muted-foreground">
|
||||
No skills in assigned groups. Add skills to the group definitions first.
|
||||
</p>
|
||||
) : (
|
||||
<ScrollArea className="max-h-[300px] pt-2">
|
||||
<ul className="space-y-1">
|
||||
{(agentEffectiveSkillsQuery.data ?? []).map((skillId) => (
|
||||
<li key={skillId} className="text-sm text-muted-foreground font-mono">
|
||||
{skillId}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ---- Additional Individual Skills ---- */}
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Additional Individual Skills
|
||||
</h3>
|
||||
|
||||
{/* ---- Add Group Dialog ---- */}
|
||||
<Dialog open={addGroupOpen} onOpenChange={setAddGroupOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Skill Group</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder="Search groups..."
|
||||
value={groupSearch}
|
||||
onChange={(e) => setGroupSearch(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<div className="max-h-64 overflow-y-auto divide-y divide-border rounded-md border border-border">
|
||||
{(() => {
|
||||
const assignedIds = new Set((agentGroupsQuery.data ?? []).map((g) => g.id));
|
||||
const available = (allGroupsQuery.data ?? []).filter(
|
||||
(g) =>
|
||||
!assignedIds.has(g.id) &&
|
||||
g.name.toLowerCase().includes(groupSearch.toLowerCase()),
|
||||
);
|
||||
if (available.length === 0) {
|
||||
return (
|
||||
<p className="px-3 py-4 text-sm text-muted-foreground">
|
||||
{groupSearch ? "No groups match your search." : "No groups available to add."}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return available.map((group) => (
|
||||
<div key={group.id} className="flex items-center justify-between gap-3 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold truncate">{group.name}</p>
|
||||
{group.isBuiltin === 1 && (
|
||||
<p className="text-xs text-muted-foreground">built-in</p>
|
||||
)}
|
||||
{group.description && (
|
||||
<p className="text-xs text-muted-foreground truncate">{group.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={assignGroupMut.isPending}
|
||||
onClick={() => assignGroupMut.mutate({ groupId: group.id })}
|
||||
className="shrink-0 h-7 text-xs"
|
||||
>
|
||||
{assignGroupMut.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
"Assign group"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
{assignGroupMut.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to assign group. Check the server logs for details.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAddGroupOpen(false);
|
||||
setCreateGroupOpen(true);
|
||||
}}
|
||||
>
|
||||
New Group
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setAddGroupOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ---- Create Group Dialog ---- */}
|
||||
<Dialog open={createGroupOpen} onOpenChange={setCreateGroupOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Skill Group</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-semibold" htmlFor="new-group-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="new-group-name"
|
||||
placeholder="Group name"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-semibold" htmlFor="new-group-desc">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
id="new-group-desc"
|
||||
placeholder="Optional description"
|
||||
value={newGroupDesc}
|
||||
onChange={(e) => setNewGroupDesc(e.target.value)}
|
||||
rows={2}
|
||||
className="text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
{createGroupMut.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to create group. Check the server logs for details.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateGroupOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!newGroupName.trim() || createGroupMut.isPending}
|
||||
onClick={() => createGroupMut.mutate()}
|
||||
>
|
||||
{createGroupMut.isPending ? (
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
Create Group
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ---- Remove Group Confirmation Dialog ---- */}
|
||||
<Dialog
|
||||
open={removeGroupConfirm !== null}
|
||||
onOpenChange={(open) => { if (!open) setRemoveGroupConfirm(null); }}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove group from agent</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Removing{" "}
|
||||
<span className="font-semibold text-foreground">{removeGroupConfirm?.name}</span>{" "}
|
||||
will uninstall skills that are not used by any other assigned group.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRemoveGroupConfirm(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={removeGroupMut.isPending}
|
||||
onClick={() => {
|
||||
if (removeGroupConfirm) {
|
||||
removeGroupMut.mutate({ groupId: removeGroupConfirm.id });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{removeGroupMut.isPending ? (
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
Remove group
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isLoading ? (
|
||||
<PageSkeleton variant="list" />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import {
|
|||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -125,6 +126,7 @@ import { InlineEditor } from "@/components/InlineEditor";
|
|||
import { PageSkeleton } from "@/components/PageSkeleton";
|
||||
import { Identity } from "@/components/Identity";
|
||||
import { SkillCard } from "@/components/SkillCard";
|
||||
import { GroupBadge } from "@/components/GroupBadge";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Section wrapper */
|
||||
|
|
@ -1400,6 +1402,51 @@ export function DesignGuide() {
|
|||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* SKILL GROUPS */}
|
||||
{/* ============================================================ */}
|
||||
<Section title="Skill Groups">
|
||||
<SubSection title="GroupBadge — built-in (no dismiss)">
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<GroupBadge name="PM Essentials" isBuiltin={true} skillCount={5} />
|
||||
<GroupBadge name="Engineer Core" isBuiltin={true} skillCount={8} />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SubSection>
|
||||
<SubSection title="GroupBadge — custom (with dismiss)">
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<GroupBadge
|
||||
name="Creative"
|
||||
isBuiltin={false}
|
||||
skillCount={3}
|
||||
onRemove={() => undefined}
|
||||
/>
|
||||
<GroupBadge
|
||||
name="Frontend Tools"
|
||||
isBuiltin={false}
|
||||
skillCount={6}
|
||||
onRemove={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SubSection>
|
||||
<SubSection title="GroupBadge — loading state (removing)">
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<GroupBadge
|
||||
name="Creative"
|
||||
isBuiltin={false}
|
||||
skillCount={3}
|
||||
onRemove={() => undefined}
|
||||
removing={true}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SubSection>
|
||||
</Section>
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* KEYBOARD SHORTCUTS */}
|
||||
{/* ============================================================ */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue