From ae0723cc554c7bfac0bb77756baec26623792716 Mon Sep 17 00:00:00 2001
From: Jiang Bohan
Date: Thu, 14 May 2026 13:10:28 +0800
Subject: [PATCH] feat(squads): add Create Agent entry on Squad detail
(MUL-2178)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a Create Agent button on the Squad detail Members tab, visible
only to workspace owner/admin (matching the AddSquadMember backend
gate). The dialog reuses the existing CreateAgentDialog — both the
manual and template paths now accept an optional squadId; when set,
the dialog runs addSquadMember after createAgent / createAgentFromTemplate
and skips the navigation to the agent detail page so the user lands
back on the Members tab.
Atomicity is best-effort frontend-serial (no new backend transaction):
on partial failure the dialog surfaces a warning toast and the agent
remains addable from the existing Add Member flow.
Co-authored-by: multica-agent
---
.../agents/components/create-agent-dialog.tsx | 60 ++++++++++++-
packages/views/locales/en/agents.json | 1 +
packages/views/locales/en/squads.json | 1 +
packages/views/locales/zh-Hans/agents.json | 1 +
packages/views/locales/zh-Hans/squads.json | 1 +
.../squads/components/squad-detail-page.tsx | 88 +++++++++++++++++--
6 files changed, 144 insertions(+), 8 deletions(-)
diff --git a/packages/views/agents/components/create-agent-dialog.tsx b/packages/views/agents/components/create-agent-dialog.tsx
index 137e46e77..0f948f505 100644
--- a/packages/views/agents/components/create-agent-dialog.tsx
+++ b/packages/views/agents/components/create-agent-dialog.tsx
@@ -108,6 +108,7 @@ export function CreateAgentDialog({
currentUserId,
template,
existingAgentNames,
+ squadId,
onClose,
onCreate,
}: {
@@ -129,6 +130,14 @@ export function CreateAgentDialog({
// when absent, default names are used verbatim and 409 stays the
// safety net.
existingAgentNames?: readonly string[];
+ // When set, every successful create (manual, duplicate, or template)
+ // is followed by addSquadMember(squadId, agent) so the new agent
+ // joins this squad. The template path also skips its usual
+ // navigation to the agent detail page so the user stays on the
+ // squad. If the squad-join call fails the agent still exists and
+ // the dialog surfaces a warning toast — the user can add it
+ // manually from the Members tab.
+ squadId?: string;
onClose: () => void;
// Returns the created Agent so the dialog can run a follow-up
// setAgentSkills with the IDs the user picked in the form. Pre-skill-
@@ -208,6 +217,36 @@ export function CreateAgentDialog({
setStep({ kind: "blank-form" });
};
+ // Shared squad-join follow-up. Returns nothing — the caller has
+ // already shown its create-success toast; we only need to surface a
+ // warning when the agent landed but the squad-join failed. Cache
+ // invalidation for the squad's members list rides along so the
+ // Members tab re-renders without a manual refetch.
+ const attachToSquad = async (agentId: string, displayName: string) => {
+ if (!squadId) return;
+ try {
+ await api.addSquadMember(squadId, {
+ member_type: "agent",
+ member_id: agentId,
+ });
+ if (wsId) {
+ queryClient.invalidateQueries({
+ queryKey: [...workspaceKeys.squads(wsId), squadId, "members"],
+ });
+ queryClient.invalidateQueries({
+ queryKey: [...workspaceKeys.squads(wsId), squadId],
+ });
+ }
+ } catch (err) {
+ toast.warning(
+ t(($) => $.create_dialog.squad_join_failed_toast, {
+ name: displayName,
+ error: err instanceof Error ? err.message : "unknown error",
+ }),
+ );
+ }
+ };
+
// Template path is one-click — picker card click goes straight to the
// API. Defaults: name auto-deduped, runtime = first usable one,
// visibility = workspace. User refines on the detail page if needed.
@@ -253,6 +292,14 @@ export function CreateAgentDialog({
t(($) => $.create_dialog.template_created_toast, { name: candidate }),
);
}
+ // Squad context: attach the freshly created agent to the squad
+ // before closing — and skip the agent-detail navigation so the
+ // user lands back on the squad page where they triggered the
+ // flow. Failures here are non-fatal; the agent exists and can be
+ // added manually if the join call 4xxs.
+ if (squadId && resp.agent.id) {
+ await attachToSquad(resp.agent.id, candidate);
+ }
onClose();
// Land on the new agent's detail page so the user can verify or
// customise instructions / skills / avatar — matches the navigation
@@ -260,8 +307,9 @@ export function CreateAgentDialog({
// response failed schema parsing (`agent.id === ""`, see schema
// fallback) we skip the navigation: the agent was created server-
// side, the list-invalidation above will surface it, and a push
- // to `/agents/` would land on a broken detail page.
- if (resp.agent.id) {
+ // to `/agents/` would land on a broken detail page. Squad-context
+ // entry also skips the push — the user wants to stay on Members.
+ if (resp.agent.id && !squadId) {
navigation.push(paths.agentDetail(resp.agent.id));
}
} catch (err) {
@@ -349,6 +397,14 @@ export function CreateAgentDialog({
);
}
}
+ // Squad context: attach the agent after skills land so the
+ // squad's Members tab shows the agent with its skills already
+ // in place. Atomicity is best-effort by design (see plan in
+ // MUL-2178) — a partial failure surfaces a warning toast and
+ // the user can retry from the Add Member dialog.
+ if (createdAgent && squadId) {
+ await attachToSquad(createdAgent.id, createdAgent.name);
+ }
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : t(($) => $.create_dialog.create_failed_toast));
diff --git a/packages/views/locales/en/agents.json b/packages/views/locales/en/agents.json
index 62336a32d..308a4f96c 100644
--- a/packages/views/locales/en/agents.json
+++ b/packages/views/locales/en/agents.json
@@ -224,6 +224,7 @@
"template_created_with_reuse_toast_one": "Agent \"{{name}}\" created. Reused {{count}} existing skill.",
"template_created_with_reuse_toast_other": "Agent \"{{name}}\" created. Reused {{count}} existing skills.",
"skill_attach_failed_toast": "Agent created, but failed to attach skills: {{error}}",
+ "squad_join_failed_toast": "Agent \"{{name}}\" was created but couldn't join the squad: {{error}}. You can add it manually from Members.",
"chooser": {
"blank_title": "Start blank",
"blank_desc": "Write everything yourself.",
diff --git a/packages/views/locales/en/squads.json b/packages/views/locales/en/squads.json
index 9337f6b0e..dfe4762ff 100644
--- a/packages/views/locales/en/squads.json
+++ b/packages/views/locales/en/squads.json
@@ -37,6 +37,7 @@
"section_count_one": "{{count}} member in this squad",
"section_count_other": "{{count}} members in this squad",
"add_member_button": "Add Member",
+ "create_agent_button": "Create Agent",
"leader_chip": "Leader"
},
"instructions_tab": {
diff --git a/packages/views/locales/zh-Hans/agents.json b/packages/views/locales/zh-Hans/agents.json
index c39959df3..c7bb3807b 100644
--- a/packages/views/locales/zh-Hans/agents.json
+++ b/packages/views/locales/zh-Hans/agents.json
@@ -219,6 +219,7 @@
"template_created_toast": "智能体「{{name}}」已创建",
"template_created_with_reuse_toast_other": "智能体「{{name}}」已创建。复用了 {{count}} 个现有 skill。",
"skill_attach_failed_toast": "智能体已创建,但 skill 关联失败:{{error}}",
+ "squad_join_failed_toast": "智能体「{{name}}」已创建,但加入小队失败:{{error}}。你可以在 Members 标签里手动添加。",
"chooser": {
"blank_title": "从零开始",
"blank_desc": "全部由你来填写。",
diff --git a/packages/views/locales/zh-Hans/squads.json b/packages/views/locales/zh-Hans/squads.json
index 11124b700..1b00f6c39 100644
--- a/packages/views/locales/zh-Hans/squads.json
+++ b/packages/views/locales/zh-Hans/squads.json
@@ -37,6 +37,7 @@
"section_count_one": "该小队有 {{count}} 名成员",
"section_count_other": "该小队有 {{count}} 名成员",
"add_member_button": "添加成员",
+ "create_agent_button": "创建智能体",
"leader_chip": "负责人"
},
"instructions_tab": {
diff --git a/packages/views/squads/components/squad-detail-page.tsx b/packages/views/squads/components/squad-detail-page.tsx
index cbe0a3b21..7f9e891c1 100644
--- a/packages/views/squads/components/squad-detail-page.tsx
+++ b/packages/views/squads/components/squad-detail-page.tsx
@@ -1,13 +1,16 @@
"use client";
-import { useEffect, useRef, useState, type ReactNode } from "react";
+import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@multica/core/api";
+import { useAuthStore } from "@multica/core/auth";
import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
import { useWorkspaceId } from "@multica/core/hooks";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { isImeComposing, timeAgo } from "@multica/core/utils";
import { agentListOptions, memberListOptions, workspaceKeys } from "@multica/core/workspace/queries";
+import { runtimeListOptions } from "@multica/core/runtimes";
+import { CreateAgentDialog } from "../../agents/components/create-agent-dialog";
import { useNavigation } from "../../navigation";
import { AppLink } from "../../navigation";
import { PageHeader } from "../../layout/page-header";
@@ -48,7 +51,7 @@ import {
} from "../../issues/components/pickers/property-picker";
import { ChevronDown, UserPlus } from "lucide-react";
import { toast } from "sonner";
-import type { Squad, SquadMember, Agent, MemberWithUser } from "@multica/core/types";
+import type { Squad, SquadMember, Agent, CreateAgentRequest, MemberWithUser } from "@multica/core/types";
import { useT } from "../../i18n";
export function SquadDetailPage() {
@@ -75,7 +78,24 @@ export function SquadDetailPage() {
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: wsMembers = [] } = useQuery(memberListOptions(wsId));
+ // Runtimes are only fetched when the Create Agent dialog might open;
+ // gating on isWorkspaceAdmin below means non-admins never trigger the
+ // request. The runtime list mirrors the agents page so the picker
+ // (and the "only my runtimes" filter) behaves identically here.
+ const currentUser = useAuthStore((s) => s.user);
+ const myRole = useMemo(() => {
+ if (!currentUser) return null;
+ return wsMembers.find((m) => m.user_id === currentUser.id)?.role ?? null;
+ }, [wsMembers, currentUser]);
+ const isWorkspaceAdmin = myRole === "owner" || myRole === "admin";
+
+ const { data: runtimes = [], isLoading: runtimesLoading } = useQuery({
+ ...runtimeListOptions(wsId),
+ enabled: !!wsId && isWorkspaceAdmin,
+ });
+
const [showAddMember, setShowAddMember] = useState(false);
+ const [showCreateAgent, setShowCreateAgent] = useState(false);
const updateSquadMut = useMutation({
mutationFn: (data: { name?: string; description?: string; instructions?: string; avatar_url?: string; leader_id?: string }) => api.updateSquad(squadId, data),
@@ -131,6 +151,25 @@ export function SquadDetailPage() {
onError: () => toast.error("Failed to archive squad"),
});
+ // CreateAgentDialog's onCreate contract: hit POST /api/agents and
+ // return the created agent so the dialog can run its skill follow-up.
+ // We deliberately do NOT navigate to the agent detail page (that's
+ // the agents-page behaviour) — the user clicked Create Agent from
+ // inside this squad, so the dialog will stay open just long enough
+ // to also call addSquadMember (handled by the dialog when squadId
+ // is set), then close the user back to Members where they can
+ // verify the new agent appeared. Cache-update keeps the agents list
+ // fresh for any pickers that read from it.
+ const handleCreateAgent = async (data: CreateAgentRequest): Promise => {
+ const agent = await api.createAgent(data);
+ queryClient.setQueryData(workspaceKeys.agents(wsId), (current = []) => {
+ const exists = current.some((a) => a.id === agent.id);
+ return exists ? current.map((a) => (a.id === agent.id ? agent : a)) : [...current, agent];
+ });
+ queryClient.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
+ return agent;
+ };
+
const getEntityName = (type: string, id: string) => {
if (type === "agent") return agents.find((a: Agent) => a.id === id)?.name ?? id.slice(0, 8);
return wsMembers.find((m) => m.user_id === id)?.name ?? id.slice(0, 8);
@@ -188,6 +227,7 @@ export function SquadDetailPage() {
isLeader={isLeader}
getEntityName={getEntityName}
onAddMemberClick={() => setShowAddMember(true)}
+ onCreateAgentClick={isWorkspaceAdmin ? () => setShowCreateAgent(true) : undefined}
onSetLeader={(id) => setLeaderMut.mutate(id)}
onRemoveMember={(m) => removeMemberMut.mutate(m)}
onUpdateRole={async (m, role) => { await updateRoleMut.mutateAsync({ member: m, role }); }}
@@ -204,6 +244,25 @@ export function SquadDetailPage() {
onSubmit={async (input) => { await addMemberMut.mutateAsync(input); }}
/>
)}
+
+ {/* Squad-scoped create flow: same dialog as the Agents page but
+ with squadId set, so the dialog runs addSquadMember after
+ createAgent / createAgentFromTemplate and skips the
+ agent-detail navigation. Only mounted for workspace
+ owner/admin since AddSquadMember is owner/admin-gated
+ server-side; for everyone else the trigger never renders. */}
+ {showCreateAgent && isWorkspaceAdmin && (
+ a.name)}
+ squadId={squadId}
+ onClose={() => setShowCreateAgent(false)}
+ onCreate={handleCreateAgent}
+ />
+ )}
);
}
@@ -846,6 +905,7 @@ function SquadOverviewPane({
isLeader,
getEntityName,
onAddMemberClick,
+ onCreateAgentClick,
onSetLeader,
onRemoveMember,
onUpdateRole,
@@ -857,6 +917,10 @@ function SquadOverviewPane({
isLeader: (m: SquadMember) => boolean;
getEntityName: (type: string, id: string) => string;
onAddMemberClick: () => void;
+ // Optional — only passed when the current user can manage the squad
+ // (workspace owner/admin). Hidden otherwise so plain members don't
+ // see a button they can't action.
+ onCreateAgentClick?: () => void;
onSetLeader: (agentId: string) => void;
onRemoveMember: (m: SquadMember) => void;
onUpdateRole: (m: SquadMember, role: string) => Promise;
@@ -910,6 +974,7 @@ function SquadOverviewPane({
isLeader={isLeader}
getEntityName={getEntityName}
onAddMemberClick={onAddMemberClick}
+ onCreateAgentClick={onCreateAgentClick}
onSetLeader={onSetLeader}
onRemoveMember={onRemoveMember}
onUpdateRole={onUpdateRole}
@@ -956,6 +1021,7 @@ function SquadMembersTab({
isLeader,
getEntityName,
onAddMemberClick,
+ onCreateAgentClick,
onSetLeader,
onRemoveMember,
onUpdateRole,
@@ -965,6 +1031,8 @@ function SquadMembersTab({
isLeader: (m: SquadMember) => boolean;
getEntityName: (type: string, id: string) => string;
onAddMemberClick: () => void;
+ // Hidden for non-admins — see SquadOverviewPane.
+ onCreateAgentClick?: () => void;
onSetLeader: (agentId: string) => void;
onRemoveMember: (m: SquadMember) => void;
onUpdateRole: (m: SquadMember, role: string) => Promise;
@@ -980,10 +1048,18 @@ function SquadMembersTab({
{t(($) => $.members_tab.section_count, { count: members.length })}