Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
ae0723cc55 feat(squads): add Create Agent entry on Squad detail (MUL-2178)
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 <github@multica.ai>
2026-05-14 13:10:28 +08:00
6 changed files with 144 additions and 8 deletions

View File

@@ -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));

View File

@@ -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.",

View File

@@ -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": {

View File

@@ -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": "全部由你来填写。",

View File

@@ -37,6 +37,7 @@
"section_count_one": "该小队有 {{count}} 名成员",
"section_count_other": "该小队有 {{count}} 名成员",
"add_member_button": "添加成员",
"create_agent_button": "创建智能体",
"leader_chip": "负责人"
},
"instructions_tab": {

View File

@@ -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<Agent> => {
const agent = await api.createAgent(data);
queryClient.setQueryData<Agent[]>(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 && (
<CreateAgentDialog
runtimes={runtimes}
runtimesLoading={runtimesLoading}
members={wsMembers}
currentUserId={currentUser?.id ?? null}
existingAgentNames={agents.map((a: Agent) => a.name)}
squadId={squadId}
onClose={() => setShowCreateAgent(false)}
onCreate={handleCreateAgent}
/>
)}
</div>
);
}
@@ -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<void>;
@@ -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<void>;
@@ -980,10 +1048,18 @@ function SquadMembersTab({
{t(($) => $.members_tab.section_count, { count: members.length })}
</p>
</div>
<Button size="sm" variant="outline" onClick={onAddMemberClick}>
<Plus className="size-3.5 mr-1.5" />
{t(($) => $.members_tab.add_member_button)}
</Button>
<div className="flex items-center gap-2">
{onCreateAgentClick && (
<Button size="sm" variant="outline" onClick={onCreateAgentClick}>
<Plus className="size-3.5 mr-1.5" />
{t(($) => $.members_tab.create_agent_button)}
</Button>
)}
<Button size="sm" variant="outline" onClick={onAddMemberClick}>
<Plus className="size-3.5 mr-1.5" />
{t(($) => $.members_tab.add_member_button)}
</Button>
</div>
</div>
<div className="space-y-2">