diff --git a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md
index ef6f8f38c..5124a744a 100644
--- a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md
+++ b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md
@@ -53,11 +53,17 @@ details disclosure; unavailable services are not presented as selectable.
only unresolved access needs warrant a separate notice and service list.
6. The user selects services and clicks **Save changes** or **Bind bot** explicitly.
Both actions follow the channel's existing accepted-to-observed confirmation.
- Access review itself never saves or binds the channel. Selected services that are no longer available
- must be reauthorized or deselected before saving.
+ Access review itself never saves or binds the channel. After a successful access
+ refresh, deleted, inactive or unauthorized services disappear from the selection
+ list and are removed from the draft, selected count and next submission. There
+ are no Unavailable placeholder rows or manual-deselection warnings. Later
+ reauthorization makes a service selectable again without restoring its old
+ selection. Pending or failed access requests never erase draft choices.
+ Required built-in services still block submission when unavailable. A missing
+ service explicitly requested by the URL remains in the separate access notice.
Both routes reuse `ChannelConfigurationPage`, `ChannelServicePicker` and
-`ChannelServiceAccessNotice`, including loading, retry and revoked-selection
+`ChannelServiceAccessNotice`, including loading, retry and revoked-selection cleanup
behavior. Bind needs no saved channel registration to review access.
The temporary draft is keyed by account subject, scope and a typed target:
@@ -111,7 +117,8 @@ authorization, explicit selection/save, draft restoration, cancellation,
redirect/storage failure and account isolation. Existing adapter and callback
tests protect grant validation and the shared return flow. Browser history
restoration coverage verifies fresh grants, temporary draft cleanup and the
-requirement to resolve revoked selections before saving. Bind route coverage
+removal of revoked selections from the list, count and submission, while failed
+refreshes preserve the draft. Bind route coverage
also verifies the complete return URL, explicit binding after refreshed grants,
accepted-versus-observed completion, restored skill overrides and clearing, and
isolation across bots and edit registrations. Full frontend
diff --git a/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts b/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts
index 2b69de662..bd2d2c291 100644
--- a/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts
+++ b/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts
@@ -214,7 +214,6 @@ export default {
'channels.edit.replaceDefaults':
'Saving replaces NyxID default access with the selected services.',
'channels.connect.searchServices': 'Search services by name or slug',
- 'channels.connect.serviceUnavailable': 'Unavailable',
'channels.connect.personal': 'Personal',
'channels.connect.organization': 'Organization',
'channels.connect.noMatches': 'No services match your search.',
@@ -266,6 +265,4 @@ export default {
'channels.access.startFailed':
'Could not open NyxID. Your changes are still here. Try again.',
'channels.access.requested': 'Requested',
- 'channels.access.unavailableSelection':
- 'Some selected services are unavailable. Restore their access in NyxID or deselect them before saving.',
};
diff --git a/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts b/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts
index aed7f5610..9ef384980 100644
--- a/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts
+++ b/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts
@@ -189,7 +189,6 @@ export default {
'channels.edit.replaceDefaults':
'保存后将使用所选服务替代 NyxID 默认授权范围。',
'channels.connect.searchServices': '按名称或标识搜索服务',
- 'channels.connect.serviceUnavailable': '不可用',
'channels.connect.personal': '个人',
'channels.connect.organization': '组织',
'channels.connect.noMatches': '没有匹配的服务。',
@@ -233,6 +232,4 @@ export default {
'channels.access.restored': '修改已保留,确认后请保存。',
'channels.access.startFailed': '无法打开 NyxID,当前修改已保留,请重试。',
'channels.access.requested': '本次需要',
- 'channels.access.unavailableSelection':
- '部分已选服务不可用。请在 NyxID 恢复权限,或取消勾选这些服务后再保存。',
};
diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelBindServiceAccess.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelBindServiceAccess.test.tsx
index 5d27e303e..d912f83cd 100644
--- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelBindServiceAccess.test.tsx
+++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelBindServiceAccess.test.tsx
@@ -239,6 +239,51 @@ it('preserves an explicitly cleared default skill after cancelled consent withou
expect(screen.getByRole('button', { name: 'Bind bot' })).toBeEnabled();
});
+it('removes a deleted service from the restored Bind draft, count and submission', async () => {
+ const review = jest
+ .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect')
+ .mockResolvedValue();
+ const first = mount();
+ await screen.findByText('Service access needed');
+ await chooseSupport();
+ fireEvent.click(screen.getByRole('checkbox', { name: /GitHub/ }));
+ fireEvent.click(
+ screen.getByRole('button', { name: /Manage service access/ }),
+ );
+ await waitFor(() => expect(review).toHaveBeenCalledTimes(1));
+ first.unmount();
+ const normalFetch = fetchMock.getMockImplementation();
+ if (!normalFetch) throw new Error('Missing request fixture');
+ fetchMock.mockImplementation((input, init) =>
+ String(input).endsWith('/user-services')
+ ? Promise.resolve(
+ response({
+ services: inventory.filter((item) => item.id !== 'us-github'),
+ }),
+ )
+ : normalFetch(input, init),
+ );
+ mount();
+ await screen.findByText(/Your changes have been kept/);
+ expect(screen.getByText('support', { exact: true })).toBeInTheDocument();
+ expect(
+ screen.queryByRole('checkbox', { name: /GitHub|us-github/ }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText('Unavailable', { exact: true }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText('2 selected')).toBeInTheDocument();
+ expect(writes()).toHaveLength(0);
+ fireEvent.click(screen.getByRole('button', { name: 'Bind bot' }));
+ await screen.findByText('Confirming your changes...');
+ expect(JSON.parse(String(writes()[0][1]?.body))).toEqual({
+ nyx_channel_bot_id: 'bot-alpha',
+ skill_name: 'support',
+ authorization_mode: 'explicit_service_allowlist',
+ service_ids: ['us-llm', 'us-ornn'],
+ });
+});
+
it('isolates bind drafts by bot and from edit registrations even when their raw IDs coincide', async () => {
const review = jest
.spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect')
diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx
index 23b4527d3..771e24497 100644
--- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx
+++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx
@@ -297,25 +297,41 @@ function ConfigurationForm({
const availableServices = (services.data ?? []).filter(
(service) => service.active && service.allowed,
);
- const requiredServices = (services.data ?? []).filter(
- (service) =>
- service.active &&
- service.allowed &&
- requiredServiceSlugs.some((slug) => service.slug === slug),
+ const hasFreshServiceAccess =
+ services.isFetchedAfterMount && services.isSuccess && !services.isFetching;
+ React.useEffect(() => {
+ if (!hasFreshServiceAccess) return;
+ // Only confirmed access changes remove draft choices. A failed or pending
+ // refresh must not erase them, and later reauthorization must not reselect them.
+ const availableIds = new Set(
+ services.data
+ ?.filter((service) => service.active && service.allowed)
+ .map((service) => service.id),
+ );
+ setServiceIds((selected) => {
+ const remaining = selected.filter((id) => availableIds.has(id));
+ return remaining.length === selected.length ? selected : remaining;
+ });
+ }, [hasFreshServiceAccess, services.data]);
+ const requiredServices = availableServices.filter((service) =>
+ requiredServiceSlugs.some((slug) => service.slug === slug),
);
const requiredIds = requiredServices.map((service) => service.id);
const missingRequiredSlugs = requiredServiceSlugs.filter(
(slug) => !requiredServices.some((service) => service.slug === slug),
);
- const serviceIds = [...new Set([...chosenServiceIds, ...requiredIds])];
+ const serviceIds = [
+ ...new Set([
+ ...chosenServiceIds.filter(
+ (id) =>
+ !hasFreshServiceAccess ||
+ availableServices.some((service) => service.id === id),
+ ),
+ ...requiredIds,
+ ]),
+ ];
const servicesReady =
- services.isFetchedAfterMount &&
- !services.isFetching &&
- !services.isError &&
- serviceIds.every((id) =>
- availableServices.some((service) => service.id === id),
- ) &&
- missingRequiredSlugs.length === 0;
+ hasFreshServiceAccess && missingRequiredSlugs.length === 0;
const toast = useConsoleToast();
const client = useQueryClient();
const listHref = buildWorkflowActivitySectionHref(scopeId, 'channels');
@@ -334,23 +350,6 @@ function ConfigurationForm({
? configDirty || labelDirty
: Boolean(skillName || chosenServiceIds.length);
const busy = submitting || Boolean(receipt) || uncertain || reviewPending;
- const options = [
- ...(services.data ?? []).filter(
- (service) =>
- (service.active && service.allowed) || serviceIds.includes(service.id),
- ),
- ...serviceIds
- .filter((id) => !services.data?.some((service) => service.id === id))
- .map((id) => ({
- id,
- slug: id,
- label: id,
- active: false,
- allowed: false,
- source: 'unknown' as const,
- organizationName: null,
- })),
- ];
React.useEffect(() => {
mounted.current = true;
return () => {
@@ -661,7 +660,7 @@ function ConfigurationForm({
) : null}
- {t( - 'channels.access.unavailableSelection', - 'Some selected services are unavailable. Restore their access in NyxID or deselect them before saving.', - )} -
- ) : null} {loading ? (