From d5e42d839c901db99e94b90a5b86d63e3c6834e0 Mon Sep 17 00:00:00 2001 From: potter Date: Thu, 14 May 2026 18:07:49 +0800 Subject: [PATCH] Complete create member flows --- .../src/pages/studio/index.test.tsx | 46 +++-- .../src/pages/studio/index.tsx | 163 ++++++++++++++---- 2 files changed, 163 insertions(+), 46 deletions(-) diff --git a/apps/aevatar-console-web/src/pages/studio/index.test.tsx b/apps/aevatar-console-web/src/pages/studio/index.test.tsx index 16b888129..660014d98 100644 --- a/apps/aevatar-console-web/src/pages/studio/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/studio/index.test.tsx @@ -3765,13 +3765,11 @@ describe("StudioPage", () => { }); expect( screen.getByText( - "Script starts as a named draft. It becomes a callable member only after Save script is catalog-applied and Bind succeeds.", + "Script creates a backend member and opens a stable script draft identity in Build. It becomes callable after Save script is catalog-applied and Bind succeeds.", ), ).toBeTruthy(); expect(screen.getByText(/Script id: refund-handler/)).toBeTruthy(); - fireEvent.click( - within(createDialog).getByRole("button", { name: "Create Script draft" }), - ); + fireEvent.click(within(createDialog).getByRole("button", { name: "Create member" })); expect(await screen.findByTestId("studio-script-build-panel")).toBeTruthy(); expect(screen.getByLabelText("Script ID")).toHaveValue("refund-handler"); @@ -3805,7 +3803,7 @@ describe("StudioPage", () => { ); expect( - within(createDialog).getByRole("button", { name: "Create Script draft" }) + within(createDialog).getByRole("button", { name: "Create member" }) ).toBeDisabled(); expect(screen.getByRole("dialog", { name: "Create member" })).toBeTruthy(); }); @@ -3832,12 +3830,12 @@ describe("StudioPage", () => { ).toHaveAttribute("aria-pressed", "true"); expect(within(createDialog).getByLabelText("Script name")).toHaveValue("script-1"); expect( - within(createDialog).getByRole("button", { name: "Create Script draft" }), + within(createDialog).getByRole("button", { name: "Create member" }), ).toBeEnabled(); }); - it("shows GAgent as a builder member kind before its create API lands", async () => { - renderStudioPage("/studio?focus=workflow%3Aworkflow-1&tab=studio"); + it("creates a GAgent member authority and opens GAgent Build", async () => { + renderStudioPage("/studio?scopeId=scope-1&focus=workflow%3Aworkflow-1&tab=studio"); fireEvent.click(await screen.findByRole("button", { name: "Create member" })); const createDialog = await screen.findByRole("dialog", { name: "Create member" }); @@ -3847,22 +3845,42 @@ describe("StudioPage", () => { }); fireEvent.click(gagentChip); - expect(gagentChip).toHaveAttribute("aria-pressed", "true"); + await waitFor(() => { + expect(gagentChip).toHaveAttribute("aria-pressed", "true"); + }); expect(within(createDialog).queryByLabelText("Member name")).toBeNull(); + const gagentNameInput = await within(createDialog).findByLabelText("GAgent name"); + expect(gagentNameInput).toHaveValue("gagent-1"); + fireEvent.change(gagentNameInput, { + target: { + value: "Orders GAgent", + }, + }); expect( - screen.getByText( - "GAgent member authority exists on backend, but this modal still hands off through Build > GAgent for implementation editing and binding prep.", + await screen.findByText( + "GAgent creates a backend member and opens Build > GAgent for actor type, role, prompt, tools, and persistence authoring.", ), ).toBeTruthy(); - fireEvent.click( - within(createDialog).getByRole("button", { name: "Open GAgent builder" }), - ); + fireEvent.click(within(createDialog).getByRole("button", { name: "Create member" })); expect(await screen.findByTestId("studio-gagent-build-panel")).toBeTruthy(); + expect(studioApi.createMember).toHaveBeenCalledWith( + expect.objectContaining({ + scopeId: "scope-1", + displayName: "Orders GAgent", + implementationKind: "gagent", + }), + ); + await waitFor(() => { + expect(message.success).toHaveBeenCalledWith( + "Created GAgent member Orders GAgent and opened Build.", + ); + }); await waitFor(() => { const searchParams = new URLSearchParams(window.location.search); expect(searchParams.get("tab")).toBe("gagents"); expect(searchParams.get("step")).toBe("build"); + expect(searchParams.get("member")).toBe("member:orders-gagent"); }); }); diff --git a/apps/aevatar-console-web/src/pages/studio/index.tsx b/apps/aevatar-console-web/src/pages/studio/index.tsx index 27a240867..49ae1060f 100644 --- a/apps/aevatar-console-web/src/pages/studio/index.tsx +++ b/apps/aevatar-console-web/src/pages/studio/index.tsx @@ -1030,6 +1030,25 @@ function buildInventoryScriptName( return `script-${Date.now()}`; } +function buildInventoryGAgentName( + members: ReadonlyArray, +): string { + const usedNames = new Set( + members + .map((member) => normalizeComparableText(member.displayName)) + .filter(Boolean), + ); + + for (let index = 1; index < 1000; index += 1) { + const candidate = `gagent-${index}`; + if (!usedNames.has(candidate)) { + return candidate; + } + } + + return `gagent-${Date.now()}`; +} + function upsertStudioMemberRosterMember( roster: StudioMemberRoster | undefined, scopeId: string, @@ -2810,6 +2829,10 @@ const StudioPage: React.FC = () => { () => buildInventoryScriptName(availableScopeScripts, studioScopeMembers), [availableScopeScripts, studioScopeMembers], ); + const suggestedCreateGAgentName = useMemo( + () => buildInventoryGAgentName(studioScopeMembers), + [studioScopeMembers], + ); const publishedScopeServiceRevisionQueries = useQueries({ queries: publishedScopeServices.map((service) => { const serviceId = trimOptional(service.serviceId); @@ -4755,7 +4778,9 @@ const StudioPage: React.FC = () => { useEffect(() => { if ( !createMemberModalOpen || - (createMemberKind !== 'workflow' && createMemberKind !== 'script') + (createMemberKind !== 'workflow' && + createMemberKind !== 'script' && + createMemberKind !== 'gagent') ) { return; } @@ -4867,19 +4892,80 @@ const StudioPage: React.FC = () => { return; } - setCreateMemberModalOpen(false); - setCreateMemberTeamId(''); - history.push( - buildStudioRoute({ - scopeId: resolvedStudioScopeId || undefined, - teamId: createMemberTeamId || undefined, - step: 'build', - tab: 'gagents', - }), - ); - setBuildSurface('gagent'); - setStudioSurface('build'); - void message.info('Opened GAgent builder.'); + const gAgentDisplayName = trimOptional(createMemberName); + if (!gAgentDisplayName) { + void message.warning('GAgent member name is required.'); + return; + } + + if ( + studioScopeMembers.some( + (member) => + normalizeComparableText(member.displayName) === + normalizeComparableText(gAgentDisplayName) && + normalizeStudioMemberBindingImplementationKind(member.implementationKind) === + 'gagent', + ) + ) { + void message.warning('A GAgent member with the same name already exists.'); + return; + } + + if (!resolvedStudioScopeId) { + void message.warning('Connect a workspace before creating a GAgent member.'); + return; + } + + setInventoryBusyKey('create'); + setInventoryBusyAction('create'); + try { + const createdGAgentMember = await studioApi.createMember({ + scopeId: resolvedStudioScopeId, + displayName: gAgentDisplayName, + implementationKind: 'gagent', + ...(createMemberTeamId ? { teamId: createMemberTeamId } : {}), + }); + queryClient.setQueryData( + ['studio-scope-members', resolvedStudioScopeId], + (current) => + upsertStudioMemberRosterMember( + current, + resolvedStudioScopeId, + createdGAgentMember, + ), + ); + void queryClient.invalidateQueries({ + queryKey: ['studio-scope-members', resolvedStudioScopeId], + }); + setSelectedWorkflowId(''); + setSelectedScriptId(''); + setTemplateWorkflow(''); + setCreateMemberModalOpen(false); + setCreateMemberTeamId(''); + history.push( + buildStudioRoute({ + scopeId: resolvedStudioScopeId, + teamId: createMemberTeamId || undefined, + memberKey: `member:${createdGAgentMember.memberId}`, + step: 'build', + tab: 'gagents', + }), + ); + setBuildSurface('gagent'); + setStudioSurface('build'); + void message.success( + `Created GAgent member ${createdGAgentMember.displayName} and opened Build.`, + ); + } catch (memberError) { + void message.error( + memberError instanceof Error + ? `Studio could not register the GAgent member authority: ${memberError.message}` + : 'Studio could not register the GAgent member authority.', + ); + } finally { + setInventoryBusyKey(''); + setInventoryBusyAction(''); + } return; } @@ -8894,13 +8980,7 @@ const StudioPage: React.FC = () => { title="Create member" onCancel={closeCreateMemberFlow} onOk={() => void handleCreateMember(createMemberKind)} - okText={ - createMemberKind === 'workflow' - ? 'Create member' - : createMemberKind === 'script' - ? 'Create Script draft' - : 'Open GAgent builder' - } + okText="Create member" okButtonProps={{ disabled: inventoryBusyAction === 'create' || @@ -8912,7 +8992,9 @@ const StudioPage: React.FC = () => { (createMemberKind === 'script' && (!appContextQuery.data?.features.scripts || !createScriptId || - createScriptIdAlreadyExists)), + createScriptIdAlreadyExists)) || + (createMemberKind === 'gagent' && + (!resolvedStudioScopeId || !trimOptional(createMemberName))), loading: inventoryBusyAction === 'create', }} cancelButtonProps={{ @@ -8953,6 +9035,8 @@ const StudioPage: React.FC = () => { setCreateMemberName(suggestedCreateWorkflowName); } else if (kind === 'script') { setCreateMemberName(suggestedCreateScriptName); + } else { + setCreateMemberName(suggestedCreateGAgentName); } }} > @@ -8961,24 +9045,37 @@ const StudioPage: React.FC = () => { ))}
- Choose the implementation kind first. Workflow entry now - registers a backend member authority; Script creates a named - draft identity before Build; GAgent opens its Build workspace - for implementation editing and binding prep. + Choose the implementation kind first. Studio creates the + backend member authority, then opens the matching Build + surface for Workflow, Script, or GAgent authoring.
- {createMemberKind === 'workflow' || createMemberKind === 'script' ? ( + {createMemberKind === 'workflow' || + createMemberKind === 'script' || + createMemberKind === 'gagent' ? (