mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): add agent teams (#132)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user