TeamsPage.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. import * as React from "react";
  2. import { useTranslation } from "react-i18next";
  3. import {
  4. Activity,
  5. Bot,
  6. CheckCircle2,
  7. Clock,
  8. Pencil,
  9. RefreshCw,
  10. Search,
  11. SlidersHorizontal,
  12. Users,
  13. } from "lucide-react";
  14. import { listAgents } from "@/api/agents";
  15. import { listTeamConfigs, listTeamRuns, listTeams } from "@/api";
  16. import { ApiErrorState } from "@/components/shared/ApiErrorState";
  17. import { EmptyState } from "@/components/shared/EmptyState";
  18. import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
  19. import { MetricCard } from "@/components/shared/MetricCard";
  20. import { PageHeader } from "@/components/shared/PageHeader";
  21. import { SearchInput } from "@/components/shared/SearchInput";
  22. import { StatusBadge } from "@/components/shared/StatusBadge";
  23. import { Badge } from "@/components/ui/badge";
  24. import { Button } from "@/components/ui/button";
  25. import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
  26. import { Select } from "@/components/ui/select";
  27. import { toast } from "@/components/ui/toaster";
  28. import { cn, relativeTime } from "@/lib/utils";
  29. import type { AgentDefinition, TeamConfig, TeamDefinition, TeamRun, TeamStatus } from "@/types";
  30. import { CreateTeamDialog, EditTeamDialog } from "./components/CreateTeamDialog";
  31. import { TeamOverview } from "./components/TeamOverview";
  32. import { TeamRuns } from "./components/TeamRuns";
  33. type StatusFilter = "all" | TeamStatus;
  34. type SortMode = "recent" | "name" | "status";
  35. export function TeamsPage() {
  36. const { t } = useTranslation();
  37. const [teams, setTeams] = React.useState<TeamDefinition[]>([]);
  38. const [configs, setConfigs] = React.useState<TeamConfig[]>([]);
  39. const [runs, setRuns] = React.useState<TeamRun[]>([]);
  40. const [agents, setAgents] = React.useState<AgentDefinition[]>([]);
  41. const [selectedTeamId, setSelectedTeamId] = React.useState<string>();
  42. const [search, setSearch] = React.useState("");
  43. const [statusFilter, setStatusFilter] = React.useState<StatusFilter>("all");
  44. const [sortMode, setSortMode] = React.useState<SortMode>("recent");
  45. const [loading, setLoading] = React.useState(true);
  46. const [error, setError] = React.useState<string>();
  47. const [relatedLoading, setRelatedLoading] = React.useState(false);
  48. const [createOpen, setCreateOpen] = React.useState(false);
  49. const [editOpen, setEditOpen] = React.useState(false);
  50. const selectedTeam = teams.find((team) => team.id === selectedTeamId);
  51. const sortedConfigs = React.useMemo(
  52. () => [...configs].sort((a, b) => b.version_no - a.version_no),
  53. [configs],
  54. );
  55. const sortedRuns = React.useMemo(
  56. () => [...runs].sort((a, b) => new Date(b.created_time).getTime() - new Date(a.created_time).getTime()),
  57. [runs],
  58. );
  59. const activeConfig = sortedConfigs[0];
  60. const failedRunCount = sortedRuns.filter((run) => run.status === "failed").length;
  61. const activeTeams = teams.filter((team) => team.status === "active").length;
  62. const draftTeams = teams.filter((team) => team.status === "draft").length;
  63. const agentNameById = React.useMemo(() => {
  64. const result = new Map<string, string>();
  65. agents.forEach((agent) => result.set(agent.id, agent.name));
  66. return result;
  67. }, [agents]);
  68. const filtered = teams
  69. .filter((team) => {
  70. const text = `${team.name} ${team.team_type} ${team.description ?? ""}`.toLowerCase();
  71. const matchesSearch = text.includes(search.toLowerCase());
  72. const matchesStatus = statusFilter === "all" || team.status === statusFilter;
  73. return matchesSearch && matchesStatus;
  74. })
  75. .sort((first, second) => {
  76. if (sortMode === "name") return first.name.localeCompare(second.name);
  77. if (sortMode === "status") return first.status.localeCompare(second.status) || first.name.localeCompare(second.name);
  78. return new Date(second.created_time).getTime() - new Date(first.created_time).getTime();
  79. });
  80. const hasFilters = search.length > 0 || statusFilter !== "all" || sortMode !== "recent";
  81. const load = React.useCallback(async () => {
  82. setLoading(true);
  83. setError(undefined);
  84. try {
  85. const [data, agentData] = await Promise.all([
  86. listTeams(),
  87. listAgents().catch(() => [] as AgentDefinition[]),
  88. ]);
  89. setTeams(data);
  90. setAgents(agentData);
  91. setSelectedTeamId((current) => current ?? data[0]?.id);
  92. } catch (err) {
  93. setError(err instanceof Error ? err.message : t("errors.failedToLoad"));
  94. } finally {
  95. setLoading(false);
  96. }
  97. }, [t]);
  98. const reloadDetails = React.useCallback(async () => {
  99. if (!selectedTeamId) return;
  100. setRelatedLoading(true);
  101. setConfigs([]);
  102. setRuns([]);
  103. try {
  104. const [configData, runData] = await Promise.all([listTeamConfigs(selectedTeamId), listTeamRuns(selectedTeamId)]);
  105. setConfigs(configData);
  106. setRuns(runData);
  107. } catch {
  108. toast.error(t("errors.failedToLoad"));
  109. } finally {
  110. setRelatedLoading(false);
  111. }
  112. }, [selectedTeamId, t]);
  113. React.useEffect(() => { void load(); }, [load]);
  114. React.useEffect(() => { if (selectedTeamId) void reloadDetails(); }, [selectedTeamId, reloadDetails]);
  115. React.useEffect(() => { if (!selectedTeamId && teams[0]) setSelectedTeamId(teams[0].id); }, [selectedTeamId, teams]);
  116. function clearFilters() {
  117. setSearch("");
  118. setStatusFilter("all");
  119. setSortMode("recent");
  120. }
  121. if (loading) return <LoadingSpinner label={t("common.loading")} />;
  122. if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
  123. return (
  124. <div className="space-y-6">
  125. <PageHeader
  126. title={t("teams.title")}
  127. description={t("teams.description")}
  128. actions={
  129. <>
  130. <Button variant="outline" onClick={() => { void load(); if (selectedTeamId) void reloadDetails(); }}>
  131. <RefreshCw className="h-4 w-4" /> {t("common.refresh")}
  132. </Button>
  133. <Button onClick={() => setCreateOpen(true)}>
  134. <Users className="h-4 w-4" /> {t("teams.newTeam")}
  135. </Button>
  136. </>
  137. }
  138. />
  139. <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
  140. <MetricCard label={t("teams.title")} value={teams.length} icon={Users} />
  141. <MetricCard label={t("common.active")} value={activeTeams} icon={CheckCircle2} />
  142. <MetricCard label={t("common.runs")} value={sortedRuns.length} icon={Activity} />
  143. <MetricCard label={t("teams.failedRuns")} value={failedRunCount} icon={Activity} />
  144. </div>
  145. <div className="grid gap-6 xl:grid-cols-[460px_1fr]">
  146. <Card className="overflow-hidden">
  147. <CardHeader>
  148. <div className="flex items-start justify-between gap-3">
  149. <div>
  150. <CardTitle>{t("teams.teamDirectory")}</CardTitle>
  151. <CardDescription>
  152. {filtered.length} / {teams.length} - {draftTeams} {t("common.draft")}
  153. </CardDescription>
  154. </div>
  155. <SlidersHorizontal className="mt-1 h-4 w-4 text-muted-foreground" />
  156. </div>
  157. </CardHeader>
  158. <CardContent className="space-y-4">
  159. <SearchInput value={search} onChange={setSearch} placeholder="Search by name, type, or description" />
  160. <div className="grid gap-3 sm:grid-cols-2">
  161. <Select
  162. aria-label={t("teams.filterByStatus")}
  163. value={statusFilter}
  164. onChange={(event) => setStatusFilter(event.target.value as StatusFilter)}
  165. options={[
  166. { value: "all", label: t("teams.allStatuses") },
  167. { value: "active", label: t("common.active") },
  168. { value: "draft", label: t("common.draft") },
  169. { value: "archived", label: t("common.archived") },
  170. ]}
  171. />
  172. <Select
  173. aria-label={t("teams.sortTeams")}
  174. value={sortMode}
  175. onChange={(event) => setSortMode(event.target.value as SortMode)}
  176. options={[
  177. { value: "recent", label: t("teams.newestFirst") },
  178. { value: "name", label: t("common.name") },
  179. { value: "status", label: t("common.status") },
  180. ]}
  181. />
  182. </div>
  183. {hasFilters ? (
  184. <Button type="button" variant="ghost" size="sm" onClick={clearFilters}>
  185. {t("common.clearFilters")}
  186. </Button>
  187. ) : null}
  188. {filtered.length ? (
  189. <div className="space-y-3">
  190. {filtered.map((team) => (
  191. <TeamDirectoryItem
  192. key={team.id}
  193. team={team}
  194. active={team.id === selectedTeamId}
  195. t={t}
  196. onClick={() => setSelectedTeamId(team.id)}
  197. />
  198. ))}
  199. </div>
  200. ) : (
  201. <EmptyState icon={Search} title={t("teams.noMatchingTeams")} description={t("teams.adjustFiltersTeam")} />
  202. )}
  203. </CardContent>
  204. </Card>
  205. <Card className="min-w-0 overflow-hidden">
  206. <CardHeader className="border-b border-border bg-muted/15 p-5">
  207. <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
  208. <div className="min-w-0">
  209. <div className="flex min-w-0 flex-wrap items-center gap-2">
  210. <CardTitle className="truncate text-lg">{selectedTeam?.name ?? t("teams.teamCockpit")}</CardTitle>
  211. {selectedTeam ? <StatusBadge status={selectedTeam.status} /> : null}
  212. </div>
  213. <CardDescription className="mt-1">
  214. {selectedTeam ? teamValueLabel(t, selectedTeam.team_type) : t("teams.selectTeamInspect")}
  215. </CardDescription>
  216. </div>
  217. <div className="flex flex-wrap items-center gap-2">
  218. {selectedTeam && activeConfig ? (
  219. <Badge className="border-border bg-surface-elevated text-muted-foreground">
  220. {sortedRuns.length} {t("common.runs")}
  221. </Badge>
  222. ) : null}
  223. {selectedTeam ? (
  224. <Button type="button" size="sm" variant="outline" onClick={() => setEditOpen(true)}>
  225. <Pencil className="h-4 w-4" /> {t("common.edit")}
  226. </Button>
  227. ) : null}
  228. </div>
  229. </div>
  230. </CardHeader>
  231. <CardContent className="p-4">
  232. {selectedTeam ? (
  233. <div className="grid min-w-0 gap-4 2xl:grid-cols-[minmax(0,1fr)_380px]">
  234. <TeamOverview
  235. team={selectedTeam}
  236. activeConfig={activeConfig}
  237. runCount={sortedRuns.length}
  238. failedRunCount={failedRunCount}
  239. latestRun={sortedRuns[0]}
  240. agentNameById={agentNameById}
  241. />
  242. <TeamRuns
  243. teamId={selectedTeam.id}
  244. configs={sortedConfigs}
  245. runs={sortedRuns}
  246. loading={relatedLoading}
  247. agentNameById={agentNameById}
  248. onRunCreated={(run) => {
  249. setRuns((current) => [run, ...current.filter((item) => item.id !== run.id)]);
  250. }}
  251. />
  252. </div>
  253. ) : (
  254. <EmptyState icon={Bot} title={t("teams.noTeams")} description={t("teams.createTeamStart")} actionLabel={t("teams.newTeam")} onAction={() => setCreateOpen(true)} />
  255. )}
  256. </CardContent>
  257. </Card>
  258. </div>
  259. <CreateTeamDialog
  260. open={createOpen}
  261. onOpenChange={setCreateOpen}
  262. onCreated={(team) => {
  263. setSelectedTeamId(team.id);
  264. setSearch("");
  265. void load();
  266. }}
  267. />
  268. <EditTeamDialog
  269. open={editOpen}
  270. onOpenChange={setEditOpen}
  271. team={selectedTeam}
  272. activeConfig={activeConfig}
  273. onUpdated={(team) => {
  274. setTeams((current) => current.map((item) => (item.id === team.id ? team : item)));
  275. void reloadDetails();
  276. }}
  277. />
  278. </div>
  279. );
  280. }
  281. function TeamDirectoryItem({
  282. team,
  283. active,
  284. t,
  285. onClick,
  286. }: {
  287. team: TeamDefinition;
  288. active?: boolean;
  289. t: (key: string, options?: Record<string, unknown>) => string;
  290. onClick: () => void;
  291. }) {
  292. return (
  293. <button
  294. type="button"
  295. onClick={onClick}
  296. className={cn(
  297. "w-full rounded-md border border-border bg-muted/30 p-4 text-left transition hover:bg-muted/55 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
  298. active && "border-primary/45 bg-primary/10 shadow-glow",
  299. )}
  300. >
  301. <div className="flex items-start justify-between gap-3">
  302. <div className="min-w-0">
  303. <div className="flex items-center gap-2">
  304. <Users className="h-4 w-4 text-primary" />
  305. <p className="truncate text-sm font-semibold">{team.name}</p>
  306. </div>
  307. <p className="mt-1 truncate text-xs text-muted-foreground">{teamValueLabel(t, team.team_type)}</p>
  308. </div>
  309. <StatusBadge status={team.status} />
  310. </div>
  311. <p className="mt-3 line-clamp-2 text-sm leading-6 text-muted-foreground">
  312. {team.description ?? t("teams.noDescription")}
  313. </p>
  314. <div className="mt-3 flex flex-wrap items-center gap-2">
  315. <Badge className="border-border bg-surface-elevated text-muted-foreground">{teamValueLabel(t, team.team_type)}</Badge>
  316. <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
  317. <Clock className="h-3.5 w-3.5" /> {relativeTime(team.created_time)}
  318. </span>
  319. </div>
  320. </button>
  321. );
  322. }
  323. function readableLabel(value: string) {
  324. return value.split(/[_-]+/).filter(Boolean).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
  325. }
  326. function teamValueLabel(t: (key: string, options?: Record<string, unknown>) => string, value: string) {
  327. const translationKey = TEAM_VALUE_LABEL_KEYS[value] ?? value;
  328. return t(`teams.${translationKey}`, { defaultValue: readableLabel(value) });
  329. }
  330. const TEAM_VALUE_LABEL_KEYS: Record<string, string> = {
  331. collaborative: "collaborative",
  332. debate: "debate",
  333. parallel: "parallel",
  334. pipeline: "pipeline",
  335. supervisor: "supervisor",
  336. };