From fd5cbd651d9757e01587de6ed3ade4ecfb22efa4 Mon Sep 17 00:00:00 2001 From: potter Date: Mon, 27 Apr 2026 11:32:46 +0800 Subject: [PATCH 1/2] Route create team to Studio member creation --- apps/aevatar-console-web/config/routes.ts | 1 + .../src/app.layout.test.ts | 34 ++++++++++++++ apps/aevatar-console-web/src/app.tsx | 12 +++++ .../src/pages/studio/index.test.tsx | 19 ++++++++ .../src/pages/studio/index.tsx | 46 +++++++++++++++++++ .../src/pages/teams/home.test.tsx | 13 ++++++ .../src/pages/teams/home.tsx | 10 ++-- .../src/pages/teams/new.test.tsx | 37 +++++++++------ .../src/pages/teams/new.tsx | 45 +++++++++--------- .../src/routesConfig.test.ts | 1 + .../navigationMenuSelection.test.ts | 4 +- .../src/shared/studio/navigation.test.ts | 18 ++++++++ .../src/shared/studio/navigation.ts | 5 ++ 13 files changed, 204 insertions(+), 41 deletions(-) diff --git a/apps/aevatar-console-web/config/routes.ts b/apps/aevatar-console-web/config/routes.ts index 546913a78..ab7899472 100644 --- a/apps/aevatar-console-web/config/routes.ts +++ b/apps/aevatar-console-web/config/routes.ts @@ -37,6 +37,7 @@ export default [ path: "/teams/new", name: "Create Team", component: "./teams/new", + hideInMenu: true, menuGroupKey: "teams", }, { diff --git a/apps/aevatar-console-web/src/app.layout.test.ts b/apps/aevatar-console-web/src/app.layout.test.ts index dc2c8edc7..abb9b1180 100644 --- a/apps/aevatar-console-web/src/app.layout.test.ts +++ b/apps/aevatar-console-web/src/app.layout.test.ts @@ -4,6 +4,10 @@ import defaultSettings from "../config/defaultSettings"; import { layout } from "./app"; describe("layout menu collapse behavior", () => { + beforeEach(() => { + window.history.replaceState({}, "", "/teams"); + }); + it("keeps grouped navigation titles hidden in collapsed mode", () => { const runtimeLayout = layout({ initialState: { @@ -20,6 +24,36 @@ describe("layout menu collapse behavior", () => { }); }); + it("defaults the global menu to collapsed for Studio create-member intent", () => { + window.history.replaceState( + {}, + "", + "/studio?tab=studio&intent=create-member", + ); + + const runtimeLayout = layout({ + initialState: { + auth: {} as never, + settings: defaultSettings, + }, + }); + + expect(runtimeLayout.defaultCollapsed).toBe(true); + }); + + it("does not default-collapse the global menu for ordinary Studio entry", () => { + window.history.replaceState({}, "", "/studio?tab=studio"); + + const runtimeLayout = layout({ + initialState: { + auth: {} as never, + settings: defaultSettings, + }, + }); + + expect(runtimeLayout.defaultCollapsed).toBe(false); + }); + it("styles collapsed menu items without icons as visible tokens", () => { const globalStyles = fs.readFileSync( path.resolve(__dirname, "./global.less"), diff --git a/apps/aevatar-console-web/src/app.tsx b/apps/aevatar-console-web/src/app.tsx index 89a50c92c..2647ac719 100644 --- a/apps/aevatar-console-web/src/app.tsx +++ b/apps/aevatar-console-web/src/app.tsx @@ -53,6 +53,14 @@ function isStudioHostRoute(pathname: string): boolean { return STUDIO_HOST_ROUTES.has(pathname); } +function shouldDefaultCollapseLayout(pathname: string, search: string): boolean { + if (!isStudioHostRoute(pathname)) { + return false; + } + + return new URLSearchParams(search).get("intent") === "create-member"; +} + function buildLoginRoute(returnTo: string): string { const params = new URLSearchParams({ redirect: sanitizeReturnTo(returnTo), @@ -814,6 +822,10 @@ export const layout = ({ overflow: "hidden", padding: 0, }, + defaultCollapsed: shouldDefaultCollapseLayout( + window.location.pathname, + window.location.search, + ), logo: , }; }; 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 6a2b5b69a..5c91fc22e 100644 --- a/apps/aevatar-console-web/src/pages/studio/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/studio/index.test.tsx @@ -2836,6 +2836,25 @@ describe("StudioPage", () => { }); }); + it("opens the create-member modal once from the typed Studio intent", async () => { + renderStudioPage("/studio?tab=studio&intent=create-member"); + + const createDialog = await screen.findByRole("dialog", { name: "Create member" }); + expect(within(createDialog).getByLabelText("Member name")).toHaveValue("draft"); + expect(studioApi.saveWorkflow).not.toHaveBeenCalled(); + + fireEvent.click(within(createDialog).getByRole("button", { name: "Cancel" })); + + await waitFor(() => { + expect(screen.queryByRole("dialog", { name: "Create member" })).toBeNull(); + }); + + await waitFor(() => { + expect(screen.queryByRole("dialog", { name: "Create member" })).toBeNull(); + }); + expect(studioApi.saveWorkflow).not.toHaveBeenCalled(); + }); + it("shows script and gagent as member kinds before their create APIs land", async () => { renderStudioPage("/studio?focus=workflow%3Aworkflow-1&tab=studio"); diff --git a/apps/aevatar-console-web/src/pages/studio/index.tsx b/apps/aevatar-console-web/src/pages/studio/index.tsx index 7b9bcf62f..828258405 100644 --- a/apps/aevatar-console-web/src/pages/studio/index.tsx +++ b/apps/aevatar-console-web/src/pages/studio/index.tsx @@ -75,6 +75,7 @@ import type { ScopedScriptDetail } from '@/shared/studio/scriptsModels'; import { buildStudioRoute, type StudioBuildFocus, + type StudioIntent, type StudioStep, type StudioTab, } from '@/shared/studio/navigation'; @@ -128,6 +129,7 @@ type StudioRouteState = { step: StudioStep; focusKey: string; tab: StudioTab; + intent: StudioIntent | ''; prompt: string; executionId: string; logsMode: '' | 'popout'; @@ -755,6 +757,10 @@ function buildWorkflowFileName(workflowName: string): string { return `${normalizedWorkflowName}.yaml`; } +function parseStudioIntent(value: string | null | undefined): StudioIntent | '' { + return trimOptional(value) === 'create-member' ? 'create-member' : ''; +} + function readWorkflowIdFromMemberKey(memberKey: string): string { const normalizedMemberKey = trimOptional(memberKey); if (!normalizedMemberKey.startsWith('workflow:')) { @@ -805,6 +811,7 @@ function readStudioRouteState(search?: string): StudioRouteState { step: 'build', focusKey: '', tab: 'workflows', + intent: '', prompt: '', executionId: '', logsMode: '', @@ -825,6 +832,7 @@ function readStudioRouteState(search?: string): StudioRouteState { step: parseStudioStep(params.get('step')), focusKey: buildFocus.key, tab: parseStudioTab(params.get('tab')), + intent: parseStudioIntent(params.get('intent')), prompt: trimOptional(params.get('prompt')), executionId: trimOptional(params.get('execution')), logsMode: parseLogsMode(params.get('logs')), @@ -1005,6 +1013,12 @@ const StudioPage: React.FC = () => { const [appliedRouteSnapshot, setAppliedRouteSnapshot] = useState( locationSnapshot, ); + const [pendingCreateMemberIntentSnapshot, setPendingCreateMemberIntentSnapshot] = + useState(() => + readStudioRouteState().intent === 'create-member' + ? getLocationSnapshot() + : '', + ); const [promptHistory, setPromptHistory] = useState< PlaygroundPromptHistoryEntry[] >(() => loadPlaygroundPromptHistory()); @@ -1024,6 +1038,7 @@ const StudioPage: React.FC = () => { }); const scriptLeaveGuardRef = useRef<(() => Promise) | null>(null); const handledLocationSnapshotRef = useRef(locationSnapshot); + const handledCreateMemberIntentSnapshotRef = useRef(''); const executionLogsWindowRef = useRef(null); const [logsDetached, setLogsDetached] = useState(false); const [authRecoveryPending, setAuthRecoveryPending] = useState(false); @@ -1052,6 +1067,9 @@ const StudioPage: React.FC = () => { setAppliedRouteSnapshot((currentSnapshot) => currentSnapshot === locationSnapshot ? currentSnapshot : locationSnapshot, ); + if (routeState.intent === 'create-member') { + setPendingCreateMemberIntentSnapshot(locationSnapshot); + } setStudioSurface((currentSurface) => currentSurface === routeStudioSurface ? currentSurface : routeStudioSurface, ); @@ -1090,6 +1108,7 @@ const StudioPage: React.FC = () => { }, [ locationSnapshot, routeState.executionId, + routeState.intent, routeState.prompt, routeBuildFocus.kind, routeBuildFocus.value, @@ -2679,6 +2698,33 @@ const StudioPage: React.FC = () => { suggestedCreateWorkflowName, ]); + useEffect(() => { + if (!isStudioLocation || !pendingCreateMemberIntentSnapshot) { + return; + } + + if (!studioHostReady || createMemberModalOpen) { + return; + } + + if ( + handledCreateMemberIntentSnapshotRef.current === + pendingCreateMemberIntentSnapshot + ) { + return; + } + + handledCreateMemberIntentSnapshotRef.current = pendingCreateMemberIntentSnapshot; + setPendingCreateMemberIntentSnapshot(''); + void openCreateMemberFlow(); + }, [ + createMemberModalOpen, + isStudioLocation, + openCreateMemberFlow, + pendingCreateMemberIntentSnapshot, + studioHostReady, + ]); + const closeCreateMemberFlow = useCallback(() => { if (inventoryBusyKey === 'create') { return; diff --git a/apps/aevatar-console-web/src/pages/teams/home.test.tsx b/apps/aevatar-console-web/src/pages/teams/home.test.tsx index e11029924..bdb5d5785 100644 --- a/apps/aevatar-console-web/src/pages/teams/home.test.tsx +++ b/apps/aevatar-console-web/src/pages/teams/home.test.tsx @@ -196,6 +196,19 @@ describe("TeamsHomePage", () => { expect(params.get("scopeLabel")).toBeNull(); }); + it("routes Create Team directly into Studio member creation", async () => { + renderWithQueryClient(React.createElement(TeamsHomePage)); + + fireEvent.click(await screen.findByRole("button", { name: "组建新团队" })); + + expect(window.location.pathname).toBe("/studio"); + const params = new URLSearchParams(window.location.search); + expect(params.get("tab")).toBe("studio"); + expect(params.get("intent")).toBe("create-member"); + expect(params.get("teamName")).toBeNull(); + expect(params.get("entryName")).toBeNull(); + }); + it("does not show the roster view toggle when the homepage only has one visible team", async () => { renderWithQueryClient(React.createElement(TeamsHomePage)); diff --git a/apps/aevatar-console-web/src/pages/teams/home.tsx b/apps/aevatar-console-web/src/pages/teams/home.tsx index 20e794ecf..50cef87fc 100644 --- a/apps/aevatar-console-web/src/pages/teams/home.tsx +++ b/apps/aevatar-console-web/src/pages/teams/home.tsx @@ -23,7 +23,6 @@ import { loadRestorableAuthSession } from "@/shared/auth/session"; import { formatCompactDateTime } from "@/shared/datetime/dateTime"; import { history } from "@/shared/navigation/history"; import { - buildTeamCreateHref, buildTeamDetailHref, } from "@/shared/navigation/teamRoutes"; import { buildRuntimeRunsHref } from "@/shared/navigation/runtimeRoutes"; @@ -35,7 +34,10 @@ import { getStudioScopeBindingCurrentRevision, type StudioScopeBindingStatus, } from "@/shared/studio/models"; -import { buildStudioWorkflowWorkspaceRoute } from "@/shared/studio/navigation"; +import { + buildStudioRoute, + buildStudioWorkflowWorkspaceRoute, +} from "@/shared/studio/navigation"; import { AevatarInspectorEmpty, AevatarPageShell, @@ -1044,7 +1046,9 @@ const TeamsHomePage: React.FC = () => { } - title="Create Team" + title="Saved Draft Recovery" >
{ marginBottom: 20, }} > - - - - + + + +
{ margin: 0, }} > - Studio + Saved draft recovery
{ gap: 8, }} > - {['行为定义', '脚本行为', 'Agent 角色', '集成'].map((item) => ( + {['旧链接兼容', '草稿恢复', '显式进入 Studio', '不创建团队事实'].map((item) => ( {item} @@ -204,18 +204,18 @@ const TeamCreatePage: React.FC = () => { }} >
- 团队名称 + Legacy team label setTeamName(event.target.value)} />
- 入口名称 + Initial member label setEntryName(event.target.value)} @@ -225,10 +225,11 @@ const TeamCreatePage: React.FC = () => { type="secondary" style={{ gridColumn: '1 / -1', lineHeight: 1.6 }} > - 团队名称会显示在创建流程中;入口名称会作为 Studio 新草稿的默认名称。 - 如果入口名称留空,Studio 会自动复用团队名称。 + This compatibility page preserves old Create Team links and saved + draft recovery. New team creation now starts in Studio by creating + the first member. {hasSavedDraft - ? ' 这次创建流程已经有已保存草稿,重新进入 Studio 会继续编辑它。' + ? ' Continue in Studio to edit the linked initial member draft.' : ''}
@@ -239,7 +240,7 @@ const TeamCreatePage: React.FC = () => { onClick={openBuilder} style={primaryActionButtonStyle} > - Open Studio + Continue in Studio
@@ -293,7 +294,8 @@ const TeamCreatePage: React.FC = () => { 已保存草稿 {resolvedDraftWorkflowName} - 这份行为定义草稿已经和当前创建团队流程关联。再次进入 Studio 时,会继续编辑它。 + This workflow draft is linked from an old Create Team flow. Continue + in Studio to edit the initial member draft. - Delete Draft 会删除当前创建流程关联的行为草稿;团队名称和入口名称会保留在这个页面。 + Delete Draft removes the linked workflow draft. Legacy labels stay + in the URL so old links remain understandable.
diff --git a/apps/aevatar-console-web/src/routesConfig.test.ts b/apps/aevatar-console-web/src/routesConfig.test.ts index 074e562b1..be1c5ea2a 100644 --- a/apps/aevatar-console-web/src/routesConfig.test.ts +++ b/apps/aevatar-console-web/src/routesConfig.test.ts @@ -54,6 +54,7 @@ describe("console routes", () => { expect(findRoute(routes, "/teams").name).toBe("My Teams"); expect(findRoute(routes, "/teams").component).toBe("./teams"); expect(findRoute(routes, "/teams/new").name).toBe("Create Team"); + expect(findRoute(routes, "/teams/new").hideInMenu).toBe(true); expect(findRoute(routes, "/teams/:scopeId").component).toBe("./teams/detail"); expect(findRoute(routes, "/runtime/gagents").name).toBe("Members"); expect(findRoute(routes, "/scopes/assets").name).toBeUndefined(); diff --git a/apps/aevatar-console-web/src/shared/navigation/navigationMenuSelection.test.ts b/apps/aevatar-console-web/src/shared/navigation/navigationMenuSelection.test.ts index 15acf8254..996e1e454 100644 --- a/apps/aevatar-console-web/src/shared/navigation/navigationMenuSelection.test.ts +++ b/apps/aevatar-console-web/src/shared/navigation/navigationMenuSelection.test.ts @@ -1,8 +1,8 @@ import { getNavigationSelectedKeys } from "./navigationMenuSelection"; describe("getNavigationSelectedKeys", () => { - it("selects Create Team without keeping My Teams selected", () => { - expect(getNavigationSelectedKeys("/teams/new")).toEqual(["/teams/new"]); + it("does not select a primary navigation item for the hidden Create Team compatibility route", () => { + expect(getNavigationSelectedKeys("/teams/new")).toEqual([]); }); it("maps team detail pages back to My Teams", () => { diff --git a/apps/aevatar-console-web/src/shared/studio/navigation.test.ts b/apps/aevatar-console-web/src/shared/studio/navigation.test.ts index 9209deeb0..0e681ac90 100644 --- a/apps/aevatar-console-web/src/shared/studio/navigation.test.ts +++ b/apps/aevatar-console-web/src/shared/studio/navigation.test.ts @@ -65,6 +65,24 @@ describe('buildStudioRoute', () => { ).toBe('/studio?focus=workflow%3Aworkflow-1&tab=studio'); }); + it('supports the typed create-member Studio intent', () => { + expect( + buildStudioRoute({ + tab: 'studio', + intent: 'create-member', + }), + ).toBe('/studio?tab=studio&intent=create-member'); + }); + + it('drops invalid Studio intent values', () => { + expect( + buildStudioRoute({ + tab: 'studio', + intent: 'delete-team' as never, + }), + ).toBe('/studio?tab=studio'); + }); + it('supports opening the scripts workspace for a specific script', () => { expect( buildStudioRoute({ diff --git a/apps/aevatar-console-web/src/shared/studio/navigation.ts b/apps/aevatar-console-web/src/shared/studio/navigation.ts index 4b018505e..e46966ee7 100644 --- a/apps/aevatar-console-web/src/shared/studio/navigation.ts +++ b/apps/aevatar-console-web/src/shared/studio/navigation.ts @@ -9,6 +9,7 @@ export type StudioTab = export type StudioStep = 'build' | 'bind' | 'invoke' | 'observe'; export type StudioBuildFocus = `workflow:${string}` | `script:${string}` | `template:${string}`; +export type StudioIntent = 'create-member'; type StudioRouteOptions = { scopeId?: string; @@ -16,6 +17,7 @@ type StudioRouteOptions = { step?: StudioStep; focus?: StudioBuildFocus; tab?: StudioTab; + intent?: StudioIntent; prompt?: string; executionId?: string; logsMode?: 'popout'; @@ -96,6 +98,9 @@ export function buildStudioRoute(options?: StudioRouteOptions): string { if (tab) { params.set('tab', tab); } + if (options?.intent === 'create-member') { + params.set('intent', options.intent); + } if (options?.prompt?.trim()) { params.set('prompt', options.prompt.trim()); } From 075fcd46d22e14cb771484b43a6736777d647f4e Mon Sep 17 00:00:00 2001 From: potter Date: Mon, 27 Apr 2026 14:22:45 +0800 Subject: [PATCH 2/2] Fix Studio create-member route handling --- .../src/app.layout.test.ts | 27 +++++++++++++++++-- apps/aevatar-console-web/src/app.tsx | 14 +++++++--- .../src/pages/teams/home.test.tsx | 1 + .../src/pages/teams/home.tsx | 21 ++++++++++++--- 4 files changed, 53 insertions(+), 10 deletions(-) diff --git a/apps/aevatar-console-web/src/app.layout.test.ts b/apps/aevatar-console-web/src/app.layout.test.ts index abb9b1180..6148b241e 100644 --- a/apps/aevatar-console-web/src/app.layout.test.ts +++ b/apps/aevatar-console-web/src/app.layout.test.ts @@ -24,7 +24,7 @@ describe("layout menu collapse behavior", () => { }); }); - it("defaults the global menu to collapsed for Studio create-member intent", () => { + it("collapses the global menu for Studio create-member intent", () => { window.history.replaceState( {}, "", @@ -39,9 +39,10 @@ describe("layout menu collapse behavior", () => { }); expect(runtimeLayout.defaultCollapsed).toBe(true); + expect(runtimeLayout.collapsed).toBe(true); }); - it("does not default-collapse the global menu for ordinary Studio entry", () => { + it("leaves the global menu uncontrolled for ordinary Studio entry", () => { window.history.replaceState({}, "", "/studio?tab=studio"); const runtimeLayout = layout({ @@ -52,6 +53,28 @@ describe("layout menu collapse behavior", () => { }); expect(runtimeLayout.defaultCollapsed).toBe(false); + expect(runtimeLayout.collapsed).toBeUndefined(); + }); + + it("updates the controlled global menu collapse state after SPA route changes", () => { + window.history.replaceState({}, "", "/teams?scopeId=scope-a"); + const teamsLayout = layout({ + initialState: { + auth: {} as never, + settings: defaultSettings, + }, + }); + + window.history.pushState({}, "", "/studio?tab=studio&intent=create-member"); + const studioLayout = layout({ + initialState: { + auth: {} as never, + settings: defaultSettings, + }, + }); + + expect(teamsLayout.collapsed).toBeUndefined(); + expect(studioLayout.collapsed).toBe(true); }); it("styles collapsed menu items without icons as visible tokens", () => { diff --git a/apps/aevatar-console-web/src/app.tsx b/apps/aevatar-console-web/src/app.tsx index 2647ac719..60cc729aa 100644 --- a/apps/aevatar-console-web/src/app.tsx +++ b/apps/aevatar-console-web/src/app.tsx @@ -61,6 +61,10 @@ function shouldDefaultCollapseLayout(pathname: string, search: string): boolean return new URLSearchParams(search).get("intent") === "create-member"; } +function shouldCollapseLayout(pathname: string, search: string): boolean { + return shouldDefaultCollapseLayout(pathname, search); +} + function buildLoginRoute(returnTo: string): string { const params = new URLSearchParams({ redirect: sanitizeReturnTo(returnTo), @@ -650,6 +654,10 @@ const AuthSessionBootstrap: React.FC = ({ export const layout = ({ initialState, }: LayoutRuntimeProps): Record => { + const pathname = window.location.pathname; + const search = window.location.search; + const collapseForRoute = shouldCollapseLayout(pathname, search); + return { onPageChange: () => { const pathname = window.location.pathname; @@ -822,10 +830,8 @@ export const layout = ({ overflow: "hidden", padding: 0, }, - defaultCollapsed: shouldDefaultCollapseLayout( - window.location.pathname, - window.location.search, - ), + defaultCollapsed: shouldDefaultCollapseLayout(pathname, search), + ...(collapseForRoute ? { collapsed: true } : {}), logo: , }; }; diff --git a/apps/aevatar-console-web/src/pages/teams/home.test.tsx b/apps/aevatar-console-web/src/pages/teams/home.test.tsx index f423c8aaa..1378f2438 100644 --- a/apps/aevatar-console-web/src/pages/teams/home.test.tsx +++ b/apps/aevatar-console-web/src/pages/teams/home.test.tsx @@ -205,6 +205,7 @@ describe("TeamsHomePage", () => { expect(window.location.pathname).toBe("/studio"); const params = new URLSearchParams(window.location.search); + expect(params.get("scopeId")).toBe("scope-a"); expect(params.get("tab")).toBe("studio"); expect(params.get("intent")).toBe("create-member"); expect(params.get("teamName")).toBeNull(); diff --git a/apps/aevatar-console-web/src/pages/teams/home.tsx b/apps/aevatar-console-web/src/pages/teams/home.tsx index cc6225244..0515718ca 100644 --- a/apps/aevatar-console-web/src/pages/teams/home.tsx +++ b/apps/aevatar-console-web/src/pages/teams/home.tsx @@ -62,7 +62,6 @@ import { type WorkflowOperationalAttention, } from "./workflowOperationalUnits"; -const initialDraft = readScopeQueryDraft(); const scopeServiceAppId = "default"; const scopeServiceNamespace = "default"; const compactTeamRosterThreshold = 6; @@ -823,8 +822,12 @@ const ScopeBackedTeamRow: React.FC<{ const TeamsHomePage: React.FC = () => { const { token } = theme.useToken(); - const [draft, setDraft] = React.useState(initialDraft); - const [activeDraft, setActiveDraft] = React.useState(initialDraft); + const [draft, setDraft] = React.useState(() => + readScopeQueryDraft(), + ); + const [activeDraft, setActiveDraft] = React.useState(() => + readScopeQueryDraft(), + ); const [manualRosterView, setManualRosterView] = React.useState< "cards" | "list" | null >(null); @@ -1082,7 +1085,17 @@ const TeamsHomePage: React.FC = () => {