From 107bf5a953ada1dec1b1f6566ce2c083f7d0f570 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 20 Mar 2026 14:32:03 -0700 Subject: [PATCH] feat(desktop): add agent teams (#132) --- desktop/scripts/check-file-sizes.mjs | 1 + desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/commands/teams.rs | 108 ++++++++ desktop/src-tauri/src/lib.rs | 4 + desktop/src-tauri/src/managed_agents/mod.rs | 2 + desktop/src-tauri/src/managed_agents/teams.rs | 91 ++++++ desktop/src-tauri/src/managed_agents/types.rs | 29 ++ desktop/src/features/agents/hooks.ts | 58 ++++ .../agents/ui/AddTeamToChannelDialog.tsx | 262 ++++++++++++++++++ desktop/src/features/agents/ui/AgentsView.tsx | 88 +++++- .../features/agents/ui/TeamDeleteDialog.tsx | 61 ++++ desktop/src/features/agents/ui/TeamDialog.tsx | 234 ++++++++++++++++ .../src/features/agents/ui/TeamsSection.tsx | 234 ++++++++++++++++ .../src/features/agents/ui/useTeamActions.ts | 174 ++++++++++++ .../channels/ui/AddChannelBotDialog.tsx | 28 ++ .../channels/ui/AddChannelBotTeamsSection.tsx | 162 +++++++++++ desktop/src/shared/api/tauriTeams.ts | 59 ++++ desktop/src/shared/api/types.ts | 24 ++ 18 files changed, 1620 insertions(+), 1 deletion(-) create mode 100644 desktop/src-tauri/src/commands/teams.rs create mode 100644 desktop/src-tauri/src/managed_agents/teams.rs create mode 100644 desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx create mode 100644 desktop/src/features/agents/ui/TeamDeleteDialog.tsx create mode 100644 desktop/src/features/agents/ui/TeamDialog.tsx create mode 100644 desktop/src/features/agents/ui/TeamsSection.tsx create mode 100644 desktop/src/features/agents/ui/useTeamActions.ts create mode 100644 desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx create mode 100644 desktop/src/shared/api/tauriTeams.ts diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 93deda4c8..f20a0b9f2 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -31,6 +31,7 @@ const rules = [ // Exceptions should stay rare and temporary. Prefer splitting files instead. const overrides = new Map([ ["src/app/AppShell.tsx", 750], + ["src/features/agents/ui/AgentsView.tsx", 575], // persona + team dialog orchestration (team state extracted to useTeamActions) ["src/features/channels/hooks.ts", 525], // canvas query + mutation hooks ["src/features/channels/ui/ChannelManagementSheet.tsx", 800], ["src/features/messages/ui/MessageComposer.tsx", 665], // media upload handlers (paste, drop, dialog) + channelId reset effect diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 853f73b40..4449594e8 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -10,6 +10,7 @@ mod media; mod messages; mod personas; mod profile; +mod teams; mod tokens; pub use agent_discovery::*; @@ -24,4 +25,5 @@ pub use media::*; pub use messages::*; pub use personas::*; pub use profile::*; +pub use teams::*; pub use tokens::*; diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs new file mode 100644 index 000000000..2a87e78ba --- /dev/null +++ b/desktop/src-tauri/src/commands/teams.rs @@ -0,0 +1,108 @@ +use tauri::{AppHandle, State}; +use uuid::Uuid; + +use crate::{ + app_state::AppState, + managed_agents::{load_teams, save_teams, CreateTeamRequest, TeamRecord, UpdateTeamRequest}, + util::now_iso, +}; + +fn trim_required(value: &str, label: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(format!("{label} is required")); + } + Ok(trimmed.to_string()) +} + +fn trim_optional(value: Option) -> Option { + value.and_then(|candidate| { + let trimmed = candidate.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) +} + +#[tauri::command] +pub fn list_teams(app: AppHandle, state: State<'_, AppState>) -> Result, String> { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + load_teams(&app) +} + +#[tauri::command] +pub fn create_team( + input: CreateTeamRequest, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let name = trim_required(&input.name, "Team name")?; + let description = trim_optional(input.description); + let now = now_iso(); + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut teams = load_teams(&app)?; + let team = TeamRecord { + id: Uuid::new_v4().to_string(), + name, + description, + persona_ids: input.persona_ids, + created_at: now.clone(), + updated_at: now, + }; + teams.push(team.clone()); + save_teams(&app, &teams)?; + Ok(team) +} + +#[tauri::command] +pub fn update_team( + input: UpdateTeamRequest, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let name = trim_required(&input.name, "Team name")?; + let description = trim_optional(input.description); + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut teams = load_teams(&app)?; + let team = teams + .iter_mut() + .find(|record| record.id == input.id) + .ok_or_else(|| format!("team {} not found", input.id))?; + + team.name = name; + team.description = description; + team.persona_ids = input.persona_ids; + team.updated_at = now_iso(); + + let updated = team.clone(); + save_teams(&app, &teams)?; + Ok(updated) +} + +#[tauri::command] +pub fn delete_team( + id: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut teams = load_teams(&app)?; + let original_len = teams.len(); + teams.retain(|record| record.id != id); + if teams.len() == original_len { + return Err(format!("team {id} not found")); + } + save_teams(&app, &teams) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 4e895e75f..bd5dcb1a1 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -174,6 +174,10 @@ pub fn run() { create_persona, update_persona, delete_persona, + list_teams, + create_team, + update_team, + delete_team, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 1c8a299fa..ac03ab37c 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -2,10 +2,12 @@ mod discovery; mod personas; mod runtime; mod storage; +mod teams; mod types; pub use discovery::*; pub use personas::*; pub use runtime::*; pub use storage::*; +pub use teams::*; pub use types::*; diff --git a/desktop/src-tauri/src/managed_agents/teams.rs b/desktop/src-tauri/src/managed_agents/teams.rs new file mode 100644 index 000000000..44816e105 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/teams.rs @@ -0,0 +1,91 @@ +use std::{fs, path::PathBuf}; + +use tauri::AppHandle; + +use crate::managed_agents::{managed_agents_base_dir, TeamRecord}; + +fn teams_store_path(app: &AppHandle) -> Result { + Ok(managed_agents_base_dir(app)?.join("teams.json")) +} + +fn sort_teams(records: &mut [TeamRecord]) { + records.sort_by(|left, right| { + left.name + .to_lowercase() + .cmp(&right.name.to_lowercase()) + .then_with(|| left.id.cmp(&right.id)) + }); +} + +pub fn load_teams(app: &AppHandle) -> Result, String> { + let path = teams_store_path(app)?; + if !path.exists() { + return Ok(Vec::new()); + } + + let content = + fs::read_to_string(&path).map_err(|error| format!("failed to read teams store: {error}"))?; + let mut records: Vec = serde_json::from_str(&content) + .map_err(|error| format!("failed to parse teams store: {error}"))?; + sort_teams(&mut records); + Ok(records) +} + +pub fn save_teams(app: &AppHandle, records: &[TeamRecord]) -> Result<(), String> { + let mut sorted = records.to_vec(); + sort_teams(&mut sorted); + + let path = teams_store_path(app)?; + let payload = serde_json::to_vec_pretty(&sorted) + .map_err(|error| format!("failed to serialize teams store: {error}"))?; + fs::write(&path, payload).map_err(|error| format!("failed to write teams store: {error}")) +} + +#[cfg(test)] +mod tests { + use super::sort_teams; + use crate::managed_agents::TeamRecord; + + fn team(id: &str, name: &str) -> TeamRecord { + TeamRecord { + id: id.to_string(), + name: name.to_string(), + description: None, + persona_ids: Vec::new(), + created_at: "2026-03-20T00:00:00Z".to_string(), + updated_at: "2026-03-20T00:00:00Z".to_string(), + } + } + + #[test] + fn sort_teams_alphabetical_case_insensitive() { + let mut teams = vec![ + team("3", "Zulu"), + team("1", "alpha"), + team("2", "Bravo"), + ]; + sort_teams(&mut teams); + + let names: Vec<&str> = teams.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(names, vec!["alpha", "Bravo", "Zulu"]); + } + + #[test] + fn sort_teams_breaks_ties_by_id() { + let mut teams = vec![ + team("b", "same"), + team("a", "same"), + ]; + sort_teams(&mut teams); + + let ids: Vec<&str> = teams.iter().map(|t| t.id.as_str()).collect(); + assert_eq!(ids, vec!["a", "b"]); + } + + #[test] + fn sort_teams_empty_is_noop() { + let mut teams: Vec = Vec::new(); + sort_teams(&mut teams); + assert!(teams.is_empty()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 223da10df..3f674c520 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -238,6 +238,35 @@ pub struct AgentModelInfo { pub description: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TeamRecord { + pub id: String, + pub name: String, + pub description: Option, + pub persona_ids: Vec, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateTeamRequest { + pub name: String, + pub description: Option, + #[serde(default)] + pub persona_ids: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateTeamRequest { + pub id: String, + pub name: String, + pub description: Option, + #[serde(default)] + pub persona_ids: Vec, +} + pub const DEFAULT_ACP_COMMAND: &str = "sprout-acp"; pub const DEFAULT_AGENT_COMMAND: &str = "goose"; pub const DEFAULT_MCP_COMMAND: &str = "sprout-mcp-server"; diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index d1c09ee6f..8a395c314 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -26,13 +26,22 @@ import { updatePersona, } from "@/shared/api/tauriPersonas"; import { setManagedAgentStartOnAppLaunch } from "@/shared/api/tauriManagedAgents"; +import { + createTeam, + deleteTeam, + listTeams, + updateTeam, +} from "@/shared/api/tauriTeams"; import type { AgentPersona, + AgentTeam, CreateManagedAgentInput, CreatePersonaInput, + CreateTeamInput, ManagedAgent, MintManagedAgentTokenInput, UpdatePersonaInput, + UpdateTeamInput, } from "@/shared/api/types"; import type { AttachManagedAgentToChannelInput, @@ -57,6 +66,7 @@ export type { export const relayAgentsQueryKey = ["relay-agents"] as const; export const managedAgentsQueryKey = ["managed-agents"] as const; export const personasQueryKey = ["personas"] as const; +export const teamsQueryKey = ["teams"] as const; export const acpProvidersQueryKey = ["acp-providers"] as const; export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const; @@ -408,3 +418,51 @@ export function useManagedAgentLogQuery( refetchInterval: pubkey ? 2_000 : false, }); } + +export function useTeamsQuery() { + return useQuery({ + queryKey: teamsQueryKey, + queryFn: listTeams, + staleTime: 30_000, + refetchInterval: 30_000, + }); +} + +export function useCreateTeamMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: CreateTeamInput) => createTeam(input), + onSuccess: (created) => { + queryClient.setQueryData(teamsQueryKey, (current) => { + const next = current ?? []; + return [created, ...next.filter((team) => team.id !== created.id)]; + }); + }, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: teamsQueryKey }); + }, + }); +} + +export function useUpdateTeamMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: UpdateTeamInput) => updateTeam(input), + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: teamsQueryKey }); + }, + }); +} + +export function useDeleteTeamMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => deleteTeam(id), + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: teamsQueryKey }); + }, + }); +} diff --git a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx new file mode 100644 index 000000000..3cffe4ccb --- /dev/null +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -0,0 +1,262 @@ +import * as React from "react"; + +import { + useAcpProvidersQuery, + useCreateChannelManagedAgentsMutation, +} from "@/features/agents/hooks"; +import type { CreateChannelManagedAgentsResult } from "@/features/agents/channelAgents"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import type { + AgentPersona, + AgentTeam, + Channel, + ChannelRole, +} from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; + +type AddTeamToChannelDialogProps = { + team: AgentTeam | null; + personas: AgentPersona[]; + open: boolean; + onOpenChange: (open: boolean) => void; + onDeployed: ( + channel: Channel, + result: CreateChannelManagedAgentsResult, + ) => void; +}; + +function resolvePersonas( + personaIds: string[], + personas: AgentPersona[], +): AgentPersona[] { + return personaIds + .map((id) => personas.find((p) => p.id === id)) + .filter((p): p is AgentPersona => p !== undefined); +} + +export function AddTeamToChannelDialog({ + team, + personas, + open, + onOpenChange, + onDeployed, +}: AddTeamToChannelDialogProps) { + const channelsQuery = useChannelsQuery(); + const providersQuery = useAcpProvidersQuery(); + const [channelId, setChannelId] = React.useState(""); + const [role, setRole] = React.useState>("bot"); + const deployMutation = useCreateChannelManagedAgentsMutation( + channelId || null, + ); + + const channels = React.useMemo( + () => + (channelsQuery.data ?? []).filter( + (channel) => channel.channelType !== "dm" && !channel.archivedAt, + ), + [channelsQuery.data], + ); + + const providers = providersQuery.data ?? []; + const defaultProvider = providers[0] ?? null; + + const resolved = team ? resolvePersonas(team.personaIds, personas) : []; + + function reset() { + setChannelId(""); + setRole("bot"); + deployMutation.reset(); + } + + function handleOpenChange(next: boolean) { + if (!next) { + reset(); + } + onOpenChange(next); + } + + React.useEffect(() => { + if (!open) { + return; + } + if (!channelId && channels.length > 0) { + setChannelId(channels[0].id); + } + }, [channelId, channels, open]); + + const selectedChannel = + channels.find((channel) => channel.id === channelId) ?? null; + + async function handleDeploy() { + if (!team || !selectedChannel || !defaultProvider) { + return; + } + + try { + const inputs = resolved.map((persona) => ({ + provider: { + id: defaultProvider.id, + label: defaultProvider.label, + command: defaultProvider.command, + defaultArgs: defaultProvider.defaultArgs, + }, + name: persona.displayName, + systemPrompt: persona.systemPrompt, + avatarUrl: persona.avatarUrl ?? undefined, + personaId: persona.id, + role, + })); + + const result = await deployMutation.mutateAsync(inputs); + onDeployed(selectedChannel, result); + handleOpenChange(false); + } catch { + // React Query stores the error; keep the dialog open. + } + } + + return ( + + +
+ + Deploy team to channel + + Create and attach one agent per persona in{" "} + {team?.name ?? "this team"} to the selected + channel. + + + +
+ {resolved.length > 0 ? ( +
+ + Personas ({resolved.length}) + +
+ {resolved.map((persona) => ( +
+ + + {persona.displayName} + +
+ ))} +
+
+ ) : null} + +
+ + +
+ +
+ + +
+ + {!defaultProvider && !providersQuery.isLoading ? ( +

+ No ACP providers found. Make sure an agent runtime (e.g. Goose) + is installed. +

+ ) : null} + + {channelsQuery.error instanceof Error ? ( +

+ {channelsQuery.error.message} +

+ ) : null} + + {deployMutation.error instanceof Error ? ( +

+ {deployMutation.error.message} +

+ ) : null} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 7611d1143..60a9b1198 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -24,6 +24,7 @@ import type { UpdatePersonaInput, } from "@/shared/api/types"; import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog"; +import { AddTeamToChannelDialog } from "./AddTeamToChannelDialog"; import { CreateAgentDialog } from "./CreateAgentDialog"; import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel"; import { ManagedAgentsSection } from "./ManagedAgentsSection"; @@ -32,7 +33,11 @@ import { PersonaDeleteDialog } from "./PersonaDeleteDialog"; import { PersonasSection } from "./PersonasSection"; import { RelayDirectorySection } from "./RelayDirectorySection"; import { SecretRevealDialog } from "./SecretRevealDialog"; +import { TeamDeleteDialog } from "./TeamDeleteDialog"; +import { TeamDialog } from "./TeamDialog"; +import { TeamsSection } from "./TeamsSection"; import { TokenRevealDialog } from "./TokenRevealDialog"; +import { useTeamActions } from "./useTeamActions"; type PersonaDialogState = { description: string; @@ -72,6 +77,15 @@ export function AgentsView() { const [actionErrorMessage, setActionErrorMessage] = React.useState< string | null >(null); + + const teamActions = useTeamActions( + { setActionNoticeMessage, setActionErrorMessage }, + { + refetchManagedAgents: () => void managedAgentsQuery.refetch(), + refetchRelayAgents: () => void relayAgentsQuery.refetch(), + }, + ); + const managedAgents = React.useMemo( () => [...(managedAgentsQuery.data ?? [])].sort((left, right) => { @@ -269,7 +283,10 @@ export function AgentsView() { mintTokenMutation.isPending || createPersonaMutation.isPending || updatePersonaMutation.isPending || - deletePersonaMutation.isPending; + deletePersonaMutation.isPending || + teamActions.createTeamMutation.isPending || + teamActions.updateTeamMutation.isPending || + teamActions.deleteTeamMutation.isPending; return ( <> @@ -338,6 +355,27 @@ export function AgentsView() { personas={personas} /> + + + { + if (!open) { + teamActions.setTeamDialogState(null); + } + }} + onSubmit={teamActions.handleTeamSubmit} + open={teamActions.teamDialogState !== null} + personas={personas} + submitLabel={teamActions.teamDialogState?.submitLabel ?? "Save"} + title={teamActions.teamDialogState?.title ?? "Team"} + /> + { + void teamActions.handleDeleteTeam(team); + }} + onOpenChange={(open) => { + if (!open) { + teamActions.setTeamToDelete(null); + } + }} + open={teamActions.teamToDelete !== null} + team={teamActions.teamToDelete} + /> + { + if (!open) { + teamActions.setTeamToAddToChannel(null); + } + }} + open={teamActions.teamToAddToChannel !== null} + personas={personas} + team={teamActions.teamToAddToChannel} + /> ); } diff --git a/desktop/src/features/agents/ui/TeamDeleteDialog.tsx b/desktop/src/features/agents/ui/TeamDeleteDialog.tsx new file mode 100644 index 000000000..383f92c4a --- /dev/null +++ b/desktop/src/features/agents/ui/TeamDeleteDialog.tsx @@ -0,0 +1,61 @@ +import type { AgentTeam } from "@/shared/api/types"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { Button } from "@/shared/ui/button"; + +type TeamDeleteDialogProps = { + open: boolean; + team: AgentTeam | null; + onConfirm: (team: AgentTeam) => void; + onOpenChange: (open: boolean) => void; +}; + +export function TeamDeleteDialog({ + open, + team, + onConfirm, + onOpenChange, +}: TeamDeleteDialogProps) { + return ( + + + + Delete team? + + {team + ? `Delete "${team.name}". Already-deployed agents are not affected, but this team template will no longer be available.` + : "Delete this team."} + + + + + + + + + + + + + ); +} diff --git a/desktop/src/features/agents/ui/TeamDialog.tsx b/desktop/src/features/agents/ui/TeamDialog.tsx new file mode 100644 index 000000000..9ae2471b8 --- /dev/null +++ b/desktop/src/features/agents/ui/TeamDialog.tsx @@ -0,0 +1,234 @@ +import * as React from "react"; + +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import type { + AgentPersona, + CreateTeamInput, + UpdateTeamInput, +} from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { Checkbox } from "@/shared/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Textarea } from "@/shared/ui/textarea"; + +type TeamDialogProps = { + open: boolean; + title: string; + description: string; + submitLabel: string; + initialValues: CreateTeamInput | UpdateTeamInput | null; + personas: AgentPersona[]; + error: Error | null; + isPending: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (input: CreateTeamInput | UpdateTeamInput) => Promise; +}; + +export function TeamDialog({ + open, + title, + description, + submitLabel, + initialValues, + personas, + error, + isPending, + onOpenChange, + onSubmit, +}: TeamDialogProps) { + const [name, setName] = React.useState(""); + const [teamDescription, setTeamDescription] = React.useState(""); + const [selectedPersonaIds, setSelectedPersonaIds] = React.useState( + [], + ); + + React.useEffect(() => { + if (!open || !initialValues) { + return; + } + + setName(initialValues.name); + setTeamDescription(initialValues.description ?? ""); + setSelectedPersonaIds(initialValues.personaIds); + }, [initialValues, open]); + + function handleOpenChange(next: boolean) { + if (!next) { + setName(""); + setTeamDescription(""); + setSelectedPersonaIds([]); + } + + onOpenChange(next); + } + + function togglePersona(personaId: string) { + setSelectedPersonaIds((current) => + current.includes(personaId) + ? current.filter((id) => id !== personaId) + : [...current, personaId], + ); + } + + async function handleSubmit() { + if (!initialValues) { + return; + } + + const baseInput = { + name, + description: teamDescription.trim() || undefined, + personaIds: selectedPersonaIds, + }; + + if ("id" in initialValues) { + await onSubmit({ id: initialValues.id, ...baseInput }); + return; + } + + await onSubmit(baseInput); + } + + return ( + + +
+ + {title} + {description} + + +
+
+ + setName(event.target.value)} + placeholder="Engineering Squad" + value={name} + /> +
+ +
+ +