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 })}

- +
+ {onCreateAgentClick && ( + + )} + +