diff --git a/apps/desktop/src/components/agents/role-templates.tsx b/apps/desktop/src/components/agents/role-templates.tsx new file mode 100644 index 00000000..6e9f4b80 --- /dev/null +++ b/apps/desktop/src/components/agents/role-templates.tsx @@ -0,0 +1,217 @@ +import { useMemo, useState } from "react"; +import { motion } from "motion/react"; +import { CalendarClock, Layers, Plus, Search, SearchX, Sparkles, X } from "lucide-react"; +import type { AgentTemplate, AgentTemplateCategory } from "@godmode/shared"; +import { AGENT_TEMPLATE_CATEGORIES, AGENT_TEMPLATE_CATEGORY_LABELS } from "@godmode/shared"; +import { AgentAvatar, EmptyState } from "@/components/common"; +import { Button } from "@/components/ui/button"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cronToHuman } from "@/components/agents/cron"; +import { cn } from "@/lib/utils"; + +const SHORT_LABELS: Record = { + leadership: "Leadership", + finance: "Finance", + legal: "Legal", + people: "People", + marketing: "Marketing", + sales: "Sales", + customer: "Customer service", + ecommerce: "E-commerce", + product: "Product & design", + engineering: "Engineering & IT", + data: "Data", + operations: "Operations", +}; + +type Filter = AgentTemplateCategory | "all" | "all-in-one"; + +const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +/** Each term has to start a word somewhere in the template: "ads" finds ads, not leads. */ +const wordStarts = (query: string) => + query + .trim() + .split(/\s+/) + .filter(Boolean) + .map((term) => new RegExp(`(^|[^\\p{L}\\p{N}])${escape(term)}`, "iu")); + +const searchText = (t: AgentTemplate) => [t.name, t.role, t.description, AGENT_TEMPLATE_CATEGORY_LABELS[t.category], ...(t.covers ?? [])].join(" "); + +/** + * Every role a company has, by department: one agent per job, or one all-in-one agent per department that covers + * them all. Searchable, because there are many. + */ +export function RoleTemplates({ + templates, + loading, + onPick, + onDescribe, +}: { + templates: AgentTemplate[]; + loading: boolean; + onPick: (templateId: string) => void; + onDescribe: (text: string) => void; +}) { + const [filter, setFilter] = useState("all"); + const [query, setQuery] = useState(""); + const terms = useMemo(() => wordStarts(query), [query]); + const found = useMemo(() => templates.filter((t) => terms.every((re) => re.test(searchText(t)))), [templates, terms]); + const inFilter = (t: AgentTemplate, f: Filter) => f === "all" || (f === "all-in-one" ? !!t.covers : t.category === f); + const shown = found.filter((t) => inFilter(t, filter)); + const grouped = AGENT_TEMPLATE_CATEGORIES.map((c) => ({ category: c, items: shown.filter((t) => t.category === c) })).filter((g) => g.items.length > 0); + const counts = (f: Filter) => found.filter((t) => inFilter(t, f)).length; + const chips: Filter[] = ["all", "all-in-one", ...AGENT_TEMPLATE_CATEGORIES]; + + return ( +
+
+
+

+ Or hire one role +

+

One agent per job, or one all-in-one agent that runs a whole department.

+
+
+ + + + + setQuery(e.target.value)} aria-label="Search roles" /> + {query && ( + + setQuery("")}> + + + + )} + + +
+
+ +
+ {chips.map((c) => { + const count = counts(c); + if (c !== "all" && c !== filter && !count) return null; + const active = filter === c; + return ( + + ); + })} +
+ + {loading ? ( +
+ {Array.from({ length: 6 }, (_, i) => ( +
+ +
+ + + +
+
+ ))} +
+ ) : shown.length === 0 ? ( + } + title={query ? `No role matches “${query.trim()}”` : "No roles here yet"} + description="Describe the job in your own words and Godmode sets the agent up for you." + action={ + + } + /> + ) : filter === "all" && !terms.length ? ( +
+ {grouped.map((g) => ( +
+

+ {AGENT_TEMPLATE_CATEGORY_LABELS[g.category]} + {g.items.length} +

+ +
+ ))} +
+ ) : ( + + )} +
+ ); +} + +function TemplateGrid({ templates, onPick }: { templates: AgentTemplate[]; onPick: (templateId: string) => void }) { + return ( +
+ {templates.map((t, i) => ( + onPick(t.id)} /> + ))} +
+ ); +} + +function TemplateCard({ template, index, onPick }: { template: AgentTemplate; index: number; onPick: () => void }) { + const allInOne = !!template.covers?.length; + return ( + + + + {template.name} + + {template.role} + {template.leads && Leads a team} + {allInOne && ( + + All-in-one + + )} + + {template.description} + {allInOne && ( + + {template.covers!.length} jobs: {template.covers!.join(" · ")} + + )} + {template.routine && ( + + + + {cronToHuman(template.routine.cron)} + + + )} + + + ); +} diff --git a/apps/desktop/src/components/agents/team-templates.tsx b/apps/desktop/src/components/agents/team-templates.tsx index 1dd4af74..0ce0df40 100644 --- a/apps/desktop/src/components/agents/team-templates.tsx +++ b/apps/desktop/src/components/agents/team-templates.tsx @@ -3,7 +3,35 @@ import { useNavigate } from "react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AnimatePresence, motion } from "motion/react"; import { toast } from "sonner"; -import { Archive, ArrowRight, Briefcase, Building2, CalendarClock, Code, FlaskConical, Handshake, Megaphone, Rocket, ShoppingBag, UserRound, Users, type LucideIcon } from "lucide-react"; +import { + Archive, + ArrowRight, + Briefcase, + Building2, + CalendarClock, + ChartColumn, + Code, + Compass, + Cpu, + Factory, + FlaskConical, + Handshake, + HeartHandshake, + Landmark, + Layers, + LifeBuoy, + Megaphone, + Palette, + Rocket, + Scale, + Shield, + ShoppingBag, + ShoppingCart, + TrendingUp, + UserRound, + Users, + type LucideIcon, +} from "lucide-react"; import type { AgentTemplate, OrgTemplateNode, TeamTemplate } from "@godmode/shared"; import { AgentAvatar } from "@/components/common"; import { localTimezone } from "@/components/agents/cron"; @@ -32,6 +60,19 @@ const ICONS: Record = { handshake: Handshake, code: Code, flask: FlaskConical, + layers: Layers, + landmark: Landmark, + cpu: Cpu, + compass: Compass, + scale: Scale, + "heart-handshake": HeartHandshake, + "trending-up": TrendingUp, + palette: Palette, + "life-buoy": LifeBuoy, + "shopping-cart": ShoppingCart, + shield: Shield, + "bar-chart": ChartColumn, + factory: Factory, }; const face = (t: Pick) => ({ id: t.id, avatar: t.avatar, color: t.color, character: t.character }); @@ -150,21 +191,36 @@ function StructureCard({ team, root, index, onPick }: { team: TeamTemplate; root ); } +/** Reports drawn in the miniature before the rest collapse into a count. */ +const MINI_REPORTS = 6; + /** The chart in miniature: the top, a line down, its reports in a row with the size of their own teams. */ function MiniChart({ root }: { root: Seat }) { + const shown = root.reports.length > MINI_REPORTS ? root.reports.slice(0, MINI_REPORTS - 1) : root.reports; + const rest = root.reports.slice(shown.length); + const columns = shown.length + (rest.length ? 1 : 0); return ( - {root.reports.length > 1 && } - {root.reports.map((r) => ( + {columns > 1 && } + {shown.map((r) => ( +{flat(r).length - 1} ))} + {rest.length > 0 && ( + + + + +{rest.reduce((n, r) => n + flat(r).length, 0)} + + + + )} ); @@ -172,6 +228,10 @@ function MiniChart({ root }: { root: Seat }) { const NEW = "__new"; const GLOBAL = "__global"; +/** Above this many schedules the automations start unticked: a big chart shouldn't start dozens of runs unasked. */ +const MANY_SCHEDULES = 10; +/** Charts this big get the bulk choices (everyone, leads only). */ +const BIG_CHART = 10; function InstallDialog({ team, root, onClose }: { team: TeamTemplate | null; root: Seat | null; onClose: () => void }) { return ( @@ -192,8 +252,8 @@ function InstallForm({ team, root, onClose }: { team: TeamTemplate; root: Seat; const [where, setWhere] = useState(team.kind === "company" ? NEW : (scoped?.id ?? GLOBAL)); const [wsName, setWsName] = useState(team.name); const [skip, setSkip] = useState>(new Set()); - const [automations, setAutomations] = useState(true); const seats = flat(root); + const [automations, setAutomations] = useState(() => seats.filter((s) => s.template.routine).length <= MANY_SCHEDULES); const kept = seats.filter((s) => !skip.has(s.path)); const scheduled = kept.filter((s) => s.template.routine); const names = new Map(seats.map((s) => [s.path, s.template.name])); @@ -238,6 +298,16 @@ function InstallForm({ team, root, onClose }: { team: TeamTemplate; root: Seat; else next.add(path); return next; }); + // A lead's whole team at once: out when anyone of it is in, otherwise back in with its lead. + const toggleTeam = (seat: Seat) => + setSkip((s) => { + const team = flat(seat).slice(1).map((m) => m.path); + const next = new Set(s); + if (team.some((p) => !s.has(p))) for (const p of team) next.add(p); + else for (const p of [seat.path, ...team]) next.delete(p); + return next; + }); + const leads = seats.filter((s) => s.depth > 0 && s.reports.length > 0); const canInstall = kept.length > 1 && (where !== NEW || wsName.trim().length > 0) && !install.isPending; return ( @@ -250,9 +320,27 @@ function InstallForm({ team, root, onClose }: { team: TeamTemplate; root: Seat; {team.description} -
-
    - +
    + {seats.length > BIG_CHART && ( +
    + + {kept.length} of {seats.length} agents + + + {leads.length > 1 && ( + + )} + +
    + )} +
      +
    @@ -308,10 +396,26 @@ function InstallForm({ team, root, onClose }: { team: TeamTemplate; root: Seat; ); } -function SeatRow({ seat, skip, onToggle, leadName, last }: { seat: Seat; skip: Set; onToggle: (path: string) => void; leadName: (path: string) => string; last?: boolean }) { +function SeatRow({ + seat, + skip, + onToggle, + onToggleTeam, + leadName, + last, +}: { + seat: Seat; + skip: Set; + onToggle: (path: string) => void; + onToggleTeam: (seat: Seat) => void; + leadName: (path: string) => string; + last?: boolean; +}) { const off = skip.has(seat.path); const top = seat.depth === 0; const id = `seat-${seat.path}`; + const team = seat.reports.length > 0 && !top ? flat(seat).slice(1) : []; + const teamOut = team.length > 0 && team.every((m) => skip.has(m.path)); return (
  • {!top && ( @@ -320,7 +424,7 @@ function SeatRow({ seat, skip, onToggle, leadName, last }: { seat: Seat; skip: S {!last && } )} -
    +
    {top ? ( ) : ( @@ -332,12 +436,24 @@ function SeatRow({ seat, skip, onToggle, leadName, last }: { seat: Seat; skip: S {off && seat.reports.length > 0 && (its team reports to {leadName(seat.path)})} {top && Reports to Godmode} + {team.length > 0 && ( + + )} {seat.template.routine && !off && }
    {seat.reports.length > 0 && (
      {seat.reports.map((r, i) => ( - + ))}
    )} diff --git a/apps/desktop/src/components/chat/tool-meta.ts b/apps/desktop/src/components/chat/tool-meta.ts index 764bbdc0..a810de49 100644 --- a/apps/desktop/src/components/chat/tool-meta.ts +++ b/apps/desktop/src/components/chat/tool-meta.ts @@ -392,6 +392,8 @@ const GODMODE_TOOLS = new Set([ "agent_get", "agent_delegate", "agent_create", + "templates_list", + "team_install", "agent_update", "agent_delete", "routine_create", @@ -507,7 +509,13 @@ function godmodeMeta(tool: string, input: Input, ctx: ToolContext): Omit !isLicenseRequired(err) && toast.error("Couldn't reach Godmode", { description: errorMessage(err) }), }); const canDescribe = description.trim().length > 3 && !describe.isPending; - const [category, setCategory] = useState("all"); - const roles = (templates.data ?? []).filter((t) => category === "all" || t.category === category); + const describeRef = useRef(null); const structuresRef = useRef(null); useEffect(() => { if (focusStructures) structuresRef.current?.scrollIntoView({ block: "start" }); @@ -145,6 +144,7 @@ function ChooseStep({ onPick, focusStructures }: { onPick: (templateId: string) )} >