| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356 |
- import * as React from "react";
- import { useTranslation } from "react-i18next";
- import {
- Activity,
- Bot,
- CheckCircle2,
- Clock,
- Pencil,
- RefreshCw,
- Search,
- SlidersHorizontal,
- Users,
- } from "lucide-react";
- import { listAgents } from "@/api/agents";
- import { listTeamConfigs, listTeamRuns, listTeams } from "@/api";
- import { ApiErrorState } from "@/components/shared/ApiErrorState";
- import { EmptyState } from "@/components/shared/EmptyState";
- import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
- import { MetricCard } from "@/components/shared/MetricCard";
- import { PageHeader } from "@/components/shared/PageHeader";
- import { SearchInput } from "@/components/shared/SearchInput";
- import { StatusBadge } from "@/components/shared/StatusBadge";
- import { Badge } from "@/components/ui/badge";
- import { Button } from "@/components/ui/button";
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
- import { Select } from "@/components/ui/select";
- import { toast } from "@/components/ui/toaster";
- import { cn, relativeTime } from "@/lib/utils";
- import type { AgentDefinition, TeamConfig, TeamDefinition, TeamRun, TeamStatus } from "@/types";
- import { CreateTeamDialog, EditTeamDialog } from "./components/CreateTeamDialog";
- import { TeamOverview } from "./components/TeamOverview";
- import { TeamRuns } from "./components/TeamRuns";
- type StatusFilter = "all" | TeamStatus;
- type SortMode = "recent" | "name" | "status";
- export function TeamsPage() {
- const { t } = useTranslation();
- const [teams, setTeams] = React.useState<TeamDefinition[]>([]);
- const [configs, setConfigs] = React.useState<TeamConfig[]>([]);
- const [runs, setRuns] = React.useState<TeamRun[]>([]);
- const [agents, setAgents] = React.useState<AgentDefinition[]>([]);
- const [selectedTeamId, setSelectedTeamId] = React.useState<string>();
- const [search, setSearch] = React.useState("");
- const [statusFilter, setStatusFilter] = React.useState<StatusFilter>("all");
- const [sortMode, setSortMode] = React.useState<SortMode>("recent");
- const [loading, setLoading] = React.useState(true);
- const [error, setError] = React.useState<string>();
- const [relatedLoading, setRelatedLoading] = React.useState(false);
- const [createOpen, setCreateOpen] = React.useState(false);
- const [editOpen, setEditOpen] = React.useState(false);
- const selectedTeam = teams.find((team) => team.id === selectedTeamId);
- const sortedConfigs = React.useMemo(
- () => [...configs].sort((a, b) => b.version_no - a.version_no),
- [configs],
- );
- const sortedRuns = React.useMemo(
- () => [...runs].sort((a, b) => new Date(b.created_time).getTime() - new Date(a.created_time).getTime()),
- [runs],
- );
- const activeConfig = sortedConfigs[0];
- const failedRunCount = sortedRuns.filter((run) => run.status === "failed").length;
- const activeTeams = teams.filter((team) => team.status === "active").length;
- const draftTeams = teams.filter((team) => team.status === "draft").length;
- const agentNameById = React.useMemo(() => {
- const result = new Map<string, string>();
- agents.forEach((agent) => result.set(agent.id, agent.name));
- return result;
- }, [agents]);
- const filtered = teams
- .filter((team) => {
- const text = `${team.name} ${team.team_type} ${team.description ?? ""}`.toLowerCase();
- const matchesSearch = text.includes(search.toLowerCase());
- const matchesStatus = statusFilter === "all" || team.status === statusFilter;
- return matchesSearch && matchesStatus;
- })
- .sort((first, second) => {
- if (sortMode === "name") return first.name.localeCompare(second.name);
- if (sortMode === "status") return first.status.localeCompare(second.status) || first.name.localeCompare(second.name);
- return new Date(second.created_time).getTime() - new Date(first.created_time).getTime();
- });
- const hasFilters = search.length > 0 || statusFilter !== "all" || sortMode !== "recent";
- const load = React.useCallback(async () => {
- setLoading(true);
- setError(undefined);
- try {
- const [data, agentData] = await Promise.all([
- listTeams(),
- listAgents().catch(() => [] as AgentDefinition[]),
- ]);
- setTeams(data);
- setAgents(agentData);
- setSelectedTeamId((current) => current ?? data[0]?.id);
- } catch (err) {
- setError(err instanceof Error ? err.message : t("errors.failedToLoad"));
- } finally {
- setLoading(false);
- }
- }, [t]);
- const reloadDetails = React.useCallback(async () => {
- if (!selectedTeamId) return;
- setRelatedLoading(true);
- setConfigs([]);
- setRuns([]);
- try {
- const [configData, runData] = await Promise.all([listTeamConfigs(selectedTeamId), listTeamRuns(selectedTeamId)]);
- setConfigs(configData);
- setRuns(runData);
- } catch {
- toast.error(t("errors.failedToLoad"));
- } finally {
- setRelatedLoading(false);
- }
- }, [selectedTeamId, t]);
- React.useEffect(() => { void load(); }, [load]);
- React.useEffect(() => { if (selectedTeamId) void reloadDetails(); }, [selectedTeamId, reloadDetails]);
- React.useEffect(() => { if (!selectedTeamId && teams[0]) setSelectedTeamId(teams[0].id); }, [selectedTeamId, teams]);
- function clearFilters() {
- setSearch("");
- setStatusFilter("all");
- setSortMode("recent");
- }
- if (loading) return <LoadingSpinner label={t("common.loading")} />;
- if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
- return (
- <div className="space-y-6">
- <PageHeader
- title={t("teams.title")}
- description={t("teams.description")}
- actions={
- <>
- <Button variant="outline" onClick={() => { void load(); if (selectedTeamId) void reloadDetails(); }}>
- <RefreshCw className="h-4 w-4" /> {t("common.refresh")}
- </Button>
- <Button onClick={() => setCreateOpen(true)}>
- <Users className="h-4 w-4" /> {t("teams.newTeam")}
- </Button>
- </>
- }
- />
- <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
- <MetricCard label={t("teams.title")} value={teams.length} icon={Users} />
- <MetricCard label={t("common.active")} value={activeTeams} icon={CheckCircle2} />
- <MetricCard label={t("common.runs")} value={sortedRuns.length} icon={Activity} />
- <MetricCard label={t("teams.failedRuns")} value={failedRunCount} icon={Activity} />
- </div>
- <div className="grid gap-6 xl:grid-cols-[460px_1fr]">
- <Card className="overflow-hidden">
- <CardHeader>
- <div className="flex items-start justify-between gap-3">
- <div>
- <CardTitle>{t("teams.teamDirectory")}</CardTitle>
- <CardDescription>
- {filtered.length} / {teams.length} - {draftTeams} {t("common.draft")}
- </CardDescription>
- </div>
- <SlidersHorizontal className="mt-1 h-4 w-4 text-muted-foreground" />
- </div>
- </CardHeader>
- <CardContent className="space-y-4">
- <SearchInput value={search} onChange={setSearch} placeholder="Search by name, type, or description" />
- <div className="grid gap-3 sm:grid-cols-2">
- <Select
- aria-label={t("teams.filterByStatus")}
- value={statusFilter}
- onChange={(event) => setStatusFilter(event.target.value as StatusFilter)}
- options={[
- { value: "all", label: t("teams.allStatuses") },
- { value: "active", label: t("common.active") },
- { value: "draft", label: t("common.draft") },
- { value: "archived", label: t("common.archived") },
- ]}
- />
- <Select
- aria-label={t("teams.sortTeams")}
- value={sortMode}
- onChange={(event) => setSortMode(event.target.value as SortMode)}
- options={[
- { value: "recent", label: t("teams.newestFirst") },
- { value: "name", label: t("common.name") },
- { value: "status", label: t("common.status") },
- ]}
- />
- </div>
- {hasFilters ? (
- <Button type="button" variant="ghost" size="sm" onClick={clearFilters}>
- {t("common.clearFilters")}
- </Button>
- ) : null}
- {filtered.length ? (
- <div className="space-y-3">
- {filtered.map((team) => (
- <TeamDirectoryItem
- key={team.id}
- team={team}
- active={team.id === selectedTeamId}
- t={t}
- onClick={() => setSelectedTeamId(team.id)}
- />
- ))}
- </div>
- ) : (
- <EmptyState icon={Search} title={t("teams.noMatchingTeams")} description={t("teams.adjustFiltersTeam")} />
- )}
- </CardContent>
- </Card>
- <Card className="min-w-0 overflow-hidden">
- <CardHeader className="border-b border-border bg-muted/15 p-5">
- <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
- <div className="min-w-0">
- <div className="flex min-w-0 flex-wrap items-center gap-2">
- <CardTitle className="truncate text-lg">{selectedTeam?.name ?? t("teams.teamCockpit")}</CardTitle>
- {selectedTeam ? <StatusBadge status={selectedTeam.status} /> : null}
- </div>
- <CardDescription className="mt-1">
- {selectedTeam ? teamValueLabel(t, selectedTeam.team_type) : t("teams.selectTeamInspect")}
- </CardDescription>
- </div>
- <div className="flex flex-wrap items-center gap-2">
- {selectedTeam && activeConfig ? (
- <Badge className="border-border bg-surface-elevated text-muted-foreground">
- {sortedRuns.length} {t("common.runs")}
- </Badge>
- ) : null}
- {selectedTeam ? (
- <Button type="button" size="sm" variant="outline" onClick={() => setEditOpen(true)}>
- <Pencil className="h-4 w-4" /> {t("common.edit")}
- </Button>
- ) : null}
- </div>
- </div>
- </CardHeader>
- <CardContent className="p-4">
- {selectedTeam ? (
- <div className="grid min-w-0 gap-4 2xl:grid-cols-[minmax(0,1fr)_380px]">
- <TeamOverview
- team={selectedTeam}
- activeConfig={activeConfig}
- runCount={sortedRuns.length}
- failedRunCount={failedRunCount}
- latestRun={sortedRuns[0]}
- agentNameById={agentNameById}
- />
- <TeamRuns
- teamId={selectedTeam.id}
- configs={sortedConfigs}
- runs={sortedRuns}
- loading={relatedLoading}
- agentNameById={agentNameById}
- onRunCreated={(run) => {
- setRuns((current) => [run, ...current.filter((item) => item.id !== run.id)]);
- }}
- />
- </div>
- ) : (
- <EmptyState icon={Bot} title={t("teams.noTeams")} description={t("teams.createTeamStart")} actionLabel={t("teams.newTeam")} onAction={() => setCreateOpen(true)} />
- )}
- </CardContent>
- </Card>
- </div>
- <CreateTeamDialog
- open={createOpen}
- onOpenChange={setCreateOpen}
- onCreated={(team) => {
- setSelectedTeamId(team.id);
- setSearch("");
- void load();
- }}
- />
- <EditTeamDialog
- open={editOpen}
- onOpenChange={setEditOpen}
- team={selectedTeam}
- activeConfig={activeConfig}
- onUpdated={(team) => {
- setTeams((current) => current.map((item) => (item.id === team.id ? team : item)));
- void reloadDetails();
- }}
- />
- </div>
- );
- }
- function TeamDirectoryItem({
- team,
- active,
- t,
- onClick,
- }: {
- team: TeamDefinition;
- active?: boolean;
- t: (key: string, options?: Record<string, unknown>) => string;
- onClick: () => void;
- }) {
- return (
- <button
- type="button"
- onClick={onClick}
- className={cn(
- "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",
- active && "border-primary/45 bg-primary/10 shadow-glow",
- )}
- >
- <div className="flex items-start justify-between gap-3">
- <div className="min-w-0">
- <div className="flex items-center gap-2">
- <Users className="h-4 w-4 text-primary" />
- <p className="truncate text-sm font-semibold">{team.name}</p>
- </div>
- <p className="mt-1 truncate text-xs text-muted-foreground">{teamValueLabel(t, team.team_type)}</p>
- </div>
- <StatusBadge status={team.status} />
- </div>
- <p className="mt-3 line-clamp-2 text-sm leading-6 text-muted-foreground">
- {team.description ?? t("teams.noDescription")}
- </p>
- <div className="mt-3 flex flex-wrap items-center gap-2">
- <Badge className="border-border bg-surface-elevated text-muted-foreground">{teamValueLabel(t, team.team_type)}</Badge>
- <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
- <Clock className="h-3.5 w-3.5" /> {relativeTime(team.created_time)}
- </span>
- </div>
- </button>
- );
- }
- function readableLabel(value: string) {
- return value.split(/[_-]+/).filter(Boolean).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
- }
- function teamValueLabel(t: (key: string, options?: Record<string, unknown>) => string, value: string) {
- const translationKey = TEAM_VALUE_LABEL_KEYS[value] ?? value;
- return t(`teams.${translationKey}`, { defaultValue: readableLabel(value) });
- }
- const TEAM_VALUE_LABEL_KEYS: Record<string, string> = {
- collaborative: "collaborative",
- debate: "debate",
- parallel: "parallel",
- pipeline: "pipeline",
- supervisor: "supervisor",
- };
|