Align desktop ACP card actions

This commit is contained in:
Haitao Pan 2026-04-04 14:08:46 +08:00
parent 94b6e38738
commit b2ddbd62c0
4 changed files with 332 additions and 145 deletions

View File

@ -68,6 +68,14 @@ class SettingsPageStateInternal extends State<SettingsPage> {
late final TextEditingController vaultTokenControllerInternal;
late final TextEditingController ollamaApiKeyControllerInternal;
late final TextEditingController runtimeLogFilterControllerInternal;
late final Map<String, TextEditingController>
externalAcpLabelControllersInternal;
late final Map<String, TextEditingController>
externalAcpEndpointControllersInternal;
late final Map<String, String> externalAcpLabelSyncedValuesInternal;
late final Map<String, String> externalAcpEndpointSyncedValuesInternal;
late final Map<String, String> externalAcpMessageByProviderInternal;
late final Set<String> externalAcpTestingProvidersInternal;
bool gatewayTestingInternal = false;
String gatewayTestStateInternal = 'idle';
String gatewayTestMessageInternal = '';
@ -138,6 +146,12 @@ class SettingsPageStateInternal extends State<SettingsPage> {
vaultTokenControllerInternal = TextEditingController();
ollamaApiKeyControllerInternal = TextEditingController();
runtimeLogFilterControllerInternal = TextEditingController();
externalAcpLabelControllersInternal = <String, TextEditingController>{};
externalAcpEndpointControllersInternal = <String, TextEditingController>{};
externalAcpLabelSyncedValuesInternal = <String, String>{};
externalAcpEndpointSyncedValuesInternal = <String, String>{};
externalAcpMessageByProviderInternal = <String, String>{};
externalAcpTestingProvidersInternal = <String>{};
}
void setStateInternal(VoidCallback fn) => setState(fn);
@ -201,6 +215,12 @@ class SettingsPageStateInternal extends State<SettingsPage> {
vaultTokenControllerInternal.dispose();
ollamaApiKeyControllerInternal.dispose();
runtimeLogFilterControllerInternal.dispose();
for (final controller in externalAcpLabelControllersInternal.values) {
controller.dispose();
}
for (final controller in externalAcpEndpointControllersInternal.values) {
controller.dispose();
}
super.dispose();
}

View File

@ -163,10 +163,7 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal {
GatewayIntegrationSubTabInternal.skills => <Widget>[
buildCollapsibleGatewaySectionInternal(
context: context,
title: appText(
'SKILLS 目录授权',
'SKILLS Directory Authorization',
),
title: appText('SKILLS 目录授权', 'SKILLS Directory Authorization'),
expanded: skillsDirectoryAuthorizationExpandedInternal,
onChanged: (value) => setStateInternal(() {
skillsDirectoryAuthorizationExpandedInternal = value;
@ -248,10 +245,7 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal {
const SizedBox(height: 16),
buildCollapsibleGatewaySectionInternal(
context: context,
title: appText(
'SKILLS 目录授权',
'SKILLS Directory Authorization',
),
title: appText('SKILLS 目录授权', 'SKILLS Directory Authorization'),
expanded: skillsDirectoryAuthorizationExpandedInternal,
onChanged: (value) => setStateInternal(() {
skillsDirectoryAuthorizationExpandedInternal = value;
@ -269,6 +263,7 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal {
AppController controller,
SettingsSnapshot settings,
) {
syncExternalAcpDraftControllersInternal(settings);
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -291,14 +286,13 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal {
settings,
),
icon: const Icon(Icons.add_rounded),
label: Text(
appText('添加更多自定义配置', 'Add more custom configurations'),
),
label: Text(appText('添加更多自定义配置', 'Add more custom configurations')),
),
),
const SizedBox(height: 16),
...settings.externalAcpEndpoints.map(
(profile) => Padding(
key: ValueKey('external-acp-card-${profile.providerKey}'),
padding: const EdgeInsets.only(bottom: 12),
child: buildExternalAcpProviderCardInternal(
context,
@ -319,8 +313,16 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal {
ExternalAcpEndpointProfile profile,
) {
final provider = profile.toProvider();
final endpoint = profile.endpoint.trim();
final configured = endpoint.isNotEmpty;
final labelController =
externalAcpLabelControllersInternal[profile.providerKey]!;
final endpointController =
externalAcpEndpointControllersInternal[profile.providerKey]!;
final message =
externalAcpMessageByProviderInternal[profile.providerKey] ?? '';
final testing = externalAcpTestingProvidersInternal.contains(
profile.providerKey,
);
final configured = endpointController.text.trim().isNotEmpty;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@ -366,27 +368,21 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal {
],
),
const SizedBox(height: 12),
EditableFieldInternal(
label: appText('显示名称', 'Display name'),
value: profile.label,
onSubmitted: (value) => saveSettingsInternal(
controller,
settings.copyWithExternalAcpEndpointForProvider(
provider,
profile.copyWith(label: value),
),
TextField(
key: ValueKey('external-acp-label-${profile.providerKey}'),
controller: labelController,
decoration: InputDecoration(
labelText: appText('显示名称', 'Display name'),
),
onChanged: (_) => setStateInternal(() {}),
),
EditableFieldInternal(
label: appText('ACP Server Endpoint', 'ACP Server Endpoint'),
value: endpoint,
onSubmitted: (value) => saveSettingsInternal(
controller,
settings.copyWithExternalAcpEndpointForProvider(
provider,
profile.copyWith(endpoint: value),
),
TextField(
key: ValueKey('external-acp-endpoint-${profile.providerKey}'),
controller: endpointController,
decoration: InputDecoration(
labelText: appText('ACP Server Endpoint', 'ACP Server Endpoint'),
),
onChanged: (_) => setStateInternal(() {}),
),
Text(
appText(
@ -395,11 +391,132 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal {
),
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
OutlinedButton(
key: ValueKey('external-acp-test-${profile.providerKey}'),
onPressed: testing
? null
: () => testExternalAcpEndpointInternal(
controller,
profile.providerKey,
),
child: Text(
testing
? appText('测试中...', 'Testing...')
: appText('测试连接', 'Test Connection'),
),
),
FilledButton(
key: ValueKey('external-acp-apply-${profile.providerKey}'),
onPressed: () => saveExternalAcpEndpointInternal(
controller,
settings,
provider,
profile,
),
child: Text(appText('保存并生效', 'Save & apply')),
),
],
),
if (message.trim().isNotEmpty) ...[
const SizedBox(height: 10),
Text(
message,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
),
);
}
Future<void> saveExternalAcpEndpointInternal(
AppController controller,
SettingsSnapshot settings,
SingleAgentProvider provider,
ExternalAcpEndpointProfile profile,
) async {
final label =
externalAcpLabelControllersInternal[profile.providerKey]?.text ??
profile.label;
final endpoint =
externalAcpEndpointControllersInternal[profile.providerKey]?.text ??
profile.endpoint;
final next = settings.copyWithExternalAcpEndpointForProvider(
provider,
profile.copyWith(label: label, endpoint: endpoint),
);
await saveSettingsInternal(controller, next);
await handleTopLevelApplyInternal(controller);
if (!mounted) {
return;
}
setStateInternal(() {
externalAcpMessageByProviderInternal[profile.providerKey] = appText(
'配置已保存并生效。',
'Configuration saved and applied.',
);
});
}
Future<void> testExternalAcpEndpointInternal(
AppController controller,
String providerKey,
) async {
final endpointText =
externalAcpEndpointControllersInternal[providerKey]?.text.trim() ?? '';
final endpoint = Uri.tryParse(endpointText);
if (endpoint == null || endpoint.host.trim().isEmpty) {
setStateInternal(() {
externalAcpMessageByProviderInternal[providerKey] = appText(
'请输入有效的 ACP Server Endpoint。',
'Enter a valid ACP server endpoint.',
);
});
return;
}
setStateInternal(() {
externalAcpTestingProvidersInternal.add(providerKey);
externalAcpMessageByProviderInternal.remove(providerKey);
});
try {
final capabilities = await controller.gatewayAcpClientInternal
.loadCapabilities(forceRefresh: true, endpointOverride: endpoint);
if (!mounted) {
return;
}
setStateInternal(() {
externalAcpMessageByProviderInternal[providerKey] = appText(
capabilities.providers.isEmpty
? '连接成功。'
: '连接成功,可用 Provider: ${capabilities.providers.map((item) => item.label).join(' / ')}',
capabilities.providers.isEmpty
? 'Connection succeeded.'
: 'Connection succeeded. Providers: ${capabilities.providers.map((item) => item.label).join(' / ')}',
);
});
} catch (error) {
if (!mounted) {
return;
}
setStateInternal(() {
externalAcpMessageByProviderInternal[providerKey] = '$error';
});
} finally {
if (mounted) {
setStateInternal(() {
externalAcpTestingProvidersInternal.remove(providerKey);
});
}
}
}
Future<void> showAddExternalAcpProviderWizardInternal(
BuildContext context,
AppController controller,

View File

@ -307,6 +307,67 @@ XWorkmate Privacy Policy
);
}
void syncExternalAcpDraftControllersInternal(SettingsSnapshot settings) {
final activeKeys = settings.externalAcpEndpoints
.map((item) => item.providerKey)
.toSet();
for (final profile in settings.externalAcpEndpoints) {
final key = profile.providerKey;
final labelController = externalAcpLabelControllersInternal.putIfAbsent(
key,
() => TextEditingController(),
);
final endpointController = externalAcpEndpointControllersInternal
.putIfAbsent(key, () => TextEditingController());
syncDraftControllerValueInternal(
labelController,
profile.label,
syncedValue: externalAcpLabelSyncedValuesInternal[key] ?? '',
onSyncedValueChanged: (value) =>
externalAcpLabelSyncedValuesInternal[key] = value,
);
syncDraftControllerValueInternal(
endpointController,
profile.endpoint,
syncedValue: externalAcpEndpointSyncedValuesInternal[key] ?? '',
onSyncedValueChanged: (value) =>
externalAcpEndpointSyncedValuesInternal[key] = value,
);
}
disposeRemovedExternalAcpDraftsInternal(
externalAcpLabelControllersInternal,
activeKeys,
);
disposeRemovedExternalAcpDraftsInternal(
externalAcpEndpointControllersInternal,
activeKeys,
);
externalAcpLabelSyncedValuesInternal.removeWhere(
(key, _) => !activeKeys.contains(key),
);
externalAcpEndpointSyncedValuesInternal.removeWhere(
(key, _) => !activeKeys.contains(key),
);
externalAcpMessageByProviderInternal.removeWhere(
(key, _) => !activeKeys.contains(key),
);
externalAcpTestingProvidersInternal.removeWhere(
(key) => !activeKeys.contains(key),
);
}
void disposeRemovedExternalAcpDraftsInternal(
Map<String, TextEditingController> controllers,
Set<String> activeKeys,
) {
final removedKeys = controllers.keys
.where((key) => !activeKeys.contains(key))
.toList(growable: false);
for (final key in removedKeys) {
controllers.remove(key)?.dispose();
}
}
GatewayConnectionProfile selectedGatewayProfileInternal(
SettingsSnapshot settings,
) {

View File

@ -159,11 +159,7 @@ void main() {
) async {
final controller = await createTestController(tester);
await _pumpSettingsPage(
tester,
controller,
tab: SettingsTab.appearance,
);
await _pumpSettingsPage(tester, controller, tab: SettingsTab.appearance);
await tester.tap(find.text('深色'));
await tester.pumpAndSettle();
@ -221,78 +217,68 @@ void main() {
(WidgetTester tester) async {
final controller = await createTestController(tester);
await _pumpSettingsPage(
tester,
controller,
tab: SettingsTab.workspace,
);
await _pumpSettingsPage(tester, controller, tab: SettingsTab.workspace);
expect(find.text('远程项目根目录'), findsNothing);
expect(find.text('Remote Project Root'), findsNothing);
},
);
testWidgets('SettingsPage renders only the active section without internal tabs', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
testWidgets(
'SettingsPage renders only the active section without internal tabs',
(WidgetTester tester) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
child: SettingsPage(
controller: controller,
showSectionTabs: false,
),
platform: TargetPlatform.macOS,
);
await pumpPage(
tester,
child: SettingsPage(controller: controller, showSectionTabs: false),
platform: TargetPlatform.macOS,
);
expect(find.byType(SectionTabs), findsNothing);
expect(find.text('Application'), findsOneWidget);
expect(find.text('OpenClaw Gateway'), findsNothing);
expect(find.text('LLM 接入点'), findsNothing);
expect(find.text('工作区路径'), findsNothing);
expect(
find.byKey(const ValueKey('external-acp-provider-add-button')),
findsNothing,
);
});
expect(find.byType(SectionTabs), findsNothing);
expect(find.text('Application'), findsOneWidget);
expect(find.text('OpenClaw Gateway'), findsNothing);
expect(find.text('LLM 接入点'), findsNothing);
expect(find.text('工作区路径'), findsNothing);
expect(
find.byKey(const ValueKey('external-acp-provider-add-button')),
findsNothing,
);
},
);
testWidgets('SettingsPage workspace edits enable the top save-and-apply flow', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
testWidgets(
'SettingsPage workspace edits enable the top save-and-apply flow',
(WidgetTester tester) async {
final controller = await createTestController(tester);
await _pumpSettingsPage(
tester,
controller,
tab: SettingsTab.workspace,
);
await _pumpSettingsPage(tester, controller, tab: SettingsTab.workspace);
await tester.enterText(
find.byType(TextFormField).first,
'/tmp/xworkmate-workspace',
);
await tester.pump();
await tester.enterText(
find.byType(TextFormField).first,
'/tmp/xworkmate-workspace',
);
await tester.pump();
expect(controller.settingsDraft.workspacePath, '/tmp/xworkmate-workspace');
expect(controller.hasSettingsDraftChanges, isTrue);
expect(
controller.settingsDraft.workspacePath,
'/tmp/xworkmate-workspace',
);
expect(controller.hasSettingsDraftChanges, isTrue);
final applyButton = tester.widget<FilledButton>(
find.byKey(const ValueKey('settings-global-apply-button')),
);
expect(applyButton.onPressed, isNotNull);
});
final applyButton = tester.widget<FilledButton>(
find.byKey(const ValueKey('settings-global-apply-button')),
);
expect(applyButton.onPressed, isNotNull);
},
);
testWidgets('SettingsPage integration tab exposes unified gateway controls', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
await _pumpSettingsPage(
tester,
controller,
tab: SettingsTab.gateway,
);
await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway);
expect(find.text('OpenClaw Gateway'), findsWidgets);
expect(find.text('LLM 接入点'), findsOneWidget);
@ -348,7 +334,10 @@ void main() {
find.byKey(const ValueKey('external-acp-provider-add-button')),
findsOneWidget,
);
expect(find.byKey(const ValueKey('settings-global-apply-button')), findsNothing);
expect(
find.byKey(const ValueKey('settings-global-apply-button')),
findsNothing,
);
});
testWidgets('SettingsPage vault card exposes concrete K/V fields', (
@ -356,11 +345,7 @@ void main() {
) async {
final controller = await createTestController(tester);
await _pumpSettingsPage(
tester,
controller,
tab: SettingsTab.gateway,
);
await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway);
expect(find.text('Vault Server'), findsAtLeastNWidgets(1));
expect(find.text('VAULT_SERVER_URL'), findsOneWidget);
@ -377,11 +362,7 @@ void main() {
) async {
final controller = await createTestController(tester);
await _pumpSettingsPage(
tester,
controller,
tab: SettingsTab.gateway,
);
await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway);
expect(find.text('外部 ACP Server Endpoint'), findsOneWidget);
expect(find.textContaining('Codex'), findsWidgets);
@ -411,11 +392,7 @@ void main() {
) async {
final controller = await createTestController(tester);
await _pumpSettingsPage(
tester,
controller,
tab: SettingsTab.gateway,
);
await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway);
await _ensureVisible(
tester,
@ -451,11 +428,7 @@ void main() {
_FakeSkillDirectoryAccessService(userHomeDirectory: '/Users/tester'),
);
await _pumpSettingsPage(
tester,
controller,
tab: SettingsTab.gateway,
);
await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway);
expect(find.text('~/.agents/skills'), findsOneWidget);
expect(find.text('/Users/tester/.agents/skills'), findsOneWidget);
@ -485,11 +458,7 @@ void main() {
),
);
await _pumpSettingsPage(
tester,
controller,
tab: SettingsTab.gateway,
);
await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway);
await _ensureVisible(
tester,
find.byKey(const ValueKey('skill-directory-batch-add-button')),
@ -546,11 +515,7 @@ paths:
),
);
await _pumpSettingsPage(
tester,
controller,
tab: SettingsTab.gateway,
);
await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway);
await _ensureVisible(
tester,
find.byKey(const ValueKey('skill-directory-batch-add-button')),
@ -590,11 +555,7 @@ paths:
_FakeSkillDirectoryAccessService(userHomeDirectory: '/Users/tester'),
);
await _pumpSettingsPage(
tester,
controller,
tab: SettingsTab.gateway,
);
await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway);
await _ensureVisible(
tester,
find.byKey(const ValueKey('skill-directory-batch-add-button')),
@ -635,11 +596,7 @@ paths:
) async {
final controller = await createTestController(tester);
await _pumpSettingsPage(
tester,
controller,
tab: SettingsTab.gateway,
);
await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway);
await tester.tap(find.byTooltip('折叠').first);
await tester.pumpAndSettle();
@ -667,11 +624,7 @@ paths:
) async {
final controller = await createTestController(tester);
await _pumpSettingsPage(
tester,
controller,
tab: SettingsTab.gateway,
);
await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway);
await _ensureVisible(tester, find.text('外部 ACP Server Endpoint'));
await tester.tap(find.text('外部 ACP Server Endpoint').first);
@ -693,6 +646,50 @@ paths:
expect(find.textContaining('OpenCode'), findsWidgets);
});
testWidgets('SettingsPage external ACP card supports continuous input', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
final customProfile = buildCustomExternalAcpEndpointProfile(
controller.settingsDraft.externalAcpEndpoints,
label: 'Initial Name',
endpoint: 'wss://initial.example.com/acp',
);
await controller.saveSettingsDraft(
controller.settingsDraft.copyWith(
externalAcpEndpoints: <ExternalAcpEndpointProfile>[
...controller.settingsDraft.externalAcpEndpoints,
customProfile,
],
),
);
await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway);
final labelField = find.byKey(
ValueKey('external-acp-label-${customProfile.providerKey}'),
);
final testButton = find.byKey(
ValueKey('external-acp-test-${customProfile.providerKey}'),
);
final applyButton = find.byKey(
ValueKey('external-acp-apply-${customProfile.providerKey}'),
);
expect(labelField, findsOneWidget);
expect(testButton, findsOneWidget);
expect(applyButton, findsOneWidget);
await tester.enterText(labelField, 'A');
await tester.pump();
await tester.enterText(labelField, 'AB');
await tester.pump();
await tester.enterText(labelField, 'ABC');
await tester.pump();
expect(find.text('ABC'), findsOneWidget);
});
testWidgets('SettingsPage shows Linux desktop integration controls', (
WidgetTester tester,
) async {
@ -758,11 +755,7 @@ paths:
) async {
final controller = await createTestController(tester);
await _pumpSettingsPage(
tester,
controller,
tab: SettingsTab.gateway,
);
await _pumpSettingsPage(tester, controller, tab: SettingsTab.gateway);
await tester.tap(find.byKey(const ValueKey('gateway-profile-chip-1')));
await tester.pumpAndSettle();
@ -847,11 +840,7 @@ paths:
(WidgetTester tester) async {
final controller = await createTestController(tester);
await _pumpSettingsPage(
tester,
controller,
tab: SettingsTab.diagnostics,
);
await _pumpSettingsPage(tester, controller, tab: SettingsTab.diagnostics);
expect(
find.byKey(const ValueKey('assistant-local-state-card')),