feat(desktop): add agent teams (#132)

This commit is contained in:
Wes
2026-03-20 14:32:03 -07:00
committed by GitHub
parent 0035ab7cd2
commit 107bf5a953
18 changed files with 1620 additions and 1 deletions
+1
View File
@@ -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
+2
View File
@@ -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::*;
+108
View File
@@ -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<String, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(format!("{label} is required"));
}
Ok(trimmed.to_string())
}
fn trim_optional(value: Option<String>) -> Option<String> {
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<Vec<TeamRecord>, 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<TeamRecord, String> {
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<TeamRecord, String> {
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)
}
+4
View File
@@ -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");
@@ -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::*;
@@ -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<PathBuf, String> {
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<Vec<TeamRecord>, 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<TeamRecord> = 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<TeamRecord> = Vec::new();
sort_teams(&mut teams);
assert!(teams.is_empty());
}
}
@@ -238,6 +238,35 @@ pub struct AgentModelInfo {
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TeamRecord {
pub id: String,
pub name: String,
pub description: Option<String>,
pub persona_ids: Vec<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateTeamRequest {
pub name: String,
pub description: Option<String>,
#[serde(default)]
pub persona_ids: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateTeamRequest {
pub id: String,
pub name: String,
pub description: Option<String>,
#[serde(default)]
pub persona_ids: Vec<String>,
}
pub const DEFAULT_ACP_COMMAND: &str = "sprout-acp";
pub const DEFAULT_AGENT_COMMAND: &str = "goose";
pub const DEFAULT_MCP_COMMAND: &str = "sprout-mcp-server";
+58
View File
@@ -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<AgentTeam[]>(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 });
},
});
}
@@ -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<Exclude<ChannelRole, "owner">>("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 (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent className="max-w-xl overflow-hidden p-0">
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Deploy team to channel</DialogTitle>
<DialogDescription>
Create and attach one agent per persona in{" "}
<strong>{team?.name ?? "this team"}</strong> to the selected
channel.
</DialogDescription>
</DialogHeader>
<div className="space-y-5 px-6 py-5">
{resolved.length > 0 ? (
<div className="space-y-1.5">
<span className="text-sm font-medium">
Personas ({resolved.length})
</span>
<div className="flex flex-wrap gap-2">
{resolved.map((persona) => (
<div
className="flex items-center gap-1.5 rounded-full border border-border/70 bg-muted/30 px-2 py-1"
key={persona.id}
>
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-5 w-5 rounded-full text-[9px]"
label={persona.displayName}
/>
<span className="text-xs font-medium">
{persona.displayName}
</span>
</div>
))}
</div>
</div>
) : null}
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="team-channel-id">
Channel
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
disabled={channels.length === 0 || deployMutation.isPending}
id="team-channel-id"
onChange={(event) => setChannelId(event.target.value)}
value={channelId}
>
{channels.length === 0 ? (
<option value="">No channels available</option>
) : null}
{channels.map((channel) => (
<option key={channel.id} value={channel.id}>
{channel.name} · {channel.visibility}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="team-channel-role"
>
Role
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
disabled={deployMutation.isPending}
id="team-channel-role"
onChange={(event) =>
setRole(event.target.value as Exclude<ChannelRole, "owner">)
}
value={role}
>
<option value="bot">bot</option>
<option value="member">member</option>
<option value="guest">guest</option>
<option value="admin">admin</option>
</select>
</div>
{!defaultProvider && !providersQuery.isLoading ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
No ACP providers found. Make sure an agent runtime (e.g. Goose)
is installed.
</p>
) : null}
{channelsQuery.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{channelsQuery.error.message}
</p>
) : null}
{deployMutation.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{deployMutation.error.message}
</p>
) : null}
</div>
<div className="flex justify-end gap-2 border-t border-border/60 px-6 py-4">
<Button
onClick={() => handleOpenChange(false)}
size="sm"
type="button"
variant="outline"
>
Cancel
</Button>
<Button
disabled={
!team ||
!selectedChannel ||
!defaultProvider ||
resolved.length === 0 ||
channelsQuery.isLoading ||
providersQuery.isLoading ||
deployMutation.isPending
}
onClick={() => void handleDeploy()}
size="sm"
type="button"
>
{deployMutation.isPending
? "Deploying..."
: `Deploy ${resolved.length} ${resolved.length === 1 ? "agent" : "agents"}`}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+87 -1
View File
@@ -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}
/>
<TeamsSection
error={
teamActions.teamsQuery.error instanceof Error
? teamActions.teamsQuery.error
: null
}
isLoading={teamActions.teamsQuery.isLoading}
isPending={
teamActions.createTeamMutation.isPending ||
teamActions.updateTeamMutation.isPending ||
teamActions.deleteTeamMutation.isPending
}
onCreate={teamActions.openCreateDialog}
onDelete={teamActions.setTeamToDelete}
onDuplicate={teamActions.openDuplicateDialog}
onEdit={teamActions.openEditDialog}
onAddToChannel={teamActions.setTeamToAddToChannel}
personas={personas}
teams={teamActions.teams}
/>
<ManagedAgentsSection
actionErrorMessage={actionErrorMessage}
actionNoticeMessage={actionNoticeMessage}
@@ -471,6 +509,54 @@ export function AgentsView() {
open={personaToDelete !== null}
persona={personaToDelete}
/>
<TeamDialog
description={teamActions.teamDialogState?.description ?? ""}
error={
teamActions.updateTeamMutation.error instanceof Error
? teamActions.updateTeamMutation.error
: teamActions.createTeamMutation.error instanceof Error
? teamActions.createTeamMutation.error
: null
}
initialValues={teamActions.teamDialogState?.initialValues ?? null}
isPending={
teamActions.createTeamMutation.isPending ||
teamActions.updateTeamMutation.isPending
}
onOpenChange={(open) => {
if (!open) {
teamActions.setTeamDialogState(null);
}
}}
onSubmit={teamActions.handleTeamSubmit}
open={teamActions.teamDialogState !== null}
personas={personas}
submitLabel={teamActions.teamDialogState?.submitLabel ?? "Save"}
title={teamActions.teamDialogState?.title ?? "Team"}
/>
<TeamDeleteDialog
onConfirm={(team) => {
void teamActions.handleDeleteTeam(team);
}}
onOpenChange={(open) => {
if (!open) {
teamActions.setTeamToDelete(null);
}
}}
open={teamActions.teamToDelete !== null}
team={teamActions.teamToDelete}
/>
<AddTeamToChannelDialog
onDeployed={teamActions.handleTeamDeployed}
onOpenChange={(open) => {
if (!open) {
teamActions.setTeamToAddToChannel(null);
}
}}
open={teamActions.teamToAddToChannel !== null}
personas={personas}
team={teamActions.teamToAddToChannel}
/>
</>
);
}
@@ -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 (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete team?</AlertDialogTitle>
<AlertDialogDescription>
{team
? `Delete "${team.name}". Already-deployed agents are not affected, but this team template will no longer be available.`
: "Delete this team."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => {
if (team) {
onConfirm(team);
}
}}
type="button"
variant="destructive"
>
Delete
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -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<void>;
};
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<string[]>(
[],
);
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 (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent className="max-w-2xl overflow-hidden p-0">
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-5">
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="team-name">
Name
</label>
<Input
autoCorrect="off"
disabled={isPending}
id="team-name"
onChange={(event) => setName(event.target.value)}
placeholder="Engineering Squad"
value={name}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="team-description">
Description
</label>
<Textarea
className="min-h-20"
disabled={isPending}
id="team-description"
onChange={(event) => setTeamDescription(event.target.value)}
placeholder="Optional description for this team."
value={teamDescription}
/>
</div>
<div className="space-y-2">
<span className="text-sm font-medium">Personas</span>
<p className="text-xs text-muted-foreground">
Select the personas to include in this team.
</p>
{personas.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
No personas available. Create one first.
</p>
) : (
<div
className="max-h-60 space-y-1 overflow-y-auto rounded-lg border border-border/70 p-2"
role="listbox"
aria-label="Personas"
aria-multiselectable="true"
>
{personas.map((persona) => {
const isSelected = selectedPersonaIds.includes(persona.id);
return (
<div
className="flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-muted/50"
key={persona.id}
onClick={() => {
if (!isPending) {
togglePersona(persona.id);
}
}}
onKeyDown={(event) => {
if (
!isPending &&
(event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
togglePersona(persona.id);
}
}}
role="option"
aria-selected={isSelected}
tabIndex={0}
>
<Checkbox
checked={isSelected}
disabled={isPending}
onCheckedChange={() => togglePersona(persona.id)}
/>
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-6 w-6 rounded-full text-[10px]"
label={persona.displayName}
/>
<span className="text-sm">{persona.displayName}</span>
{persona.isBuiltIn ? (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Built-in
</span>
) : null}
</div>
);
})}
</div>
)}
</div>
{error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error.message}
</p>
) : null}
</div>
<div className="flex justify-end gap-2 border-t border-border/60 px-6 py-4">
<Button
onClick={() => handleOpenChange(false)}
size="sm"
type="button"
variant="outline"
>
Cancel
</Button>
<Button
disabled={
name.trim().length === 0 ||
selectedPersonaIds.length === 0 ||
isPending
}
onClick={() => void handleSubmit()}
size="sm"
type="button"
>
{isPending ? "Saving..." : submitLabel}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,234 @@
import {
CopyPlus,
Ellipsis,
Info,
Pencil,
Plus,
Rocket,
Trash2,
Users,
} from "lucide-react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { AgentPersona, AgentTeam } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Skeleton } from "@/shared/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
const MAX_VISIBLE_AVATARS = 4;
type TeamsSectionProps = {
teams: AgentTeam[];
personas: AgentPersona[];
error: Error | null;
isLoading: boolean;
isPending: boolean;
onCreate: () => void;
onDuplicate: (team: AgentTeam) => void;
onEdit: (team: AgentTeam) => void;
onDelete: (team: AgentTeam) => void;
onAddToChannel: (team: AgentTeam) => 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 TeamsSection({
teams,
personas,
error,
isLoading,
isPending,
onCreate,
onDuplicate,
onEdit,
onDelete,
onAddToChannel,
}: TeamsSectionProps) {
return (
<section className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold tracking-tight">Teams</h3>
<p className="text-sm text-muted-foreground">
Named groups of personas you can deploy to a channel together.
</p>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Create team"
onClick={onCreate}
type="button"
variant="ghost"
size="icon"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Create team</TooltipContent>
</Tooltip>
</div>
{isLoading ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{["first", "second", "third"].map((key) => (
<div
className="rounded-xl border border-border/70 bg-card/80 p-3 shadow-sm"
key={key}
>
<div className="flex items-center gap-2.5">
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-20 rounded-full" />
</div>
</div>
</div>
))}
</div>
) : null}
{!isLoading && teams.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{teams.map((team) => {
const resolved = resolvePersonas(team.personaIds, personas);
const visible = resolved.slice(0, MAX_VISIBLE_AVATARS);
const overflow = resolved.length - visible.length;
return (
<div
className="rounded-xl border border-border/70 bg-card/80 p-3 shadow-sm"
key={team.id}
>
<div className="flex items-start justify-between gap-2.5">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 shrink-0 text-muted-foreground" />
<p className="truncate text-sm font-semibold tracking-tight">
{team.name}
</p>
{team.description ? (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="View description"
className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
type="button"
>
<Info className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p>{team.description}</p>
</TooltipContent>
</Tooltip>
) : null}
</div>
<div className="mt-2 flex items-center gap-2">
<div className="flex -space-x-1.5">
{visible.map((persona) => (
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-6 w-6 rounded-full border-2 border-card text-[10px]"
key={persona.id}
label={persona.displayName}
/>
))}
{overflow > 0 ? (
<span className="flex h-6 w-6 items-center justify-center rounded-full border-2 border-card bg-muted text-[10px] font-medium text-muted-foreground">
+{overflow}
</span>
) : null}
</div>
<span className="text-xs text-muted-foreground">
{team.personaIds.length}{" "}
{team.personaIds.length === 1 ? "persona" : "personas"}
</span>
</div>
</div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
type="button"
>
<Ellipsis className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
disabled={isPending}
onClick={() => onAddToChannel(team)}
>
<Rocket className="h-4 w-4" />
Deploy to channel
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={isPending}
onClick={() => onEdit(team)}
>
<Pencil className="h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
disabled={isPending}
onClick={() => onDuplicate(team)}
>
<CopyPlus className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isPending}
onClick={() => onDelete(team)}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
})}
</div>
) : null}
{!isLoading && teams.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center">
<p className="text-sm font-semibold tracking-tight">No teams yet</p>
<p className="mt-2 text-sm text-muted-foreground">
Create a team to group personas for quick deployment to channels.
</p>
</div>
) : null}
{error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error.message}
</p>
) : null}
</section>
);
}
@@ -0,0 +1,174 @@
import * as React from "react";
import {
useCreateTeamMutation,
useDeleteTeamMutation,
useTeamsQuery,
useUpdateTeamMutation,
} from "@/features/agents/hooks";
import type { CreateChannelManagedAgentsResult } from "@/features/agents/channelAgents";
import type {
AgentTeam,
Channel,
CreateTeamInput,
UpdateTeamInput,
} from "@/shared/api/types";
type TeamDialogState = {
description: string;
initialValues: CreateTeamInput | UpdateTeamInput;
submitLabel: string;
title: string;
} | null;
type ActionMessages = {
setActionNoticeMessage: (message: string | null) => void;
setActionErrorMessage: (message: string | null) => void;
};
type RefetchCallbacks = {
refetchManagedAgents: () => void;
refetchRelayAgents: () => void;
};
export function useTeamActions(
actions: ActionMessages,
refetch: RefetchCallbacks,
) {
const teamsQuery = useTeamsQuery();
const createTeamMutation = useCreateTeamMutation();
const updateTeamMutation = useUpdateTeamMutation();
const deleteTeamMutation = useDeleteTeamMutation();
const [teamDialogState, setTeamDialogState] =
React.useState<TeamDialogState>(null);
const [teamToDelete, setTeamToDelete] = React.useState<AgentTeam | null>(
null,
);
const [teamToAddToChannel, setTeamToAddToChannel] =
React.useState<AgentTeam | null>(null);
const teams = teamsQuery.data ?? [];
async function handleTeamSubmit(input: CreateTeamInput | UpdateTeamInput) {
actions.setActionNoticeMessage(null);
actions.setActionErrorMessage(null);
try {
if ("id" in input) {
await updateTeamMutation.mutateAsync(input);
actions.setActionNoticeMessage(`Updated team "${input.name}".`);
} else {
await createTeamMutation.mutateAsync(input);
actions.setActionNoticeMessage(`Created team "${input.name}".`);
}
setTeamDialogState(null);
} catch (error) {
actions.setActionErrorMessage(
error instanceof Error ? error.message : "Failed to save team.",
);
}
}
async function handleDeleteTeam(team: AgentTeam) {
actions.setActionNoticeMessage(null);
actions.setActionErrorMessage(null);
try {
await deleteTeamMutation.mutateAsync(team.id);
actions.setActionNoticeMessage(`Deleted team "${team.name}".`);
setTeamToDelete(null);
} catch (error) {
actions.setActionErrorMessage(
error instanceof Error ? error.message : "Failed to delete team.",
);
}
}
function handleTeamDeployed(
channel: Channel,
result: CreateChannelManagedAgentsResult,
) {
actions.setActionErrorMessage(null);
const successCount = result.successes.length;
const failCount = result.failures.length;
if (failCount === 0) {
actions.setActionNoticeMessage(
`Deployed ${successCount} ${successCount === 1 ? "agent" : "agents"} to ${channel.name}.`,
);
} else {
actions.setActionNoticeMessage(
`Deployed ${successCount} ${successCount === 1 ? "agent" : "agents"} to ${channel.name}. ${failCount} failed.`,
);
}
setTeamToAddToChannel(null);
refetch.refetchManagedAgents();
refetch.refetchRelayAgents();
}
function openCreateDialog() {
actions.setActionNoticeMessage(null);
actions.setActionErrorMessage(null);
setTeamDialogState({
title: "Create team",
description: "Group personas together for quick deployment to channels.",
submitLabel: "Create team",
initialValues: {
name: "",
description: "",
personaIds: [],
},
});
}
function openDuplicateDialog(team: AgentTeam) {
actions.setActionNoticeMessage(null);
actions.setActionErrorMessage(null);
setTeamDialogState({
title: `Duplicate ${team.name}`,
description: "Create a new team by copying this one.",
submitLabel: "Create team",
initialValues: {
name: `${team.name} copy`,
description: team.description ?? "",
personaIds: [...team.personaIds],
},
});
}
function openEditDialog(team: AgentTeam) {
actions.setActionNoticeMessage(null);
actions.setActionErrorMessage(null);
setTeamDialogState({
title: `Edit ${team.name}`,
description: "Update this team's name, description, or personas.",
submitLabel: "Save changes",
initialValues: {
id: team.id,
name: team.name,
description: team.description ?? "",
personaIds: [...team.personaIds],
},
});
}
return {
teams,
teamsQuery,
createTeamMutation,
updateTeamMutation,
deleteTeamMutation,
teamDialogState,
setTeamDialogState,
teamToDelete,
setTeamToDelete,
teamToAddToChannel,
setTeamToAddToChannel,
handleTeamSubmit,
handleDeleteTeam,
handleTeamDeployed,
openCreateDialog,
openDuplicateDialog,
openEditDialog,
};
}
@@ -4,10 +4,12 @@ import * as React from "react";
import {
useCreateChannelManagedAgentsMutation,
usePersonasQuery,
useTeamsQuery,
type CreateChannelManagedAgentResult,
} from "@/features/agents/hooks";
import { AddChannelBotGenericSection } from "@/features/channels/ui/AddChannelBotGenericSection";
import { AddChannelBotPersonasSection } from "@/features/channels/ui/AddChannelBotPersonasSection";
import { AddChannelBotTeamsSection } from "@/features/channels/ui/AddChannelBotTeamsSection";
import type { AcpProvider } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
@@ -81,8 +83,10 @@ export function AddChannelBotDialog({
onOpenChange,
}: AddChannelBotDialogProps) {
const personasQuery = usePersonasQuery();
const teamsQuery = useTeamsQuery();
const createBotsMutation = useCreateChannelManagedAgentsMutation(channelId);
const personas = personasQuery.data ?? [];
const teams = teamsQuery.data ?? [];
const [selectedProviderId, setSelectedProviderId] = React.useState("");
const [selectedPersonaIds, setSelectedPersonaIds] = React.useState<string[]>(
[],
@@ -155,6 +159,19 @@ export function AddChannelBotDialog({
onOpenChange(next);
}
function handleToggleTeam(personaIds: string[]) {
setSelectedPersonaIds((current) => {
const allSelected = personaIds.every((id) => current.includes(id));
if (allSelected) {
return current.filter((id) => !personaIds.includes(id));
}
const merged = new Set([...current, ...personaIds]);
return [...merged];
});
setSubmissionNotice(null);
setSubmissionError(null);
}
async function handleSubmit() {
if (!selectedProvider || selectedCount === 0) {
return;
@@ -291,6 +308,17 @@ export function AddChannelBotDialog({
</DropdownMenu>
</div>
{teams.length > 0 ? (
<AddChannelBotTeamsSection
canToggleSelections={canToggleSelections}
isLoading={teamsQuery.isLoading}
onToggleTeam={handleToggleTeam}
personas={personas}
selectedPersonaIds={selectedPersonaIds}
teams={teams}
/>
) : null}
<AddChannelBotPersonasSection
canToggleSelections={canToggleSelections}
includeGeneric={includeGeneric}
@@ -0,0 +1,162 @@
import { Users } from "lucide-react";
import type * as React from "react";
import type { AgentPersona, AgentTeam } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/ui/tooltip";
type SelectionChipButtonProps = {
disabled: boolean;
label: string;
onClick: () => void;
selected: boolean;
children: React.ReactNode;
};
function SelectionChipButton({
disabled,
label: _label,
onClick,
selected,
children,
}: SelectionChipButtonProps) {
return (
<button
aria-pressed={selected}
className={cn(
"inline-flex min-h-9 items-center gap-2 rounded-full border py-1.5 px-3 text-sm font-medium transition-colors",
selected
? "border-foreground bg-foreground text-background shadow-sm"
: "border-border/70 bg-muted/25 text-foreground hover:bg-muted/55",
disabled && "cursor-not-allowed opacity-50",
)}
disabled={disabled}
onClick={onClick}
type="button"
>
{children}
</button>
);
}
function resolveTeamPersonas(
team: AgentTeam,
personas: AgentPersona[],
): AgentPersona[] {
return team.personaIds
.map((id) => personas.find((p) => p.id === id))
.filter((p): p is AgentPersona => p !== undefined);
}
type AddChannelBotTeamsSectionProps = {
canToggleSelections: boolean;
isLoading: boolean;
onToggleTeam: (personaIds: string[]) => void;
personas: AgentPersona[];
selectedPersonaIds: readonly string[];
teams: AgentTeam[];
};
export function AddChannelBotTeamsSection({
canToggleSelections,
isLoading,
onToggleTeam,
personas,
selectedPersonaIds,
teams,
}: AddChannelBotTeamsSectionProps) {
if (isLoading || teams.length === 0) {
return null;
}
return (
<div className="space-y-3">
<div>
<div className="text-sm font-medium">Teams</div>
<p className="text-xs text-muted-foreground">
Select a team to toggle all its personas at once.
</p>
</div>
<TooltipProvider delayDuration={150}>
<div className="flex flex-wrap gap-2">
{teams.map((team) => {
const resolved = resolveTeamPersonas(team, personas);
const validIds = resolved.map((p) => p.id);
const allSelected =
validIds.length > 0 &&
validIds.every((id) => selectedPersonaIds.includes(id));
return (
<Tooltip key={team.id}>
<TooltipTrigger asChild>
<div>
<SelectionChipButton
disabled={!canToggleSelections || validIds.length === 0}
label={team.name}
onClick={() => onToggleTeam(validIds)}
selected={allSelected}
>
<Users
className={cn(
"h-4 w-4",
allSelected
? "text-background/70"
: "text-muted-foreground",
)}
/>
{team.name}
<span
className={cn(
"text-xs",
allSelected
? "text-background/60"
: "text-muted-foreground",
)}
>
({validIds.length})
</span>
</SelectionChipButton>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-left">
<div className="space-y-1.5">
<p className="font-medium">{team.name}</p>
{team.description ? (
<p className="text-[11px] text-primary-foreground/80">
{team.description}
</p>
) : null}
<div className="flex flex-wrap gap-1">
{resolved.map((persona) => (
<div
className="flex items-center gap-1 rounded-full bg-primary-foreground/10 px-1.5 py-0.5"
key={persona.id}
>
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-4 w-4 rounded-full text-[8px] bg-primary-foreground/20 text-primary-foreground"
label={persona.displayName}
/>
<span className="text-[10px] text-primary-foreground">
{persona.displayName}
</span>
</div>
))}
</div>
</div>
</TooltipContent>
</Tooltip>
);
})}
</div>
</TooltipProvider>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { invokeTauri } from "@/shared/api/tauri";
import type {
AgentTeam,
CreateTeamInput,
UpdateTeamInput,
} from "@/shared/api/types";
type RawTeam = {
id: string;
name: string;
description: string | null;
persona_ids: string[];
created_at: string;
updated_at: string;
};
function fromRawTeam(team: RawTeam): AgentTeam {
return {
id: team.id,
name: team.name,
description: team.description,
personaIds: team.persona_ids,
createdAt: team.created_at,
updatedAt: team.updated_at,
};
}
export async function listTeams(): Promise<AgentTeam[]> {
return (await invokeTauri<RawTeam[]>("list_teams")).map(fromRawTeam);
}
export async function createTeam(input: CreateTeamInput): Promise<AgentTeam> {
return fromRawTeam(
await invokeTauri<RawTeam>("create_team", {
input: {
name: input.name,
description: input.description,
personaIds: input.personaIds,
},
}),
);
}
export async function updateTeam(input: UpdateTeamInput): Promise<AgentTeam> {
return fromRawTeam(
await invokeTauri<RawTeam>("update_team", {
input: {
id: input.id,
name: input.name,
description: input.description,
personaIds: input.personaIds,
},
}),
);
}
export async function deleteTeam(id: string): Promise<void> {
await invokeTauri("delete_team", { id });
}
+24
View File
@@ -407,6 +407,30 @@ export type UpdatePersonaInput = {
systemPrompt: string;
};
// ── Team types ────────────────────────────────────────────────────────────────
export type AgentTeam = {
id: string;
name: string;
description: string | null;
personaIds: string[];
createdAt: string;
updatedAt: string;
};
export type CreateTeamInput = {
name: string;
description?: string;
personaIds: string[];
};
export type UpdateTeamInput = {
id: string;
name: string;
description?: string;
personaIds: string[];
};
// ── Forum types ───────────────────────────────────────────────────────────────
export type ThreadSummary = {