Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -284,9 +284,17 @@ describe('StudioMemberBindPanel', () => {
expect(screen.getByTestId('studio-bind-smoke-test-section')).toBeTruthy();
expect(screen.getByTestId('studio-bind-snippet-section')).toBeTruthy();
expect(screen.getByTestId('studio-bind-supporting-section')).toBeTruthy();
fireEvent.click(screen.getByText('Published contract source'));
expect(await screen.findByText('Published service')).toBeTruthy();
expect(primaryGrid.contains(screen.getByText('Published service'))).toBe(false);
expect(screen.getByText('Current member publication')).toBeTruthy();
expect(screen.getByText('member:default')).toBeTruthy();
expect(screen.queryByRole('combobox')).toBeNull();
expect(
screen.queryByText('Select a published service'),
).toBeNull();
expect(screen.getByRole('button', { name: 'Chat' })).toHaveAttribute(
'aria-pressed',
'true',
);
expect(screen.queryByText('Published service')).toBeNull();
expect(screen.queryByText('Binding Contract')).toBeNull();
expect(screen.queryByText('Current contract')).toBeNull();
expect(screen.queryByText('Published contract context')).toBeNull();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
LinkOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import { Alert, Button, Collapse, Empty, Input, Select, Space, Tag, Typography, message } from 'antd';
import { Alert, Button, Collapse, Empty, Input, Space, Tag, Typography, message } from 'antd';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
applyRuntimeEvent,
Expand Down Expand Up @@ -249,9 +249,31 @@ const sourceControlStackStyle: React.CSSProperties = {
minWidth: 0,
};

const sourceControlSelectStyle: React.CSSProperties = {
height: 58,
width: '100%',
const endpointChoiceRowStyle: React.CSSProperties = {
display: 'flex',
flexWrap: 'wrap',
gap: 6,
};

const endpointChoiceButtonStyle: React.CSSProperties = {
alignItems: 'center',
background: '#ffffff',
border: '1px solid #d9e2ef',
borderRadius: 999,
color: '#334155',
cursor: 'pointer',
display: 'inline-flex',
fontSize: 12,
fontWeight: 700,
minHeight: 30,
padding: '0 10px',
};

const endpointChoiceButtonActiveStyle: React.CSSProperties = {
...endpointChoiceButtonStyle,
background: '#111827',
border: '1px solid #111827',
color: '#ffffff',
};

const parameterGridStyle: React.CSSProperties = {
Expand Down Expand Up @@ -726,24 +748,6 @@ const StudioMemberBindPanel: React.FC<StudioMemberBindPanelProps> = ({
smokeInput,
]);

const serviceOptions = useMemo(
() =>
services.map((service) => ({
label: service.displayName || service.serviceId,
value: service.serviceId,
})),
[services],
);

const endpointOptions = useMemo(
() =>
(selectedService?.endpoints ?? []).map((endpoint) => ({
label: endpoint.displayName || endpoint.endpointId,
value: endpoint.endpointId,
})),
[selectedService?.endpoints],
);

const snippetMap = useMemo(() => {
if (!bindContract) {
return {
Expand All @@ -763,7 +767,6 @@ const StudioMemberBindPanel: React.FC<StudioMemberBindPanelProps> = ({
const selectedSnippet = snippetMap[snippetTab];
const bindingCatalog: ScopeServiceBindingCatalogSnapshot | undefined = bindingsQuery.data;
const bindingList = bindingCatalog?.bindings ?? [];
const hasMultiplePublishedServices = services.length > 1;
const revisionList = revisionCatalogQuery.data?.revisions ?? [];
const hasEndpointOptions = Boolean(selectedService?.endpoints.length);
const endpointUnavailableMessage =
Expand Down Expand Up @@ -949,12 +952,12 @@ const StudioMemberBindPanel: React.FC<StudioMemberBindPanelProps> = ({
<AevatarPanel
layoutMode="document"
padding={14}
title="Published contract source"
titleHelp="Choose the service and endpoint first. The invoke URL, smoke test, and snippets all follow this selection."
title="Current member publication"
titleHelp="Bind is pinned to the selected member. Published service ids stay visible only as supporting diagnostics."
extra={
<Space wrap size={[6, 6]}>
<Tag color={bindContract ? 'green' : 'default'}>
{bindContract ? 'contract selected' : 'needs endpoint'}
{bindContract ? 'member contract selected' : 'needs endpoint'}
</Tag>
{revisionList.length > 0 ? (
<Tag>revisions · {revisionList.length}</Tag>
Expand Down Expand Up @@ -988,46 +991,53 @@ const StudioMemberBindPanel: React.FC<StudioMemberBindPanelProps> = ({
</div>
<div style={controlsGridStyle}>
<div style={sourceControlStackStyle}>
<Typography.Text type="secondary">Published service</Typography.Text>
{hasMultiplePublishedServices ? (
<Select
options={serviceOptions}
placeholder="Select a published service"
style={sourceControlSelectStyle}
value={selectedServiceId || undefined}
onChange={(value) => {
setSelectedServiceId(String(value || ''));
setSelectedEndpointId('');
}}
/>
<Typography.Text type="secondary">Current member</Typography.Text>
<div style={valueCardStyle}>
<Typography.Text strong style={{ wordBreak: 'break-word' }}>
{selectedService?.displayName ||
selectedService?.serviceId ||
'No published contract'}
</Typography.Text>
<Typography.Text type="secondary">
{normalizedMemberId
? `member:${normalizedMemberId}`
: 'No member selected'}
</Typography.Text>
</div>
</div>
<div style={sourceControlStackStyle}>
<Typography.Text type="secondary">Endpoint</Typography.Text>
{selectedService && hasEndpointOptions ? (
<div style={endpointChoiceRowStyle}>
{selectedService.endpoints.map((endpoint) => {
const active = endpoint.endpointId === selectedEndpointId;
return (
<button
aria-pressed={active}
className={AEVATAR_INTERACTIVE_CHIP_CLASS}
key={endpoint.endpointId}
type="button"
style={
active
? endpointChoiceButtonActiveStyle
: endpointChoiceButtonStyle
}
onClick={() => setSelectedEndpointId(endpoint.endpointId)}
>
{endpoint.displayName || endpoint.endpointId}
</button>
);
})}
</div>
) : (
<div style={valueCardStyle}>
<Typography.Text strong style={{ wordBreak: 'break-word' }}>
{selectedService?.displayName ||
selectedService?.serviceId ||
'No published service'}
</Typography.Text>
<Typography.Text strong>No endpoint data available</Typography.Text>
<Typography.Text type="secondary">
{selectedService?.serviceId || 'No service id'}
Bind can still show revision diagnostics below.
</Typography.Text>
</div>
)}
</div>
<div style={sourceControlStackStyle}>
<Typography.Text type="secondary">Endpoint</Typography.Text>
<Select
disabled={!selectedService || !hasEndpointOptions}
options={endpointOptions}
placeholder={
hasEndpointOptions
? 'Select an endpoint'
: 'No endpoint data available'
}
style={sourceControlSelectStyle}
value={selectedEndpointId || undefined}
onChange={(value) => setSelectedEndpointId(String(value || ''))}
/>
</div>
</div>
{endpointUnavailableMessage ? (
<Alert
Expand Down Expand Up @@ -1335,6 +1345,17 @@ const StudioMemberBindPanel: React.FC<StudioMemberBindPanelProps> = ({
label: 'Contract details',
children: bindContract ? (
<div style={parameterGridStyle}>
<div style={valueCardStyle}>
<Typography.Text type="secondary">
Published service
</Typography.Text>
<Typography.Text strong style={{ wordBreak: 'break-word' }}>
{bindContract.serviceId}
</Typography.Text>
<Typography.Text type="secondary">
Platform diagnostic id for this member contract.
</Typography.Text>
</div>
<div style={valueCardStyle}>
<Typography.Text type="secondary">Workspace ID</Typography.Text>
<Typography.Text strong style={{ wordBreak: 'break-word' }}>
Expand Down
72 changes: 72 additions & 0 deletions apps/aevatar-console-web/src/pages/studio/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4114,6 +4114,78 @@ describe("StudioPage", () => {
expect(screen.queryByText("services:default,billing-api")).toBeNull();
});

it("keeps Invoke on the selected member when a stale bind selection exists", async () => {
mockStudioMembers = [
...mockStudioMembers,
{
memberId: "support-member",
scopeId: "scope-1",
displayName: "support-member",
description: "Support member",
implementationKind: "workflow",
lifecycleStage: "bind_ready",
publishedServiceId: "support-service",
lastBoundRevisionId: "rev-support",
createdAt: "2026-04-27T08:00:00Z",
updatedAt: "2026-04-27T08:05:00Z",
},
];
mockScopeRuntimeApi.listServices.mockResolvedValue([
{
serviceId: "default",
displayName: "workspace-demo",
deploymentStatus: "Active",
primaryActorId: "actor-default",
endpoints: [
{
endpointId: "chat",
displayName: "Chat",
kind: "chat",
description: "Chat with workspace-demo.",
requestTypeUrl: "",
responseTypeUrl: "",
},
],
},
{
serviceId: "support-service",
displayName: "support-member",
deploymentStatus: "Active",
primaryActorId: "actor-support",
endpoints: [
{
endpointId: "support-chat",
displayName: "Support chat",
kind: "chat",
description: "Chat with support.",
requestTypeUrl: "",
responseTypeUrl: "",
},
],
},
]);

renderStudioPage(
"/studio?scopeId=scope-1&member=member%3Aworkspace-demo&step=bind"
);

expect(await screen.findByTestId("studio-bind-surface")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Select bind endpoint" }));

await replaceStudioRoute(
"/studio?scopeId=scope-1&member=member%3Asupport-member&step=invoke"
);

expect(await screen.findByTestId("studio-invoke-surface")).toBeTruthy();
await waitFor(() => {
expect(screen.getByText("service:support-service")).toBeTruthy();
expect(screen.getByText("member:support-member")).toBeTruthy();
expect(screen.getByText("services:support-service")).toBeTruthy();
expect(screen.getByText("endpoint:support-chat")).toBeTruthy();
});
expect(screen.queryByText("service:default")).toBeNull();
});

it("shows an invoke empty state when a bound member has no endpoint data", async () => {
mockScopeRuntimeApi.listServices.mockResolvedValueOnce([
{
Expand Down
9 changes: 5 additions & 4 deletions apps/aevatar-console-web/src/pages/studio/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7226,10 +7226,11 @@ const StudioPage: React.FC = () => {
const hasInvokeTargetMemberSelection =
Boolean(workbenchStudioMemberId);
const invokeTargetServiceId =
currentInvokeSelectionServiceId ||
currentBindingSelectionServiceId ||
currentSelectedMemberServiceId ||
trimOptional(routeState.memberId);
hasInvokeTargetMemberSelection
? currentSelectedMemberServiceId
: currentInvokeSelectionServiceId ||
currentBindingSelectionServiceId ||
trimOptional(routeState.memberId);
const invokeTargetService = useMemo(
() => {
if (!invokeTargetServiceId) {
Expand Down
Loading